2022年10月14日

WebSocket

WebSocket 协议,在规范 RFC 6455 中描述,提供了一种通过持久连接在浏览器和服务器之间交换数据的方式。数据可以在两个方向上以“数据包”的形式传递,而无需断开连接,也不需要额外的 HTTP 请求。

WebSocket 特别适合需要持续数据交换的服务,例如在线游戏、实时交易系统等等。

一个简单的例子

要打开一个 WebSocket 连接,我们需要使用 URL 中的特殊协议 ws 创建 new WebSocket

let socket = new WebSocket("ws://javascript.js.cn");

还有一种加密的 wss:// 协议。它就像 WebSockets 的 HTTPS。

始终优先使用 wss://

wss:// 协议不仅加密,而且更可靠。

这是因为 ws:// 数据未加密,任何中间人可见。旧的代理服务器不知道 WebSocket,它们可能会看到“奇怪”的标头并中止连接。

另一方面,wss:// 是 WebSocket over TLS(就像 HTTPS 是 HTTP over TLS 一样),传输安全层在发送方加密数据并在接收方解密数据。因此,数据包以加密形式通过代理传递。它们无法看到内部内容并让它们通过。

创建套接字后,我们应该监听其上的事件。总共有 4 个事件

  • open – 连接已建立,
  • message – 收到数据,
  • error – WebSocket 错误,
  • close – 连接已关闭。

…如果我们想发送一些东西,那么 socket.send(data) 会做到这一点。

这是一个例子

let socket = new WebSocket("wss://javascript.js.cn/article/websocket/demo/hello");

socket.onopen = function(e) {
  alert("[open] Connection established");
  alert("Sending to server");
  socket.send("My name is John");
};

socket.onmessage = function(event) {
  alert(`[message] Data received from server: ${event.data}`);
};

socket.onclose = function(event) {
  if (event.wasClean) {
    alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
  } else {
    // e.g. server process killed or network down
    // event.code is usually 1006 in this case
    alert('[close] Connection died');
  }
};

socket.onerror = function(error) {
  alert(`[error]`);
};

出于演示目的,有一个用 Node.js 编写的 小型服务器 server.js 正在运行,用于上面的示例。它以“Hello from server, John”进行响应,然后等待 5 秒并关闭连接。

因此,您将看到事件 openmessageclose

实际上就是这样,我们已经可以进行 WebSocket 通信了。很简单,不是吗?

现在让我们更深入地讨论一下。

打开 WebSocket

当创建 new WebSocket(url) 时,它会立即开始连接。

在连接过程中,浏览器(使用标头)询问服务器:“您是否支持 WebSocket?” 如果服务器回复“是”,则对话将继续使用 WebSocket 协议,这与 HTTP 完全不同。

以下是由 new WebSocket("wss://javascript.js.cn/chat") 发出的请求的浏览器标头示例。

GET /chat
Host: javascript.info
Origin: https://javascript.js.cn
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
Sec-WebSocket-Version: 13
  • Origin – 客户端页面的来源,例如 https://javascript.js.cn。WebSocket 对象本质上是跨域的。没有特殊的标头或其他限制。旧服务器无法处理 WebSocket,因此没有兼容性问题。但是,Origin 标头很重要,因为它允许服务器决定是否与该网站进行 WebSocket 通信。
  • Connection: Upgrade – 信号表明客户端希望更改协议。
  • Upgrade: websocket – 请求的协议是“websocket”。
  • Sec-WebSocket-Key – 浏览器生成的随机密钥,用于确保服务器支持 WebSocket 协议。它是随机的,以防止代理缓存任何后续通信。
  • Sec-WebSocket-Version – WebSocket 协议版本,当前版本为 13。
WebSocket 握手无法模拟

我们无法使用 XMLHttpRequestfetch 来进行这种类型的 HTTP 请求,因为 JavaScript 不允许设置这些头部信息。

如果服务器同意切换到 WebSocket,它应该发送代码 101 响应

101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=

这里 Sec-WebSocket-AcceptSec-WebSocket-Key,使用特殊算法重新编码。浏览器看到它后,就会明白服务器确实支持 WebSocket 协议。

之后,数据将使用 WebSocket 协议传输,我们很快就会看到它的结构(“帧”)。这与 HTTP 完全不同。

扩展和子协议

可能存在额外的头部信息 Sec-WebSocket-ExtensionsSec-WebSocket-Protocol,它们描述了扩展和子协议。

