mqtt-plus 架构解析(四):MqttMessageInterceptor 的扩展点设计
摘要
拦截器链看起来是框架里很"常规"的能力,但一旦边界没定好,就很容易把日志、鉴权、观测和错误处理揉成一团。本文围绕 MqttMessageInterceptor、MqttContext 和 DefaultMqttMessageRouter,解释 mqtt-plus 为什么只保留 beforeHandle / afterHandle 两个钩子、它们在路由链中的准确位置,以及为什么拦截器最终被设计成 per-listener 粒度而不是 per-message 全局钩子。
项目地址
项目地址:
https://github.com/mqttplus/mqtt-plus
配套的示例工程:
https://github.com/mqttplus/mqtt-plus-examples
如果你对这个方向感兴趣,欢迎关注、试用,也欢迎一起交流 issue 和 PR。
如果这篇文章对你有帮助,欢迎点赞、收藏,也欢迎给项目一个 Star。
到第 3 篇为止,我们已经把 mqtt-plus 的主链路拆开了:先 resolve listener,再按 listener 转换 payload,再用 invoker 真正调用方法。接下来很自然会遇到一个工程问题:
如果我想在每次消息处理前后统一打日志、做耗时统计、做 topic 级鉴权、做原始 payload 审计,该把这些逻辑放哪?
最粗暴的办法当然是直接改路由器,但这会让每一种横切逻辑都开始侵入消息主链。框架一旦走到这一步,主链会越来越重,扩展点也会越来越脆。
mqtt-plus 给这个问题的回答很克制:保留一个足够轻的拦截器接口,把它放进 listener 调用前后,但不让它越权去接管整条路由语义。
一、这篇文章到底想回答什么?
这一篇只回答三个问题:
MqttMessageInterceptor为什么只有两个钩子,而不是一个更复杂的 AOP 式模型- 拦截器在消息链里的准确位置在哪里,它能拿到什么上下文
- 为什么 mqtt-plus 最终把它设计成 per-listener 粒度,而不是消息级一次性拦截
如果只先记住一句话,可以记这句:
mqtt-plus 的拦截器不是为了"接管消息处理",而是为了在不侵入路由主链的前提下,为每个 listener 提供一个稳定、轻量、可组合的横切入口。
也正因为这个定位足够克制,后面的错误处理和路由扩展才不会和它缠在一起。
二、MqttMessageInterceptor 到底是什么?
从接口上看,MqttMessageInterceptor 比很多人预期得更简单:
java
public interface MqttMessageInterceptor {
void beforeHandle(MqttContext context);
void afterHandle(MqttContext context);
}
没有返回值,没有短路信号,也没有单独的 onError 钩子。
这恰恰说明 mqtt-plus 对拦截器的定位很明确:它不是要把整个消息处理生命周期都做成一个可编排的责任链,也不是想复刻 Servlet Filter 或 Spring AOP 的全部语义。它想保留的,只是最常见、最稳定的那两个横切切点:
- 调用 listener 之前
- 调用 listener 之后
这两个点足够支持很多现实场景:
- 进入时记录 topic、broker、payloadSize
- 出来时记录耗时和结果
- 在调用前检查 headers 或 topic 是否满足规则
- 在调用前后把一些观测指标埋进去
但它又故意没有做得太重,比如没有把"是否继续执行"或"异常单独回调"也塞进来。因为一旦拦截器接口承载太多流程控制语义,它就会开始和错误处理策略、路由策略、消息确认语义互相缠绕。
设计决策:
MqttMessageInterceptor只保留beforeHandle / afterHandle两个钩子,不额外承载短路、重试或错误决策能力。这样做的重点不是功能少,而是让拦截器始终停留在"横切观察与轻量加工"这一层,不侵入消息主链的控制语义。
三、拦截器在路由链里的位置到底在哪里?
把它放回第 2 篇讲过的消息链里,位置会非常清楚。它不在 adapter 侧,也不在 registry 解析阶段,而是在"某个 listener 已经确定、即将转换 payload 并调用方法"的这段路径上。
先看全局位置:
Adapter callback
InboundMessageSink
MqttMessageRouter.route(...)
MqttListenerRegistry.resolve()
for each matched listener
listener handling block
aggregate ErrorAction
再把 listener handling block 展开看:
create MqttContext
Interceptor.beforeHandle(context)
convert payload
ListenerInvoker.invoke(...)
Interceptor.afterHandle(context)
这个顺序在 DefaultMqttMessageRouter.route(...) 里也能直接对上:
- 先
listenerRegistry.resolve(brokerId, topic)找到所有匹配的MqttListenerDefinition - 对每个 definition 创建新的
MqttContext - 遍历 interceptors 调
beforeHandle(context) - 转换 payload,调用
listenerInvoker.invoke(...) - 不管成功还是失败,最终都在
finally里遍历 interceptors 调afterHandle(context) - 最后再把这个 listener 的处理结果交给错误处理策略和聚合器
这里有两个细节特别值得注意:
beforeHandle发生在 payload 转换之前,所以它拿到的是原始 bytes 语义的上下文,而不是某个已经转换好的业务对象。afterHandle写在finally里,所以不管 listener 成功还是抛异常,它都会被执行。
这让拦截器非常适合做观测、审计和清理类逻辑,但不适合承担错误决策本身。因为错误决策已经明确交给了 ErrorHandlingStrategy。
四、MqttContext 为什么长这样?
拦截器之所以能保持轻量,和 MqttContext 的建模方式关系很大。
MqttContext 当前只带 4 类信息:
brokerIdtopic- 原始
byte[] payload MqttHeaders
也就是说,它刻意保留的是"消息级事实",而不是"方法调用结果"或"业务对象"。
这种克制很重要,因为它决定了拦截器看见的世界是什么:
- 它知道消息来自哪个 broker
- 它知道 topic 是什么
- 它知道原始 payload 和 headers
- 但它不知道业务方法返回了什么,也不直接感知转换后的 POJO
这背后的好处是,拦截器不会被业务方法签名绑住。无论 listener 最终接收的是 String、byte[] 还是某个 POJO,拦截器看到的上下文模型都保持一致。
还有一个容易忽略但很实用的实现细节:MqttContext 在构造时会 clone payload,getPayload() 也返回 clone。这说明它虽然是一个轻量上下文对象,但仍然在尽量避免原始 payload 被外部代码直接篡改。
换句话说,mqtt-plus 并没有把 MqttContext 做成一个"大家随便挂状态"的可变大口袋,而是让它更像一个只读快照。这种选择虽然限制了某些高级玩法,但换来了更清晰的边界和更低的副作用风险。
五、为什么它是 per-listener 粒度,而不是 per-message 一次拦截?
这是这条设计里最值得说清的一点。
在 mqtt-plus 的路由模型里,一条消息可能匹配多个 listener。这个前提在第 2 篇已经讲过了。一旦接受这个前提,拦截器的粒度选择就不再是"写法偏好",而是架构一致性问题。
如果拦截器是 per-message 粒度,那么一条消息进入框架时只会统一拦一次。但接下来不同 listener 会各自经历:
- 不同的 payload 转换
- 不同的方法参数绑定
- 不同的执行结果
- 不同的错误处理结果
这时候你会发现,所谓"统一拦一次"其实并不能准确描述真正发生的处理过程。因为消息虽然只有一份,但 listener 调用是多份的。
Listener B Listener A Interceptor Router Listener B Listener A Interceptor Router beforeHandle(context for A) invoke A afterHandle(context for A) beforeHandle(context for B) invoke B afterHandle(context for B)
这张图表达的核心是:拦截器的调用单位不是"收到一条消息",而是"处理一个匹配到的 listener"。
只有这样,拦截器链才和整个路由模型一致。否则你会很难回答这些问题:
- 两个 listener 都匹配了,日志到底算一次还是两次?
- 一个 listener 成功、一个失败,拦截器应该看到哪个结果?
- 其中一个 listener 用
String,另一个用 POJO,拦截器究竟围绕谁的调用前后执行?
设计决策: mqtt-plus 的拦截器是 per-listener 粒度,而不是 per-message 粒度。因为在这个框架里,真正独立的处理单元从来不是"消息本身",而是"消息与某个 listener 的一次匹配和调用"。
这也是为什么 DefaultMqttMessageRouter 会在遍历 matches 时,为每个 MqttListenerDefinition 都重新创建一次 MqttContext 并执行一轮 interceptor。
六、为什么当前没有单独的 onError 钩子?
很多框架在讲拦截器时,都会进一步提供 onError 或 around 之类的钩子。从能力角度看,这当然更丰富;但 richness 往往也意味着职责开始重叠。
在 mqtt-plus 里,异常路径已经有一个很清晰的归属:
- listener 调用如果抛异常,
DefaultMqttMessageRouter直接交给ErrorHandlingStrategy.onError(...) - 不同 listener 的结果再由
ErrorActionAggregator去聚合
既然错误处理已经有独立机制,那么拦截器层如果再加一个 onError,很容易出现职责重叠:
- 错误日志到底在 interceptor 里打,还是在 error strategy 里打?
- 拦截器有没有资格影响最终 ACK / RETRY / REJECT 决策?
- 一旦两边都能碰异常,错误路径的优先级谁说了算?
从当前源码看,mqtt-plus 选择了比较克制的方案:让 afterHandle 保证被执行,把真正的错误决策留给错误处理层。这个选择不一定最"强大",但它非常清楚,也非常适合当前框架的边界。
这也说明一个事实:mqtt-plus 的拦截器不是"万能切面",它更接近一个围绕 listener 调用的轻量观测与包装点。
七、小结
这一篇真正想说的是,mqtt-plus 的拦截器链不是为了把所有横切问题都塞进一个 SPI,而是为了给消息主链旁边留出一个足够稳定、足够轻的扩展位。
可以把结论压缩成这几条:
MqttMessageInterceptor当前只有beforeHandle / afterHandle两个钩子,这不是能力不够,而是边界选择。- 它插在 listener 已经解析出来、payload 即将转换并调用方法的那一段路径上。
MqttContext保留的是消息级快照,而不是业务级上下文,因此拦截器可以保持统一视角。afterHandle放在finally里,说明拦截器更适合做观测、清理和统一包装,而不是承担错误决策。- 之所以是 per-listener 粒度,是因为 mqtt-plus 的真正处理单元本来就是"消息与某个 listener 的一次匹配"。
下一篇会继续沿着这条主线往下走,但主题会变得更敏感一些:当一条消息触发多个 listener,而它们的执行结果又不一致时,框架到底应该如何做错误处理和结果聚合。
系列导航
本文是 mqtt-plus 架构解析 系列的第 4/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 的抽取过程与决策 | 链接 |