WebSocket - 初入探究 与 实现

前言

本文为初入研究 Websocket协议,对于真正应用中,各种语言都有实现库,建议采用库,而不是自己实现,本文基于node.js,但其他语言都适用

本文主要简述:

  • Websocket相关知识
  • Websocket的协议概要
  • HTTP 与 Websocket 混合服务器简易demo

为什么要实现一个 HTTP 与 Websocket 混合服务器呢?
因为有个朋友 之前被 他的技术总监 蠢哭了...
他的技术总监让他用php连接其他websocket服务器,原因是:不能在js出现websocket的地址....那时我正在看 RFC6455,于是我就开玩笑的说,实现一个 HTTP+Websocket 服务器吧😏

于是,我就顺便写了一个这样的一个demo(顺便实现了聊天室),代码量也从原来简易50行,变成了150行,这个demo纯娱乐学习,实际工作中,最好不要这样写,除非你经过深思熟虑,下面demo中,存在的一些问题,因为是简易实现demo的原因,就不进行更多的扩展了

结果截图

WebSocket相关知识

WebSocket 简述####

WebSocket 是基于TCP的一个双向传输数据的协议,和HTTP协议一样,是在应用层的.他的出现,是为了解决网页进行 持久双向传输数据 的问题

WebSocket 与 HTTP的关系 与 TCP链接的关系

其实WebSocket 和 HTTP 实际上都是一个TCP链接, WebSocket协议和HTTP协议的作用就是 规定他们用TCP对话的规矩
可以查看 RFC6455 文档,来看version:13具体的协议
HTTP1.1可以查看RFC 2616

WebSocket协议的请求(握手),是和HTTP兼容的,可以理解成是一种"升级",但应答规则不一样了

Websocket协议概要

WebSocket 基本步骤如下:

  1. 先进行TCP连接
  2. 客户端发送握手
  3. 服务器响应握手
  4. 握手完毕后,可以相互传输数据
  5. 连接结束,发送关闭控制帧并断开TCP

TCP连接

这部分就不详细讲了(TCP连接细节也不细说了),每个语言的都差不多
服务端:监听TCP端口(本文监听80)
客户端:发起TCP连接

由于本文采用浏览器+服务器的形式,所以是由浏览器发起的连接

握手协议

在TCP连接连接后,客户端发送握手(一段字符串,每个句末为\r\n换行):

//来自 客户端的握手,注释内容,实际上不会出现,
GET /chat HTTP/1.1  //必须,一定要是GET请求,HTTP协议一定要为1.1以上
Host: server.example.com //必须(不知有何实际作用,标示入口,但可伪造)
Upgrade: websocket //必须,值为大小写不敏感的websocket
Connection: Upgrade //必须,值为大小写不敏感的Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== //必须,客户端定义的值,经过了base64编码,用于验证握手
Origin: http://example.com //必须,浏览器都会附上,可伪造
Sec-WebSocket-Protocol: chat, superchat //可选,希望采用的交流协议,按优先级排序
Sec-WebSocket-Version: 13 //必须,websocket的协议版本
Sec-WebSocket-Extensions:x-webkit-deflate-frame //可选,希望采用的扩展协议

由于客户端的握手,是和HTTP的请求是兼容的,所以也适应HTTP请求中的规矩

服务端收到来客户端的握手后,应该响应握手(字符串,每个句末为\r\n换行):

HTTP/1.1 101 Switching Protocols  //必须,只能返回101,否则出错
Upgrade: websocket //必须,值为"websocket"
Connection: Upgrade //必须,值为"Upgrade"
Sec-WebSocket-Accept:s3pPLMBiTxaQ9kYGzzhZRbK+xOo= //必须,客户端请求中的 Sec-WebSocket-Key的值+258EAFA5-E914-47DA-95CA-C5AB0DC85B11,再进行base64
Sec-WebSocket-Protocol: chat, superchat //可选,采用的交流协议
Sec-WebSocket-Extensions:x-webkit-deflate-frame //可选,采用的扩展协议

