设备接入云端,靠的是 MQTT。本文讲解本项目中最核心的 MQTT 客户端模块:如何订阅设备上报、如何解析二进制报文、如何通过 Spring Integration 与 EMQX 建立连接,以及下行指令如何转发给设备。
一、模块定位
charge-mqtt-client 是设备接入的核心模块,全项目实现最完整(14 个 Java 文件,成熟度 ★★★★☆)。职责:
- 上行:订阅 EMQX 的
charge/stat,接收设备二进制报文 → 解析 → 投 RabbitMQ - 下行:消费 RabbitMQ 的下行指令 → MQTT publish 给设备
端口:HTTP 8080 | MQTT over WebSocket: ws://emqx:8083
二、模块结构(14 个类)
charge-mqtt-client/src/main/java/com/yeseesion/SmartChargeStation/mqtt/client/
├── MqttApplication.java 启动类
├── api/SendApi.java 发送/模拟 REST 接口(/mqtt/send、/mqtt/simulate)
├── conf/MqttProps.java @ConfigurationProperties(prefix="mqtt") 配置映射
├── factory/
│ ├── FactoryBuilder.java MqttPahoClientFactory 构建
│ ├── InBoundMessageRev.java ★★核心:入站消息分发(471 行,全项目最大类)
│ ├── InBoundSubscribe.java 入站适配器/订阅配置
│ └── OutBoundSend.java 出站 Handler 配置
├── model/MqttConstants.java 通道常量 OUT_CHANNEL="out" / In_CHANNEL="in"
├── mq/
│ ├── ChargeCmdConsumer.java 消费下行指令 → MQTT
│ ├── ChargeStatConsumers.java 三路 Fanout 消费者(手动 ACK)
│ ├── ChargeStatProducer.java Fanout 广播生产者
│ └── RabbitConfig.java Fanout 交换机 + 3 队列 + TTL
├── service/MqttService.java @MessagingGateway 网关接口
└── utils/TransformerUtils.java objectToHex / hexStringToByteArray / bytesToHex
三、上行链路核心:InBoundMessageRev(471 行)
按 Topic 分发逻辑
| Topic | 处理 |
|---|---|
charge/stat |
hex → ChargeStatData.fromHexString() 解析 24 字节帧 → buildStatJson() → 投 Fanout |
charge/stat/json |
调试通道,直接收 JSON(跳过二进制解析) |
charge/cmd |
指令回环/日志 |
三个 HTTP 回调(均带 X-Internal-Key 头)
java
callNettyPush() → POST http://netty:8085/netty/push // 推小程序
cacheStatToRedis()→ SET charge:stat:{chargeRecordId} TTL 24h
callIotDBSave() → POST http://iotdb-service:8087/iotdb/charge/stat // 写时序库
代码 172-174 行有一段被注释掉的旧逻辑------原先收到 MQTT 后同步串行调用三个下游,重构为 RabbitMQ Fanout 后异步解耦,但旧代码未删除。
四、Spring Integration MQTT 集成(常见疑问)
关键概念
| 概念 | 本项目用法 |
|---|---|
MqttPahoClientFactory |
FactoryBuilder 构建,配置 broker 地址/账号/密码 |
MqttPahoMessageDrivenChannelAdapter |
入站适配器(InBoundSubscribe),订阅 charge/stat 等 |
MqttPahoMessageHandler |
出站 handler(OutBoundSend),publish 到 charge/cmd |
@MessagingGateway |
MqttService 接口,声明式发送消息 |
| Channel | 内部消息通道(out / in 两个常量) |
为什么用 Spring Integration 而不是裸 Paho?
- 消息收发的生命周期由 Spring 管理,与 Boot 应用集成度高
- 通道抽象(Channel)方便后续加拦截器/过滤器/转换器
- 断线重连由 Paho 客户端 + Spring 适配器协同处理
下行指令的格式选择:为什么不是真·二进制?(重要!)
java
// ChargeCmdPayload 只有字段,没有 toBytes()
// 实际发送走 TransformerUtils.objectToHex():
// 整个对象 JSON 序列化 → 转 hex 字符串 → MQTT publish
字段名(start_tag 等 snake_case)也会进 payload。这是刻意为之 :下行指令由小程序发起 ,用 JSON 序列化转 hex,小程序端和抓包工具都能直接看到请求参数 (目标桩 ID、充电时长等),调试体验远优于二进制帧。上行(设备→云端)走真·二进制是设备端省流量,下行走 JSON 是前端要可读------同一个项目里按方向选协议,这正是"按场景做取舍"的体现。
注意:若未来设备端指令下发频率变高或需严格省流,可再评估是否统一为二进制------当前下行频率低(用户手动点击),JSON 方案是合理的。
五、消息可靠性设计
| 层级 | 措施 |
|---|---|
| 设备→EMQX | MQTT QoS 1(至少一次) |
| EMQX→MQTT客户端 | 订阅 QoS 1 |
| MQTT客户端→下游 | RabbitMQ 手动 ACK + 失败重入队(当前无重试上限,见下面缺陷) |
| RabbitMQ 队列 | 持久化队列(重启不丢) |
已知缺陷 :basicNack(requeue=true) 且未配置死信队列(DLQ)→ 毒药消息无限重投(见 05 篇)。
六、常见问题与解答
- MQTT 的 QoS 0/1/2 有什么区别?为什么用 QoS 1 不用 2?(QoS1 至少一次、有重复;QoS2 恰好一次、开销大;充电状态可容忍重复上报)
- MQTT 的 retain 消息有什么用?(保留最近一条,新订阅者立即收到;本项目未用)
buildStatJson()把二进制解析结果转 JSON 的字段有哪些?(chargingTime/chargedEnergy/currentPower/currentCost/chargingProgress/state;注意 chargeRecordId 是 URL 参数,不在 JSON 内;state 对应"充电中/已完成"等状态枚举)- 如果三个下游全部宕机,MQTT 客户端会发生什么?(RabbitMQ 消息堆积,队列 TTL 到期丢弃;MQTT 客户端本身不阻塞)
- Spring Integration 的 Channel 和 MQ 的 Queue 是一回事吗?(不是:Channel 是应用内消息管道,Queue 是跨应用的消息存储)
小结
MQTT 客户端是设备接入的咽喉:上行把二进制报文解析后交给 RabbitMQ 分发,下行把指令发布到 MQTT。它本身不直接连数据库,靠消息中间件与下游解耦。下一篇 【从零搭建物联网智能充电桩系统】4、Netty WebSocket 服务 讲解与小程序保持长连接的 Netty 服务端。