HTTP基础知识 - Webscoket

Websocket API

WebSocket 对象提供了一组 API,用于创建和管理 WebSocket 连接,以及通过连接发送和接收数据。

浏览器提供的WebSocket API很简洁,调用示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var ws = new WebSocket('wss://example.com/socket'); // 创建安全WebSocket 连接(wss)

ws.onerror = function (error) { ... } // 错误处理
ws.onclose = function () { ... } // 关闭时调用

ws.onopen = function () { // 连接建立时调用
ws.send("Connection established. Hello server!"); // 向服务端发送消息
}

ws.onmessage = function(msg) { // 接收服务端发送的消息
if(msg.data instanceof Blob) { // 处理二进制信息
processBlob(msg.data);
} else {
processText(msg.data); // 处理文本信息
}
}

其中, onmessage 就是对接收到消息的处理, 数据格式按照和服务端约定的格式解析即可. 使用 ws.send 即可向服务端发送数据

Websocket提供的信道是全双工的

  1. 在同一个TCP 连接上,可以 双向 传输文本信息和二进制数据,通过数据帧中的一位(bit)来区分二进制或者文本。
  2. WebSocket 只提供了最基础的文本和二进制数据传输功能,如果需要传输其他类型的数据,就需要通过额外的机制进行协商。

send()方法是异步的,数据会在客户端排队函数立即返回成功

  1. 在传输大文件时,不要因为回调已经执行,就错误地以为数据已经发送出去了,数据很可能还在排队。要监控在浏览器中排队的数据量,可以 查询套接字的bufferedAmount 属性

    1
    2
    3
    4
    5
    6
    7
    8
    var ws = new WebSocket('wss://example.com/socket');

    ws.onopen = function () {
    subscribeToApplicationUpdates(function(evt) {
    if (ws.bufferedAmount == 0)
    ws.send(evt.data);
    });
    };
  2. 大量排队的消息,甚至一个大消息,都可能导致排在它后面的 消息延迟 ——队首阻塞!为解决这个问题,应用可以 ·将大消息切分成小块· ,通过 监控bufferedAmount 的值来避免队首阻塞。甚至还可以实现自己的优先队列,而不是盲目都把它们送到套接字上排队。要实现最优化传输,应用必须关心任意时刻在套接字上排队的是什么消息!

子协议协商

在以往使用HTTP 或XHR 协议来传输数据时,它们可以通过每次请求和响应的HTTP 首部来沟通元数据,以进一步确定传输的数据格式,而 WebSocket 并没有提供等价的机制。上文已经提到WebSocket只提供最基础的文本和二进制数据传输,对消息的具体内容格式是未知的。因此,如果WebSocket需要沟通关于消息的元数据,客户端和服务器必须达成沟通这一 数据的子协议 ,进而间接地实现其他格式数据的传输。下面是一些可能策略的介绍:

  • 客户端和服务器可以提前 确定一种固定的消息格式 ,比如所有通信都通过 JSON编码的消息或者某种自定义的二进制格式进行,而必要的元数据作为这种数据结构的一个部分

  • 如果客户端和服务器要发送不同的数据类型,那它们可以确定一个双方都知道的 消息首部 ,利用它来沟通说明信息或有关净荷的其他解码信息

  • 混合使用文本和二进制消息可以沟通 净荷和元数据 ,比如用文本消息实现 HTTP 首部的功能,后跟包含应用净荷的二进制消息

上面介绍了一些可能的策略来实现其他格式数据的传输,确定了消息的串行格式化,但怎么确保客户端和服务端是按照约定发送和处理数据,这个约定客户端和服务端是如何协商的呢?这就需要WebSocket 提供一个机制来协商,这时WebSocket构造器方法的第二个可选参数就派上用场了,通过这个参数客户端和服务端就可以根据约定好的方式处理发送及接收到的数据。

1
2
3
4
WebSocket WebSocket(
in DOMString url, // 表示要连接的URL。这个URL应该为响应WebSocket的地址。
in optional DOMString protocols // 可以是一个单个的协议名字字符串或者包含多个协议名字字符串的数组。默认设为一个空字符串。
);

通过上述WebSocket构造器方法的第二个参数,客户端可以在 初次连接握手 时,可以告知服务器自己支持哪种协议。如下所示:

