快速搞懂 | WebSocket协议的详解与实践

6,597 阅读14分钟

概述

对WebSocket协议只停留在长连接上?想了解WebSokcet协议具体是如何实现的吗?浏览器背着我们又偷偷做了哪些工作?

由于实践是检验协议的唯一标准,因此本文将以客户端和服务端一次数据传输来学习WebSocket协议的设计。

本文基于WebSocket协议的文档RFC6455总结而成(已经大佬翻译了中文版)

提示,如果你对TCP协议还不够了解,建议阅读TCP协议小册

本文预设你已经知道如何使用WebSocket

测试环境搭建

  • 操作系统 Bug10
  • 软件 WireShark 用于抓包
  • 编写好的WebSocket服务端和客户端用于本地测试

WireShark 配置

众所周知WireShark可以用来抓取网络封包,被广泛的应用到安全以及测试领域中,因此我们可以用WireShark来分析使用WebSocket协议的过程中服务端和客户端都偷偷说了些什么。

第一步,选择要监听的网卡

由于我们使用的是本地的测试环境,也就是说我们访问的地址localhost,因此我们需要监听本地回环网络,也就是图中红框所标注的网卡。

第二步,过滤无关数据

如下图所示,设置tcp.port == 8080 表示我们只监听8080端口的数据,下文中还会再次说明

WebSocket协议详解

握手协议-Opening Handshake

客户端如果想与服务端建立WebSocket连接,则必须经历以下流程

0x01 客户端发出握手请求

在客户端与服务端建立TCP连接之后,客户端以HTTP报文的形式发送握手请求到服务端,该HTTP报文必须符合以下要求

  • HTTP报文必须合法,且请求的方式为GET

  • HTTP报文的必须包含以下消息头以标志这是一个WebSocket握手请求

Upgrade: websocket
Connection: Upgrade
  • HTTP报文的消息头中必须包含Sec-WebSocket-Key字段,此字段主要用于WebSocket协议的校验,以防止滥用,此字段只能出现一次, 其值的算法在后文会详细说明

  • 如果此请求来自浏览器,则HTTP报文的消息头必须包含Origin字段,其他方式的请求也可以包含此字段。

  • HTTP报文的消息头中必须包含Sec-WebSocket-Version,以表明WebSocket的版本,且其值必须为13

  • HTTP报文消息头中可以包含Sec-WebSocket-Protocol,以表明客户端所希望执行的子协议

  • HTTP报文消息头中可以包含Sec-WebSocket-Extensions,以表明客户端所希望执行的扩展(如消息压缩插件)

  • HTTP报文可以包含其他消息头

在客户端发出握手的HTTP请求之后,在服务端返回响应之前,客户端不能发送任何数据给服务端

0x02 服务端响应

如果服务端决定接收来自客户端的握手请求与客户端建立WebSocket连接,那么服务端必须完成以下操作。

假设来自客户端的握手请求是合法的, 即客户端的握手请求符合RFC6455的定义

1、 服务端必须向客户端证明自己处理WebSocket协议。证明的方式如下

在客户端随机生成一个16字节大小的字节数组,并使用Base64加密该字节数组,将加密后所得到得字符串填入Sec-WebSocket-Key字段, 如以下所示

Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

服务端要证明自己可以处理WebSocket协议,则必须对Sec-WebSocket-Key做以下操作,取出其值也就是dGhlIHNhbXBsZSBub25jZQ==,将该字符串与魔法字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11连接,得出以下字符串

dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11

对以上字符串执行SHA-1算法得出其散列值(一个字节数组),如下所示

0xb3 0x7a 0x4f 0x2c 0xc0 0x62 0x4f 0x16 0x90 0xf6 0x46 0x06 0xcf 0x38 0x59 0x45 0xb2 0xbe 0xc4 0xea

对以上字节数组执行Base64加密算法得出字符串s3pPLMBiTxaQ9kYGzzhZRbK+xOo=,然后将该值写入握手响应的Sec-WebSocket-Accept字段中, 也就是说你会在响应中看到以下字段

Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

以上来自RFC6455文档。 如果你曾经调用过第三方接口(如某信的公众号),是不是有种似曾相识的感觉呢?

客户端会对此值进行校验,以检查服务端是否能够正确的处理WebSocket协议。

我们可以看看Web应用容器/框架们是如何处理此字段的

一号选手 Jetty

处理握手协议的关键类org.eclipse.jetty.websocket.server.HandshakeRFC6455

public class HandshakeRFC6455 implements WebSocketHandshake
{
    /**
     * RFC 6455 - Sec-WebSocket-Version
     * 验证了上文的说法,即版本号必须为13
     * 没办法规矩是人定的,(ˉ▽ˉ;)...
     */
    public static final int VERSION = 13;

    @Override
    public void doHandshakeResponse(ServletUpgradeRequest request, ServletUpgradeResponse response) throws IOException
    {
        String key = request.getHeader("Sec-WebSocket-Key");
        if (key == null)
            throw new BadMessageException("Missing request header 'Sec-WebSocket-Key'");

        // build response
        response.setHeader("Upgrade", "WebSocket");
        response.addHeader("Connection", "Upgrade");
        //没错,关键类就在这儿 AcceptHash.hashKey(key)
        response.addHeader("Sec-WebSocket-Accept", AcceptHash.hashKey(key));

        request.complete();

        response.setStatusCode(HttpServletResponse.SC_SWITCHING_PROTOCOLS);
        response.complete();
    }
}

