WebSocket 理论知识

警告
本文最后更新于 2023-12-12,文中内容可能已过时。

WebSocket 理论知识

基本信息

官网(已不可用):websocket.org - Powered by Kaazing

维基百科:WebSocket - 维基百科,自由的百科全书

协议规范:RFC6455

协议标准:WebSockets Standard,不怎么长,很快能看完。

这里提一句 WHATWG 这个组织:

WHATWG 是 Web Hypertext Application Technology Working Group 的缩写,即 Web 超文本应用技术工作组。这个组织是一个由志愿者组成的组织,致力于开发和维护 Web 标准。其目标是使 Web 成为一个更开放、更创新、更安全的平台。官方网站为WHATWG.org

我们在学习 JavaScript 的相关知识的时候,会经常访问这个组织维护的 HTML Standard

WebSocket 是一种类似于 HTTP 网络传输协议,两者都是基于 TCP 协议,但是 WebSocket 更加简单,也更加高效,浏览器和服务器只需要完成一次握手,两者之间就可以建立持久性的连接,并进行双向的数据传输,即

除了客户端可以请求服务端之后,服务端还可以在客户端没有请求的情况下主动向客户端推送数据,而通过 HTTP 是无法做到这一点的。简而言之就是,在 WebSocket 协议中,建立连接的两端是平等的

