Commit e2b995d448955285ee340be209e0dc2d1e7a1c75
1 parent
e41d66ff
feat(iot):【协议改造】emqx 初步改造(40%):整体代码风格的优化
Showing
15 changed files
with
1056 additions
and
1314 deletions
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.config; |
| 2 | 2 | |
| 3 | -import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; | |
| 4 | 3 | import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolManager; |
| 5 | -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxAuthEventProtocol; | |
| 6 | -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.downstream.IotEmqxDownstreamSubscriber; | |
| 7 | -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol; | |
| 8 | 4 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager; |
| 9 | -import io.vertx.core.Vertx; | |
| 10 | -import lombok.extern.slf4j.Slf4j; | |
| 11 | -import org.springframework.beans.factory.annotation.Qualifier; | |
| 12 | -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; | |
| 13 | 5 | import org.springframework.boot.context.properties.EnableConfigurationProperties; |
| 14 | 6 | import org.springframework.context.annotation.Bean; |
| 15 | 7 | import org.springframework.context.annotation.Configuration; |
| 16 | 8 | |
| 17 | - | |
| 18 | 9 | @Configuration |
| 19 | 10 | @EnableConfigurationProperties(IotGatewayProperties.class) |
| 20 | -@Slf4j | |
| 21 | 11 | public class IotGatewayConfiguration { |
| 22 | 12 | |
| 23 | 13 | @Bean |
| ... | ... | @@ -30,36 +20,4 @@ public class IotGatewayConfiguration { |
| 30 | 20 | return new IotProtocolManager(gatewayProperties); |
| 31 | 21 | } |
| 32 | 22 | |
| 33 | - /** | |
| 34 | - * IoT 网关 EMQX 协议配置类 | |
| 35 | - */ | |
| 36 | - @Configuration | |
| 37 | - @ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.emqx", name = "enabled", havingValue = "true") | |
| 38 | - @Slf4j | |
| 39 | - public static class EmqxProtocolConfiguration { | |
| 40 | - | |
| 41 | - @Bean(name = "emqxVertx", destroyMethod = "close") | |
| 42 | - public Vertx emqxVertx() { | |
| 43 | - return Vertx.vertx(); | |
| 44 | - } | |
| 45 | - | |
| 46 | - @Bean | |
| 47 | - public IotEmqxAuthEventProtocol iotEmqxAuthEventProtocol(IotGatewayProperties gatewayProperties, | |
| 48 | - @Qualifier("emqxVertx") Vertx emqxVertx) { | |
| 49 | - return new IotEmqxAuthEventProtocol(gatewayProperties.getProtocol().getEmqx(), emqxVertx); | |
| 50 | - } | |
| 51 | - | |
| 52 | - @Bean | |
| 53 | - public IotEmqxUpstreamProtocol iotEmqxUpstreamProtocol(IotGatewayProperties gatewayProperties, | |
| 54 | - @Qualifier("emqxVertx") Vertx emqxVertx) { | |
| 55 | - return new IotEmqxUpstreamProtocol(gatewayProperties.getProtocol().getEmqx(), emqxVertx); | |
| 56 | - } | |
| 57 | - | |
| 58 | - @Bean | |
| 59 | - public IotEmqxDownstreamSubscriber iotEmqxDownstreamSubscriber(IotEmqxUpstreamProtocol mqttUpstreamProtocol, | |
| 60 | - IotMessageBus messageBus) { | |
| 61 | - return new IotEmqxDownstreamSubscriber(mqttUpstreamProtocol, messageBus); | |
| 62 | - } | |
| 63 | - } | |
| 64 | - | |
| 65 | 23 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java
| ... | ... | @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.iot.gateway.config; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapConfig; |
| 5 | +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxConfig; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpConfig; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttConfig; |
| 7 | 8 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpConfig; |
| ... | ... | @@ -32,11 +33,6 @@ public class IotGatewayProperties { |
| 32 | 33 | private TokenProperties token; |
| 33 | 34 | |
| 34 | 35 | /** |
| 35 | - * 协议配置(旧版,保持兼容) | |
| 36 | - */ | |
| 37 | - private ProtocolProperties protocol; | |
| 38 | - | |
| 39 | - /** | |
| 40 | 36 | * 协议实例列表 |
| 41 | 37 | */ |
| 42 | 38 | private List<ProtocolInstanceProperties> protocols; |
| ... | ... | @@ -78,186 +74,6 @@ public class IotGatewayProperties { |
| 78 | 74 | |
| 79 | 75 | } |
| 80 | 76 | |
| 81 | - @Data | |
| 82 | - public static class ProtocolProperties { | |
| 83 | - | |
| 84 | - /** | |
| 85 | - * EMQX 组件配置 | |
| 86 | - */ | |
| 87 | - private EmqxProperties emqx; | |
| 88 | - | |
| 89 | - } | |
| 90 | - | |
| 91 | - @Data | |
| 92 | - public static class EmqxProperties { | |
| 93 | - | |
| 94 | - /** | |
| 95 | - * 是否开启 | |
| 96 | - */ | |
| 97 | - @NotNull(message = "是否开启不能为空") | |
| 98 | - private Boolean enabled; | |
| 99 | - | |
| 100 | - /** | |
| 101 | - * HTTP 服务端口(默认:8090) | |
| 102 | - */ | |
| 103 | - private Integer httpPort = 8090; | |
| 104 | - | |
| 105 | - /** | |
| 106 | - * MQTT 服务器地址 | |
| 107 | - */ | |
| 108 | - @NotEmpty(message = "MQTT 服务器地址不能为空") | |
| 109 | - private String mqttHost; | |
| 110 | - | |
| 111 | - /** | |
| 112 | - * MQTT 服务器端口(默认:1883) | |
| 113 | - */ | |
| 114 | - @NotNull(message = "MQTT 服务器端口不能为空") | |
| 115 | - private Integer mqttPort = 1883; | |
| 116 | - | |
| 117 | - /** | |
| 118 | - * MQTT 用户名 | |
| 119 | - */ | |
| 120 | - @NotEmpty(message = "MQTT 用户名不能为空") | |
| 121 | - private String mqttUsername; | |
| 122 | - | |
| 123 | - /** | |
| 124 | - * MQTT 密码 | |
| 125 | - */ | |
| 126 | - @NotEmpty(message = "MQTT 密码不能为空") | |
| 127 | - private String mqttPassword; | |
| 128 | - | |
| 129 | - /** | |
| 130 | - * MQTT 客户端的 SSL 开关 | |
| 131 | - */ | |
| 132 | - @NotNull(message = "MQTT 是否开启 SSL 不能为空") | |
| 133 | - private Boolean mqttSsl = false; | |
| 134 | - | |
| 135 | - /** | |
| 136 | - * MQTT 客户端 ID(如果为空,系统将自动生成) | |
| 137 | - */ | |
| 138 | - @NotEmpty(message = "MQTT 客户端 ID 不能为空") | |
| 139 | - private String mqttClientId; | |
| 140 | - | |
| 141 | - /** | |
| 142 | - * MQTT 订阅的主题 | |
| 143 | - */ | |
| 144 | - @NotEmpty(message = "MQTT 主题不能为空") | |
| 145 | - private List<@NotEmpty(message = "MQTT 主题不能为空") String> mqttTopics; | |
| 146 | - | |
| 147 | - /** | |
| 148 | - * 默认 QoS 级别 | |
| 149 | - * <p> | |
| 150 | - * 0 - 最多一次 | |
| 151 | - * 1 - 至少一次 | |
| 152 | - * 2 - 刚好一次 | |
| 153 | - */ | |
| 154 | - private Integer mqttQos = 1; | |
| 155 | - | |
| 156 | - /** | |
| 157 | - * 连接超时时间(秒) | |
| 158 | - */ | |
| 159 | - private Integer connectTimeoutSeconds = 10; | |
| 160 | - | |
| 161 | - /** | |
| 162 | - * 重连延迟时间(毫秒) | |
| 163 | - */ | |
| 164 | - private Long reconnectDelayMs = 5000L; | |
| 165 | - | |
| 166 | - /** | |
| 167 | - * 是否启用 Clean Session (清理会话) | |
| 168 | - * true: 每次连接都是新会话,Broker 不保留离线消息和订阅关系。 | |
| 169 | - * 对于网关这类“永远在线”且会主动重新订阅的应用,建议为 true。 | |
| 170 | - */ | |
| 171 | - private Boolean cleanSession = true; | |
| 172 | - | |
| 173 | - /** | |
| 174 | - * 心跳间隔(秒) | |
| 175 | - * 用于保持连接活性,及时发现网络中断。 | |
| 176 | - */ | |
| 177 | - private Integer keepAliveIntervalSeconds = 60; | |
| 178 | - | |
| 179 | - /** | |
| 180 | - * 最大未确认消息队列大小 | |
| 181 | - * 限制已发送但未收到 Broker 确认的 QoS 1/2 消息数量,用于流量控制。 | |
| 182 | - */ | |
| 183 | - private Integer maxInflightQueue = 10000; | |
| 184 | - | |
| 185 | - /** | |
| 186 | - * 是否信任所有 SSL 证书 | |
| 187 | - * 警告:此配置会绕过证书验证,仅建议在开发和测试环境中使用! | |
| 188 | - * 在生产环境中,应设置为 false,并配置正确的信任库。 | |
| 189 | - */ | |
| 190 | - private Boolean trustAll = false; | |
| 191 | - | |
| 192 | - /** | |
| 193 | - * 遗嘱消息配置 (用于网关异常下线时通知其他系统) | |
| 194 | - */ | |
| 195 | - private final Will will = new Will(); | |
| 196 | - | |
| 197 | - /** | |
| 198 | - * 高级 SSL/TLS 配置 (用于生产环境) | |
| 199 | - */ | |
| 200 | - private final Ssl sslOptions = new Ssl(); | |
| 201 | - | |
| 202 | - /** | |
| 203 | - * 遗嘱消息 (Last Will and Testament) | |
| 204 | - */ | |
| 205 | - @Data | |
| 206 | - public static class Will { | |
| 207 | - | |
| 208 | - /** | |
| 209 | - * 是否启用遗嘱消息 | |
| 210 | - */ | |
| 211 | - private boolean enabled = false; | |
| 212 | - /** | |
| 213 | - * 遗嘱消息主题 | |
| 214 | - */ | |
| 215 | - private String topic; | |
| 216 | - /** | |
| 217 | - * 遗嘱消息内容 | |
| 218 | - */ | |
| 219 | - private String payload; | |
| 220 | - /** | |
| 221 | - * 遗嘱消息 QoS 等级 | |
| 222 | - */ | |
| 223 | - private Integer qos = 1; | |
| 224 | - /** | |
| 225 | - * 遗嘱消息是否作为保留消息发布 | |
| 226 | - */ | |
| 227 | - private boolean retain = true; | |
| 228 | - | |
| 229 | - } | |
| 230 | - | |
| 231 | - /** | |
| 232 | - * 高级 SSL/TLS 配置 | |
| 233 | - */ | |
| 234 | - @Data | |
| 235 | - public static class Ssl { | |
| 236 | - | |
| 237 | - /** | |
| 238 | - * 密钥库(KeyStore)路径,例如:classpath:certs/client.jks | |
| 239 | - * 包含客户端自己的证书和私钥,用于向服务端证明身份(双向认证)。 | |
| 240 | - */ | |
| 241 | - private String keyStorePath; | |
| 242 | - /** | |
| 243 | - * 密钥库密码 | |
| 244 | - */ | |
| 245 | - private String keyStorePassword; | |
| 246 | - /** | |
| 247 | - * 信任库(TrustStore)路径,例如:classpath:certs/trust.jks | |
| 248 | - * 包含服务端信任的 CA 证书,用于验证服务端的身份,防止中间人攻击。 | |
| 249 | - */ | |
| 250 | - private String trustStorePath; | |
| 251 | - /** | |
| 252 | - * 信任库密码 | |
| 253 | - */ | |
| 254 | - private String trustStorePassword; | |
| 255 | - | |
| 256 | - } | |
| 257 | - | |
| 258 | - } | |
| 259 | - | |
| 260 | - // NOTE:暂未统一为 ProtocolProperties,待协议改造完成再调整 | |
| 261 | 77 | /** |
| 262 | 78 | * 协议实例配置 |
| 263 | 79 | */ |
| ... | ... | @@ -283,6 +99,10 @@ public class IotGatewayProperties { |
| 283 | 99 | private String protocol; |
| 284 | 100 | /** |
| 285 | 101 | * 服务端口 |
| 102 | + * <p> | |
| 103 | + * 不同协议含义不同: | |
| 104 | + * 1. TCP/UDP/HTTP/WebSocket/MQTT/CoAP:对应网关自身监听的服务端口 | |
| 105 | + * 2. EMQX:对应网关提供给 EMQX 回调的 HTTP Hook 端口(/mqtt/auth、/mqtt/acl、/mqtt/event) | |
| 286 | 106 | */ |
| 287 | 107 | @NotNull(message = "服务端口不能为空") |
| 288 | 108 | private Integer port; |
| ... | ... | @@ -292,7 +112,7 @@ public class IotGatewayProperties { |
| 292 | 112 | * @see cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum |
| 293 | 113 | * |
| 294 | 114 | * 为什么是可选的呢? |
| 295 | - * 1. {@link IotProtocolTypeEnum#HTTP}、${@link IotProtocolTypeEnum#COAP} 协议,目前强制是 JSON 格式 | |
| 115 | + * 1. {@link IotProtocolTypeEnum#HTTP}、{@link IotProtocolTypeEnum#COAP} 协议,目前强制是 JSON 格式 | |
| 296 | 116 | * 2. {@link IotProtocolTypeEnum#EMQX} 协议,目前支持根据产品(设备)配置的序列化类型来解析 |
| 297 | 117 | */ |
| 298 | 118 | private String serialize; |
| ... | ... | @@ -304,13 +124,17 @@ public class IotGatewayProperties { |
| 304 | 124 | */ |
| 305 | 125 | @Valid |
| 306 | 126 | private IotHttpConfig http; |
| 127 | + /** | |
| 128 | + * WebSocket 协议配置 | |
| 129 | + */ | |
| 130 | + @Valid | |
| 131 | + private IotWebSocketConfig websocket; | |
| 307 | 132 | |
| 308 | 133 | /** |
| 309 | 134 | * TCP 协议配置 |
| 310 | 135 | */ |
| 311 | 136 | @Valid |
| 312 | 137 | private IotTcpConfig tcp; |
| 313 | - | |
| 314 | 138 | /** |
| 315 | 139 | * UDP 协议配置 |
| 316 | 140 | */ |
| ... | ... | @@ -324,16 +148,15 @@ public class IotGatewayProperties { |
| 324 | 148 | private IotCoapConfig coap; |
| 325 | 149 | |
| 326 | 150 | /** |
| 327 | - * WebSocket 协议配置 | |
| 151 | + * MQTT 协议配置 | |
| 328 | 152 | */ |
| 329 | 153 | @Valid |
| 330 | - private IotWebSocketConfig websocket; | |
| 331 | - | |
| 154 | + private IotMqttConfig mqtt; | |
| 332 | 155 | /** |
| 333 | - * MQTT 协议配置 | |
| 156 | + * EMQX 协议配置 | |
| 334 | 157 | */ |
| 335 | 158 | @Valid |
| 336 | - private IotMqttConfig mqtt; | |
| 159 | + private IotEmqxConfig emqx; | |
| 337 | 160 | |
| 338 | 161 | } |
| 339 | 162 | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManager.java
| ... | ... | @@ -5,6 +5,7 @@ import cn.hutool.core.util.BooleanUtil; |
| 5 | 5 | import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; |
| 6 | 6 | import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; |
| 7 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapProtocol; |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol; | |
| 8 | 9 | import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol; |
| 9 | 10 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttProtocol; |
| 10 | 11 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpProtocol; |
| ... | ... | @@ -109,6 +110,8 @@ public class IotProtocolManager implements SmartLifecycle { |
| 109 | 110 | return createWebSocketProtocol(config); |
| 110 | 111 | case MQTT: |
| 111 | 112 | return createMqttProtocol(config); |
| 113 | + case EMQX: | |
| 114 | + return createEmqxProtocol(config); | |
| 112 | 115 | default: |
| 113 | 116 | throw new IllegalArgumentException(String.format( |
| 114 | 117 | "[createProtocol][协议实例 %s 的协议类型 %s 暂不支持]", config.getId(), protocolType)); |
| ... | ... | @@ -175,4 +178,14 @@ public class IotProtocolManager implements SmartLifecycle { |
| 175 | 178 | return new IotMqttProtocol(config); |
| 176 | 179 | } |
| 177 | 180 | |
| 181 | + /** | |
| 182 | + * 创建 EMQX 协议实例 | |
| 183 | + * | |
| 184 | + * @param config 协议实例配置 | |
| 185 | + * @return EMQX 协议实例 | |
| 186 | + */ | |
| 187 | + private IotEmqxProtocol createEmqxProtocol(IotGatewayProperties.ProtocolInstanceProperties config) { | |
| 188 | + return new IotEmqxProtocol(config); | |
| 189 | + } | |
| 190 | + | |
| 178 | 191 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxAuthEventProtocol.java deleted
| 1 | -package cn.iocoder.yudao.module.iot.gateway.protocol.emqx; | |
| 2 | - | |
| 3 | -import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 4 | -import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 5 | -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmqxAuthEventHandler; | |
| 6 | -import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; | |
| 7 | -import io.vertx.core.Vertx; | |
| 8 | -import io.vertx.core.http.HttpServer; | |
| 9 | -import io.vertx.ext.web.Router; | |
| 10 | -import io.vertx.ext.web.handler.BodyHandler; | |
| 11 | -import jakarta.annotation.PostConstruct; | |
| 12 | -import jakarta.annotation.PreDestroy; | |
| 13 | -import lombok.extern.slf4j.Slf4j; | |
| 14 | - | |
| 15 | -/** | |
| 16 | - * IoT 网关 EMQX 认证事件协议服务 | |
| 17 | - * <p> | |
| 18 | - * 为 EMQX 提供 HTTP 接口服务,包括: | |
| 19 | - * 1. 设备认证接口 - 对应 EMQX HTTP 认证插件 | |
| 20 | - * 2. 设备事件处理接口 - 对应 EMQX Webhook 事件通知 | |
| 21 | - * | |
| 22 | - * @author 芋道源码 | |
| 23 | - */ | |
| 24 | -@Slf4j | |
| 25 | -public class IotEmqxAuthEventProtocol { | |
| 26 | - | |
| 27 | - private final IotGatewayProperties.EmqxProperties emqxProperties; | |
| 28 | - | |
| 29 | - private final String serverId; | |
| 30 | - | |
| 31 | - private final Vertx vertx; | |
| 32 | - | |
| 33 | - private HttpServer httpServer; | |
| 34 | - | |
| 35 | - public IotEmqxAuthEventProtocol(IotGatewayProperties.EmqxProperties emqxProperties, | |
| 36 | - Vertx vertx) { | |
| 37 | - this.emqxProperties = emqxProperties; | |
| 38 | - this.vertx = vertx; | |
| 39 | - this.serverId = IotDeviceMessageUtils.generateServerId(emqxProperties.getMqttPort()); | |
| 40 | - } | |
| 41 | - | |
| 42 | - @PostConstruct | |
| 43 | - public void start() { | |
| 44 | - try { | |
| 45 | - startHttpServer(); | |
| 46 | - log.info("[start][IoT 网关 EMQX 认证事件协议服务启动成功, 端口: {}]", emqxProperties.getHttpPort()); | |
| 47 | - } catch (Exception e) { | |
| 48 | - log.error("[start][IoT 网关 EMQX 认证事件协议服务启动失败]", e); | |
| 49 | - throw e; | |
| 50 | - } | |
| 51 | - } | |
| 52 | - | |
| 53 | - @PreDestroy | |
| 54 | - public void stop() { | |
| 55 | - stopHttpServer(); | |
| 56 | - log.info("[stop][IoT 网关 EMQX 认证事件协议服务已停止]"); | |
| 57 | - } | |
| 58 | - | |
| 59 | - /** | |
| 60 | - * 启动 HTTP 服务器 | |
| 61 | - */ | |
| 62 | - private void startHttpServer() { | |
| 63 | - int port = emqxProperties.getHttpPort(); | |
| 64 | - | |
| 65 | - // 1. 创建路由 | |
| 66 | - Router router = Router.router(vertx); | |
| 67 | - router.route().handler(BodyHandler.create()); | |
| 68 | - | |
| 69 | - // 2. 创建处理器,传入 serverId | |
| 70 | - IotEmqxAuthEventHandler handler = new IotEmqxAuthEventHandler(serverId); | |
| 71 | - router.post(IotMqttTopicUtils.MQTT_AUTH_PATH).handler(handler::handleAuth); | |
| 72 | - router.post(IotMqttTopicUtils.MQTT_EVENT_PATH).handler(handler::handleEvent); | |
| 73 | - // TODO @haohao:/mqtt/acl 需要处理么? | |
| 74 | - // TODO @芋艿:已在 EMQX 处理,如果是“设备直连”模式需要处理 | |
| 75 | - | |
| 76 | - // 3. 启动 HTTP 服务器 | |
| 77 | - try { | |
| 78 | - httpServer = vertx.createHttpServer() | |
| 79 | - .requestHandler(router) | |
| 80 | - .listen(port) | |
| 81 | - .result(); | |
| 82 | - } catch (Exception e) { | |
| 83 | - log.error("[startHttpServer][HTTP 服务器启动失败, 端口: {}]", port, e); | |
| 84 | - throw e; | |
| 85 | - } | |
| 86 | - } | |
| 87 | - | |
| 88 | - /** | |
| 89 | - * 停止 HTTP 服务器 | |
| 90 | - */ | |
| 91 | - private void stopHttpServer() { | |
| 92 | - if (httpServer == null) { | |
| 93 | - return; | |
| 94 | - } | |
| 95 | - | |
| 96 | - try { | |
| 97 | - httpServer.close().result(); | |
| 98 | - log.info("[stopHttpServer][HTTP 服务器已停止]"); | |
| 99 | - } catch (Exception e) { | |
| 100 | - log.error("[stopHttpServer][HTTP 服务器停止失败]", e); | |
| 101 | - } | |
| 102 | - } | |
| 103 | - | |
| 104 | -} | |
| 105 | 0 | \ No newline at end of file |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxConfig.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx; | |
| 2 | + | |
| 3 | +import jakarta.validation.Valid; | |
| 4 | +import jakarta.validation.constraints.Max; | |
| 5 | +import jakarta.validation.constraints.Min; | |
| 6 | +import jakarta.validation.constraints.NotEmpty; | |
| 7 | +import jakarta.validation.constraints.NotNull; | |
| 8 | +import lombok.Data; | |
| 9 | + | |
| 10 | +import java.util.List; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * IoT EMQX 协议配置 | |
| 14 | + * | |
| 15 | + * @author 芋道源码 | |
| 16 | + */ | |
| 17 | +@Data | |
| 18 | +public class IotEmqxConfig { | |
| 19 | + | |
| 20 | + // ========== MQTT Client 配置(连接 EMQX Broker) ========== | |
| 21 | + | |
| 22 | + /** | |
| 23 | + * MQTT 服务器地址 | |
| 24 | + */ | |
| 25 | + @NotEmpty(message = "MQTT 服务器地址不能为空") | |
| 26 | + private String mqttHost; | |
| 27 | + | |
| 28 | + /** | |
| 29 | + * MQTT 服务器端口(默认:1883) | |
| 30 | + */ | |
| 31 | + @NotNull(message = "MQTT 服务器端口不能为空") | |
| 32 | + private Integer mqttPort = 1883; | |
| 33 | + | |
| 34 | + /** | |
| 35 | + * MQTT 用户名 | |
| 36 | + */ | |
| 37 | + @NotEmpty(message = "MQTT 用户名不能为空") | |
| 38 | + private String mqttUsername; | |
| 39 | + | |
| 40 | + /** | |
| 41 | + * MQTT 密码 | |
| 42 | + */ | |
| 43 | + @NotEmpty(message = "MQTT 密码不能为空") | |
| 44 | + private String mqttPassword; | |
| 45 | + | |
| 46 | + /** | |
| 47 | + * MQTT 客户端的 SSL 开关 | |
| 48 | + */ | |
| 49 | + @NotNull(message = "MQTT 是否开启 SSL 不能为空") | |
| 50 | + private Boolean mqttSsl = false; | |
| 51 | + | |
| 52 | + /** | |
| 53 | + * MQTT 客户端 ID | |
| 54 | + */ | |
| 55 | + @NotEmpty(message = "MQTT 客户端 ID 不能为空") | |
| 56 | + private String mqttClientId; | |
| 57 | + | |
| 58 | + /** | |
| 59 | + * MQTT 订阅的主题 | |
| 60 | + */ | |
| 61 | + @NotEmpty(message = "MQTT 主题不能为空") | |
| 62 | + private List<@NotEmpty(message = "MQTT 主题不能为空") String> mqttTopics; | |
| 63 | + | |
| 64 | + /** | |
| 65 | + * 默认 QoS 级别 | |
| 66 | + * <p> | |
| 67 | + * 0 - 最多一次 | |
| 68 | + * 1 - 至少一次 | |
| 69 | + * 2 - 刚好一次 | |
| 70 | + */ | |
| 71 | + @NotNull(message = "MQTT QoS 不能为空") | |
| 72 | + @Min(value = 0, message = "MQTT QoS 不能小于 0") | |
| 73 | + @Max(value = 2, message = "MQTT QoS 不能大于 2") | |
| 74 | + private Integer mqttQos = 1; | |
| 75 | + | |
| 76 | + /** | |
| 77 | + * 连接超时时间(秒) | |
| 78 | + */ | |
| 79 | + @NotNull(message = "连接超时时间不能为空") | |
| 80 | + @Min(value = 1, message = "连接超时时间不能小于 1 秒") | |
| 81 | + private Integer connectTimeoutSeconds = 10; | |
| 82 | + | |
| 83 | + /** | |
| 84 | + * 重连延迟时间(毫秒) | |
| 85 | + */ | |
| 86 | + @NotNull(message = "重连延迟时间不能为空") | |
| 87 | + @Min(value = 0, message = "重连延迟时间不能小于 0 毫秒") | |
| 88 | + private Long reconnectDelayMs = 5000L; | |
| 89 | + | |
| 90 | + /** | |
| 91 | + * 是否启用 Clean Session (清理会话) | |
| 92 | + * true: 每次连接都是新会话,Broker 不保留离线消息和订阅关系。 | |
| 93 | + * 对于网关这类“永远在线”且会主动重新订阅的应用,建议为 true。 | |
| 94 | + */ | |
| 95 | + @NotNull(message = "是否启用 Clean Session 不能为空") | |
| 96 | + private Boolean cleanSession = true; | |
| 97 | + | |
| 98 | + /** | |
| 99 | + * 心跳间隔(秒) | |
| 100 | + * 用于保持连接活性,及时发现网络中断。 | |
| 101 | + */ | |
| 102 | + @NotNull(message = "心跳间隔不能为空") | |
| 103 | + @Min(value = 1, message = "心跳间隔不能小于 1 秒") | |
| 104 | + private Integer keepAliveIntervalSeconds = 60; | |
| 105 | + | |
| 106 | + /** | |
| 107 | + * 最大未确认消息队列大小 | |
| 108 | + * 限制已发送但未收到 Broker 确认的 QoS 1/2 消息数量,用于流量控制。 | |
| 109 | + */ | |
| 110 | + @NotNull(message = "最大未确认消息队列大小不能为空") | |
| 111 | + @Min(value = 1, message = "最大未确认消息队列大小不能小于 1") | |
| 112 | + private Integer maxInflightQueue = 10000; | |
| 113 | + | |
| 114 | + /** | |
| 115 | + * 是否信任所有 SSL 证书 | |
| 116 | + * 警告:此配置会绕过证书验证,仅建议在开发和测试环境中使用! | |
| 117 | + * 在生产环境中,应设置为 false,并配置正确的信任库。 | |
| 118 | + */ | |
| 119 | + @NotNull(message = "是否信任所有 SSL 证书不能为空") | |
| 120 | + private Boolean trustAll = false; | |
| 121 | + | |
| 122 | + // ========== MQTT Will / SSL 高级配置 ========== | |
| 123 | + | |
| 124 | + /** | |
| 125 | + * 遗嘱消息配置 (用于网关异常下线时通知其他系统) | |
| 126 | + */ | |
| 127 | + @Valid | |
| 128 | + private Will will = new Will(); | |
| 129 | + | |
| 130 | + /** | |
| 131 | + * 高级 SSL/TLS 配置 (用于生产环境) | |
| 132 | + */ | |
| 133 | + @Valid | |
| 134 | + private Ssl sslOptions = new Ssl(); | |
| 135 | + | |
| 136 | + // ========== HTTP Hook 配置(网关提供给 EMQX 调用) ========== | |
| 137 | + | |
| 138 | + /** | |
| 139 | + * HTTP Hook 服务配置(用于 /mqtt/auth、/mqtt/event) | |
| 140 | + */ | |
| 141 | + @Valid | |
| 142 | + private Http http = new Http(); | |
| 143 | + | |
| 144 | + /** | |
| 145 | + * 遗嘱消息 (Last Will and Testament) | |
| 146 | + */ | |
| 147 | + @Data | |
| 148 | + public static class Will { | |
| 149 | + | |
| 150 | + /** | |
| 151 | + * 是否启用遗嘱消息 | |
| 152 | + */ | |
| 153 | + private boolean enabled = false; | |
| 154 | + /** | |
| 155 | + * 遗嘱消息主题 | |
| 156 | + */ | |
| 157 | + private String topic; | |
| 158 | + /** | |
| 159 | + * 遗嘱消息内容 | |
| 160 | + */ | |
| 161 | + private String payload; | |
| 162 | + /** | |
| 163 | + * 遗嘱消息 QoS 等级 | |
| 164 | + */ | |
| 165 | + @Min(value = 0, message = "遗嘱消息 QoS 不能小于 0") | |
| 166 | + @Max(value = 2, message = "遗嘱消息 QoS 不能大于 2") | |
| 167 | + private Integer qos = 1; | |
| 168 | + /** | |
| 169 | + * 遗嘱消息是否作为保留消息发布 | |
| 170 | + */ | |
| 171 | + private boolean retain = true; | |
| 172 | + | |
| 173 | + } | |
| 174 | + | |
| 175 | + /** | |
| 176 | + * 高级 SSL/TLS 配置 | |
| 177 | + */ | |
| 178 | + @Data | |
| 179 | + public static class Ssl { | |
| 180 | + | |
| 181 | + /** | |
| 182 | + * 密钥库(KeyStore)路径,例如:classpath:certs/client.jks | |
| 183 | + * 包含客户端自己的证书和私钥,用于向服务端证明身份(双向认证)。 | |
| 184 | + */ | |
| 185 | + private String keyStorePath; | |
| 186 | + /** | |
| 187 | + * 密钥库密码 | |
| 188 | + */ | |
| 189 | + private String keyStorePassword; | |
| 190 | + /** | |
| 191 | + * 信任库(TrustStore)路径,例如:classpath:certs/trust.jks | |
| 192 | + * 包含服务端信任的 CA 证书,用于验证服务端的身份,防止中间人攻击。 | |
| 193 | + */ | |
| 194 | + private String trustStorePath; | |
| 195 | + /** | |
| 196 | + * 信任库密码 | |
| 197 | + */ | |
| 198 | + private String trustStorePassword; | |
| 199 | + | |
| 200 | + } | |
| 201 | + | |
| 202 | + /** | |
| 203 | + * HTTP Hook 服务 SSL 配置 | |
| 204 | + */ | |
| 205 | + @Data | |
| 206 | + public static class Http { | |
| 207 | + | |
| 208 | + /** | |
| 209 | + * 是否启用 SSL | |
| 210 | + */ | |
| 211 | + private Boolean sslEnabled = false; | |
| 212 | + | |
| 213 | + /** | |
| 214 | + * SSL 证书路径 | |
| 215 | + */ | |
| 216 | + private String sslCertPath; | |
| 217 | + | |
| 218 | + /** | |
| 219 | + * SSL 私钥路径 | |
| 220 | + */ | |
| 221 | + private String sslKeyPath; | |
| 222 | + | |
| 223 | + } | |
| 224 | + | |
| 225 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocol.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx; | |
| 2 | + | |
| 3 | +import cn.hutool.core.collection.CollUtil; | |
| 4 | +import cn.hutool.core.lang.Assert; | |
| 5 | +import cn.hutool.core.util.StrUtil; | |
| 6 | +import cn.hutool.extra.spring.SpringUtil; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; | |
| 9 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 10 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolInstanceProperties; | |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; | |
| 12 | +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.downstream.IotEmqxDownstreamSubscriber; | |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmqxAuthEventHandler; | |
| 14 | +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmqxUpstreamHandler; | |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; | |
| 16 | +import io.netty.handler.codec.mqtt.MqttQoS; | |
| 17 | +import io.vertx.core.Vertx; | |
| 18 | +import io.vertx.core.buffer.Buffer; | |
| 19 | +import io.vertx.core.http.HttpServer; | |
| 20 | +import io.vertx.core.http.HttpServerOptions; | |
| 21 | +import io.vertx.core.net.JksOptions; | |
| 22 | +import io.vertx.core.net.PemKeyCertOptions; | |
| 23 | +import io.vertx.ext.web.Router; | |
| 24 | +import io.vertx.ext.web.handler.BodyHandler; | |
| 25 | +import io.vertx.mqtt.MqttClient; | |
| 26 | +import io.vertx.mqtt.MqttClientOptions; | |
| 27 | +import lombok.Getter; | |
| 28 | +import lombok.extern.slf4j.Slf4j; | |
| 29 | + | |
| 30 | +import java.util.List; | |
| 31 | +import java.util.Map; | |
| 32 | +import java.util.concurrent.TimeUnit; | |
| 33 | + | |
| 34 | +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * IoT 网关 EMQX 协议实现: | |
| 38 | + * <p> | |
| 39 | + * 1. 提供 HTTP Hook 服务(/mqtt/auth、/mqtt/acl、/mqtt/event)给 EMQX 调用 | |
| 40 | + * 2. 通过 MQTT Client 订阅设备上行消息,并发布下行消息到 Broker | |
| 41 | + * | |
| 42 | + * @author 芋道源码 | |
| 43 | + */ | |
| 44 | +@Slf4j | |
| 45 | +public class IotEmqxProtocol implements IotProtocol { | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * 协议配置 | |
| 49 | + */ | |
| 50 | + private final ProtocolInstanceProperties properties; | |
| 51 | + /** | |
| 52 | + * EMQX 配置 | |
| 53 | + */ | |
| 54 | + private final IotEmqxConfig emqxConfig; | |
| 55 | + /** | |
| 56 | + * 服务器 ID | |
| 57 | + */ | |
| 58 | + @Getter | |
| 59 | + private final String serverId; | |
| 60 | + | |
| 61 | + /** | |
| 62 | + * 运行状态 | |
| 63 | + */ | |
| 64 | + @Getter | |
| 65 | + private volatile boolean running = false; | |
| 66 | + | |
| 67 | + /** | |
| 68 | + * Vert.x 实例 | |
| 69 | + */ | |
| 70 | + private Vertx vertx; | |
| 71 | + /** | |
| 72 | + * HTTP Hook 服务器 | |
| 73 | + */ | |
| 74 | + private HttpServer httpServer; | |
| 75 | + | |
| 76 | + /** | |
| 77 | + * MQTT Client | |
| 78 | + */ | |
| 79 | + private volatile MqttClient mqttClient; | |
| 80 | + /** | |
| 81 | + * MQTT 重连定时器 ID | |
| 82 | + */ | |
| 83 | + private volatile Long reconnectTimerId; | |
| 84 | + | |
| 85 | + /** | |
| 86 | + * 上行消息处理器 | |
| 87 | + */ | |
| 88 | + private final IotEmqxUpstreamHandler upstreamHandler; | |
| 89 | + | |
| 90 | + /** | |
| 91 | + * 下行消息订阅者 | |
| 92 | + */ | |
| 93 | + private final IotEmqxDownstreamSubscriber downstreamSubscriber; | |
| 94 | + | |
| 95 | + public IotEmqxProtocol(ProtocolInstanceProperties properties) { | |
| 96 | + Assert.notNull(properties, "协议实例配置不能为空"); | |
| 97 | + Assert.notNull(properties.getEmqx(), "EMQX 协议配置(emqx)不能为空"); | |
| 98 | + this.properties = properties; | |
| 99 | + this.emqxConfig = properties.getEmqx(); | |
| 100 | + Assert.notNull(emqxConfig.getConnectTimeoutSeconds(), | |
| 101 | + "MQTT 连接超时时间(emqx.connect-timeout-seconds)不能为空"); | |
| 102 | + this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort()); | |
| 103 | + this.upstreamHandler = new IotEmqxUpstreamHandler(serverId); | |
| 104 | + | |
| 105 | + // 初始化下行消息订阅者 | |
| 106 | + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); | |
| 107 | + this.downstreamSubscriber = new IotEmqxDownstreamSubscriber(this, messageBus); | |
| 108 | + } | |
| 109 | + | |
| 110 | + @Override | |
| 111 | + public String getId() { | |
| 112 | + return properties.getId(); | |
| 113 | + } | |
| 114 | + | |
| 115 | + @Override | |
| 116 | + public IotProtocolTypeEnum getType() { | |
| 117 | + return IotProtocolTypeEnum.EMQX; | |
| 118 | + } | |
| 119 | + | |
| 120 | + @Override | |
| 121 | + public void start() { | |
| 122 | + if (running) { | |
| 123 | + log.warn("[start][IoT EMQX 协议 {} 已经在运行中]", getId()); | |
| 124 | + return; | |
| 125 | + } | |
| 126 | + | |
| 127 | + // 1.1 创建 Vertx 实例 | |
| 128 | + this.vertx = Vertx.vertx(); | |
| 129 | + | |
| 130 | + try { | |
| 131 | + // 1.2 启动 HTTP Hook 服务 | |
| 132 | + startHttpServer(); | |
| 133 | + | |
| 134 | + // 1.3 启动 MQTT Client | |
| 135 | + startMqttClient(); | |
| 136 | + running = true; | |
| 137 | + log.info("[start][IoT EMQX 协议 {} 启动成功,hookPort:{},serverId:{}]", | |
| 138 | + getId(), properties.getPort(), serverId); | |
| 139 | + | |
| 140 | + // 2. 启动下行消息订阅者 | |
| 141 | + this.downstreamSubscriber.start(); | |
| 142 | + } catch (Exception e) { | |
| 143 | + log.error("[start][IoT EMQX 协议 {} 启动失败]", getId(), e); | |
| 144 | + // 启动失败时,关闭资源 | |
| 145 | + stop0(); | |
| 146 | + throw e; | |
| 147 | + } | |
| 148 | + } | |
| 149 | + | |
| 150 | + @Override | |
| 151 | + public void stop() { | |
| 152 | + if (!running) { | |
| 153 | + return; | |
| 154 | + } | |
| 155 | + stop0(); | |
| 156 | + } | |
| 157 | + | |
| 158 | + private void stop0() { | |
| 159 | + // 1. 停止下行消息订阅者 | |
| 160 | + try { | |
| 161 | + downstreamSubscriber.stop(); | |
| 162 | + log.info("[stop][IoT EMQX 协议 {} 下行消息订阅者已停止]", getId()); | |
| 163 | + } catch (Exception e) { | |
| 164 | + log.error("[stop][IoT EMQX 协议 {} 下行消息订阅者停止失败]", getId(), e); | |
| 165 | + } | |
| 166 | + | |
| 167 | + // 2.1 先置为 false:避免 closeHandler 触发重连 | |
| 168 | + running = false; | |
| 169 | + stopMqttClientReconnectChecker(); | |
| 170 | + // 2.2 停止 MQTT Client | |
| 171 | + stopMqttClient(); | |
| 172 | + | |
| 173 | + // 2.3 停止 HTTP Hook 服务 | |
| 174 | + stopHttpServer(); | |
| 175 | + | |
| 176 | + // 2.4 关闭 Vertx | |
| 177 | + if (vertx != null) { | |
| 178 | + try { | |
| 179 | + vertx.close().result(); | |
| 180 | + log.info("[stop][IoT EMQX 协议 {} Vertx 已关闭]", getId()); | |
| 181 | + } catch (Exception e) { | |
| 182 | + log.error("[stop][IoT EMQX 协议 {} Vertx 关闭失败]", getId(), e); | |
| 183 | + } | |
| 184 | + vertx = null; | |
| 185 | + } | |
| 186 | + | |
| 187 | + log.info("[stop][IoT EMQX 协议 {} 已停止]", getId()); | |
| 188 | + } | |
| 189 | + | |
| 190 | + // ======================================= HTTP Hook Server ======================================= | |
| 191 | + | |
| 192 | + /** | |
| 193 | + * 启动 HTTP Hook 服务(/mqtt/auth、/mqtt/acl、/mqtt/event) | |
| 194 | + */ | |
| 195 | + private void startHttpServer() { | |
| 196 | + // 1. 创建路由 | |
| 197 | + Router router = Router.router(vertx); | |
| 198 | + router.route().handler(BodyHandler.create()); | |
| 199 | + | |
| 200 | + // 2. 创建处理器 | |
| 201 | + IotEmqxAuthEventHandler handler = new IotEmqxAuthEventHandler(serverId); | |
| 202 | + router.post(IotMqttTopicUtils.MQTT_AUTH_PATH).handler(handler::handleAuth); | |
| 203 | + router.post(IotMqttTopicUtils.MQTT_ACL_PATH).handler(handler::handleAcl); | |
| 204 | + router.post(IotMqttTopicUtils.MQTT_EVENT_PATH).handler(handler::handleEvent); | |
| 205 | + | |
| 206 | + // 3. 启动 HTTP Server(支持 HTTPS) | |
| 207 | + IotEmqxConfig.Http httpConfig = emqxConfig.getHttp(); | |
| 208 | + HttpServerOptions options = new HttpServerOptions().setPort(properties.getPort()); | |
| 209 | + if (httpConfig != null && Boolean.TRUE.equals(httpConfig.getSslEnabled())) { | |
| 210 | + Assert.notBlank(httpConfig.getSslCertPath(), "EMQX HTTP SSL 证书路径(emqx.http.ssl-cert-path)不能为空"); | |
| 211 | + Assert.notBlank(httpConfig.getSslKeyPath(), "EMQX HTTP SSL 私钥路径(emqx.http.ssl-key-path)不能为空"); | |
| 212 | + PemKeyCertOptions pemKeyCertOptions = new PemKeyCertOptions() | |
| 213 | + .setKeyPath(httpConfig.getSslKeyPath()) | |
| 214 | + .setCertPath(httpConfig.getSslCertPath()); | |
| 215 | + options.setSsl(true).setKeyCertOptions(pemKeyCertOptions); | |
| 216 | + } | |
| 217 | + try { | |
| 218 | + httpServer = vertx.createHttpServer(options) | |
| 219 | + .requestHandler(router) | |
| 220 | + .listen() | |
| 221 | + .result(); | |
| 222 | + log.info("[startHttpServer][IoT EMQX 协议 {} HTTP Hook 服务启动成功, port: {}, ssl: {}]", | |
| 223 | + getId(), properties.getPort(), httpConfig != null && Boolean.TRUE.equals(httpConfig.getSslEnabled())); | |
| 224 | + } catch (Exception e) { | |
| 225 | + log.error("[startHttpServer][IoT EMQX 协议 {} HTTP Hook 服务启动失败, port: {}]", getId(), properties.getPort(), e); | |
| 226 | + throw e; | |
| 227 | + } | |
| 228 | + } | |
| 229 | + | |
| 230 | + private void stopHttpServer() { | |
| 231 | + if (httpServer == null) { | |
| 232 | + return; | |
| 233 | + } | |
| 234 | + try { | |
| 235 | + httpServer.close().result(); | |
| 236 | + log.info("[stopHttpServer][IoT EMQX 协议 {} HTTP Hook 服务已停止]", getId()); | |
| 237 | + } catch (Exception e) { | |
| 238 | + log.error("[stopHttpServer][IoT EMQX 协议 {} HTTP Hook 服务停止失败]", getId(), e); | |
| 239 | + } finally { | |
| 240 | + httpServer = null; | |
| 241 | + } | |
| 242 | + } | |
| 243 | + | |
| 244 | + // ======================================= MQTT Client ====================================== | |
| 245 | + | |
| 246 | + private void startMqttClient() { | |
| 247 | + // 1.1 创建 MQTT Client | |
| 248 | + MqttClient client = createMqttClient(); | |
| 249 | + this.mqttClient = client; | |
| 250 | + // 1.2 连接 MQTT Broker | |
| 251 | + if (!connectMqttClient(client)) { | |
| 252 | + throw new RuntimeException("MQTT Client 启动失败: 连接 Broker 失败"); | |
| 253 | + } | |
| 254 | + | |
| 255 | + // 2. 启动定时重连检查 | |
| 256 | + startMqttClientReconnectChecker(); | |
| 257 | + } | |
| 258 | + | |
| 259 | + private void stopMqttClient() { | |
| 260 | + MqttClient client = this.mqttClient; | |
| 261 | + if (client == null || !client.isConnected()) { | |
| 262 | + return; | |
| 263 | + } | |
| 264 | + this.mqttClient = null; | |
| 265 | + | |
| 266 | + // 1. 批量取消订阅 | |
| 267 | + List<String> topicList = emqxConfig.getMqttTopics(); | |
| 268 | + if (CollUtil.isNotEmpty(topicList)) { | |
| 269 | + try { | |
| 270 | + client.unsubscribe(topicList).toCompletionStage().toCompletableFuture() | |
| 271 | + .get(5, TimeUnit.SECONDS); | |
| 272 | + } catch (Exception e) { | |
| 273 | + log.warn("[stopMqttClient][IoT EMQX 协议 {} 取消订阅异常]", getId(), e); | |
| 274 | + } | |
| 275 | + } | |
| 276 | + | |
| 277 | + // 2. 断开 MQTT 连接 | |
| 278 | + try { | |
| 279 | + client.disconnect().toCompletionStage().toCompletableFuture() | |
| 280 | + .get(5, TimeUnit.SECONDS); | |
| 281 | + } catch (Exception e) { | |
| 282 | + log.warn("[stopMqttClient][IoT EMQX 协议 {} 断开连接异常]", getId(), e); | |
| 283 | + } | |
| 284 | + } | |
| 285 | + | |
| 286 | + // ======================================= MQTT 基础方法 ====================================== | |
| 287 | + | |
| 288 | + /** | |
| 289 | + * 创建 MQTT 客户端 | |
| 290 | + * | |
| 291 | + * @return 新创建的 MqttClient | |
| 292 | + */ | |
| 293 | + private MqttClient createMqttClient() { | |
| 294 | + // 1.1 基础配置 | |
| 295 | + MqttClientOptions options = new MqttClientOptions() | |
| 296 | + .setClientId(emqxConfig.getMqttClientId()) | |
| 297 | + .setUsername(emqxConfig.getMqttUsername()) | |
| 298 | + .setPassword(emqxConfig.getMqttPassword()) | |
| 299 | + .setSsl(emqxConfig.getMqttSsl()) | |
| 300 | + .setCleanSession(emqxConfig.getCleanSession()) | |
| 301 | + .setKeepAliveInterval(emqxConfig.getKeepAliveIntervalSeconds()) | |
| 302 | + .setMaxInflightQueue(emqxConfig.getMaxInflightQueue()); | |
| 303 | + options.setConnectTimeout(emqxConfig.getConnectTimeoutSeconds() * 1000); // Vert.x 需要毫秒 | |
| 304 | + options.setTrustAll(emqxConfig.getTrustAll()); | |
| 305 | + // 1.2 配置遗嘱消息 | |
| 306 | + IotEmqxConfig.Will will = emqxConfig.getWill(); | |
| 307 | + if (will.isEnabled()) { | |
| 308 | + Assert.notBlank(will.getTopic(), "遗嘱消息主题(emqx.will.topic)不能为空"); | |
| 309 | + Assert.notNull(will.getPayload(), "遗嘱消息内容(emqx.will.payload)不能为空"); | |
| 310 | + options.setWillFlag(true) | |
| 311 | + .setWillTopic(will.getTopic()) | |
| 312 | + .setWillMessageBytes(Buffer.buffer(will.getPayload())) | |
| 313 | + .setWillQoS(will.getQos()) | |
| 314 | + .setWillRetain(will.isRetain()); | |
| 315 | + } | |
| 316 | + // 1.3 配置高级 SSL/TLS(仅在启用 SSL 且不信任所有证书时生效) | |
| 317 | + if (Boolean.TRUE.equals(emqxConfig.getMqttSsl()) && !Boolean.TRUE.equals(emqxConfig.getTrustAll())) { | |
| 318 | + IotEmqxConfig.Ssl sslOptions = emqxConfig.getSslOptions(); | |
| 319 | + if (StrUtil.isNotBlank(sslOptions.getTrustStorePath())) { | |
| 320 | + options.setTrustStoreOptions(new JksOptions() | |
| 321 | + .setPath(sslOptions.getTrustStorePath()) | |
| 322 | + .setPassword(sslOptions.getTrustStorePassword())); | |
| 323 | + } | |
| 324 | + if (StrUtil.isNotBlank(sslOptions.getKeyStorePath())) { | |
| 325 | + options.setKeyStoreOptions(new JksOptions() | |
| 326 | + .setPath(sslOptions.getKeyStorePath()) | |
| 327 | + .setPassword(sslOptions.getKeyStorePassword())); | |
| 328 | + } | |
| 329 | + } | |
| 330 | + | |
| 331 | + // 2. 创建客户端 | |
| 332 | + return MqttClient.create(vertx, options); | |
| 333 | + } | |
| 334 | + | |
| 335 | + /** | |
| 336 | + * 连接 MQTT Broker(同步等待) | |
| 337 | + * | |
| 338 | + * @param client MQTT 客户端 | |
| 339 | + * @return 连接成功返回 true,失败返回 false | |
| 340 | + */ | |
| 341 | + @SuppressWarnings("BooleanMethodIsAlwaysInverted") | |
| 342 | + private synchronized boolean connectMqttClient(MqttClient client) { | |
| 343 | + String host = emqxConfig.getMqttHost(); | |
| 344 | + int port = emqxConfig.getMqttPort(); | |
| 345 | + int timeoutSeconds = emqxConfig.getConnectTimeoutSeconds(); | |
| 346 | + try { | |
| 347 | + // 1. 连接 Broker | |
| 348 | + client.connect(port, host).toCompletionStage().toCompletableFuture() | |
| 349 | + .get(timeoutSeconds, TimeUnit.SECONDS); | |
| 350 | + log.info("[connectMqttClient][IoT EMQX 协议 {} 连接成功, host: {}, port: {}]", | |
| 351 | + getId(), host, port); | |
| 352 | + | |
| 353 | + // 2. 设置处理器 | |
| 354 | + setupMqttClientHandlers(client); | |
| 355 | + subscribeMqttClientTopics(client); | |
| 356 | + return true; | |
| 357 | + } catch (Exception e) { | |
| 358 | + log.error("[connectMqttClient][IoT EMQX 协议 {} 连接发生异常]", getId(), e); | |
| 359 | + return false; | |
| 360 | + } | |
| 361 | + } | |
| 362 | + | |
| 363 | + /** | |
| 364 | + * 关闭 MQTT 客户端 | |
| 365 | + */ | |
| 366 | + private void closeMqttClient() { | |
| 367 | + MqttClient oldClient = this.mqttClient; | |
| 368 | + if (oldClient == null || !oldClient.isConnected()) { | |
| 369 | + return; | |
| 370 | + } | |
| 371 | + this.mqttClient = null; | |
| 372 | + try { | |
| 373 | + oldClient.disconnect().toCompletionStage().toCompletableFuture() | |
| 374 | + .get(5, TimeUnit.SECONDS); | |
| 375 | + } catch (Exception ignored) { | |
| 376 | + } | |
| 377 | + } | |
| 378 | + | |
| 379 | + // ======================================= MQTT 重连机制 ====================================== | |
| 380 | + | |
| 381 | + /** | |
| 382 | + * 启动 MQTT Client 周期性重连检查器 | |
| 383 | + */ | |
| 384 | + private void startMqttClientReconnectChecker() { | |
| 385 | + long interval = emqxConfig.getReconnectDelayMs(); | |
| 386 | + this.reconnectTimerId = vertx.setPeriodic(interval, timerId -> { | |
| 387 | + if (!running) { | |
| 388 | + return; | |
| 389 | + } | |
| 390 | + if (mqttClient != null && mqttClient.isConnected()) { | |
| 391 | + return; | |
| 392 | + } | |
| 393 | + log.info("[startMqttClientReconnectChecker][IoT EMQX 协议 {} 检测到断开,尝试重连]", getId()); | |
| 394 | + tryReconnectMqttClient(); | |
| 395 | + }); | |
| 396 | + } | |
| 397 | + | |
| 398 | + /** | |
| 399 | + * 停止 MQTT Client 重连检查器 | |
| 400 | + */ | |
| 401 | + private void stopMqttClientReconnectChecker() { | |
| 402 | + if (reconnectTimerId != null && vertx != null) { | |
| 403 | + try { | |
| 404 | + vertx.cancelTimer(reconnectTimerId); | |
| 405 | + } catch (Exception ignored) { | |
| 406 | + } | |
| 407 | + reconnectTimerId = null; | |
| 408 | + } | |
| 409 | + } | |
| 410 | + | |
| 411 | + /** | |
| 412 | + * 尝试重连 MQTT Client | |
| 413 | + */ | |
| 414 | + private synchronized void tryReconnectMqttClient() { | |
| 415 | + // 1. 前置检查 | |
| 416 | + if (!running) { | |
| 417 | + return; | |
| 418 | + } | |
| 419 | + if (mqttClient != null && mqttClient.isConnected()) { | |
| 420 | + return; | |
| 421 | + } | |
| 422 | + | |
| 423 | + log.info("[tryReconnectMqttClient][IoT EMQX 协议 {} 开始重连]", getId()); | |
| 424 | + try { | |
| 425 | + // 2. 关闭旧客户端 | |
| 426 | + closeMqttClient(); | |
| 427 | + | |
| 428 | + // 3.1 创建新客户端 | |
| 429 | + MqttClient client = createMqttClient(); | |
| 430 | + this.mqttClient = client; | |
| 431 | + // 3.2 连接(失败只打印日志,等下次定时) | |
| 432 | + if (!connectMqttClient(client)) { | |
| 433 | + log.warn("[tryReconnectMqttClient][IoT EMQX 协议 {} 重连失败,等待下次重试]", getId()); | |
| 434 | + } | |
| 435 | + } catch (Exception e) { | |
| 436 | + log.error("[tryReconnectMqttClient][IoT EMQX 协议 {} 重连异常]", getId(), e); | |
| 437 | + } | |
| 438 | + } | |
| 439 | + | |
| 440 | + // ======================================= MQTT Handler ====================================== | |
| 441 | + | |
| 442 | + /** | |
| 443 | + * 设置 MQTT Client 事件处理器 | |
| 444 | + */ | |
| 445 | + private void setupMqttClientHandlers(MqttClient client) { | |
| 446 | + // 1. 断开重连监听 | |
| 447 | + client.closeHandler(closeEvent -> { | |
| 448 | + if (!running) { | |
| 449 | + return; | |
| 450 | + } | |
| 451 | + log.warn("[setupMqttClientHandlers][IoT EMQX 协议 {} 连接断开,立即尝试重连]", getId()); | |
| 452 | + vertx.runOnContext(v -> tryReconnectMqttClient()); | |
| 453 | + }); | |
| 454 | + | |
| 455 | + // 2. 异常处理 | |
| 456 | + client.exceptionHandler(exception -> | |
| 457 | + log.error("[setupMqttClientHandlers][IoT EMQX 协议 {} MQTT Client 异常]", getId(), exception)); | |
| 458 | + | |
| 459 | + // 3. 上行消息处理 | |
| 460 | + client.publishHandler(upstreamHandler::handle); | |
| 461 | + } | |
| 462 | + | |
| 463 | + /** | |
| 464 | + * 订阅 MQTT Client 主题(同步等待) | |
| 465 | + */ | |
| 466 | + private void subscribeMqttClientTopics(MqttClient client) { | |
| 467 | + List<String> topicList = emqxConfig.getMqttTopics(); | |
| 468 | + if (!client.isConnected()) { | |
| 469 | + log.warn("[subscribeMqttClientTopics][IoT EMQX 协议 {} MQTT Client 未连接, 跳过订阅]", getId()); | |
| 470 | + return; | |
| 471 | + } | |
| 472 | + if (CollUtil.isEmpty(topicList)) { | |
| 473 | + log.warn("[subscribeMqttClientTopics][IoT EMQX 协议 {} 未配置订阅主题, 跳过订阅]", getId()); | |
| 474 | + return; | |
| 475 | + } | |
| 476 | + // 执行订阅 | |
| 477 | + Map<String, Integer> topics = convertMap(emqxConfig.getMqttTopics(), topic -> topic, | |
| 478 | + topic -> emqxConfig.getMqttQos()); | |
| 479 | + try { | |
| 480 | + client.subscribe(topics).toCompletionStage().toCompletableFuture() | |
| 481 | + .get(10, TimeUnit.SECONDS); | |
| 482 | + log.info("[subscribeMqttClientTopics][IoT EMQX 协议 {} 订阅成功, 共 {} 个主题]", getId(), topicList.size()); | |
| 483 | + } catch (Exception e) { | |
| 484 | + log.error("[subscribeMqttClientTopics][IoT EMQX 协议 {} 订阅失败]", getId(), e); | |
| 485 | + } | |
| 486 | + } | |
| 487 | + | |
| 488 | + /** | |
| 489 | + * 发布消息到 MQTT Broker | |
| 490 | + * | |
| 491 | + * @param topic 主题 | |
| 492 | + * @param payload 消息内容 | |
| 493 | + */ | |
| 494 | + public void publishMessage(String topic, byte[] payload) { | |
| 495 | + if (mqttClient == null || !mqttClient.isConnected()) { | |
| 496 | + log.warn("[publishMessage][IoT EMQX 协议 {} MQTT Client 未连接, 无法发布消息]", getId()); | |
| 497 | + return; | |
| 498 | + } | |
| 499 | + MqttQoS qos = MqttQoS.valueOf(emqxConfig.getMqttQos()); | |
| 500 | + mqttClient.publish(topic, Buffer.buffer(payload), qos, false, false); | |
| 501 | + } | |
| 502 | + | |
| 503 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxUpstreamProtocol.java deleted
| 1 | -package cn.iocoder.yudao.module.iot.gateway.protocol.emqx; | |
| 2 | - | |
| 3 | -import cn.hutool.core.lang.Assert; | |
| 4 | -import cn.hutool.core.util.StrUtil; | |
| 5 | -import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 6 | -import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 7 | -import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 8 | -import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; | |
| 9 | -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmqxUpstreamHandler; | |
| 10 | -import io.netty.handler.codec.mqtt.MqttQoS; | |
| 11 | -import io.vertx.core.Vertx; | |
| 12 | -import io.vertx.core.buffer.Buffer; | |
| 13 | -import io.vertx.core.net.JksOptions; | |
| 14 | -import io.vertx.mqtt.MqttClient; | |
| 15 | -import io.vertx.mqtt.MqttClientOptions; | |
| 16 | -import jakarta.annotation.PostConstruct; | |
| 17 | -import jakarta.annotation.PreDestroy; | |
| 18 | -import lombok.Getter; | |
| 19 | -import lombok.extern.slf4j.Slf4j; | |
| 20 | - | |
| 21 | -import java.util.HashMap; | |
| 22 | -import java.util.List; | |
| 23 | -import java.util.Map; | |
| 24 | -import java.util.concurrent.CountDownLatch; | |
| 25 | -import java.util.concurrent.atomic.AtomicBoolean; | |
| 26 | - | |
| 27 | -/** | |
| 28 | - * IoT 网关 EMQX 协议:接收设备上行消息 | |
| 29 | - * | |
| 30 | - * @author 芋道源码 | |
| 31 | - */ | |
| 32 | -@Slf4j | |
| 33 | -public class IotEmqxUpstreamProtocol implements IotProtocol { | |
| 34 | - | |
| 35 | - private static final String ID = "emqx"; | |
| 36 | - | |
| 37 | - private final IotGatewayProperties.EmqxProperties emqxProperties; | |
| 38 | - | |
| 39 | - private volatile boolean running = false; | |
| 40 | - | |
| 41 | - private final Vertx vertx; | |
| 42 | - | |
| 43 | - @Getter | |
| 44 | - private final String serverId; | |
| 45 | - | |
| 46 | - private MqttClient mqttClient; | |
| 47 | - | |
| 48 | - private IotEmqxUpstreamHandler upstreamHandler; | |
| 49 | - | |
| 50 | - public IotEmqxUpstreamProtocol(IotGatewayProperties.EmqxProperties emqxProperties, | |
| 51 | - Vertx vertx) { | |
| 52 | - this.emqxProperties = emqxProperties; | |
| 53 | - this.serverId = IotDeviceMessageUtils.generateServerId(emqxProperties.getMqttPort()); | |
| 54 | - this.vertx = vertx; | |
| 55 | - } | |
| 56 | - | |
| 57 | - @Override | |
| 58 | - public String getId() { | |
| 59 | - return ID; | |
| 60 | - } | |
| 61 | - | |
| 62 | - @Override | |
| 63 | - public IotProtocolTypeEnum getType() { | |
| 64 | - return IotProtocolTypeEnum.EMQX; | |
| 65 | - } | |
| 66 | - | |
| 67 | - @Override | |
| 68 | - @PostConstruct | |
| 69 | - public void start() { | |
| 70 | - if (running) { | |
| 71 | - return; | |
| 72 | - } | |
| 73 | - | |
| 74 | - try { | |
| 75 | - // 1. 启动 MQTT 客户端 | |
| 76 | - startMqttClient(); | |
| 77 | - | |
| 78 | - // 2. 标记服务为运行状态 | |
| 79 | - running = true; | |
| 80 | - log.info("[start][IoT 网关 EMQX 协议启动成功]"); | |
| 81 | - } catch (Exception e) { | |
| 82 | - log.error("[start][IoT 网关 EMQX 协议服务启动失败,应用将关闭]", e); | |
| 83 | - stop(); | |
| 84 | - | |
| 85 | - // 异步关闭应用 | |
| 86 | - Thread shutdownThread = new Thread(() -> { | |
| 87 | - try { | |
| 88 | - // 确保日志输出完成,使用更优雅的方式 | |
| 89 | - log.error("[start][由于 MQTT 连接失败,正在关闭应用]"); | |
| 90 | - // 等待日志输出完成 | |
| 91 | - Thread.sleep(1000); | |
| 92 | - } catch (InterruptedException ie) { | |
| 93 | - Thread.currentThread().interrupt(); | |
| 94 | - log.warn("[start][应用关闭被中断]"); | |
| 95 | - } | |
| 96 | - System.exit(1); | |
| 97 | - }); | |
| 98 | - shutdownThread.setDaemon(true); | |
| 99 | - shutdownThread.setName("emergency-shutdown"); | |
| 100 | - shutdownThread.start(); | |
| 101 | - | |
| 102 | - throw e; | |
| 103 | - } | |
| 104 | - } | |
| 105 | - | |
| 106 | - @Override | |
| 107 | - @PreDestroy | |
| 108 | - public void stop() { | |
| 109 | - if (!running) { | |
| 110 | - return; | |
| 111 | - } | |
| 112 | - | |
| 113 | - // 1. 停止 MQTT 客户端 | |
| 114 | - stopMqttClient(); | |
| 115 | - | |
| 116 | - // 2. 标记服务为停止状态 | |
| 117 | - running = false; | |
| 118 | - log.info("[stop][IoT 网关 MQTT 协议服务已停止]"); | |
| 119 | - } | |
| 120 | - | |
| 121 | - @Override | |
| 122 | - public boolean isRunning() { | |
| 123 | - return running; | |
| 124 | - } | |
| 125 | - | |
| 126 | - /** | |
| 127 | - * 启动 MQTT 客户端 | |
| 128 | - */ | |
| 129 | - private void startMqttClient() { | |
| 130 | - try { | |
| 131 | - // 1. 初始化消息处理器 | |
| 132 | - this.upstreamHandler = new IotEmqxUpstreamHandler(this); | |
| 133 | - | |
| 134 | - // 2. 创建 MQTT 客户端 | |
| 135 | - createMqttClient(); | |
| 136 | - | |
| 137 | - // 3. 同步连接 MQTT Broker | |
| 138 | - connectMqttSync(); | |
| 139 | - } catch (Exception e) { | |
| 140 | - log.error("[startMqttClient][MQTT 客户端启动失败]", e); | |
| 141 | - throw new RuntimeException("MQTT 客户端启动失败: " + e.getMessage(), e); | |
| 142 | - } | |
| 143 | - } | |
| 144 | - | |
| 145 | - /** | |
| 146 | - * 同步连接 MQTT Broker | |
| 147 | - */ | |
| 148 | - private void connectMqttSync() { | |
| 149 | - String host = emqxProperties.getMqttHost(); | |
| 150 | - int port = emqxProperties.getMqttPort(); | |
| 151 | - // 1. 连接 MQTT Broker | |
| 152 | - CountDownLatch latch = new CountDownLatch(1); | |
| 153 | - AtomicBoolean success = new AtomicBoolean(false); | |
| 154 | - mqttClient.connect(port, host, connectResult -> { | |
| 155 | - if (connectResult.succeeded()) { | |
| 156 | - log.info("[connectMqttSync][MQTT 客户端连接成功, host: {}, port: {}]", host, port); | |
| 157 | - setupMqttHandlers(); | |
| 158 | - subscribeToTopics(); | |
| 159 | - success.set(true); | |
| 160 | - } else { | |
| 161 | - log.error("[connectMqttSync][连接 MQTT Broker 失败, host: {}, port: {}]", | |
| 162 | - host, port, connectResult.cause()); | |
| 163 | - } | |
| 164 | - latch.countDown(); | |
| 165 | - }); | |
| 166 | - | |
| 167 | - // 2. 等待连接结果 | |
| 168 | - try { | |
| 169 | - // 应用层超时控制:防止启动过程无限阻塞,与MQTT客户端的网络超时是不同层次的控制 | |
| 170 | - boolean awaitResult = latch.await(10, java.util.concurrent.TimeUnit.SECONDS); | |
| 171 | - if (!awaitResult) { | |
| 172 | - log.error("[connectMqttSync][等待连接结果超时]"); | |
| 173 | - throw new RuntimeException("连接 MQTT Broker 超时"); | |
| 174 | - } | |
| 175 | - if (!success.get()) { | |
| 176 | - throw new RuntimeException(String.format("首次连接 MQTT Broker 失败,地址: %s, 端口: %d", host, port)); | |
| 177 | - } | |
| 178 | - } catch (InterruptedException e) { | |
| 179 | - Thread.currentThread().interrupt(); | |
| 180 | - log.error("[connectMqttSync][等待连接结果被中断]", e); | |
| 181 | - throw new RuntimeException("连接 MQTT Broker 被中断", e); | |
| 182 | - } | |
| 183 | - } | |
| 184 | - | |
| 185 | - /** | |
| 186 | - * 异步连接 MQTT Broker | |
| 187 | - */ | |
| 188 | - private void connectMqttAsync() { | |
| 189 | - String host = emqxProperties.getMqttHost(); | |
| 190 | - int port = emqxProperties.getMqttPort(); | |
| 191 | - mqttClient.connect(port, host, connectResult -> { | |
| 192 | - if (connectResult.succeeded()) { | |
| 193 | - log.info("[connectMqttAsync][MQTT 客户端重连成功]"); | |
| 194 | - setupMqttHandlers(); | |
| 195 | - subscribeToTopics(); | |
| 196 | - } else { | |
| 197 | - log.error("[connectMqttAsync][连接 MQTT Broker 失败, host: {}, port: {}]", | |
| 198 | - host, port, connectResult.cause()); | |
| 199 | - log.warn("[connectMqttAsync][重连失败,将再次尝试]"); | |
| 200 | - reconnectWithDelay(); | |
| 201 | - } | |
| 202 | - }); | |
| 203 | - } | |
| 204 | - | |
| 205 | - /** | |
| 206 | - * 延迟重连 | |
| 207 | - */ | |
| 208 | - private void reconnectWithDelay() { | |
| 209 | - if (!running) { | |
| 210 | - return; | |
| 211 | - } | |
| 212 | - if (mqttClient != null && mqttClient.isConnected()) { | |
| 213 | - return; | |
| 214 | - } | |
| 215 | - | |
| 216 | - long delay = emqxProperties.getReconnectDelayMs(); | |
| 217 | - log.info("[reconnectWithDelay][将在 {} 毫秒后尝试重连 MQTT Broker]", delay); | |
| 218 | - vertx.setTimer(delay, timerId -> { | |
| 219 | - if (!running) { | |
| 220 | - return; | |
| 221 | - } | |
| 222 | - if (mqttClient != null && mqttClient.isConnected()) { | |
| 223 | - return; | |
| 224 | - } | |
| 225 | - | |
| 226 | - log.info("[reconnectWithDelay][开始重连 MQTT Broker]"); | |
| 227 | - try { | |
| 228 | - createMqttClient(); | |
| 229 | - connectMqttAsync(); | |
| 230 | - } catch (Exception e) { | |
| 231 | - log.error("[reconnectWithDelay][重连过程中发生异常]", e); | |
| 232 | - vertx.setTimer(delay, t -> reconnectWithDelay()); | |
| 233 | - } | |
| 234 | - }); | |
| 235 | - } | |
| 236 | - | |
| 237 | - /** | |
| 238 | - * 停止 MQTT 客户端 | |
| 239 | - */ | |
| 240 | - private void stopMqttClient() { | |
| 241 | - if (mqttClient == null) { | |
| 242 | - return; | |
| 243 | - } | |
| 244 | - try { | |
| 245 | - if (mqttClient.isConnected()) { | |
| 246 | - // 1. 取消订阅所有主题 | |
| 247 | - List<String> topicList = emqxProperties.getMqttTopics(); | |
| 248 | - for (String topic : topicList) { | |
| 249 | - try { | |
| 250 | - mqttClient.unsubscribe(topic); | |
| 251 | - } catch (Exception e) { | |
| 252 | - log.warn("[stopMqttClient][取消订阅主题({})异常]", topic, e); | |
| 253 | - } | |
| 254 | - } | |
| 255 | - | |
| 256 | - // 2. 断开 MQTT 客户端连接 | |
| 257 | - try { | |
| 258 | - CountDownLatch disconnectLatch = new CountDownLatch(1); | |
| 259 | - mqttClient.disconnect(ar -> disconnectLatch.countDown()); | |
| 260 | - if (!disconnectLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)) { | |
| 261 | - log.warn("[stopMqttClient][断开 MQTT 连接超时]"); | |
| 262 | - } | |
| 263 | - } catch (Exception e) { | |
| 264 | - log.warn("[stopMqttClient][关闭 MQTT 客户端异常]", e); | |
| 265 | - } | |
| 266 | - } | |
| 267 | - } catch (Exception e) { | |
| 268 | - log.warn("[stopMqttClient][停止 MQTT 客户端过程中发生异常]", e); | |
| 269 | - } finally { | |
| 270 | - mqttClient = null; | |
| 271 | - } | |
| 272 | - } | |
| 273 | - | |
| 274 | - /** | |
| 275 | - * 创建 MQTT 客户端 | |
| 276 | - */ | |
| 277 | - private void createMqttClient() { | |
| 278 | - // 1.1 创建基础配置 | |
| 279 | - MqttClientOptions options = (MqttClientOptions) new MqttClientOptions() | |
| 280 | - .setClientId(emqxProperties.getMqttClientId()) | |
| 281 | - .setUsername(emqxProperties.getMqttUsername()) | |
| 282 | - .setPassword(emqxProperties.getMqttPassword()) | |
| 283 | - .setSsl(emqxProperties.getMqttSsl()) | |
| 284 | - .setCleanSession(emqxProperties.getCleanSession()) | |
| 285 | - .setKeepAliveInterval(emqxProperties.getKeepAliveIntervalSeconds()) | |
| 286 | - .setMaxInflightQueue(emqxProperties.getMaxInflightQueue()) | |
| 287 | - .setConnectTimeout(emqxProperties.getConnectTimeoutSeconds() * 1000) // Vert.x 需要毫秒 | |
| 288 | - .setTrustAll(emqxProperties.getTrustAll()); | |
| 289 | - // 1.2 配置遗嘱消息 | |
| 290 | - IotGatewayProperties.EmqxProperties.Will will = emqxProperties.getWill(); | |
| 291 | - if (will.isEnabled()) { | |
| 292 | - Assert.notBlank(will.getTopic(), "遗嘱消息主题(will.topic)不能为空"); | |
| 293 | - Assert.notNull(will.getPayload(), "遗嘱消息内容(will.payload)不能为空"); | |
| 294 | - options.setWillFlag(true) | |
| 295 | - .setWillTopic(will.getTopic()) | |
| 296 | - .setWillMessageBytes(Buffer.buffer(will.getPayload())) | |
| 297 | - .setWillQoS(will.getQos()) | |
| 298 | - .setWillRetain(will.isRetain()); | |
| 299 | - } | |
| 300 | - // 1.3 配置高级 SSL/TLS (仅在启用 SSL 且不信任所有证书时生效) | |
| 301 | - if (Boolean.TRUE.equals(emqxProperties.getMqttSsl()) && !Boolean.TRUE.equals(emqxProperties.getTrustAll())) { | |
| 302 | - IotGatewayProperties.EmqxProperties.Ssl sslOptions = emqxProperties.getSslOptions(); | |
| 303 | - if (StrUtil.isNotBlank(sslOptions.getTrustStorePath())) { | |
| 304 | - options.setTrustStoreOptions(new JksOptions() | |
| 305 | - .setPath(sslOptions.getTrustStorePath()) | |
| 306 | - .setPassword(sslOptions.getTrustStorePassword())); | |
| 307 | - } | |
| 308 | - if (StrUtil.isNotBlank(sslOptions.getKeyStorePath())) { | |
| 309 | - options.setKeyStoreOptions(new JksOptions() | |
| 310 | - .setPath(sslOptions.getKeyStorePath()) | |
| 311 | - .setPassword(sslOptions.getKeyStorePassword())); | |
| 312 | - } | |
| 313 | - } | |
| 314 | - // 1.4 安全警告日志 | |
| 315 | - if (Boolean.TRUE.equals(emqxProperties.getTrustAll())) { | |
| 316 | - log.warn("[createMqttClient][安全警告:当前配置信任所有 SSL 证书(trustAll=true),这在生产环境中存在严重安全风险!]"); | |
| 317 | - } | |
| 318 | - | |
| 319 | - // 2. 创建客户端实例 | |
| 320 | - this.mqttClient = MqttClient.create(vertx, options); | |
| 321 | - } | |
| 322 | - | |
| 323 | - /** | |
| 324 | - * 设置 MQTT 处理器 | |
| 325 | - */ | |
| 326 | - private void setupMqttHandlers() { | |
| 327 | - // 1. 设置断开重连监听器 | |
| 328 | - mqttClient.closeHandler(closeEvent -> { | |
| 329 | - if (!running) { | |
| 330 | - return; | |
| 331 | - } | |
| 332 | - log.warn("[closeHandler][MQTT 连接已断开, 准备重连]"); | |
| 333 | - reconnectWithDelay(); | |
| 334 | - }); | |
| 335 | - | |
| 336 | - // 2. 设置异常处理器 | |
| 337 | - mqttClient.exceptionHandler(exception -> | |
| 338 | - log.error("[exceptionHandler][MQTT 客户端异常]", exception)); | |
| 339 | - | |
| 340 | - // 3. 设置消息处理器 | |
| 341 | - mqttClient.publishHandler(upstreamHandler::handle); | |
| 342 | - } | |
| 343 | - | |
| 344 | - /** | |
| 345 | - * 订阅设备上行消息主题 | |
| 346 | - */ | |
| 347 | - private void subscribeToTopics() { | |
| 348 | - // 1. 校验 MQTT 客户端是否连接 | |
| 349 | - List<String> topicList = emqxProperties.getMqttTopics(); | |
| 350 | - if (mqttClient == null || !mqttClient.isConnected()) { | |
| 351 | - log.warn("[subscribeToTopics][MQTT 客户端未连接, 跳过订阅]"); | |
| 352 | - return; | |
| 353 | - } | |
| 354 | - | |
| 355 | - // 2. 批量订阅所有主题 | |
| 356 | - Map<String, Integer> topics = new HashMap<>(); | |
| 357 | - int qos = emqxProperties.getMqttQos(); | |
| 358 | - for (String topic : topicList) { | |
| 359 | - topics.put(topic, qos); | |
| 360 | - } | |
| 361 | - mqttClient.subscribe(topics, subscribeResult -> { | |
| 362 | - if (subscribeResult.succeeded()) { | |
| 363 | - log.info("[subscribeToTopics][订阅主题成功, 共 {} 个主题]", topicList.size()); | |
| 364 | - } else { | |
| 365 | - log.error("[subscribeToTopics][订阅主题失败, 共 {} 个主题, 原因: {}]", | |
| 366 | - topicList.size(), subscribeResult.cause().getMessage(), subscribeResult.cause()); | |
| 367 | - } | |
| 368 | - }); | |
| 369 | - } | |
| 370 | - | |
| 371 | - /** | |
| 372 | - * 发布消息到 MQTT Broker | |
| 373 | - * | |
| 374 | - * @param topic 主题 | |
| 375 | - * @param payload 消息内容 | |
| 376 | - */ | |
| 377 | - public void publishMessage(String topic, byte[] payload) { | |
| 378 | - if (mqttClient == null || !mqttClient.isConnected()) { | |
| 379 | - log.warn("[publishMessage][MQTT 客户端未连接, 无法发布消息]"); | |
| 380 | - return; | |
| 381 | - } | |
| 382 | - MqttQoS qos = MqttQoS.valueOf(emqxProperties.getMqttQos()); | |
| 383 | - mqttClient.publish(topic, Buffer.buffer(payload), qos, false, false); | |
| 384 | - } | |
| 385 | - | |
| 386 | -} | |
| 387 | 0 | \ No newline at end of file |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamHandler.java
| ... | ... | @@ -5,7 +5,7 @@ import cn.hutool.extra.spring.SpringUtil; |
| 5 | 5 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; |
| 6 | 6 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 7 | 7 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; |
| 8 | -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol; | |
| 9 | 9 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 10 | 10 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 11 | 11 | import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; |
| ... | ... | @@ -21,13 +21,13 @@ import lombok.extern.slf4j.Slf4j; |
| 21 | 21 | @Slf4j |
| 22 | 22 | public class IotEmqxDownstreamHandler { |
| 23 | 23 | |
| 24 | - private final IotEmqxUpstreamProtocol protocol; | |
| 24 | + private final IotEmqxProtocol protocol; | |
| 25 | 25 | |
| 26 | 26 | private final IotDeviceService deviceService; |
| 27 | 27 | |
| 28 | 28 | private final IotDeviceMessageService deviceMessageService; |
| 29 | 29 | |
| 30 | - public IotEmqxDownstreamHandler(IotEmqxUpstreamProtocol protocol) { | |
| 30 | + public IotEmqxDownstreamHandler(IotEmqxProtocol protocol) { | |
| 31 | 31 | this.protocol = protocol; |
| 32 | 32 | this.deviceService = SpringUtil.getBean(IotDeviceService.class); |
| 33 | 33 | this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); |
| ... | ... | @@ -74,4 +74,4 @@ public class IotEmqxDownstreamHandler { |
| 74 | 74 | return IotMqttTopicUtils.buildTopicByMethod(message.getMethod(), productKey, deviceName, isReply); |
| 75 | 75 | } |
| 76 | 76 | |
| 77 | -} | |
| 78 | 77 | \ No newline at end of file |
| 78 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamSubscriber.java
| ... | ... | @@ -3,9 +3,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.downstream; |
| 3 | 3 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 5 | 5 | import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber; |
| 6 | -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol; | |
| 7 | -import jakarta.annotation.PostConstruct; | |
| 8 | -import jakarta.annotation.PreDestroy; | |
| 6 | +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol; | |
| 9 | 7 | import lombok.extern.slf4j.Slf4j; |
| 10 | 8 | |
| 11 | 9 | /** |
| ... | ... | @@ -18,22 +16,11 @@ public class IotEmqxDownstreamSubscriber extends IotProtocolDownstreamSubscriber |
| 18 | 16 | |
| 19 | 17 | private final IotEmqxDownstreamHandler downstreamHandler; |
| 20 | 18 | |
| 21 | - public IotEmqxDownstreamSubscriber(IotEmqxUpstreamProtocol protocol, IotMessageBus messageBus) { | |
| 19 | + public IotEmqxDownstreamSubscriber(IotEmqxProtocol protocol, IotMessageBus messageBus) { | |
| 22 | 20 | super(protocol, messageBus); |
| 23 | 21 | this.downstreamHandler = new IotEmqxDownstreamHandler(protocol); |
| 24 | 22 | } |
| 25 | 23 | |
| 26 | - @PostConstruct | |
| 27 | - public void startSubscriber() { | |
| 28 | - // EMQX 模式下,由 Spring 管理 Bean 生命周期;需要显式启动订阅者,才能从消息总线消费下行消息并发布到 Broker | |
| 29 | - start(); | |
| 30 | - } | |
| 31 | - | |
| 32 | - @PreDestroy | |
| 33 | - public void stopSubscriber() { | |
| 34 | - stop(); | |
| 35 | - } | |
| 36 | - | |
| 37 | 24 | @Override |
| 38 | 25 | protected void handleMessage(IotDeviceMessage message) { |
| 39 | 26 | downstreamHandler.handle(message); | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandler.java
| ... | ... | @@ -10,16 +10,20 @@ import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 10 | 10 | import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; |
| 11 | 11 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; |
| 12 | 12 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; | |
| 13 | 14 | import io.vertx.core.json.JsonObject; |
| 14 | 15 | import io.vertx.ext.web.RoutingContext; |
| 15 | 16 | import lombok.extern.slf4j.Slf4j; |
| 16 | 17 | |
| 18 | +import java.util.Locale; | |
| 19 | + | |
| 17 | 20 | /** |
| 18 | 21 | * IoT 网关 EMQX 认证事件处理器 |
| 19 | 22 | * <p> |
| 20 | 23 | * 为 EMQX 提供 HTTP 接口服务,包括: |
| 21 | - * 1. 设备认证接口 - 对应 EMQX HTTP 认证插件 | |
| 22 | - * 2. 设备事件处理接口 - 对应 EMQX Webhook 事件通知 | |
| 24 | + * 1. 设备认证接口 - 对应 EMQX HTTP 认证插件 {@link #handleAuth(RoutingContext)} | |
| 25 | + * 2. 设备事件处理接口 - 对应 EMQX Webhook 事件通知 {@link #handleEvent(RoutingContext)} | |
| 26 | + * 3. 设备 ACL 权限接口 - 对应 EMQX HTTP ACL 插件 {@link #handleAcl(RoutingContext)} | |
| 23 | 27 | * |
| 24 | 28 | * @author 芋道源码 |
| 25 | 29 | */ |
| ... | ... | @@ -45,15 +49,17 @@ public class IotEmqxAuthEventHandler { |
| 45 | 49 | private static final String RESULT_IGNORE = "ignore"; |
| 46 | 50 | |
| 47 | 51 | /** |
| 48 | - * EMQX 事件类型常量 | |
| 52 | + * EMQX 事件类型常量 - 客户端连接 | |
| 49 | 53 | */ |
| 50 | 54 | private static final String EVENT_CLIENT_CONNECTED = "client.connected"; |
| 55 | + /** | |
| 56 | + * EMQX 事件类型常量 - 客户端断开连接 | |
| 57 | + */ | |
| 51 | 58 | private static final String EVENT_CLIENT_DISCONNECTED = "client.disconnected"; |
| 52 | 59 | |
| 53 | 60 | private final String serverId; |
| 54 | 61 | |
| 55 | 62 | private final IotDeviceMessageService deviceMessageService; |
| 56 | - | |
| 57 | 63 | private final IotDeviceCommonApi deviceApi; |
| 58 | 64 | |
| 59 | 65 | public IotEmqxAuthEventHandler(String serverId) { |
| ... | ... | @@ -62,13 +68,16 @@ public class IotEmqxAuthEventHandler { |
| 62 | 68 | this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); |
| 63 | 69 | } |
| 64 | 70 | |
| 71 | + // ========== 认证处理 ========== | |
| 72 | + | |
| 65 | 73 | /** |
| 66 | 74 | * EMQX 认证接口 |
| 67 | 75 | */ |
| 68 | 76 | public void handleAuth(RoutingContext context) { |
| 77 | + JsonObject body = null; | |
| 69 | 78 | try { |
| 70 | 79 | // 1. 参数校验 |
| 71 | - JsonObject body = parseRequestBody(context); | |
| 80 | + body = parseRequestBody(context); | |
| 72 | 81 | if (body == null) { |
| 73 | 82 | return; |
| 74 | 83 | } |
| ... | ... | @@ -91,11 +100,179 @@ public class IotEmqxAuthEventHandler { |
| 91 | 100 | sendAuthResponse(context, RESULT_DENY); |
| 92 | 101 | } |
| 93 | 102 | } catch (Exception e) { |
| 94 | - log.error("[handleAuth][设备认证异常]", e); | |
| 103 | + log.error("[handleAuth][设备认证异常][body={}]", body, e); | |
| 104 | + sendAuthResponse(context, RESULT_IGNORE); | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + /** | |
| 109 | + * 解析认证接口请求体 | |
| 110 | + * <p> | |
| 111 | + * 认证接口解析失败时返回 JSON 格式响应(包含 result 字段) | |
| 112 | + * | |
| 113 | + * @param context 路由上下文 | |
| 114 | + * @return 请求体JSON对象,解析失败时返回null | |
| 115 | + */ | |
| 116 | + private JsonObject parseRequestBody(RoutingContext context) { | |
| 117 | + try { | |
| 118 | + JsonObject body = context.body().asJsonObject(); | |
| 119 | + if (body == null) { | |
| 120 | + log.info("[parseRequestBody][请求体为空]"); | |
| 121 | + sendAuthResponse(context, RESULT_IGNORE); | |
| 122 | + return null; | |
| 123 | + } | |
| 124 | + return body; | |
| 125 | + } catch (Exception e) { | |
| 126 | + log.error("[parseRequestBody][body({}) 解析请求体失败]", context.body().asString(), e); | |
| 95 | 127 | sendAuthResponse(context, RESULT_IGNORE); |
| 128 | + return null; | |
| 129 | + } | |
| 130 | + } | |
| 131 | + | |
| 132 | + /** | |
| 133 | + * 执行设备认证 | |
| 134 | + * | |
| 135 | + * @param clientId 客户端ID | |
| 136 | + * @param username 用户名 | |
| 137 | + * @param password 密码 | |
| 138 | + * @return 认证是否成功 | |
| 139 | + */ | |
| 140 | + private boolean handleDeviceAuth(String clientId, String username, String password) { | |
| 141 | + try { | |
| 142 | + CommonResult<Boolean> result = deviceApi.authDevice(new IotDeviceAuthReqDTO() | |
| 143 | + .setClientId(clientId).setUsername(username).setPassword(password)); | |
| 144 | + result.checkError(); | |
| 145 | + return BooleanUtil.isTrue(result.getData()); | |
| 146 | + } catch (Exception e) { | |
| 147 | + log.error("[handleDeviceAuth][设备({}) 认证接口调用失败]", username, e); | |
| 148 | + throw e; | |
| 149 | + } | |
| 150 | + } | |
| 151 | + | |
| 152 | + /** | |
| 153 | + * 发送 EMQX 认证响应 | |
| 154 | + * 根据 EMQX 官方文档要求,必须返回 JSON 格式响应 | |
| 155 | + * | |
| 156 | + * @param context 路由上下文 | |
| 157 | + * @param result 认证结果:allow、deny、ignore | |
| 158 | + */ | |
| 159 | + private void sendAuthResponse(RoutingContext context, String result) { | |
| 160 | + // 构建符合 EMQX 官方规范的响应 | |
| 161 | + JsonObject response = new JsonObject() | |
| 162 | + .put("result", result) | |
| 163 | + .put("is_superuser", false); | |
| 164 | + // 可以根据业务需求添加客户端属性 | |
| 165 | + // response.put("client_attrs", new JsonObject().put("role", "device")); | |
| 166 | + // 可以添加认证过期时间(可选) | |
| 167 | + // response.put("expire_at", System.currentTimeMillis() / 1000 + 3600); | |
| 168 | + | |
| 169 | + // 回复响应 | |
| 170 | + context.response() | |
| 171 | + .setStatusCode(SUCCESS_STATUS_CODE) | |
| 172 | + .putHeader("Content-Type", "application/json; charset=utf-8") | |
| 173 | + .end(response.encode()); | |
| 174 | + } | |
| 175 | + | |
| 176 | + // ========== ACL 处理 ========== | |
| 177 | + | |
| 178 | + /** | |
| 179 | + * EMQX ACL 接口 | |
| 180 | + * <p> | |
| 181 | + * 用于 EMQX 的 HTTP ACL 插件校验设备的 publish/subscribe 权限。 | |
| 182 | + * 若请求参数无法识别,则返回 ignore 交给 EMQX 自身 ACL 规则处理。 | |
| 183 | + */ | |
| 184 | + public void handleAcl(RoutingContext context) { | |
| 185 | + JsonObject body = null; | |
| 186 | + try { | |
| 187 | + // 1.1 解析请求体 | |
| 188 | + body = parseRequestBody(context); | |
| 189 | + if (body == null) { | |
| 190 | + return; | |
| 191 | + } | |
| 192 | + String username = body.getString("username"); | |
| 193 | + String topic = body.getString("topic"); | |
| 194 | + if (StrUtil.hasBlank(username, topic)) { | |
| 195 | + log.info("[handleAcl][ACL 参数不完整: username={}, topic={}]", username, topic); | |
| 196 | + sendAuthResponse(context, RESULT_IGNORE); | |
| 197 | + return; | |
| 198 | + } | |
| 199 | + // 1.2 解析设备身份 | |
| 200 | + IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(username); | |
| 201 | + if (deviceInfo == null) { | |
| 202 | + sendAuthResponse(context, RESULT_IGNORE); | |
| 203 | + return; | |
| 204 | + } | |
| 205 | + // 1.3 解析 ACL 动作(兼容多种 EMQX 版本/插件字段) | |
| 206 | + Boolean subscribe = parseAclSubscribeFlag(body); | |
| 207 | + if (subscribe == null) { | |
| 208 | + sendAuthResponse(context, RESULT_IGNORE); | |
| 209 | + return; | |
| 210 | + } | |
| 211 | + | |
| 212 | + // 2. 执行 ACL 校验 | |
| 213 | + boolean allowed = subscribe | |
| 214 | + ? IotMqttTopicUtils.isTopicSubscribeAllowed(topic, deviceInfo.getProductKey(), deviceInfo.getDeviceName()) | |
| 215 | + : IotMqttTopicUtils.isTopicPublishAllowed(topic, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); | |
| 216 | + sendAuthResponse(context, allowed ? RESULT_ALLOW : RESULT_DENY); | |
| 217 | + } catch (Exception e) { | |
| 218 | + log.error("[handleAcl][ACL 处理失败][body={}]", body, e); | |
| 219 | + sendAuthResponse(context, RESULT_IGNORE); | |
| 220 | + } | |
| 221 | + } | |
| 222 | + | |
| 223 | + /** | |
| 224 | + * 解析 ACL 动作类型:订阅/发布 | |
| 225 | + * | |
| 226 | + * @param body ACL 请求体 | |
| 227 | + * @return true 订阅;false 发布;null 不识别 | |
| 228 | + */ | |
| 229 | + private static Boolean parseAclSubscribeFlag(JsonObject body) { | |
| 230 | + // 1. action 字段(常见为 publish/subscribe) | |
| 231 | + String action = body.getString("action"); | |
| 232 | + if (StrUtil.isNotBlank(action)) { | |
| 233 | + String lower = action.toLowerCase(Locale.ROOT); | |
| 234 | + if (lower.contains("sub")) { | |
| 235 | + return true; | |
| 236 | + } | |
| 237 | + if (lower.contains("pub")) { | |
| 238 | + return false; | |
| 239 | + } | |
| 240 | + } | |
| 241 | + | |
| 242 | + // 2. access 字段:可能是数字或字符串 | |
| 243 | + Integer access = body.getInteger("access"); | |
| 244 | + if (access != null) { | |
| 245 | + if (access == 1) { | |
| 246 | + return true; | |
| 247 | + } | |
| 248 | + if (access == 2) { | |
| 249 | + return false; | |
| 250 | + } | |
| 251 | + } | |
| 252 | + String accessText = body.getString("access"); | |
| 253 | + if (StrUtil.isNotBlank(accessText)) { | |
| 254 | + String lower = accessText.toLowerCase(Locale.ROOT); | |
| 255 | + if (lower.contains("sub")) { | |
| 256 | + return true; | |
| 257 | + } | |
| 258 | + if (lower.contains("pub")) { | |
| 259 | + return false; | |
| 260 | + } | |
| 261 | + if (StrUtil.isNumeric(accessText)) { | |
| 262 | + int value = Integer.parseInt(accessText); | |
| 263 | + if (value == 1) { | |
| 264 | + return true; | |
| 265 | + } | |
| 266 | + if (value == 2) { | |
| 267 | + return false; | |
| 268 | + } | |
| 269 | + } | |
| 96 | 270 | } |
| 271 | + return null; | |
| 97 | 272 | } |
| 98 | 273 | |
| 274 | + // ========== 事件处理 ========== | |
| 275 | + | |
| 99 | 276 | /** |
| 100 | 277 | * EMQX 统一事件处理接口:根据 EMQX 官方 Webhook 设计,统一处理所有客户端事件 |
| 101 | 278 | * 支持的事件类型:client.connected、client.disconnected 等 |
| ... | ... | @@ -124,59 +301,16 @@ public class IotEmqxAuthEventHandler { |
| 124 | 301 | break; |
| 125 | 302 | } |
| 126 | 303 | |
| 127 | - // EMQX Webhook 只需要 200 状态码,无需响应体 | |
| 304 | + // 3. EMQX Webhook 只需要 200 状态码,无需响应体 | |
| 128 | 305 | context.response().setStatusCode(SUCCESS_STATUS_CODE).end(); |
| 129 | 306 | } catch (Exception e) { |
| 130 | - log.error("[handleEvent][事件处理失败][body={}]", body != null ? body.encode() : "null", e); | |
| 131 | - // 即使处理失败,也返回 200 避免EMQX重试 | |
| 307 | + log.error("[handleEvent][事件处理失败][body={}]", body, e); | |
| 308 | + // 即使处理失败,也返回 200 避免 EMQX 重试 | |
| 132 | 309 | context.response().setStatusCode(SUCCESS_STATUS_CODE).end(); |
| 133 | 310 | } |
| 134 | 311 | } |
| 135 | 312 | |
| 136 | 313 | /** |
| 137 | - * 处理客户端连接事件 | |
| 138 | - */ | |
| 139 | - private void handleClientConnected(JsonObject body) { | |
| 140 | - String username = body.getString("username"); | |
| 141 | - log.info("[handleClientConnected][设备上线: {}]", username); | |
| 142 | - handleDeviceStateChange(username, true); | |
| 143 | - } | |
| 144 | - | |
| 145 | - /** | |
| 146 | - * 处理客户端断开连接事件 | |
| 147 | - */ | |
| 148 | - private void handleClientDisconnected(JsonObject body) { | |
| 149 | - String username = body.getString("username"); | |
| 150 | - String reason = body.getString("reason"); | |
| 151 | - log.info("[handleClientDisconnected][设备下线: {} ({})]", username, reason); | |
| 152 | - handleDeviceStateChange(username, false); | |
| 153 | - } | |
| 154 | - | |
| 155 | - /** | |
| 156 | - * 解析认证接口请求体 | |
| 157 | - * <p> | |
| 158 | - * 认证接口解析失败时返回 JSON 格式响应(包含 result 字段) | |
| 159 | - * | |
| 160 | - * @param context 路由上下文 | |
| 161 | - * @return 请求体JSON对象,解析失败时返回null | |
| 162 | - */ | |
| 163 | - private JsonObject parseRequestBody(RoutingContext context) { | |
| 164 | - try { | |
| 165 | - JsonObject body = context.body().asJsonObject(); | |
| 166 | - if (body == null) { | |
| 167 | - log.info("[parseRequestBody][请求体为空]"); | |
| 168 | - sendAuthResponse(context, RESULT_IGNORE); | |
| 169 | - return null; | |
| 170 | - } | |
| 171 | - return body; | |
| 172 | - } catch (Exception e) { | |
| 173 | - log.error("[parseRequestBody][body({}) 解析请求体失败]", context.body().asString(), e); | |
| 174 | - sendAuthResponse(context, RESULT_IGNORE); | |
| 175 | - return null; | |
| 176 | - } | |
| 177 | - } | |
| 178 | - | |
| 179 | - /** | |
| 180 | 314 | * 解析事件接口请求体 |
| 181 | 315 | * <p> |
| 182 | 316 | * 事件接口解析失败时仅返回 200 状态码,无响应体(符合 EMQX Webhook 规范) |
| ... | ... | @@ -201,23 +335,22 @@ public class IotEmqxAuthEventHandler { |
| 201 | 335 | } |
| 202 | 336 | |
| 203 | 337 | /** |
| 204 | - * 执行设备认证 | |
| 205 | - * | |
| 206 | - * @param clientId 客户端ID | |
| 207 | - * @param username 用户名 | |
| 208 | - * @param password 密码 | |
| 209 | - * @return 认证是否成功 | |
| 338 | + * 处理客户端连接事件 | |
| 210 | 339 | */ |
| 211 | - private boolean handleDeviceAuth(String clientId, String username, String password) { | |
| 212 | - try { | |
| 213 | - CommonResult<Boolean> result = deviceApi.authDevice(new IotDeviceAuthReqDTO() | |
| 214 | - .setClientId(clientId).setUsername(username).setPassword(password)); | |
| 215 | - result.checkError(); | |
| 216 | - return BooleanUtil.isTrue(result.getData()); | |
| 217 | - } catch (Exception e) { | |
| 218 | - log.error("[handleDeviceAuth][设备({}) 认证接口调用失败]", username, e); | |
| 219 | - throw e; | |
| 220 | - } | |
| 340 | + private void handleClientConnected(JsonObject body) { | |
| 341 | + String username = body.getString("username"); | |
| 342 | + log.info("[handleClientConnected][设备上线: {}]", username); | |
| 343 | + handleDeviceStateChange(username, true); | |
| 344 | + } | |
| 345 | + | |
| 346 | + /** | |
| 347 | + * 处理客户端断开连接事件 | |
| 348 | + */ | |
| 349 | + private void handleClientDisconnected(JsonObject body) { | |
| 350 | + String username = body.getString("username"); | |
| 351 | + String reason = body.getString("reason"); | |
| 352 | + log.info("[handleClientDisconnected][设备下线: {} ({})]", username, reason); | |
| 353 | + handleDeviceStateChange(username, false); | |
| 221 | 354 | } |
| 222 | 355 | |
| 223 | 356 | /** |
| ... | ... | @@ -247,29 +380,4 @@ public class IotEmqxAuthEventHandler { |
| 247 | 380 | } |
| 248 | 381 | } |
| 249 | 382 | |
| 250 | - /** | |
| 251 | - * 发送 EMQX 认证响应 | |
| 252 | - * 根据 EMQX 官方文档要求,必须返回 JSON 格式响应 | |
| 253 | - * | |
| 254 | - * @param context 路由上下文 | |
| 255 | - * @param result 认证结果:allow、deny、ignore | |
| 256 | - */ | |
| 257 | - private void sendAuthResponse(RoutingContext context, String result) { | |
| 258 | - // 构建符合 EMQX 官方规范的响应 | |
| 259 | - JsonObject response = new JsonObject() | |
| 260 | - .put("result", result) | |
| 261 | - .put("is_superuser", false); | |
| 262 | - | |
| 263 | - // 可以根据业务需求添加客户端属性 | |
| 264 | - // response.put("client_attrs", new JsonObject().put("role", "device")); | |
| 265 | - | |
| 266 | - // 可以添加认证过期时间(可选) | |
| 267 | - // response.put("expire_at", System.currentTimeMillis() / 1000 + 3600); | |
| 268 | - | |
| 269 | - context.response() | |
| 270 | - .setStatusCode(SUCCESS_STATUS_CODE) | |
| 271 | - .putHeader("Content-Type", "application/json; charset=utf-8") | |
| 272 | - .end(response.encode()); | |
| 273 | - } | |
| 274 | - | |
| 275 | -} | |
| 276 | 383 | \ No newline at end of file |
| 384 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxUpstreamHandler.java
| ... | ... | @@ -3,7 +3,6 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream; |
| 3 | 3 | import cn.hutool.core.util.StrUtil; |
| 4 | 4 | import cn.hutool.extra.spring.SpringUtil; |
| 5 | 5 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 6 | -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol; | |
| 7 | 6 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 8 | 7 | import io.vertx.mqtt.messages.MqttPublishMessage; |
| 9 | 8 | import lombok.extern.slf4j.Slf4j; |
| ... | ... | @@ -20,16 +19,16 @@ public class IotEmqxUpstreamHandler { |
| 20 | 19 | |
| 21 | 20 | private final String serverId; |
| 22 | 21 | |
| 23 | - public IotEmqxUpstreamHandler(IotEmqxUpstreamProtocol protocol) { | |
| 22 | + public IotEmqxUpstreamHandler(String serverId) { | |
| 24 | 23 | this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); |
| 25 | - this.serverId = protocol.getServerId(); | |
| 24 | + this.serverId = serverId; | |
| 26 | 25 | } |
| 27 | 26 | |
| 28 | 27 | /** |
| 29 | 28 | * 处理 MQTT 发布消息 |
| 30 | 29 | */ |
| 31 | 30 | public void handle(MqttPublishMessage mqttMessage) { |
| 32 | - log.info("[handle][收到 MQTT 消息, topic: {}, payload: {}]", mqttMessage.topicName(), mqttMessage.payload()); | |
| 31 | + log.debug("[handle][收到 MQTT 消息, topic: {}, payload: {}]", mqttMessage.topicName(), mqttMessage.payload()); | |
| 33 | 32 | String topic = mqttMessage.topicName(); |
| 34 | 33 | byte[] payload = mqttMessage.payload().getBytes(); |
| 35 | 34 | try { |
| ... | ... | @@ -57,4 +56,4 @@ public class IotEmqxUpstreamHandler { |
| 57 | 56 | } |
| 58 | 57 | } |
| 59 | 58 | |
| 60 | -} | |
| 61 | 59 | \ No newline at end of file |
| 60 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java
| ... | ... | @@ -38,6 +38,12 @@ public final class IotMqttTopicUtils { |
| 38 | 38 | */ |
| 39 | 39 | public static final String MQTT_EVENT_PATH = "/mqtt/event"; |
| 40 | 40 | |
| 41 | + /** | |
| 42 | + * MQTT ACL 接口路径 | |
| 43 | + * 对应 EMQX HTTP ACL 插件的 ACL 请求接口 | |
| 44 | + */ | |
| 45 | + public static final String MQTT_ACL_PATH = "/mqtt/acl"; | |
| 46 | + | |
| 41 | 47 | // ========== 工具方法 ========== |
| 42 | 48 | |
| 43 | 49 | /** |
| ... | ... | @@ -85,4 +91,28 @@ public final class IotMqttTopicUtils { |
| 85 | 91 | || topic.equals(SYS_TOPIC_PREFIX + productKey + "/" + deviceName + "/#"); |
| 86 | 92 | } |
| 87 | 93 | |
| 88 | -} | |
| 89 | 94 | \ No newline at end of file |
| 95 | + /** | |
| 96 | + * 校验主题是否允许发布 | |
| 97 | + * <p> | |
| 98 | + * 规则:主题必须以 /sys/{productKey}/{deviceName}/ 开头,且不允许包含通配符(+/#)。 | |
| 99 | + * | |
| 100 | + * @param topic 发布的主题 | |
| 101 | + * @param productKey 产品 Key | |
| 102 | + * @param deviceName 设备名称 | |
| 103 | + * @return 是否允许发布 | |
| 104 | + */ | |
| 105 | + // TODO DONE @AI:这个逻辑,是不是 mqtt 协议,也要使用???答:是通用工具方法,MQTT 协议可按需调用; | |
| 106 | + // TODO @AI:那你改下 mqtt,也调用!!! | |
| 107 | + public static boolean isTopicPublishAllowed(String topic, String productKey, String deviceName) { | |
| 108 | + if (!StrUtil.isAllNotBlank(topic, productKey, deviceName)) { | |
| 109 | + return false; | |
| 110 | + } | |
| 111 | + // MQTT publish topic 不允许包含通配符,但这里做一次兜底校验 | |
| 112 | + if (topic.contains("#") || topic.contains("+")) { | |
| 113 | + return false; | |
| 114 | + } | |
| 115 | + String deviceTopicPrefix = SYS_TOPIC_PREFIX + productKey + "/" + deviceName + "/"; | |
| 116 | + return topic.startsWith(deviceTopicPrefix); | |
| 117 | + } | |
| 118 | + | |
| 119 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml
| ... | ... | @@ -125,43 +125,49 @@ yudao: |
| 125 | 125 | max-message-size: 8192 # 最大消息大小(字节) |
| 126 | 126 | connect-timeout-seconds: 60 # 连接超时时间(秒) |
| 127 | 127 | ssl-enabled: false # 是否启用 SSL |
| 128 | - | |
| 129 | - # 协议配置(旧版,保持兼容) | |
| 130 | - protocol: | |
| 131 | 128 | # ==================================== |
| 132 | 129 | # 针对引入的 EMQX 组件的配置 |
| 133 | 130 | # ==================================== |
| 134 | - emqx: | |
| 131 | + - id: emqx-1 | |
| 135 | 132 | enabled: true |
| 136 | - http-port: 8090 # MQTT HTTP 服务端口 | |
| 137 | - mqtt-host: 127.0.0.1 # MQTT Broker 地址 | |
| 138 | - mqtt-port: 1883 # MQTT Broker 端口 | |
| 139 | - mqtt-username: admin # MQTT 用户名 | |
| 140 | - mqtt-password: public # MQTT 密码 | |
| 141 | - mqtt-client-id: iot-gateway-mqtt # MQTT 客户端 ID | |
| 142 | - mqtt-ssl: false # 是否开启 SSL | |
| 143 | - mqtt-topics: | |
| 144 | - - "/sys/#" # 系统主题 | |
| 145 | - clean-session: true # 是否启用 Clean Session (默认: true) | |
| 146 | - keep-alive-interval-seconds: 60 # 心跳间隔,单位秒 (默认: 60) | |
| 147 | - max-inflight-queue: 10000 # 最大飞行消息队列,单位:条 | |
| 148 | - connect-timeout-seconds: 10 # 连接超时,单位:秒 | |
| 149 | - # 是否信任所有 SSL 证书 (默认: false)。警告:生产环境必须为 false! | |
| 150 | - # 仅在开发环境或内网测试时,如果使用了自签名证书,可以临时设置为 true | |
| 151 | - trust-all: true # 在 dev 环境可以设为 true | |
| 152 | - # 遗嘱消息配置 (用于网关异常下线时通知其他系统) | |
| 153 | - will: | |
| 154 | - enabled: true # 生产环境强烈建议开启 | |
| 155 | - topic: "gateway/status/${yudao.iot.gateway.emqx.mqtt-client-id}" # 遗嘱消息主题 | |
| 156 | - payload: "offline" # 遗嘱消息负载 | |
| 157 | - qos: 1 # 遗嘱消息 QoS | |
| 158 | - retain: true # 遗嘱消息是否保留 | |
| 159 | - # 高级 SSL/TLS 配置 (当 trust-all: false 且 mqtt-ssl: true 时生效) | |
| 160 | - ssl-options: | |
| 161 | - key-store-path: "classpath:certs/client.jks" # 客户端证书库路径 | |
| 162 | - key-store-password: "your-keystore-password" # 客户端证书库密码 | |
| 163 | - trust-store-path: "classpath:certs/trust.jks" # 信任的 CA 证书库路径 | |
| 164 | - trust-store-password: "your-truststore-password" # 信任的 CA 证书库密码 | |
| 133 | + protocol: emqx | |
| 134 | + port: 8090 # EMQX HTTP Hook 端口(/mqtt/auth、/mqtt/event) | |
| 135 | + emqx: | |
| 136 | + mqtt-host: 127.0.0.1 # MQTT Broker 地址 | |
| 137 | + mqtt-port: 1883 # MQTT Broker 端口 | |
| 138 | + mqtt-username: admin # MQTT 用户名 | |
| 139 | + mqtt-password: public # MQTT 密码 | |
| 140 | + mqtt-client-id: iot-gateway-mqtt # MQTT 客户端 ID | |
| 141 | + mqtt-ssl: false # 是否开启 SSL | |
| 142 | + mqtt-topics: | |
| 143 | + - "/sys/#" # 系统主题 | |
| 144 | + mqtt-qos: 1 # 默认 QoS | |
| 145 | + clean-session: true # 是否启用 Clean Session (默认: true) | |
| 146 | + keep-alive-interval-seconds: 60 # 心跳间隔,单位秒 (默认: 60) | |
| 147 | + max-inflight-queue: 10000 # 最大飞行消息队列,单位:条 | |
| 148 | + connect-timeout-seconds: 10 # 连接超时,单位:秒 | |
| 149 | + reconnect-delay-ms: 5000 # 重连延迟,单位:毫秒 | |
| 150 | + # 是否信任所有 SSL 证书 (默认: false)。警告:生产环境必须为 false! | |
| 151 | + # 仅在开发环境或内网测试时,如果使用了自签名证书,可以临时设置为 true | |
| 152 | + trust-all: true # 在 dev 环境可以设为 true | |
| 153 | + # EMQX HTTP Hook 回调网关的 HTTPS 配置(可选) | |
| 154 | + http: | |
| 155 | + ssl-enabled: false | |
| 156 | + # ssl-cert-path: "path/to/server.crt" | |
| 157 | + # ssl-key-path: "path/to/server.key" | |
| 158 | + # 遗嘱消息配置 (用于网关异常下线时通知其他系统) | |
| 159 | + will: | |
| 160 | + enabled: true # 生产环境强烈建议开启 | |
| 161 | + topic: "gateway/status/iot-gateway-mqtt" # 遗嘱消息主题 | |
| 162 | + payload: "offline" # 遗嘱消息负载 | |
| 163 | + qos: 1 # 遗嘱消息 QoS | |
| 164 | + retain: true # 遗嘱消息是否保留 | |
| 165 | + # 高级 SSL/TLS 配置 (当 trust-all: false 且 mqtt-ssl: true 时生效) | |
| 166 | + ssl-options: | |
| 167 | + key-store-path: "classpath:certs/client.jks" # 客户端证书库路径 | |
| 168 | + key-store-password: "your-keystore-password" # 客户端证书库密码 | |
| 169 | + trust-store-path: "classpath:certs/trust.jks" # 信任的 CA 证书库路径 | |
| 170 | + trust-store-password: "your-truststore-password" # 信任的 CA 证书库密码 | |
| 165 | 171 | |
| 166 | 172 | --- #################### 日志相关配置 #################### |
| 167 | 173 | |
| ... | ... | @@ -181,7 +187,6 @@ logging: |
| 181 | 187 | cn.iocoder.yudao.module.iot.gateway.protocol.emqx: DEBUG |
| 182 | 188 | cn.iocoder.yudao.module.iot.gateway.protocol.http: DEBUG |
| 183 | 189 | cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG |
| 184 | - cn.iocoder.yudao.module.iot.gateway.protocol.mqttws: DEBUG | |
| 185 | 190 | cn.iocoder.yudao.module.iot.gateway.protocol.coap: DEBUG |
| 186 | 191 | cn.iocoder.yudao.module.iot.gateway.protocol.websocket: DEBUG |
| 187 | 192 | # 根日志级别 | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotDirectDeviceEmqxProtocolIntegrationTest.java deleted
| 1 | -package cn.iocoder.yudao.module.iot.gateway.protocol.emqx; | |
| 2 | - | |
| 3 | -import cn.hutool.core.map.MapUtil; | |
| 4 | -import cn.hutool.http.HttpResponse; | |
| 5 | -import cn.hutool.http.HttpUtil; | |
| 6 | -import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 7 | -import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; | |
| 8 | -import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 9 | -import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 10 | -import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO; | |
| 11 | -import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO; | |
| 12 | -import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; | |
| 13 | -import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec; | |
| 14 | -import cn.iocoder.yudao.module.iot.gateway.codec.alink.IotAlinkDeviceMessageCodec; | |
| 15 | -import io.netty.handler.codec.mqtt.MqttQoS; | |
| 16 | -import io.vertx.core.Vertx; | |
| 17 | -import io.vertx.core.buffer.Buffer; | |
| 18 | -import io.vertx.mqtt.MqttClient; | |
| 19 | -import io.vertx.mqtt.MqttClientOptions; | |
| 20 | -import lombok.extern.slf4j.Slf4j; | |
| 21 | -import org.junit.jupiter.api.AfterAll; | |
| 22 | -import org.junit.jupiter.api.BeforeAll; | |
| 23 | -import org.junit.jupiter.api.Disabled; | |
| 24 | -import org.junit.jupiter.api.Test; | |
| 25 | - | |
| 26 | -import java.util.concurrent.TimeUnit; | |
| 27 | - | |
| 28 | -/** | |
| 29 | - * IoT 直连设备 EMQX 协议集成测试(手动测试) | |
| 30 | - * | |
| 31 | - * <p>测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 EMQX Broker 连接平台 | |
| 32 | - * | |
| 33 | - * <p>EMQX 协议架构: | |
| 34 | - * <pre> | |
| 35 | - * +--------+ MQTT +-------------+ HTTP Hook +---------+ | |
| 36 | - * | 设备 | ----------------> | EMQX Broker | --------------------> | 网关 | | |
| 37 | - * +--------+ +-------------+ +---------+ | |
| 38 | - * | | | | |
| 39 | - * | 1. 连接认证 | 2. 调用 /mqtt/auth | | |
| 40 | - * | 3. 发布消息 | 4. 调用 /mqtt/event (上线/下线) | | |
| 41 | - * | | 5. 网关订阅 EMQX 消息 | | |
| 42 | - * | | | | |
| 43 | - * </pre> | |
| 44 | - * | |
| 45 | - * <p>测试分类: | |
| 46 | - * <ul> | |
| 47 | - * <li>第一部分:模拟设备连接 EMQX Broker,发送 MQTT 消息</li> | |
| 48 | - * <li>第二部分:模拟 EMQX Server 调用网关 HTTP Hook 接口(认证、事件)</li> | |
| 49 | - * </ul> | |
| 50 | - * | |
| 51 | - * <p>使用步骤: | |
| 52 | - * <ol> | |
| 53 | - * <li>启动 EMQX Broker(MQTT 端口 1883)</li> | |
| 54 | - * <li>启动 yudao-module-iot-gateway 服务(HTTP 端口 18083)</li> | |
| 55 | - * <li>配置 EMQX HTTP 认证插件指向网关的 /mqtt/auth 接口</li> | |
| 56 | - * <li>配置 EMQX Webhook 插件指向网关的 /mqtt/event 接口</li> | |
| 57 | - * <li>运行测试方法</li> | |
| 58 | - * </ol> | |
| 59 | - * | |
| 60 | - * @author 芋道源码 | |
| 61 | - */ | |
| 62 | -@Slf4j | |
| 63 | -@Disabled | |
| 64 | -@SuppressWarnings("HttpUrlsUsage") | |
| 65 | -public class IotDirectDeviceEmqxProtocolIntegrationTest { | |
| 66 | - | |
| 67 | - private static final String SERVER_HOST = "127.0.0.1"; | |
| 68 | - /** | |
| 69 | - * EMQX 认证事件 HTTP 接口端口(网关提供给 EMQX Server 调用) | |
| 70 | - */ | |
| 71 | - private static final int HTTP_PORT = 18083; | |
| 72 | - /** | |
| 73 | - * EMQX Broker MQTT 端口(设备连接 EMQX) | |
| 74 | - */ | |
| 75 | - private static final int MQTT_PORT = 1883; | |
| 76 | - private static final int TIMEOUT_SECONDS = 10; | |
| 77 | - | |
| 78 | - private static Vertx vertx; | |
| 79 | - | |
| 80 | - // ===================== 编解码器(EMQX 使用 Alink 协议) ===================== | |
| 81 | - | |
| 82 | - private static final IotDeviceMessageCodec CODEC = new IotAlinkDeviceMessageCodec(); | |
| 83 | - | |
| 84 | - // ===================== 直连设备信息(根据实际情况修改,从 iot_device 表查询) ===================== | |
| 85 | - | |
| 86 | - private static final String PRODUCT_KEY = "4aymZgOTOOCrDKRT"; | |
| 87 | - private static final String DEVICE_NAME = "small"; | |
| 88 | - private static final String DEVICE_SECRET = "0baa4c2ecc104ae1a26b4070c218bdf3"; | |
| 89 | - | |
| 90 | - @BeforeAll | |
| 91 | - public static void setUp() { | |
| 92 | - vertx = Vertx.vertx(); | |
| 93 | - } | |
| 94 | - | |
| 95 | - @AfterAll | |
| 96 | - public static void tearDown() { | |
| 97 | - if (vertx != null) { | |
| 98 | - vertx.close(); | |
| 99 | - } | |
| 100 | - } | |
| 101 | - | |
| 102 | - // ================================================================================== | |
| 103 | - // 第一部分:模拟设备连接 EMQX Broker | |
| 104 | - // ================================================================================== | |
| 105 | - | |
| 106 | - /** | |
| 107 | - * 设备连接测试:模拟设备连接 EMQX Broker | |
| 108 | - * <p> | |
| 109 | - * 当设备连接 EMQX 时,EMQX 会自动调用网关的 /mqtt/auth 接口进行认证 | |
| 110 | - */ | |
| 111 | - @Test | |
| 112 | - public void testDeviceConnect() throws Exception { | |
| 113 | - // 1. 构建认证信息 | |
| 114 | - IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET); | |
| 115 | - log.info("[testDeviceConnect][认证信息: clientId={}, username={}, password={}]", | |
| 116 | - authInfo.getClientId(), authInfo.getUsername(), authInfo.getPassword()); | |
| 117 | - | |
| 118 | - // 2. 创建客户端并连接 EMQX Broker | |
| 119 | - MqttClient client = createClient(authInfo); | |
| 120 | - try { | |
| 121 | - client.connect(MQTT_PORT, SERVER_HOST) | |
| 122 | - .toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 123 | - log.info("[testDeviceConnect][连接 EMQX Broker 成功,客户端 ID: {}]", client.clientId()); | |
| 124 | - log.info("[testDeviceConnect][EMQX 会自动调用网关的 /mqtt/auth 接口进行认证]"); | |
| 125 | - log.info("[testDeviceConnect][EMQX 会自动调用网关的 /mqtt/event 接口通知设备上线]"); | |
| 126 | - } finally { | |
| 127 | - disconnect(client); | |
| 128 | - log.info("[testDeviceConnect][EMQX 会自动调用网关的 /mqtt/event 接口通知设备下线]"); | |
| 129 | - } | |
| 130 | - } | |
| 131 | - | |
| 132 | - /** | |
| 133 | - * 属性上报测试:设备通过 EMQX Broker 发布属性消息 | |
| 134 | - * <p> | |
| 135 | - * 消息流程:设备 -> EMQX Broker -> 网关(订阅 EMQX 消息) | |
| 136 | - */ | |
| 137 | - @Test | |
| 138 | - public void testPropertyPost() throws Exception { | |
| 139 | - // 1. 连接 EMQX Broker | |
| 140 | - MqttClient client = connectToEmqx(); | |
| 141 | - log.info("[testPropertyPost][连接 EMQX Broker 成功]"); | |
| 142 | - | |
| 143 | - try { | |
| 144 | - // 2.1 构建属性上报消息 | |
| 145 | - IotDeviceMessage request = IotDeviceMessage.requestOf( | |
| 146 | - IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), | |
| 147 | - IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder() | |
| 148 | - .put("width", 1) | |
| 149 | - .put("height", "2") | |
| 150 | - .build())); | |
| 151 | - | |
| 152 | - // 2.2 发布消息到 EMQX Broker | |
| 153 | - String topic = String.format("/sys/%s/%s/thing/property/post", PRODUCT_KEY, DEVICE_NAME); | |
| 154 | - publish(client, topic, request); | |
| 155 | - log.info("[testPropertyPost][属性上报消息已发送到 EMQX Broker]"); | |
| 156 | - log.info("[testPropertyPost][网关会通过订阅 EMQX 接收此消息]"); | |
| 157 | - | |
| 158 | - // 2.3 等待消息处理 | |
| 159 | - Thread.sleep(2000); | |
| 160 | - log.info("[testPropertyPost][请检查网关日志确认消息是否被正确处理]"); | |
| 161 | - } finally { | |
| 162 | - disconnect(client); | |
| 163 | - } | |
| 164 | - } | |
| 165 | - | |
| 166 | - /** | |
| 167 | - * 事件上报测试:设备通过 EMQX Broker 发布事件消息 | |
| 168 | - * <p> | |
| 169 | - * 消息流程:设备 -> EMQX Broker -> 网关(订阅 EMQX 消息) | |
| 170 | - */ | |
| 171 | - @Test | |
| 172 | - public void testEventPost() throws Exception { | |
| 173 | - // 1. 连接 EMQX Broker | |
| 174 | - MqttClient client = connectToEmqx(); | |
| 175 | - log.info("[testEventPost][连接 EMQX Broker 成功]"); | |
| 176 | - | |
| 177 | - try { | |
| 178 | - // 2.1 构建事件上报消息 | |
| 179 | - IotDeviceMessage request = IotDeviceMessage.requestOf( | |
| 180 | - IotDeviceMessageMethodEnum.EVENT_POST.getMethod(), | |
| 181 | - IotDeviceEventPostReqDTO.of( | |
| 182 | - "eat", | |
| 183 | - MapUtil.<String, Object>builder().put("rice", 3).build(), | |
| 184 | - System.currentTimeMillis())); | |
| 185 | - | |
| 186 | - // 2.2 发布消息到 EMQX Broker | |
| 187 | - String topic = String.format("/sys/%s/%s/thing/event/post", PRODUCT_KEY, DEVICE_NAME); | |
| 188 | - publish(client, topic, request); | |
| 189 | - log.info("[testEventPost][事件上报消息已发送到 EMQX Broker]"); | |
| 190 | - log.info("[testEventPost][网关会通过订阅 EMQX 接收此消息]"); | |
| 191 | - | |
| 192 | - // 2.3 等待消息处理 | |
| 193 | - Thread.sleep(2000); | |
| 194 | - log.info("[testEventPost][请检查网关日志确认消息是否被正确处理]"); | |
| 195 | - } finally { | |
| 196 | - disconnect(client); | |
| 197 | - } | |
| 198 | - } | |
| 199 | - | |
| 200 | - /** | |
| 201 | - * 订阅下行消息测试:设备订阅服务端下发的消息 | |
| 202 | - * <p> | |
| 203 | - * 消息流程:网关 -> EMQX Broker -> 设备 | |
| 204 | - */ | |
| 205 | - @Test | |
| 206 | - public void testSubscribe() throws Exception { | |
| 207 | - // 1. 连接 EMQX Broker | |
| 208 | - MqttClient client = connectToEmqx(); | |
| 209 | - log.info("[testSubscribe][连接 EMQX Broker 成功]"); | |
| 210 | - | |
| 211 | - try { | |
| 212 | - // 2. 设置消息处理器 | |
| 213 | - client.publishHandler(message -> log.info("[testSubscribe][收到下行消息: topic={}, payload={}]", | |
| 214 | - message.topicName(), message.payload().toString())); | |
| 215 | - | |
| 216 | - // 3. 订阅下行主题 | |
| 217 | - String topic = String.format("/sys/%s/%s/thing/service/#", PRODUCT_KEY, DEVICE_NAME); | |
| 218 | - log.info("[testSubscribe][订阅主题: {}]", topic); | |
| 219 | - subscribe(client, topic); | |
| 220 | - log.info("[testSubscribe][订阅成功,等待下行消息... (30秒后自动断开)]"); | |
| 221 | - log.info("[testSubscribe][网关下发的消息会通过 EMQX Broker 转发给设备]"); | |
| 222 | - | |
| 223 | - // 4. 保持连接 30 秒等待消息 | |
| 224 | - Thread.sleep(30000); | |
| 225 | - } finally { | |
| 226 | - disconnect(client); | |
| 227 | - } | |
| 228 | - } | |
| 229 | - | |
| 230 | - // ================================================================================== | |
| 231 | - // 第二部分:模拟 EMQX Server 调用网关 HTTP Hook 接口 | |
| 232 | - // 说明:这些接口是 EMQX Server 自动调用的,这里只是用于单独测试接口功能 | |
| 233 | - // ================================================================================== | |
| 234 | - | |
| 235 | - /** | |
| 236 | - * 认证接口测试:模拟 EMQX Server 调用 /mqtt/auth 接口 | |
| 237 | - * <p> | |
| 238 | - * 注意:正常情况下此接口由 EMQX HTTP 认证插件自动调用,这里只是测试接口本身 | |
| 239 | - */ | |
| 240 | - @Test | |
| 241 | - public void testEmqxAuthHook() { | |
| 242 | - // 1.1 构建请求 | |
| 243 | - String url = String.format("http://%s:%d/mqtt/auth", SERVER_HOST, HTTP_PORT); | |
| 244 | - IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET); | |
| 245 | - // 1.2 EMQX 认证请求格式 | |
| 246 | - String payload = JsonUtils.toJsonString(MapUtil.builder() | |
| 247 | - .put("clientid", authInfo.getClientId()) | |
| 248 | - .put("username", authInfo.getUsername()) | |
| 249 | - .put("password", authInfo.getPassword()) | |
| 250 | - .build()); | |
| 251 | - // 1.3 输出请求 | |
| 252 | - log.info("[testEmqxAuthHook][模拟 EMQX Server 调用认证接口]"); | |
| 253 | - log.info("[testEmqxAuthHook][请求 URL: {}]", url); | |
| 254 | - log.info("[testEmqxAuthHook][请求体: {}]", payload); | |
| 255 | - | |
| 256 | - // 2.1 发送请求 | |
| 257 | - try (HttpResponse httpResponse = HttpUtil.createPost(url) | |
| 258 | - .header("Content-Type", "application/json") | |
| 259 | - .body(payload) | |
| 260 | - .execute()) { | |
| 261 | - // 2.2 输出结果 | |
| 262 | - log.info("[testEmqxAuthHook][响应状态码: {}]", httpResponse.getStatus()); | |
| 263 | - log.info("[testEmqxAuthHook][响应体: {}]", httpResponse.body()); | |
| 264 | - log.info("[testEmqxAuthHook][认证结果: result=allow 表示认证成功, result=deny 表示认证失败]"); | |
| 265 | - } | |
| 266 | - } | |
| 267 | - | |
| 268 | - /** | |
| 269 | - * 认证失败测试:模拟 EMQX Server 调用 /mqtt/auth 接口(错误密码) | |
| 270 | - */ | |
| 271 | - @Test | |
| 272 | - public void testEmqxAuthHookFailed() { | |
| 273 | - // 1.1 构建请求 | |
| 274 | - String url = String.format("http://%s:%d/mqtt/auth", SERVER_HOST, HTTP_PORT); | |
| 275 | - // 1.2 使用错误的密码 | |
| 276 | - String payload = JsonUtils.toJsonString(MapUtil.builder() | |
| 277 | - .put("clientid", PRODUCT_KEY + "." + DEVICE_NAME) | |
| 278 | - .put("username", DEVICE_NAME + "&" + PRODUCT_KEY) | |
| 279 | - .put("password", "wrong_password") | |
| 280 | - .build()); | |
| 281 | - // 1.3 输出请求 | |
| 282 | - log.info("[testEmqxAuthHookFailed][模拟 EMQX Server 调用认证接口(错误密码)]"); | |
| 283 | - log.info("[testEmqxAuthHookFailed][请求 URL: {}]", url); | |
| 284 | - log.info("[testEmqxAuthHookFailed][请求体: {}]", payload); | |
| 285 | - | |
| 286 | - // 2.1 发送请求 | |
| 287 | - try (HttpResponse httpResponse = HttpUtil.createPost(url) | |
| 288 | - .header("Content-Type", "application/json") | |
| 289 | - .body(payload) | |
| 290 | - .execute()) { | |
| 291 | - // 2.2 输出结果 | |
| 292 | - log.info("[testEmqxAuthHookFailed][响应状态码: {}]", httpResponse.getStatus()); | |
| 293 | - log.info("[testEmqxAuthHookFailed][响应体: {}]", httpResponse.body()); | |
| 294 | - log.info("[testEmqxAuthHookFailed][预期结果: result=deny]"); | |
| 295 | - } | |
| 296 | - } | |
| 297 | - | |
| 298 | - /** | |
| 299 | - * 设备上线事件测试:模拟 EMQX Server Webhook 调用 /mqtt/event 接口 | |
| 300 | - * <p> | |
| 301 | - * 注意:正常情况下此接口由 EMQX Webhook 插件自动调用,这里只是测试接口本身 | |
| 302 | - */ | |
| 303 | - @Test | |
| 304 | - public void testEmqxClientConnectedHook() { | |
| 305 | - // 1.1 构建请求 | |
| 306 | - String url = String.format("http://%s:%d/mqtt/event", SERVER_HOST, HTTP_PORT); | |
| 307 | - IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET); | |
| 308 | - // 1.2 EMQX Webhook client.connected 事件格式 | |
| 309 | - String payload = JsonUtils.toJsonString(MapUtil.builder() | |
| 310 | - .put("event", "client.connected") | |
| 311 | - .put("clientid", authInfo.getClientId()) | |
| 312 | - .put("username", authInfo.getUsername()) | |
| 313 | - .put("peername", "127.0.0.1:12345") | |
| 314 | - .put("connected_at", System.currentTimeMillis()) | |
| 315 | - .build()); | |
| 316 | - // 1.3 输出请求 | |
| 317 | - log.info("[testEmqxClientConnectedHook][模拟 EMQX Server Webhook 调用设备上线事件]"); | |
| 318 | - log.info("[testEmqxClientConnectedHook][请求 URL: {}]", url); | |
| 319 | - log.info("[testEmqxClientConnectedHook][请求体: {}]", payload); | |
| 320 | - | |
| 321 | - // 2.1 发送请求 | |
| 322 | - try (HttpResponse httpResponse = HttpUtil.createPost(url) | |
| 323 | - .header("Content-Type", "application/json") | |
| 324 | - .body(payload) | |
| 325 | - .execute()) { | |
| 326 | - // 2.2 输出结果 | |
| 327 | - log.info("[testEmqxClientConnectedHook][响应状态码: {}]", httpResponse.getStatus()); | |
| 328 | - log.info("[testEmqxClientConnectedHook][响应体: {}]", httpResponse.body()); | |
| 329 | - log.info("[testEmqxClientConnectedHook][预期结果: 状态码 200,设备状态更新为在线]"); | |
| 330 | - } | |
| 331 | - } | |
| 332 | - | |
| 333 | - /** | |
| 334 | - * 设备下线事件测试:模拟 EMQX Server Webhook 调用 /mqtt/event 接口 | |
| 335 | - * <p> | |
| 336 | - * 注意:正常情况下此接口由 EMQX Webhook 插件自动调用,这里只是测试接口本身 | |
| 337 | - */ | |
| 338 | - @Test | |
| 339 | - public void testEmqxClientDisconnectedHook() { | |
| 340 | - // 1.1 构建请求 | |
| 341 | - String url = String.format("http://%s:%d/mqtt/event", SERVER_HOST, HTTP_PORT); | |
| 342 | - IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET); | |
| 343 | - // 1.2 EMQX Webhook client.disconnected 事件格式 | |
| 344 | - String payload = JsonUtils.toJsonString(MapUtil.builder() | |
| 345 | - .put("event", "client.disconnected") | |
| 346 | - .put("clientid", authInfo.getClientId()) | |
| 347 | - .put("username", authInfo.getUsername()) | |
| 348 | - .put("reason", "normal") | |
| 349 | - .put("disconnected_at", System.currentTimeMillis()) | |
| 350 | - .build()); | |
| 351 | - // 1.3 输出请求 | |
| 352 | - log.info("[testEmqxClientDisconnectedHook][模拟 EMQX Server Webhook 调用设备下线事件]"); | |
| 353 | - log.info("[testEmqxClientDisconnectedHook][请求 URL: {}]", url); | |
| 354 | - log.info("[testEmqxClientDisconnectedHook][请求体: {}]", payload); | |
| 355 | - | |
| 356 | - // 2.1 发送请求 | |
| 357 | - try (HttpResponse httpResponse = HttpUtil.createPost(url) | |
| 358 | - .header("Content-Type", "application/json") | |
| 359 | - .body(payload) | |
| 360 | - .execute()) { | |
| 361 | - // 2.2 输出结果 | |
| 362 | - log.info("[testEmqxClientDisconnectedHook][响应状态码: {}]", httpResponse.getStatus()); | |
| 363 | - log.info("[testEmqxClientDisconnectedHook][响应体: {}]", httpResponse.body()); | |
| 364 | - log.info("[testEmqxClientDisconnectedHook][预期结果: 状态码 200,设备状态更新为离线]"); | |
| 365 | - } | |
| 366 | - } | |
| 367 | - | |
| 368 | - // ===================== 辅助方法 ===================== | |
| 369 | - | |
| 370 | - /** | |
| 371 | - * 创建 MQTT 客户端 | |
| 372 | - * | |
| 373 | - * @param authInfo 认证信息 | |
| 374 | - * @return MQTT 客户端 | |
| 375 | - */ | |
| 376 | - private MqttClient createClient(IotDeviceAuthReqDTO authInfo) { | |
| 377 | - MqttClientOptions options = new MqttClientOptions() | |
| 378 | - .setClientId(authInfo.getClientId()) | |
| 379 | - .setUsername(authInfo.getUsername()) | |
| 380 | - .setPassword(authInfo.getPassword()) | |
| 381 | - .setCleanSession(true) | |
| 382 | - .setKeepAliveInterval(60); | |
| 383 | - return MqttClient.create(vertx, options); | |
| 384 | - } | |
| 385 | - | |
| 386 | - /** | |
| 387 | - * 连接 EMQX Broker 并认证设备 | |
| 388 | - * | |
| 389 | - * @return 已认证的 MQTT 客户端 | |
| 390 | - */ | |
| 391 | - private MqttClient connectToEmqx() throws Exception { | |
| 392 | - IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET); | |
| 393 | - MqttClient client = createClient(authInfo); | |
| 394 | - client.connect(MQTT_PORT, SERVER_HOST) | |
| 395 | - .toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 396 | - return client; | |
| 397 | - } | |
| 398 | - | |
| 399 | - /** | |
| 400 | - * 订阅主题 | |
| 401 | - * | |
| 402 | - * @param client MQTT 客户端 | |
| 403 | - * @param topic 主题 | |
| 404 | - */ | |
| 405 | - private void subscribe(MqttClient client, String topic) throws Exception { | |
| 406 | - client.subscribe(topic, MqttQoS.AT_LEAST_ONCE.value()) | |
| 407 | - .toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 408 | - log.info("[subscribe][订阅主题成功: {}]", topic); | |
| 409 | - } | |
| 410 | - | |
| 411 | - /** | |
| 412 | - * 发布消息 | |
| 413 | - * | |
| 414 | - * @param client MQTT 客户端 | |
| 415 | - * @param topic 发布主题 | |
| 416 | - * @param request 请求消息 | |
| 417 | - */ | |
| 418 | - private void publish(MqttClient client, String topic, IotDeviceMessage request) throws Exception { | |
| 419 | - byte[] payload = CODEC.encode(request); | |
| 420 | - log.info("[publish][发送消息: topic={}, payload={}]", topic, new String(payload)); | |
| 421 | - client.publish(topic, Buffer.buffer(payload), MqttQoS.AT_LEAST_ONCE, false, false) | |
| 422 | - .toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 423 | - log.info("[publish][消息发布成功]"); | |
| 424 | - } | |
| 425 | - | |
| 426 | - /** | |
| 427 | - * 断开连接 | |
| 428 | - * | |
| 429 | - * @param client MQTT 客户端 | |
| 430 | - */ | |
| 431 | - private void disconnect(MqttClient client) throws Exception { | |
| 432 | - client.disconnect() | |
| 433 | - .toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 434 | - log.info("[disconnect][断开连接成功]"); | |
| 435 | - } | |
| 436 | - | |
| 437 | -} |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/package-info.java
0 → 100644
| 1 | +/** | |
| 2 | + * IoT 网关 EMQX 协议集成测试包 | |
| 3 | + * | |
| 4 | + * <p> | |
| 5 | + * 测试类直接使用 mqtt 包下的单测即可,因为设备都是通过 MQTT 协议连接 EMQX Broker。 | |
| 6 | + * | |
| 7 | + * @see cn.iocoder.yudao.module.iot.gateway.protocol.mqtt | |
| 8 | + * | |
| 9 | + * <h2>架构</h2> | |
| 10 | + * <pre> | |
| 11 | + * +--------+ MQTT +-------------+ HTTP Hook +---------+ | |
| 12 | + * | 设备 | --------------> | EMQX Broker | ----------------> | 网关 | | |
| 13 | + * +--------+ +-------------+ +---------+ | |
| 14 | + * </pre> | |
| 15 | + * | |
| 16 | + * @author 芋道源码 | |
| 17 | + */ | |
| 18 | +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx; | ... | ... |