AcceptHash.hashKey(key)

public class AcceptHash
{
    //魔法值,注意其编码的方式
    private static final byte[] MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.ISO_8859_1);
    
    public static String hashKey(String key)
    {
        try
        {
            //使用SHA1求对应字符串的散列值
            MessageDigest md = MessageDigest.getInstance("SHA1");
            //获取Sec-WebSocket-Key
            md.update(key.getBytes(StandardCharsets.UTF_8));
            //此法相当于将两个字符串连接在一起
            md.update(MAGIC);
            //digest()会获取经过SHA1算法计算之后的字节数组
            return Base64.getEncoder().encodeToString(md.digest());
        }
        catch (Exception e)
        {
            throw new RuntimeException(e);
        }
    }
}

二号选手 Express-ws

express-ws 依赖 ws, 上图代码位于websocket-server.js

留个小问题,对比JS的代码,你知道为啥Java要特别指定字符串的编码值吗?

2、 在处理WebSocket协议之后, 服务端需要处理来自客户端握手请求中的扩展请求,即处理Sec-WebSocket-Extensions字段。该字段表明了客户端希望服务端加载的扩展插件, 但事实上插件是否加载是以服务端为准的,服务端会在返回的消息头中表明它所支持的插件。

例如,客户端发送了以下的插件扩展请求,希望服务端加载permessage-deflate以及client_max_window_bits插件

Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

如果服务端仅支持permessage-deflate插件的,那么服务端会返回

Sec-WebSocket-Extensions: permessage-deflate

在之后的通信过程中,服务端和客户端都只会加载permessage-deflate插件(关于此插件,实践过程中会提及,困扰了我好久)

3、 告知客户端服务端所支持的子协议,也就是处理Sec-WebSocket-Protocol字段并从中选择一个所支持协议并返回给客户端。处理方式如下

    var protocol = req.headers['sec-websocket-protocol'];
    //如果存在sec-websocket-protocol字段
    if (protocol) {
      //客户端传过来的所有的协议
      protocol = protocol.trim().split(/ *, */);

      //
      // Optionally call external protocol selection handler.
      // handleProtocols 为钩子函数,即如果定义了此函数则将协议的选择交给此函数处理
      if (this.options.handleProtocols) {
        protocol = this.options.handleProtocols(protocol, req);
      } else {
        //否则,就默认呗,还能咋样
        protocol = protocol[0];
      }
      //如果选择到了协议,就返回Sec-WebSocket-Protocol
      if (protocol) {
        headers.push(`Sec-WebSocket-Protocol: ${protocol}`);
        ws.protocol = protocol;
      }
    }

此字段相当于为开发者预留更多的操作空间。更多资料

除此之外,服务端响应还必须包含以下字段和值以表明成功建立WebSocket连接

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: WebSocket

实践

  • 写一个WebSocket服务端和客户端。
  • 打开WireShark,如果服务端在本地则监听回环网络,如果不在本机则监听对应网卡
  • 设置过滤规则,值监听对应端口,本次测试服务端的端口是8080
tcp.port == 8080

如下图所示,为WireShark捕获的WebSocket协议握手以及通信包。事实上握手协议可以视为一个协商过程,即服务端和客户端互相告知自己可以什么样的方式来处理数据过程的。

1.客户端发起握手请求

2.服务端响应握手请求

3.协议切换,在本例中服务端在与客户端完成握手之后,会立即发送一条数据给客户端,如下图所示,注意红框所标准的内容,此时协议已经转换成了WebSocket协议

相信你也注意到了第一个黄框所标注的内容,里面的内容是WebSocket协议传输数据的关键。而这也是我们要用WireShark抓包的原因,因为Chrome并不会呈现底层的细节给我们,它只会告诉我们服务端返回了什么数据。

数据传输

想要真正理解WebSocket的数据传输协议为什么要这样子设计你必须知道以下概念。

  • WebSocket协议是基于TCP协议的应用层协议
  • TCP是基于字节流的传输协议
  • 流是没有边界的

以上概念意味着我们必须知道数据的边界是什么,换种说法就是我们必须知道以什么形式去划分字节流,以便获得一个完整的数据。

WebSocket数据通信协议的定义如下所示

      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 ...                |
     +---------------------------------------------------------------+

在开始之前有必要对payload解释一下,payload即负载可以理解我们要传输的数据。

下表为对WebSocket种各字段的解释.

