《Node.js实战》阅读笔记(二)

千里之行,始于足下。第二章是实战内容:构建多个房间的聊天室程序。
本章会构建一个在线聊天程序,用户可以在一个简单的表单中输入消息,相互聊天,消息输入后会发送给同一聊天室内的其他所有用户。

程序需求及初始设置

需求:进入聊天室后,程序会自动给用户分配一个昵称,但他们可以用聊天命令修改自己的昵称。聊天命令以斜杠(/)开头。同样,用户可以输入命令创建新的聊天室(或加入已有的聊天室)。在加入或创建聊天室时,新聊天室的名称会出现在聊天程序顶端的水平条上,也会出现在聊天消息区域右侧的可用房间列表中。
将要创建的聊天程序需要完成如下任务:

  • 提供静态文件
  • 在服务器上处理与聊天相关的信息
  • 在用户的浏览器中处理与聊天相关的消息。
    为了提供静态文件,需要使用Node内置的http模块。但通过HTTP提供文件时,通常不能只是发送文件中的内容,还应该有所发送文件的类型。也就是说要正确的MIME类型设置HTTP头的Content-Type。为了查找这些MIME类型,你会用到第三方的模块mime。
    为了处理与聊天相关的消息,需要用Ajax轮询服务器。但为了让这个程序尽可能快地做出响应,我们不会用传统的Ajax发送消息。Ajax用HTTP做传输机制,并且HTTP本来就不是做实时通信的。在用HTTP发送消息时,必须用一个新的TCP/IP连接。打开和关闭连接需要时间。此外,因为每次请求都要发送HTTP头,所以传输的数据量也较大。这个程序没用依赖于HTTP的方案,而采用了WebSocket,这是一个为支持实时通讯而设计的轻量的双向通信协议。

创建程序的文件结构

  • 创建如下的文件结构:


    文件结构
  • 命令:

npm init --y
npm i socket.io mime --save-dev
  • 逻辑


    服务端与客户端

创建静态文件服务器

  • common.js引入
var http = require('http');
var fs = require('fs');
var path = require('path');
var mime = require('mime');
// cache 是用来缓存文件内容的对象
var cache = {};
  • 发送文件数据及错误响应三个辅助函数
// 404处理
function send404(res) {
    res.writeHead(404, {
        'Content-Type': 'text/plain'
    })
    res.write('Error 404: resource not found.')
    res.end()
}
// 发送文件
function sendFile(res, filePath, fileContents){
    res.writeHead(200, {
                // mime2.0以上lookup更名为getType
        'Content-Type': mime.getType(path.basename(filePath))
    });
    res.end(fileContents);
}

访问内存(RAM)要比访问文件系统快得多,所以Node程序通常会把常用的数据缓存到内存里。我们的聊天程序就要把静态文件缓存到内存中,只有第一次访问的时候才会从文件系统中读取。下一个辅助函数就会确定文件是否缓存了,如果是,就返回它。如果文件还没缓存,它会从硬盘中读取并返回它。如果文件不存在,则返回一个HTTP 404错误作为响应。如下:

// 提供静态文件服务
function serveStatic(res, cache, absPath) {
    // 检查文件是否缓存在内存中
    if(cache[absPath]){
        // 从内存中返回文件
        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{
                // 发送HTTP 404 响应
                send404(res);
            }
        });
    }
}
  • 创建HTTP服务器
// 创建HTTP服务器
var server = http.createServer(function(req, res){
    var filePath = false;
    if(req.url == '/'){
        // 确定返回的默认HTML文件
        filePath = 'public/index.html'
    }else{
        // 将URL路径转为文件的相对路径
        filePath = 'public' + req.url;
    }
    var absPath = './' + filePath;
    // 返回静态文件
    serveStatic(res, cache, absPath);
});
  • 启动HTTP服务器
    现在已经写好了创建代码,但还没添加启动它的逻辑。添加下面代码,它会启动服务器,要求服务器监听TCP/IP端口3000。
server.listen(3000, function() {
    console.log("Server listening on port 3000");
})

在命令行中输入下面这条命令启动:

node server.js

服务器运行起来后,在浏览器中访问http://127.0.0.1:3000会激发404错误辅助函数,页面上会显示“Error 404: resource not found。”
尽管你已经添加了静态文件处理逻辑,但还没添加那些静态文件。记住,在命令行中按下Ctrl-C可以停止正在运行的服务器。
接下来,让我们把必须的静态文件加上,把这个聊天程序的功能再向前推进一步。