经过接受客户端的握手,并返回服务端的握手(握手没有错误),则建立了websocket连接了

数据传输

互相传输时的数据,并不能直接发送,需要按照一定的格式
每次传输数据,都需要构建帧,并整帧传输(注意,整个帧将转成2进制数据,经过tcp连接传输到另外一端)

帧结构

帧组成: FIN + RSV1 + RSV2 + RSV3 + Opcode + Mask + Payload length + Masking-key + Extension data + Application data

翻译过来就是:
是否结束+三个扩展码(RSV1-3)+操作码+是否有掩码+数据长度+掩码(可有可无)+扩展段(可有可无)+数据

所以,你需要在你发送的数据前面添加以上数据
注意,服务端发送的帧不能有掩码,否则应该报错

  • FIN: 1bit ,表示是否最后一帧,用于分片
  • RSV1-3: 各1bit,在扩展协议中指定,否则无效
  • Opcode:4bit,用来说明这个帧是干什么的用,按照该值区分控制帧和数据帧
  • Mask:1bit,表示是否有掩码
  • Payload length:7bit 或者 7+16bit 或者 7+64bit,用于表示数据长度(这个长度是转化为二进制数据的长度),这个长度有点复杂:
    • 当 要发送的数据的长度 小于126时 占用 7bit,7bit中直接填充数据长度
    • 当长度少于65536(2^16)时,占用7+16bit,前7bit为126(0x7E,无掩码时),后面16bit为实际长度
    • 当长度大于等于65536时,占用7+64bit,前7bit为127(0x7F,无掩码时),后面64bit为实际长度
  • Masking-key:0或32bit,当前面的MASK表示有掩码时,就会加入4个字节的掩码,掩码由发送方定义,用于加密Application data
  • Extension data: 0bit 或 自定义字节数,扩展协议自己定义的数据,如果没有使用扩展协议,或者扩展协议没有定义,则为0bit
  • Application data: 要发送的信息,如果Mask为1则内容需要Masking-key来进行掩码处理

Opcode中的值代表着这个帧的作用(0-7:数据帧 8-F:控制帧)

  • 0:后续帧,分片时用到
  • 1:文本帧,说明发过来的数据是文本
  • 2:二进制帧,说明发过来的数据是二进制
  • 3-7:保留的数据帧,暂时无作用
  • 8:关闭帧,说明对面要关闭连接了
  • 9:ping帧,对方ping过来,你就要pong回去→_→
  • A:pong帧,对方ping过来时,需要返回pong帧回去,以示响应
  • B-F:保留的控制帧,暂时无作用

帧的结构,就是如上了,当要发送数据的时候,按照以上格式,发送即可

掩码加密与解密

当发送的数据需要掩码加密(解密也一样)的时候,一共有4字节位掩码,规则如下

第i个数据(Application data) 需要和 第 i%4 个掩码做 异或运算,即

//原始数据
var data = new Buffer("我是demo")
//四个字节的掩码
var mask = [0x24,0x48,0xad,0x54]
for(var i = 0;i<data.length;i++){
  data[i] = data[i] ^ mask[i%4];
}
//data即可变成 加密或解密后的数据
数据分片

当你没法一个帧就把想要的数据发送完毕,你可以选择数据分片,注意,控制帧不能分片(数据长度也不得超过125)

方法如下 (当只有份2片时,只执行1,3):

  1. 发送第一个帧 FIN 为0,Opcode 为响应的数据类型
  2. 发送其他分片帧 FIN 为0,Opcode 为0
  3. 发送最后一个帧 FIN 为1,Opcode 为0

只有FINOpcode需要变化,其他的该怎么写,还是怎么写

关闭帧
  • 当接收到关闭帧这个控制帧后,应该 尽快吧没有发送完毕的数据发送完(例如分片),然后再响应一个关闭帧.
  • 关闭帧内可能会有数据,可以用来说明关闭的理由等等,但是没有规定是人类可读语言,所以不一定是字符串
