MQTT 消息模板:类型保持、编译缓存与降级策略

同一份采集数据,交给不同下游时可能需要不同字段名和嵌套结构。为每种报文写一个 Java 分支容易维护失控。我在项目中实现了一个受限的结构模板:YAML 描述形状,占位符取值,渲染后再序列化为 JSON。

更新于 2026-09-09。依据 TemplateCompiler、MqttMessageFormatterImpl 和消息上下文实现整理。本文不把设计目标当作微秒级性能实测。

为什么先构造对象,再序列化

直接在 JSON 字符串中替换文本,看起来最简单,却很容易把数字变成字符串,或者破坏引号、数组和转义。

项目先把 YAML 解析为 Map/List,再将它编译成节点树。输出也是对象结构,最后统一序列化。以构造数据 temperature = 25.5 为例:

device: "${meta.deviceId}"
temperature: "${field.temperature}"
description: "当前温度 ${field.temperature} ℃"
payload: "${fields}"

上面是语法示例,输出规则如下:

模板值 输出 类型
${field.temperature} 25.5 Number
当前温度 ${field.temperature} ℃ 当前温度 25.5 ℃ String
${fields} 采集字段对象 Object

纯占位符返回原始对象,混合文本才做字符串拼接。这样既能表达供程序读取的数据,也能表达供人阅读的描述。

这套规则只处理结构与取值,没有引入任意脚本执行。YAML 解析与 JSON 序列化仍依赖项目已有库,因此准确说法是“不额外引入通用模板引擎”,而不是“零第三方依赖”。

编译阶段承担结构分析

节点类型有五种:

类型 含义 渲染工作
LITERAL 常量或 null 返回原值
PLACEHOLDER 纯占位符 从上下文取原始对象
MIXED 文本与占位符混合 取值并拼接文本
OBJECT Map 递归构造属性
ARRAY List 递归构造元素

一次编译把不随消息变化的结构分析移出渲染过程。渲染仍需要遍历节点、创建结果对象和执行序列化,不是没有分配或没有成本。

当前编译器对嵌套占位符会告警并按字面量处理。它不是完整表达式语言:条件分支、循环和复杂数据转换不应通过越来越难解释的字符串规则偷偷加入。如果需求演进到那一步,应重新比较映射器、受限表达式或成熟模板方案。

缓存的正确性比命中率重要

编译结果以模板对象为键缓存。仅把 System.identityHashCode 的整数结果作为唯一键不可靠,因为不同对象可能得到相同值。

当前实现使用 IdentityHashMap 按引用区分模板,并同步保护编译缓存访问。它能避免把相同哈希值误当成同一对象,但不会自动清理旧对象,也不会自动感知对象内容被原地修改。

因此,需要配合两条纪律:模板更新尽量替换对象,产品修改或重载时显式失效缓存。引用缓存和内容哈希缓存各有成本,选择前者意味着必须维护好对象生命周期。

性能验证也应分开测首次编译、命中渲染、序列化和发布。把网络耗时排除后的数字不能代表整个 MQTT 通道;本文不保留没有测试条件的“低于 50μs”结论。

三层优先级解决配置复用

当前模板按产品级、全局级、内置默认结构选择。设备 ID 和标签通过上下文注入,使同一模板能服务多台设备。

这满足当前项目的复用需求,不需要先实现每台设备单独配置报文。原文“覆盖 95% 场景”缺少统计依据,本次删除。未来若同一产品下确有独立报文要求,再评估增加设备覆盖或目标配置;不应仅为格式差异随意拆分物模型。

DATA、ALARM、RECOVERY 共用编译和渲染机制,但上下文不同。模板预览需要使用对应消息类型的样例,避免在数据消息里引用只存在于告警中的字段。

降级有代价,不能只看有没有发出去

当前格式化失败会回退默认结构,这有助于维持基础输出。但是,下游可能只接受自定义结构:默认 JSON 成功发出,不意味着对方成功接收和解析。

因此,降级应可观察,日志与监控需要表达“格式化失败后使用默认结构”。严格接口可以考虑拒绝发送并记录失败,宽松消费端可以接受默认结构;具体选择取决于契约,不存在统一最优答案。

缺失变量同样不能只靠 null 兜底。例如设备标识是必填字段时,输出 null 可能比明确拒绝更难排查。当前支持取值,不代表已经实现下游 Schema 的完整校验。

模板不是连接器

格式化层负责 payload,不负责 Broker 连接、鉴权、QoS、重连或多目标路由。支持多种报文格式,也不代表已经同时向多个 Broker 独立发送。

在TDengine 与 All-in-One 的集成分析中,MQTT 可以承担数据传递;是否入库、由谁消费、失败怎样补偿属于另一层设计。

有价值的验证样例

我会用数字、布尔、数组、嵌套对象、中文文本和缺失字段验证输出类型;对同模板并发渲染与连续重载检查结果隔离;对格式错误检查实际降级形状,而不仅断言没有异常。

这套实现的重点是把灵活性限制在可解释范围内。与规则引擎一样,先明确小语言的语义和边界,再增加新语法,比不断补特殊分支更容易维护。