字段名 SIZE 作用
FIN 1位 标志此数据封包是否为此次消息的最后一个封包
RSV1, RSV2, RSV2 每个标志各一位,总计三位 这三个标志位主要与扩展相关,下文的实践会给出详细说明
opcode 4位 标志此数据封包中负载(即Payload,可以视为实际传输的内容)的类型,由于其有4位数,因此支持16种类型的内容,常用的有0001即文本,0002二进制数据
MASK 1位 表示此数据封包种的负载(Payload)是否执行了掩码操作,如果此位为1则Masking-key会起作用,掩码具体如何起作用会在下文的实践种说明
Payload len 7位 表示负载的长度,由于此字段为7位因此,此字段最大支持传输127个字节数
Extended Payload Length 16 位或 64位 如果Payload len字段的值为126,那么整个Payload的长度为 后续的16位(即2个字节),如果Payload len 字段的值为127那么整个payload的长度为 后续的64位(即8个字节)
Masking-key 0或4字节 如果Mask位为1,则此字段起作用,此字段主要用来对数据执行掩码操作
Payload Data N字节 此次数据封包要传输的内容,长度由Payload len与Extend payload len 定义

现在,你知道WebSocket协议是如何定义流的边界了吗?

实践

学习的最好办法就是实践,加油,奥里给! - 巨魔

测试环境: Bug 10 + Chrome + Jetty

发送数据

首先让我们发条消息juejin,kesan(长度为12)给服务端

注意到以下信息

  • FIN = 1 因为我们发送的消息一个封包完全足够传输,所以此数据封包也是最后一个/一帧, 因此 FIN = 1 没毛病
  • RSV1 = 1, RSV2 = 0, RSV3 = 0, 在上文我们说过这三个标志位与扩展相关的,而此时WireShark也提示我们此次传输启用了Pre-Message Compressed插件
  • OPCODE = 0001, 此次的负载/传输的是文本消息
  • Payload length = 14 此次负载内容的字节数是14,你发现问题了吗?
  • Masking-key = 7eca6052 此次负载的掩码为7eca6052,掩码一般出现在客户端发送数据到服务端的过程中

让我们康康此次传输的内容,你发现问题了吗?

数据去哪了呀?等等,你还记得掩码让我们看一下去掉掩码之后得数据是咋样的

还是不对,去掉掩码之后还是不对,而且长度也不对,看到旁边的 Decompressed payload了吗?让我们进去看看

终于对了!!!现在你知道此次传输发生了什么吗?先考虑一会

RSV1 = 1 说明Chrome启用了插件并且Jetty也支持此插件,因此此次消息传输启用了插件,那么到底启用了什么插件呢?其实在上文的握手协议已经说明了,在客户端和服务端执行握手协议的时候会协商二者要共同启用的插件并通过Sec-WebSocket-Extensions来说明。而此次启用的插件是permessage-deflate插件,用来压缩消息。

但尴尬的是,消息压缩似乎不起作用并且还变长了ԾㅂԾ.

我们换个方法测试一下,发一大堆1给服务端,看看发生了什么

哦吼,起作用了,可以看出压缩前的数据长度为41字节,而压缩后的数据只有5字节,确实起作用了呀。(此插件的实现不在本文的讨论范围内)

那么,我想康康没有启用压缩数据的WebSocket包该咋办?

有请Bug 10的御用Bug多PDF阅读器EPUB阅读器Edge浏览器选手登场

没错,Edge不支持permessage-deflate数据压缩插件

Edge这孩子比较惨,他微软爸爸一个插件都没给他,太惨了,难怪要拐走隔壁谷歌的chromium当自己的儿子。

好吧,看看原汁原味的数据封包长什么样,可以看出只要去掉掩码就可以直接获取数据,而不需要再解压缩数据

去掉掩码只需要将4个字节的数据与掩码异或即可 见以下代码,分为4字节和不足4字节的情况

    if (remaining >= 4 && (offset & 3) == 0)
    {
        payload.putInt(start, payload.getInt(start) ^ maskInt);
        start += 4;
        offset += 4;
    }
    else
    {
        payload.put(start, (byte)(payload.get(start) ^ maskBytes[offset & 3]));
        ++start;
        ++offset;
    }
接收数据

如下图所示为服务端发送给客户端的消息没有掩码也没有启用插件,因此其RSV1,RSV2, RSV3 = 0

当然,你在测试过程中会发现浏览器和服务端一直在偷偷玩pongpongpong的游戏,如下图所示

事实上,这是服务端和客户端在玩心跳连接,如果你不知道心跳连接时啥子玩意,建议阅读

总结

关于WebSocket协议还有很多细节没有讲到,只讲了我学到的(●ˇ∀ˇ●),如果真正的要理解协议的设计的各个细节建议还是阅读RFC6455文档,毕竟信息经过传播都会存在一定程度失真。

学到了什么?

  • 一句话来说就是流是没有边界(出自张师傅的小册),一旦理解了这个概念理解其它基于TCP传输协议的应用层协议时候就很快了。

  • 所有适用于Socket的优化手段基本上也都适用于WebSocket,如NIO和AIO等

  • 启用数据压缩插件真的好吗?不见得, 还是得分情况,毕竟无论是数据压缩还是解压缩都是需要时间得,得问值不值得,具体就得看业务场景了

  • 基于散列函数的身份校验方法,简单来说就是将一堆参数和特定的字符串(密钥、魔数)以约定形式组合起来得出其散列值以供校验方校验请求方的身份信息。

有人看的话再谈谈其他方面的吧,看需求去了(^_^)