ping帧
  • 当接收到ping帧的时候,应该返回一个pong帧,而且,ping帧可能带有数据,那么pong帧也需要带上ping过来的数据并返回

数据传输的基本注意事项,就以上了

连接结束####

当连接不需要继续存在时,就可以结束了
基本流程是:

  1. 一端发送一个 关闭帧
  2. 另外一端再响应一个关闭帧
  3. 断开TCP

完成这三步即可,但是,存在特殊情况

  • 有一端的程序关闭了,TCP连接直接关闭,并没有发送 关闭帧
  • 有一端的程序发送关闭帧以后,马上断开了TCP,另外一端发送关闭帧的时候,报错了

我就被以上的坑坑过,所以要注意一下,当TCP连接出错时,直接当成已经关闭即可
如果 浏览器发送关闭帧,服务器没有响应的话,大概会在30-60秒左右会断开TCP,所以不需要怕发了关闭帧缺没有断开TCP(但如果是自己实现的客户端就要注意了!!!)

经过 TCP连接握手协议数据传输连接结束 就基本走完一个websocket流程了

HTTP 与 Websocket 混合服务器简易demo

看完以上,就基本知道websocket的基本原理了,下面这个demo 是可选看的,但看完以后,或许会加深一点理解,本来想一块一块说明,但是发现很多余,所以直接贴代码了,并补回了一点注释

var i = 0;

//聊天室相关语句
var websocket_pool = new Set();
var history = [];

//工具包函数集合
var Websocket_Http_Util = {
    //用来解析头部的函数
    getHeaders: function (headerString) {
        var header_arr = headerString.split("\r\n");
        var headers = {};
        for (var i in header_arr) {
            var tmp = header_arr[i].split(":");
            if (tmp.length < 2) continue;
            headers[tmp[0].trim()] = tmp[1].trim();
        }

        //这部分是不标准的
        var first_line = header_arr[0].split(" ");
        headers.method = first_line[0];
        headers.path = first_line[1];
        return headers;
    },
    //用来把想要发送的数据打包成 数据帧的函数
    packMessage: function (message) {
        var message_len = Buffer.byteLength(message);
        var len = message_len > 65535 ? 10 : (message_len > 125 ? 4 : 2);
        var buf = new Buffer(message_len + len);
        buf[0] = 0x81;
        if (len == 2) {
            buf[1] = message_len;
        } else if (len == 4) {
            buf[1] = 126;
            buf.writeUInt16BE(message_len, 2);
        } else {
            buf[1] = 127;
            buf.writeUInt32BE(message_len >>> 32, 2);
            buf.writeUInt32BE(message_len & 0xFFFFFFFF, 6);
        }
        buf.write(message, len);
        return buf;
    }
};