添加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;
}

这个程序还不能用,但静态文件已经可以看了,基本的视觉布局也搭建好了。把这些料理好了之后,我们接下来去定义服务端聊天消息的分发。

用Socket.IO 处理与聊天相关的消息

我们前面说过程序必须要做三件事,其中第一个提供静态文件已经做了,现在来解决第二个,处理浏览器和服务器之间的通信。
Socket.IO为Node及客户端JavaScript提供了基于WebSocket以及其他传输方式的封装,它提供了一个抽象层。如果浏览器没有实现WebSocket,Socket.IO会自动启用一个备选方案,而对外提供的API还是一样的。
Socket.IO提供了开箱即用的虚拟通道,所以程序不用把每条消息都向已连接的用户广播,而是只向那些预订了某个通道的用户广播。
Socket.IO还是事件发射器(Event Emitter)的好例子。事件发射器本质上是组织异步逻辑的一种很方便的设计模式。

  • 设置Socket.IO服务器
    首先,把下面这两行代码添加到server.js中。第一行加载一个定制的Node模块,它提供的逻辑是用来处理基于Socket.IO的服务端聊天功能的。第二行启动Socket.IO服务器,给它提供一个已经定义好的HTTP服务器,这样它就能跟HTTP服务器共享同一个TCP/IP端口:
// 设置socket.io服务器
var chatServer = require('./lib/chat_server.js');
chatServer.listen(server);

现在你要在lib目录中创建一个新文件,chat_server.js。先把下面的变量声明添加到这个文件中。这些声明让我们可以使用Socket.IO,并初始化了一些定义聊天状态的变量:

var socketio = require('socket.io');
var io;
var guestNumber = 1;
var nickNames = {};
var namesUsed = [];
var currentRoom = ();
  • 确立连接逻辑
    定义聊天服务器函数listen。server.js中会调用这个函数。
    它启动Socket.IO服务器,限定Socket.IO向控制台输出的日志的详细程度,并确定该如何处理每个接进来的连接。
// 启动socket.io服务器
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');
        // 处理用户的消息,更名,以及聊天室的创建和变更
        handleMessageBroadcasting(socket, nickNames);
        handleNameChangeAttempts(socket, nickNames, namesUsed);
        handleRoomJoining(socket);
        // 用户发出请求时,向其提供已经被占用的聊天室的列表
        socket.on('rooms', function() {
            socket.emit('rooms', io.sockets.manager.rooms);
        });
        // 定义用户断开连接后的清除逻辑
        handleClientDisconnection(socket, nickNames, namesUsed);
    })
}
  • 处理程序场景及事件
    聊天程序需要处理下面这些场景和事件:
     分配昵称;
     房间更换请求;
     昵称更换请求;
     发送聊天消息;
     房间创建;
     用户断开连接。
    要实现这些功能得添加几个辅助函数,如下文所述。
     分配昵称;
    要添加的第一个辅助函数是assignGuestName,用来处理新用户的昵称。当用户第一次连到聊天服务器上时,用户会被放到一个叫做Lobby的聊天室中,并调用assignGuestName给他们分配一个昵称,以便可以相互区分开。
    程序分配的所有昵称基本上都是在Guest后面加上一个数字,有新用户连进来时这个数字就会往上增长。用户昵称存在变量nickNames中以便于引用,并且会跟一个内部socket ID关联。昵称还会被添加到namesUsed中,这个变量中保存的是已经被占用的昵称。把下面清单中的代码添加到lib/chat_server.js中实现这个功能。
// 分配昵称
function assignGuestName(socket, guestNumber, nickNames, namesUsed){
    // 生成新昵称
    var name = 'Guest' + guestNumber;
    // 把用户昵称跟客户端连接ID关联上
    nickNames[socket.id] = name;
    // 让用户知道他们的昵称
    socket.emit('nameResult', {
        success: true,
        name: name
    });
    // 存放已经被占用的昵称
    namesUsed.push(name);
    // 增加用来生成昵称的计数器
    return guestNumber + 1;
}
  • 进入聊天室
    要添加到chat_server.js中的第二个辅助函数是joinRoom。这个函数如下所示,处理逻辑跟用户加入聊天室相关。
