深入学习WebSockets概念和实践

深入学习WebSockets概念和实践

WebSocket 协议为 Internet 通信创造了新的可能性,并为真正的实时通讯打开了大门。本文将介绍 WebSockets 的发展史,并深入学习 WebSockets 是如何产生的、它们是什么、如何工作,以及 WebSockets 如何在实际应用程序中工作的。

WebSockets:背景

WebSockets 于 2008 年首次被提出,自 2010 年左右开始获得浏览器厂商的广泛支持。在 WebSockets 出现之前,“实时”通讯已经存在,但它很难实现,通讯速度比较慢,而且是通过入侵现有的web技术来实现的,而这些技术并不是为实时应用而设计的。这是因为web是建立在HTTP协议的基础上的,而HTTP协议最初完全是作为一种请求-响应机制设计的。打开一个连接,描述想要的,返回一个响应,然后关闭连接。这在 Web 的早期是很好的,因为在那时的场景中,只需要处理一个文本文档和一些额外的资源即可(通常是图像)。

使用 JavaScript 编写 Web 脚本

1995 年,Netscape Communications 聘请了 Brendan Eich,目标是将脚本功能嵌入其 Netscape Navigator 浏览器中,于是 JavaScript 诞生了。

最初,JavaScript 设计的有点奇怪,不能做很多事情(尤其是在 JavaScript 可以使用的浏览器 DOM 极其有限的情况下),但它对一些事情很有用,例如在表单提交之前对输入字段进行简单的验证后再将表单数据发送到服务器。

微软很快就凭借 Internet Explorer 进入了浏览器领域,这是早期浏览器战争真正开始的地方。两家公司都在争夺最好的浏览器,因此不可避免地会定期向 Netscape 和 Internet Explorer 添加特性和功能。

浏览器战争

XMLHttpRequest 和 AJAX 的诞生

当时引入的两个最重要的功能是将 Java applet 嵌入到页面中的能力,以及微软提供的ActiveX控件。

这些本质上是预编译的组件,可以选择在网页中呈现自己的嵌入式用户界面。更重要的是,除了当时 JavaScript 微薄的脚本功能套件之外,它们还提供了大量其他可能性。

虽然通过 Java 提供了一些类似的网络功能,但最重要的后台通信功能首次出现在 1999 年,使用Microsoft XMLHTTP ActiveXObject接口。它在 Internet Explorer 5.0 中原生可用,无需安装插件,可以用一行 JavaScript 实例化,并且在处理 Java applet 时不会带来任何的不兼容。

XMLHTTP对象使向服务器发出请求并接收响应成为可能——所有这些都无需重新加载页面或以其他方式中断用户体验。然后JavaScript代码可以解析响应并在不刷新页面的情况下修改页面,从而将大量丰富的体验集成到网站中。

常见的早期用例包括允许下拉框填充基于用户先前输入的选项,以及在填写用户注册表单时对用户名可用性的“即时”验证。

下面是实例化 XMLHTTP 对象的示例 JavaScript 代码:

var xmlhttp = new ActiveXObject("Microsoft.XMLHTTP");
xmlhttp.open("GET", "/api/data", true);
xmlhttp.onreadystatechange = function () {
    if (xmlhttp.readyState === 4) {
        console.log(xmlhttp.responseText);
    }
};
xmlhttp.send(null);

由于被其他浏览器采用,XMLHTTP后来成为XMLHttpRequest事实上的标准。这也是术语“AJAX”被创造出来的时间,即“异步 JavaScriptXML”。

后来出现了 JSON 标准并使一切变得更好,但 AJAX 中的“X”(更不用说 XMLHttpRequest 中的“XML”)从未真正消失,尽管实际的 XML 格式数据已经从标准消息传递中基本消失了。

XMLHttpRequest 对象现代用法的代码示例:

const req = new XMLHttpRequest();
req.addEventListener("load", () => console.log(this.responseText));
req.open("GET", "/api/data");
req.send();

充满新可能性的世界

在通常情况下,XMLHttpRequest 仍然遵循用于检索原始 HTML 文档的相同的 HTTP 请求-响应模型。没有真正的概念允许服务器主动联系用户,或者为更复杂的用例建立任何类型的通用双向连接。

