WebSocketFrameEncoder&WebSocketFrameDecoder源码浅析

学习链接

WebSocket协议:5分钟从入门到精通

WebSocket 数据帧WebSocket编解码器

吃透Netty源码系列四十六之WebSocket预备知识

吃透Netty源码系列四十七之WebSocketServerProtocolHandler详解

吃透Netty源码系列四十八之WebSocket编解码器详解一

吃透Netty源码系列四十九之WebSocket编解码器详解二

文章目录

WebSocket协议

在WebSocket API尚未被众多浏览器实现和发布的时期,开发者在开发需要接收来自服务器的实时通知应用程序时,不得不求助于一些"hacks"来模拟实时连接以实现实时通信,最流行的一种方式是长轮询 。 长轮询主要是发出一个HTTP请求到服务器,然后保持连接打开以允许服务器在稍后的时间响应(由服务器确定)。为了这个连接有效地工作,许多技术需要被用于确保消息不错过,如需要在服务器端缓存和记录多个的连接信息(每个客户)。虽然长轮询是可以解决这一问题的,但它会耗费更多的资源,如CPU、内存和带宽等,要想很好的解决实时通信问题就需要设计和发布一种新的协议。

WebSocket 是伴随HTML5发布的一种新协议。它实现了浏览器与服务器全双工通信(full-duplex),可以传输基于消息的文本和二进制数据。WebSocket 是浏览器中最靠近套接字的API,除最初建立连接时需要借助于现有的HTTP协议,其他时候直接基于TCP完成通信。它是浏览器中最通用、最灵活的一个传输机制,其极简的API 可以让我们在客户端和服务器之间以数据流的形式实现各种应用数据交换(包括JSON 及自定义的二进制消息格式),而且两端都可以随时向另一端发送数据。在这个简单的API 之后隐藏了很多的复杂性,而且还提供了更多服务,如:

  • 连接协商和同源策略;
  • 与既有 HTTP 基础设施的互操作;
  • 基于消息的通信和高效消息分帧;
  • 子协议协商及可扩展能力。

所幸,浏览器替我们完成了上述工作,我们只需要简单的调用即可。任何事物都不是完美的,设计限制和性能权衡始终会有,利用WebSocket 也不例外,在提供自定义数据交换协议同时,也不再享有在一些本由浏览器提供的服务和优化,如状态管理、压缩、缓存等。

随着HTML5的发布,越来越多的浏览器开始支持WebSocket,如果你的应用还在使用长轮询,那就可以考虑切换了。

1.WebSocket API

WebSocket 对象提供了一组 API,用于创建和管理 WebSocket 连接,以及通过连接发送和接收数据。浏览器提供的WebSocket API很简洁,调用示例如下:

js 复制代码
ar 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);                        // 处理文本信息
    }
}
1.1.接收和发送数据

WebSocket提供了极简的API,开发者可以轻松的调用,浏览器会为我们完成缓冲、解析、重建接收到的数据等工作。应用只需监听onmessage事件,用回调处理返回数据即可。 WebSocket支持文本和二进制数据传输,浏览器如果接收到文本数据,会将其转换为DOMString 对象,如果是二进制数据或Blob 对象,可直接将其转交给应用或将其转化为ArrayBuffer,由应用对其进行进一步处理。从内部看,协议只关注消息的两个信息:净荷长度和类型(前者是一个可变长度字段),据以区别UTF-8 数据和二进制数据。示例如下:

js 复制代码
var wss = new WebSocket('wss://example.com/socket');
ws.binaryType = "arraybuffer";

// 接收数据
wss.onmessage = function(msg) {
    if (msg.data instanceof ArrayBuffer) {
        processArrayBuffer(msg.data);
    } else {
        processText(msg.data);
    }
}

// 发送数据
ws.onopen = function() {
    socket.send("Hello server!");
    socket.send(JSON.stringify({
        'msg': 'payload'
    }));

    var buffer = new ArrayBuffer(128);
    socket.send(buffer);

    var intview = new Uint32Array(buffer);
    socket.send(intview);

    var blob = new Blob([buffer]);
    socket.send(blob);
}

Blob 对象是包含有只读原始数据的类文件对象,可存储二进制数据,它会被写入磁盘;ArrayBuffer (缓冲数组)是一种用于呈现通用、固定长度的二进制数据的类型,作为内存区域可以存放多种类型的数据。

对于将要传输的二进制数据,开发者可以决定以何种方式处理,可以更好的处理数据流,Blob 对象一般用来表示一个不可变文件对象或原始数据,如果你不需要修改它或者不需要把它切分成更小的块,那这种格式是理想的;如果你还需要再处理接收到的二进制数据,那么选择ArrayBuffer 应该更合适。

WebSocket 提供的信道是全双工的,在同一个TCP 连接上,可以双向传输文本信息和二进制数据,通过数据帧中的一位(bit)来区分二进制或者文本。WebSocket 只提供了最基础的文本和二进制数据传输功能,如果需要传输其他类型的数据,就需要通过额外的机制进行协商。WebSocket 中的send( ) 方法是异步的:提供的数据会在客户端排队,而函数则立即返回。在传输大文件时,不要因为回调已经执行,就错误地以为数据已经发送出去了,数据很可能还在排队。要监控在浏览器中排队的数据量,可以查询套接字的bufferedAmount 属性:

js 复制代码
var ws = new WebSocket('wss://example.com/socket');

ws.onopen = function() {
    subscribeToApplicationUpdates(function(evt) {
        if (ws.bufferedAmount == 0)
            ws.send(evt.data);
    });
};

前面的例子是向服务器发送应用数据,所有WebSocket 消息都会按照它们在客户端排队的次序逐个发送。因此,大量排队的消息,甚至一个大消息,都可能导致排在它后面的消息延迟------队首阻塞!为解决这个问题,应用可以将大消息切分成小块,通过监控bufferedAmount 的值来避免队首阻塞。甚至还可以实现自己的优先队列,而不是盲目都把它们送到套接字上排队。要实现最优化传输,应用必须关心任意时刻在套接字上排队的是什么消息!

1.2.子协议协商

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

  • 客户端和服务器可以提前确定一种固定的消息格式,比如所有通信都通过 JSON编码的消息或者某种自定义的二进制格式进行,而必要的元数据作为这种数据结构的一个部分;
  • 如果客户端和服务器要发送不同的数据类型,那它们可以确定一个双方都知道的消息首部,利用它来沟通说明信息或有关净荷的其他解码信息;
  • 混合使用文本和二进制消息可以沟通净荷和元数据,比如用文本消息实现 HTTP首部的功能,后跟包含应用净荷的二进制消息。

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

WebSocket构造器方法如下所示:

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

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

js 复制代码
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 回调,连接断开。
1.3.WS与WSS

