千里之行,始于足下。第二章是實戰(zhàn)內(nèi)容:構(gòu)建多個房間的聊天室程序。
本章會構(gòu)建一個在線聊天程序,用戶可以在一個簡單的表單中輸入消息,相互聊天,消息輸入后會發(fā)送給同一聊天室內(nèi)的其他所有用戶。
程序需求及初始設(shè)置
需求:進入聊天室后,程序會自動給用戶分配一個昵稱,但他們可以用聊天命令修改自己的昵稱。聊天命令以斜杠(/)開頭。同樣,用戶可以輸入命令創(chuàng)建新的聊天室(或加入已有的聊天室)。在加入或創(chuàng)建聊天室時,新聊天室的名稱會出現(xiàn)在聊天程序頂端的水平條上,也會出現(xiàn)在聊天消息區(qū)域右側(cè)的可用房間列表中。
將要創(chuàng)建的聊天程序需要完成如下任務(wù):
- 提供靜態(tài)文件
- 在服務(wù)器上處理與聊天相關(guān)的信息
- 在用戶的瀏覽器中處理與聊天相關(guān)的消息。
為了提供靜態(tài)文件,需要使用Node內(nèi)置的http模塊。但通過HTTP提供文件時,通常不能只是發(fā)送文件中的內(nèi)容,還應(yīng)該有所發(fā)送文件的類型。也就是說要正確的MIME類型設(shè)置HTTP頭的Content-Type。為了查找這些MIME類型,你會用到第三方的模塊mime。
為了處理與聊天相關(guān)的消息,需要用Ajax輪詢服務(wù)器。但為了讓這個程序盡可能快地做出響應(yīng),我們不會用傳統(tǒng)的Ajax發(fā)送消息。Ajax用HTTP做傳輸機制,并且HTTP本來就不是做實時通信的。在用HTTP發(fā)送消息時,必須用一個新的TCP/IP連接。打開和關(guān)閉連接需要時間。此外,因為每次請求都要發(fā)送HTTP頭,所以傳輸?shù)臄?shù)據(jù)量也較大。這個程序沒用依賴于HTTP的方案,而采用了WebSocket,這是一個為支持實時通訊而設(shè)計的輕量的雙向通信協(xié)議。
創(chuàng)建程序的文件結(jié)構(gòu)
-
創(chuàng)建如下的文件結(jié)構(gòu):
文件結(jié)構(gòu) 命令:
npm init --y
npm i socket.io mime --save-dev
-
邏輯
服務(wù)端與客戶端
創(chuàng)建靜態(tài)文件服務(wù)器
- common.js引入
var http = require('http');
var fs = require('fs');
var path = require('path');
var mime = require('mime');
// cache 是用來緩存文件內(nèi)容的對象
var cache = {};
- 發(fā)送文件數(shù)據(jù)及錯誤響應(yīng)三個輔助函數(shù)
// 404處理
function send404(res) {
res.writeHead(404, {
'Content-Type': 'text/plain'
})
res.write('Error 404: resource not found.')
res.end()
}
// 發(fā)送文件
function sendFile(res, filePath, fileContents){
res.writeHead(200, {
// mime2.0以上lookup更名為getType
'Content-Type': mime.getType(path.basename(filePath))
});
res.end(fileContents);
}
訪問內(nèi)存(RAM)要比訪問文件系統(tǒng)快得多,所以Node程序通常會把常用的數(shù)據(jù)緩存到內(nèi)存里。我們的聊天程序就要把靜態(tài)文件緩存到內(nèi)存中,只有第一次訪問的時候才會從文件系統(tǒng)中讀取。下一個輔助函數(shù)就會確定文件是否緩存了,如果是,就返回它。如果文件還沒緩存,它會從硬盤中讀取并返回它。如果文件不存在,則返回一個HTTP 404錯誤作為響應(yīng)。如下:
// 提供靜態(tài)文件服務(wù)
function serveStatic(res, cache, absPath) {
// 檢查文件是否緩存在內(nèi)存中
if(cache[absPath]){
// 從內(nèi)存中返回文件
sendFile(res, absPath, cache[absPath]);
} else {
// 檢查文件是否存在
fs.exists(absPath, function(exists){
if(exists) {
// 從硬盤中讀取文件
fs.readFile(absPath, function(err, data){
if(err){
send404(res);
}else{
// 從硬盤中讀取文件并返回
cache[absPath] = data;
sendFile(res, absPath, data);
}
})
} else{
// 發(fā)送HTTP 404 響應(yīng)
send404(res);
}
});
}
}
- 創(chuàng)建HTTP服務(wù)器
// 創(chuàng)建HTTP服務(wù)器
var server = http.createServer(function(req, res){
var filePath = false;
if(req.url == '/'){
// 確定返回的默認HTML文件
filePath = 'public/index.html'
}else{
// 將URL路徑轉(zhuǎn)為文件的相對路徑
filePath = 'public' + req.url;
}
var absPath = './' + filePath;
// 返回靜態(tài)文件
serveStatic(res, cache, absPath);
});
- 啟動HTTP服務(wù)器
現(xiàn)在已經(jīng)寫好了創(chuàng)建代碼,但還沒添加啟動它的邏輯。添加下面代碼,它會啟動服務(wù)器,要求服務(wù)器監(jiān)聽TCP/IP端口3000。
server.listen(3000, function() {
console.log("Server listening on port 3000");
})
在命令行中輸入下面這條命令啟動:
node server.js
服務(wù)器運行起來后,在瀏覽器中訪問http://127.0.0.1:3000會激發(fā)404錯誤輔助函數(shù),頁面上會顯示“Error 404: resource not found?!?br>
盡管你已經(jīng)添加了靜態(tài)文件處理邏輯,但還沒添加那些靜態(tài)文件。記住,在命令行中按下Ctrl-C可以停止正在運行的服務(wù)器。
接下來,讓我們把必須的靜態(tài)文件加上,把這個聊天程序的功能再向前推進一步。
添加HTML和CSS文件
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<title>chat</title>
<link rel="stylesheet" type="text/css" href="stylesheets/style.css">
</head>
<body>
<div id="content">
<div id="room">
<div id="room-list"></div>
<div id="message"></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>
</div>
<script src="./javascripts/jquery-1.11.0.min.js"></script>
<script src="./javascripts/socket.io.js"></script>
<script type="text/javascript" src="javascripts/chat.js"></script>
<script type="text/javascript" src="javascripts/chat_ui.js"></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: #ddd;
margin-bottom: 1em;
}
#message{
width: 690px;
height: 300px;
overflow: auto;
background: #eee;
margin-bottom: 1em;
margin-right: 10px;
}
這個程序還不能用,但靜態(tài)文件已經(jīng)可以看了,基本的視覺布局也搭建好了。把這些料理好了之后,我們接下來去定義服務(wù)端聊天消息的分發(fā)。
用Socket.IO 處理與聊天相關(guān)的消息
我們前面說過程序必須要做三件事,其中第一個提供靜態(tài)文件已經(jīng)做了,現(xiàn)在來解決第二個,處理瀏覽器和服務(wù)器之間的通信。
Socket.IO為Node及客戶端JavaScript提供了基于WebSocket以及其他傳輸方式的封裝,它提供了一個抽象層。如果瀏覽器沒有實現(xiàn)WebSocket,Socket.IO會自動啟用一個備選方案,而對外提供的API還是一樣的。
Socket.IO提供了開箱即用的虛擬通道,所以程序不用把每條消息都向已連接的用戶廣播,而是只向那些預(yù)訂了某個通道的用戶廣播。
Socket.IO還是事件發(fā)射器(Event Emitter)的好例子。事件發(fā)射器本質(zhì)上是組織異步邏輯的一種很方便的設(shè)計模式。
- 設(shè)置Socket.IO服務(wù)器
首先,把下面這兩行代碼添加到server.js中。第一行加載一個定制的Node模塊,它提供的邏輯是用來處理基于Socket.IO的服務(wù)端聊天功能的。第二行啟動Socket.IO服務(wù)器,給它提供一個已經(jīng)定義好的HTTP服務(wù)器,這樣它就能跟HTTP服務(wù)器共享同一個TCP/IP端口:
// 設(shè)置socket.io服務(wù)器
var chatServer = require('./lib/chat_server.js');
chatServer.listen(server);
現(xiàn)在你要在lib目錄中創(chuàng)建一個新文件,chat_server.js。先把下面的變量聲明添加到這個文件中。這些聲明讓我們可以使用Socket.IO,并初始化了一些定義聊天狀態(tài)的變量:
var socketio = require('socket.io');
var io;
var guestNumber = 1;
var nickNames = {};
var namesUsed = [];
var currentRoom = ();
- 確立連接邏輯
定義聊天服務(wù)器函數(shù)listen。server.js中會調(diào)用這個函數(shù)。
它啟動Socket.IO服務(wù)器,限定Socket.IO向控制臺輸出的日志的詳細程度,并確定該如何處理每個接進來的連接。
// 啟動socket.io服務(wù)器
exports.listen = function(server) {
io = socketio.listen(server);
io.set('log level', 1);
// 定義每個用戶連接的處理邏輯
io.sockets.on('connection', function(socket){
// 在用戶連接上來時賦予其一個訪問名
guestNumber = assignGuestName(socket, guestNumber, nickNames, namesUsed);
// 在用戶連接上來時把他放入聊天室Lobby
joinRoom(socket, 'Lobby');
// 處理用戶的消息,更名,以及聊天室的創(chuàng)建和變更
handleMessageBroadcasting(socket, nickNames);
handleNameChangeAttempts(socket, nickNames, namesUsed);
handleRoomJoining(socket);
// 用戶發(fā)出請求時,向其提供已經(jīng)被占用的聊天室的列表
socket.on('rooms', function() {
socket.emit('rooms', io.sockets.manager.rooms);
});
// 定義用戶斷開連接后的清除邏輯
handleClientDisconnection(socket, nickNames, namesUsed);
})
}
- 處理程序場景及事件
聊天程序需要處理下面這些場景和事件:
? 分配昵稱;
? 房間更換請求;
? 昵稱更換請求;
? 發(fā)送聊天消息;
? 房間創(chuàng)建;
? 用戶斷開連接。
要實現(xiàn)這些功能得添加幾個輔助函數(shù),如下文所述。
? 分配昵稱;
要添加的第一個輔助函數(shù)是assignGuestName,用來處理新用戶的昵稱。當(dāng)用戶第一次連到聊天服務(wù)器上時,用戶會被放到一個叫做Lobby的聊天室中,并調(diào)用assignGuestName給他們分配一個昵稱,以便可以相互區(qū)分開。
程序分配的所有昵稱基本上都是在Guest后面加上一個數(shù)字,有新用戶連進來時這個數(shù)字就會往上增長。用戶昵稱存在變量nickNames中以便于引用,并且會跟一個內(nèi)部socket ID關(guān)聯(lián)。昵稱還會被添加到namesUsed中,這個變量中保存的是已經(jīng)被占用的昵稱。把下面清單中的代碼添加到lib/chat_server.js中實現(xiàn)這個功能。
// 分配昵稱
function assignGuestName(socket, guestNumber, nickNames, namesUsed){
// 生成新昵稱
var name = 'Guest' + guestNumber;
// 把用戶昵稱跟客戶端連接ID關(guān)聯(lián)上
nickNames[socket.id] = name;
// 讓用戶知道他們的昵稱
socket.emit('nameResult', {
success: true,
name: name
});
// 存放已經(jīng)被占用的昵稱
namesUsed.push(name);
// 增加用來生成昵稱的計數(shù)器
return guestNumber + 1;
}
- 進入聊天室
要添加到chat_server.js中的第二個輔助函數(shù)是joinRoom。這個函數(shù)如下所示,處理邏輯跟用戶加入聊天室相關(guān)。
// 進入聊天室
function joinRoom(socket, room){
// 讓用戶進入房間
socket.join(room);
// 記錄用戶的當(dāng)前房間
currentRoom[socket.id] = room;
// 讓用戶知道他們進入了新的房間
socket.emit('joinResult', {
room: room
});
// 讓房間里的其他用戶知道有新用戶進入了房間
socket.broadcast.to(room).emit('message', {
text: nickNames[sockets.id] + 'has joined' + room + '.'
});
// 確定有哪些用戶在這個房間里
var usersInRoom = io.socket.clients(room);
// 如果不止一個用戶在這個房間,匯總下都是誰
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 += '.';
// 將房間里的其他用戶的匯總發(fā)送給這個用戶
socket.emit('message', {
text: usersInRoomSummary
})
}
將用戶加入Socket.IO房間很簡單,只要調(diào)用socket對象上的join方法就行。然后程序就會把相關(guān)細節(jié)向這個用戶及同一房間中的其他用戶發(fā)送。程序會讓用戶知道有哪些用戶在這個房間里,還會讓其他用戶知道這個用戶進來了。
- 處理昵稱變更需求
如果用戶都用程序分配的昵稱,很難記住誰是誰。因此聊天程序允許用戶發(fā)起更名請求。更名需要用戶的瀏覽器通過socket.io發(fā)送一個請求,并接收表示成功或失敗的響應(yīng)。
將下面代碼清單中的代碼加到lib/chat_server.js中,這段代碼定義了一個處理用戶更名請求的函數(shù)。從程序的角度來講,用戶不能將昵稱改成以Guest開頭,或改成其他已經(jīng)被占用的昵稱。
// 更名請求的處理邏輯
function handleNameChangeAttempts(socket, nickNames, namesUsed){
// 添加nameAttempt事件的監(jiān)聽器
socket.on('nameAttempt', function(name){
if(name.indexOf('Guest') == 0){
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{
// 昵稱被占用,給客戶端發(fā)送錯誤信息
socket.emit('nameResult', {
success: false,
message: 'That name is already in use'
})
}
}
})
}
- 發(fā)送聊天信息
基本流程:用戶發(fā)射一個事件,表明消息是從哪個房間發(fā)出來的,以及消息的內(nèi)容是什么,然后服務(wù)器將這條消息轉(zhuǎn)發(fā)給同一房間的所有用戶。
將下面代碼加到lib/chat_server.js中,sockrtIo的broadcast函數(shù)是用來轉(zhuǎn)發(fā)消息的:
// 發(fā)送聊天消息
function handleMessageBroadcasting(socket) {
socket.on('message', function(message){
socket.broadcast.to(message.room).emit('message', {
text: nickNames[socket.id] + ':' + message.text
})
})
}
- 創(chuàng)建房間
接下來要添加讓用戶加入已有房間的邏輯,如果房間還沒有的話,則創(chuàng)建一個房間。
將下面的代碼添加到lib/chat_server.js文件中,實現(xiàn)更換房間的功能。注意Socket.IO中l(wèi)eave方法的使用:
// 創(chuàng)建房間
function hanleRoomJoining(socket) {
socket.on('join', function(room){
socket.leave(currentRoom[socket.id]);
joinRoom(socket, room.newRoom);
})
}
- 用戶斷開連接
最后還要把下面這段代碼添加到lib/chat_server.js文件中,當(dāng)用戶離開聊天程序時,從nickNames和namesUsed中移除用戶的昵稱:
function handleClientDisConnection(socket) {
socket.on('disconnect', function() {
var nameIndex = namesUsed.indexOf(nickNames[socket.id]);
delete namesUsed[nameIndex];
delete nickNames[socket.id];
});
}
客戶端js配置
客戶端js需要實現(xiàn)以下功能:
- 向服務(wù)器發(fā)送用戶的消息和昵稱/房間變更請求;
- 顯示其他用戶的消息,以及可用房間的列表。
- 將消息和昵稱、房間變更請求傳給服務(wù)器
要添加的第一段客戶端JavaScript代碼是一個JavaScript原型對象,用來處理聊天命令、發(fā)送消息、請求變更房間或昵稱。
在public/javascripts目錄下創(chuàng)建一個chat.js文件,把下面的代碼放進去。這段代碼相當(dāng)于定義了一個JavaScript“類”,在初始化時可用傳入一個Socket.IO的參數(shù)socket:
var Chat = function(socket) {
this.socket = socket;
}
// 發(fā)送聊天信息的函數(shù)
Chat.prototype.sendMessage = function(room, text){
var message = {
room: room,
text: text
};
this.socket.emit('message', message);
}
// 變更房間的函數(shù)
Chat.prototype.changeRoom = function(room) {
this.socket.emit('join', {
newRoom: room
})
}
// 處理聊天命令
Chat.prototype.processCommand = function(command){
var words = command.split(' ');
var command = words[0].sustring(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;
}
- 在用戶界面中顯示消息及可用房間
這個聊天程序會用兩個輔助函數(shù)顯示文本數(shù)據(jù)。一個函數(shù)用來顯示可疑的文本數(shù)據(jù),另一個函數(shù)顯示受信的文本數(shù)據(jù)。
函數(shù)divEscapedContentElement用來顯示可疑的文本。它會凈化文本,將特殊字符轉(zhuǎn)換成HTML實體。函數(shù)divSystemContentElement用來顯示系統(tǒng)創(chuàng)建的受信內(nèi)容,而不是其他用戶創(chuàng)建的。
在public/javascripts目錄下創(chuàng)建chat_ui.js文件,并把下面兩個輔助函數(shù)放進去:
function divEscapedContentElement(message) {
return $('<div></div>').text(message);
}
function divSystemContentElement(message) {
return $('<div></div>').html('<i>' + message + '</i>');
}
下一個要加到chat_ui.js中的函數(shù)是用來處理用戶輸入的。如果用戶輸入的內(nèi)容以斜杠(/)開頭,它會將其作為聊天命令處理。如果不是,就作為聊天消息發(fā)送給服務(wù)器并廣播給其他用戶,并添加到用戶所在聊天室的聊天文本中。
// 處理原始的用戶輸入
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('');
}
輔助函數(shù)現(xiàn)在已經(jīng)定義好了,你還需要添加下面這個代碼清單中的邏輯,它要在用戶的瀏覽器加載完頁面后執(zhí)行。這段代碼會對客戶端的Socket.IO事件處理進行初始化。
// 客戶端程序初始化邏輯
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();
})
});
setInterval(function(){
socket.emit('rooms');
}, 1000);
$('#send-message').focus();
$('#send-form').submit(function(event) {
processUserInput(chatApp, socket);
return false;
});
});
so 運行~