然而,JavaScript 一直在获得新的特性和功能,浏览器也在增强文档对象模型 (DOM)。使得 JavaScript 在丰富用户与网页交互的体验方面具有越来越大的潜力。

HTTP请求-响应模型

随着充满活力的体验的潜能开始变得明显,开发人员自然而然地倾向于直接在浏览器中实现客户端-服务器应用程序的想法。在此之前,任何重要工作的标准范例都是构建一个专用的软件应用程序,将其与安装程序打包,让用户下载安装程序,然后在他们的机器上安装它。

不用说,这对不懂 IT 技术的用户来说都是一个相当大的进入障碍,让应用程序保持更新,并进行修复和增强功能就是一项具有挑战的事情。

因此,很容易理解,能够构建一个应用程序是多么诱人,该应用程序既不需要安装就可以访问,也不需要用户培训和反馈来迭代软件的实现。

孵化实时WEB

“创新就是把两个已经存在的东西以一种新的方式组合在一起。——汤姆·弗雷斯顿

当某些事情在技术上似乎是可行的,并且潜在的回报值得付出努力时,我们通常会竭尽全力将可用的东西拼装成满足我们需求的形状。

因此,开发人员使用 XMLHttpRequest 并滥用它来模拟 web 页面和服务器之间的实时交互通信。实现这一点的技术都变得很普遍——甚至是“标准的”。

这些技术中最常见的可能是长轮询,这涉及打开到服务器的 XMLHttpRequest 连接,并将其保持打开状态,直到不再需要进行通信为止。

在正常情况下,当发出HTTP请求时,服务器的响应将通过发出请求的连接流返回给客户端。其目的是允许浏览器在等待服务器响应的下一部分时开始呈现HTML页面。

对于长轮询,让 HTTP 连接保持打开状态意味着只要连接保持打开状态,服务器就可以继续持续响应数据。没有技术要求数据采用一种或另一种格式,或者在向客户端发送数据后关闭请求。

这同样适用于客户端发送的 HTTP 请求负载,服务器可能会在客户端的请求数据全部到达之前开始传递其响应,并且在客户端选择停止发送请求数据之前,并不严格要求它停止发送请求数据。这就意味着,就像服务器可以在连接的生命周期内继续传递响应数据一样的原理,客户端也可以这样做,最终的结果就实现了服务器和客户端之间事实上的双向通信。

HTTP长轮询

在缺乏其它合适工具的情况下,长轮询对于 web 应用程序开发人员来说是可行的,但是正确地执行长轮询是很棘手的,并且充满了必须处理的可能意想不到的复杂情况。

总而言之,长轮询实际上只是对可用工具进行重新利用的一种情况,以便完成它们并非真正设计用来做的事情。

需要一个真正的解决方案 - 可以使开发人员在 Web 环境中具有适当的 TCP/IP 套接字风格能力的东西。此类解决方案需要为 Web 构建,并且需要解决在 Web 环境中操作时出现的所有问题。

WebSockets 诞生

在2008年年中左右,开发人员 Michael CarterIan Hickson 特别强烈地感受到使用 Comet 实现任何真正健壮的东西所带来的痛苦和局限性。

通过在IRC和W3C邮件列表上的合作,他们制定了一项计划,为现代实时、双向的WEB通信引入一个新标准,于是“WebSocket”这个名字就应运而生了。

这个设想很快就进入了W3C HTML草案标准,不久之后,Michael Carter 在 Comet 社区发布一篇介绍 WebSockets 的文章。

2010 年,Google Chrome 4 是第一个全面支持 WebSockets 的浏览器。在接下来的几年里,其他浏览器供应商也纷纷效仿。2011 年,RFC 6455(WebSocket 协议)发布到 IETF 网站。

到今天,所有主流浏览器都完全支持 WebSockets,包括 Internet Explorer 10 和 11。此外,自2013年以来,iOS和Android上的浏览器都支持WebSocket,这意味着WebSocket支持的现代前景是非常健康的。很多“物联网”或 IoT 也可以在某些版本的Android上运行,所以截至2018年,在其他类型的设备上支持WebSocket也相当普遍。

