编写 WebSocket 服务器

WebSocket 服务器无非是在 TCP 服务器的任何端口上监听、遵循特定协议的应用程序。如果您之前从未创建过自定义服务器,那么创建自定义服务器可能看起来很复杂。实际上,在您选择的平台上实现基本的 WebSocket 服务器实际上非常简单。

WebSocket 服务器可以使用任何能够使用 Berkeley 套接字 的服务器端编程语言编写,例如 C(++), Python, PHP服务器端 JavaScript。这不是任何特定语言的教程,而是一个指导,旨在帮助您编写自己的服务器。

本文假设您已经熟悉 HTTP 的工作原理,并且您具备中等水平的编程经验。根据语言支持情况,可能需要了解 TCP 套接字。本指南的范围是介绍编写 WebSocket 服务器所需的最低知识。

注意:阅读最新的官方 WebSockets 规范,RFC 6455。第 1 节和第 4-7 节对服务器实现者尤其重要。第 10 节讨论安全性,您在公开服务器之前绝对应该仔细阅读它。

本文以非常低级的水平解释了 WebSocket 服务器。WebSocket 服务器通常是独立的、专门的服务器(出于负载均衡或其他实际原因),因此您通常会使用 反向代理(例如,普通的 HTTP 服务器)来检测 WebSocket 握手,预处理它们,并将这些客户端发送到真正的 WebSocket 服务器。这意味着您不必用 cookie 和身份验证处理程序(例如)来膨胀服务器代码。

WebSocket 握手

首先,服务器必须使用标准 TCP 套接字监听传入的套接字连接。根据您的平台,这可能会自动为您处理。例如,假设您的服务器在 example.com 的 8000 端口上监听,并且您的套接字服务器响应 example.com/chat 上的 GET 请求。

警告:服务器可以在任何它选择的端口上监听,但如果它选择 80 或 443 之外的任何端口,它可能会遇到防火墙和/或代理问题。浏览器通常要求 WebSocket 使用安全连接,尽管它们可能为本地设备提供例外。

握手是 WebSockets 中的“Web”。它是从 HTTP 到 WebSockets 的桥梁。在握手过程中,连接的详细信息将被协商,如果条款不利,任何一方都可以在完成之前退出。服务器必须谨慎地理解客户端要求的所有内容,否则可能会出现安全问题。

注意:请求 URI(此处为 /chat)在规范中没有定义的含义。因此,许多人使用它让一台服务器处理多个 WebSocket 应用程序。例如,example.com/chat 可以调用一个多人聊天应用程序,而同一服务器上的 /game 可能会调用一个多人游戏。

客户端握手请求

