1 项目名称

Web聊天室(《这是NodeJs实战》第二章的一个案例,把整个开发过程记录下来)

2 项目描述

该项目是一个简单的在线聊天程序。打开聊天页面,程序自动给用户分配一个昵称,进入默认的Lobby聊天室。用户可以发送消息,也可以使用聊天命令(聊天命令以/开头)修改自己的昵称或者加入已有的聊天室(聊天室不存在时,创建新的聊天室)。在加入或创建聊天室时,新聊天室的名称会出现在聊天程序顶端的水平条上,也会出现在聊天消息区域右侧的可用房间列表中。在用户换到新房间后,系统会显示信息以确认这一变化。

3 系统设计

该项目使用Node实现,因为Node用一个端口就可以轻松地提供HTTP和WebSocket两种服务。使用HTTP处理静态文件的同时使用WebSocket实现实时数据(聊天消息)。程序的实现可以划分以下几个功能模块:

  1. 提供静态文件(比如HTML、CSS和客户端JavaScript)
  2. 在服务器上处理与聊天相关的消息
  3. 在用户的浏览器中处理与聊天相关的消息

为了提供静态文件,需要使用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});//创建聊天室

05-12 22:37