使用nodejs实现服务端websocket通讯
发布于 2 天前 作者 zy445566 808 次浏览 来自 分享

起因是在写一个前置监控服务项目,需要数据相对实时的传输,然后正好看到nodejs文档中,实现websocket看起来挺简单的(其实只是冰山一角还有坑),所以就打算自己实现一遍websocket通讯服务。先看看nodejs官方文档怎么实现的:

// nodejs在http模块实现websocket的例子
const http = require('http');

// Create an HTTP server
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('okay');
});
server.on('upgrade', (req, socket, head) => {
  socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n' +
               'Upgrade: WebSocket\r\n' +
               'Connection: Upgrade\r\n' +
               '\r\n');

  socket.pipe(socket); // echo back
});

第一眼以为通过upgrade拿到socket套接字,然后就可以直接用socket.write和socket.on(‘data’)的方法来发送和获取数据。但事实并不是这样。

第一坑:Sec-WebSocket-Accept

我在浏览器中写好websocket的例子:

    var ws = new WebSocket(`ws://${window.location.host}/`);
    ws.onopen = function()
    {
        console.log("握手成功");
        ws.send("发送数据测试");
    };      
    ws.onmessage = function (e) 
    { 
        console.log(e.data);
    };

结果一连接就断开,说我没有Sec-WebSocket-Accept这个http头,网上一查一点结果都没有,看来实现这个的确实不多。 找来找去终于找到了websocket的协议文档(https://tools.ietf.org/html/rfc6455)。

发现Sec-WebSocket-Accept这个返回头是根据客户端的请求头sec-websocket-key,加上全局唯一ID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)后使用sha1摘要后,再以base64格式输出。