即使您正在构建服务器,客户端仍然必须通过联系服务器并请求 WebSocket 连接来启动 WebSocket 握手过程。因此,您必须知道如何解释客户端的请求。客户端将发送一个非常标准的 HTTP 请求,其中包含类似于以下内容的标头(HTTP 版本**必须**为 1.1 或更高,并且方法**必须**为 GET

http
GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

客户端可以在这里请求扩展和/或子协议;有关详细信息,请参阅 其他。此外,常见的标头,如 User-AgentRefererCookie 或身份验证标头也可能存在。对它们做任何你想做的事情;它们与 WebSocket 没有直接关系。忽略它们也是安全的。在许多常见的设置中,反向代理已经处理了它们。

注意:所有浏览器都会发送 Origin 标头。您可以使用此标头来进行安全操作(检查相同来源、自动允许或拒绝等),并在您不喜欢看到的内容时发送 403 Forbidden。这对于防范 跨站 WebSocket 劫持 (CSWH) 很有效。但是,请注意,非浏览器代理可以发送伪造的 Origin。大多数应用程序会拒绝没有此标头的请求。

如果任何标头未被理解或其值不正确,服务器应发送 400(“错误请求”)响应并立即关闭套接字。像往常一样,它也可以在 HTTP 响应正文中给出握手失败的原因,但该消息可能永远不会显示(浏览器不会显示它)。如果服务器不理解该版本的 WebSockets,它应发送一个包含其理解的版本(s)的 Sec-WebSocket-Version 标头。在上面的示例中,它指示 WebSocket 协议的版本 13。

这里最有趣的标头是 Sec-WebSocket-Key。让我们接下来看一下它。

注意:常规 HTTP 状态码 只能在握手之前使用。握手成功后,您必须使用另一组代码(在规范的第 7.4 节中定义)。

服务器握手响应

服务器收到握手请求时,它应发送一个特殊的响应,指示协议将从 HTTP 更改为 WebSocket。该标头看起来类似于以下内容(记住每行标头以 \r\n 结尾,并在最后一行之后添加额外的 \r\n 来指示标头的结尾)

http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

此外,服务器可以在这里决定扩展/子协议请求;有关详细信息,请参阅 其他Sec-WebSocket-Accept 标头很重要,因为服务器必须从客户端发送给它的 Sec-WebSocket-Key 中推导出它。要获得它,请将客户端的 Sec-WebSocket-Key 和字符串 "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 连接在一起(这是一个 "魔术字符串"),对结果进行 SHA-1 哈希,并返回该哈希的 base64 编码。

注意:这个看似过于复杂的流程是为了让客户端清楚地知道服务器是否支持 WebSockets。这很重要,因为如果服务器接受了 WebSockets 连接但将数据解释为 HTTP 请求,可能会出现安全问题。

因此,如果 Key 为 "dGhlIHNhbXBsZSBub25jZQ==",则 Sec-WebSocket-Accept 标头的值为 "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="。一旦服务器发送了这些标头,握手就完成了,您就可以开始交换数据了!

注意:服务器可以在发送回复握手之前发送其他标头,如 Set-Cookie,或通过其他状态码要求身份验证或重定向。

跟踪客户端

这与 WebSocket 协议没有直接关系,但值得一提的是:您的服务器必须跟踪客户端的套接字,这样您就不会与已经完成握手的客户端再次握手。同一个客户端 IP 地址可以尝试连接多次。但是,如果客户端尝试连接的次数过多,服务器可以拒绝它们,以防止服务器受到 拒绝服务攻击

例如,您可能会保存一个包含用户名或 ID 号码以及相应 WebSocket 和您需要与该连接关联的其他数据的表格。

交换数据帧

客户端或服务器可以选择在任何时间发送消息——这就是 WebSockets 的魔力。但是,从这些所谓的“帧”数据中提取信息并非那么神奇。尽管所有帧都遵循相同的特定格式,但从客户端到服务器的数据使用 XOR 加密(使用 32 位密钥)进行掩码。规范的第 5 节详细介绍了这一点。

格式

每个数据帧(从客户端到服务器或反之亦然)都遵循相同的格式

bash
Frame format:

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

这意味着一个帧包含以下字节

  • 第一个字节
    • 位 0:FIN
    • 位 1:RSV1
    • 位 2:RSV2
    • 位 3:RSV3
    • 位 4-7:OPCODE
  • 字节 2-10:有效负载长度(请参阅 解码有效负载长度
  • 如果使用掩码,接下来的 4 个字节将包含掩码密钥(请参阅 读取和取消掩码数据
  • 所有后续字节都是有效负载

MASK 位告诉您消息是否被编码。来自客户端的消息必须被掩码,因此您的服务器必须预期它为 1。(实际上,规范的第 5.1 节 规定,如果客户端发送未掩码的消息,您的服务器必须断开与客户端的连接。)当将帧发送回客户端时,不要对其进行掩码,也不要设置掩码位。我们将在后面解释掩码。注意:即使使用安全套接字,您也必须掩码消息。 RSV1-3 可以忽略,它们用于扩展。

操作码字段定义了如何解释有效负载数据:0x0 表示继续,0x1 表示文本(始终以 UTF-8 编码),0x2 表示二进制,以及其他将在后面讨论的“控制代码”。在这个版本的 WebSockets 中,0x30x7 以及 0xB0xF 没有任何意义。

FIN 位指示这是否是系列中的最后一条消息。如果为 0,则服务器将继续侦听消息的更多部分;否则,服务器应将消息视为已传递。稍后将详细介绍。

解码有效负载长度

要读取有效负载数据,您必须知道何时停止读取。这就是为什么有效负载长度很重要。不幸的是,这有点复杂。要读取它,请执行以下步骤:

  1. 读取位 9-15(含)并将它们解释为无符号整数。如果它小于或等于 125,那么这就是长度;您已完成。如果它为 126,请转到步骤 2。如果它为 127,请转到步骤 3。
  2. 读取接下来的 16 位并将它们解释为无符号整数。您已完成
  3. 读取接下来的 64 位并将它们解释为无符号整数。(最高有效位必须为 0。)您已完成

读取和取消掩码数据

如果 MASK 位已设置(对于客户端到服务器消息应该设置),请读取接下来的 4 个八位字节(32 位);这是掩码密钥。解码有效负载长度和掩码密钥后,您可以从套接字中读取该字节数。我们称数据为ENCODED,密钥为MASK。要获取DECODED,请遍历ENCODED 的八位字节(对于文本数据,字节也称为字符)并使用MASK 的(i 模 4)个八位字节对八位字节进行异或运算。以下是伪代码(恰好是有效的 JavaScript):

js
const MASK = [1, 2, 3, 4]; // 4-byte mask
const ENCODED = [105, 103, 111, 104, 110]; // encoded string "hello"

// Create the byte Array of decoded payload
const DECODED = Uint8Array.from(ENCODED, (elt, i) => elt ^ MASK[i % 4]); // Perform an XOR on the mask

现在,您可以根据应用程序确定DECODED 的含义。

消息分段

FIN 和操作码字段协同工作以将消息拆分为单独的帧。这称为消息分段。分段仅在操作码 0x00x2 上可用。

回想一下,操作码指示帧的用途。如果它为 0x1,则有效负载为文本。如果它为 0x2,则有效负载为二进制数据。但是,如果它为 0x0,则该帧是延续帧;这意味着服务器应将该帧的有效负载与它从该客户端接收的最后一个帧连接起来。以下是一个粗略的草图,其中服务器对客户端发送文本消息做出反应。第一条消息在单个帧中发送,而第二条消息跨三个帧发送。FIN 和操作码详细信息仅显示在客户端

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!

请注意,第一个帧包含整条消息(具有 FIN=1opcode!=0x0),因此服务器可以根据需要进行处理或响应。客户端发送的第二个帧具有文本有效负载(opcode=0x1),但整条消息尚未到达(FIN=0)。该消息的所有剩余部分都通过延续帧(opcode=0x0)发送,并且消息的最后一帧用 FIN=1 标记。 规范的第 5.4 节 描述了消息分段。

Ping 和 Pong:WebSockets 的心跳

在握手后的任何时间点,客户端或服务器都可以选择向对方发送 ping。接收到 ping 后,接收方必须尽快发送 pong。例如,您可以使用它来确保客户端仍处于连接状态。

ping 或 pong 只是一个常规帧,但它是控制帧。ping 的操作码为 0x9,pong 的操作码为 0xA。当您收到 ping 时,请使用与 ping 完全相同的有效负载数据发送 pong(对于 ping 和 pong,最大有效负载长度为 125)。您也可能会在不发送 ping 的情况下收到 pong;如果发生这种情况,请忽略它。

注意:如果您在有机会发送 pong 之前收到了多个 ping,您只发送一个 pong。

关闭连接

要关闭连接,客户端或服务器都可以发送包含指定控制序列的数据的控制帧以开始关闭握手(详见 第 5.5.1 节)。接收到此类帧后,另一端将发送一个 Close 帧作为响应。然后,第一个对等方关闭连接。在关闭连接后接收的任何进一步数据都将被丢弃。

其他

注意:WebSocket 代码、扩展、子协议等在 IANA WebSocket 协议注册表 中注册。

WebSocket 扩展和子协议在 握手 期间通过标头进行协商。有时扩展和子协议非常相似,但存在明显的区别。扩展控制 WebSocket 修改有效负载,而子协议构建 WebSocket 有效负载并且从不修改任何内容。扩展是可选的和通用的(例如压缩);子协议是强制性的和本地的(例如用于聊天和 MMORPG 游戏的子协议)。

扩展

将扩展视为在通过电子邮件将文件发送给某人之前压缩该文件。无论您做什么,您都以不同的形式发送相同的数据。接收方最终将能够获得与本地副本相同的数据,但它以不同的方式发送。这就是扩展的作用。WebSockets 定义了一种协议和一种简单的发送数据的方法,但诸如压缩之类的扩展可以允许发送相同的数据,但以更短的格式。

注意:规范的第 5.8、9、11.3.2 和 11.4 节解释了扩展。

子协议

将子协议视为自定义的 XML 架构文档类型定义。您仍然使用 XML 及其语法,但您还受到您商定的结构的限制。WebSocket 子协议就是这样。它们没有引入任何花哨的东西,它们只是建立结构。与文档类型或架构一样,双方都必须就子协议达成一致;与文档类型或架构不同,子协议在服务器上实现,并且客户端无法外部引用。

注意:规范的第 1.9、4.2、11.3.4 和 11.5 节解释了子协议。

客户端必须请求特定的子协议。为此,它将发送类似以下内容作为原始握手的一部分

http
GET /chat HTTP/1.1
...
Sec-WebSocket-Protocol: soap, wamp

或等效地

http
...
Sec-WebSocket-Protocol: soap
Sec-WebSocket-Protocol: wamp

现在,服务器必须从客户端建议的协议中选择一个它支持的协议。如果有多个,则发送客户端发送的第一个。假设我们的服务器可以使用 soapwamp。那么,在响应握手中,它将发送

bash
Sec-WebSocket-Protocol: soap

警告:服务器不能发送多个 Sec-Websocket-Protocol 标头。如果服务器不想使用任何子协议,它不应该发送任何 Sec-WebSocket-Protocol 标头。发送空白标头是错误的。如果客户端没有获得它想要的子协议,它可能会关闭连接。

如果您希望服务器遵守某些子协议,那么自然地,您需要在服务器上进行额外的代码。假设我们使用子协议 json。在此子协议中,所有数据都作为 JSON 传递。如果客户端请求此协议并且服务器希望使用它,则服务器需要一个 JSON 解析器。实际上,这将是库的一部分,但服务器需要在周围传递数据。

注意:为了避免命名冲突,建议将子协议名称作为域字符串的一部分。如果您正在构建使用专有格式(仅限于 Example Inc.)的自定义聊天应用程序,那么您可能会使用以下内容:Sec-WebSocket-Protocol: chat.example.com。请注意,这不是必需的,它只是一个可选约定,您可以使用任何您想要的字符串。