//创建TCP服务器
var http_websocket = require('net').createServer(socket => {
    //当有客户端连接成功时,则会执行本函数

    //当TCP连接接收到消息时,运行一次下面的函数
    socket.once("data", data => {
        //解析头部,这里没有做验证函数,
        var headers = Websocket_Http_Util.getHeaders(data.toString());

        //这里的验证是否websocket也采用粗略验证,这个是不正规的!
        if (headers["Sec-WebSocket-Key"]) {
            //websocket
            var name = 'socke[' + (i++) + ']';  //用来标记连接名字,无实际作用
            var tmpData = new Buffer(0);        //分片数据集合
            var tmpType = null;                 //储存分片数据的类型
            //给websocket时间做得触发器,会出发data:接受到消息 时间,和 end:关闭连接 事件
            var websocketEmitter = new (require('events').EventEmitter)();

            //聊天室相关语句
            websocket_pool.add(socket);

            //握手实现,这里取最简单的
            socket.write(
                "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept:" +
                require('crypto').createHash('sha1').update(headers["Sec-WebSocket-Key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest('base64') +
                "\r\n\r\n"
            );

            //接受包
            socket.on("data", data => {
                var index = 2;
                var isFinish = data[0] >>> 7 == 1;
                var opcode = data[0] & 15;
                var len = data[1] & 127;

                //分析长度
                if (len == 126) {
                    len = data.readUIntBE(index, index += 2);
                } else if (len == 127) {
                    len = data.readUIntBE(index, index += 8);
                }

                //掩码解析并解码
                var mask = (data[1] >>> 7 > 0) ? data.slice(index, index += 4) : null;
                if (mask) {
                    for (var i = index; i < data.length; i++) {
                        data[i] = mask[(i - index) % 4] ^ data[i];
                    }
                }

                //截获消息
                data = data.slice(index);
                if (isFinish) {
                    //消息响应
                    if (opcode == 8) {
                        //关闭帧响应
                        socket.write(new Buffer([0x88, 0x00]));
                        socket.end();
                        websocketEmitter.emit("end");
                    } else if (opcode == 9) {
                        //ping帧响应
                        console.log("接受ping,来自:" + name);
                        socket.write(Buffer.concat([new Buffer([0x8A, data.length]), data]));
                    } else if (opcode == 0 || opcode == 1 || opcode == 2) {
                        //数据响应
                        websocketEmitter.emit("data", Buffer.concat([tmpData, data]));
                        tmpData = new Buffer(0);
                        tmpType = null;
                    }
                } else {
                    //当分片时,纪录缓存数据
                    tmpData = Buffer.concat([tmpData, data]);
                    if (tmpType === null) tmpType = opcode;
                }
            })

            websocketEmitter.on("data", data => {
                //接受信息
                console.log(name, data.toString());

                //聊天室相关语句
                var message = Websocket_Http_Util.packMessage(name + ":" + data);
                for(var s of websocket_pool){
                    s.write(message);
                }
                history.push(name + ":" + data.toString());
                if(history.length > 100) history.shift();
            });
            websocketEmitter.once("end", () => {
                //连接断开(绑定一次)
                console.log(name, "断开连接");
                //聊天室相关语句
                websocket_pool.delete(socket)
            });
            socket.on("end", ()=> {
                console.log("socket断开连接");
                websocketEmitter.emit("end");
            });
            socket.on("error", (err) => {
                websocketEmitter.emit("end");
            })
        } else {
            //HTTP这块做得非常粗略
            //直接返回了内容,没有判断他的头部+请求内容
            //这里就可以去写更多的代码,去实现好HTTP的实际服务器
            console.log("网页访问");
            socket.write("HTTP/1.1 200 OK\r\nserver: Meislzhua\r\n\r\n");
            socket.write("you get path in:" + headers.path);
            for(var message of history){
                socket.write(message+"<br>\r\n");
            }
            socket.end();
        }
    });
});
//绑定在80端口
http_websocket.listen(80);

浏览器端,做得好丑,只为了实现功能

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>socket-test</title>
    <script src="//cdn.bootcss.com/jquery/2.2.4/jquery.min.js"></script>

    <style>
        #message-box {
            max-height: 300px;
            overflow: auto;
        }
    </style>
    <script>
        var addMessage = function (message) {
            $("#message-box .content").append(message + "<br>")
        };

        var ws = new WebSocket("ws://localhost");
        ws.onopen = function () {
            addMessage("连接服务器成功!")
        };
        ws.onclose = function () {
            addMessage("与服务器断开连接")
        };
        ws.onerror = function(evt) {
            addMessage("出错:");
            addMessage(JSON.stringify(evt));
        };
        ws.onmessage = function(message){
            console.log(message);
            $("#response-box .content").append(message.data + "<br>")
        }
    </script>
</head>

<body>
<input type="text" id="send-text">
<button id="commit" onclick="ws.send($('#send-text').val());$('#send-text').val('')">提交</button>
<div id="message-box">
    <div class="content"></div>
</div>
<div id="response-box">
    <div class="content"></div>
</div>
</body>
</html>

推荐阅读更多精彩内容