半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。HTTP协议这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。我们只能使用轮询:每隔一段时候,就发出一个询问,了解服务器有没有新的信息。轮询的效率低,非常浪费资源。WebSocket就可以解决这些问题。
WebSocket是HTML5新增的协议,目的是在浏览器和服务器间建立一个不受限的全双工通信的通道。这就使得浏览器具备了实时双向通信的能力。
其特点包括:
(1)建立在 TCP 协议之上,服务器端的实现比较容易。
(2)与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。
(3)数据格式比较轻量,性能开销小,通信高效。
(4)可以发送文本,也可以发送二进制数据。
(5)没有同源限制,客户端可以与任意服务器通信。
(6)协议标识符是ws
(如果加密,则为wss
)。地址比如ws://example.com:80/some/path
协议分为两部分:“握手” 和 “数据传输”。
握手部分的设计目的就是兼容现有的基于 HTTP 的服务端组件(web 服务器软件)或者中间件(代理服务器软件)。这样一个端口就可以同时接受普通的 HTTP 请求或则 WebSocket 请求了。为了这个目的,WebSocket 客户端的握手是一个 HTTP 升级版的请求(HTTP Upgrade request)。
所以,WebSocket连接必须由客户端发起,因为握手协议是一个标准的HTTP Upgrade请求。客户端发出的握手信息类似如下:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
WebSocket的发起握手内容包括了 HTTP 升级请求和一些必选以及可选的头字段。握手的细节如下:
重点请求首部意义如下:
Connection: Upgrade
:表示要升级协议Upgrade: websocket
:表示要升级到 websocket 协议。Sec-WebSocket-Version: 13
:表示 websocket 的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Version
header,里面包含服务端支持的版本号。Sec-WebSocket-Key
:与后面服务端响应首部的Sec-WebSocket-Accept
是配套的,提供基本的防护,比如恶意的连接,或者无意的连接。Sec-WebSocket-Protocol
: 它可以指出让服务端选择使用哪些协议。客户端需要验证服务端选择的子协议,是否是其当初的握手请求中的 Sec-WebSocket-Protocol
中的一个。注意,上面的请求示例省略了部分非重点请求首部。由于是标准的 HTTP 请求,类似 Host、Origin、Cookie 等请求首部会照常发送。在握手阶段,可以通过相关请求首部进行 安全限制、权限校验等。
服务端回应的握手信息类似如下:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
服务端返回内容如下,状态代码101
表示协议切换。任何其他的非 101
表示 WebSocket 握手还没有结束,客户端需要使用原有的 HTTP 的方式去响应那些状态码。到此完成协议升级,后续的数据交互都按照新的协议来。
客户端的握手请求由 请求行 (Request-Line) 开始。客户端的回应由 状态行 (Status-Line) 开始。首行之后的部分,都是没有顺序要求的 HTTP Headers。其中的一些 HTTP头 的意思稍后将会介绍,不过也可包括例子中没有提及的头信息,比如 Cookies 信息
服务端为了告知客户端它已经接收到了客户端的握手请求,服务端需要返回一个包含Sec-WebSocket-Accept
的握手响应。这个值的信息来自于客户端的握手请求中的 Sec-WebSocket-Key
头字段:
也就是说,服务端返回的 Header 字段 Sec-WebSocket-Accept
是根据客户端请求 Header 中的Sec-WebSocket-Key
计算出来。
计算公式为:
Sec-WebSocket-Key
跟该固定字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11
拼接。Sec-WebSocket-Key/Sec-WebSocket-Accept
在主要作用在于提供基础的防护,减少恶意连接、意外连接。作用大致归纳如下:
强调:Sec-WebSocket-Key/Sec-WebSocket-Accept 的换算,只能带来基本的保障,但连接是否安全、数据是否安全、客户端 / 服务端是否合法的 ws 客户端、ws 服务端,其实并没有实际性的保证。
握手完成之后,双方传输数据的协议格式如下:
FIN: 1 bit
标记这个帧是不是消息中的最后一帧。第一个帧也可是最后一帧。
RSV1, RSV2, RSV3: 1 bit each
必须是0,除非有扩展赋予了这些位非0值的意义。
Opcode: 4 bits
定义了如何解释 “有效负荷数据 Payload data”。如果接收到一个未知的操作码,接收端必须标记 WebSocket 为失败。定义了如下的操作码:
%x0
表示这是一个继续帧(continuation frame)%x1
表示这是一个文本帧 (text frame)%x2
表示这是一个二进制帧 (binary frame)%x3-7
为将来的非控制帧(non-control frame)而保留的%x8
表示这是一个连接关闭帧 (connection close)%x9
表示这是一个 ping 帧%xA
表示这是一个 pong 帧%xB-F
为将来的控制帧(control frame)而保留的Mask: 1 bit
表示是否要对数据载荷进行掩码操作。所有的由客户端发往服务端的帧都必须设置为 1。如果被设置为 1,那么在 Masking-Key 部分将有一个掩码key,服务端需要使用它将 “有效载荷数据” 进行反掩码操作。从客户端向服务端发送数据时,需要对数据进行掩码操作;从服务端向客户端发送数据时,不需要对数据进行掩码操作。如果服务端接收到的数据没有进行过掩码操作,服务端需要断开连接。
如果 Mask 是 1,那么在 Masking-key 中会定义一个掩码键(masking key),并用这个掩码键来对数据载荷进行反掩码。所有客户端发送到服务端的数据帧,Mask 都是 1。
Payload length: 7 bits, 7+16 bits, or 7+64 bits
Masking-Key: 1 bit
所有从客户端传送到服务端的数据帧,数据载荷都进行了掩码操作,Mask 为 1,且携带了 4 字节的 Masking-key。如果 Mask 为 0,则没有 Masking-key。
掩码键(Masking-key)是由客户端挑选出来的 32 位的随机数。掩码操作不会影响数据载荷的长度。掩码、反掩码操作都采用如下算法:
首先,假设:
original-octet-i
:为原始数据的第 i 字节。transformed-octet-i
:为转换后的数据的第 i 字节。j
:为i mod 4
的结果。masking-key-octet-j
:为 mask key 第 j 字节。则生成方式是通过原始数据的第 i 字节 (original-octet-i)与Masking-Key中的第 j 个字节 (masking-key-octet-j) 进行异或(XOR)操作:
j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j
在WebSocket 协议中,数据掩码的作用是增强协议的安全性。但数据掩码并不是为了保护数据本身,因为算法本身是公开的,运算也不复杂。除了加密通道本身,似乎没有太多有效的保护通信安全的办法。
那么为什么还要引入掩码计算呢,除了增加计算机器的运算量外似乎并没有太多的收益(这也是不少同学疑惑的点)。
答案还是两个字:安全。但并不是为了防止数据泄密,而是**为了防止早期版本的协议中存在的代理缓存污染攻击(proxy cache poisoning attacks)**等问题。关于代理缓存污染攻击的原理可以参考WebSocket协议深入探究。
数据分片的目的就是允许发送那些在发送时不知道其缓冲的长度的消息。如果消息不能被碎片化,那么一端就必须将消息整个地载入内存缓冲,这样在发送消息前才可以计算出消息的字节长度。有了碎片化的机制,服务端或者中间件就可以选取其适用的内存缓冲长度,然后当缓冲满了之后就发送一个消息碎片。
碎片机制带来的另一个好处就是可以方便实现多路复用。没有多路复用的话,就需要将一整个大的消息放在一个逻辑通道中发送,这样会占用整个输出通道。多路复用需要可以将消息分割成小的碎片,使这些小的碎片可以共享输出通道。(注意多路复用的扩展在这片文档中并没有进行描述)
WebSocket 的每条消息可能被切分成多个数据帧。当 WebSocket 的接收方收到一个数据帧时,会根据FIN
的值来判断,是否已经收到消息的最后一个数据帧。
FIN=1 表示当前数据帧为消息的最后一个数据帧,此时接收方已经收到完整的消息,可以对消息进行处理。FIN=0 则接收方还需要继续监听接收其余的数据帧。
此外,opcode
在数据交换的场景下,表示的是数据的类型。0x01
表示文本,0x02
表示二进制。而0x00
比较特殊,表示延续帧(continuation frame),顾名思义,就是完整消息对应的数据帧还没接收完。下面的例子演示了碎片化是如何工作的。
例子:第一条消息
FIN=1, 表示是当前消息的最后一个数据帧。服务端收到当前数据帧后,可以处理消息。opcode=0x1,表示客户端发送的是文本类型。
例子:第二条消息
- FIN=0,opcode=0x1,表示发送的是文本类型,且消息还没发送完成,还有后续的数据帧。
- FIN=0,opcode=0x0,表示消息还没发送完成,还有后续的数据帧,当前的数据帧需要接在上一条数据帧之后。
- FIN=1,opcode=0x0,表示消息已经发送完成,没有后续的数据帧,当前的数据帧需要接在上一条数据帧之后。服务端可以将关联的数据帧组装成完整的消息。
WebSocket协议为了保持客户端、服务端的实时双向通信,需要确保客户端、服务端之间的 TCP 通道保持连接没有断开。然而,对于长时间没有数据往来的连接,如果依旧长时间保持着,可能会浪费包括的连接资源。
但不排除有些场景,客户端、服务端虽然长时间没有数据往来,但仍需要保持连接。这个时候,可以采用WebSocket数据帧的心跳字段来实现。
ping、pong 的操作,对应的是 WebSocket 的两个控制帧,Opcode
分别是0x9
、0xA
。
WebSocket 协议的设计理念就是提供极小的帧结构(帧结构存在的目的就是使得协议是基于帧的,而不是基于流的,同时帧可以区分 Unicode 文本和二进制的数据)。它期望可以在应用层中使得元数据可以被放置到 WebSocket 层上,也就是说,给应用层提供一个将数据直接放在 TCP 层上的机会,再简单的说就可以给浏览器脚本提供一个使用受限的 Raw TCP 的机会。
从概念上来说,WebSocket 只是一个建立于 TCP 之上的层,它提供了下面的功能:
从概念上将,就只有上述的几个用处。不过 WebSocket 可以很好的和 HTTP 协议一同协作,并且可以充分的利用现有的 web 基础设施,比如代理。WebSocket 的目的就是让简单的事情变得更加的简单。
协议被设计成可扩展的,将来的版本中将很可能会添加关于多路复用的概念。(也就是说目前的WebSocket协议还未支持多路复用)
WebSocket 是一个独立的基于 TCP 的协议,它与 HTTP 之间的唯一关系就是它的握手请求可以作为一个升级请求(Upgrade request)经由 HTTP 服务器解释(也就是可以使用 Nginx 反向代理一个 WebSocket)。
默认情况下,WebSocket 协议使用 80 端口作为一般请求的端口,端口 443 作为基于传输加密层连接(TLS)的端口。
下面列出的协议缺点仅供大家参考,可能不完全正确。暂时记下两点供大家讨论,互相学习。