编写 WebSocket 服务器

这篇翻译不完整。请帮忙从英语翻译这篇文章

概述

一个 WebSocket 服务器说白了就是一个监听着协议规定的端口的 TCP 程序。估计很多人看见“创建一个服务器”都会被吓跑,但是实际上在你所选择的平台上创建一个简单 WebSocket 服务器也不难。

一个 WebSocket 服务器可以用各种支持 Berkeley sockets 的服务端编程语言来编写,比如 C(++) 或者 Python,甚至 PHP服务端的 JavaScript。本文并不是某个特定语言的教程,只是作为一个帮助你编写自己服务的指南。

你首先要了解 HTTP 工作原理,以及中等的编程水平。取决于编程语言的支持,TCP套接字的知识也可能会需要。这篇指南的内容范围仅呈现出要编写 WebSocket 服务器所需要的基础知识。

记得阅读最新版本的 WebSockets 标准 RFC 6455,尤其对于服务器程序开发者要特别关注第一章节和第4 - 7章节 。第10章节讲的是安全问题,因此在你暴露你的服务器之前,你一定要仔细阅读。

这里简单介绍 WebSocket 服务器。WebSocket 服务器经常是分布部署(为了负载均衡或者其他现实因素),因此你可以用一个反向代理(比如常规的 HTTP 服务器)来探测 WebSocket 握手信息并提前处理好,然后把这些客户端信息发送给真正的 WebSocket 服务器。这就意味着,你不会为了处理cookie和身份验证(举个例子)而增加服务端代码。

第一步: WebSocket 握手

首先,服务器必须通过标准 TCP 套接字来侦听外来的连接请求。取决于你的平台,可能已经处理好握手了。举个例子,我们假定你的服务监听在地址 example.com ,端口号 8000 上,而且能够回应路径 /chat 上的 GET 请求。

警告: 服务器监听任意所选择的端口,但是如果在 80 或者 443 之外的端口上,可能会在防火墙或者代理上出问题。在 443 端口上比较容易成功,但是需要安全连接(TLS/SSL)。另外就是大多数浏览器(比如 Firefox 8+)不允许在安全页面上连接不安全的 WebSocket 服务器。

握手这一环节就是 WebSockets 上的“web”。它是从 HTTP 到 WS 的桥梁。握手时会协商连接的详细信息,并且如果条件不适合,任何一方都可以在成功建立连接之前退出。服务器一定要清楚客户端所要求的一切信息,否则可能会发生安全问题。

客户端握手请求

即使你正在构建一个服务,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,RefererCookie, or authentication 等也可能会出现。你可以随意处理这些内容,它们并不直接属于WebScoket的一部分,忽略它们也没事。在许多的常见设置中,反向代理已经对它们进行了处理。

如果存在任何未知或者错误的请求头,服务器应该发送一个 "400 Bad Request"并立即关闭 socket。像往常一样,也可以在HTTP响应体中给出握手失败的原因,但是这个消息可能从来都不会显示出来 (浏览器不显示)。 如果服务器无法理解请求的WebSockets的版本,它应该发送一个包含它能理解的版本号的Sec-WebSocket-Version的头部信息。 (本指南解释了最新版本的v13)。现在让我们来了解一下最让人期待的头部信息,Sec-WebSocket-Key。

提示: 所有的浏览器都会发送 Origin 请求头,你可以用它来处理安全问题(比如黑名单,白名单,origin检查之类的)然后发送回去 403 Forbidden 来禁止连接。但是要注意,非浏览器客户端可以发送伪造的 Origin。大多数应用如果没有检测到 Origin 请求头将会拒绝请求。

提示: 请求地址 (例如这里是 /chat ) 在规范中没有明确定义。所以许多人巧妙的利用这点,让一个服务器处理多个 WebSocket 应用。例如在 example.com/chat 上是一个聊天室,与此同时在 /game 上运行一个多人联机游戏。

注意: 常规 HTTP 状态码 只能在握手前使用。握手成功后,你就得用不同的代码了 (参阅规范的第7章第4节 ).

服务器握手响应

