# Nodejs 从零搭建 Websocket

虽然目前存在很多的 websocket 的组件,但是由于好奇心的驱使,还是要学习下 Nodejs 搭建 Websocket 而不使用三方的框架。

# 准备好 HTTP 服务器

需要提供一个 html 页面来做 websocket 的客户端,来调用服务

const http = require('http');
const fs = require('fs');
const path = require('path');

var socket;

var server = http.createServer((req, res) => {
    if (req.url == '/favicon.ico') {
        res.statusCode = 404;
        res.end();
    } else if (req.url == '/') {
        res.setHeader('Content-Type', 'text/html;charset=utf-8');
        var html = fs.readFileSync(path.resolve(__dirname, 'html', 'index.html'));
        res.end(html.toString('utf8'));
    } else {
        res.setHeader("Content-Type", "text/plain;charset=utf-8");
        res.end("向这个世界问好吧");
    }
});

// 每次客户端请求 HTTP 升级时发出
server.on("upgrade", function(req, _socket, upgradeHead) {
    // 这里就是 websocket 开始的地方
})

server.listen(8058, () => {
    console.log("服务器已启动");
});

当客户端链接的时候,会触发 serverupgrade 事件,在事件中可完成握手

# 简单的客户端

提供客户端的链接,在控制台中可以输入命令来发送数据

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    测试 websocket
    <script>
        var ws = new WebSocket('ws://127.0.0.1:8058/chathub');
        ws.onopen = function(e) {
            console.log(e);
        }
        ws.onerror = function(e) {
            console.log(e);
        }
    </script>
</body>
</html>

# 握手

使用 HTTP 协议代替 TCP 的握手。并没有代替,也代替不了,因为 HTTP 是基于 TCP 协议,HTTP 的链接就是 TCP 的链接;所以在这里纯粹就是交换协议升级信息,而最终的长连接也是最初创建的 TCP 链接。

# 升级请求

客户端发送请求

var ws = new WebSocket('ws://127.0.0.1:8058/chathub');

HTTP 请求头(删除了多余字段)

GET http://127.0.0.1:8058/chathub HTTP/1.1
Host: 127.0.0.1:8058
Connection: Upgrade // 表示要升级协议
Upgrade: websocket // 要升级到 websocket 协议
Sec-WebSocket-Version: 13 // websocket 版本号
Sec-WebSocket-Key: QzAARdX9Ojxe++23gG0TMw== // 浏览器提供,用于与服务器验证

# 服务器响应

HTTP 响应头(删除了多余字段)

HTTP/1.1 101 Switching Protocols // 切换协议
Connection: Upgrade // 升级协议
Upgrade: websocket // 升级为 websocket 协议
Sec-WebSocket-Accept: t7loKaUq6cn28MwzPEcave/go98= // 见说明

# Sec-WebSocket-Accept

请求头中的 Sec-WebSocket-Accept 值与 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 链接,然后通过 SHA-1 获取结果,然后转为 base64

const crypto = require('crypto');