WebSocket 资源URI采用了自定义模式:ws 表示纯文本通信( 如ws://http://example.com/socket),wss 表示使用加密信道通信(TCP+TLS)。为什么不使用http而要自定义呢?

WebSocket 的主要目的,是在浏览器中的应用与服务器之间提供优化的、双向通信机制。可是,WebSocket 的连接协议也可以用于浏览器之外的场景,可以通过非HTTP协商机制交换数据。考虑到这一点,HyBi Working Group 就选择采用了自定义的URI模式:

  • ws协议:普通请求,占用与http相同的80端口;
  • wss协议:基于SSL的安全传输,占用与tls相同的443端口。

各自的URI如下:

js 复制代码
ws-URI = "ws:" "//" host [ ":" port ] path [ "?" query ]
wss-URI = "wss:" "//" host [ ":" port ] path [ "?" query ]

很多现有的HTTP 中间设备可能不理解新的WebSocket 协议,而这可能导致各种问题:盲目的连接升级、意外缓冲WebSocket 帧、不明就里地修改内容、把WebSocket 流量误当作不完整的HTTP 通信,等等。这时WSS就提供了一种不错的解决方案,它建立一条端到端的安全通道,这个端到端的加密隧道对中间设备模糊了数据,因此中间设备就不能再感知到数据内容,也就无法再对请求做特殊处理。

2. WebSocket协议

HyBi Working Group 制定的WebSocket 通信协议(RFC 6455)包含两个高层组件:开放性HTTP 握手用于协商连接参数,二进制消息分帧机制用于支持低开销的基于消息的文本和二进制数据传输。WebSocket 协议尝试在既有HTTP 基础设施中实现双向HTTP 通信,因此也使用HTTP 的80 和443 端口。不过,这个设计不限于通过HTTP 实现WebSocket 通信,未来的实现可以在某个专用端口上使用更简单的握手,而不必重新定义一个协议。WebSocket 协议是一个独立完善的协议,可以在浏览器之外实现。不过,它的主要应用目标还是实现浏览器应用的双向通信。

2.1.数据成帧

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

java 复制代码
      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-------+-+-------------+-------------------------------+
     |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
     |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
     |N|V|V|V|       |S|             |   (if payload len==126/127)   |
     | |1|2|3|       |K|             |                               |
     +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
     |     Extended payload length continued, if payload len == 127  |
     + - - - - - - - - - - - - - - - +-------------------------------+
     |                               |Masking-key, if MASK set to 1  |
     +-------------------------------+-------------------------------+
     | Masking-key (continued)       |          Payload Data         |
     +-------------------------------- - - - - - - - - - - - - - - - +
     :                     Payload Data continued ...                :
     + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
     |                     Payload Data continued ...                |
     +---------------------------------------------------------------+
  • FIN: 1 bit 。表示此帧是否是消息的最后帧,第一帧也可能是最后帧。
  • RSV1,RSV2,RSV3: 各1 bit 。必须是0,除非协商了扩展定义了非0的意义。
  • opcode:4 bit。表示被传输帧的类型:x0 表示一个后续帧;x1 表示一个文本帧;x2 表示一个二进制帧;x3-7 为以后的非控制帧保留;x8 表示一个连接关闭;x9 表示一个ping;xA 表示一个pong;xB-F 为以后的控制帧保留。
  • Mask: 1 bit。表示净荷是否有掩码(只适用于客户端发送给服务器的消息)。
  • 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"。

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

消息:一系列帧,与应用消息对等。

是否把消息分帧由客户端和服务器实现决定,应用并不需要关注WebSocket帧和如何分帧,因为客户端(如浏览器)和服务端为完成该工作。那么客户端和服务端是按照什么规则进行分帧的呢?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。

控制帧

控制帧由操作码标识,操作码的最高位是1。当前为控制帧定义的操作码有0x8(关闭)、0x9(Ping)和0xA(Pong),操作码0xB-0xF是保留的,未定义。控制帧用来交流WebSocket的状态,能够插入到消息的多个帧的中间。所有的控制帧必须有一个小于等于125字节的有效载荷长度,必须不能被分帧。

  • 关闭:操作码为0x8。关闭帧可能包含一个主体(帧的应用数据部分)指明关闭的原因,如终端关闭,终端接收到的帧太大,或终端接收到的帧不符合终端的预期格式。从客户端发送到服务器的关闭帧必须标记,在发送关闭帧后,应用程序必须不再发送任何数据。如果终端接收到一个关闭帧,且先前没有发送关闭帧,终端必须发送一个关闭帧作为响应。终端可能延迟发送关闭帧,直到它的当前消息发送完成。在发送和接收到关闭消息后,终端认为WebSocket连接已关闭,必须关闭底层的TCP连接。服务器必须立即关闭底层的TCP连接;客户端应该等待服务器关闭连接,但并非必须等到接收关闭消息后才关闭,如果它在合理的时间间隔内没有收到反馈,也可以将TCP关闭。如果客户端和服务器同时发送关闭消息,两端都已发送和接收到关闭消息,应该认为WebSocket连接已关闭,并关闭底层TCP连接。
  • Ping:操作码为0x9。一个Ping帧可能包含应用程序数据。当接收到Ping帧,终端必须发送一个Pong帧响应,除非它已经接收到一个关闭帧。它应该尽快返回Pong帧作为响应。终端可能在连接建立后、关闭前的任意时间内发送Ping帧。注意:Ping帧可作为keepalive或作为验证远程终端是否可响应的手段。
  • Pong:操作码为0xA。Pong 帧必须包含与被响应Ping帧的应用程序数据完全相同的数据。如果终端接收到一个Ping 帧,且还没有对之前的Ping帧发送Pong 响应,终端可能选择发送一个Pong 帧给最近处理的Ping帧。一个Pong 帧可能被主动发送,这作为单向心跳。对主动发送的Pong 帧的响应是不希望的。
数据帧

数据帧携带需要发送的目标数据,由操作码标识,操作码的最高位是0。当前为数据帧定义的(文本),0x2(二进制),操作码0x3-0x7为以后的非控制帧保留,未定义。

操作码决定了数据的解释:

  • 文本:操作码为0x1。有效载荷数据是UTF-8编码的文本数据。特定的文本帧可能包含部分的UTF-8 序列,然而,整个消息必须包含有效的UTF-8,当终端以UTF-8解释字节流时发现字节流不是一个合法的UTF-8流,那么终端将关闭连接。
  • 二进制:操作码为0x2。有效载荷数据是任意的二进制数据,它的解释由应用程序层唯一决定。
2.2.协议扩展

从上述的数据分帧格式可以知道,有很多扩展位预留,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 握手中通知服务器,服务器必须选择并确认要在商定连接中使用的扩展。下面就是对升级协商的介绍。

2.3.升级协商

从上面的介绍可知,WebSocket具有很大的灵活性,提供了很多强大的特性:基于消息的通信、自定义的二进制分帧层、子协议协商、可选的协议扩展等等。上面也讲到,客户端和服务端需先通过HTTP方式协商适当的参数后才可建立连接,完成协商之后,所有信息的发送和接收不再和HTTP相关,全由WebSocket自身的机制处理。当然,完成最初的连接参数协商并非必须使用HTTP协议,它只是一种实现方案,可以有其他选择。但使用HTTP协议完成最初的协商,有以下好处:让WebSockets 与现有HTTP 基础设施兼容:WebSocket 服务器可以运行在80 和443 端口上,这通常是对客户端唯一开放的端口;可以重用并扩展HTTP 的Upgrade 流,为其添加自定义的WebSocket 首部,以完成协商。

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

  • 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连接,随后再发起升级协商。升级协商具体如下:

java 复制代码
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来确保不同的消息与该密钥加密的密钥流不同。

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

java 复制代码
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](https://link.zhihu.com/?target=http%3A//www.ietf.org/rfc/rfc5234.txt)定义如下:

复制代码
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 | "+" | "/"

如果客户端发送的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="。

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

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

  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 协议接管。

3.使用场景及性能

Websocket协议具有极简的API,开发者可以很简便的调用,而且提供了二进制分帧、可扩展性以及子协议协商等强大特性,使得WebSocket 成为在浏览器中采用自定义应用协议的最佳选择。但,在计算机世界里,任何技术和理论一般都是为解决特定问题而生的,并不是普世化的解决方案,WebSocket亦是如此。WebSocket 不能取代XHR 或SSE,何时以及如何使用,毋庸置疑会对性能产生巨大影响,要获得最佳性能,我们必须善于利用它的长处!下面将对现有的一些协议与WebSocket 对比进行一个大致介绍。

请求和响应流

XHR 是专门为"事务型"请求/ 响应通信而优化的:客户端向服务器发送完整的、格式良好的HTTP 请求,服务器返回完整的响应。这里不支持请求流,在Streams API 可用之前,没有可靠的跨浏览器响应流API。 SSE 可以实现服务器到客户端的高效、低延迟的文本数据流:客户端发起 SSE 连接,服务器使用事件源协议将更新流式发送给客户端。客户端在初次握手后,不能向服务器发送任何数据。 WebSocket 是唯一一个能通过同一个TCP 连接实现双向通信的机制,客户端和服务器随时可以交换数据。因此,WebSocket 在两个方向上都能保证文本和二进制应用数据的低延迟交付。

客户端到服务端传递消息的总时延由以下四个部分构成:

  • 传播延迟:消息从发送端到接收端需要的时间,是信号传播距离和速度的函数,传播时间取决于距离和信号通过的媒介,播速度通常不超过光速;
  • 传输延迟:把消息中的所有比特转移到链路中需要的时间,是消息长度和链路速率的函数,由传输链路的速率决
    定,与客户端到服务器的距离无关;
  • 处理延迟:处理分组首部、检查位错误及确定分组目标所需的时间,常由硬件完成,因此相应的延迟一般非常短;
  • 排队延迟:如果分组到达的速度超过了路由器的处理能力,那么分组就要在入站缓冲区排队,到来的分组排队等待处理的时间就是排队延迟。

无论是什么样的传输机制,都不会减少客户端与服务器间的往返次数,数据包的传播延迟都一样。但,采用不同的传输机制可以有不同的排队延迟。对XHR 轮询而言,排队延迟就是客户端轮询间隔:服务器上的消息可用之后,必须等到下一次客户端XHR 请求才能发送。相对来说,SSE 和WebSocket 使用持久连接,这样服务器(和客户端------如果是WebSocket)就可以在消息可用时立即发送它,消除了消息的排队延迟,也就使得总的传输延迟更小。

消息开销

在完成最初的升级协商之后,客户端和服务器即可通过WebSocket 协议双向交换数据,消息分帧之后每帧会添加2~14 字节的开销;SSE 会给每个 消息添加 5 字节,但仅限于 UTF-8 内容(SSE 不是为传输二进制载荷而设计的!如果有必要,可以把二进制对象编码为base64 形式,然后再使用SSE); HTTP 1.x 请求(XHR 及其他常规请求)会携带 500~800 字节的 HTTP 元数据,加上cookie; HTTP 2.0 压缩 HTTP 元数据,可以显著减少开销,如果请求都不修改首部,那么开销可以低至8 字节。WebSocket专门为双向通信而设计,开销很小,在实时通知应用开发中是不错的选择。

上述开销不包括IP、TCP 和TLS 分帧的开销,后者一共会给每个消息增加60~100 字节,无论使用的是什么应用协议。

效率及压缩

在使用HTTP协议传输数据时,每个请求都可以协商最优的传输编码格式(如对文本数据采用gzip 压缩);SSE 只能传输UTF-8 格式数据,事件流数据可以在整个会话期间使用gzip 压缩;WebSocket 可以传输文本和二进制数据,压缩整个会话行不通,二进制的净荷也可能已经压缩过了!

鉴于WebSocket的特殊性,它需要实现自己的压缩机制,并针对每个消息选择应用。HyBi 工作组正在为WebSocket 协议制定以消息为单位的压缩扩展,但这个扩展尚未得到任何浏览器支持。目前来说,除非应用通过细致优化自己的二进制净荷实现自己的压缩逻辑,同时也针对文本消息实现自己的压缩逻辑,否则传输数据过程中一定会产生很大的字节开销!

自定义应用协议

HTTP已经诞生了数十年,具有广泛的应用,各种优化专门的优化机制也已经被浏览器及服务器等设备实施,XHR 请求自然而然就继承了所有这些功能。然而,对于只使用HTTP协议完成升级协商的WebSocket来说,流式数据处理可以让我们在客户端和服务器间自定义协议,但也会错过浏览器提供的很多服务,应用可能必须实现自已的逻辑来填充某些功能空白,比如缓存、状态管理、元数据交付等等。

部署WebSocket

HTTP 是专为短时突发性传输设计的,很多服务器、代理和其他中间设备的HTTP 连接空闲超时设置都很激进。这就与WebSocket的长时连接、实时双向通信相悖,部署时需要关注下面的三个方面:

  • 位于各自网络中的路由器、负载均衡器和代理;
  • 外部网络中透明、确定的代理服务器(如 ISP 和运营商的代理);
  • 客户网络中的路由器、防火墙和代理。

鉴于用户所处的网络环境是各不相同的,不受开发者所控制。某些网络甚至会完全屏蔽WebSocket通信,有些设备也不支持WebSocket协议,这时就需要采用备用机制,使用其他技术来实现类似与WebSocket的通信(如http://socket.io等)。虽然,我们无法处理网络中的中间设备,但对于处在我们自己掌控下的基础设施还是可以做一些工作的,可以对通信路径上的每一台负载均衡器、路由器和Web 服务器针对长时连接进行调优。然而,长时连接和空闲会话会占用所有中间设备及服务器的内存和套接字资源,开销很大,部署WebSocket、SSE及HTTP 2.0等赖于长时会话的协议都会对运维提出新的挑战。在使用WebSocket的过程中,也需要做到优化二进制净荷和压缩 UTF-8 内容以最小化传输数据、监控客户端缓冲数据的量、切分应用消息避免队首阻塞、合用的情况下利用其他传输机制等。

总结

WebSocket 协议为实时双向通信而设计,提供高效、灵活的文本和二进制数据传输,同时也错过了浏览器为HTTP提供的一些服务,在使用时需要应用自己实现。在进行应用数据传输时,需要根据不同的场景选择恰当的协议,WebSocket 并不能取代HTTP、XHR 或SSE,关键还是要利用这些机制的长处以求得最佳性能。

Socket.IO

鉴于现在不同的平台及浏览器版本对WebSocket支持的不同,有开发者做了一个叫做socket.io的为实时应用提供跨平台实时通信的库,我们可以使用它完成向WebSocket的切换。socket.io旨在使实时应用在每个浏览器和移动设备上成为可能,模糊不同的传输机制之间的差异。socket.io 的名字源于它使用了浏览器支持并采用的 HTML5 WebSocket 标准,因为并不是所有的浏览器都支持 WebSocket ,所以该库支持一系列降级功能:

  • Websocket
  • Adobe: registered: Flash: registered: Socket
  • AJAX long polling
  • AJAX multipart streaming
  • Forever Iframe
  • JSONP Polling

在大部分情境下,你都能通过这些功能选择与浏览器保持类似长连接的功能。具体细节请看http://socket.io/docs/

WebSocket协议:5分钟从入门到精通

一、内容概览

WebSocket的出现,使得浏览器具备了实时双向通信的能力。本文由浅入深,介绍了WebSocket如何建立连接、交换数据的细节,以及数据帧的格式。此外,还简要介绍了针对WebSocket的安全攻击,以及协议是如何抵御类似攻击的。

二、什么是WebSocket

HTML5开始提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议。它基于TCP传输协议,并复用HTTP的握手通道。

对大部分web开发者来说,上面这段描述有点枯燥,其实只要记住几点:

  1. WebSocket可以在浏览器里使用
  2. 支持双向通信
  3. 使用很简单
1、有哪些优点

说到优点,这里的对比参照物是HTTP协议,概括地说就是:支持双向通信,更灵活,更高效,可扩展性更好。

  1. 支持双向通信,实时性更强。
  2. 更好的二进制支持。
  3. 较少的控制开销。连接创建后,ws客户端、服务端进行数据交换时,协议控制的数据包头部较小。在不包含头部的情况下,服务端到客户端的包头只有2~10字节(取决于数据包长度),客户端到服务端的的话,需要加上额外的4字节的掩码。而HTTP协议每次通信都需要携带完整的头部。
  4. 支持扩展。ws协议定义了扩展,用户可以扩展协议,或者实现自定义的子协议。(比如支持自定义压缩算法等)

对于后面两点,没有研究过WebSocket协议规范的同学可能理解起来不够直观,但不影响对WebSocket的学习和使用。

2、需要学习哪些东西

对网络应用层协议的学习来说,最重要的往往就是连接建立过程数据交换教程。当然,数据的格式是逃不掉的,因为它直接决定了协议本身的能力。好的数据格式能让协议更高效、扩展性更好。

下文主要围绕下面几点展开:

  1. 如何建立连接
  2. 如何交换数据
  3. 数据帧格式
  4. 如何维持连接

三、入门例子

在正式介绍协议细节前,先来看一个简单的例子,有个直观感受。例子包括了WebSocket服务端、WebSocket客户端(网页端)。完整代码可以在 这里 找到。

这里服务端用了ws这个库。相比大家熟悉的socket.iows实现更轻量,更适合学习的目的。

1、服务端

代码如下,监听8080端口。当有新的连接请求到达时,打印日志,同时向客户端发送消息。当收到到来自客户端的消息时,同样打印日志。

javascript 复制代码
var app = require('express')();
var server = require('http').Server(app);
var WebSocket = require('ws');

var wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', function connection(ws) {
    console.log('server: receive connection.');
    
    ws.on('message', function incoming(message) {
        console.log('server: received: %s', message);
    });

    ws.send('world');
});