例如

  • Sec-WebSocket-Extensions: deflate-frame 表示浏览器支持数据压缩。扩展是与数据传输相关的,扩展 WebSocket 协议的功能。头部信息 Sec-WebSocket-Extensions 由浏览器自动发送,其中包含它支持的所有扩展的列表。

  • Sec-WebSocket-Protocol: soap, wamp 表示我们希望传输的不仅仅是任何数据,而是使用 SOAP 或 WAMP(“WebSocket 应用消息协议”)协议的数据。WebSocket 子协议在 IANA 目录 中注册。因此,此头部信息描述了我们将使用的格式。

    此可选头部信息使用 new WebSocket 的第二个参数设置。这是子协议的数组,例如,如果我们想使用 SOAP 或 WAMP

    let socket = new WebSocket("wss://javascript.js.cn/chat", ["soap", "wamp"]);

服务器应该响应它同意使用的协议和扩展列表。

例如,请求

GET /chat
Host: javascript.info
Upgrade: websocket
Connection: Upgrade
Origin: https://javascript.js.cn
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: deflate-frame
Sec-WebSocket-Protocol: soap, wamp

响应

101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=
Sec-WebSocket-Extensions: deflate-frame
Sec-WebSocket-Protocol: soap

这里服务器响应它支持扩展“deflate-frame”,并且只支持请求的子协议中的 SOAP。

数据传输

WebSocket 通信由“帧”组成 - 数据片段,可以从任一方发送,并且可以有几种类型

  • “文本帧” - 包含各方相互发送的文本数据。
  • “二进制数据帧” - 包含各方相互发送的二进制数据。
  • “ping/pong 帧”用于检查连接,由服务器发送,浏览器会自动响应这些帧。
  • 还有“连接关闭帧”和一些其他服务帧。

在浏览器中,我们直接处理的只有文本或二进制帧。

WebSocket 的 .send() 方法可以发送文本或二进制数据。

调用 socket.send(body) 允许 body 为字符串或二进制格式,包括 BlobArrayBuffer 等。无需任何设置:只需以任何格式发送即可。

当我们接收数据时,文本始终以字符串形式出现。对于二进制数据,我们可以选择 BlobArrayBuffer 格式。

这由 socket.binaryType 属性设置,默认值为 "blob",因此二进制数据以 Blob 对象形式出现。

Blob 是一个高级二进制对象,它直接与 <a><img> 和其他标签集成,因此这是一个合理的默认值。但对于二进制处理,要访问单个数据字节,我们可以将其更改为 "arraybuffer"

socket.binaryType = "arraybuffer";
socket.onmessage = (event) => {
  // event.data is either a string (if text) or arraybuffer (if binary)
};

速率限制

想象一下,我们的应用程序正在生成大量要发送的数据。但用户网络连接缓慢,可能是在城市以外的移动互联网上。

我们可以反复调用 socket.send(data)。但数据将被缓冲(存储)在内存中,并且仅在网络速度允许的情况下才会发送出去。

socket.bufferedAmount 属性存储当前缓冲的字节数,等待通过网络发送。

我们可以检查它以查看套接字是否实际上可用于传输。

// every 100ms examine the socket and send more data
// only if all the existing data was sent out
setInterval(() => {
  if (socket.bufferedAmount == 0) {
    socket.send(moreData());
  }
}, 100);

连接关闭

通常,当一方想要关闭连接时(浏览器和服务器拥有同等权利),它们会发送一个包含数字代码和文本原因的“连接关闭帧”。

该方法是

socket.close([code], [reason]);
  • code 是一个特殊的 WebSocket 关闭代码(可选)
  • reason 是一个描述关闭原因的字符串(可选)

然后,另一方在 close 事件处理程序中获取代码和原因,例如

// closing party:
socket.close(1000, "Work complete");

// the other party
socket.onclose = event => {
  // event.code === 1000
  // event.reason === "Work complete"
  // event.wasClean === true (clean close)
};

最常见的代码值

  • 1000 – 默认值,正常关闭(如果未提供 code,则使用),
  • 1006 – 无法手动设置此代码,表示连接已丢失(没有关闭帧)。

还有其他代码,例如

  • 1001 – 对方即将离开,例如服务器正在关闭,或浏览器离开页面,
  • 1009 – 消息太大,无法处理,
  • 1011 – 服务器上出现意外错误,
  • …等等。

