mqtt-plus 架构解析(三):Payload 序列化与反序列化,为什么要拆成两条链

mqtt-plus 架构解析(三):Payload 序列化与反序列化,为什么要拆成两条链

摘要

在 MQTT 场景里,入站和出站看起来都在做"payload 转换",但它们其实不是同一个问题。本文围绕 PayloadConverterPayloadSerializerDefaultMqttTemplate 和 starter 中的内置实现,解释 mqtt-plus 为什么把反序列化和序列化拆成两条独立链,以及这种拆分对扩展性、默认行为和工程边界到底意味着什么。


项目地址

项目地址:

https://github.com/mqttplus/mqtt-plus

配套的示例工程:

https://github.com/mqttplus/mqtt-plus-examples

如果你对这个方向感兴趣,欢迎关注、试用,也欢迎一起交流 issue 和 PR。

如果这篇文章对你有帮助,欢迎点赞、收藏,也欢迎给项目一个 Star。


只要开始在业务里发和收结构化消息,payload 转换几乎一定会变成一个框架问题。

收消息时,你关心的是 bytes 怎么变成 Stringbyte[] 或某个业务对象;发消息时,你关心的是 Stringbyte[] 或某个 Java 对象怎么变成最终上网线的 bytes。表面上看,这像是同一个问题的两个方向,但一旦进入框架设计,差别会非常明显:

  • 入站是按 listener 的 payloadType 决定怎么转
  • 出站是按当前 publish 的对象类型决定怎么序列化
  • 入站发生在路由链内部
  • 出站发生在 MqttTemplate 往 adapter 下发之前

也正因为如此,mqtt-plus 最后没有做"一个通用 payload 接口包打天下",而是走向了两条链。


一、这篇文章到底想回答什么?

这一篇只回答三个问题:

  • 为什么 PayloadConverterPayloadSerializer 不能合成一个接口
  • mqtt-plus 的入站链和出站链分别发生在什么位置、解决什么问题
  • 为什么 Jackson 这类具体序列化能力放在 starter 层,而不是 core 层

如果只先记住一句话,可以记这句:

在 mqtt-plus 里,"bytes -> 目标参数类型"和"对象 -> byte[]"不是同一种抽象,它们触发的时机、依赖的上下文和扩展方式都不一样,所以应该拆成两条链,而不是勉强统一成一个接口。

这条边界一旦接受,后面很多设计就会顺下来,包括为什么 DefaultMqttTemplate 要接管出站序列化,以及为什么 core 层可以始终不直接依赖 JSON 实现。


二、为什么不是一个接口搞定所有 payload 转换?

如果只看名字,PayloadConverterPayloadSerializer 很像;但从职责角度看,它们处理的是两个完全不同的方向:

  • PayloadConverter 解决的是入站反序列化:byte[] -> targetType
  • PayloadSerializer 解决的是出站序列化: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[] 时,直接返回 clone
  • StringPayloadConverter:目标类型是 String 时,按字符串解码
  • JacksonPayloadConverter:除 Stringbyte[] 外的其他类型,交给 ObjectMapper.readValue(...)

从这里可以看出,入站链本质上是一个"按 targetType 逐个试"的责任链。它不是拿到 bytes 后先猜一种格式去解,再想办法塞给所有 listener;而是每个 listener 都带着自己的 payloadType 进入转换链。

这也解释了为什么在第 2 篇里,同一条消息可以同时被 Stringbyte[] 和 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(...) 里,顺序大致是这样的:

  1. payload == null,发出 UTF-8 bytes 的 "null"
  2. payload instanceof byte[],直接原样返回
  3. payload instanceof String,按 UTF-8 编码
  4. 遍历 PayloadSerializer 链,找第一个 supports(payload.getClass()) 的实现
  5. 如果都不支持,最后退回 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 测试体系:MqttTestTemplateEmbeddedBroker 的设计 待更新
10 从内部项目到开源框架:mqtt-plus 的抽取过程与决策 待更新

上一篇:消息路由:一条 MQTT 消息如何到达你的 @MqttListener

下一篇:拦截器链:MqttMessageInterceptor 的扩展点设计(待更新链接)

相关推荐
Aray12342 小时前
论Serverless架构模式及其应用实践
云原生·架构·serverless
卷福同学2 小时前
去掉手机APP开屏广告,李跳跳2.2下载使用
java·后端·算法
天马行空_kk2 小时前
从 Skill 聊到多模态的一个下午
架构
漫霂2 小时前
二叉树的翻转
java·数据结构·算法
语戚2 小时前
力扣 51. N 皇后:基础回溯、布尔数组优化、位运算全解(Java 实现)
java·算法·leetcode·力扣·剪枝·回溯·位运算
熊猫钓鱼>_>2 小时前
从零构建大模型可调用的Skill:基于Function Calling的完整指南
人工智能·算法·语言模型·架构·agent·skill·functioncall
程序猿阿越2 小时前
Kafka4源码(三)Share Group共享组
java·后端·源码阅读
亦暖筑序3 小时前
让AI不再"一本正经胡说八道":Spring AI RAG与VectorStore源码全解
java·源码阅读
资深流水灯工程师3 小时前
FREERTOS整体架构
架构