app.get('/', function (req, res) {
  res.sendfile(__dirname + '/index.html');
});

app.listen(3000);
2、客户端

代码如下,向8080端口发起WebSocket连接。连接建立后,打印日志,同时向服务端发送消息。接收到来自服务端的消息后,同样打印日志。

javascript 复制代码
var ws = new WebSocket('ws://localhost:8080');
ws.onopen = function () {
    console.log('ws onopen');
    ws.send('from client: hello');
};
ws.onmessage = function (e) {
    console.log('ws onmessage');
    console.log('from server: ' + e.data);
};
3、运行结果

可分别查看服务端、客户端的日志,这里不展开。

服务端输出:

bash 复制代码
server: receive connection.
server: received hello

客户端输出:

bash 复制代码
client: ws connection is open
client: received world

四、如何建立连接

前面提到,WebSocket复用了HTTP的握手通道。具体指的是,客户端通过HTTP请求与WebSocket服务端协商升级协议。协议升级完成后,后续的数据交换则遵照WebSocket的协议。

1、客户端:申请协议升级

首先,客户端发起协议升级请求。可以看到,采用的是标准的HTTP报文格式,且只支持GET方法。

http 复制代码
GET / HTTP/1.1
Host: localhost:8080
Origin: http://127.0.0.1:3000
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: w4v7O6xFTi36lq3RNcgctw==

重点请求首部意义如下:

注意,上面请求省略了部分非重点请求首部。由于是标准的HTTP请求,类似Host、Origin、Cookie等请求首部会照常发送。在握手阶段,可以通过相关请求首部进行 安全限制、权限校验等。

2、服务端:响应协议升级

服务端返回内容如下,状态代码101表示协议切换。到此完成协议升级,后续的数据交互都按照新的协议来。

http 复制代码
HTTP/1.1 101 Switching Protocols
Connection:Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=

备注:每个header都以\r\n结尾,并且最后一行加上一个额外的空行\r\n。此外,服务端回应的HTTP状态码只能在握手阶段使用。过了握手阶段后,就只能采用特定的错误码。

3、Sec-WebSocket-Accept的计算

Sec-WebSocket-Accept根据客户端请求首部的Sec-WebSocket-Key计算出来。

计算公式为:

  1. Sec-WebSocket-Key258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
  2. 通过SHA1计算出摘要,并转成base64字符串。

伪代码如下:

javascript 复制代码
>toBase64( sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 )  )

验证下前面的返回结果:

javascript 复制代码
const crypto = require('crypto');
const magic = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const secWebSocketKey = 'w4v7O6xFTi36lq3RNcgctw==';

let secWebSocketAccept = crypto.createHash('sha1')
	.update(secWebSocketKey + magic)
	.digest('base64');

console.log(secWebSocketAccept);
// Oy4NRAQ13jhfONC7bP8dTKb4PTU=

五、数据帧格式

客户端、服务端数据的交换,离不开数据帧格式的定义。因此,在实际讲解数据交换之前,我们先来看下WebSocket的数据帧格式。

WebSocket客户端、服务端通信的最小单位是帧(frame),由1个或多个帧组成一条完整的消息(message)。

  1. 发送端:将消息切割成多个帧,并发送给服务端;
  2. 接收端:接收消息帧,并将关联的帧重新组装成完整的消息;

