编写 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)
GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
客户端可以在此处请求扩展和/或子协议;有关详细信息,请参阅杂项。此外,诸如 User-Agent、Referer、Cookie 等常见标头或身份验证标头也可能存在。您可以随意处理它们;它们与 WebSocket 没有直接关系。忽略它们也是安全的。在许多常见的设置中,反向代理已经处理了它们。
注意:所有浏览器都会发送一个Origin 标头。您可以使用此标头进行安全检查(检查同源、自动允许或拒绝等),如果您不喜欢看到的内容,则发送 403 Forbidden。这对于跨站点 WebSocket 劫持 (CSWH) 是有效的。但是,请注意非浏览器代理可以发送伪造的 Origin。大多数应用程序会拒绝没有此标头的请求。
如果任何标头无法理解或值不正确,服务器应发送 400(“错误请求”)响应并立即关闭套接字。通常,它还可以在 HTTP 响应正文中给出握手失败的原因,但该消息可能永远不会显示(浏览器不显示它)。如果服务器不理解该版本的 WebSockets,它应该返回一个 Sec-WebSocket-Version 标头,其中包含它理解的版本。在上面的示例中,它表示 WebSocket 协议的第 13 版。
这里最有趣的标头是 Sec-WebSocket-Key。接下来我们来看它。
注意:常规 HTTP 状态码只能在握手之前使用。握手成功后,您必须使用一组不同的代码(在规范的 7.4 节中定义)。
服务器握手响应
当服务器收到握手请求时,它应该发回一个特殊响应,表明协议将从 HTTP 更改为 WebSocket。该标头看起来像以下内容(请记住每行标头都以 \r\n 结尾,并在最后一个标头后添加一个额外的 \r\n 以表示标头结束)
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 节详细描述了这一点。
格式
每个数据帧(从客户端到服务器或反之亦然)都遵循相同的格式
Data frame from the client to server (message length 0–125): 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 | Masking-key | |I|S|S|S| (4) |A| (7) | (32) | |N|V|V|V| |S| | | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + : Payload Data continued ... : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+ Data frame from the client to server (16-bit message length): 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) | |N|V|V|V| |S| (== 126) | | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+-------------------------------+ | Masking-key | +---------------------------------------------------------------+ : Payload Data : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+ Data frame from the server to client (64-bit payload length): 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) | (64) | |N|V|V|V| |S| (== 127) | | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + | Extended payload length continued | + - - - - - - - - - - - - - - - +-------------------------------+ | | Masking-key | +-------------------------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + : Payload Data continued ... : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+
这意味着一个帧包含以下字节
- 第一个字节
- Bit 0 FIN:指示这是否是系列中的最后一条消息。如果为 0,则服务器继续监听消息的更多部分;否则,服务器应认为消息已送达。稍后会详细介绍。
- 位 1–3 RSV1、RSV2、RSV3:可以忽略,它们用于扩展。
- 位 4-7 OPCODE:定义如何解释有效载荷数据:
0x0表示继续,0x1表示文本(总是以 UTF-8 编码),0x2表示二进制,以及其他稍后将讨论的所谓“控制代码”。在此版本的 WebSockets 中,0x3到0x7和0xB到0xF没有意义。
- 位 8 MASK:指示消息是否已编码。来自客户端的消息必须被掩码,因此您的服务器必须期望此位为 1。(实际上,规范的 5.1 节指出,如果客户端发送未掩码的消息,您的服务器必须与该客户端断开连接。)服务器到客户端的消息未被掩码,此位设置为 0。我们稍后将在读取和取消掩码数据中解释掩码。注意:即使使用安全套接字,也必须掩码消息。
- 位 9-15:有效载荷长度。也可能包括接下来的 2 字节或 8 字节;请参阅解码有效载荷长度。
- 如果使用掩码(对于客户端到服务器的消息总是如此),接下来的 4 个字节包含掩码键;请参阅读取和取消掩码数据。
- 所有后续字节都是有效载荷。
解码有效载荷长度
要读取有效载荷数据,您必须知道何时停止读取。这就是为什么有效载荷长度很重要。不幸的是,这有些复杂。要读取它,请遵循以下步骤
- 读取位 9-15(包含)并将其解释为无符号整数。如果它小于或等于 125,那么这就是长度;您已完成。如果它是 126,转到步骤 2。如果它是 127,转到步骤 3。
- 读取接下来的 16 位,并将其解释为无符号整数。您已完成。
- 读取接下来的 64 位,并将其解释为无符号整数。(最高有效位必须为 0。)您已完成。
读取并解除数据掩码
如果 MASK 位已设置(并且对于客户端到服务器的消息,它应该已设置),请读取接下来的 4 个八位字节(32 位);这是掩码密钥。解码有效载荷长度和掩码密钥后,您可以从套接字读取该数量的字节。我们将数据称为 ENCODED,密钥称为 MASK。要获取 DECODED,遍历 ENCODED 的八位字节,并用 MASK 的第 (i modulo 4) 个八位字节对八位字节进行 XOR 运算。以 JavaScript 为例
// The function receives the frame as a Uint8Array.
// firstIndexAfterPayloadLength is the index of the first byte
// after the payload length, so it can be 2, 4, or 10.
function getPayloadDecoded(frame, firstIndexAfterPayloadLength) {
const mask = frame.slice(
firstIndexAfterPayloadLength,
firstIndexAfterPayloadLength + 4,
);
const encodedPayload = frame.slice(firstIndexAfterPayloadLength + 4);
// XOR each 4-byte sequence in the payload with the bitmask
const decodedPayload = encodedPayload.map((byte, i) => byte ^ mask[i % 4]);
return decodedPayload;
}
const frame = Uint8Array.from([
// FIN=1, RSV1-3=0, opcode=0x1 (text)
0b10000001,
// MASK=1, payload length=5
0b10000101,
// 4-byte mask
1, 2, 3, 4,
// 5-byte payload
105, 103, 111, 104, 110,
]);
// Assume you got the number 2 from properly decoding the payload length
const decoded = getPayloadDecoded(frame, 2);
现在,您可以根据您的应用程序弄清楚 decoded 的含义。例如,如果它是文本消息,您可以将其解码为 UTF-8。
console.log(new TextDecoder().decode(decoded)); // "hello"
掩码是一种安全措施,可避免恶意方预测发送到服务器的数据。客户端将为每条消息生成一个加密随机掩码密钥。
消息分片
FIN 和 opcode 字段协同工作,将消息分割成独立的帧发送。这称为消息分片。分片仅适用于操作码 0x0 到 0x2。
回想一下,操作码说明了帧的用途。如果它是 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=1 且 opcode!=0x0),因此服务器可以根据需要进行处理或响应。客户端发送的第二个帧包含文本有效载荷(opcode=0x1),但整个消息尚未到达(FIN=0)。该消息的所有剩余部分都通过继续帧(opcode=0x0)发送,消息的最终帧由 FIN=1 标记。规范的 5.4 节描述了消息分片。
Pings 和 Pongs:WebSocket 的心跳
在握手之后的任何时候,客户端或服务器都可以选择向对方发送 ping。收到 ping 后,接收方必须尽快发送 pong。例如,您可以使用此功能来确保客户端仍然连接。
ping 或 pong 只是一个常规帧,但它是一个控制帧。ping 的操作码为 0x9,pong 的操作码为 0xA。当您收到 ping 时,发送一个与 ping 具有完全相同有效载荷数据的 pong(对于 ping 和 pong,最大有效载荷长度为 125)。您也可能在从未发送 ping 的情况下收到 pong;如果发生这种情况,请忽略它。
注意:如果您在有机会发送 pong 之前收到了不止一个 ping,您只需发送一个 pong。
关闭连接
要关闭连接,客户端或服务器都可以发送一个控制帧,其中包含指定控制序列的数据,以开始关闭握手(详细信息请参见第 5.5.1 节)。收到这样的帧后,另一个对等体发送一个关闭帧作为响应。然后第一个对等体关闭连接。连接关闭后收到的任何进一步数据都将被丢弃。
杂项
注意: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 节中解释。
客户端必须请求一个特定的子协议。为此,它将发送类似这样的内容作为原始握手的一部分
GET /chat HTTP/1.1
...
Sec-WebSocket-Protocol: soap, wamp
或者,等价地
...
Sec-WebSocket-Protocol: soap
Sec-WebSocket-Protocol: wamp
现在服务器必须选择客户端建议并支持的协议之一。如果有一个以上,则发送客户端发送的第一个。想象一下我们的服务器可以使用 soap 和 wamp。那么,在响应握手中,它会发送
Sec-WebSocket-Protocol: soap
警告:服务器不能发送多个 Sec-WebSocket-Protocol 标头。如果服务器不想使用任何子协议,它不应该发送任何 Sec-WebSocket-Protocol 标头。发送一个空白标头是不正确的。如果客户端没有得到它想要的子协议,它可能会关闭连接。
如果您希望您的服务器遵守某些子协议,那么自然您需要在服务器上添加额外的代码。让我们想象我们正在使用一个名为 json 的子协议。在这个子协议中,所有数据都以 JSON 形式传递。如果客户端请求此协议并且服务器希望使用它,则服务器需要有一个 JSON 解析器。实际上,这将是库的一部分,但服务器需要传递数据。
注意:为了避免名称冲突,建议将您的子协议名称作为域字符串的一部分。如果您正在构建一个使用 Example Inc. 专有格式的自定义聊天应用程序,那么您可以使用:Sec-WebSocket-Protocol: chat.example.com。请注意,这不是必需的,它只是一个可选约定,您可以使用任何您喜欢的字符串。