什么是 WebSockets?

简而言之,WebSockets 是建立在设备TCP/IP堆栈之上的一个微型的传输层。其目的是为 web 应用程序开发人员提供一个基本的尽可能接近原生的 TCP 通信层,同时添加一些抽象来消除某些在 Web 工作方式方面可能存在的兼容性。

它们还迎合了这样一个事实,即 Web 具有额外的安全考虑因素,必须考虑这些因素以保护消费者和服务提供商。

WebSockets 是传输还是协议?

可能听说过 WebSockets 同时被称为“传输”和“协议”。前者更准确,因为虽然它们是一种必须遵守建立通信和封装传输数据的严格规则集的协议,该标准对实际数据有效载荷在外部消息信封内的结构不采取任何措施。

事实上,该规范的一部分包含了让客户端和服务器就传输数据的格式化和解析协议达成一致的选项。该标准将这些称为“子协议”,以避免在命名中产生歧义。子协议的例子有 JSON、XML、MQTT、WAMP。这些可以确保不仅在数据的结构方式上达成一致,而且在通信必须开始、继续和最终终止的方式上达成一致。

WebSocket 仅提供一个传输层,在该层上可以实现消息传递过程,这就是为什么大多数常见的子协议并不仅限于基于WebSocket的通信。

WebSocket认证和授权是如何工作的?

WebSockets 是一个建立在 TCP/IP 之上的薄层,因此除了基本的握手和消息帧规范之外的任何事情实际上都需要在每个应用程序或每个库的基础上处理。

“该协议没有规定服务器可以在 WebSocket 握手期间对客户端进行身份验证的任何特定方式。WebSocket 服务器可以使用通用 HTTP 服务器可用的任何客户端身份验证机制,例如 cookie、HTTP 身份验证或 TLS 身份验证”。

简而言之,使用的基于 HTTP 的身份验证方法,或者使用子协议,例如MQTT或WAMP,它们都提供了 WebSocket 身份验证和授权的方法。

抛弃 HTTP:WebSockets 和重新设计的 TCP 套接字

在发出 HTTP 请求和接收响应时,实际涉及的双向网络通信发生在活动的 TCP/IP 连接上。

正如我们现在所知,WebSockets 也是建立在 TCP 堆栈之上的,这意味着我们需要的是一种让客户端和服务器共同同意保持 TCP 连接打开并将其重新用于持续通信的方法。如果这样做,那么就没有技术上的理由为什么他们不能继续使用套接字传输任何类型的任意数据,只要他们都同意应该如何解析发送和接收的二进制数据。

要开始为 WebSocket 通信重新利用 TCP 连接的过程,客户端可以包含一组专门为此类用例发明的标准 HTTP 请求标头:

GET /index.html HTTP/1.1
Host: www.example.com
Connection: Upgrade
Upgrade: websocket

该 Connection 头告诉客户端想协商中所使用的插座的方式发生变化的服务器。随附的值 Upgrade 表示当前通过 TCP 使用的传输协议应该更改。

既然服务器知道客户端想要升级当前在活动 TCP 套接字上使用的协议,服务器知道查找相应的 Upgrade 标头,这将告诉它客户端想要在剩余的生命周期中使用哪种传输协议通讯。一旦服务器看到标头 WebSocket 的值 Upgrade ,它就知道 WebSocket 握手过程已经开始。

在浏览器中使用 WebSockets

WebSocket API 是在 WHATWG HTML Living Standard 中定义的, 实际上使用起来非常简单。构建一个 WebSocket 只需要一行代码:

const ws = new WebSocket('ws://example.org');

注意 ws 在拥有该 http 通讯的地方使用,还可以选择 wss 在使用 https 的地方。这些协议是与 WebSocket 规范一起引入的 ,旨在表示 HTTP 连接,其中包括升级连接以使用 WebSockets 的请求。

创建 WebSocket 对象本身并没不需要做很多事情,连接是异步建立的,因此需要在发送任何消息之前侦听握手的完成情况,并且还包括从服务器接收的消息的侦听器:

ws.addEventListener("open", () => {
    console.log("websocket open");
    ws.send("hello");
});

ws.addEventListener("close", () => {
    console.log("websocket close");
});

