1 项目名称
Web聊天室(《这是NodeJs实战》第二章的一个案例,把整个开发过程记录下来)
2 项目描述
该项目是一个简单的在线聊天程序。打开聊天页面,程序自动给用户分配一个昵称,进入默认的Lobby聊天室。用户可以发送消息,也可以使用聊天命令(聊天命令以/开头)修改自己的昵称或者加入已有的聊天室(聊天室不存在时,创建新的聊天室)。在加入或创建聊天室时,新聊天室的名称会出现在聊天程序顶端的水平条上,也会出现在聊天消息区域右侧的可用房间列表中。在用户换到新房间后,系统会显示信息以确认这一变化。
3 系统设计
该项目使用Node实现,因为Node用一个端口就可以轻松地提供HTTP和WebSocket两种服务。使用HTTP处理静态文件的同时使用WebSocket实现实时数据(聊天消息)。程序的实现可以划分以下几个功能模块:
- 提供静态文件(比如HTML、CSS和客户端JavaScript)
- 在服务器上处理与聊天相关的消息
- 在用户的浏览器中处理与聊天相关的消息
为了提供静态文件,需要使用Node内置的http模块。但通过HTTP提供文件时,通常不能只是发送文件中的内容,还应该有所发送文件的类型。也就是说要用正确的MIME类型设置HTTP 头的Content-Type。为了查找这些MIME类型,会用到第三方的模块mime。
为了处理与聊天相关的消息,需要用Ajax轮询服务器。为了让这个程序能尽可能快的作出响应,我们不会用传统的Ajax发送消息。采用WebSocket,这是一个为支持实时通讯而设计的轻量的双向通信协议。因为在大多数情况下,只有兼容HTML5的浏览器才支持WebSocket,所以这个程序会使用流行的Socket.IO库,他给不能使用WebSocket的浏览器提供了一些后备措施。
4 系统实现
使用WebStorm开发该项目。WebStorm被称为“最强大的HTML5编辑器”、“最智能的JavaScript IDE”。
4.1 创建程序的文件结构
使用WebStorm,选择一个目录,创建一个新的空项目。设计项目结构如下所示:
4.2 指明依赖项
程序的依赖项是在package.json文件中指明的。这个文件总是被放在程序的根目录下。 package.json文件用于描述你的应用程序,它包含一些JSON表达式。在package.json文件中可以定义很多事情,但最重要的是程序的名称、版本号、对程序的描述,以及程序的依赖项。 代码清单1中是一个包描述文件,描述了项目的功能和依赖项。将这个文件保存到项目的根目录中,命名为package.json。
{ "name": "chatrooms", "version": "0.0.1", "description":"Minimalist multiroom chat server", "dependencies":{ "socket.io":"~0.9.6", "mime":"~1.2.11" } } |
4.3 安装依赖项
切换到DOS窗口,在项目的根目录下输入以下这条命令
npm install
如果按照失败,切换到国内的npm镜像,然后再安装。镜像使用方法(三种办法任意一种都能解决问题,建议使用第三种,将配置写死,下次用的时候配置还在):
1.通过config命令
npm config set registry
https://registry.npm.taobao.org
npm info underscore (如果上面配置正确,这个命令会有字符串response)
2.命令行指定
npm --registry
https://registry.npm.taobao.org info underscore
3.编辑 ~/.npmrc 加入下面内容
registry = https://registry.npm.taobao.org
安装成功后,在根目录下创建的node_modules目录,这个目录中放的就是程序的依赖项。
4.4提供HTML、CSS和客户端 JavaScript的服务
程序的逻辑是由一些文件实现的,有些运行在服务器上,有些运行在客户端。
在客户端运行的JavaScript需要作为静态资源发给浏览器,而不是在Node上执行。
服务器端的文件:
server.js
lib/chat_server.js
发送给客户端的文件:
public/index.html
public/stylesheets/style.css
public/javascripts/chat.js
public/javascripts/chat_ui.js
4.4.1 在server.js中提供静态文件服务器
/**
* Created by Administrator on 2016-05-05.
*/
var http = require('http');
var fs = require('fs');
var path = require('path');
var mime = require('mime');
var cache={};//缓存文件内容的对象
/* 请求的文件不存在时,发送404错误*, /
function send404(response){
response.writeHead(404,{'Content-Type':'text/plain'});
response.write('Error 404:resource not found.');
response.end();
}
/* 发送数据文件*/
function sendFile(response,filePath,fileContents){
response.writeHead(200,{"Content-type":mime.lookup(path.basename(filePath))});
response.end(fileContents);
}
/*提供静态文件服务*/
function serverStatic(response,cache,absPath){
if(cache[absPath]){
sendFile(response, absPath,cache[absPath]);//从内存中返回数据
}else{
fs.exists(absPath,function(exists){//检查文件是否存在
if(exists){
fs.readFile(absPath,function(err,data){
if(err){
send404(response);
}else{
cache[absPath] = data;
sendFile(response,absPath,data);
}
});
}else{
send404(response);
}
});
}
}
/* 1 创建HTTP服务器*,从该句代码开始阅读*/
var server= http.createServer(function(request,response){
var filePath = false;
if(request.url == '/'){
filePath = 'public/index.html';
}else{
filePath = 'public' + request.url;
}
var absPath='./' + filePath;
serverStatic(response,cache,absPath);
});
server.listen(3000,function(){
console.log("server listening on port 3000.");
}); /* 2 加载chat_server,创建聊天服务器,chat_server 模块随后实现*/
var chatServer = require('./lib/chat_server');
chatServer.listen(server); |
4.4.2添加HTML和CSS文件
Index.html文件内容:
<!doctype html>
<html lang='en'>
<head>
<title>Chat</title>
<link rel='stylesheet' href='/stylesheets/style.css'></link>
</head>
<body>
<div id='content'>
<div id='room'></div>
<div id='room-list'></div>
<div id='messages'></div>
<form id='send-form'>
<input id='send-message' />
<input id='send-button' type='submit' value='Send'/>
<div id='help'>
Chat commands:
<ul>
<li>Change nickname: <code>/nick [username]</code></li>
<li>Join/create room: <code>/join [room name]</code></li>
</ul>
</div>
</form>
</div>
<script src='/socket.io/socket.io.js' type='text/javascript'></script>
<script src='http://code.jquery.com/jquery-1.8.0.min.js' type='text/javascript'></script>
<script src='/javascripts/chat.js' type='text/javascript'></script>
<script src='/javascripts/chat_ui.js' type='text/javascript'></script>
</body>
</html> |
style.css文件内容
body {
padding: 50px;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}
a {
color: #00B7FF;
}
#content {
width: 800px;
margin-left: auto;
margin-right: auto;
}
#room {
background-color: #ddd;
margin-bottom: 1em;
}
#messages {
width: 690px;
height: 300px;
overflow: auto;
background-color: #eee;
margin-bottom: 1em;
margin-right: 10px;
}
#room-list {
float: right;
width: 100px;
height: 300px;
overflow: auto;
}
#room-list div {
border-bottom: 1px solid #eee;
}
#room-list div:hover {
background-color: #ddd;
}
#send-message {
width: 700px;
margin-bottom: 1em;
margin-right: 1em;
}
#help {
font: 10px "Lucida Grande", Helvetica, Arial, sans-serif;
} |
4.5用Socket.IO处理与聊天相关的功能
4.5.1 chat_server.js实现服务器端功能
var socketio = require('socket.io');
var io;
var guestNumber = 1;
var nickNames = {};
var namesUsed = [];
var currentRoom = {};
exports.listen = function(server){ //启动socket.io服务器,允许它搭载在已有的http服务器上
io = socketio.listen(server);
io.set('log level',1); //定义每个用户连接的处理逻辑
io.sockets.on('connection',function(socket){
// 1 客户端连接后,分配用户昵称
guestNumber=assignGuestName(socket,guestNumber,nickNames,namesUsed);
// 2 加入Lobby聊天室
joinRoom(socket,'Lobby');
// 3 处理广播消息
handleMessageBroadcasting(socket,nickNames);
// 4处理修改昵称命令
handleNameChangeAttempts(socket,nickNames,namesUsed);
// 5 处理切换/创建聊天室命令
handleRoomJoining(socket);
// 6 当收到客户端请求后,发送给客户端房间列表
socket.on('rooms',function(){
socket.emit('rooms',io.sockets.manager.rooms);
});
// 7 处理客户端断开连接
handleClientDisconnection(socket,nickNames,namesUsed);
});
};
/* 分配用户昵称*/
function assignGuestName(socket,guestNumber,nickNames,namesUsed){
var name='Guest'+guestNumber; //将昵称保存在昵称集合nickNames中
nickNames[socket.id] = name; //发送给客户端知悉其昵称
socket.emit('nameResult',{
success:true,
name:name
}); //将昵称保存在另一个已使用的昵称数组中
namesUsed.push(name);
return guestNumber + 1;
}
/*加入房间*/
function joinRoom(socket, room) { //用户进入房间
socket.join(room); //记录用户的当前房间
currentRoom[socket.id] = room; //让用户知悉他进入了新的房间
socket.emit('joinResult', {room: room});
//让该房间里的其他用户知悉有新用户进入了房间 socket.broadcast.to(room).emit('message', {
text: nickNames[socket.id] + ' has joined ' + room + '.'
});
//获取该房间里所有的用户
var usersInRoom = io.sockets.clients(room); //如果用户数量大于1
if (usersInRoom.length > 1) {
var usersInRoomSummary = 'Users currently in ' + room + ': ';
for (var index in usersInRoom) {
var userSocketId = usersInRoom[index].id; //判断非当前用户
if (userSocketId != socket.id) {
if (index > 0) {
usersInRoomSummary += ', ';
}
usersInRoomSummary += nickNames[userSocketId];
}
}
usersInRoomSummary += '.';
//汇总该房间里的其它成员名称发送给给该用户
socket.emit('message', {text: usersInRoomSummary});
}
}
/* 处理更名请求*/
function handleNameChangeAttempts(socket, nickNames, namesUsed) {
socket.on('nameAttempt', function(name) {
if (name.indexOf('Guest') == 0) {//不能以Guest开头
socket.emit('nameResult', {
success: false,
message: 'Names cannot begin with "Guest".'
});
} else {//注册用户名称
if (namesUsed.indexOf(name) == -1) {
var previousName = nickNames[socket.id];
var previousNameIndex = namesUsed.indexOf(previousName);
namesUsed.push(name);
nickNames[socket.id] = name;
delete namesUsed[previousNameIndex]; //当前用户会收到更名信息
socket.emit('nameResult', {
success: true,
name: name
}); //房间中其他用户知悉当前用户已更名
socket.broadcast.to(currentRoom[socket.id]).emit('message', {
text: previousName + ' is now known as ' + name + '.'
});
} else {//该昵称已经存在
socket.emit('nameResult', {
success: false,
message: 'That name is already in use.'
});
}
}
});
}
/*发送聊天消息,即Node服务器收到客户端消息,转发给该房间的其它用户*/
function handleMessageBroadcasting(socket) {
socket.on('message', function (message) {
socket.broadcast.to(message.room).emit('message', {
text: nickNames[socket.id] + ': ' + message.text
});
});
}
/* 切换聊天室,即离开当前房间,加入其他房间,房间不存在则创建新的房间*/
function handleRoomJoining(socket) {
socket.on('join', function(room) {
socket.leave(currentRoom[socket.id]);
joinRoom(socket, room.newRoom);
});
}
/*处理客户端断开连接*/
function handleClientDisconnection(socket) {
socket.on('disconnect', function() {
var nameIndex = namesUsed.indexOf(nickNames[socket.id]);
delete namesUsed[nameIndex];
delete nickNames[socket.id];
});
} |
4.5.2 chat.js以及chat_ui.js实现客户端功能
客户端JavaScript需要实现以下功能:向服务器发送用户的消息和昵称/房间变更请求; 显示其他用户的消息,以及可用房间的列表。Chat.js中定义一个原型对象,用于处理聊天消息和命令,该原型对象中的函数在chat_ui.js中调用。
chat.js文件内容:
/**
* Created by Administrator on 2016-05-05.
*/
/*JavaScript原型对象,处理发送聊天消息、变更房间、处理聊天命令*/
var Chat = function(socket) {
this.socket = socket;
};
Chat.prototype.sendMessage = function(room, text) {
var message = {
room: room,
text: text
};
this.socket.emit('message', message);
};
Chat.prototype.changeRoom = function(room) {
this.socket.emit('join', {
newRoom: room
});
};
Chat.prototype.processCommand = function(command) {
var words = command.split(' ');
var command = words[0]
.substring(1, words[0].length)
.toLowerCase();
var message = false;
switch(command) {
case 'join':
words.shift();
var room = words.join(' ');
this.changeRoom(room);//变更房间
break;
case 'nick':
words.shift();
var name = words.join(' ');
this.socket.emit('nameAttempt', name);//修改昵称
break;
default:
message = 'Unrecognized command.';
break;
};
return message;
}; |
chat_ui.js文件内容:
/**
* Created by Administrator on 2016-05-05.
*/ /*用来显示可疑的文本。它会净化文本,将特殊字符转换 成HTML实体*/
function divEscapedContentElement(message) {
return $('<div></div>').text(message);
}
/*显示系统创建的受信内容*/
function divSystemContentElement(message) {
return $('<div></div>').html('<i>' + message + '</i>');
}
/*显示用户输入的信息*/
function processUserInput(chatApp, socket) {
var message = $('#send-message').val();
var systemMessage;
if (message.charAt(0) == '/') {//显示系统受信内容
systemMessage = chatApp.processCommand(message);
if (systemMessage) {
$('#messages').append(divSystemContentElement(systemMessage));
}
} else {//显示用户输入内容
chatApp.sendMessage($('#room').text(), message);
$('#messages').append(divEscapedContentElement(message));
$('#messages').scrollTop($('#messages').prop('scrollHeight'));
}
//清空输入框
$('#send-message').val('');
}
/*客户端程序初始化逻辑*/
var socket = io.connect();
$(document).ready(function() {
var chatApp = new Chat(socket);
//显示用户更名结果
socket.on('nameResult', function(result) {
var message;
if (result.success) {
message = 'You are now known as ' + result.name + '.';
} else {
message = result.message;
}
$('#messages').append(divSystemContentElement(message));
});
//显示用户切换聊天室结果
socket.on('joinResult', function(result) {
$('#room').text(result.room);
$('#messages').append(divSystemContentElement('Room changed.'));
});
//显示聊天消息
socket.on('message', function (message) {
var newElement = $('<div></div>').text(message.text);
$('#messages').append(newElement);
});
//显示房间列表
socket.on('rooms', function(rooms) {
$('#room-list').empty();
for(var room in rooms) {
room = room.substring(1, room.length);
if (room != '') {
$('#room-list').append(divEscapedContentElement(room));
}
}
$('#room-list div').click(function() {
chatApp.processCommand('/join ' + $(this).text());
$('#send-message').focus();
});
});
//每间隔1秒,向服务器重新请求房间列表
setInterval(function() {
socket.emit('rooms');
}, 1000);
$('#send-message').focus();
//提交表单,发送聊天消息
$('#send-form').submit(function() {
processUserInput(chatApp, socket);
return false;
});
}); |
4.6 服务器与客户端的事件分析
服务器与客户端的交互主要是通过相互发送事件-处理事件完成的,以下是在整个流程中发生的事件:
4.6.1服务器事件流程
'connection'事件//接收客户端连接
{
// 1 assignGuestName()函数中的事件
socket.emit('nameResult',{ //分配默认昵称
success:true,
name:name
});
// 2 joinRoom()函数中的事件
socket.emit('joinResult', {room: room});//加入房间
socket.broadcast.to(room).emit('message', {//广播消息
text: nickNames[socket.id] + ' has joined ' + room + '.'
});
socket.emit('message', {text: usersInRoomSummary});//发送给每个用户包含该房间的其他用户的列表
// 3 handleMessageBroadcasting()函数中的事件
socket.on('message', function (message) {//收到客户端消息后,发射给同房间的其他用户
socket.broadcast.to(message.room).emit('message', {
text: nickNames[socket.id] + ': ' + message.text
});
});
// 4 handleNameChangeAttempts()函数中的事件
socket.emit('nameResult', {//更名
success: true,
name: name
});
socket.broadcast.to(currentRoom[socket.id]).emit('message', {//房间中其他用户知悉当前用户已更名
text: previousName + ' is now known as ' + name + '.'
});
// 5 handleRoomJoining()函数中的事件
socket.on('join', function(room) {//切换聊天室
socket.leave(currentRoom[socket.id]);
joinRoom(socket, room.newRoom);
});
// 6 当收到客户端请求后,发送给客户端房间列表
socket.on('rooms',function(){
socket.emit('rooms',io.sockets.manager.rooms);
});
// 7 handleClientDisconnection()函数中的事件
socket.on('disconnect', function() {
var nameIndex = namesUsed.indexOf(nickNames[socket.id]);
delete namesUsed[nameIndex];
delete nickNames[socket.id];
});
}
4.6.2客户端事件流程
// 1 连接服务器
var socket = io.connect();
// 2 连接服务器后,处理服务器发送过来的事件
socket.on('nameResult', function(result) {...});//显示昵称
socket.on('joinResult', function(result) {...});//显示加入房间
socket.on('message', function (message) {...});//显示该房间的其他用户
socket.on('rooms', function(rooms) {...});//显示房间列表
setInterval(function() {
socket.emit('rooms');//定期向服务器请求房间列表
}, 1000);
// 3 点击房间列表
$('#room-list div').click(function() {
this.socket.emit('join', { newRoom: room});//切换聊天室
});
// 4 点击提交按钮调用processUserInput()函数中触发的客户端事件
this.socket.emit('message', message);//发送消息
this.socket.emit('nameAttempt', name);//更名
this.socket.emit('join', { newRoom: room});//创建聊天室