mqtt-plus 架构解析(三):Payload 序列化与反序列化,为什么要拆成两条链
摘要
在 MQTT 场景里,入站和出站看起来都在做"payload 转换",但它们其实不是同一个问题。本文围绕 PayloadConverter、PayloadSerializer、DefaultMqttTemplate 和 starter 中的内置实现,解释 mqtt-plus 为什么把反序列化和序列化拆成两条独立链,以及这种拆分对扩展性、默认行为和工程边界到底意味着什么。
项目地址
项目地址:
https://github.com/mqttplus/mqtt-plus
配套的示例工程:
https://github.com/mqttplus/mqtt-plus-examples
如果你对这个方向感兴趣,欢迎关注、试用,也欢迎一起交流 issue 和 PR。
如果这篇文章对你有帮助,欢迎点赞、收藏,也欢迎给项目一个 Star。
只要开始在业务里发和收结构化消息,payload 转换几乎一定会变成一个框架问题。
收消息时,你关心的是 bytes 怎么变成 String、byte[] 或某个业务对象;发消息时,你关心的是 String、byte[] 或某个 Java 对象怎么变成最终上网线的 bytes。表面上看,这像是同一个问题的两个方向,但一旦进入框架设计,差别会非常明显:
- 入站是按 listener 的
payloadType决定怎么转 - 出站是按当前 publish 的对象类型决定怎么序列化
- 入站发生在路由链内部
- 出站发生在
MqttTemplate往 adapter 下发之前
也正因为如此,mqtt-plus 最后没有做"一个通用 payload 接口包打天下",而是走向了两条链。
一、这篇文章到底想回答什么?
这一篇只回答三个问题:
- 为什么
PayloadConverter和PayloadSerializer不能合成一个接口 - mqtt-plus 的入站链和出站链分别发生在什么位置、解决什么问题
- 为什么 Jackson 这类具体序列化能力放在 starter 层,而不是 core 层
如果只先记住一句话,可以记这句:
在 mqtt-plus 里,"bytes -> 目标参数类型"和"对象 -> byte[]"不是同一种抽象,它们触发的时机、依赖的上下文和扩展方式都不一样,所以应该拆成两条链,而不是勉强统一成一个接口。
这条边界一旦接受,后面很多设计就会顺下来,包括为什么 DefaultMqttTemplate 要接管出站序列化,以及为什么 core 层可以始终不直接依赖 JSON 实现。
二、为什么不是一个接口搞定所有 payload 转换?
如果只看名字,PayloadConverter 和 PayloadSerializer 很像;但从职责角度看,它们处理的是两个完全不同的方向:
PayloadConverter解决的是入站反序列化:byte[] -> targetTypePayloadSerializer解决的是出站序列化:sourceType -> byte[]
这两个方向的最大区别,不在"一个进来一个出去",而在它们依赖的信息不同。
入站反序列化时,框架已经通过路由找到了某个 MqttListenerDefinition,因此知道这个 listener 希望收到的 payloadType 是什么。换句话说,入站链是"目标类型驱动"的。
出站序列化时,框架根本没有 listener 上下文,它只拿到了 MqttTemplate.publish(brokerId, topic, payload) 里的当前对象,因此只能根据 payload.getClass() 去决定怎么序列化。也就是说,出站链是"源类型驱动"的。
出站:序列化链
Java Object / String / byte[]
PayloadSerializer.supports(sourceType)?
serialize(payload)
byte[]
Adapter.publish(...)
入站:反序列化链
MQTT payload bytes
PayloadConverter.supports(targetType)?
convert(payload, targetType)
Listener Method Argument
这张图最想表达的其实是:入站和出站并不共享同一份上下文。
- 入站知道"我要变成谁"
- 出站只知道"我现在是谁"
一旦把这两件事硬塞进一个接口里,接口签名会开始别扭,调用时机也会混乱。与其造一个看似统一、实际语义很杂的抽象,不如从一开始就承认它们是两类问题。
三、入站链:PayloadConverter 是怎么工作的?
PayloadConverter 的接口很简单:
java
public interface PayloadConverter {
boolean supports(Class<?> targetType);
Object convert(byte[] payload, Class<?> targetType);
}
这个接口的关键不是方法少,而是它把入站链的判断方式钉得很死:按目标类型匹配。
也就是说,框架先知道某个 listener 期望的 payloadType,然后遍历 converter 链,找第一个 supports(targetType) 的实现来完成转换。这个顺序在第 2 篇里其实已经埋了伏笔:DefaultMqttMessageRouter 是先 resolve 到 listener,再在循环中执行 convertPayload(payload, definition.getPayloadType())。
当前 starter 里默认提供了 3 类入站转换实现:
ByteArrayPayloadConverter:目标类型是byte[]时,直接返回 cloneStringPayloadConverter:目标类型是String时,按字符串解码JacksonPayloadConverter:除String和byte[]外的其他类型,交给ObjectMapper.readValue(...)
从这里可以看出,入站链本质上是一个"按 targetType 逐个试"的责任链。它不是拿到 bytes 后先猜一种格式去解,再想办法塞给所有 listener;而是每个 listener 都带着自己的 payloadType 进入转换链。
这也解释了为什么在第 2 篇里,同一条消息可以同时被 String、byte[] 和 POJO listener 消费。因为入站链从一开始就不是"消息级别统一反序列化",而是"listener 级别独立反序列化"。
四、出站链:PayloadSerializer 为什么要放到 DefaultMqttTemplate 里?
v1.1.0 之前,mqtt-plus 的 publish 侧本质上只把对象往 adapter 层传,adapter 再决定怎么处理。这样做的一个直接问题是:如果 payload 是一个 POJO,底层很容易退化成 String.valueOf(payload),也就是发出去的不是结构化 JSON,而是对象的 toString()。
PayloadSerializer 的引入,就是为了把这件事往上提一层,统一在 DefaultMqttTemplate 里解决。
接口本身也很简单:
java
public interface PayloadSerializer {
boolean supports(Class<?> sourceType);
byte[] serialize(Object payload);
}
它和 PayloadConverter 的不同点非常明显:判断条件不再是 targetType,而是 sourceType。因为 publish 侧没有 listener 上下文,框架只能根据当前对象的类型去选序列化器。
真正的关键逻辑在 DefaultMqttTemplate.serializePayload(...) 里,顺序大致是这样的:
payload == null,发出 UTF-8 bytes 的"null"payload instanceof byte[],直接原样返回payload instanceof String,按 UTF-8 编码- 遍历
PayloadSerializer链,找第一个supports(payload.getClass())的实现 - 如果都不支持,最后退回
String.valueOf(payload).getBytes(...)
Yes
No
Yes
No
Yes
No
Yes
No
Outgoing payload
payload == null ?
UTF-8 bytes of
ull\
byte[] ?
Return as-is
String ?
UTF-8 encode
First PayloadSerializer supports(sourceType)?
serializer.serialize(payload)
Fallback to String.valueOf(...)
这张图想强调的是:出站链不是 adapter 的工作,而是 template 的工作。
这样做的好处很直接:
- 所有 adapter 看到的都是统一的
byte[] - Paho 和 Spring Integration 不需要各自维护一套 publish 侧序列化逻辑
- 扩展 JSON、Protobuf、Avro 这类格式时,扩展点稳定且一致
设计决策: mqtt-plus 把 publish 侧序列化放在
DefaultMqttTemplate,而不是下沉到 adapter 层。这样做的核心目的,是让所有 adapter 都工作在统一的byte[]语义上,避免"不同 adapter 各自决定对象怎么变 bytes"的分裂行为。
五、starter 为什么内置 3 组实现,而且 Jackson 是条件装配的?
看 starter 的默认实现,其实很能说明这套设计的边界感。
默认内置的 3 组实现分别是:
| 方向 | 类名 | supports 逻辑 | 处理方式 |
|---|---|---|---|
| 入站 | ByteArrayPayloadConverter |
byte[].class |
返回 clone |
| 入站 | StringPayloadConverter |
String.class |
bytes 转字符串 |
| 入站 | JacksonPayloadConverter |
非 String / 非 byte[] |
ObjectMapper.readValue(...) |
| 出站 | ByteArrayPayloadSerializer |
byte[].class |
原样返回 |
| 出站 | StringPayloadSerializer |
String.class |
UTF-8 编码 |
| 出站 | JacksonPayloadSerializer |
非 byte[] / 非 String |
ObjectMapper.writeValueAsBytes(...) |
这里最值得注意的,不是"默认支持了 JSON",而是 Jackson 的支持没有进入 core,而是停留在 starter 的条件装配里。
在 MqttPlusAutoConfiguration 里:
payloadConverters()会先注册 byte[] 和 String converter- 如果 classpath 里存在 Jackson,再动态补
JacksonPayloadConverter payloadSerializerChain()也是同样的思路:byte[]、String 一定有,Jackson 序列化器只有在存在时才加入链路
这意味着 mqtt-plus 的立场非常明确:
- core 层定义的是"可以扩展 payload 转换"这个能力
- starter 层才决定"在 Spring Boot 默认环境下,给你哪些内置实现"
这样做的一个非常现实的好处是:core 不必因为"大家都爱 JSON"而直接引入 JSON 依赖。框架对 JSON 友好,但不被 JSON 绑死。
设计决策: Jackson 这类具体序列化能力放在 starter 层做条件装配,而不是直接放进 core。这样 core 才能保持稳定、最小依赖,而 Spring Boot 用户又能在默认情况下拿到足够实用的 JSON 体验。
六、这套双链设计给扩展带来了什么?
一旦入站链和出站链分开,扩展方式就会清晰很多。
如果你想支持一种新的消息格式,比如 Protobuf 或 Avro,本质上有三种可能:
- 只关心入站:那就实现一个
PayloadConverter - 只关心出站:那就实现一个
PayloadSerializer - 两边都要:那就各实现一个,分别接到两条链上
这比"写一个万能转换器"更可控,因为你不会被迫同时处理自己并不需要的方向。很多业务场景里,本来就只需要其中一边。
另外,starter 在构造 mqttPlusPayloadSerializerChain 时,还把内置和自定义实现区分开了:
- 用户自定义 serializer 先加入链
- 内置 serializer 再按固定顺序补到后面
- Jackson 仍然保持条件存在
这会让扩展语义非常稳定:如果你明确注册了自己的 serializer,框架会优先尊重你,而不是突然被内置 Jackson 抢走控制权。
从框架维护角度看,这也是双链设计的另一个价值:每条链的默认行为、优先级和扩展点,都可以单独演进,而不用担心"动一个方向,另一个方向一起被带歪"。
七、小结
这一篇真正想说明的不是"mqtt-plus 多了一个 PayloadSerializer 接口",而是:payload 转换在框架里本来就应该分成两种问题。
可以把结论压缩成这几条:
PayloadConverter解决的是入站byte[] -> targetType,它依赖 listener 的payloadType。PayloadSerializer解决的是出站sourceType -> byte[],它依赖当前 publish 对象的类型。DefaultMqttTemplate接管 publish 侧序列化后,adapter 层终于可以统一只处理byte[]语义。- starter 提供 byte[]、String、Jackson 三组默认实现,但把 Jackson 约束在条件装配层,不让 core 被具体格式依赖拖住。
- 这种双链设计看起来比"一个接口通吃"更啰嗦,但它换来了更清晰的责任边界和更稳定的扩展模型。
下一篇会继续沿着这条链往外扩,进入另一个很适合做框架扩展、但也最容易做乱的点:MqttMessageInterceptor 到底应该如何设计成一条稳定的拦截器链。
系列导航
本文是 mqtt-plus 架构解析 系列的第 3/10 篇。
| # | 主题 | 链接 |
|---|---|---|
| 1 | 总览:分层架构与设计哲学 | 链接 |
| 2 | 消息路由:一条 MQTT 消息如何到达你的 @MqttListener |
链接 |
| 3 | Payload 序列化与反序列化:双链设计的取舍 | 本文 |
| 4 | 拦截器链:MqttMessageInterceptor 的扩展点设计 |
待更新 |
| 5 | 错误处理:ErrorAction 聚合策略的设计逻辑 |
待更新 |
| 6 | 多 Broker 管理:如何让一个应用同时连接多个 MQTT 服务 | 待更新 |
| 7 | 动态订阅与重连恢复:Reconciler 的协调机制 |
待更新 |
| 8 | Spring Boot 自动装配:零件是怎么被粘合起来的 | 待更新 |
| 9 | 测试体系:MqttTestTemplate 与 EmbeddedBroker 的设计 |
待更新 |
| 10 | 从内部项目到开源框架:mqtt-plus 的抽取过程与决策 | 待更新 |
上一篇:消息路由:一条 MQTT 消息如何到达你的 @MqttListener
下一篇:拦截器链:MqttMessageInterceptor 的扩展点设计(待更新链接)