本节的重点,就是讲解数据帧 的格式。详细定义可参考 RFC6455 5.2节

1、数据帧格式概览

下面给出了WebSocket数据帧的统一格式。熟悉TCP/IP协议的同学对这样的图应该不陌生。

  1. 从左到右,单位是比特。比如FINRSV1各占据1比特,opcode占据4比特。

  2. 内容包括了标识、操作代码、掩码、数据、数据长度等。(下一小节会展开)

    0 1 2 3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
    +-+-+-+-+-------+-+-------------+-------------------------------+
    |F|R|R|R| opcode|M| Payload len | Extended payload length |
    |I|S|S|S| (4) |A| (7) | (16/64) |
    |N|V|V|V| |S| | (if payload len==126/127) |
    | |1|2|3| |K| | |
    +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
    | Extended payload length continued, if payload len == 127 |

                                  • +-------------------------------+
                                    | |Masking-key, if MASK set to 1 |
                                    +-------------------------------+-------------------------------+
                                    | Masking-key (continued) | Payload Data |
                                    +-------------------------------- - - - - - - - - - - - - - - - +
                                    : Payload Data continued ... :

    | Payload Data continued ... |
    +---------------------------------------------------------------+

2、数据帧格式详解

针对前面的格式概览图,这里逐个字段进行讲解,如有不清楚之处,可参考协议规范,或留言交流。

FIN:1个比特。

如果是1,表示这是消息(message)的最后一个分片(fragment),如果是0,表示不是是消息(message)的最后一个分片(fragment)。

RSV1, RSV2, RSV3:各占1个比特。

一般情况下全为0。当客户端、服务端协商采用WebSocket扩展时,这三个标志位可以非0,且值的含义由扩展进行定义。如果出现非零的值,且并没有采用WebSocket扩展,连接出错。

Opcode: 4个比特。

操作代码,Opcode的值决定了应该如何解析后续的数据载荷(data payload)。如果操作代码是不认识的,那么接收端应该断开连接(fail the connection)。可选的操作代码如下:

Mask: 1个比特。

表示是否要对数据载荷进行掩码操作。从客户端向服务端发送数据时,需要对数据进行掩码操作;从服务端向客户端发送数据时,不需要对数据进行掩码操作。

如果服务端接收到的数据没有进行过掩码操作,服务端需要断开连接。

如果Mask是1,那么在Masking-key中会定义一个掩码键(masking key),并用这个掩码键来对数据载荷进行反掩码。所有客户端发送到服务端的数据帧,Mask都是1。

掩码的算法、用途在下一小节讲解。

Payload length:数据载荷的长度,单位是字节。为7位,或7+16位,或1+64位。

假设数Payload length === x,如果

此外,如果payload length占用了多个字节的话,payload length的二进制表达采用网络序(big endian,重要的位在前)。

Masking-key:0或4字节(32位)

所有从客户端传送到服务端的数据帧,数据载荷都进行了掩码操作,Mask为1,且携带了4字节的Masking-key。如果Mask为0,则没有Masking-key。

备注:载荷数据的长度,不包括mask key的长度。

Payload data:(x+y) 字节

载荷数据:包括了扩展数据、应用数据。其中,扩展数据x字节,应用数据y字节。

扩展数据:如果没有协商使用扩展的话,扩展数据数据为0字节。所有的扩展都必须声明扩展数据的长度,或者可以如何计算出扩展数据的长度。此外,扩展如何使用必须在握手阶段就协商好。如果扩展数据存在,那么载荷数据长度必须将扩展数据的长度包含在内。

应用数据:任意的应用数据,在扩展数据之后(如果存在扩展数据),占据了数据帧剩余的位置。载荷数据长度 减去 扩展数据长度,就得到应用数据的长度。

3、掩码算法

掩码键(Masking-key)是由客户端挑选出来的32位的随机数。掩码操作不会影响数据载荷的长度。掩码、反掩码操作都采用如下算法:

首先,假设:

算法描述为: original-octet-i 与 masking-key-octet-j 异或后,得到 transformed-octet-i。

j = i MOD 4

transformed-octet-i = original-octet-i XOR masking-key-octet-j

六、数据传递

一旦WebSocket客户端、服务端建立连接后,后续的操作都是基于数据帧的传递。

WebSocket根据opcode来区分操作的类型。比如0x8表示断开连接,0x0-0x2表示数据交互。

1、数据分片

WebSocket的每条消息可能被切分成多个数据帧。当WebSocket的接收方收到一个数据帧时,会根据FIN的值来判断,是否已经收到消息的最后一个数据帧。

FIN=1表示当前数据帧为消息的最后一个数据帧,此时接收方已经收到完整的消息,可以对消息进行处理。FIN=0,则接收方还需要继续监听接收其余的数据帧。

此外,opcode在数据交换的场景下,表示的是数据的类型。0x01表示文本,0x02表示二进制。而0x00比较特殊,表示延续帧(continuation frame),顾名思义,就是完整消息对应的数据帧还没接收完。

2、数据分片例子

直接看例子更形象些。下面例子来自MDN,可以很好地演示数据的分片。客户端向服务端两次发送消息,服务端收到消息后回应客户端,这里主要看客户端往服务端发送的消息。

第一条消息

FIN=1, 表示是当前消息的最后一个数据帧。服务端收到当前数据帧后,可以处理消息。opcode=0x1,表示客户端发送的是文本类型。

第二条消息

  1. FIN=0,opcode=0x1,表示发送的是文本类型,且消息还没发送完成,还有后续的数据帧。

  2. FIN=0,opcode=0x0,表示消息还没发送完成,还有后续的数据帧,当前的数据帧需要接在上一条数据帧之后。

  3. FIN=1,opcode=0x0,表示消息已经发送完成,没有后续的数据帧,当前的数据帧需要接在上一条数据帧之后。服务端可以将关联的数据帧组装成完整的消息。

    Client: FIN=1, opcode=0x1, msg="hello"
    Server: (process complete message immediately) Hi.
    Client: FIN=0, opcode=0x1, msg="and a"
    Server: (listening, new message containing text started)
    Client: FIN=0, opcode=0x0, msg="happy new"
    Server: (listening, payload concatenated to previous message)
    Client: FIN=1, opcode=0x0, msg="year!"
    Server: (process complete message) Happy new year to you too!

七、连接保持+心跳

WebSocket为了保持客户端、服务端的实时双向通信,需要确保客户端、服务端之间的TCP通道保持连接没有断开。然而,对于长时间没有数据往来的连接,如果依旧长时间保持着,可能会浪费包括的连接资源。

但不排除有些场景,客户端、服务端虽然长时间没有数据往来,但仍需要保持连接。这个时候,可以采用心跳来实现。

ping、pong的操作,对应的是WebSocket的两个控制帧,opcode分别是0x90xA

举例,WebSocket服务端向客户端发送ping,只需要如下代码(采用ws模块)

javascript 复制代码
ws.ping('', false, true);

八、Sec-WebSocket-Key/Accept的作用

前面提到了,Sec-WebSocket-Key/Sec-WebSocket-Accept在主要作用在于提供基础的防护,减少恶意连接、意外连接。

作用大致归纳如下:

  1. 避免服务端收到非法的websocket连接(比如http客户端不小心请求连接websocket服务,此时服务端可以直接拒绝连接)
  2. 确保服务端理解websocket连接。因为ws握手阶段采用的是http协议,因此可能ws连接是被一个http服务器处理并返回的,此时客户端可以通过Sec-WebSocket-Key来确保服务端认识ws协议。(并非百分百保险,比如总是存在那么些无聊的http服务器,光处理Sec-WebSocket-Key,但并没有实现ws协议。。。)
  3. 用浏览器里发起ajax请求,设置header时,Sec-WebSocket-Key以及其他相关的header是被禁止的。这样可以避免客户端发送ajax请求时,意外请求协议升级(websocket upgrade)
  4. 可以防止反向代理(不理解ws协议)返回错误的数据。比如反向代理前后收到两次ws连接的升级请求,反向代理把第一次请求的返回给cache住,然后第二次请求到来时直接把cache住的请求给返回(无意义的返回)。
  5. Sec-WebSocket-Key主要目的并不是确保数据的安全性,因为Sec-WebSocket-Key、Sec-WebSocket-Accept的转换计算公式是公开的,而且非常简单,最主要的作用是预防一些常见的意外情况(非故意的)。

强调:Sec-WebSocket-Key/Sec-WebSocket-Accept 的换算,只能带来基本的保障,但连接是否安全、数据是否安全、客户端/服务端是否合法的 ws客户端、ws服务端,其实并没有实际性的保证。

九、数据掩码的作用

WebSocket协议中,数据掩码的作用是增强协议的安全性。但数据掩码并不是为了保护数据本身,因为算法本身是公开的,运算也不复杂。除了加密通道本身,似乎没有太多有效的保护通信安全的办法。

那么为什么还要引入掩码计算呢,除了增加计算机器的运算量外似乎并没有太多的收益(这也是不少同学疑惑的点)。

答案还是两个字:安全。但并不是为了防止数据泄密,而是为了防止早期版本的协议中存在的代理缓存污染攻击(proxy cache poisoning attacks)等问题。