ws.addEventListener("error", () => {
    console.log("websocket error");
});

ws.addEventListener("message", (event) => {
    console.log("Received:", event.data);
});

当连接终止时,WebSockets 不会自动恢复,这是需要自己实现的东西,也是存在许多客户端库的部分原因。

通常为了保持连接状态,需要增加心跳机制。

WebSocket 心跳机制

websocket 心跳机制是每隔一段时间会向服务器发送一个数据包,告诉服务器自己还活着,同时客户端会确认服务器端是否还活着,如果还活着的话,就会回传一个数据包给客户端来确定服务器端也还活着,否则的话,有可能是网络断开连接了。就需要建立重连机制。主要在一些长时间连接的应用场景需要考虑心跳机制及重连机制,以保证长时间的连接及数据交互。

websocket心跳机制

开源 WebSocket 库

WebSocket 库有两个主要类:

  • 实现协议的 WebSocket 库,剩下的交给开发人员

  • WebSocket 库建立在协议之上,具有实时消息传递应用程序通常需要的各种附加功能。这可能包括恢复丢失的连接、发布/订阅和频道、身份验证、授权等。

后一种类型通常需要在客户端使用它们自己的库,而不仅仅是使用浏览器提供的原始 WebSocket API。

以下推荐的库都是开源的。

WS

ws 是“使用简单、速度极快且经过全面测试的 Node.js 的 WebSocket 客户端和服务器”。它绝对是一个准系统实现,旨在完成实现协议的所有艰苦工作。但是,诸如连接恢复、发布/订阅等附加功能是您必须自己管理的问题。

仓库地址:https://github.com/websockets/ws

客户端
const WebSocket = require("ws");

const ws = new WebSocket("ws://www.host.com/path");

ws.on("open", function open() {
    ws.send("something");
});

ws.on("message", function incoming(data) {
    console.log(data);
});
服务端
const WebSocket = require("ws");

const wss = new WebSocket.Server({ port: 8080 });

wss.on("connection", function connection(ws) {
    ws.on("message", function incoming(message) {
        console.log("received: %s", message);
    });

    ws.send("something");
});

Socket.io

Socket.IO 已经存在一段时间了,可以被认为是 WebSockets 的“jQuery”。它使用长轮询和 WebSockets 进行传输,默认情况下从长轮询开始,然后升级到 WebSockets(如果可用)。

仓库地址:https://github.com/socketio/socket.io

鉴于长轮询的相关性逐渐减弱,如今 Socket.IO 的主要吸引力在于其其他功能,例如恢复断开的连接、自动支持 JSON 和“命名空间”,它们本质上是多路复用在同一客户端连接上的隔离消息通道。

Socket.IO 实际上不能与通用的 WebSockets 解决方案互换——无论是在服务器端还是在客户端——并且尝试连接到 Socket.io 客户端或服务器以外的其他东西将会失败。它有自己的附加握手协议,以及每条消息中包含的一些附加元数据。

你应该使用 Socket.IO 吗?

从好的方面来说,除了启动和运行的简单性之外,它还很完善,如果您遇到困难,那里有大量的学习资料。此外,最新版本——Socket.IO 4—— 确实包含了许多新功能和重大修复。

另一方面,随着时间的推移,有大量关于内存泄漏和一般性能问题的报告,尽管Github 存储库中存在大量问题,但响应很少,而且这些天提交和更新似乎相对较少。

像 jQuery 一样,Socket.IO 很大程度上是过去时代的产物,对于新项目,最好使用更现代的东西。

客户端
const io = require("socket.io-client");
const socket = io();
socket.emit("chat message", "Hello there");
服务端
const app = require("express")();
const http = require("http").Server(app);
const io = require("socket.io")(http);

app.get("/", function (req, res) {
    res.sendFile(__dirname + "/index.html");
});

io.on("connection", function (socket) {
    console.log("a user connected");
});

http.listen(3000, function () {
    console.log("listening on *:3000");
});

μWebSockets

μWebSockets 是 ws 的直接替代品,实施时特别注重性能和稳定性。据我所知,μWebSockets 是最快的 WebSocket 服务器实现。它实际上是由 SocketCluster 在幕后使用的,我将在下面讨论。