1
2
3
4
5
6
7
8
9
var ws = new WebSocket('wss://example.com/socket',['appProtocol', 'appProtocol-v2']);

ws.onopen = function () {
if (ws.protocol == 'appProtocol-v2') {
...
} else {
...
}
}

如上所示,WebSocket 构造函数接受了一个可选的子协议名字的数组,通过这个数组,客户端可以向服务器通告自己能够理解或希望服务器接受的协议。当服务器接收到该请求后,会根据自身的支持情况,返回相应信息。

  • 有支持的协议,则子协议协商成功,触发客户端的onopen回调,应用可以查询 WebSocket 对象上的 protocol 属性 ,从而得知服务器选定的协议

  • 没有支持的协议,则协商失败,触发 onerror 回调 ,连接断开。

数据成帧

帧:最小的通信单位,包含可变长度的帧首部和净荷部分,净荷可能包含完整或部分应用消息。
消息:一系列帧,与应用消息对等。

WebSocket 使用了自定义的二进制分帧格式,把每个应用消息切分成一或多个帧,发送到目的地之后再组装起来,等到接收到完整的消息后再通知接收端。基本的成帧协议定义了帧类型有操作码、有效载荷的长度,指定位置的Extension data和Application data,统称为Payload data,保留了一些特殊位和操作码供后期扩展。在打开握手完成后,终端发送一个关闭帧之前的任何时间里,数据帧可能由客户端或服务器的任何一方发送。具体的帧格式如下所示:

WEBSOCKET数据帧

标识位 大小 作用
FIN 1 bit 表示此帧是否是消息的最后帧,第一帧也可能是最后帧。
RSV1 1 bit 必须是0,除非协商了扩展定义了非0的意义。
RSV2 1 bit 必须是0,除非协商了扩展定义了非0的意义。
RSV3 1 bit 必须是0,除非协商了扩展定义了非0的意义。
opcode 4 bit 表示被传输帧的类型:
x0 表示一个后续帧
x1 表示一个文本帧
x2 表示一个二进制帧
x3-7 为以后的非控制帧保留
x8 表示一个连接关闭
x9 表示一个ping
xA 表示一个pong
xB-F 为以后的控制帧保留。
Mask 1bit 表示净荷是否有掩码(只适用于客户端发送给服务器的消息)。
Payload length 7 bit, 7 + 16 bit, 7 + 64 bit 荷长度由可变长度字段表示
如果是 0~125,就是净荷长度
如果是 126,则接下来 2 字节表示的 16 位无符号整数才是这一帧的长度
如果是 127,则接下来 8 字节表示的 64 位无符号整数才是这一帧的长度
Masking-key 0或4 Byte 用于给净荷加掩护,客户端到服务器标记。
Extension data x Byte。默认为0 Byte,除非协商了扩展。
Application data y Byte 在”Extension data”之后,占据了帧的剩余部分。
Payload data (x + y) Byte ”extension data” 后接 “application data”。

分帧规则

那么客户端和服务端是按照什么规则进行分帧的呢? RFC 6455 规定的分帧规则如下:

  1. 一个未分帧的消息包含单个帧,FIN设置为1,opcode非0。

  2. 一个分帧了的消息包含,

    • 开始于:单个帧,FIN设为0,opcode非0;
    • 后接 :0个或多个帧,FIN设为0,opcode设为0;
    • 终结于:单个帧,FIN设为1,opcode设为0。
    • 一个分帧了消息在概念上等价于一个未分帧的大消息,它的有效载荷长度等于所有帧的有效载荷长度的累加;然而,有扩展时,这可能不成立,因为扩展定义了出现的Extension data的解释。例如,Extension data可能只出现在第一帧,并用于后续的所有帧,或者Extension data出现于所有帧,且只应用于特定的那个帧。
    • 在缺少Extension data时,下面的示例示范了分帧如何工作。举例:如一个文本消息作为三个帧发送,第一帧的opcode是0x1,FIN是0,第二帧的opcode是0x0,FIN是0,第三帧的opcode是0x0,FIN是1。
  3. 控制帧可能被插入到分帧了消息中,控制帧必须不能被分帧。如果控制帧不能插入,例如,如果是在一个大消息后面,ping的延迟将会很长。因此要求处理消息帧中间的控制帧。

  4. 消息的帧必须以发送者发送的顺序传递给接受者。

  5. 一个消息的帧必须不能交叉在其他帧的消息中,除非有扩展能够解释交叉。

  6. 一个终端必须能够处理消息帧中间的控制帧。

  7. 一个发送者可能对任意大小的非控制消息分帧。

  8. 客户端和服务器必须支持接收分帧和未分帧的消息。

  9. 由于控制帧不能分帧,中间设施必须不尝试改变控制帧。

  10. 中间设施必须不修改消息的帧,如果保留位的值已经被使用,且中间设施不明白这些值的含义。