1、代理缓存污染攻击

下面摘自2010年关于安全的一段讲话。其中提到了代理服务器在协议实现上的缺陷可能导致的安全问题。猛击出处

"We show, empirically, that the current version of the WebSocket consent mechanism is vulnerable to proxy cache poisoning attacks. Even though the WebSocket handshake is based on HTTP, which should be understood by most network intermediaries, the handshake uses the esoteric "Upgrade" mechanism of HTTP 5. In our experiment, we find that many proxies do not implement the Upgrade mechanism properly, which causes the handshake to succeed even though subsequent traffic over the socket will be misinterpreted by the proxy."

TALKING Huang, L-S., Chen, E., Barth, A., Rescorla, E., and C.

Jackson, "Talking to Yourself for Fun and Profit", 2010,

在正式描述攻击步骤之前,我们假设有如下参与者:

攻击步骤一:

  1. 攻击者 浏览器 向 邪恶服务器 发起WebSocket连接。根据前文,首先是一个协议升级请求。
  2. 协议升级请求 实际到达 代理服务器
  3. 代理服务器 将协议升级请求转发到 邪恶服务器
  4. 邪恶服务器 同意连接,代理服务器 将响应转发给 攻击者

由于 upgrade 的实现上有缺陷,代理服务器 以为之前转发的是普通的HTTP消息。因此,当协议服务器 同意连接,代理服务器 以为本次会话已经结束。

攻击步骤二:

  1. 攻击者 在之前建立的连接上,通过WebSocket的接口向 邪恶服务器 发送数据,且数据是精心构造的HTTP格式的文本。其中包含了 正义资源 的地址,以及一个伪造的host(指向正义服务器)。(见后面报文)
  2. 请求到达 代理服务器 。虽然复用了之前的TCP连接,但 代理服务器 以为是新的HTTP请求。
  3. 代理服务器邪恶服务器 请求 邪恶资源
  4. 邪恶服务器 返回 邪恶资源代理服务器 缓存住 邪恶资源 (url是对的,但host是 正义服务器 的地址)。

到这里,受害者可以登场了:

  1. 受害者 通过 代理服务器 访问 正义服务器正义资源
  2. 代理服务器 检查该资源的url、host,发现本地有一份缓存(伪造的)。
  3. 代理服务器邪恶资源 返回给 受害者
  4. 受害者 卒。

附:前面提到的精心构造的"HTTP请求报文"。

复制代码
Client → Server:
POST /path/of/attackers/choice HTTP/1.1 Host: host-of-attackers-choice.com Sec-WebSocket-Key: <connection-key>
Server → Client:
HTTP/1.1 200 OK
Sec-WebSocket-Accept: <connection-key>
2、当前解决方案

最初的提案是对数据进行加密处理。基于安全、效率的考虑,最终采用了折中的方案:对数据载荷进行掩码处理。

需要注意的是,这里只是限制了浏览器对数据载荷进行掩码处理,但是坏人完全可以实现自己的WebSocket客户端、服务端,不按规则来,攻击可以照常进行。

但是对浏览器加上这个限制后,可以大大增加攻击的难度,以及攻击的影响范围。如果没有这个限制,只需要在网上放个钓鱼网站骗人去访问,一下子就可以在短时间内展开大范围的攻击。

十、写在后面

WebSocket可写的东西还挺多,比如WebSocket扩展。客户端、服务端之间是如何协商、使用扩展的。WebSocket扩展可以给协议本身增加很多能力和想象空间,比如数据的压缩、加密,以及多路复用等。

篇幅所限,这里先不展开,感兴趣的同学可以留言交流。文章如有错漏,敬请指出。

十一、相关链接

RFC6455:websocket规范

https://tools.ietf.org/html/rfc6455

规范:数据帧掩码细节

https://tools.ietf.org/html/rfc6455#section-5.3

规范:数据帧格式

https://tools.ietf.org/html/rfc6455#section-5.1

server-example

https://github.com/websockets/ws#server-example

编写websocket服务器

https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers

对网络基础设施的攻击(数据掩码操作所要预防的事情)

https://tools.ietf.org/html/rfc6455#section-10.3

Talking to Yourself for Fun and Profit(含有攻击描述)

http://w2spconf.com/2011/papers/websocket.pdf

What is Sec-WebSocket-Key for?

https://stackoverflow.com/questions/18265128/what-is-sec-websocket-key-for

10.3. Attacks On Infrastructure (Masking)

https://tools.ietf.org/html/rfc6455#section-10.3

Talking to Yourself for Fun and Profit

http://w2spconf.com/2011/papers/websocket.pdf

Why are WebSockets masked?

https://stackoverflow.com/questions/33250207/why-are-websockets-masked

How does websocket frame masking protect against cache poisoning?

https://security.stackexchange.com/questions/36930/how-does-websocket-frame-masking-protect-against-cache-poisoning

What is the mask in a WebSocket frame?

https://stackoverflow.com/questions/14174184/what-is-the-mask-in-a-websocket-frame

WebSocketProtocolHandler

WebSocketServerProtocolHandler

1、这个处理器用于运行1个websocket服务器,它处理websocket握手和控制帧(close,ping,pong),文本和二进制数据会传递给pipeline中的下一个处理器,可以查看netty源码中的示例代码:io.netty.example.http.websocketx.html5.WebSocketServer

2、这个处理的实现默认只是启动1个只处理websocket请求的ws服务器,不会处理其它类型的http请求。如果需要支持同时处理ws请求的和http请求,可以查看netty源码中的示例代码:io.netty.example.http.websocketx.server.WebSocketServer

3、当握手一旦完成,就可以通过拦截userEventTriggered(ChannelHandlerContext, Object)检查事件类型是否为WebSocketServerProtocolHandler.HandshakeComplete握手完成事件,并且可以从该事件对象中获取请求相关信息和子协议。

WebSocketServerProtocolHandshakeHandler
WebSocketServerHandshakerFactory
WebSocketServerHandshaker08

WebSocketClientProtocolHandler

1、这个处理器用于运行1个websocket客户端,它处理websocket握手和控制帧(ping,pong),文本和二进制数据会传递给pipeline中的下一个处理器。如果handleCloseFrames设置为false,当收到close帧时,在关闭之前,会将此关闭帧传递给下一个处理器,该配置默认为true。

2、当与远程服务器连接完成时,就会创建1个websocket连接。

3、一旦完成握手,就可以拦截userEventTriggered(ctx, evt)获取事件对象evt,检查evt类型是WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_ISSUED 或者 WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_COMPLETE类型

WebSocketClientProtocolHandshakeHandler
WebSocketClientHandshakerFactory
WebSocketClientHandshaker08

WebSocket08FrameDecoder

WebSocket08FrameDecoder继承自ByteToMessageDecoder,实现了WebSocketFrameDecoder接口

State

java 复制代码
enum State {
    
    // 第一次读一个字节 FIN, RSV, OPCODE
    // (解析第一个字节,是不是最后一帧(FIN),扩展位怎么样(RSV1/RSV2/RSV3),是什么帧类型(OPCODE))
    READING_FIRST,
    
    // 解析出MASK, PAYLOAD LEN描述
    // (解析第二个字节,是否有掩码,数据长度是多少)
    READING_SECOND,
    
    // 解析具体长度PAYLOAD LEN
    // (处理长度,如果是0-125,那好办,如果是126,就要读取后面2个字节的数据,如果是127,就要读取后面8个字节的数据。)
    READING_SIZE,
    
    // 解析掩码
    // 如果有掩码就解析出4字节掩码
    MASKING_KEY,
    
    // 解析数据
    // (解析出最后的数据)
    PAYLOAD,
    
    // 帧损坏了
    // (帧数据可能损坏了,可能要关闭连接)
    CORRUPT
}

OPCODE_XXX

该opCode用于标识帧类型

java 复制代码
// 后续帧
private static final byte OPCODE_CONT = 0x0;

// 文本帧
private static final byte OPCODE_TEXT = 0x1;

// 二进制帧
private static final byte OPCODE_BINARY = 0x2;

// 关闭帧
private static final byte OPCODE_CLOSE = 0x8;

// Ping帧
private static final byte OPCODE_PING = 0x9;

// Pong帧
private static final byte OPCODE_PONG = 0xA;

属性

java 复制代码
// WebSocket解码器配置
// ===
// ===final int maxFramePayloadLength;=========
// ===final boolean expectMaskedFrames;========
// ===final boolean allowMaskMismatch;=========
// ===final boolean allowExtensions;===========
// ===final boolean closeOnProtocolViolation;==
// ===final boolean withUTF8Validator;=========
// ===
private final WebSocketDecoderConfig config
    
// 分片数
private int fragmentedFramesCount;

// 该帧是否是最后1个分片
private boolean frameFinalFlag;

// 是否使用了掩码
private boolean frameMasked;

// 保留位 RSV1/RSV2/RSV3
private int frameRsv;

// 标识帧类型
private int frameOpcode;

// 帧长度模式
private int framePayloadLen1;

// 帧实际长度
private long framePayloadLength;

// 掩码
private byte[] maskingKey;

// 是否接收到过关闭帧
private boolean receivedClosingHandshake;

// 初始化解析状态
private State state = State.READING_FIRST;

decode

这是WebSocket08FrameDecoder中的核心方法

