Websockets 的缺陷以及替代技术
Websockets 是一种功能强大的工具,已成为构建实时应用程序的粉丝最爱,但您可能会因为各种错误的原因而使用它们。让我们来探讨一下 WebSocket 的陷阱,以及如何使用普通的 HTTP 来完成同样的工作。
什么是 WebSocket?
如果你是网络开发的新手,或者以前没听说过 WebSocket,那么它就是一种使用 HTTP 作为传输协议在客户端和服务器之间打开双向通信通道的方法。用不太专业的术语来说,它是一种在客户端和服务器之间保持开放式通信线路的方式,这样双方就可以随时发送和接收信息。(MDN 参考资料)
由于 WebSocket 的宣传方式,人们很自然地认为它是在客户端和服务器之间协调长期数据流(例如实时应用程序)的最佳(有时也是唯一)方式。但在实践中,有几个原因让你可能不想使用它们:
WebSocket 消息不是事务性的
在很多情况下,WebSockets 被用作保持某种状态对象一致性的方式。例如,你使用套接字的发送端来表示某个对象的突变,而套接字的接收端来表示因这些突变而改变的状态。这样,如果有多个客户端在监听同一个对象,他们都能看到相同的状态变化,而无需刷新页面。
# Client 1
>>> { command: "increment", amount: 5 }
<<< { event: "count", value: 5 }
>>> { command: "decrement", amount: 2 }
<<< { event: "count", value: 3 }
# Client 2
<<< { event: "count", value: 5 }
<<< { event: "count", value: 3 }
但如果在状态对象上设置某种不变条件呢?例如,你想确保计数永远不为负数:
<<< { event: "count", amount: 5 }
>>> { command: "decrement", amount: 6 }
<<< { error: "count cannot be negative" }
这里的问题是,突变和错误信息之间没有关联,因为错误信息和其他信息一样,都是在同一数据流上接收到的。我们无法可靠地说 “流上收到的下一条信息 “是上一条命令的结果,因为服务器可能在这段时间内发送了任意数量的信息。
如果我们想更新用户界面以显示错误,就必须以某种方式将错误事件联系起来(比如在命令和错误信息中提供关联请求 ID):
>>> { command: "decrement", amount: 6, requestId: "123" }
<<< { error: "count cannot be negative", requestId: "123" }
这就变得更加棘手了,因为现在您必须跟踪发送的每一条信息,而且还必须找到某种方法,以惰性方式将错误事件反馈给用户界面。如果你想在服务器上显示已收到命令,情况也是一样。在这种情况下,你还需要处理某些难以追踪的边缘情况:
- 如果套接字在服务器处理命令之前关闭了怎么办?
- 如果由于某种原因套接字上从未收到响应信息怎么办?
- 如果处理的是大量并发请求怎么办?
这给本应简单的事情带来了太多的未知数和复杂性。如果要处理需要知道是否收到的消息,最好使用 REST 等事务性更强的协议来表示套接字的发送端。
( < > ) = HTTP
( <<< >>> ) = WebSocket
# Success
> POST /increment '{ value: 5 }'
< 200 OK
<<< { event: "count", value: 5 }
#- (the update message still gets sent to all connected clients)
# Failure
> POST /decrement '{ value: 6 }'
< 400 Bad Request
#- (no update gets sent because the request failed)
我们实际上已经完全放弃了套接字的发送端,取而代之的是 HTTP,这意味着我们现在只能依靠 WebSockets 来表示一个数据流(接收端)。事实证明,还有其他方法不需要全双工连接的开销。(我们稍后会讨论这个问题)
如果你发送的信息不一定需要被确认(如心跳或键盘输入),那么 WebSockets 就非常适合。因此,这篇文章的标题是:你可能不需要 Websockets。
您必须管理套接字生命周期
使用 WebSockets 时,您不仅要随意发送和接收消息,您的应用程序还必须对连接的打开和关闭做出响应。这意味着要处理 “打开 “和 “关闭”(或 “出错”)等事件,在尝试重新连接时决定要做什么,以及在不再需要连接时清理资源。
例如,浏览器中 WebSocket 的基本生命周期可能是这样的:
const socket = new WebSocket("wss://example.com/socket");
socket.addEventListener("open", () => {
console.log("Socket opened");
});
socket.addEventListener("message", (event) => {
console.log("Received message:", event.data);
});
socket.addEventListener("error", (err) => {
console.error("Socket error:", err);
});
socket.addEventListener("close", () => {
console.log("Socket closed. Attempting to reconnect…");
// Potentially restart or schedule a new socket connection here
});
在一个典型的应用程序中,您可能需要重新启动已关闭的连接,在套接字停机时缓冲消息,并使用指数回退处理重试。忽略这些步骤中的任何一个都可能导致信息丢失、笨拙的用户体验或连接滞留。相比之下,HTTP 等更简单的请求/响应模型的生命周期更为直接:每个请求开始、完成(或失败),然后继续。
WebSocket 生命周期的额外复杂性是您可能不需要它的主要原因之一–除非除了基于套接字的消息传递外别无选择(上一节已部分说明),否则您最好使用更简单的通信模式。
使服务器代码更复杂
当一个新的 WebSocket 连接启动时,服务器必须处理 HTTP “升级 ”请求握手。服务器不是完成一个普通请求,而是检查表示 WebSocket 握手的特殊标头,然后将连接从 HTTP 升级到持久套接字。这意味着对于每个初始连接,服务器都必须解析和验证 “Sec-WebSocket-Key ”等 WebSocket 标头,并以正确的 “Sec-WebSocket-Accept ”标头作为回应。(MDN 参考资料)
升级机制本身需要额外的管道:你需要在服务器上创建一个升级事件监听器,确认请求有效,完成握手,然后开始广播或接收数据。这不仅增加了更多的活动部件(与标准的请求/响应流相比),还意味着仅仅理解 HTTP 还不足以进行调试或故障排除–现在你要处理的是一个专门的连接协议。
如果您还要处理上文详述的类似请求/响应语义,则会带来更多复杂性,因为现在您在编写服务器代码时要考虑套接字的持久性,而不是 HTTP 的短暂性。此外,您的应用程序还需要管理所有边缘情况:如果客户端尝试以不支持的方式升级怎么办?如果握手中途失败或超时怎么办?部分数据帧需要重新组装怎么办?
虽然库和框架在隐藏其中一些细节方面做得很好,但所有这些潜在的故障点都指向一个事实:如果你并不真正需要双向、始终在线套接字的功能,那么握手成本和扩展的错误状态可能会掩盖性能或实时性方面的优势。
那么还有什么选择呢?
我们在前面的章节中简单介绍过,但如果我们能抽象掉套接字的发送端,只在接收端留下单向数据流,我们就能使用一种简单得多的通信模式。
HTTP 流
如果深入研究 HTTP 的工作原理,你会发现它实际上是一个专为流式数据而设计的协议。否则,我们就无法在不加载整个文件的情况下流式传输视频,也无法在不下载整个页面的情况下加载庞大的网站。
事实证明,数据流并不一定要从大块数据中分割出来。我们可以用同样的原理来表示任意数据流,比如我们依赖 WebSockets 实现的实时更新。
下面以服务器端 JavaScript 为例,说明如何使用之前的计数器示例:
let counter = 0;
let resolvers = new Set();
// this returns a promise that resolves when the next
// value is available.
async function nextValue() {
return new Promise((resolve) => resolvers.add(resolve));
}
// look up what an `async generator` is if you're lost
// looking at this syntax. explaining it is out of scope
// for this post.
async function* valueGenerator() {
// (this loop gets broken when the response stream is closed.)
while (true) {
// every time we get the next value from the iterator,
// we yield the return from an awaited promise that resolves
// when the next value is available.
yield await nextValue();
}
}
async function processCommand(command) {
// this is what handles our "state updates"
counter = nextCounterValue(command);
// for each iterator (i.e. client that called `/stream`)
// that's waiting on a value, we resolve the promise with
// the new value
for (const resolver of resolvers) {
resolver(counter);
resolvers.delete(resolver);
}
}
// this is the function that computes the next state
// based on the command, and enforces any invariants
// that we want to have on the state.
function nextCounterValue(command) {
let next = counter;
if (command.type === "increment") {
next += command.amount;
} else if (command.type === "decrement") {
next -= command.amount;
}
if (next < 0) {
throw new Error("count cannot be negative");
}
return next;
}
// we use hono/express like syntax here, but you can
// use any server framework you want.
app.post("/increment", async (req, res) => {
try {
const { value } = await req.json();
processCommand({ type: "increment", amount: value });
return new Response("OK", 200);
} catch (error) {
return new Response(error.message, 400);
}
});
app.post("/decrement", async (req, res) => {
try {
const { value } = await req.json();
processCommand({ type: "decrement", amount: value });
return new Response("OK", 200);
} catch (error) {
return new Response(error.message, 400);
}
});
app.get("/stream", (req, res) => {
// We can create a stream from any async iterator, so
// we can pass the generator function that yields counter
// updates as they become available.
const stream = ReadableStream.from(valueGenerator());
return new Response(stream);
});
然后,我们就可以在浏览器端使用Stream API 来读取数据,并根据服务器发送的数据更新我们的用户界面。
const response = await fetch("/stream");
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
// wait for the next chunk of data
// (will only come when a state update is made)
const { done, value } = await reader.read();
// when the server is done sending data, we break out of the loop
if (done) break;
// decode the chunk since data gets encoded over the network
const chunk = decoder.decode(value);
// update the UI with the new state
updateUI(chunk);
}
通过这种设置,我们完全不需要 WebSockets,同时还能在多个客户端之间保持实时更新!