你从未听说过的 HTTP 全双工面貌

WebSocket 并不是唯一道路

当提到在 Web 上进行全双工通信,大多数人第一时间想到的是 WebSocket,因为它的唯一目的就是这个。但 WebSocket 并不是实现全双工通信的唯一的方法,基础的 HTTP 本身也是一个全双工协议。

传统的 HTTP

当提到 HTTP 协议时,很多人会联想到类似这么一个图示:

这是使用 HTTP 的常用方式,也被称作半双工模型。客户端发送一个请求给服务端,服务端处理请求之后返回一个响应。它们之间的连接可能会立即断开,或者保持并留作后用。

服务器发送事件(SSE)

但那并不是唯一一种使用 HTTP 的方式。一些人可能熟悉 SSE (Server-Sent-Events),即在服务端处理了请求之后,它并不会立即发送回响应然后关闭对话。相反地,它保持会话活动并在服务端检测到有可用数据更新时持续地向客户端发送消息事件。其图示类似下面这样:

流式 HTTP

然而,SSE 依旧不能描述 HTTP 协议的全部潜能。真正的 HTTP 协议是一个流式传输协议,即客户端和服务端都能够持续地向对方发送和接收数据,只要它们之间的连接还保持着。因此,准确的图示应该是这样的:

这个模型给予了 HTTP 足够的能力以实现全双工通信。

SSE 并不是服务端专属

熟悉 SSE 的人知道如何在服务端使用它,它是一个设计完善并且广为采纳的,用于服务端向客户端传输文本(有时候是 JSON)数据帧的编码格式。然而,这样的一个技术并不是服务端专属的,它也可以被用在客户端上,用于持续向服务端发送数据片段。在接下来的内容中,我将会示范如何使用这个技术来在流式 HTTP 的基础上实现全双工通信。

为了简便,在这里我们只将间歇性地从客户端往服务端发送一个 ping 事件,而服务端也会对应地向客户端返回一个 pong 事件。

服务端示例

首先,我们来看一个服务端示例,它使用 JavaScript/TypeScript 编写。

// server.ts
import { EventConsumer, EventEndpoint } from "@ayonli/jsext/sse";

export default {
    port: 8000,
    async fetch(req: Request) {
        const sse = new EventEndpoint(req);

        // EventConsumer takes a Response object, so we create one for it with the
        // request body as its body, which is a ReadableStream.
        const res = new Response(req.body!, {
            headers: { "Content-Type": "text/event-stream" },
        });
        const client = new EventConsumer(res);

        // listen the event sent by the client
        client.addEventListener("ping", ev => {
            console.log("ping", ev.data);

            // send event to the client
            sse.dispatchEvent(new MessageEvent("pong", { data: ev.data }));
        });

        client.addEventListener("connect", ev => {
            console.log("connect", ev.data);
        });

        return sse.response!;
    }
};
TypeScript

然后我们使用以下任意一条命令来启动服务器(不要忘了先安装依赖):

tsx --import=@ayonli/jsext/http server.ts # Node.js (with `tsx`)

deno serve server.ts # Deno

bun run server.ts  # Bun
ShellScript

客户端示例

现在我们再来编写一个客户端程序,它将连接上面的服务器并向服务端发送数据帧,而服务端将会依次响应。

// client.ts
import { EventConsumer, EventEndpoint } from "@ayonli/jsext/sse";

// EventEndpoint takes a Request object, so we create one for it for simulation.
const req = new Request("http://localhost:8000");
const sse = new EventEndpoint(req);

// We need to write the first chunk of data so the server can parse
// the header and start receiving body frames.
sse.dispatchEvent(new MessageEvent("connect", { data: "hello" }));

// @ts-ignore suppress TS error for 'duplex' option
const res = await fetch(req.url, {
    method: "POST",
    // Pass the response body of the EventEndpoint instance as the request body,
    // so when sending events, the data will be piped to the request.
    body: sse.response!.body,
    headers: { "Content-Type": "text/event-stream" },
    duplex: "half",
});
const client = new EventConsumer(res);

// listen the event sent by the server
client.addEventListener("pong", ev => {
    console.log("pong", ev.data);
});

// periodically sending events to the server
let count = 0;
setInterval(() => {
    sse.dispatchEvent(new MessageEvent("ping", { data: String(count++) }));
}, 1_000);
TypeScript

现在我们使用以下任意一条命令来启动客户端:

tsx client.ts # Node.js (with `tsx`)

deno run --allow-net client.ts # Deno

bun run client.ts # Bun
ShellScript

然后,我们就可以在终端中看到,在服务端标签页中,将会持续的打印类似以下的内容:

connect hello
ping 0
ping 1
ping 2
ping 3
ping 4
ping 5
ping 6
ShellScript

而在客户端标签页中,将会持续打印如下的内容:

pong 0
pong 1
pong 2
pong 3
pong 4
pong 5
pong 6
ShellScript

除了使用 SSE,我们也可以使用存粹的 HTTP 报文体来分段地在客户端和服务端之间传递数据,只要双方有合适的方式来分隔数据帧即可。

浏览器支持?

遗憾的是,浏览器目前尚未支持基于 HTTP 的全双工通信。在 Chrome 中,如果我们尝试运行上面的客户端代码,fetch API 会直接抛出错误一个 ERR_H2_OR_QUIC_REQUIRED 错误。在 whatwg 的 GitHub 上,有一篇关于这个问题的讨论:https://github.com/whatwg/fetch/issues/1254,感兴趣可以自行查看。

最终结论

这篇文章为我们展示了一个全新的视角来应用 HTTP,加强了我们对这个 Web 领域最流行的传输协议的认识。在学习时,我们应当时刻保持开放的思想,不仅是感兴趣于新的高大上的东西,同时也包含那些我们似乎已经非常熟悉的事物,它们也许还有其他可能性是我们尚未得知的。

附言:JsExt 是一个 JavaScript 扩展工具库,它提供了非常多的模块和特性可以用来构建强壮而又现代化的、基于 Web 标准的应用程序。感兴趣可以看看,觉得好的话也可以给个🌟。

Leave a comment