WebSocket帧长度
基本部分

基本的部分是这样的:

0-125

然后后面的根据数据长度有不同的情况,第一种就是数据长度在0-125之间,掩码的情况,没掩码的情况就是掩码的4个字节没有了。:

126

第二种长度数据是126,这个不是表示数据长度就是126,只是说7位不够描述长度,长度要用2个字节描述,后面会直接跟着2个字节的长度数据:

127

第二种长度数据是127,这个不是表示数据长度就是127,只是说2字节不够描述长度,长度要用8个字节描述,后面会直接跟着8个字节的长度数据:

解析方法
decode
java 复制代码
@Override
protected void decode(ChannelHandlerContext ctx, 
                      ByteBuf in, 
                      List<Object> out) throws Exception {
    
    // 如果之前接收过关闭帧, 则跳过(丢弃)当前所有的可读字节, 并且结束当前decode方法
    if (receivedClosingHandshake) {
        in.skipBytes(actualReadableBytes());
        return;
    }

    switch (state) {
            
    // 读取第1个字节
    case READING_FIRST:
        
        // 确保当前有可读字节 
        if (!in.isReadable()) {
            return;
        }

        // 帧数据长度
        framePayloadLength = 0;

        // 读取第1个字节
        byte b = in.readByte();
            
        // 0x80二进制为: 1000 0000 
        // 第一个字节b, 与 0x80 做 位与 操作后, 只会保留b的第1位, 即 FIN 位
        frameFinalFlag = (b & 0x80) != 0;
            
        // 0x70二进制为: 0111 0000
        // 第一个字节b, 与 0x70 做 位与 操作后, 只会保留b的第2,3,4位, 即RSV1, RSV2, RSV3保留位
        // 然后 右移4位
        frameRsv = (b & 0x70) >> 4;
            
        // 0x0F二进制为: 0000 1111
        // 第一个字节b, 与 0x0F 做 位与 操作后, 会保留b的后4位, 即 OPCODE 位
        frameOpcode = b & 0x0F;

        // 切换为 读取第二个字节
        state = State.READING_SECOND;
            
    case READING_SECOND:
            
        // 确保当前有可读字节 
        if (!in.isReadable()) {
            return;
        }
        
        // 再读取第2个字节
        b = in.readByte();
            
        // 0x80二进制为: 1000 0000 
        // 第2个字节b, 与 0x80 做 位与 操作后, 只会保留第2个字节b的第1位, 即 MASK 位
        // 0 表示不使用掩码, 1表示使用了掩码
        frameMasked = (b & 0x80) != 0;
        
        // 0x7F二进制为: 0111 1111 
        // 第2个字节b, 与 0x7F 做 位与 操作后, 会保留第2个字节b的后7位, 即 Payload len 位
        // 根据 该Payload len位确定帧长如何计算
        
        framePayloadLen1 = b & 0x7F;

        // 如果 RSV1/RSV2/RSV3 不为0, 那么WebSocketDecoderConfig#allowExtensions必须设置为true, 表示允许使用扩展位, 否则将抛出异常
        if (frameRsv != 0 && !config.allowExtensions()) {
            protocolViolation(ctx, in, "RSV != 0 and no extension negotiated, RSV:" + frameRsv);
            return;
        }

        // 如果WebSocketDecoderConfig#allowMaskMismatch为false, 那么就要求帧数据掩码位必须符合如果WebSocketDecoderConfig#expectMaskedFrames, 否则抛出异常
        if (!config.allowMaskMismatch() && config.expectMaskedFrames() != frameMasked) {
            protocolViolation(ctx, in, "received a frame that is not masked as expected");
            return;
        }

        /*
        0x0 - 后续帧
        0x1 - 文本帧
        0x2 - 二进制帧
        0x8 - 关闭帧
        0x9 - Ping帧
        0xA - Pong帧
        */
        // 如果是 关闭帧, Ping帧, Pong帧
        if (frameOpcode > 7) {

            // 这 3种帧是控制帧, 不能分片, 即 FIN 位 只能是 1, 否则抛出异常
            if (!frameFinalFlag) {
                protocolViolation(ctx, in, "fragmented control frame");
                return;
            }

            // Payload len位 值不能超过125, 即必须在[0-125]范围内, 否则抛出异常
            if (framePayloadLen1 > 125) {
                protocolViolation(ctx, in, "control frame with payload length > 125 octets");
                return;
            }

            // 必须是 0x8, 0x9, 0xA, 即不能是除 关闭帧, Ping帧, Pong帧 以外的其它帧类型, 否则抛出异常
            if (!(frameOpcode == OPCODE_CLOSE || frameOpcode == OPCODE_PING
                  || frameOpcode == OPCODE_PONG)) {
                protocolViolation(ctx, in, "control frame using reserved opcode " + frameOpcode);
                return;
            }

            // 如果是 关闭帧 类型, 并且 帧长值为 1, 那么会抛出异常
            if (frameOpcode == 8 && framePayloadLen1 == 1) {
                protocolViolation(ctx, in, "received close control frame with payload len 1");
                return;
            }
        } else {
            
            /*
            0x0 - 后续帧
            0x1 - 文本帧
            0x2 - 二进制帧
            */
            // 那就是只能是 这3种帧类型了, 否则抛出异常
            if (!(frameOpcode == OPCODE_CONT || frameOpcode == OPCODE_TEXT
                  || frameOpcode == OPCODE_BINARY)) {
                protocolViolation(ctx, in, "data frame using reserved opcode " + frameOpcode);
                return;
            }

            // 如果当前 帧类型 是 后续帧, 那么 fragmentedFramesCount(已收到的分片个数) 就不能是0, 否则抛出异常
            // 这也就意味着后续帧 必须跟在 文本帧或二进制帧或后续帧后面
            if (fragmentedFramesCount == 0 && frameOpcode == OPCODE_CONT) {
                protocolViolation(ctx, in, "received continuation data frame outside fragmented message");
                return;
            }

            // 如果当前 帧类型是 文本帧或二进制帧, 那么 fragmentedFramesCount(已收到的分片个数) 必须是0, 否则抛出异常
            if (fragmentedFramesCount != 0 && frameOpcode != OPCODE_CONT) {
                protocolViolation(ctx, in,
                                  "received non-continuation data frame while inside fragmented message");
                return;
            }
        }

        // 切换为 读取帧大小
        state = State.READING_SIZE;
            
    case READING_SIZE:

        // 如果 Payload len位 的值是 126, 那么表示后面有2个字节长度表示真正的帧长
        if (framePayloadLen1 == 126) {
            
            // 保证后续至少有2个字节可读, 以便读取真正的帧长
            if (in.readableBytes() < 2) {
                return;
            }
            
            // 读取2个字节, 获取真正的帧长 值
            framePayloadLength = in.readUnsignedShort();
            
            // 帧长值不能小于126, 如果小于126, 那么Payload len位 的 值 就应该是0-125之间, 否则抛出异常
            if (framePayloadLength < 126) {
                protocolViolation(ctx, in, "invalid data frame length (not using minimal length encoding)");
                return;
            }
            
        // 如果 Payload len位 的值是 127, 那么表示后面有8个字节长度表示真正的帧长
        } else if (framePayloadLen1 == 127) { 
            
            // 保证后续至少有2个字节可读, 以便读取真正的帧长
            if (in.readableBytes() < 8) {
                return;
            }
            
            // 读取8个字节, 获取真正的帧长 值
            framePayloadLength = in.readLong();
            
            // 如果获取到的真正的帧长 值 小于 65536, 即 2^16, 
            // 那么完全可以用Payload len位 的值为 126的方式, 
            // 而没有必要用 Payload len位 的值为 127的方式, 所以抛出异常
            if (framePayloadLength < 65536) {
                protocolViolation(ctx, in, "invalid data frame length (not using minimal length encoding)");
                return;
            }
            
        } else {
            // payload len位 的值 为[0-125]范围, 该值就表示 真正的帧长
            framePayloadLength = framePayloadLen1;
        }

        // 如果读取到的 真正的帧长 超过了 WebSocketDecoderConfig#maxFramePayloadLength配置的值
        // 那么抛出异常
        if (framePayloadLength > config.maxFramePayloadLength()) {
            protocolViolation(ctx, in, WebSocketCloseStatus.MESSAGE_TOO_BIG,
                "Max frame length of " + config.maxFramePayloadLength() + " has been exceeded.");
            return;
        }

        // 状态切换为 读取掩码
        state = State.MASKING_KEY;
            
    case MASKING_KEY:
        
        // 如果前面解析出来的掩码位 为1, 那么表示使用了掩码
        if (frameMasked) {
            
            // 掩码一共有4位, 这里保证至少有4个字节可读
            if (in.readableBytes() < 4) {
                return;
            }
            
            // 初始化maskingKey 为默认的4个字节
            if (maskingKey == null) {
                maskingKey = new byte[4];
            }
            
            // 读取4个字节的掩码 到 maskingKey
            in.readBytes(maskingKey);
        }
            
        // 状态 切换为 读取内容
        state = State.PAYLOAD;
            
    case PAYLOAD:
            
        // 如果后续可读字节数 小于 指定的帧长值, 直接结束当前方法, 直到满足指定的帧长
        if (in.readableBytes() < framePayloadLength) {
            return;
        }

        ByteBuf payloadBuffer = null;
            
        try {
            
            // 从 in 缓冲区中读取 framePayloadLength个字节 到 payloadBuffer 中
            payloadBuffer = readBytes(ctx.alloc(), 
                                      in, 
                                      toFrameLength(framePayloadLength));

            // 状态继续重置为 读取第一个字节, 表示读取下一帧
            state = State.READING_FIRST;

            // 使用掩码 解码数据
            if (frameMasked) {
                unmask(payloadBuffer);
            }

            // Ping帧 / Pong帧 / 关闭帧 是控制帧, 
            // 控制帧不能分帧, 所以直接交给下1个入站处理器处理
            if (frameOpcode == OPCODE_PING) {
                // Ping帧 直接交给下1个入站处理器处理
                out.add(new PingWebSocketFrame(frameFinalFlag, 
                                               frameRsv, payloadBuffer));
                payloadBuffer = null;
                return;
            }
            
            if (frameOpcode == OPCODE_PONG) {
                // Ping帧 直接交给下1个入站处理器处理
                out.add(new PongWebSocketFrame(frameFinalFlag, 
                                               frameRsv, payloadBuffer));
                payloadBuffer = null;
                return;
            }
            
            if (frameOpcode == OPCODE_CLOSE) {
                // 如果是关闭帧, 将 receivedClosingHandshake 此标记置为true, 表示收到过关闭帧
                // 并且此时需要回1个关闭帧
                receivedClosingHandshake = true;
                
                // 检查关闭帧的内容
                // payloadBuffer要么是null或者是空的
                // 或者可读字节数>=2并且code < 0 || 1000 <= code && code <= 1003 
                //                   || 1007 <= code && code <= 1014 ||3000 <= code;
                // 如果可读字节数>2, 那么后续必须是UTF8编码
                checkCloseFrameBody(ctx, payloadBuffer);
                
                // 回1个关闭帧
                out.add(new CloseWebSocketFrame(frameFinalFlag, 
                                                frameRsv, payloadBuffer));
                payloadBuffer = null;
                return;
            }

            // 走到这里, 只可能是二进制帧 或 文本帧 或 后续帧 了
            
            // 如果是 最后1个分片, 则将 fragmentedFramesCount 分片数 置为0
            if (frameFinalFlag) {
                fragmentedFramesCount = 0;
            } else {
                // 如果不是 最后1个分片, 那么分片数 +1 , 表示收到的分片数
                fragmentedFramesCount++;
            }

            // 如果是 文本帧, 则直接 组装成 TextWebSocketFrame 交给后面的处理器, 然后结束
            if (frameOpcode == OPCODE_TEXT) {
                out.add(new TextWebSocketFrame(frameFinalFlag, 
                                               frameRsv, payloadBuffer));
                payloadBuffer = null;
                return;
            } else if (frameOpcode == OPCODE_BINARY) {
                // 如果是 二进制帧, 则直接 组装成 BinaryWebSocketFrame 交给后面的处理器, 然后结束
                out.add(new BinaryWebSocketFrame(frameFinalFlag, 
                                                 frameRsv, payloadBuffer));
                payloadBuffer = null;
                return;
            } else if (frameOpcode == OPCODE_CONT) {
                // 如果是 后续帧, 则直接 组装成 ContinuationWebSocketFrame 交给后面的处理器, 然后结束
                out.add(new ContinuationWebSocketFrame(frameFinalFlag, frameRsv,
                                                       payloadBuffer));
                payloadBuffer = null;
                return;
            } else {
                
                // 不支持其它类型帧
                throw new UnsupportedOperationException(frameOpcode);
            }
        } finally {
            if (payloadBuffer != null) {
                // 如果payloadBuffer 不为null, 表示发生了异常, 此时手动释放 payloadBuffer
                payloadBuffer.release();
            }
        }
    case CORRUPT:
        // 如果帧解析状态 已经是损坏了, 为避免父类报错, 需要读取数据, 这里读取1个字节
        if (in.isReadable()) {
            in.readByte();
        }
        return;
    default:
        throw new Error("Shouldn't reach here.");
    }
}
protocolViolation
java 复制代码
private void protocolViolation(ChannelHandlerContext ctx, 
                               ByteBuf in, 
                               CorruptedWebSocketFrameException ex) {
    // 状态切换为 帧已损坏
    state = State.CORRUPT;
    
    // 获取当前缓冲区可读字节数
    int readableBytes = in.readableBytes();
    
    // 因为当前帧 已损坏, 跳过所有的可读字节数, 以便 ByteToMessageDecoder释放 cumulation
    if (readableBytes > 0) {
        // Fix for memory leak, caused by ByteToMessageDecoder#channelRead:
        // buffer 'cumulation' is released ONLY when no more readable bytes available.
        in.skipBytes(readableBytes);
    }
    
    // 如果当前channle还处于激活状态
    // 如果 WebSocketServerConfig#closeOnProtocolViolation配置为true, 那么需要写回1个关闭帧后关闭链接
    if (ctx.channel().isActive() && config.closeOnProtocolViolation()) {
        
        Object closeMessage;
        
        // 如果接收到过关闭帧, 则直接写空的ByteBuf
        if (receivedClosingHandshake) {
            closeMessage = Unpooled.EMPTY_BUFFER;
        } else {
            
            // 如果未接受到过关闭帧, 从异常中获取状态和异常信息组装WebSocket关闭帧
            
            WebSocketCloseStatus closeStatus = ex.closeStatus();
            
            String reasonText = ex.getMessage();
            
            if (reasonText == null) {
                reasonText = closeStatus.reasonText();
            }
            
            closeMessage = new CloseWebSocketFrame(closeStatus, reasonText);
        }
        
        // 发送完成后, 关闭链接
        ctx.writeAndFlush(closeMessage).addListener(ChannelFutureListener.CLOSE);
    }
    throw ex;
}
unmask