const crypto=require('crypto')
function getSecWebSocketAccept (secWebsocketKey){
    return crypto.createHash('sha1')
    .update(`${secWebsocketKey}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
    .digest('base64');
}

const secWebSocketAccept = getSecWebSocketAccept(req.headers['sec-websocket-key'])
socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n' +
            'Upgrade: WebSocket\r\n' +
            'Connection: Upgrade\r\n' +
            'Sec-WebSocket-Accept: '+ secWebSocketAccept +'\r\n' +
            '\r\n');

再刷新下浏览器,发现握手成功了。

第二坑:接收到的客户端数据是乱码

握手成功后,肯定是要看客户端给我发了什么数据,原来是个buffer,但toString后居然是乱码。

socket.on('data', (data) => {
    console.log(data.toString())
});

当时就在想里面是不是有猫腻,一看果然websocket还有frame的概念,接收到data就是一个frame,在这个框架里面有一定的结构。

在文档中叫Base Framing Protocol(https://tools.ietf.org/html/rfc6455#section-5.2),大概的结构如下:

/**
    我在第二三行重新加了个按字节和比特来计算的比例尺
     0                   1                   2                   3
     0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
                   1               2               3               4
     0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
    +-+-+-+-+-------+-+-------------+-------------------------------+
    |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
    |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
    |N|V|V|V|       |S|             |   (if payload len==126/127)   |
    | |1|2|3|       |K|             |                               |
    +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
    |     Extended payload length continued, if payload len == 127  |
    + - - - - - - - - - - - - - - - +-------------------------------+
    |                               |Masking-key, if MASK set to 1  |
    +-------------------------------+-------------------------------+
    | Masking-key (continued)       |          Payload Data         |
    +-------------------------------- - - - - - - - - - - - - - - - +
    :                     Payload Data continued ...                :
    + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
    |                     Payload Data continued ...                |
    +---------------------------------------------------------------+
    */

什么意思呢?那么按照我标记的字节来算吧

---
第1个字节的第1个比特是FIN的值,用来标识这个frame信息传递是否结束,1是结束
第1个字节的第2-3个比特是RSV的值,用来标识这个frame信息传递是否结束
第1个字节的第4-8个比特是opcode的值,来标记状态1是文本数据2是二进制数据8是请求关闭链接
---
第2个字节的第1个比特是Mask的值,用来标识数据是否使用Masking-key来做异或解码
第2个字节的第2-8个比特PayloadLen,
代表数据长度,如果为126,则使用16位的扩展数据长
代表数据长度,如果为127,则使用8位的扩展数据长度
扩展长度使用大字端读取就好

那知道这些就可以通过代码来实现解码,代码实现如下

function decodeSocketFrame (bufData){
    let bufIndex = 0
    const byte1 = bufData.readUInt8(bufIndex++).toString(2)
    const byte2 = bufData.readUInt8(bufIndex++).toString(2)
    const frame =  {
        fin:parseInt(byte1.substring(0,1),2),
        // RSV是保留字段,暂时不计算
        opcode:parseInt(byte1.substring(4,8),2),
        mask:parseInt(byte2.substring(0,1),2),
        payloadLen:parseInt(byte2.substring(1,8),2),
    }
    // 如果frame.payloadLen为126或127说明这个长度不够了,要使用扩展长度了
    // 如果frame.payloadLen为126,则使用Extended payload length同时为16/8字节数
    // 如果frame.payloadLen为127,则使用Extended payload length同时为64/8字节数
    // 注意payloadLen得长度单位是字节(bytes)而不是比特(bit)
    if(frame.payloadLen==126) {
        frame.payloadLen = bufData.readUIntBE(bufIndex,2);
        bufIndex+=2;
    } else if(frame.payloadLen==127) {
        // 虽然是8字节,但是前四字节目前留空,因为int型是4字节不留空int会溢出
        bufIndex+=4;
        frame.payloadLen = bufData.readUIntBE(bufIndex,4);
        bufIndex+=4;
    }
    if(frame.mask){
        const payloadBufList = []
        // maskingKey为4字节数据
        frame.maskingKey=[bufData[bufIndex++],bufData[bufIndex++],bufData[bufIndex++],bufData[bufIndex++]];
        for(let i=0;i<frame.payloadLen;i++) {
            payloadBufList.push(bufData[bufIndex+i]^frame.maskingKey[i%4]);
        }
        frame.payloadBuf = Buffer.from(payloadBufList)
    } else {
        frame.payloadBuf = bufData.slice(bufIndex,bufIndex+frame.payloadLen)
    }
    return frame
}

那如果你解码数据,那么发送的时候其实也是要遵循这种基本框架的,所以还要进行加码成frame框架后再发送,同理根据协议,可以实现如下代码:

function encodeSocketFrame (frame){
    const frameBufList = [];
    // 对fin位移七位则为10000000加opcode为10000001
    const header = (frame.fin<<7)+frame.opcode;
    console.log(header)
    frameBufList.push(header)
    const bufBits = Buffer.byteLength(frame.payloadBuf);
    let payloadLen = bufBits;
    let extBuf;
    if(bufBits>=126) {
        //65536是2**16即两字节数字极限
        if(bufBits>=65536) {
            extBuf = Buffer.allocUnsafe(8);
            buf.writeUInt32BE(bufBits, 4);
            payloadLen = 127;
        } else {
            extBuf = Buffer.allocUnsafe(2);
            buf.writeUInt16BE(bufBits, 0);
            payloadLen = 126;
        }
    }
    let payloadLenBinStr = payloadLen.toString(2);
    while(payloadLenBinStr.length<8){payloadLenBinStr='0'+payloadLenBinStr;}
    frameBufList.push(parseInt(payloadLenBinStr,2));
    if(bufBits>=126) {
        frameBufList.push(extBuf);
    }
    frameBufList.push(...frame.payloadBuf)
    return Buffer.from(frameBufList)
}

那么我们发送和接受就简单了,直接通过socket再发送就好了,如下

socket.on('data', (data) => {
    console.log(decodeSocketFrame(data).payloadBuf.toString())
    socket.write(encodeSocketFrame({
        fin:1,
        opcode:1,
        payloadBuf:Buffer.from('你好')
    }))
});

总结

其实websocket和http对于socket来说都是在上面加了一层协议,通过不同方法来实现其功能,随着技术的发展,协议也确实往复杂的方向发展。 在工作种如果自己实现协议可能就太费时间了,但是如果是非工作,实现一遍也还是能收获良多的。

4 回复

@luanxuechao 主要还是socket.io偏大,但确实兼容性和使用上都不错

回到顶部