WebSocket 协议规范将ws(WebSocket)和wss(WebSocket Secure)定义为两个新的统一资源标识符(URI)方案[4],分别对应明文和加密连接。除了方案名称和片段 ID(不支持#)之外,其余的 URI 组件都被定义为此 URI 的通用语法。[5]。例如ws://example.com/wsapiwss://secure.example.com/wsapi

Websocket 与 HTTP 和 HTTPS 使用相同的 TCP端口,可以绕过大多数防火墙的限制。默认情况下,Websocket 协议使用 80 端口;运行在 TLS 之上时,默认使用 443 端口。

WebSocket 与 HTTP 的关系与比较

参考博客:websocket 优缺点、应用场景以及与 HTTP 协议的异同 - 掘金

WebSocket 是一种与HTTP不同的协议。两者都位于OSI 模型应用层,并且都依赖于传输层的 TCP 协议。虽然它们不同,但是RFC 6455中规定:it is designed to work over HTTP ports 80 and 443 as well as to support HTTP proxies and intermediaries(WebSocket 通过 HTTP 端口 80 和 443 进行工作,并支持 HTTP 代理和中介),从而使其与 HTTP 协议兼容。 为了实现兼容性,WebSocket 握手使用 HTTP Upgrade 头[1] 从 HTTP 协议更改为 WebSocket 协议。

也就是说,虽然 WebSocket 和 HTTP 是两个不同的协议,但是 WebSocket 协议在握手阶段的工作是基于 HTTP 协议的。这个我们在后面的请求分析阶段有所验证。

与 HTTP 协议的相同点

  • 都是基于 TCP 的应用层协议;
  • 都使用 Request/Response 模型进行连接的建立;
  • 在连接的建立过程中对错误的处理方式相同,在这个阶段 WS 可能返回和 HTTP 相同的返回码;
  • 都可以在网络中传输数据。

与 HTTP 协议的不同点

  • WS 使用 HTTP 来建立进行握手/连接,主要是通过定义了ConnectionUpgrade这两个消息头,而这两个消息头在 HTTP 中并不会使用;

    注意,在通过代理转发 WS 请求的时候,需要在代理服务器中手动设置ConnectionUpgrade这两个消息头,具体请看通过 Nginx 代理小节

  • WS 是 HTML5 中的协议,支持持久连接;而 Http 协议不支持持久连接。

  • WS 连接建立之后,通信双方都可以在任何时刻向另一方发送数据;

  • WS 连接建立之后,数据的传输使用帧来传递,不再需要 Request 消息,而且 WS 的数据帧有序。

HTTP 的缺点是什么

  • 无状态、短链接,客户端想要持续了解服务端的状态只能轮询。

  • 半双工,服务端无法直接给客户端发消息,

WebSocket 相比 HTTP 的优势

HTTP 是无状态的短链接,WebSocket 是有状态的长连接。WebSocket 对 HTTP 的优势主要还是在效率上有多方面的提升,这种效率的提升其实可以看作是长连接对短链接的提升。

长连接和短链接的区别请看下图:

可以看到 HTTP 每一次都是短连接,一个 TCP 连接的建立需要经过三四握手,因此 TCP 是比较重的,好不容易建立了连接,在 HTTP 协议中就用那么一会儿,虽然可以通过 TCP 复用来提高 TCP 连接的利用率,但是效率还是不如 WebSocket,在 WebSocket 中,一个 TCP 连接可以持续使用,而且是双向通信。

优势:

  • 传统的 http 请求,其并发能力都是依赖同时发起多个 TCP 连接访问服务器实现的 (因此并发数受限于浏览器允许的并发连接数),而 WebSocket 则允许我们在一条 ws 连接上同时并发多个请求,即在 A 请求发出后 A 响应还未到达,就可以继续发出 B 请求。由于 TCP 的慢启动特性(新连接速度上来是需要时间的),以及连接本身的握手损耗,都使得 WebSocket 协议相比 HTTP 协议有很大的效率提升。

  • HTTP 协议的消息头很占用空间,且每个请求携带的几百上千字节的消息头大部分是重复的,很多时候可能响应都远没有请求中的 header 空间大。如此多无效的内容传递是因为 HTTP 是无状态的,需要在每个请求都携带状态信息,而 WebSocket 握手成功之后,传递消息就开始采用 WebSocket 协议,开始以长连接传递消息,这就使得其成为一种有状态的协议,可以省略部分状态信息,WebSocket 协议传输的内容也需要包含类似于 HTTP 消息头之类的东西,主要是用于描述消息体,但是最多只需要十几个字节(记录消息头的长度和掩码),极大地提升了传递的效率。

    关于 WebSocket 的消息头,请看后文

  • WebSocket 支持服务器推送消息,这带来了及时消息通知的更好体验,也是 Ajax 请求无法达到的。

  • WebSocket 协议可以轻松地扩展,从而满足更多不同应用程序的需求。

WebSocket 缺点也是有的:

  • 服务器长期维护长连接需要一定的成本
  • 各个浏览器支持程度不一
  • 数据包大小的限制:WebSocket 协议发送的数据包不能超过 2GB。
  • WebSocket 是长连接,既然是长连接,就必须考虑断开重连的问题,因此我们在编码的时候就必须考虑任务执行过程中断连的情况,比如用户进电梯或电信用户打个电话网断了,这时候就需要重连。

主要用于做什么

WebSocket 解决了 HTTP 协议中传统轮询 (Traditional Polling)、长轮询 (Long Polling) 带来的问题(服务端负载、延迟等)。

WebSocket 在实时通信领域运用的比较多,比如社交聊天、弹幕、多玩家游戏、协同编辑、股票基金实时报价、体育实况更新、视频会议/聊天、基于位置的应用、在线教育、智能家居等需要高实时的场景。

WebSocket 的工作过程

参考博客:

WebSocket 详解(五):刨根问底 HTTP 与 WebSocket 的关系 (下篇)-网页端 IM 开发/专项技术区 - 即时通讯开发者社区!

万字长文,一篇吃透 WebSocket:概念、原理、易错常识、动手实践 - 网页端 IM 开发/专项技术区 - 即时通讯开发者社区!

WebSocket 协议深入探究_语言 & 开发_陈映平_InfoQ 精选文章

工作过程如下图所示:

首先,我们要清楚一点,WebSocket 基于 TCP,因此,我们需要通过三次握手,建立 TCP 连接,然后再开始进行通信。

《用电信号传输 TCP/IP 数据 —— 探索协议栈和网卡》的TCP 报文头详解

TCP 连接建立之后,首先由服务端发起一个 HTTP 请求到服务端,进行握手,握手之后,服务端和客户端同时升级 HTTP 协议为 WebSocket 协议,然后双方开始以 WebSocket 协议交换数据。

也就是说,WebSocket 的握手阶段,是通过 HTTP 协议实现的,然后后续服务端和客户端均采用的 WebSocket 协议,也是从当前已经建立的 HTTP 连接中升级而来,此时,我们会发现,WebSocket 对 HTTP 的兼容非常好,只要是能建立 HTTP 请求的地方,都可以实现 WebSocket 协议的通信,这也是 WebSocket 的握手使用 HTTP 来实现的目的,即最大的对 HTTP 的兼容。现有的网络环境(客户端、服务器、网络中间人、代理等)对 HTTP 都有很好的支持,所以这样做可以充分利用现有的 HTTP 的基础设施。

下面我们来详细分析握手和数据传输这两个阶段

通过 HTTP 握手

Websocket 通过 HTTP/1.1 协议的 101 状态码进行握手。

客户端发起的请求消息如下,注意必须是GET请求,且 HTTP 版本必须大于 1.1

1
2
3
4
5
6
7
8
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

对消息头/报头的分析如下:

  • Upgrade:Upgrade 是 HTTP1.1 中用于定义转换协议的 header 域。它表示,如果服务器支持的话,客户端希望使用现有的「网络层」已经建立好的这个「连接(此处是 TCP 连接)」,切换到另外一个「应用层」(此处是 WebSocket)协议;Upgrade 字段必须设置 Websocket,表示希望升级到 Websocket 协议。

  • Connection:HTTP1.1 中规定 Upgrade 只能应用在「直接连接」中,而 Connection 头的意义就是,任何接收到此消息的人(往往是代理服务器)都要在转发此消息之前处理掉 Connection 中指定的域,因此,所以带有 Upgrade 头的 HTTP1.1 消息,必须含有 Connection 头,且 Connection 消息头的内容必须包含 Upgrade,即不转发 Upgrade 域。如果客户端和服务器之间是通过代理连接的,那么这个代理必须对 Upgrade 和 Connection 进行转发,否则无法建立连接。

    具体请看后文的通过 Nginx 代理小节

  • Sec-WebSocket-Key:Sec-WebSocket-Key 的内容是随机的字符串,服务器端会接收到请求之后,会把Sec-WebSocket-Key拼接上一个特殊字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11(注意,这是一个固定值),然后计算SHA-1摘要,之后进行 Base64 编码,将结果做为Sec-WebSocket-Accept头的值,返回给客户端。客户端再对Sec-WebSocket-Accept进行检查,这样的设计只能提供基本的防护,比如恶意的连接,或者无意的连接。因为258EAFA5-E914-47DA-95CA-C5AB0DC85B11是一个固定值,而 HTTP 请求中的 header 又是很容易获得的,因此这样的设计避免不了中间人攻击。

  • Sec-WebSocket-Protocol 表示客户端支持的子协议的列表(关于子协议会在下面介绍)。

  • Sec-WebSocket-Version 表示支持的 Websocket 版本。RFC6455 要求使用的版本是 13,之前草案的版本均应当弃用。

  • Origin:作安全使用,防止跨站攻击,浏览器一般会使用这个来标识原始域。Origin 字段是必须的。如果缺少 origin 字段,WebSocket 服务器需要回复 HTTP 403 状态码(禁止访问)。

  • 其他一些定义在 HTTP 协议中的字段,如 Cookie 等,也可以在 Websocket 中使用。

如果服务器接受了这个请求,可能会发送如下这样的返回信息,这是一个标准的 HTTP 的 Response 消息。101 表示服务器收到了客户端切换协议的请求,并且同意切换到此协议。

RFC2616 规定只有切换到的协议「比HTTP1.1更好」的时候才能同意切换

服务端返回的响应:

1
2
3
4
5
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

如果返回的返回码不是 101,则按照 RFC2616 进行处理。如果是 101,进行下一步,开始解析 header 域,所有 header 域的值不区分大小写;

判断是否含有 Upgrade 头,且内容包含 websocket;

判断是否含有 Connection 头,且内容包含 Upgrade;

判断是否含有Sec-WebSocket-Accept头,其内容在下面介绍;

如果含有Sec-WebSocket-Extensions头,要判断是否之前的 Request 握手带有此内容,如果没有,则连接失败;

如果含有Sec-WebSocket-Protocol头,要判断是否之前的 Request 握手带有此协议,如果没有,则连接失败。

Sec-WebSocket-Key 与 Sec-WebSocket-Accept

服务端为了告知客户端它已经接收到了客户端的握手请求,服务端需要返回一个包含Sec-WebSocket-Accept的握手响应。这个值的信息来自于客户端的握手请求中的 Sec-WebSocket-Key 头字段:

  • 客户端握手中的 Sec-WebSocket-Key 头字段的值是采用 base64 编码的 16 字节随机数。
  • 服务端需将该值和固定的 GUID 字符串( 258EAFA5-E914-47DA-95CA-C5AB0DC85B11)拼接后使用 SHA-1 进行哈希,并采用 base64 编码后,作为响应握手的 Sec-WebSocket-Accept 值返回。
  • 客户端收到响应后,也必须按照服务端生成 Sec-WebSocket-Accept 的方式生成字符串,与服务端回传的进行对比,如果不同就标记连接为失败。

也就是说,服务端返回的 Header 字段 Sec-WebSocket-Accept 是根据客户端请求 Header 中的Sec-WebSocket-Key计算出来。

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 服务端,其实并没有实际性的保证。也避免不了中间人攻击,因为258EAFA5-E914-47DA-95CA-C5AB0DC85B11是一个固定值,而 HTTP 请求中的 header 又是很容易获得的,因此可以很容易地进行劫持,如果想要避免中间人攻击,得使用加密后的 WebSocket,即 wss。

通过 WebSocket 协议传输数据

WebSocket 中所有发送的数据使用帧的形式发送。客户端发送的数据帧都要经过掩码处理服务端发送的所有数据帧都不能经过掩码处理。否则对方需要发送关闭帧。

一个帧包含一个帧类型的标识码,一个负载长度,和负载。负载包括扩展内容和应用内容,结构很简单。

帧其实就是比特流,输出的时候,就是一个比特接着一个比特地输出。

针对上图,你可以理解为每一行的比特都是接在上一行最后一个比特的后面的,之所以用 32 个比特一行来拆分成多行,主要是为了方便展示,不然只用一行比特的话,直观是直观,但是不好看

第二行的0-9的循环,表示的是比特位,第一行0123表示的是比特数量的十位,一行总共 32 个比特,也就是 4 个字节。

  • FIN:1 个比特。

    • 如果是 1,表示这是消息(message)的最后一个分片(fragment),此时接收方已经收到完整的消息,可以对消息进行处理。当我们只需要用一个帧就可以传完的时候,这个帧的 FIN 为 1,当我们需要分片的时候,分片的最后一个帧 FIN 为 1。

    • 如果是 0,表示不是是消息(message)的最后一个分片(fragment),接收方还需要继续监听接收其余的数据帧。

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

  • Opcode: 操作代码,占 4 个比特,总共可以表示 16 个操作,用一个 16 进制数即可描述。Opcode 的值决定了应该如何解析后续的数据载荷(data payload)。如果操作代码是不认识的,那么接收端应该断开连接(fail the connection)。可选的操作代码如下:

    • 0x0:表示一个延续帧。当 Opcode 为 0 时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片。

    • 0x1:表示这是一个文本帧(frame)

    • 0x2:表示这是一个二进制帧(frame)

    • 0x3-0x7:保留的操作代码,用于后续定义的非控制帧。

    • 0x8:表示连接断开。

    • 0x9:表示这是一个 ping 操作。

    • 0xA:表示这是一个 pong 操作。

    • 0xB-0xF:保留的操作代码,用于后续定义的控制帧。

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

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

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

  • Payload length:数据载荷的长度,占用 7 个比特,或 7+16 个比特,或 1+64 个比特,单位是字节。假设数 Payload length === x,

    • x 为0~126:数据的长度为 x 字节。

    • x 为126:后续 2 个字节代表一个 16 位的无符号整数,该无符号整数的值为数据的长度。对应到上图就是帧的第一行的后面 16 个比特,

    • x 为 127:后续 8 个字节代表一个 64 位的无符号整数(最高位为 0),该无符号整数的值为数据的长度。对应到上图就是帧的第一行的后面 16 个比特,加上整个第二行 32 个比特,机上第三行的前面 16 个比特。

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

  • Masking-key:0 或 4 字节(32 位),所有从客户端传送到服务端的数据帧,数据载荷都进行了掩码操作,Mask 为 1,且携带了 4 字节的Masking-key。如果 Mask 为 0,则没有Masking-key。注意:载荷数据的长度,不包括Masking-key的长度。

  • Payload data:(x+y) 字节,载荷数据:包括了扩展数据、应用数据。其中,扩展数据 x 字节,应用数据 y 字节。

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

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

Payload data以外的部分,其实都是为了通信而增加的成本,当这个成本很大的时候,消息传递的效率就很低。在握手阶段结束后,采用 WebSocket 协议交换数据,通过上面对数据帧的分析,我们可以发现,用于协议控制的数据包头部相对 HTTP 来说是很小的。在不包含扩展的情况下,对于服务器到客户端的内容,此头部大小只有 2 至 10 字节(只包含 FIN、RSV1, RSV2, RSV3、Opcode、Mask、Payload length);对于客户端到服务器的内容,此头部还需要加上额外的 4 字节的掩码(Masking-key)。相对于 HTTP 请求每次都要携带完整的头部,此项开销显著减少了。

掩码算法

参考:websocket 报文格式及掩码处理方式 - 老耗子 - 博客园

掩码键Masking-key是由客户端生成的 32 位的随机数。掩码、反掩码操作均以字节为单位进行异或运算,而不是以比特为单位进行异或运算,掩码操作不会影响数据载荷的长度。

首先,介绍几个概念

  • original-octet-i:为原始数据的第 i 字节。

  • transformed-octet-i:为转换后的数据的第 i 字节。

  • j:为 i 对 4 取模也就是取余的结果。因为掩码总共 4 个字节嘛。

  • masking-key-octet-j:为 Masking-key 第 j 字节。

掩码、反掩码操作都采用如下算法:

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

1
2
j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j

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

1
2
j = i MOD 4
original-octet-i = transformed-octet-i XOR masking-key-octet-j

这里用到了一个定理,参与异或运算的两个数,异或运算的结果和其中一个数继续进行异或运算,可以得出另一个数

《Java 基本程序设计结构》的位运算符小节在讲解异或运算的时候介绍过

简单实践:

客户端:发送语音文件到服务端,先发送 txt 消息文件名称"tts";再读物文件,发送 bin 消息二进制流数据 (多帧发送)。

服务端:先接收 txt 消息,需要接收的文件名称并创建打开;再接收 bin 消息语音流消息,并写入文件;当接收到最后一帧二进制后,关闭打开的文件。

传输 txt 消息的时候,生成的掩码 KEY:00001110 00101001 10101100 11010000,四个字节对应的数字为 14、51、172、208。

客服端处理消息,字母 t 的 ASCII 码为 116,字母 s 的 ASCII 码为 115,“tts"转化为 ASCII 码为 116 116 115,

  • 第一个字节(i=1,j=1),116(t)与掩码 14 异或处理,01110100(116)和00001110(14)异或的结果为01111010,得到异或值:122,

  • 第二个字节(i=2,j=2),116(t)与掩码 51 异或处理,得到异或值:71

  • 第三个字节(i=3,j=3),115(s)与掩码 172 异或处理,得到异或值:223

最终发送的用于网络传输的消息的字节为 122,71,223

服务端收到消息数据:122,71,223 后,也获取了四字节的掩码00001110 00101001 10101100 11010000,四个字节对应的数字为 14、51、172、208。然后开始反掩码运算

  • 第一个字节(i=1,j=1),122 与掩码 14 异或处理,01111010(122)和00001110(14)异或的结果为01110100,得到异或值:116,为字母 t

  • 第二个字节(i=2,j=2),71 与掩码 51 异或处理,得到异或值:116,为字母 t

  • 第三个字节(i=3,j=3),223 与掩码 172 异或处理,得到异或值:115,为字母 s

解码获取字符串:116,116,115;即字符:tts

掩码的这个计算方式,其实并不能起到多大的安全作用,本质原因是因为一个消息帧中包含了掩码本身,经过简单的运算即可进行反编码操作拿到原始的消息,可见,掩码的设计,并不是为了防止数据泄密,而是为了防止早期版本的协议中存在的代理缓存污染攻击(proxy cache poisoning attacks)等问题。关于代理缓存污染攻击的原理可以参考WebSocket 协议深入探究数据掩码的作用小节。

数据分片

参考:WebSocket 协议深入探究_语言 & 开发_陈映平_InfoQ 精选文章

当我们只需要用一个帧就可以传完的时候,FIN 为 1,Opcode 为 1,表示传递的是一个文本,而且已经传完,如果是二进制帧,则 Opcode 为 2

当我们需要用多个帧来传递一个字符串的时候,每一个帧的情况如下,

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

与 HTTP 消息/报文的不同

HTTP 是文本协议,我们在看 HTTP 报文的时候一般看到的都是字符 (其实最终也是以 TCP 帧的格式传递),而 WebSocket 的消息并不是一个文本协议,其直接采用跟 TCP 一样的比文本更底层的二进制数据帧,这样可以直接对接 TCP 的 socket,我们在分析 WebSocket 的帧的时候,跟分析 tcp 的请求的时候感觉很像。

关于 TCP 的数据帧的分析,请看《用电信号传输 TCP/IP 数据 —— 探索协议栈和网卡》的TCP 报文头详解

总结

学完 WebSocket 之后,感觉基于 TCP,或者说基于 socket,开发一个自定义的网络协议,并不是一种难事。开发一种网络传输协议,实际上就是将数据以一种固定的格式序列化化成一个消息帧,然后在接收端根据协议格式将数据读取出来。TCP、HTTP 还有 WebSocket 都是如此。

关于如何将消息序列化成帧,请看《Java 序列化》

在学习了 Netty 之后,我们和可以很方便地进行 socket 编程,而且可以直接操作字节流,更多详细信息,请看《Netty 设计》和《Netty 基础实践》

通过 Nginx 代理

WebScoket 协议如需要通过 nginx 代理,需要 location 节点增加以下节点即可正常建立连接

1
2
3
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

一个是启用 HTTP 1.1,因为 Nginx 对 HTTP 的反向代理,默认使用 HTTP 1.0 连接到后端,那样没法保持长连接,后端作出 HTTP 响应后,连接就被掐断了,所以启用 HTTP 1.1 以支持长连接。

一个是设置消息头 Upgrade 和 Connection,为什么要让 Nginx 加这个请求头,对于 WebSocket 协议,客户端不是已经加了 Upgrade 和 Connection 请求头了吗?那是因为根据 HTTP 协议规范,Upgrade 和 Connection 属于 hop-by-hop 请求头(跳跳请求头),Nginx 作为中间的代理,按照规范不能直接转发 hop-by-hop header,所以需要我们手工强制设定。

关于什么是 hop-by-hop header,请看《nginxTips.md》

跨域

参考博客:javascript - Why is there no same-origin policy for WebSockets? Why can I connect to ws://localhost? - Stack Overflow

首先我们要搞清楚,什么同源策略 SOP(Same Origin Policy),所谓"同源"指的是"三个相同”。

  • 协议相同
  • 域名相同
  • 端口相同

举例来说,http://www.example.com/dir/page.html这个网址,协议是http://,域名是www.example.com,端口是80(默认端口可以省略)。它的同源情况如下。

  • http://www.example.com/dir2/other.html:同源
  • http://example.com/dir/other.html:不同源(域名不同)
  • http://v2.www.example.com/dir/other.html:不同源(域名不同)
  • http://www.example.com:81/dir/other.html:不同源(端口不同)

随着互联网的发展,“同源政策"越来越严格。目前,如果非同源,共有三种行为受到限制。

  • Cookie、LocalStorage 和 IndexDB 无法读取。

  • DOM 无法获得。

  • AJAX 请求不能发送。

关于跨域的详细知识,请看《跨域相关知识》

我们所说的跨域请求(Cross Origin Request),其实就是违反了同源策略的跨源请求。

这里有一点需要注意,并不是 JavaScript 中所有的 URL 请求都受到 SOP 的限制,而是这些 URL 请求中,只有通过 AJAX 发出的时候,才会被 SOP 限制,请求资源 (包括<img><link><script>标签) 的跨域 GET 请求和提交表单时的跨域 POST 请求一直被允许的,是可行的,这两种请求是 Web 的基本特性。现在,请求具有相同属性的跨域 AJAX 调用也被允许,称为简单跨域请求。但是,除非服务器的 CORS 消息头明确允许,否则不允许在代码中访问来自此类请求的返回数据。而且,正是因为某些恶意网站会发起这种简单跨域请求(主要是 POST 请求),所以服务器需要 anti-CSRF tokens 来保护自己(CSRF:Cross-site request forgery),以阻止数据被不信任的请求篡改。

为什么 WebSocket 可以跨域?

首先,WebSocket 建立连接是通过 WebSocket 的自己的客户端,而不是通过 AJAX,因此,是不受 SOP 限制的。

而且 WebSocket 协议本身不实行同源政策,只要服务器支持,就可以通过它进行跨源通信。在通过 HTTP 握手小节,分析 WebSocket 握手时的 HTTP 请求分析过,有一个消息头是Origin,表示该请求的请求源(origin),即发自哪个域名。这个消息头必须有,否则 WebSocket 服务器需要回复 HTTP 403 状态码(禁止访问),正是因为有了Origin这个字段,所以 WebSocket 才没有实行同源政策。因为服务器可以根据这个字段,判断是否许可本次通信。如果该域名在白名单内,,服务器就会正常响应。

其实也可以换个角度解释,为什么 WebSocket 不跟 HTTP 一样采用相同的同源限制设计(采用 CROS 之类的设计)。

对于 AJAX,在实行 SOP 策略的时代,服务器从不期望经过身份验证的浏览器从不同的域发送请求,因此不需要确保请求来自受信任的位置,只需检查会话 cookie。后来 SOP 有所放松 (如 CORS),必须进行客户端检查,以避免使现有应用程序遭受 CSRF(Cross-site request forgery)攻击。

如果 Web 是今天发明的,了解我们现在所知道的,那么 AJAX 就不需要 SOP 和 CORS 了,所有的验证都可能留给服务器。

WebSockets 作为一种较新的技术,从一开始就被设计为支持跨域场景,所有的对请求来源的验证和限制,都留给了服务器。任何编写服务器逻辑的人都应该意识到跨域请求的可能性,并执行必要的验证,而不需要像 CORS 那样采取严厉的浏览器端预防措施。


0%