完整列表可以在 RFC6455,§7.4.1 中找到。

WebSocket 代码有点像 HTTP 代码,但有所不同。特别是,低于1000的代码是保留的,如果我们尝试设置这样的代码,就会出现错误。

// in case connection is broken
socket.onclose = event => {
  // event.code === 1006
  // event.reason === ""
  // event.wasClean === false (no closing frame)
};

连接状态

要获取连接状态,还可以使用socket.readyState属性,其值如下:

  • 0 – “CONNECTING”: 连接尚未建立,
  • 1 – “OPEN”: 正在通信,
  • 2 – “CLOSING”: 连接正在关闭,
  • 3 – “CLOSED”: 连接已关闭。

聊天示例

让我们回顾一下使用浏览器 WebSocket API 和 Node.js WebSocket 模块https://github.com/websockets/ws的聊天示例。我们将主要关注客户端,但服务器也很简单。

HTML:我们需要一个<form>来发送消息,以及一个<div>来接收消息

<!-- message form -->
<form name="publish">
  <input type="text" name="message">
  <input type="submit" value="Send">
</form>

<!-- div with messages -->
<div id="messages"></div>

从 JavaScript 中,我们想要三件事

  1. 打开连接。
  2. 在表单提交时 – socket.send(message) 用于发送消息。
  3. 在收到消息时 – 将其追加到div#messages

以下是代码

let socket = new WebSocket("wss://javascript.js.cn/article/websocket/chat/ws");

// send message from the form
document.forms.publish.onsubmit = function() {
  let outgoingMessage = this.message.value;

  socket.send(outgoingMessage);
  return false;
};

// message received - show the message in div#messages
socket.onmessage = function(event) {
  let message = event.data;

  let messageElem = document.createElement('div');
  messageElem.textContent = message;
  document.getElementById('messages').prepend(messageElem);
}

服务器端代码有点超出了我们的范围。在这里,我们将使用 Node.js,但您不必这样做。其他平台也有自己的方法来处理 WebSocket。

服务器端算法将是

  1. 创建clients = new Set() – 一组套接字。
  2. 对于每个接受的 websocket,将其添加到集合clients.add(socket)中,并设置message事件监听器以获取其消息。
  3. 收到消息时:遍历客户端并将消息发送给每个人。
  4. 连接关闭时:clients.delete(socket)
const ws = new require('ws');
const wss = new ws.Server({noServer: true});

const clients = new Set();

http.createServer((req, res) => {
  // here we only handle websocket connections
  // in real project we'd have some other code here to handle non-websocket requests
  wss.handleUpgrade(req, req.socket, Buffer.alloc(0), onSocketConnect);
});

function onSocketConnect(ws) {
  clients.add(ws);

  ws.on('message', function(message) {
    message = message.slice(0, 50); // max message length will be 50

    for(let client of clients) {
      client.send(message);
    }
  });

  ws.on('close', function() {
    clients.delete(ws);
  });
}

以下是工作示例

您也可以下载它(iframe 中的右上角按钮)并在本地运行它。只需在运行之前不要忘记安装Node.jsnpm install ws

总结

WebSocket 是一种现代的方式,可以实现浏览器与服务器之间的持久连接。

  • WebSockets 没有跨域限制。
  • 它们在浏览器中得到了很好的支持。
  • 可以发送/接收字符串和二进制数据。

API 很简单。

方法

  • socket.send(data),
  • socket.close([code], [reason]).

事件

  • open,
  • message,
  • error,
  • close.

WebSocket 本身不包括重新连接、身份验证和许多其他高级机制。因此,有客户端/服务器库可以做到这一点,也可以手动实现这些功能。

有时,为了将 WebSocket 集成到现有项目中,人们会并行运行 WebSocket 服务器和主 HTTP 服务器,并且它们共享一个数据库。对 WebSocket 的请求使用wss://ws.site.com,这是一个指向 WebSocket 服务器的子域,而https://site.com指向主 HTTP 服务器。

当然,其他集成方式也是可能的。

教程地图

评论

在评论之前请阅读…
  • 如果您有改进建议,请提交 GitHub 问题或拉取请求,而不是评论。
  • 如果您不理解文章中的某些内容,请详细说明。
  • 要插入少量代码,请使用<code>标签,对于多行代码,请用<pre>标签包裹,对于超过 10 行的代码,请使用沙箱(plnkrjsbincodepen…)