概述
本文是笔者的系列博文 《Bun技术评估》 中的第十九篇。
本文主要探讨的内容,是如何在bun开发项目中,实现MQ(消息队列)。
不要误会! 当前的Bun并不支持原生的MQ技术、协议或者技术体系,最接近的一个也就是WS的Pub/Sub模式了。所以本文探讨的内容,是基于第三方技术的实现。笔者猜想,这可能是由于这个市场相对比较分裂,还没有形成几个少数的开放而强势的事实标准的技术。
理论上所有nodejs能够实现的消息队列应用,可以在bun作为运行时进行无缝的支持。常见的技术选项包括MQTT、Kafka、RabbitMQ、RocketMQ等等。笔者这里基于技术尝试和实践的考虑,选择的是MATS。本文的内容和实现,也基于这个技术展开。
NATS
那么,为什么是NATS?
笔者不想一一例举各种功能特性和性能的比较,很多东西都是见仁见智,而且受到实际场景的制约的。没有最好的技术,只有合适的技术,这里的合适可能是更适合的环境、场景、开发团队的喜好和特点、技术水平、对开发效率的影响、运维方便性等,可以有很多方面。
简单而言,就是笔者不是特别喜欢Java或者Windows来源的技术,必须是开源的技术,开放性比较好,支持多种语言、平台和技术框架跨平台和系统。另外一些重要的考量就是,在没有环境限制的情况下,优先考虑比较新的技术框架,因为它们的设计和实现,都是针对了老旧系统的问题进行了改进和规避,并且更适应新的互联网和云计算技术体系的。当然也不能过于新,需要一定的发展周期和沉淀,相对比较成熟适用。还有就是实现方式简洁优雅,运行轻便快捷,有独特的思维和处理方式,充分考量性能安全可靠性等等。最好还有完善的文档和社区(因为它其实在很大的程度上体现了开发团队的水平、能力和组织性)...
相对而言,笔者觉得,nats就相当的符合上面的那些考量。

