从零开始实现一个简单的WebSocket协议

JS_Even_JS

一、WebSocket简介

WebSocket是一种基于TCP连接的全双工通信的协议,其工作在应用层,建立连接的时候通过复用http握手通道完成http协议的切换升级,即切换到WebSocket协议,协议切换成功后,将不再需要客户端发起请求,服务端就可以直接向客户端发送数据,实现双向通信。
相对于Http协议而言,WebSocket有以下优点:

  • 可以支持双向通信
  • WebSocket协议可以更好的支持二进制可以直接传送二进制数据
  • 同时WebSocket协议的头部非常小,服务器发到客户端的数据包的包头,只有2~10个字节(取决于数据包的长度),客户端发送服务端的包头稍微大一点,因为其要进行掩码加密,所以还要加上4个字节的掩码。总得来说,头部不超过14个字节。
  • 支持扩展,用户可以扩展协议实现自己的子协议。

二、实现一个WebSocket协议

① 设计WebSocket协议
我们使用WebSocket的时候,是通过new一个WebSocket对象,所以Websocket是一个类,同时在创建对象的时候,需要传递一些配置对象作为参数。由于WebSocket可以复用http握手通道,所以我们需要有一个web服务器,才能复用http的握手通道,所以如果我们已经创建好了一个web服务器,那么我们可以把这个web服务器传递给WebSocket,那么WebSocket就不需要再创建自己的web服务器了;当然我们也可以只传递一个端口号由WebSocket自己创建一个web服务器。当客户端发来了消息,我们还需要能够通知到Websocket对象,所以我们还需要让WebSocket继承EventEmitter,如:

const http = require("http");
const { EventEmitter } = require('events')
class WebSocket extends EventEmitter { 
    constructor(options) {
        super();
        if (options.server) {
            this.server = options.server; // 使用了传递的web server
        } else {
            this.server = http.createServer(); // 自己创建一个web server
            this.server.listen(options.port || 3000);// 监听端口,默认3000
        }
    }
}

② 建立连接,切换升级到WebSocket协议
上一步,我们的WebSocket已经拥有web服务器了,接下来我们就是需要客户端通过http请求协议切换升级,当浏览器通过new WebSocket()创建WebSocket客户端的时候,浏览器会自动发起协议升级请求,如:
屏幕快照 2020-03-19 下午5.23.42.png
屏幕快照 2020-03-19 下午5.24.19.png

  • Connection: Upgrade 表示要升级协议
  • Upgrade: websocket 表示要升级到websocket协议
  • Sec-WebSocket-version 表示websocket的版本
  • Sec-WebSocket-Key 浏览器生成的一个字符串,服务器端需要取出该字符串与258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接,然后通过SHA1算法计算出摘要,并转成base64字符串,然后作为Sec-WebSocket-Accept的值放到响应头部返回给客户端,否则会连接失败。

所以建立连接的关键就是要监听upgrade事件,然后根据Sec-WebSocket-Key生成对应Sec-WebSocket-Accept的key值,然后返回给客户端

const crypto = require('crypto');
const MAGIC_STRING = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; // 固定的字符串
function hashWebSocketKey (key) {
    var sha1 = crypto.createHash('sha1'); // 拿到sha1算法
    sha1.update(key + MAGIC_STRING, 'ascii');
    return sha1.digest('base64');
};
class WebSocket extends EventEmitter { 
    constructor(options) {
    this.server.on("upgrade", (req, socket, upgradeHead) => {
        this.socket = socket;
        // 处理协议升级请求
        var resKey = hashWebSocketKey(req.headers['sec-websocket-key']); // 对浏览器生成的key进行加密
        // 构造响应头
        var resHeaders = [
            'HTTP/1.1 101 Switching Protocols',
            'Upgrade: websocket',
            'Connection: Upgrade',
            'Sec-WebSocket-Accept: ' + resKey
        ]
        .concat('', '')
        .join('\r\n');
        socket.write(resHeaders); // 返回响应头部
      });
    }
}