仓库地址:https://github.com/uNetworking/uWebSockets

const WebSocketServer = require("uws").Server;
const wss = new WebSocketServer({ port: 3000 });

const onMessage = (message) => {
    console.log(`received: ${message}`);
};

wss.on("connection", function (ws) {
    ws.on("message", onMessage);
    ws.send("something");
});

faye-websocket

faye-websocket 是客户端和服务器端符合标准的 WebSocket 实现,起源于 Ruby-on-Rails 社区,是Faye 项目的一部分。根据 Github,项目:

“它本身不提供服务器,而是可以轻松处理现有 Node 应用程序中的 WebSocket 连接。除了标准 WebSocket API 之外,它不提供任何抽象。”

仓库地址:https://github.com/faye/faye-websocket-node

在下面的服务器示例代码中,您可以看到处理连接升级和从入站套接字缓冲区转换消息帧的所有工作都由WebSocket库提供的类处理。与其他最小的解决方案一样,这是一个简单的实现——您需要自己处理特定于应用程序的问题。

客户端
const WebSocket = require("faye-websocket");
const ws = new WebSocket.Client("ws://www.example.com/");

ws.on("open", function (event) {
    console.log("open");
    ws.send("Hello, world!");
});

ws.on("message", function (event) {
    console.log("message", event.data);
});

ws.on("close", function (event) {
    console.log("close", event.code, event.reason);
    ws = null;
});
服务端
const WebSocket = require("faye-websocket");
const http = require("http");

var server = http.createServer();

server.on("upgrade", function (request, socket, body) {
    if (WebSocket.isWebSocket(request)) {
        var ws = new WebSocket(request, socket, body);

        ws.on("message", function (event) {
            ws.send(event.data);
        });

        ws.on("close", function (event) {
            console.log("close", event.code, event.reason);
            ws = null;
        });
    }
});

server.listen(8000);

SocketCluster

SocketCluster 是一个全功能的客户端-服务器消息框架,完全围绕 WebSockets 构建,并在底层使用 μWebSockets。

“SocketCluster 是 Node.js 的开源实时框架。它支持直接的客户端-服务器通信和通过发布/订阅通道进行的组通信。它旨在轻松扩展到任意数量的进程/主机,非常适合构建聊天系统。请参阅用于聊天的 SocketCluster 设计模式”。

与 Socket.IO 等更简单的解决方案相比,SocketCluster 需要稍多的安装,但通常很容易启动和运行。

客户端
const socket = socketCluster.create();
socket.emit("sampleClientEvent", {
    message: "This is an object with a message property",
});
服务端
const SocketCluster = require("socketcluster");
const socketCluster = new SocketCluster({
    workers: 1, // Number of worker processes
    brokers: 1, // Number of broker processes
    port: 8000, // The port number on which your server should listen
    appName: "myapp", // A unique name for your app

    // Switch wsEngine to 'sc-uws' for a MAJOR performance boost (beta)
    wsEngine: "ws",

    /* A JS file which you can use to configure each of your
     * workers/servers - This is where most of your backend code should go
     */
    workerController: __dirname + "/worker.js",

    /* JS file which you can use to configure each of your
     * brokers - Useful for scaling horizontally across multiple machines (optional)
     */
    brokerController: __dirname + "/broker.js",

    // Whether or not to reboot the worker in case it crashes (defaults to true)
    rebootWorkerOnCrash: true,
});

WebSockets 和可扩展性:超越单一服务器

服务器可以处理的并发连接数少将成为服务器负载的瓶颈。大多数性能还可以的 WebSocket 服务器可以支持数千个并发连接。

真正的问题是一旦 WebSocket 服务器进程处理了实际数据的接收,处理和响应消息所需的工作量。通常会有各种各样的潜在问题,例如从数据库读取和写入、与游戏服务器的集成、每个客户端的资源分配和管理等。

一旦一台机器无法处理工作负载,就需要开始添加额外的服务器,这意味着需要考虑负载平衡、连接到不同服务器的客户端之间的消息同步、通用访问到客户端状态而不管连接周期或客户端连接到的特定服务器——这个列表一直在继续。