在NATS的官网 (nats.io) 上这样介绍NATS(好像一下格局就比较高了?):
Connective Technology for Adaptive Edge & Distributed Systems
为自适应边缘计算和分布式设计的连接技术
其三大特性集合包括:
- Single Platform,单一的平台,支持Streaming、Key-Value、Object Store 和 PubSub
- Microservices, 微服务,易发现,去中心,零信任安全
- Multi-cloud to Edge,连接应用和数据,无论是在云端和边缘
当然,这显然是一个营销化的表述,笔者在其技术文档中,找到了相对更技术化的功能特性列表:
- 以百万每秒的性能级别,发布和订阅消息
- 支持扇入/扇出(fan-in/fan-out)的交付模式
- 请求/响应
- 支持主流编程语言包括js、java、dotnet、php、pythoy...
- 使用JetStream实现持久化
- 最少一次交付/正好一次交付
- 工作队列
- 流处理
- 数据复制、数据保留、数据去重
- 高级数据结构
- 安全特性包括TLS和JWT
- 群集,高可用,自动恢复和故障容错
- 多协议支持包括TCP、MQTT、WS
- 单一可执行文件,无依赖,支持主流OS和CPU架构
NATS有一个官方的github地址,相关的发布程序都可以在这里下载:
NATS服务器
NATS是go语言开发的,所以它的安装和部署就是典型的go应用程序-单一可执行文件+参数/配置文件。因此,它的实际部署方式可以包括在线安装、容器、下载安装等多种方式。本文使用下载安装执行的方式:
js
// 工作文件夹
mkdir /opt/nats
cd /opt/nats
// 发布版本页面 https://github.com/nats-io/nats-server/releases/latest
// 选择合适的版本和架构
curl -L https://github.com/nats-io/nats-server/releases/download/vX.Y.Z/nats-server-vX.Y.Z-linux-amd64.zip -o nats-server.zip
// 解压和复制
unzip nats-server.zip -d nats-server
Archive: nats-server.zip
creating: nats-server-vX.Y.Z-linux-amd64/
sudo cp nats-server/nats-server-vX.Y.Z-linux-amd64/nats-server /usr/bin
sudo chmod a+x /usr/bin/nats-server
这里,我们就可以得到一个可执行的nats-server文件。这个文件是非常精简的,只有不到20M。
如果要让它能够按照我们设定的方式执行,需要一些参数,这可以通过一个配置文件实现。比如下面这个:
nats-server.conf
# 客户端监听的端口
port: 4222
tls {
cert_file : "./nats-tls/server-chain.crt"
key_file : "./nats-tls/server.key"
ca_file : "./nats-tls/ntca.crt"
# Client Verify
verify: false
# 允许非 TLS 连接 (生产环境应禁用)
# insecure: true
}
# HTTP 监控端口
http_port: 4224
# 客户端认证
authorization {
users: [
{ user: "user", password: "$passwd" }
]
}
// 使用配置文件启动
nohop nats-server -c nats-server.conf -DV &
NATS服务器,并不是本文的重点,这里只是简单说明一下,下载、配置和启动一个NATS服务器的简单过程。我们这里可以看到,我们可以简单的设置启动侦听的端口和客户端认证的方式。当然也可以选择tls来加强连接的安全性(需要相关的证书生成和配置)。
NATS客户端实现
命令行客户端
在正式使用nats客户端程序库进行开发之前,笔者建议开发者使用nats的命令行客户端软件,来熟悉一下nats的操作,并且对程序的开发也有很大帮助,比如如发送和接收测试信息(不用单独再写程序),测试和确认服务正常等等。
这个软件有官方的版本上github上(项目名称natscli),同样的单一可执行文件,可以选择操作系统和CPU架构的版本,在这里下载:
客户端程序安装完成之后,在正式工作之前,需要先设置一下context(上下文),告诉客户端知道如何去连接服务器。然后就可以使用命令行的方式进行操作了。一般情况下,我们使用客户端主要进行一些辅助性的操作,所以这里只是简单例举一下它的使用(以nats demo服务器为例):
js
// 设置并选择context
nats context add natsdemo --server demo.nats.io:4222 --description "NATS Demo"
nats context select natsdemo
NATS Configuration Context "natsdemo"
Description: NATS Demo
Server URLs: demo.nats.io:4222
// 确认context
nats context ls
Known contexts:
localhost Localhost
nats* NATS Demo
// 订阅主题,进入侦听状态
./nats sub yanjh.demo
14:40:55 Subscribing on yanjh.demo
// 发送消息到主题(使用另一个客户端)
nats pub cli.demo "hello world"
12:31:20 Published 11 bytes to "cli.demo"