在遵循了上述分帧规则之后,一个消息的所有帧属于同样的类型,由第一个帧的opcdoe指定。由于控制帧不能分帧,消息的所有帧的类型要么是文本、二进制数据或保留的操作码中的一个。

虽然客户端和服务端都遵循同样的分帧规则,但也是有些差异的。在客户端往服务端发送数据时,为防止客户端中运行的恶意脚本对不支持 WebSocket 的中间设备进行 缓存投毒攻击(cache poisoning attack) ,发送帧的净荷都要使用帧首部中指定的值加掩码。被标记的帧必须设置MASK域为1,Masking-key必须完整包含在帧里,它用于标记Payload data。Masking-key是由客户端随机选择的32位值,标记键应该是 不可预测 的,给定帧的Masking-key必须不能简单到服务器或代理可以预测Masking-key是用于一序列帧的,不可预测的Masking-key是阻止恶意应用的作者从wire上获取数据的关键。由于客户端发送到服务端的信息需要进行掩码处理,所以客户端发送数据的分帧开销要大于服务端发送数据的开销,服务端的分帧开销是 2~10 Byte ,客户端是则是 6~14 Byte

控制帧

控制帧用来交流WebSocket的状态,能够插入到消息的多个帧的中间。所有的控制帧必须有一个小于等于125字节的有效载荷长度,必须不能被分帧。

控制帧由 操作码标识 ,操作码的最高位是 1 。当前为控制帧定义的操作码有

  • 0x8(关闭)
  • 0x9(Ping)
  • 0xA(Pong)
  • 操作码0xB-0xF是保留的,未定义。
  1. 关闭:操作码为0x8。关闭帧可能包含一个主体(帧的应用数据部分)指明关闭的原因,如终端关闭,终端接收到的帧太大,或终端接收到的帧不符合终端的预期格式。从客户端发送到服务器的关闭帧必须标记,在发送关闭帧后,应用程序必须不再发送任何数据。如果终端接收到一个关闭帧,且先前没有发送关闭帧,终端必须发送一个关闭帧作为响应。终端可能延迟发送关闭帧,直到它的当前消息发送完成。在发送和接收到关闭消息后,终端认为WebSocket连接已关闭,必须关闭底层的TCP连接。服务器必须立即关闭底层的TCP连接;客户端应该等待服务器关闭连接,但并非必须等到接收关闭消息后才关闭,如果它在合理的时间间隔内没有收到反馈,也可以将TCP关闭。如果客户端和服务器同时发送关闭消息,两端都已发送和接收到关闭消息,应该认为WebSocket连接已关闭,并关闭底层TCP连接。

  2. Ping:操作码为0x9。一个Ping帧可能包含应用程序数据。当接收到Ping帧,终端必须发送一个Pong帧响应,除非它已经接收到一个关闭帧。它应该尽快返回Pong帧作为响应。终端可能在连接建立后、关闭前的任意时间内发送Ping帧。注意:Ping帧可作为 keepalive作为验证远程终端 是否可响应的手段。

  3. Pong:操作码为0xA。Pong 帧必须包含与被响应Ping帧的应用程序数据完全相同的数据。如果终端接收到一个Ping 帧,且还没有对之前的Ping帧发送Pong 响应,终端可能选择发送一个Pong 帧给最近处理的Ping帧。一个Pong 帧可能被主动发送,这作为单向心跳。对主动发送的Pong 帧的响应是不希望的。

数据帧

数据帧携带需要发送的目标数据,由操作码标识,操作码的最高位是0。操作码决定了数据的解释.

