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});//创建聊天室