背景

前段时间做了个websocket的应用,基于刚开始工作那会儿对websocket的有限了解,认为websocket在握手、协议升级完成后,就直接利用http所使用的TCP连接来进行全双工通信。也就是说,把它理解成了个 类传输层协议

公司内网简单的搜了搜关于websocket文章(实际上讲的更多的是如何在公司的运维平台上跑、如何穿透网关、如何选择开发框架等技巧),也没搜太多资料就开干了。

一天工夫搞定代码,其中大半天时间都在手撸TCP分包逻辑。完事后简单梳理下代码,发现所使用的开发组件,在message响应中,居然可以区分收到的数据是字符串还是二进制类型。瞬间就有点懵逼了,这框架是咋知道数据类型的呢?看来事情有哪里不太对。

于是翻开了websocket的RFC文档,才发现这东西其实是个应用层协议,那大半天的分包逻辑其实是不用写的。我去,一口老血喷出来啊。所以起了心思在此记录一下。

借用阮一峰老师的一张图,比较形象。
image.png

错误的理解:websocket协议通过http协议发起连接,然后协议升级后去掉http,用tcp来传输数据,是类传输层协议。

正确的理解:websocket协议通过http协议发起连接,然后协议升级将http替换为websocket,用websocket数据帧传输数据,是应用层协议。

WebSocket协议RFC文档

生命周期

image.png
参考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的作用:
提供基础的防护,减少恶意连接、意外连接。

  1. http客户端误请求,或者某些http的扫描器连接到ws协议端口,服务端可以直接拒绝。
  2. 客户端确保服务端理解实现的是websocket协议,若服务端实现的是http协议但是也返回了应答,则可以拒绝并断开。
  3. 防止代理缓存。例如反向代理缓存了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数据传输帧的完整编码结构如下:

image.png

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,那么

  1. 计算最终使用的掩码索引index = original-octet-i MOD 4
  2. 取出Masking-key在index位置的字节作为掩码mask = Masking-key[index]
  3. 将原始数据与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。

☞ 参与评论