当前为数据帧定义的 :

  • 0x01(文本)
  • 0x2(二进制)
  • 操作码0x3-0x7为以后的非控制帧保留,未定义。
  1. 文本:操作码为0x1。有效载荷数据是UTF-8编码的文本数据。特定的文本帧可能包含部分的UTF-8 序列,然而,整个消息必须包含有效的UTF-8,当终端以UTF-8解释字节流时发现字节流不是一个合法的UTF-8流,那么终端将关闭连接。

  2. 二进制:操作码为0x2。有效载荷数据是任意的二进制数据,它的解释由应用程序层唯一决定。

数据帧

WebSocket 规范允许对协议进行扩展,可以使用这些预留位在基本的WebSocket 分帧层之上实现更多的功能。

下面是负责制定 WebSocket 规范的 HyBi Working Group 进行的两项扩展:

  • 多路复用扩展(A Multiplexing Extension for WebSockets) :这个扩展可以将WebSocket 的逻辑连接独立出来,实现共享底层的TCP 连接。每个WebSocket 连接都需要一个专门的TCP 连接,这样效率很低。多路复用扩展解决了这个问题。它使用“信道ID”扩展每个WebSocket 帧,从而实现多个虚拟的WebSocket 信道共享一个TCP 连接。

  • 压缩扩展(Compression Extensions for WebSocket) :给WebSocket 协议增加了压缩功能。基本的WebSocket 规范没有压缩数据的机制或建议,每个帧中的净荷就是应用提供的净荷。虽然这对优化的二进制数据结构不是问题,但除非应用实现自己的压缩和解压缩逻辑,否则很多情况下都会造成传输载荷过大的问题。实际上,压缩扩展就相当于HTTP 的传输编码协商。

要使用扩展,客户端必须在第一次的Upgrade 握手中通知服务器,服务器必须选择并确认要在商定连接中使用的扩展。

升级协商

在协商过程中,用到的一些头域如下:

头部 含义
Sec-WebSocket-Version 客户端发送,表示它想使用的WebSocket 协议版本(13表示RFC 6455)。如果服务器不支持这个版本,必须回应自己支持的版本
Sec-WebSocket-Key 客户端发送,自动生成的一个键,作为一个对服务器的“挑战”,以验证服务器支持请求的协议版本
Sec-WebSocket-Accept 服务器响应,包含Sec-WebSocket-Key 的签名值,证明它支持请求的协议版本
Sec-WebSocket-Protocol 用于协商应用子协议:客户端发送支持的协议列表,服务器必须只回应一个协议名
Sec-WebSocket-Extensions 用于协商本次连接要使用的WebSocket 扩展:客户端发送支持的扩展,服务器通过返回相同的首部确认自己支持一或多个扩展。

在进行HTTP Upgrade之前,客户端会根据给定的URI、子协议、扩展和在浏览器情况下的origin,先打开一个TCP连接,随后再发起升级协商。升级协商具体如下:

1
2
3
4
5
6
7
8
9
GET /socket HTTP/1.1 // 请求的方法必须是GET,HTTP版本必须至少是1.1
Host: thirdparty.com
Origin: Example Domain
Connection: Upgrade
Upgrade: websocket // 请求升级到WebSocket 协议
Sec-WebSocket-Version: 13 // 客户端使用的WebSocket 协议版本
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== // 自动生成的键,以验证服务器对协议的支持,其值必须是nonce组成的随机选择的16字节的被base64编码后的值
Sec-WebSocket-Protocol: appProtocol, appProtocol-v2 // 可选的应用指定的子协议列表
Sec-WebSocket-Extensions: x-webkit-deflate-message, x-custom-extension // 可选的客户端支持的协议扩展列表,指示了客户端希望使用的协议级别的扩展

Nonce : 在安全工程中,Nonce是一个在加密通信只能使用一次的数字。在认证协议中,它往往是一个随机或伪随机数,以避免重放攻击。Nonce也用于流密码以确保安全。如果需要使用相同的密钥加密一个以上的消息,就需要Nonce来确保不同的消息与该密钥加密的密钥流不同。

与浏览器中客户端发起的任何连接一样,WebSocket 请求也必须遵守同源策略:浏览器会自动在升级握手请求中追加Origin 首部,远程服务器可能使用CORS 判断接受或拒绝跨源请求。要完成握手,服务器必须返回一个成功的“Switching Protocols”(切换协议)响应,具体如下:

1
2
3
4
5
6
7
HTTP/1.1 101 Switching Protocols // 101 响应码确认升级到WebSocket 协议
Upgrade: websocket
Connection: Upgrade
Access-Control-Allow-Origin: Example Domain // CORS 首部表示选择同意跨源连接
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= // 签名的键值验证协议支持
Sec-WebSocket-Protocol: appProtocol-v2 // 服务器选择的应用子协议
Sec-WebSocket-Extensions: x-custom-extension // 服务器选择的WebSocket 扩展

所有兼容RFC 6455 的WebSocket 服务器都使用相同的算法计算客户端挑战的答案:将Sec-WebSocket-Key 的内容与标准定义的唯一GUID 字符串拼接起来,计算出SHA1 散列值,结果是一个base-64 编码的字符串,把这个字符串发给客户端即可。

Sec-WebSocket-Accept 这个头域的 ABNF [RFC2616]定义如下:

1
2
3
4
5
6
7
Sec-WebSocket-Accept = base64-value-non-empty
  base64-value-non-empty = (1*base64-data [ base64-padding ]) |
  base64-padding
  base64-data = 4base64-character
  base64-padding = (2base64-character "==") |
  (3base64-character "=")
  base64-character = ALPHA | DIGIT | "+" | "/"

成功的WebSocket 握手必须是客户端发送协议版本和自动生成的挑战值,服务器返回101 HTTP 响应码(Switching Protocols)和散列形式的挑战答案,确认选择的协议版本。

如果客户端发送的key值为:”dGhlIHNhbXBsZSBub25jZQ==”,服务端将把”258EAFA5-E914-47DA-95CA-C5AB0DC85B11” 这个唯一的GUID与它拼接起来,就是”dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CAC5AB0DC85B11”,然后对其进行SHA-1哈希,结果为”0xb3 0x7a 0x4f 0x2c 0xc0 0x62 0x4f 0x16 0x90 0xf6 0x46 0x06 0xcf 0x38 0x59 0x45 0xb2 0xbe 0xc4 0xea”,再进行base64-encoded即可得”s3pPLMBiTxaQ9kYGzzhZRbK+xOo=”。

一旦客户端打开握手发送出去,在发送任何数据之前,客户端必须等待服务器的响应。客户端必须按如下步骤验证响应:

  1. 如果从服务器接收到的状态码不是 101 ,按HTTP 【RFC2616】 程序处理响应。在特殊情况下,如果客户端接收到401状态码,可能执行认证;服务器可能用3xx状态码重定向客户端(但不要求客户端遵循他们)。否则按下面处理。

  2. 如果响应 缺失Upgrade头域 或 Upgrade头域的值没有包含 大小写不敏感的ASCII 值”websocket” ,客户端必须使WebSocket连接失败。

  3. 如果响应 缺失Connection头域 或其值不包含 大小写不敏感的ASCII值”Upgrade” ,客户端必须使WebSocket连接失败。

  4. 如果响应 缺失Sec-WebSocket-Accept头域 或其值 不包含 Sec-WebSocket-Key (作为字符串,非base64解码的)+ “258EAFA5-E914-47DA-95CA-C5AB0DC85B11” 的base64编码 SHA-1值,客户端必须使WebSocket连接失败。

  5. 如果响应 包含Sec-WebSocket-Extensions头域 ,且其值指示使用的扩展 不出现 在客户端发送的握手(服务器指示的扩展不是客户端要求的),客户端必须使WebSocket连接失败。

  6. 如果响应包含Sec-WebSocket-Protocol头域 ,且这个头域指示使用的子协议 不包含 在客户端的握手(服务器指示的子协议不是客户端要求的),客户端必须使WebSocket连接失败。

如果客户端完成了对服务端响应的升级协商验证,该连接就可以用作双向通信信道交换WebSocket 消息。从此以后,客户端与服务器之间不会再发生HTTP 通信,一切由 WebSocket 协议接管。


HTTP基础知识 - Webscoket
http://www.zhangdeman.cn/archives/ef4c647.html
作者
白茶清欢
发布于
2021年10月14日
许可协议