服务器收到握手请求后,就应该发送一个看起来很怪异 (但是仍然是 HTTP ) 的响应,就像这样:(记得每一个响应头之间用 \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 和规定中指明的 魔法字符串 "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 简单地连在一起,进行 SHA-1 Hash,然后用 base64 编码,返回结果即可。

仅供参考: 这个过程对于判断服务器是否支持WebSockets这个很明显的事情来说可能显得太过复杂。但是,这非常重要,如果服务器接受了一个WebSockets的链接却按照HTTP请求来解析可能会带来安全上的问题。

所以假设客户端发来的是 "dGhlIHNhbXBsZSBub25jZQ==",服务器就回复 "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="。一旦服务器发送完这些响应头,连接就建立了,可以开始交换数据了。

当然服务器也可以发送 Set-Cookie 之类的头,或者关于认证的请求,或者通过其他状态码重定向,总之只要在握手响应信息发出之前都可以。

持续追踪客户端

这部分和Websocket协议没有直接关系,但请务必注意:你的服务器必须持续追踪每个客户端的socket以避免进行重复的握手。同一个客户IP地址可能尝试连接多次(但服务器为保护自己免受DoS拒绝服务式攻击,也可拒绝那些过多请求连接的客户端)。

第二步:收发数据帧

客户端和服务端都能在任意时候发送数据——这是WebSocket的神奇之处。但从这些"帧"提取数据就是不是那么愉快的体验了。虽然所有帧都用一种格式,但从客户端发到服务端的数据是被 XOR异或加密 (用一个 32 位的key)掩蔽的。本规范的第 5 节详细介绍这个问题。

数据帧格式

(不管是从客户端到服务端还是相反) 每个数据帧的格式都是:

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

RSV1-3 可忽略,那是用于扩展的。

MASK 位表明报文是否被编码(按:即前述XOR异或加密)。客户端发来的消息应该被掩蔽,所以你的服务器应该期待这个位为1。(事实上,规范的5.1节说如客户端发来“未掩蔽”的报文那服务端应该断开这个连接。)服务端回报文给客户端则不要掩蔽数据,相应的MASK位为0。稍后我们将解释掩蔽。注意:即使在一个安全套接字上传输数据,也要遵守上述规则

 

操作码(opcode) 字段定义怎样解释负载数据: 字段定义了如何解释有效负载数据:  0x0 为继续,0x1 文本 (以 utf-8 编码),0x2 为二进制数据,和后面将要讨论的其他所谓"控制代码"。在此版本的 Websocket,0x3 到 0x7 和 0xB 到 0xF 无意义。

FIN 位表示是否一个连续报文的最后一帧。如果它为 0,服务器将继续侦听后续报文;否则,服务器应该认为这个报文已经收完。稍后详述。

读解负载数据长度

读取负载数据,需要知道读到那里为止。因此获知负载数据长度很重要。这个过程稍微有点复杂,要以下这些步骤:

  1. 读取9-15位 (包括9和15位本身),并转换为无符号整数。如果值小于或等于125,这个值就是长度;如果是 126,请转到步骤 2。如果它是 127,请转到步骤 3。
  2. 读取接下来的 16 位并转换为无符号整数,并作为长度。
  3. 读取接下来的 64 位并转换为无符号整数 (最高有效位必须为0),并作为长度。

读取并解码数据

如果 MASK 位被置1 (客户端到服务器的报文应该置1),接下来的 4 个位组 (32 位) 是掩码密钥。拿到载荷数据长度和掩码密钥后,你可以继续从套接字读取相应长度的字节。我们称数据为编码 (ENCODED),密钥掩码 (MASK)。为了得到解码 (DECODED),循环编码的位组 (又名字节 文本数据的字符),并用掩码的某个位组 (i对4取模的那个) 来异或编码的每个位组。伪代码如下 (有效的JavaScript代码)

 var DECODED = "";
for (var i = 0; i < ENCODED.length; i++) {
    DECODED[i] = ENCODED[i] ^ MASK[i % 4];
}

现在对于你的应用,你可以理解这些解码的含义了。

报文分段

FIN 位和操作码字段共同起到把一个报文分成几个数据帧发送的作用。这就叫做报文分段,分段只在操作码为 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并且操作码不等于 0x0),所以服务器可以直接以合适的方式来处理或者回应。客户端发送的第二帧有一个文本载荷( 操作码等于 0x1),但是完整的报文还未到达 (FIN等于0)。所有报文剩余的部分通过连续的帧( 操作码等于0x0)发送过来,并且最后一帧的 FIN 被设为1。规范的5.4节 描述了报文分段

Ping包和Pong包:  WebSocket的心跳包

在握手后的任何一个时间点,客户端或者服务器可以选择发送一个ping包给对方。当ping包被收到时,接受者必须尽可能快的发回pong包。例如,你可以使用这个机制来确定客户端仍然处于连接状态。

一个ping包或者pong包就是一个常规的数据帧,但是他是控制帧 (control frame)。ping包的操作码为 0x9,pong包的操作码为 0xA。当你收到一个ping包时,发回一个和ping包的载荷数据完全相同 (对于ping包和pong包,最大有效负载的长度为125) 的pong包。你也可能在从未发送ping包的情况下收到pong包,如果这发生了,忽略它。

如果你在有机会发回pong包之前收到了超过一个以上的ping包,你只要发送回一个pong包即可。

第 4 步: 关闭连接

要关闭一个连接的话,客户端或者服务端可以发送一个带有一段特殊控制序列数据的控制帧,来开始挥手过程 (详情见第5.5.5节)。一旦接收到这样的帧,另一方发送关闭帧作为回应。任何关闭连接之后的数据将被废弃。

杂项

WebSocket 代码, 扩展, 子协议等等东西都登记在 IANA WebSocket Protocol Registry 上。

WebSocket的扩展和子协议都是在握手阶段通过头部信息协商的。有时扩展和子协议看起来太过相似以致难以区分,但是有明确区别的。扩展控制WebSocket的修改负载数据,而子协议组织WebSocket的负载数据并且从不修改任何东西。扩展是可选的并且泛化的 (就像压缩);子协议是强制的并且局部的 (就像聊天和网游)。

扩展

这个章节需要扩充。如果你有能力的话就来编辑它吧。

把扩展看作在发送email给别人前先压缩下文件。无论你做什么,你都是在以不同形式发送一样的数据。接收者最终将会得到你本地拷贝一样的数据,但它不同形式发送的。这就是扩展所做的。Websocket定义了一个协议和一种简单的发送数据的方式,但一个扩展,比如压缩,允许以一种更简短的方式发送一样的数据。

扩展在规范的第5.8,9,11.3.2和11.4节中有解释。

TODO

子协议

把子协议看作可扩展标记语言模式 (XML schema) 或者文档类型声明(doctype declaration)。你仍然是使用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。对于不同的版本,一个广泛使用的方式是添加斜杠 /  后面跟版本号,比如  chat.example.com/2.0。注意这并不是必需的,它只是一个可选的约定,你可以任何你想要的字符串。

相关信息

文档标签和贡献者

 此页面的贡献者: bee0060, relaxgo, dukai, longjmp, laobubu
 最后编辑者: bee0060,