至此,我们就可以确认,nats服务端已经安装完成,可以通过客户端连接并正常工作了。
下面我们的重点,就是探讨如何在bun程序中实现客户端并且进行消息传输的。
NATS npm
前面已经提到,bun可以通过nats npm来实现nats客户端应用开发。这是NATS官方的npm客户端库。在bun中,nats可以作为外部npm添加到项目当中:
bun add nats@lastest
// package.json dependences: { "nats": "^2.29.3" },
客户端实例和连接
和所有的C/S应用一样,在使用之前,需要先建立连接。nats的连接非常简单,就是执行 await connect方法,参数就是一些连接参数和选项,连接后就可以获得一个连接好的客户端实例进行后续操作了。
ntclient.ts
// nats 模块
const nats = require('nats');
// 连接参数
const CONN_NATS = {
servers: "nats://demo.nats.io:4222", //tls
user: '$user',
pass: '$password',
reconnect: true,
maxReconnectAttempts: 100,
tls: {
caFile: __dirname + '/ntca.crt', // CA 证书
..
},
};
// 启动连接
try {
const NTCLIENT = await nats.connect(CONN_NATS);
... doSomeThing ...
} catch (err){
console.log("NATS Error:", err.message);
}
// 关闭连接
await NTCLIENT.close();
这里有几个连接参数和选项包括:
- servers, 服务器URL
- user/pass, 用户名和密码
- reconnect, 自动重连
- maxReconnectAttempts, 自动重连尝试次数
- tls,如果使用tls,相关的配置,如CA证书,客户端证书和私钥等
连接成功之后,就可以使用发布/订阅模式来发送和接收消息信息了。
发送信息
使用客户端发送信息,非常简单,就是直接调用publish方法,参数有三个:
- topic: 主题或者通道
- content: buffer形式的内容
- option: 选项,如headers等
一个简单的参考代码如下:
js
NTCLIENT.publish(channel, Buffer.from(content),{ headers });
接收信息
接收消息的操作,稍微复杂一点,因为需要先订阅一个主题。然后在主题收到消息的时候,就会收到消息的事件。下面是相关的示例代码:
js
const subscription = NTCLIENT.subscribe(channel);
try {
for await (const msg of subscription) {
let headers = {};
if (msg.headers) for (const [k, v] of msg.headers) headers[k] = v.join();
// call back
cb(msg.string(), msg.subject, headers);
};
} catch (error) {
console.log("Error:", error);
}
// 取消订阅
subscription.unsubscribe();
注意这里nats比较独特的消息接收机制,并不是JS中常见的onMessage模型,而是基于订阅对象的for.. await..of方式。客户端可以使用一个主题来创建一个订阅对象,创建订阅之后,可以遍历这个对象,就可以依次获取订阅对象接收到的消息对象。并使用消息对象的属性进行业务操作。
常见的消息对象的属性和方法包括:
- headers: 头信息,基本上同http headers
- subject: 消息对象所在主题
- data: 消息对象数据内容,可以使用对应的转换方法
- string(): 转换为字符串
这就是nats消息传输的一般操作过程。当然,实际上,nats还提供了一些扩展性的功能和特性,来满足更多样的业务应用的需求。
NATS扩展应用
除了正常的基于订阅模式的消息发送/接收操作之外,nats提供了几个不同于传统MQ系统的扩展应用方式。
通配主题
nats npm支持订阅时,指定带有通配符的主题名称,这样就可以通过合理规划主题的结构,更加高效和灵活的处理主题信息的发送和订阅(如带有层次结构的发送策略)。
下面是几个示例:
js
// * 通配符
const s1 = nc.subscribe("help.*.system");
const s2 = nc.subscribe("help.me.*");
// 精确结尾
// the '>' matches any tokens in that position or following
// '>' can only be specified at the end of the subject
const s3 = nc.subscribe("help.>");
// 获取消息主题的确切值, message.subject
for await (const m of s) {
console.log(
`${s.getProcessed()} - ${m.subject} ${
m.data ? " " + sc.decode(m.data) : ""
}`,
);
}
Headers
NATS支持类似于HTTP协议的Headers,这个功能有助于开发者将信息内容本身和其他相关附带信息即元数据分开,但在同一个消息中发送。这个元数据可以有很多用途,如消息ID、认证信息、签名、时间戳、业务Tag等等,取决于业务需求。
下面是nats官方的示例:
js
import { connect, createInbox, Empty, headers } from "nats";
const nc = await connect( { servers: `demo.nats.io` });
const subj = createInbox();
const sub = nc.subscribe(subj);
(async () => {
for await (const m of sub) {
if (m.headers) {
for (const [key, value] of m.headers) {
console.log(`${key}=${value}`);
}
// reading/setting a header is not case sensitive
console.log("id", m.headers.get("id"));
}
}
})().then();
// headers always have their names turned into a canonical mime header key
// header names can be any printable ASCII character with the exception of `:`.
// header values can be any ASCII character except `\r` or `\n`.
// see https://www.ietf.org/rfc/rfc822.txt
const h = headers();
h.append("id", "123456");
h.append("unix_time", Date.now().toString());
nc.publish(subj, Empty, { headers: h });
await nc.flush();
await nc.close();
看起来,nats通过headers方法和类,来实现headers的功能。在发送端,可以通过headers的append方法来设置内容,并注入publish的option参数当中;在订阅端,从消息的headers属性中,按照键值对的方式来访问消息的header内容。
请求/响应模式 Request/Response
nats虽然是一个消息队列系统,原生就是一个异步系统,但它其实是可以以请求/响应的模式,就是同步的方式进行工作的。这就非常有趣了,就像是一个模拟HTTP服务的系统,这个特性在一个需要即时确定请求结果的场景中,是比较适合的。
笔者理解,nats请求响应工作模式的基本方式如下:
- 响应端(接收端)使用正常方式来订阅主题
- 发送端(请求端)使用特别的request方法,来发送消息作为请求
- 响应端会基于订阅收到请求的消息
- 响应端处理后,调用消息对象的respond方法来发送响应信息
- 请求端,会在发送方法的回调中,接收到响应的信息
所以,根据以上工作方式,nats提供的代码示例如下:
js
// 引用和连接
import { connect, Empty, StringCodec } from "nats";
const nc = await connect({ servers: "demo.nats.io:4222" });
const sc = StringCodec(); // 编解码
// 响应/订阅端
// this subscription listens for `time` requests and returns the current time
const subscription = nc.subscribe("time");
(async (sub) => {
console.log(`listening for ${sub.getSubject()} requests...`);
for await (const m of sub) {
if (m.respond(sc.encode(new Date().toISOString()))) {
console.info(`[time] handled #${sub.getProcessed()}`);
} else {
console.log(`[time] #${sub.getProcessed()} ignored - no reply subject`);
}
}
console.log(`subscription ${sub.getSubject()} drained.`);
})(subscription);
// 请求/发送端
// the client makes a request and receives a promise for a message
// by default the request times out after 1s (1000 millis) and has
// no payload.
await nc.request("time", Empty, { timeout: 1000 })
.then((m) => {
console.log(`got response: ${sc.decode(m.data)}`);
})
.catch((err) => {
console.log(`problem with request: ${err.message}`);
});
await nc.close()
这个模式的有趣和值得注意的地方是,请求响应模型,其实是通过消息传递和处理的方式实现的。也就是说,客户端发送请求消息之后,消息并不是由mast服务器来处理,而是转发给另一个订阅的客户端,处理完成之后,再次发送结果消息给请求端。也就是说,结构上的两个nats客户端,在逻辑上构成了请求和响应的客户端/服务器模式。
Inbox 收件箱
在nats的请求响应模式,我们看到,响应端响应消息的方式,是基于消息对象的response方法。还有一种常用的方式,是使用临时主题(Inbox)的方式:
js
// 响应端
for await (const msg of sub) {
const requestData = sc.decode(msg.data);
console.log(`收到请求内容: ${requestData}`);
// msg.reply 是请求方提供的 inbox 地址
if (msg.reply) {
nc.publish(msg.reply, sc.encode(`你说的是: ${requestData}`));
}
}
// 请求端,创建临时主题
// create a subscription subject that the responding send replies to
const inbox = createInbox();
const sub = nc.subscribe(inbox, {
max: 1,
callback: (_err, msg) => {
t.log(`the time is ${sc.decode(msg.data)}`);
},
});
nc.publish("time", Empty, { reply: inbox });
注意在这种情况下,发布消息的时候,在参数选项中,通过reply指定了临时的主题,这样保证响应端可以响应到正确的主题之上,并且这是一次性的。因为这里createInbox方法会得到一个唯一不重复的主题(结果类似 _INBOX.nG5uOQn3yZ7C2VTXnVpYG1), 而且设置了max=1来自动在收到消息后,取消订阅。
据说,nats的request方法和机制,本质上就是上述机制的集成:
- 自动调用createInbox()
- 自动订阅inbox
- publish(),同时设置reply
- 等待响应并解析
队列组 Queue Groups
nats提供了队列组的功能,来实现接收消息并处理的负载平衡。就是实现可以使用多个订阅客户端(队列组)来订阅同一个主题,当消息发送到来时,nats服务器可以将消息随机分配发送到组中的一个订阅者来处理,这样可以通过调整消费者的数量,来缩放消息处理的能力,这就是一个可缩放的生产者/多消费者的应用模式。

