背景
前段时间做了个websocket的应用,基于刚开始工作那会儿对websocket的有限了解,认为websocket在握手、协议升级完成后,就直接利用http所使用的TCP连接来进行全双工通信。也就是说,把它理解成了个 类传输层协议。
公司内网简单的搜了搜关于websocket文章(实际上讲的更多的是如何在公司的运维平台上跑、如何穿透网关、如何选择开发框架等技巧),也没搜太多资料就开干了。
一天工夫搞定代码,其中大半天时间都在手撸TCP分包逻辑。完事后简单梳理下代码,发现所使用的开发组件,在message响应中,居然可以区分收到的数据是字符串还是二进制类型。瞬间就有点懵逼了,这框架是咋知道数据类型的呢?看来事情有哪里不太对。
于是翻开了websocket的RFC文档,才发现这东西其实是个应用层协议,那大半天的分包逻辑其实是不用写的。我去,一口老血喷出来啊。所以起了心思在此记录一下。
借用阮一峰老师的一张图,比较形象。
错误的理解:websocket协议通过http协议发起连接,然后协议升级后去掉http,用tcp来传输数据,是类传输层协议。
正确的理解:websocket协议通过http协议发起连接,然后协议升级将http替换为websocket,用websocket数据帧传输数据,是应用层协议。
生命周期
参考rfc文档画的一个简单的生命周期,其中比较有意思的是,协议中提到了关闭连接时,最好由服务端发起TCP关闭流程,这样能更快的建立重连。由客户端发起的话,需要等待2MSL周期才能再次建立连接。
下面分别整理一下握手阶段、数据帧传输阶段的知识点。
握手阶段
握手阶段,对客户端和服务端都有一些要求,分别分析。
客户端
客户端发送握手请求时,有如下要求
要求 | 描述 | 可选 |
---|---|---|
http协议 | http协议必须大于等于1.1版本,必须是get | 必须 |
Header Host | 必须有一个Host header | 必须 |
Header Upgrade | 必须有一个Upgrade header,它的值必须包含websocket | 必须 |
Header Connection | 必须有一个Connection header,它的值必须包含Upgrade | 必须 |
Header Sec-WebSocket-Key | 必须有一个Sec-WebSocket-Key header,它是一个16字节的随机值,base64编码的字符串 | 必须 |
Header Sec-WebSocket-Version | 必须有一个Sec-WebSocket-Version header,值必须为13,代表ws协议的版本号 | 必选 |
Header Origin | 从浏览器中发起的ws连接,必须携带此header,值是源地址ascci序列化后的结果。非浏览器来源则可能有可能没有 | 可选 |
Header Sec-WebSocket-Protocol | 可能有Sec-WebSocket-Protocol header,标明子协议,字符的范围是U+0021到U+007E,但是不包含其中的定义在RFC2616中的分隔符 | 可选 |
Header Sec-WebSocket-Extensions | 可能有Sec-WebSocket-Extensions header,表示客户端期望使用的协议级别的扩展 | 可选 |
其它Header | 可能还会包含其他的文档中定义的header字段,如cookie(RFC6265)或者认证相关的header字段如Authorization字段(RFC2616) | 可选 |
客户端处理握手请求响应的时候,有如下要求
要求 | 描述 | 可选 |
---|---|---|
握手返回码 | 返回码必须是101 Switching Protocols,若不是101则按对应返回码含义处理会话。 | 必须 |
Header Upgrade | 响应中必须包含Upgrade header,且其值包含websocket,否则连接失败 | 必须 |
Header Connection | 必须有一个Connection header,它的值必须包含Upgrade | 必须 |
Header Sec-WebSocket-Accept | 必须有一个Sec-WebSocket-Accept header 注① | 必须 |
Header Sec-WebSocket-Extensions | 可能有一个Sec-WebSocket-Extensions header,如果有,且request Sec-WebSocket-Extensions header中没有此header的值,则握手连接失败 | 可选 |
Header Sec-WebSocket-Protocol | 可能有一个Sec-WebSocket-Protocol header,如果有,且request Sec-WebSocket-Protocol header中没有它的值,则握手连接失败 | 可选 |
注①
Sec-WebSocket-Accept的计算:Sec-WebSocket-Accept = base64(sha1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
将握手请求header中的Sec-WebSocket-Key和字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼起来,计算sha1,以base64编码输出,即为Sec-WebSocket-Accept的值。
Sec-WebSocket-Accept的作用:
提供基础的防护,减少恶意连接、意外连接。
- http客户端误请求,或者某些http的扫描器连接到ws协议端口,服务端可以直接拒绝。
- 客户端确保服务端理解实现的是websocket协议,若服务端实现的是http协议但是也返回了应答,则可以拒绝并断开。
- 防止代理缓存。例如反向代理缓存了ws协议路径的缓存,后面的ws握手直接给返回缓存内容,无意义。
服务端
服务端处理握手请求时,有如下要求,如不满足要求则返回错误码,400 bad request
要求 | 描述 | 可选 |
---|---|---|
http协议 | http协议必须大于等于1.1版本,必须是get | 必须 |
Header Host | 请求host | 必选 |
Header Upgrade | Upgrade header,必须含websocket | 必须 |
Header Connection | Connection header 必须含Upgrade | 必须 |
Header Sec-WebSocket-Key | Sec-WebSocket-Key header,16字节,参见握手请求部分 | 必须 |
Header Sec-WebSocket-Version | Sec-WebSocket-Version header,必须为13 | 必须 |
Header Origin | 请求来源 | 可选 |
Header Sec-WebSocket-Protocol | Sec-WebSocket-Protocol header,子协议 | 可选 |
Header Sec-WebSocket-Extensions | Sec-WebSocket-Extensions header,协议扩展 | 可选 |
其它Header | 如cookie(RFC6265)或者认证相关的header字段如Authorization字段(RFC2616) | 可选 |
若握手请求符合要求,则构造应答请求
要求 | 描述 | 可选 |
---|---|---|
协议连接 | 握手成功后则建立TCP或者TLS连接 | 必须 |
认证 | 可以用cookie或者authorization头进行认证,返回401等返回码 | 可选 |
重定向 | 返回3xx等 | 可选 |
构造返回信息 | 构造含origin 、key 、version、resource、subprotocol、extensions的响应内容,详见4.2.2 | 必须 |
构造同意连接响应 | 返回响应码101、Upgrade头、Connection头、按客户端说明中注①的方式生成Sec-WebSocket-Accept头 | 必须 |
数据帧传输
websocket协议提供了一套应用层数据编解码方法,这样就不需要业务自己去分包,处理粘包问题了。
数据帧详情见rfc第5节,这里对其数据帧编码及传输控制,逐个详细理解一下。
一个websocket数据传输帧的完整编码结构如下:
FIN
是否尾帧,占1bit。值取0和1,0表示非尾帧,1表示尾帧。
这个字段非常重要,在一个较大应用层数据分多帧传输时,可以用来标记何时收到尾帧,配合后面的payload length完成分包逻辑。
RSV1、RSV2、RSV3
协议扩展字段,各占1bit。如果连接没有协议扩展定义这三个标记位,那么它们必须都为0。若无扩展定义且非0,则应该断开连接。
opcode
操作码码,占4bit。操作码分为数据类型标记和控制码,含义如下
值 | 类型 | 含义 |
---|---|---|
0 | 类型标记 | 持续帧,表示此帧的payload数据应该接续在前面到达的帧payload后面,与FIN配合来拼接得到完整应用数据 |
1 | 类型标记 | 文本帧,表示这帧的payload是一个文本,注意此文本需要用UTF-8来解析,如果按UTF-8解析失败,则无论是客户端还是服务端,都应该立即断开连接 |
2 | 类型标记 | 二进制帧 |
3-7 | 类型标记 | 预留给以后的其它非控制帧 |
8 | 控制码 | 连接关闭 |
9 | 控制码 | ping包 注② |
10 | 控制码 | pong包 |
11-15 | 控制码 | 预留给以后其它的控制帧 |
若操作码使用了未定义的,则应该断开连接。
注②
ping包和pong包是协议内设计的心跳包,可以随时发送而不扰乱分片payload组装的过程。一端在收到ping控制指令后,应该立即回一个pong指令。
这个设计在协议连接穿透网关时比较有用,有的网关可能有持续多久无数据则断掉连接的设计,此时若想做连接保活,就不用构造业务心跳包来保活了,直接用ping包即可。
MASK
是否使用掩码,占1bit。表示是否使用掩码,0为不使用,1为使用。
如果设置为1,那么Masking-key中将存储有随机生成的掩码,协议规定从客户端发送到服务端的数据必须使用掩码。其目的是为了安全,防止“代理缓存污染攻击”,其攻击原理这里不深究,可以自行搜索。
Payload len
载荷字节长度,占7bit,或者7+16bit,或者7+64bit。这个字段也非常重要,用来处理最基本的分包问题,此字段决定了底层的TCP/TLS连接在二进制收包后,如何分离出一个个的完整websocket frame。进一步,配合FIN包可以分离出完整的应用层message。
前7bit值 | 后续16bit值 | 后续64bit值 | 含义 |
---|---|---|---|
0~125 | 无 | 无 | 长度为0~125字节的载荷 |
126 | 解析为uint16 | 无 | 表示长度为126 ~ 65535字节的载荷 |
127 | 无 | 解析为uint64 | 表示长度为65536 ~ uint64最大值字节的载荷 |
Masking-key
掩码,占0~4字节。若前面的mask设置的是1,则掩码占4字节。客户端向服务端发送数据的时候必须添加掩码。
掩码算法:
以字节为单位,假设原始payload中一个字节的数据为original-octet-i
,转换后的字节数据为transformed-octet-i
,掩码字节索引为index
,最终使用的掩码为mask,那么
- 计算最终使用的掩码索引
index = original-octet-i MOD 4
- 取出Masking-key在index位置的字节作为掩码
mask = Masking-key[index]
- 将原始数据与mask做异或运算,得到转换后的数据
transformed-octet-i = original-octet-i XOR mask
此算法是可逆的,由transformed-octet-i得到original-octet-i,重复上面的步骤即可。
Payload Data
Payload数据,占Payload len长度。
值得注意的是,若握手的过程中定义了协议扩展,那么扩展的数据也必须包含在Payload len长度中,即Payload Data = Extension Data + Applicatino Data。
定义扩展的时候,需要扩展自己定义如何计算长度,例如取前Payload Data4个字节作为扩展数据长度等。
若未定义协议扩展,则Extension Data的长度为0,Payload Data全部是Applicatino Data。
本文链接:https://www.zoucz.com/blog/2020/12/13/8731c3a0-3d22-11eb-90b5-eb40e9720ed0/