ChipCode 的 MQTT 通道:设备联调为什么要下沉到进程层
九条工具链里,MQTT 是最不像“工具链”的那条。
其他八条都围绕“编译、烧录、渲染”这类一次性动作展开,而 MQTT 是一条长连接,天生异步、天生有状态。把它塞进一个围绕“调用-返回”设计的系统里,会撞上一堆问题。
这篇讲为什么还是塞了,以及塞进去之后付出了什么。
一开始想走 HTTP 轮询
最省事的做法是:设备端起一个 HTTP 服务,宿主定时去拉状态。
我们试了。结论是不行,原因有三个。
第一,设备负担。 ESP32 上开一个 HTTP 服务不算重,但每次请求都要建连、解析、回包。为了及时看到一条状态,轮询间隔得压到一秒以内,功耗和射频占用都上来了。做电池供电的设备直接没戏。
第二,方向是反的。 大部分联调场景是设备主动上报——传感器读数、按键事件、异常。用轮询去拉,等于让宿主去猜“你现在有没有话要说”。中间那几百毫秒的窗口,事件要么丢了,要么堆在设备侧。
第三,NAT 和局域网穿透。 设备在一个网段,开发机在另一个网段,这件事在实验室里不成立,在很多公司里也不成立。
MQTT 天然解决这三个问题:长连接不用反复建连、发布订阅是推模型、broker 居中转发不用直连。
为什么它不是 http 通道的一个变体
看清单就知道差别有多大:
{
"capabilities": {
"monitor": {
"transport": "mqtt",
"broker": "mqtt://192.168.1.20:1883",
"topics": {
"up": "chipcode/dev/{deviceId}/log",
"down": "chipcode/dev/{deviceId}/cmd"
},
"qos": 1
}
}
}
http 通道的描述是“往哪儿发一个请求”,mqtt 通道的描述是“订阅哪些主题、发布到哪些主题、用什么 QoS”。这不是参数差异,是模型差异。
http 的一次调用有明确的开始和结束;mqtt 的一次“调用”其实是发布一条命令,然后等一个可能永远不来的回复。
于是引入了一个请求-响应关联层
上层的 invoke(cap, ctx) 签名是同步语义的:给一个能力,返回一个结果。
要在 MQTT 上实现这个签名,必须自己造出“请求-响应”的对应关系。做法很常规:
- 发布时生成一个
reqId(时间戳 + 随机数),放进 payload - 设备回包时把这个
reqId原样带回来 - 宿主侧维护一张
Map<reqId, {resolve, reject, timer}> - 超时后 reject,并清掉定时器
const pending = new Map<string, Pending>();
function publish(req: Command, timeoutMs = 8000) {
const reqId = `${
Date.now()}-${
rand()}`;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(reqId);
reject(new Inconclusive(`no reply for ${
reqId}`));
}, timeoutMs);
pending.set(reqId, {
resolve, reject, timer });
client.publish(cap.topics.down, JSON.stringify({
...req, reqId }), {
qos: cap.qos });
});
}
注意 reject 的那个错误类型是 Inconclusive 而不是 Fail。设备没回话,不等于设备出错了。 它可能只是没连上、可能在重启、可能这条消息 QoS 0 丢了。这三种情况的处置方式完全不同,所以不能压成一个“失败”。
QoS 选 1 不选 2
清单里默认 qos: 1。
QoS 2 保证“恰好一次”,代价是四次握手。联调场景里我们更关心的是“命令能到”,重复一条命令通常无害(重发一次“读温度”不会把设备弄坏)。
反过来,如果命令有副作用(比如“校准”“复位”),重复执行就有害。这种情况我们让插件在清单里显式声明 idempotent: false,宿主侧对它的超时重发策略会关掉。
默认值应该对应最常见的场景,例外情况显式声明。 这条我们贯彻得比较彻底。
遗嘱消息是个被低估的功能
设备掉线这件事,在联调阶段发生得比想象中频繁:断电、烧录时复位、WiFi 抖动。
如果只靠心跳超时判断,中间有几秒到几十秒的窗口,宿主不知道设备死没死,只能干等。
MQTT 的 LWT(Last Will and Testament)刚好解决这个:设备连上时在 CONNECT 包里声明一条遗嘱,broker 在检测到异常断开时替它发出来。
我们在设备模板里默认加了一条:
// 连接时设置遗嘱:异常断开时由 broker 代发
esp_mqtt_client_config_t cfg = {
.session.last_will.topic = "chipcode/dev/esp32-01/status",
.session.last_will.msg = "offline",
.session.last_will.qos = 1,
.session.last_will.retain = 1,
};
retain = 1 是关键。遗嘱被保留之后,任何新订阅者一连上就能立刻知道“这台设备当前是离线的”,不用等下一次状态上报。
这个小设计让“设备在线状态”从一次轮询变成了一次订阅。体验差别很大。
主题设计:三级够了
chipcode/dev/{deviceId}/log
chipcode/dev/{deviceId}/cmd
chipcode/dev/{deviceId}/status
三级。没有第四级。
一开始设计过五级(加了 project、stage),后来砍掉了。理由:MQTT 的通配符(+、#)在层级多的时候容易写出“订阅了所有项目所有设备”这种意外,而权限控制又跟不上。
三级足够区分设备,语义清晰,通配符也好写。需要更多维度就在 payload 里带,不要往主题里塞。
代价与边界
说清楚这套东西的问题。
一是它把宿主变成了一个有状态的东西。 长连接要重连、要保活、要处理 broker 重启。这些逻辑没有一处是“纯”的,测试起来很烦。我们的做法是把连接管理单独抽出来,用一个假的 broker(本地 mosquitto)跑集成测试,而不是在单元测试里 mock。
二是它让“一次调用”的语义变模糊了。 超时到底算失败还是算不确定,取决于命令本身是否幂等。这个判断没法由框架替你完成,只能由插件作者声明。
三是它不适合高频数据。 如果设备每秒上报一百条传感器数据,走 MQTT 到宿主再展示,性能瓶颈在宿主侧的解析和渲染。这种场景应该在设备侧做聚合,或者干脆落盘再看。
我们没打算用它替代示波器和逻辑分析仪。它解决的是“设备在另一个房间,我想知道它在干什么”这个具体问题。
现在的状态
MQTT 通道上线之后,用得最多的是两类场景:一类是板子装在盒子里、只留一根电源线的时候;另一类是多个设备要一起看的时候——一个 broker 订阅三台设备,日志按 deviceId 分开显示。
这两类场景有个共同点:你已经不能靠“拔下来插到电脑上”来调试了。
到这一步,联调就不再是“看一眼串口”,而是“维持一条通道”。这也正是它必须下沉到进程层、而不能做成一次性请求的原因。