概述
本文是笔者的系列博文 《Bun技术评估》 中的第十一篇。
本文主要探讨的内容,是基于HTTP来实现实时Web应用的核心: WebSocket(WS)。
在nodejs中,对于WS的技术接纳是比较保守的,只有在最新的几个版本中,才增加了内置的WC Client的实现。好像也没有内置的WS服务器实现。作为一个后来者, bun好像从这里面看到了机会,内置了WS的完整实现。
bun ws也提供了官方的技术文档,链接如下:
基本原理
笔者理解,WS本质上并不是一个全新的技术。基于兼容和技术继承的考虑,WS其实是基于HTTP协议的,也可以理解成为一种升级或者扩展的协议。但就是这个协议,其实从范式上而言,解决了HTTP的最大的问题:请求-响应模型导致的无法原生实现的服务器向客户端主动发送信息的机制。
要实现这一点,WS应该做了以下工作:
- 客户端使用传统HTTP方法,和服务器建立连接
- 客户端基于连接协议(WS://)请求服务器,将当前的连接"升级"成为WS
- 要升级协议,服务器需要将连接永久化,并且需要记录这个和客户端之间建立的"通道(Socket)"
- 基于这个通道,客户端和服务端都可以发送消息和信息
- 客户端和服务器端,都需要在程序运行环境中,建立消息发送和接收处理的机制
上面就是笔者理解的WS的工作原理。其实可以看到,也没有非常特殊的地方,就是一个HTTP持久连接的使用机制。原理很简单,但在Web技术发展的早期,我们要考虑到,一台服务器要为很多个客户端提供服务,并且维持这些连接,将会付出巨大的代价,所以选择了简单方便的请求-响应机制,就可以为更多的客户端提供服务。现在的计算机系统处理能力变得非常强大,而且人们对于Web应用的使用体验要求提高了,实时的双向信息发送,会带来更好的体验。WS为此奠定了相关的技术方案和标准,而条件已经具备,具体选择哪种择技术方案,取决于产品和应用环境的要求,以及可以使用的资源,和愿意付出的代价,比如程序的复杂性、客户端服务器处理能力要求提高,网络的要求提升等等,可以综合考虑来选定。
这里有两个比较常见的应用场景:
- 对实时性要求比较高,强调应用体验的Web应用程序,或者APP应用程序
主要需要考虑的问题,就是如果WS连接由客户端页面发起,可能会造成大量WS连接和重复连接的问题,需要进行很好的规划和管理。建议的方式,是只在需要的页面或者应用环境中,实现WS客户端,并且要考虑对WS进行认证,只建立和维护WS的逻辑客户端(如用户)。
- Web应用之间的数据集成
这也是一个非常常见的应用场景。Web应用之间集成,通常使用API接口的方式,但如果需要进行双向的数据交付,可能需要在两者之间都实现和维护接口,开发工作是比较复杂的。但如果使用WS进行集成,数据就可以进行双向的传输,从而满足更复杂的业务需求。
当然,这种场景理想的实现方案是MQ,但那样又需要引入第三方系统和技术。如果要求和需求没那么复杂,完全可以考虑WS的实现。而且WS基于HTTP协议,不需要改变网络拓扑和配置。
基本形式
和PG、S3、Testing等技术一样,bun提供了开箱可用的WS技术实现的支撑。笔者稍微修改了一下官方的示例代码,来展示一个相对完整的bun ws应用的流程和框架:
js
const
WEBPORT = 7069,
WSLIST = [];
const wsHandle = (ws, message)=>{
console.log("Server Received:", message);
WSLIST.forEach(wws => {
if (wws.channel == "PUBLIC") wws.send("PONG-"+ wws.wsid);
});
}
const startServer = ()=>{
Bun.serve({
port: WEBPORT,
fetch(req, server) {
// upgrade the request to a WebSocket
if (server.upgrade(req)) return ;
return new Response("Upgrade failed", { status: 500 });
},
websocket: {
message: wsHandle, // a message is received
open(ws) {
ws.wsid = (Date.now() - 170000000000).toString(36);
ws.channel = "PUBLIC";
console.log("WS Open"+ ws.wsid);
WSLIST.push(ws);
}, // a socket is opened
close(ws, code, message) { console.log("WS Close") }, // a socket is closed
drain(ws) {}, // the socket is ready to receive more data
}, // handlers
});
}; startServer();
const startClient = async()=>{
const socket1 = new WebSocket("ws://localhost:"+WEBPORT+"/ws")
socket1.addEventListener("message", ev => {
console.log("Client1-Recevied:", ev.data);
});
await new Promise((r,j)=>setTimeout(()=>r(),500));
const socket2 = new WebSocket("ws://localhost:"+WEBPORT+"/ws");
socket2.addEventListener("message", ev => {
console.log("Client2-Recevied:", ev.data);
});
setInterval(() => {
if (Math.random() > 0.5 ) {
socket1.send("PING1-"+Date.now());
} else {
socket2.send("PING2-"+Date.now());
}
}, 3000);
}; setTimeout(startClient, 1000);
// 运行
bun test/bunws.ts
WS Openk5vatzjc
WS Openk5vatzx7
Server Received: PING2-1750059843799
Client1-Recevied: PONG-k5vatzjc
Client2-Recevied: PONG-k5vatzx7
...
简单总结一下这里面需要注意的地方:
- 在处理ws连接请求时(客户端使用ws协议请求),将会调用server.upgrade方法,正常返回即可(bun会向客户端发送状态:101-Switching Protocols)建立ws连接
- 核心是在Bun中,定义的websocket对象的方法,这个方法中的内容,会基于ws连接而有效
- 它的简单的生命周期是open(连接打开),message(收到消息),close(连接关闭),drain(可发送信息)
- 如果要管理多个ws client,可能需要为客户端连接分配一个id,并且维护一个连接列表
- 收到信息的逻辑,在message方法中实现,参数是服务端从客户端收到的消息
- 可以调用服务端维护的ws客户端的send方法,从服务端向客户端发送消息
- 客户端使用new WebSocket方法结合连接URL来创建ws实例
- 同时应当绑定收到消息时候的事件处理方法
- 连接成功后,可以调用ws实例的send方法,向服务端发送信息
- 没有明确提出来,但一些AI声称Bun的实现是全双工的(应当是继承于TCP,但考虑到背压管理,这其实不是很重要)
性能
作为后来者,几乎所有的bun技术模块,都比较重视程序的性能。bun ws也不例外。
bun的ws实现基于一个比较有名的ws npm库-uWebSockets。根据它的生成,可以提供比常用的ws npm库高出7倍的消息处理能力。在其提到的测试环境中,每秒传输高达70000条消息。
鉴于nodejs本身没有ws服务器实现,而常用的ws npm确实好像口碑也不是很好,笔者这里就不作相关的验证了,基本上可以确定bun ws可以提供nodejs+ws更好的ws应用性能,应当是确定无疑的。
bun ws技术文档中,有一个章节讨论了相关的性能设计,笔者认为理解这方面的内容,很有启发意义:
在 Bun 中,处理器是按服务器声明一次,而不是按套接字声明。
ServerWebSocket 要求你向 Bun.serve() 方法传递一个 WebSocketHandler 对象,该对象包含 open、message、close、drain 和 error 方法。这与客户端的 WebSocket 类不同,客户端类继承自 EventTarget(使用 onmessage、onopen、onclose)。
客户端往往不会打开很多套接字连接,所以基于事件的 API 是合理的。
但服务器往往会打开很多套接字连接,这意味着:
- 为每个连接添加/移除事件监听器所花费的时间会累积起来
- 为每个连接存储回调函数引用会消耗额外内存
- 通常,人们会为每个连接创建新的函数,这也意味着更多的内存消耗
因此,ServerWebSocket 不使用基于事件的 API,而是要求你在 Bun.serve() 中传递一个包含各个事件方法的单个对象,这个对象会在每个连接中被重用。 这样可以减少内存使用量,并减少添加/移除事件监听器所花费的时间。
订阅/发布
从签名的基本模式来看,要实现一个相对比较复杂和实际的WS应用,是需要开发者维护一个客户端列表和相对应的消息分发逻辑的。幸运的是,bun ws内置提供了订阅/发布的应用模型,在很多情况下,可以大大简化开发工作和不当实现的风险。
我们来解读一下其所提供的示例代码:
js
const server = Bun.serve<{ username: string }>({
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/chat") {
console.log(`upgrade!`);
const username = getUsernameFromReq(req);
const success = server.upgrade(req, { data: { username } });
return success
? undefined
: new Response("WebSocket upgrade error", { status: 400 });
}
return new Response("Hello world");
},
websocket: {
open(ws) {
const msg = `${ws.data.username} has entered the chat`;
ws.subscribe("the-group-chat");
server.publish("the-group-chat", msg);
},
message(ws, message) {
// this is a group chat
// so the server re-broadcasts incoming message to everyone
server.publish("the-group-chat", `${ws.data.username}: ${message}`);
},
close(ws) {
const msg = `${ws.data.username} has left the chat`;
ws.unsubscribe("the-group-chat");
server.publish("the-group-chat", msg);
},
},
});
console.log(`Listening on ${server.hostname}:${server.port}`);
可以看到,bun ws把ws连接可以抽象成为channel来进行管理。这样,从服务器角度,发送消息,就可以直接使用server向频道来发送消息,而无需找到对应的ws连接。ws在连接时,可以按需选择是否加入某个已经存在的频道,或者创建新的频道,提供了很高的业务灵活性。在业务应用中,无需考虑具体的ws连接,只需要面对业务组织的频道即可。
这段代码还给开发者应用ws提供了一个比较好的实践方式,就是在创建ws的时候,可以注入一个ws相关的数据(ws.data),然后在后续的应用中,就有机会可以关联到这个ws,比如关闭连接。
Backpress 背压控制
这是笔者在bun ws上学习到的新概念。
背压是指当数据发送速度超过接收方处理速度时产生的"压力"。在 WebSocket 通信中,如果服务器发送消息的速度比客户端处理的速度快,就会产生背压(其实就是积压的水平)。背压管理在大规模和大容量的WS应用系统中,是非常重要的,它可以防止无限制的消息积压导致内存溢出,根据网络状况调整发送策略,避免和减少消息丢失或者延迟,并且防止慢客户端影响整个服务器性能。
bun ws(实际上是uWebSocket)在底层实现了自动的背压检测和管理,可以在一定程度上实现消息发送的自我控制和适应。包括监控网络发送缓冲区的使用情况,当检测到背压时,消息会被自动排入队列,并且通过返回值告知开发者当前的背压状态,开发者可以选择后续的处理策略。
例如下面的代码,开发者可以主动检测背压状态,并且进行处理:
js
function sendWithBackpressureControl(ws, message) {
const result = ws.send(message);
switch (true) {
case result === -1:
console.log("背压检测到,延迟后重试");
setTimeout(() => sendWithBackpressureControl(ws, message), 100);
break;
case result === 0:
console.log("连接异常,停止发送");
break;
case result > 0:
console.log(`消息发送成功: ${result} 字节`);
break;
}
}
关于背压管理,在服务器端,还可以有一些配置和选项:
- backpressureLimit?: number; // 背压限制, default: 1024 * 1024 = 1 MB
- closeOnBackpressureLimit?: boolean; // 背压超限时关闭连接, default: false
- sendPings?: boolean; // 发送挂起的信息 default: true
其他选项
bun ws的应用提供了一些选项,可以用来控制服务器或者优化应用过程。
- timeout 超时
- limits 限制
- publishToSelf 发布到自己(服务器)default: false
- perMessageDeflate 消息压缩设置
js
websocket: {
idleTimeout: 60, // 60 seconds
maxPayloadLength: 1024 * 1024, // 1 MB
publishToSelf: false; // 发布到自己(服务器)default: false
perMessageDeflate: true 消息压缩策略
// ...
},
客户端
在bun ws中,客户端的实现,完全就是标准的浏览器中的 WebSocket对象的实现和使用方式。没有什么需要特别说明的。使用方法也和浏览器中类似,示例代码如下:
js
const socket = new WebSocket("ws://localhost:3000", {
headers: {
// custom headers
},
});
// message is received
socket.addEventListener("message", event => {});
// socket opened
socket.addEventListener("open", event => {});
// socket closed
socket.addEventListener("close", event => {});
// error handler
socket.addEventListener("error", event => {});
这里就需要吐槽以下这个标准的浏览器事件侦听的实现模型了。直接用on()...,多么简洁直观!
类定义
bun ws最后有一个有趣的部分,就是这个实现的完整的类定义。可以发现其实现非常简单直接清晰,是一个很好的系统架构设计的参考:
js
namespace Bun {
export function serve(params: {
fetch: (req: Request, server: Server) => Response | Promise<Response>;
websocket?: {
message: (
ws: ServerWebSocket,
message: string | ArrayBuffer | Uint8Array,
) => void;
open?: (ws: ServerWebSocket) => void;
close?: (ws: ServerWebSocket, code: number, reason: string) => void;
error?: (ws: ServerWebSocket, error: Error) => void;
drain?: (ws: ServerWebSocket) => void;
maxPayloadLength?: number; // default: 16 * 1024 * 1024 = 16 MB
idleTimeout?: number; // default: 120 (seconds)
backpressureLimit?: number; // default: 1024 * 1024 = 1 MB
closeOnBackpressureLimit?: boolean; // default: false
sendPings?: boolean; // default: true
publishToSelf?: boolean; // default: false
perMessageDeflate?:
| boolean
| {
compress?: boolean | Compressor;
decompress?: boolean | Compressor;
};
};
}): Server;
}
type Compressor =
| `"disable"`
| `"shared"`
| `"dedicated"`
| `"3KB"`
| `"4KB"`
| `"8KB"`
| `"16KB"`
| `"32KB"`
| `"64KB"`
| `"128KB"`
| `"256KB"`;
interface Server {
pendingWebSockets: number;
publish(
topic: string,
data: string | ArrayBufferView | ArrayBuffer,
compress?: boolean,
): number;
upgrade(
req: Request,
options?: {
headers?: HeadersInit;
data?: any;
},
): boolean;
}
interface ServerWebSocket {
readonly data: any;
readonly readyState: number;
readonly remoteAddress: string;
send(message: string | ArrayBuffer | Uint8Array, compress?: boolean): number;
close(code?: number, reason?: string): void;
subscribe(topic: string): void;
unsubscribe(topic: string): void;
publish(topic: string, message: string | ArrayBuffer | Uint8Array): void;
isSubscribed(topic: string): boolean;
cork(cb: (ws: ServerWebSocket) => void): void;
}
以下是笔者对此的一些初步解读和理解:
- Bun.serve,处理的结果是一个server实例
- websocket是一个可选的server属性
- 其中有一系列必须和可选的方法定义和属性
- server实例时一个接口实现
- 和WS相关的方法包括publis和upgrade
- publish的参数包括topic,data和compress(可选),结果是一个number(背压)
- upgrade参数包括req(有机会进行认证和配置),和data(可以注入ws实例)
- ws是一个ServerWebSocket接口的实现
- ws核心的方法包括send、publish、subscribe等
- ws实例,会在server收到消息,回调server.message方法,注入ws参数
- message方法的另一个参数,就是收到的消息内容
小结
本文探讨了bun中内置实现的WS模块。包括基本模型和流程、性能分析和设计、订阅发布模式、还有一些配置和选项、客户端等方面的内容。其简单实用的设计和应用风格,可以帮助开发者更方便的将WS集成到自己的应用环境当中。