掩码解码方法

java 复制代码
private void unmask(ByteBuf frame) {
    int i = frame.readerIndex();
    int end = frame.writerIndex();

    ByteOrder order = frame.order();

    // 把掩码二进制数组转换为int
    int intMask = ((maskingKey[0] & 0xFF) << 24)
                | ((maskingKey[1] & 0xFF) << 16)
                | ((maskingKey[2] & 0xFF) << 8)
                | (maskingKey[3] & 0xFF);

    // 如果是小端序,需要把INT类型的掩码反转
    if (order == ByteOrder.LITTLE_ENDIAN) {
        intMask = Integer.reverseBytes(intMask);
    }

    // XOR运算,还原原始值
    for (; i + 3 < end; i += 4) {
        int unmasked = frame.getInt(i) ^ intMask;
        frame.setInt(i, unmasked);
    }
    for (; i < end; i++) {
        frame.setByte(i, frame.getByte(i) ^ maskingKey[i % 4]);
    }
}
checkCloseFrameBody

检查关闭帧内容

java 复制代码
protected void checkCloseFrameBody(
        ChannelHandlerContext ctx, ByteBuf buffer) {
    
    // buffer为null 或者 没有内容 直接返回
    if (buffer == null || !buffer.isReadable()) {
        return;
    }
    
    // 关闭帧如果有内容, 必须>=2个字节长度, 因为关闭帧不能分片, 所以这里<2直接抛出异常
    if (buffer.readableBytes() < 2) {
        protocolViolation(ctx, buffer, WebSocketCloseStatus.INVALID_PAYLOAD_DATA, "Invalid close frame body");
    }

    // 读取2个字节, 去匹配 WebSocket的状态, 如果未匹配, 则报错
    int statusCode = buffer.getShort(buffer.readerIndex());
    if (!WebSocketCloseStatus.isValidStatusCode(statusCode)) {
        protocolViolation(ctx, buffer, "Invalid close frame getStatus code: " + statusCode);
    }

    // 超过2个字节, 则校验必须是UTF8编码格式
    if (buffer.readableBytes() > 2) {
        try {
            new Utf8Validator().check(buffer, buffer.readerIndex() + 2, buffer.readableBytes() - 2);
        } catch (CorruptedWebSocketFrameException ex) {
            protocolViolation(ctx, buffer, ex);
        }
    }
}

WebSocket08FrameEncoder

WebSocket08FrameEncoder继承自MessageToMessageEncoder<WebSocketFrame>,实现了WebSocketFrameEncoder接口

OPCODE_XXX

与WebSocket08FrameDecoder中定义的OPCODE值相同

java 复制代码
// 后续帧
private static final byte OPCODE_CONT = 0x0;

// 文本帧
private static final byte OPCODE_TEXT = 0x1;

// 二进制帧
private static final byte OPCODE_BINARY = 0x2;

// 关闭帧
private static final byte OPCODE_CLOSE = 0x8;

// Ping帧
private static final byte OPCODE_PING = 0x9;

// Pong帧
private static final byte OPCODE_PONG = 0xA;

属性

java 复制代码
// 阈值,发送的字节超过此长度将不会合并到一个bytebuf中
private static final int GATHERING_WRITE_THRESHOLD = 1024