在upgrade事件回调中,我们可以拿到客户端的socket,然后通过客户端socket直接把响应头写入即可。

③ 接收客户端发过来的数据
经过上面,客户端已经可以和服务器端连接成功了,接下来客户端会发送数据到服务器端,服务器可以通过连接时的socket监听客户端发送过来的数据,同样是在监听到upgrade事件的时候,拿到客户端socket进行监听data事件即可,如:

class WebSocket extends EventEmitter { 
    constructor(options) {
        this.buffer = Buffer.alloc(0); // 初始化一个字节数据都没有的buffer
        this.closed = false;
        this.server.on("upgrade", (req, socket, upgradeHead) => {
            socket.on('data', (data) => { // 监听客户端发送过来的数据,该数据是一个Buffer类型的数据
                this.buffer = data; // 将客户端发送过来的帧数据保存到buffer变量中
                this.processBuffer(); // 处理Buffer数据
            });
            socket.on('close', (error) => { // 监听客户端连接断开事件
                if (!this.closed) {
                    this.emit('close', 1006, "timeout");
                    this.closed = true;
                }
            });
        });
    }
}

我们通过客户端socket监听其data事件就可以拿到客户端发送过来的数据,这个data是一个Buffer类型的数据包包含头部和真实数据,所以还需要进行解析处理才能拿到真正的数据。

④ 处理客户端发送过来的数据
上一步已经拿到了客户端发送过来的Buffer数据,接下来就是要实现processBuffer()方法来处理数据,我们先看一下数据帧的结构,如:
屏幕快照 2020-03-19 下午5.47.14.png

  • 首先我们先看第一个字节(8位),第一个字节的最高位表示的是FIN标识,如果FIN为1表示这是消息的最后一部分分片(fragment),就是消息已经发送完毕了;如果FIN为0表示这不是消息的最后一部分数据后续还会有数据过来
  • FIN位后的RSV1,RSV2,RSV3,各占一位,一般值都为0,主要用于WebSocket协议的扩展,所以可以认为这三位都是0
  • 第一个字节剩下的后四位表示的是操作码,代表的是数据帧类型,比如文本类型、二进制类型等
  • 再看第二个字节(8位),第二个字节的最高位表示的是Mask位,如果Mask位为1,表示这是客户端发送过来的数据,因为客户端发送的数据要进行掩码加密;如果Mask为0,表示这是服务端发送的数据
  • 第二个字节还剩下7位,表示的是传输字节的长度,其值为0-127,根据值的不同,存储数据长度的位置可能会向后扩展。其规则为,如果这7位表示的值在[0-125]之间那么就不用向后扩展,第二个字节的后7位就足够存储,这个7位表示的值就是发送数据的长度;如果这7位表示的值为126,表示客户端发送数据的字节长度在(125,65535)之间,此时需要16位两个字节来存储字节长度,所以用第三和第四个字节来表示客户端发送数据的长度;如果这7位表示的值为127,表示客户端发送的数据的字节长度大于65535,就要用64位,8个字节才存储数据长度,即第三到第10个字节来存储,但是这8个字节的前4个字节值必须为0,否则数据异常,连接必须关闭,所以其实是用第六到第十个字节来存储数据的长度
  • 根据以上规则,我们就可以知道真实数据的位置了,接下来我们就可以对真实数据进行解析了,如果第二个字节的第一位即Mask位值为1,那么表示客户端发送的数据,那么真实数据之前就会有四个字节的掩码。解码数据的时候,我们要使用到这个掩码,因为掩码有4个字节,所以解码的时候,我们要遍历真实数据,然后依次与掩码进行异或运算