function getWebsocketAccept(req) {
    var sha1 = crypto.createHash('sha1');
    sha1.update(req.headers['sec-websocket-key'] 
        + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', 'ascii');
    return sha1.digest('base64');
}

# 完成握手

目前只需要返回上面的 HTTP 响应头,就可以完成整个握手工作了

server.on("upgrade", function(req, _socket, upgradeHead) {
    socket = _socket;

    var resHeaders = 'HTTP/1.1 101 Switching Protocols\r\n';
    resHeaders += 'Upgrade: websocket\r\n';
    resHeaders += 'Connection: Upgrade\r\n';
    resHeaders += `Sec-WebSocket-Accept: ${getWebsocketAccept(req)}\r\n`;
    resHeaders += '\r\n';
    socket.write(resHeaders);
})

# 接受数据

serverupgrade 事件, 会传递一个 socket 对象, 就是与客户端的链接

server.on("upgrade", function(req, _socket, upgradeHead) {
    socket = _socket;

    socket.on("data", data => {
        console.log(data, data.length);
    });

    // to do 握手
})

然鹅,数据是二进制的,包含了一个头部,并且需要解码(客户端的消息)

# 数据协议

通信协议格式是WebSocket格式,服务器端采用Tcp Socket方式接收数据,进行解析,协议格式如下:

fsdfsd

# 第一个字节

第一位为 FIN ,用于描述消息是否结束, 如果是 1 的话,该消息是消息尾部,如果是 0 则还有后续的数据包。 第二位到第四位, 用于扩展定义,如果没有扩展约定的情况,必须为 0 第五位到第八位, OPCODE ,用于表示消息接收类型

OPCODE 定义 OPCODE 定义
0x0 附加数据帧(继续处理) 0x8 close 关闭连接
0x1 文本(utf8) 0x9 ping
0x2 二进制 0xA pong
0x3 - 0x7 暂无定义,保留(非控制) 0xB - 0xF 暂无定义,保留(控制)

# 第二个字节

第一位为 MASK,用于表示后续数据是否需要解码(客户端发送数据需要解码) 后七位为 数据长度,该长度决定了,后续字节的意义。

后七位长度 真实长度
小于 126 后七位长度就是数据的真实长度
126 第三个字节和第四个字节表示数据的真实长度
127 第三到第十个字节表示数据的真实长度,第三到第六位数据应该全部为 0

# 抛弃表示长度的字节后

如果 MASK 为 1 的话, 那么接下来的四位就是 32 位的掩码,后面的数据解码后,就是真实的数据了。如果 MASK 为 0 的话,那么接下来的数据全都是真实数据了。

# 解码

解码其实就是分组解码

function unMaskBuffer(data, mask) {
    var r = Buffer.alloc(data.length);
    for (var i = 0; i < data.length; i++) {
        r[i] = mask[i % 4] ^ data[i];
    }
    return r;
}

# 获取真实数据

根据上述的协议,获取真实数据的代码如下:

function getRealData(data) {

    var realIndex = 2;
    // 第一字节的判断
    var fByte = data.readUInt8(0);
    var FIN = fByte >> 7;
    var OPCODE = fByte & 0x0f;
    var sByte = data.readUInt8(1);
    var MASK = sByte >> 7;
    var length = sByte & 0x7f;
    if (length == 126) {
        length = data.readUInt16BE(2);
        realIndex = 4;
    }
    if (length == 127) {
        var empty = data.readUInt32BE(2);
        if (empty != 0) {
            console.log("按照要求此处应该为0");
        }
        realIndex = 10;
        length = data.readUInt32BE(6);
    }
    var payload;
    if (MASK) {
        var maskBuffer = data.slice(realIndex, realIndex + 4);
        realIndex += 4;
        payload = unMaskBuffer(data.slice(realIndex), maskBuffer);
    } else {
        payload = data.slice(realIndex);
    }
    return {
        type: OPCODE,
        payload: payload
    }
}

注意:在 Nodejs 中存在一个问题,当大于 65536 时,会有溢出的情况,后面的数据包会乱掉,所以当真实数据和指定的数据不一致时,需要等后面的包补充。待验证

# 服务端发送数据

服务端发送数据,也需要协议格式,单不需要掩码

function sendMessage(type, payload) {
    var fByte = 0x80 | type;
    if(payload.length < 126) {
        var buf = Buffer.alloc(2 + payload.length);
        buf.writeUInt8(fByte, 0);
        buf.writeUInt8(payload.length, 1);
        payload.copy(buf, 2);
        return this.write(buf);
    }
    if(payload.length < 65536) {
        var buf = Buffer.alloc(4 + payload.length);
        buf.writeUInt8(fByte, 0);
        buf.writeUInt8(126, 1);
        buf.writeUInt16BE(payload.length, 2);
        payload.copy(buf, 4);
        return this.write(buf);
    }
    // 建议大于 65536 的包直接拆包
}

有时间需要补充一个分包的功能

# 心跳

pingpong ,要不就你 pingpong,假设服务端来 ping

setInterval(() => {
    sendMessage.call(socket, 9, Buffer.alloc(0));
}, 1000);

然后会收到客户端的 pong,如果客户端来 ping 的话,那么服务端收到 ping 的消息后,需要回复 pong , 一般情况下,都是服务端来 ping ,目前没有 js api 来实现 ping

# 客户端掩码的原由

安全。但并不是为了防止数据泄密,而是为了防止早期版本的协议中存在的代理缓存污染攻击(proxy cache poisoning attacks)等问题。

# 不使用 Serverupgrade 事件直接实现

本来只是一个猜测,如果是因为 Nodejs 的封装关系,可能会导致真相的隐藏;现在的问题就是,在整个请求和响应的上下文中,只要能获取到当前的 socket 就可以实现。

var server = http.createServer((req, res) => {
    if (req.url == '/favicon.ico') {
        // ...
    } else if (req.url == '/') {
        // ...
    } else if(req.url == '/chathub') {
        res.statusCode = 101;
        res.statusMessage = 'Switching Protocols',
        res.setHeader('Upgrade', 'websocket');
        res.setHeader('Connection', 'Upgrade');
        res.setHeader('Sec-WebSocket-Accept', getWebsocketAccept(req));
        res.end();
        res.socket.on("data", data => {
            var real = getRealData(data);
            if(real.type == 1 || real.type == 2) {
                sendMessage.call(req.socket, real.type, real.payload);
            }
        })
        sendMessage.call(req.socket, 1, Buffer.from("欢迎来到王者荣耀", 'utf8'));
    } else {
        // ...
    }
});

# 扩展思考

如果在 C# 中要怎么实现呢? 也找了很多的参考文档,大体的意思就是,如果是 framework 4.5 及以上,提供了升级协议的方法,而在此之前,在现有的 MVC 框架中是无法直接实现的,只能用 SOCKET 自己来实现。

# 参考文档