// 表示websocket是否需要对数据进行掩码运算
// 掩码运算也叫XOR加密,详情可以在http://www.ruanyifeng.com/blog/2017/05/xor.html了解。
// 那么websocket客户端发送到服务器端的数据需要进行XOR运算是为了防止攻击
// 因为websocket发送的数据,黑客很有可能在数据字节码中加入http请求的关键字,比如getxx \r\n,
// 如果不加以限制,那么某些代理服务器会以为这是一个http请求导致错误转发。
// 那么通过对原生字节进行XOP计算后,http关键字会被转化为其它字节,从而避免攻击。
private final boolean maskPayload;

encode

java 复制代码
@Override
protected void encode(ChannelHandlerContext ctx, 
                      WebSocketFrame msg, 
                      List<Object> out) throws Exception {
    
    // WebSocketFrame 帧中待写入的byteBuf数据
    final ByteBuf data = msg.content();
    
    // 掩码
    byte[] mask;

    // 帧类型
    byte opcode;
    
    // 根据 不同的 WebSocketFrame 帧实现类, 得到 opcode帧类型(只能是如下6种帧类型, 否则抛出异常)
    if (msg instanceof TextWebSocketFrame) {
        opcode = OPCODE_TEXT;
    } else if (msg instanceof PingWebSocketFrame) {
        opcode = OPCODE_PING;
    } else if (msg instanceof PongWebSocketFrame) {
        opcode = OPCODE_PONG;
    } else if (msg instanceof CloseWebSocketFrame) {
        opcode = OPCODE_CLOSE;
    } else if (msg instanceof BinaryWebSocketFrame) {
        opcode = OPCODE_BINARY;
    } else if (msg instanceof ContinuationWebSocketFrame) {
        opcode = OPCODE_CONT;
    } else {
        throw new UnsupportedOperationException();
    }
	
    // 待写入的byteBuf数据 的可读字节数
    int length = data.readableBytes();

    // 待组装帧数据的第一个字节
    int b0 = 0;
	
    // 如果当前写入的帧 是最后1个分片
    if (msg.isFinalFragment()) {
        // 第一个字节的左边第一个bit位设置为1
        b0 |= 1 << 7;
    }
    
    // RSV1, RSV2, RSV3:各占1个比特 正常全为0,属于扩展字段
    // msg.rsv()模上8就会保留右边3位, 然后左移4位, 就会是 0+右边3位, 然后保留b0的第一位
    b0 |= msg.rsv() % 8 << 4;
    
    // 由于opcode都<=10, 所以根本就不会影响到b0左边的4位, 相当于保留opcode右边的4位到b0右边的4位
    b0 |= opcode % 128;

    // 如果是 Ping帧类型, 那么长度不能超过125, 否则抛出过长帧异常
    if (opcode == OPCODE_PING && length > 125) {
        throw new TooLongFrameException();
    }

    boolean release = true;
    
    ByteBuf buf = null;
    
    try {
        
        // 如果需要掩码, 则掩码的长度为4
        int maskLength = maskPayload ? 4 : 0;
        
        // 如果待写入的byteBuf数据 的长度 <=125
        if (length <= 125) {
            
            // (b0算1个字节 + 掩码位和7位的数据长度算1个字节 = 2个字节) 加上 掩码长度(如果存在掩码的话)
            int size = 2 + maskLength;
            
            // 如果 需要掩码 或者 待写入的byteBuf数据的长度<=1024(这个条件肯定满足阿), 
            // 则size继续加上内容的长度就可以了, 就是所有需要写入的长度
            if (maskPayload || length <= GATHERING_WRITE_THRESHOLD) {
                size += length;
            }
            
            buf = ctx.alloc().buffer(size);
            
            // 先写第一个字节
            buf.writeByte(b0);
            
            // 0x80二进制为: 1000 0000 
            // 如果 需要掩码, 则让0x80与length异或运算, 如果不需要掩码, 则直接 length
            byte b = (byte) (maskPayload ? 0x80 | (byte) length : (byte) length);
            
            // 写入第二个字节(还需要写入内容)
            buf.writeByte(b);
            
        } else if (length <= 0xFFFF) { // 如果length长度<=65535, 但是>125
            
            // 当长度<=65535, 但是>125时, 此时长度可以使用2个字节表示出来, 即126这种
            
            // (b0算1个字节 + 掩码位和7位的数据长度算1个字节 + 2个字节用来表示内容长度 = 4个字节) 加上 掩码长度(如果存在掩码的话)
            int size = 4 + maskLength;
            
            // 如果 需要掩码 或者 length <= 1024
            if (maskPayload || length <= GATHERING_WRITE_THRESHOLD) {
                size += length;
            }
            
            buf = ctx.alloc().buffer(size);
            
            // 先写第一个字节
            buf.writeByte(b0);
            
            // 再写入第二个字节, 因为肯定是126这种, 只需要确定要不要加掩码就行了, 所以只有这2种值
            buf.writeByte(maskPayload ? 0xFE : 126);
            
            // 假设length=3520 二进制为【00000000 00000000 00001101 11000000】
            // length分为俩个字节写入,先右移8位,把高位写入
            //右移8位:length >>> 8 = [00000000 00000000 00000000 00001101] & [11111111] = [00001101]
            buf.writeByte(length >>> 8 & 0xFF);
            
            //length & 0xFF = [00000000 00000000 00001101 11000000]  & [11111111]  = [11000000]
            //写入低8位
            buf.writeByte(length & 0xFF);
            
        } else { // length长度 >65535
            
            // (b0算1个字节 + 掩码位和7位的数据长度算1个字节 + 8个字节用来表示内容长度 = 10个字节) 加上 掩码长度(如果存在掩码的话)
            int size = 10 + maskLength;
            
            // 如果 需要掩码 或者 length <= 1024
            if (maskPayload || length <= GATHERING_WRITE_THRESHOLD) {
                size += length;
            }
            
            buf = ctx.alloc().buffer(size);
            
            // 先写第一个字节
            buf.writeByte(b0);
            
            // 再写入第二个字节, 因为肯定是127这种, 只需要确定要不要加掩码就行了, 所以只有这2种值
            buf.writeByte(maskPayload ? 0xFF : 127);
            
            // 写入占8个字节的长度
            buf.writeLong(length);
        }

        // 如果 需要掩码
        if (maskPayload) {
            
            // 生成随机数作为XOR的KEY
            int random = PlatformDependent.threadLocalRandom()
                                          .nextInt(Integer.MAX_VALUE);
            
            // 返回字节数组
            mask = ByteBuffer.allocate(4).putInt(random).array();
            
            // 把掩码写入到buf中
            buf.writeBytes(mask);

            // 获得字符序列
            ByteOrder srcOrder = data.order();
            ByteOrder dstOrder = buf.order();

            int counter = 0;
            int i = data.readerIndex();
            int end = data.writerIndex();

            // 如果字符序列相同
            if (srcOrder == dstOrder) {
                
                // 把数组拼接为32位的int形式
                int intMask = ((mask[0] & 0xFF) << 24)
                    | ((mask[1] & 0xFF) << 16)
                    | ((mask[2] & 0xFF) << 8)
                    | (mask[3] & 0xFF);

                // 小端序列转换掩码
                if (srcOrder == ByteOrder.LITTLE_ENDIAN) {
                    intMask = Integer.reverseBytes(intMask);
                }

                // 每4个字节一组与掩码Key进行XOR运算
                for (; i + 3 < end; i += 4) {
                    int intData = data.getInt(i);
                    buf.writeInt(intData ^ intMask);
                }
            }
            
            // 如果i的值已经被增加,这里不会循环
            for (; i < end; i++) {
                byte byteData = data.getByte(i);
                buf.writeByte(byteData ^ mask[counter++ % 4]);
            }
            
            // 返回buf到底层channel中输出
            out.add(buf);
            
        } else {
            
            // 如果能写的下, 就直接将待写入的内容写入到buf
            if (buf.writableBytes() >= data.readableBytes()) {
                buf.writeBytes(data);
                out.add(buf);
            } else {
                // 如果写不下, 则添加到out中
                out.add(buf);
                out.add(data.retain());
            }
            
        }
        
        // 如果正常执行到这里, 那么就不用释放buf
        release = false;
        
    } finally {
        
        // 如果上面发生了异常, 那么 release就是true, 并且如果此时buf不为null, 那么需要释放buf
        if (release && buf != null) {
            buf.release();
        }
        
    }
}
相关推荐
ywl47081208715 小时前
第一章Netty,如何实现I/O多路复用的功能
netty·nio·selector
ywl4708120871 天前
第一章Netty,NIO零拷贝详解
netty·nio·selector
CSharp精选营5 天前
WebSocket 快速入门教程(附示例源码)
websocket·教程·csharp·实时通信·asp.net-core
七夜zippoe18 天前
DolphinDB WebSocket接入:实时数据流
网络·websocket·网络协议·dolphindb·实时数据流
于先生吖18 天前
从零搭建Java萌宠社交系统:WebSocket实时聊天+动态发布模块实现
java·开发语言·websocket
Zhan86112419 天前
WebSocket心跳与断线重连实战:芬兰赫尔辛基指数行情数据接口接入记录
网络·websocket·网络协议
colofullove20 天前
实时游玩页与 WebSocket 状态管理实现
websocket·网络协议·状态模式
小短腿的代码世界20 天前
WebSocket协议在Qt中的工业级实现:5层架构设计与万级并发压测验证
qt·websocket·网络协议
葡萄皮sandy20 天前
SSE和WebSocket
网络·websocket·网络协议