function unmask(maskBytes, data) {
    var payload = Buffer.alloc(data.length);
    for (var i = 0; i < data.length; i++) { // 遍历真实数据
        payload[i] = maskBytes[i % 4] ^ data[i]; // 掩码有4个字节依次与真实数据进行异或运算即可
    }
    return payload;
};
processBuffer() {
    let buf = this.buffer;
    let idx = 2; // 首先分析前两个字节
    // 处理第一个字节
    const byte1 = buf.readUInt8(0); // 读取buffer数据的前8 bit并转换为十进制整数
    // 获取第一个字节的最高位,看是0还是1
    const str1 = byte1.toString(2); // 将第一个字节转换为二进制的字符串形式
    const FIN = str1[0];
    // 获取第一个字节的后四位,让第一个字节与00001111进行与运算,即可拿到后四位
    let opcode = byte1 & 0x0f; //截取第一个字节的后4位,即opcode码, 等价于 (byte1 & 15)
    // 处理第二个字节
    const byte2 = buf.readUInt8(1); // 从第一个字节开始读取8位,即读取数据帧第二个字节数据
    const str2 = byte2.toString(2); // 将第二个字节转换为二进制的字符串形式
    const MASK = str2[0]; // 获取第二个字节的第一位,判断是否有掩码,客户端必须要有
    let length = parseInt(str2.substring(1), 2); // 获取第二个字节除第一位掩码之后的字符串并转换为整数
    if (length === 126) { // 说明125<数据长度<65535(16个位能描述的最大值,也就是16个1的时候)
        length = buf.readUInt16BE(2); // 就用第三个字节及第四个字节表示数据的长度
        idx += 2; // 偏移两个字节
    } else if (length === 127) { // 说明数据长度已经大于65535,16个位也已经不足以描述数据长度了,就用第三到第十个字节这八个字节来描述数据长度
        const highBits = buf.readUInt32BE(2); // 从第二个字节开始读取32位,即4个字节,表示后8个字节(64位)用于表示数据长度,其中高4字节是0
        if (highBits != 0) { // 前四个字节必须为0,否则数据异常,需要关闭连接
            this.close(1009, ''); //1009 关闭代码,说明数据太大; 协议里是支持 63 位长度,不过这里我们自己实现的话,只支持 32 位长度,防止数据过大;
        }
        length = buf.readUInt32BE(6); // 获取八个字节中的后四个字节用于表示数据长度,即从第6到第10个字节,为真实存放的数据长度
        idx += 8;
    }
    let realData = null; // 保存真实数据对应字符串形式
    if (MASK) { // 如果存在MASK掩码,表示是客户端发送过来的数据,是加密过的数据,需要进行数据解码
        const maskDataBuffer = buf.slice(idx, idx + 4); //获取掩码数据, 其中前四个字节为掩码数据
        idx += 4; //指针前移到真实数据段
        const realDataBuffer = buf.slice(idx, idx + length); // 获取真实数据对应的Buffer
        realData = unmask(maskDataBuffer, realDataBuffer); //解码真实数据
        console.log(`realData is ${realData}`);
    }
    let realDataBuffer = Buffer.from(realData); // 将真实数据转换为Buffer
    this.buffer = buf.slice(idx + length); // 清除已处理的buffer数据
    if (FIN) { // 如果第一个字节的第一位为1,表示是消息的最后一个分片,即全部消息结束了(发送的数据比较少,一次发送完成)
        this.handleRealData(opcode, realDataBuffer); // 处理操作码
    }
}

⑤ 处理真实数据
前一步,我们已经拿到真实数据了,接下来就是要处理真实数据,主要就是将其转发给服务端WebSocket,根据操作码即数据类型来处理,如:

var OPCODES = {
    CONTINUE: 0,
    TEXT: 1,
    BINARY: 2,
    CLOSE: 8,
    PING: 9,
    PONG: 10
};
// 处理客户端发送过来的真实数据
handleRealData(opcode, realDataBuffer) {
    switch (opcode) {
        case OPCODES.TEXT:
            this.emit('data', realDataBuffer.toString('utf8')); // 服务端WebSocket监听data事件即可拿到数据
            break;
        case OPCODES.BINARY: //二进制文件直接交付
            this.emit('data', realDataBuffer);
            break;
        default:
            this.close(1002, 'unhandle opcode:' + opcode);
    }
}