笔者理解,在nats中,是这样实现队列组的:
- 接收端(消费者)在订阅主题的时候,可选择指定要参加的队列
- 发送者正常发布信息
- 如果存在队列组,服务器会发送给队列组中的一个成员客户端
- 同时,还会发送给其他队列组或者未设置队列组的客户端
基于以上的描述,相关的示例代码非常简单,如下:
js
// 队列组只需要在订阅端设置
nc.subscribe(subj, {
queue: "workers",
callback: (_err, _msg) => {
t.log("worker1 got message");
},
});
nc.subscribe(subj, {
queue: "workers",
callback: (_err, _msg) => {
t.log("worker2 got message");
},
});
nats客户端在订阅的时候,我们可以将队列组看成一个普通的订阅者,和其他订阅者一样,都可以接收到主题消息,但是队列组中,会选择其中的一个来进行处理而已。这样就可以和原有的模式进行结合和兼容,无需特别的设置和处理,并且开发者可以灵活选择处理方式。
消息计数
NATS提供了一种特殊形式订阅方式,可以通过配置一个计数器,当发送的消息数量达到计数时,就取消订阅。虽然这个功能看起来很酷,但笔者想不出除了临时订阅(只处理一条消息,如临时的请求响应模式)之外,还有什么其他的应用场景(或者是消息数量配额?)。
nats官方给出的示例如下(两种用法,效果相同):
js
const sc = StringCodec();
// `max` specifies the number of messages that the server will forward.
// The server will auto-cancel.
const subj = createInbox();
const sub1 = nc.subscribe(subj, {
callback: (_err, msg) => {
t.log(`sub1 ${sc.decode(msg.data)}`);
},
max: 10,
});
// another way after 10 messages
const sub2 = nc.subscribe(subj, {
callback: (_err, msg) => {
t.log(`sub2 ${sc.decode(msg.data)}`);
},
});
// if the subscription already received 10 messages, the handler
// won't get any more messages
sub2.unsubscribe(10);
缓冲 Cache
出于性能考量的因素,很多客户端库都会对传出的数据进行缓冲,以便可以一次将更大的块写入网络,来提高网络传输性能。一般情况下客户端会有自己的写缓冲策略,如缓冲区满、定时写缓冲等等。当然如果应用程序需要确定信息的传输,可以强行执行flush()方法,实现写缓冲。此外,有的客户端ping/pong心跳机制,也可以达到这个目的。
相关的示例代码如下:
js
// 等待flush完成
await nc.flush();
// 查看这个订阅者已经处理/接收了多少条消息,用于衡量是否有积压
Subscription.getProcessed() / getReceived()
缓冲的处理和设置,有时候会影响到系统的性能和运行,所以有必要经常进行检查和分析。和缓冲相关的常见问题包括:
- 客户端消息延迟发送
可能是太依赖自动flush,这是可能需要及时手动调用flush(),或观察缓冲积压
- 订阅者收不到消息
可能是处理太慢导致服务器踢出,可以考虑使用 queue group 分摊压力,优化处理速度
- 内存飙升
经常是发送速度远快于消费速度,这时需要实施速率限制或背压控制
安全和认证
nats中的应用安全,包括很多方面的问题。由于这部分内容比较多,这里仅从特性和应用的角度阐述,不在展开说明,主要目的是让读者了解其具备比较完善的的安全机制。
传输安全
nats通过tls来实现信息传输和网络安全。类似于HTTPS的实现方式。并可以选择对连接客户端进行双向的验证。
客户端和用户认证
nats服务器的部署,支持多种客户端和用户的认证方式。
- user/password: 普通的用户名密码组合
- token: 配置和使用认证token,可选bcrypt加密
- nkeys: 一种新型的高度安全的公钥认证系统
- JWT: 分布式配置的认证机制
授权
我们可以通过一个授权配置信息来初步了解一下nats的授权模型:
js
authorization {
default_permissions = {
publish = "SANDBOX.*"
subscribe = ["PUBLIC.>", "_INBOX.>"]
}
ADMIN = {
publish = ">"
subscribe = ">"
}
REQUESTOR = {
publish = ["req.a", "req.b"]
subscribe = "_INBOX.>"
}
RESPONDER = {
subscribe = ["req.a", "req.b"]
publish = "_INBOX.>"
}
users = [
{user: admin, password: $ADMIN_PASS, permissions: $ADMIN}
{user: client, password: $CLIENT_PASS, permissions: $REQUESTOR}
{user: service, password: $SERVICE_PASS, permissions: $RESPONDER}
{user: other, password: $OTHER_PASS}
]
}
可以和看到,这个授权机制,可以结合的要素包括:客户端使用的用户、操作主题(支持通配符)和操作方式(订阅/发布),并可以设置一个默认的配置。这里的示例并不完整,其他的配置还包括Allow/Deny配置,Queue授权,allow_response等等的配置。感兴趣的读者可以自行查阅官方技术文档。
性能测试和评估
natscli工具,内置了性能测试。笔者简单使用了一下,考虑到测试系统的配置,感觉nats的性能还是非常不错的:
js
// 启动服务
./nats-server -m 8222 -js
// 发布性能
./nats bench pub test --msgs 10000000 --clients 2 --no-progress
17:37:09 Starting Core NATS publish benchmark [clients=2, msg-size=128 B, msgs=10,000,000, multi-subject=false, multi-subject-max=100,000, sleep=0s, subject=test]
17:37:09 Starting publisher, publishing 5,000,000 messages
17:37:09 Starting publisher, publishing 5,000,000 messages
Pub stats: 2,451,483 msgs/sec ~ 299.25 MB/sec
[1] 1,233,625 msgs/sec ~ 150.59 MB/sec (5000000 msgs)
[2] 1,225,741 msgs/sec ~ 149.63 MB/sec (5000000 msgs)
min 1,225,741 | avg 1,229,683 | max 1,233,625 | stddev 3,942 msgs
// jetstream 持久化性能
./nats bench js pub js.bench --clients 2 --msgs 1000000 --no-progress --create
17:43:02 Starting JetStream publish benchmark [batch=500, clients=2, dedup-window=2m0s, deduplication=false, max-bytes=1,073,741,824, msg-size=128 B, msgs=1,000,000, multi-subject=false, multi-subject-max=100,000, purge=false, replicas=1, sleep=0s, storage=file, stream=benchstream, subject=js.bench]
17:43:02 Starting JS publisher, publishing 500,000 messages
17:43:02 Starting JS publisher, publishing 500,000 messages
Pub stats: 47,768 msgs/sec ~ 5.83 MB/sec
[1] 23,981 msgs/sec ~ 2.93 MB/sec (500000 msgs)
[2] 23,884 msgs/sec ~ 2.92 MB/sec (500000 msgs)
min 23,884 | avg 23,932 | max 23,981 | stddev 48 msgs
// 延迟
./nats latency --server-b localhost:4222 --rate 500000
17:45:24 ==============================
17:45:24 Pub Server RTT : 260µs
17:45:24 Sub Server RTT : 175µs
17:45:24 Message Payload: 8B
17:45:24 Target Duration: 5s
17:45:24 Target Msgs/Sec: 500000
17:45:24 Target Band/Sec: 7.6M
17:45:24 ==============================
17:45:30 HDR Percentiles:
17:45:30 10: 1.944ms
17:45:30 50: 12.039ms
17:45:30 75: 22.798ms
17:45:30 90: 48.482ms
17:45:30 99: 70.697ms
17:45:30 99.9: 77.583ms
17:45:30 99.99: 81.222ms
17:45:30 99.999: 81.606ms
17:45:30 99.9999: 81.648ms
17:45:30 99.99999: 81.649ms
17:45:30 100: 81.649ms
17:45:30 ==============================
17:45:30 Actual Msgs/Sec: 500001
17:45:30 Actual Band/Sec: 7.6M
17:45:30 Minimum Latency: 81µs
17:45:30 Median Latency : 12.039ms
17:45:30 Maximum Latency: 81.649ms
17:45:30 1st Sent Wall Time : 5.409ms
17:45:30 Last Sent Wall Time: 4.999988s
17:45:30 Last Recv Wall Time: 5.001159s
小结
本文探讨和评估了一个bun的MQ技术实现,该技术方案基于nats npm,包括了NATS系统介绍,客户端和客户端的各种应用方式,包括发布/订阅、通配主题、Headers、请求响应模式、队列组、消息计数等等功能特性,以及安全和认证方面的内容,和性能测试方面等。