// 进入聊天室
function joinRoom(socket, room){
    // 让用户进入房间
    socket.join(room);
    // 记录用户的当前房间
    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 += '.';
    // 将房间里的其他用户的汇总发送给这个用户
    socket.emit('message', {
        text: usersInRoomSummary
    })
}

将用户加入Socket.IO房间很简单,只要调用socket对象上的join方法就行。然后程序就会把相关细节向这个用户及同一房间中的其他用户发送。程序会让用户知道有哪些用户在这个房间里,还会让其他用户知道这个用户进来了。

  • 处理昵称变更需求
    如果用户都用程序分配的昵称,很难记住谁是谁。因此聊天程序允许用户发起更名请求。更名需要用户的浏览器通过socket.io发送一个请求,并接收表示成功或失败的响应。
    将下面代码清单中的代码加到lib/chat_server.js中,这段代码定义了一个处理用户更名请求的函数。从程序的角度来讲,用户不能将昵称改成以Guest开头,或改成其他已经被占用的昵称。
// 更名请求的处理逻辑
function handleNameChangeAttempts(socket, nickNames, namesUsed){
    // 添加nameAttempt事件的监听器
    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{
                // 昵称被占用,给客户端发送错误信息
                socket.emit('nameResult', {
                    success: false,
                    message: 'That name is already in use'
                })
            }
        }
    })
}
  • 发送聊天信息
    基本流程:用户发射一个事件,表明消息是从哪个房间发出来的,以及消息的内容是什么,然后服务器将这条消息转发给同一房间的所有用户。
    将下面代码加到lib/chat_server.js中,sockrtIo的broadcast函数是用来转发消息的:
// 发送聊天消息
function handleMessageBroadcasting(socket) {
    socket.on('message', function(message){
        socket.broadcast.to(message.room).emit('message', {
            text: nickNames[socket.id] + ':' + message.text
        })
    })
}
  • 创建房间
    接下来要添加让用户加入已有房间的逻辑,如果房间还没有的话,则创建一个房间。
    将下面的代码添加到lib/chat_server.js文件中,实现更换房间的功能。注意Socket.IO中leave方法的使用:
// 创建房间
function hanleRoomJoining(socket) {
    socket.on('join', function(room){
        socket.leave(currentRoom[socket.id]);
        joinRoom(socket, room.newRoom);
    })
}
  • 用户断开连接
    最后还要把下面这段代码添加到lib/chat_server.js文件中,当用户离开聊天程序时,从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需要实现以下功能:

  1. 向服务器发送用户的消息和昵称/房间变更请求;
  2. 显示其他用户的消息,以及可用房间的列表。
  • 将消息和昵称、房间变更请求传给服务器
    要添加的第一段客户端JavaScript代码是一个JavaScript原型对象,用来处理聊天命令、发送消息、请求变更房间或昵称。
    在public/javascripts目录下创建一个chat.js文件,把下面的代码放进去。这段代码相当于定义了一个JavaScript“类”,在初始化时可用传入一个Socket.IO的参数socket:
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].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;
}
  • 在用户界面中显示消息及可用房间
    这个聊天程序会用两个辅助函数显示文本数据。一个函数用来显示可疑的文本数据,另一个函数显示受信的文本数据。
    函数divEscapedContentElement用来显示可疑的文本。它会净化文本,将特殊字符转换成HTML实体。函数divSystemContentElement用来显示系统创建的受信内容,而不是其他用户创建的。
    在public/javascripts目录下创建chat_ui.js文件,并把下面两个辅助函数放进去:
function divEscapedContentElement(message) {
    return $('<div></div>').text(message);
}

function divSystemContentElement(message) {
    return $('<div></div>').html('<i>' + message + '</i>');
}

下一个要加到chat_ui.js中的函数是用来处理用户输入的。如果用户输入的内容以斜杠(/)开头,它会将其作为聊天命令处理。如果不是,就作为聊天消息发送给服务器并广播给其他用户,并添加到用户所在聊天室的聊天文本中。

// 处理原始的用户输入
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('');
}

辅助函数现在已经定义好了,你还需要添加下面这个代码清单中的逻辑,它要在用户的浏览器加载完页面后执行。这段代码会对客户端的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 运行~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,302评论 5 470
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,232评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,337评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,977评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,920评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,194评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,638评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,319评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,455评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,379评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,426评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,106评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,696评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,786评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,996评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,467评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,043评论 2 341

推荐阅读更多精彩内容