⑥ 服务端WebSocket发送数据
上一步,服务端WebSocket已经能够拿到客户端发送过来的数据了,接下来就是要让服务端能够发送数据,所以要添加一个send(data)方法,传递要发送的数据,我们根据数据的类型,选定对应的操作码,然后转换成Buffer数据发送出去即可。

// 根据发送数据的类型设置上对应的操作码,将数据转换为Buffer形式
send(data) {
    let opcode;
    let buffer;
    if (Buffer.isBuffer(data)) { // 如果是二进制数据
        opcode = OPCODES.BINARY; // 操作码设置为二进制类型
        buffer = data;
    } else if (typeof data === "string") { // 如果是字符串
        opcode = OPCODES.TEXT; // 操作码设置为文本类型
        buffer = Buffer.from(data, 'utf8'); // 将字符串转换为Buffer数据
    } else {
        throw new Error('cannot send object.Must be string of Buffer');
    }
    this.doSend(opcode, buffer);
}

⑦ 编码数据
上一步我们传递的是服务端要发送的真实数据,但是我们需要将真实数据编码成帧数据才能成功发送给客户端,编码数据的规则和之前解码数据的规则是一样的,这里我们只处理简单少量的数据,直接将第一个字节的最高位即FIN设置为1再加上操作码即可完成第一个字节,然后获取数据的长度作为第二个字节,再将真实数据放到第二个字节之后即可。如:

function encodeMessage(opcode, payload) {
    let buf;
    // 0x80 二进制为 10000000 | opcode 进行或运算就相当于是将首位置为1
    let b1 = 0x80| opcode; // 如果没有数据了将FIN置为1
    let b2; // 存放数据长度
    let length = payload.length;
    console.log(`encodeMessage: length is ${length}`);
    if (length < 126) {
        buf = Buffer.alloc(payload.length + 2 + 0); // 服务器返回的数据不需要加密,直接加2个字节即可
        b2 = length; // MASK为0,直接赋值为length值即可
        buf.writeUInt8(b1, 0); //从第0个字节开始写入8位,即将b1写入到第一个字节中
        buf.writeUInt8(b2, 1); //读8―15bit,将字节长度写入到第二个字节中
        payload.copy(buf, 2); //复制数据,从2(第三)字节开始,将数据插入到第二个字节后面
    }
    return buf;
}
// 开始发送数据
doSend(opcode, buffer) {
    this.socket.write(encodeMessage(opcode, buffer)); //编码后直接通过socket发送
}

我们将编码好的数据直接通过客户端socket写入即可,客户端socket通过监听message事件即可收到来自服务器发送的数据了。

三、WebSocket的使用

引入我们自己的WebSocket类,然后创建WebSocket对象,可以传入一个web server或者指定一个port,如:

const WebSocket = require("./WebSocket");
// 直接传递一个server
const http = require("http");
const server = http.createServer();
const ws = new WebSocket({
    server
});
server.listen(3000);

// 直接传递一个端口号
//const ws = new WebSocket({
//    port: 3000
//});
ws.on('data', (data) => {
    console.log('receive data:' + data);
    ws.send('this message from server');
});

ws.on('close', (code, reason) => {
    console.log('close:', code, reason);
});

四、总结

总的来说,WebSocket协议内部就是需要一个web服务器,这个服务器可以从外部传入,也可以自己创建,通过复用http通道完成协议的切换,正确响应协议切换请求即可完成WebSocket的连接。建立连接后就能拿到客户端的socket,然后通过客户端socket监听客户端发送过来的数据,然后对数据帧进行解析,拿到真实的数据,最后发送给服务端即可。服务端发送数据的时候也是按照同样的规则将数据封装成数据帧结构就可以发送给客户端了。完整代码如下:

const crypto = require('crypto');
const http = require("http");
const { EventEmitter } = require('events');
const MAGIC_STRING = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
function hashWebSocketKey (key) {
    var sha1 = crypto.createHash('sha1');
    sha1.update(key + MAGIC_STRING, 'ascii');
    return sha1.digest('base64');
};
function unmask(maskBytes, data) {
    var payload = Buffer.alloc(data.length);
    for (var i = 0; i < data.length; i++) { // 遍历真实数据
        payload[i] = maskBytes[i % 4] ^ data[i]; // 掩码有4个字节依次与真实数据进行异或运算即可
    }
    return payload;
};
var OPCODES = {
    CONTINUE: 0,
    TEXT: 1,
    BINARY: 2,
    CLOSE: 8,
    PING: 9,
    PONG: 10
};
function encodeMessage(opcode, payload) {
    let buf;
    // 0x80 二进制为 10000000 | opcode 进行或运算就相当于是将首位置为1
    let b1 = 0x80| opcode; // 如果没有数据了将FIN置为1
    let b2; // 存放数据长度
    let length = payload.length;
    if (length < 126) {
        buf = Buffer.alloc(payload.length + 2 + 0); // 服务器返回的数据不需要加密,直接加2个字节即可
        b2 = length; // MASK为0,直接赋值为length值即可
        buf.writeUInt8(b1, 0); //从第0个字节开始写入8位,即将b1写入到第一个字节中
        buf.writeUInt8(b2, 1); //读8―15bit,将字节长度写入到第二个字节中
        payload.copy(buf, 2); //复制数据,从2(第三)字节开始,将数据插入到第二个字节后面
    }
    return buf;
}
class WebSocket extends EventEmitter { 
    constructor(options) {
        super();
        if (options.server) {
            this.server = options.server;
        } else {
            this.server = http.createServer();
            this.server.listen(options.port || 3000);
        }
        this.socket = null;
        this.buffer = Buffer.alloc(0); // 初始化一个字节数据都没有的buffer
        this.closed = false;
        this.server.on("upgrade", (req, socket, upgradeHead) => {
            this.socket = socket;
            // 处理协议升级请求
            var resKey = hashWebSocketKey(req.headers['sec-websocket-key']); // 对浏览器生成的key进行加密
            // 构造响应头
            var resHeaders = [
                'HTTP/1.1 101 Switching Protocols',
                'Upgrade: websocket',
                'Connection: Upgrade',
                'Sec-WebSocket-Accept: ' + resKey
            ]
                .concat('', '')
                .join('\r\n');
            socket.write(resHeaders);
            socket.on('data', (data) => { // 监听客户端发送过来的数据,该数据是一个Buffer类型的数据
                this.buffer = data; // 将客户端发送过来的帧数据保存到buffer变量中
                this.processBuffer(); // 处理Buffer数据
            });
            socket.on('close', (error) => { // 监听客户端连接断开事件
                if (!this.closed) {
                    this.emit('close', 1006, "timeout");
                    this.closed = true;
                }
            });
        });
    }
    processBuffer() {
        let buf = this.buffer;
        let idx = 2; // 首先分析前两个字节
        // 处理第一个字节
        const byte1 = buf.readUInt8(0); // 读取buffer数据的前8 bit并转换为十进制整数
        // 获取第一个字节的最高位,看是0还是1
        const str1 = byte1.toString(2); // 将第一个字节转换为二进制的字符串形式
        const FIN = str1[0];
        // 获取第一个字节的后四位,让第一个字节与00001111进行与运算,即可拿到后四位
        let opcode = byte1 & 0x0f; //截取第一个字节的后4位,即opcode码, 等价于 (byte1 & 15)
        // 处理第二个字节
        const byte2 = buf.readUInt8(1); // 从第一个字节开始读取8位,即读取数据帧第二个字节数据
        const str2 = byte2.toString(2); // 将第二个字节转换为二进制的字符串形式
        const MASK = str2[0]; // 获取第二个字节的第一位,判断是否有掩码,客户端必须要有
        let length = parseInt(str2.substring(1), 2); // 获取第二个字节除第一位掩码之后的字符串并转换为整数
        if (length === 126) { // 说明125<数据长度<65535(16个位能描述的最大值,也就是16个1的时候)
            length = buf.readUInt16BE(2); // 就用第三个字节及第四个字节表示数据的长度
            idx += 2; // 偏移两个字节
        } else if (length === 127) { // 说明数据长度已经大于65535,16个位也已经不足以描述数据长度了,就用第三到第十个字节这八个字节来描述数据长度
            const highBits = buf.readUInt32BE(2); // 从第二个字节开始读取32位,即4个字节,表示后8个字节(64位)用于表示数据长度,其中高4字节是0
            if (highBits != 0) { // 前四个字节必须为0,否则数据异常,需要关闭连接
                this.close(1009, ''); //1009 关闭代码,说明数据太大; 协议里是支持 63 位长度,不过这里我们自己实现的话,只支持 32 位长度,防止数据过大;
            }
            length = buf.readUInt32BE(6); // 获取八个字节中的后四个字节用于表示数据长度,即从第6到第10个字节,为真实存放的数据长度
            idx += 8;
        }
        let realData = null; // 保存真实数据对应字符串形式
        if (MASK) { // 如果存在MASK掩码,表示是客户端发送过来的数据,是加密过的数据,需要进行数据解码
            const maskDataBuffer = buf.slice(idx, idx + 4); //获取掩码数据, 其中前四个字节为掩码数据
            idx += 4; //指针前移到真实数据段
            const realDataBuffer = buf.slice(idx, idx + length); // 获取真实数据对应的Buffer
            realData = unmask(maskDataBuffer, realDataBuffer); //解码真实数据
            console.log(`realData is ${realData}`);
        }
        let realDataBuffer = Buffer.from(realData); // 将真实数据转换为Buffer
        this.buffer = buf.slice(idx + length); // 清除已处理的buffer数据
        if (FIN) { // 如果第一个字节的第一位为1,表示是消息的最后一个分片,即全部消息结束了(发送的数据比较少,一次发送完成)
            this.handleRealData(opcode, realDataBuffer); // 处理操作码
        }
    }
    // 处理客户端发送过来的真实数据
    handleRealData(opcode, realDataBuffer) {
        switch (opcode) {
            case OPCODES.TEXT:
                this.emit('data', realDataBuffer.toString('utf8')); //Buffer.toString()默认utf8 这里是故意指示的
                break;
            case OPCODES.BINARY: //二进制文件直接交付
                this.emit('data', realDataBuffer);
                break;
            default:
                this.close(1002, 'unhandle opcode:' + opcode);
        }
    }
    // 根据发送数据的类型设置上对应的操作码,将数据转换为Buffer形式
    send(data) {
        let opcode;
        let buffer;
        if (Buffer.isBuffer(data)) { // 如果是二进制数据
            opcode = OPCODES.BINARY; // 操作码设置为二进制类型
            buffer = data;
        } else if (typeof data === "string") { // 如果是字符串
            opcode = OPCODES.TEXT; // 操作码设置为文本类型
            buffer = Buffer.from(data, 'utf8'); // 将字符串转换为Buffer数据
        } else {
            throw new Error('cannot send object.Must be string of Buffer');
        }
        this.doSend(opcode, buffer);
    }
    // 开始发送数据
    doSend(opcode, buffer) {
        this.socket.write(encodeMessage(opcode, buffer)); //编码后直接通过socket发送
    }
}
module.exports = WebSocket;
阅读 3.3k

前海拾贝
徜徉前端的海洋,拾取晶莹的贝壳。

前端工程师

2.5k 声望
3.7k 粉丝
0 条评论

前端工程师

2.5k 声望
3.7k 粉丝
文章目录
宣传栏