Commit 44838510c950098e14a86fc47795cba72fe0c0ba

Authored by puhui999
1 parent 45638b35

feat:【iot】WebSocket 连接线程安全与 JDK 8 兼容

yudao-dependencies/pom.xml
@@ -76,6 +76,8 @@ @@ -76,6 +76,8 @@
76 <jimubi.version>2.3.0</jimubi.version> 76 <jimubi.version>2.3.0</jimubi.version>
77 <weixin-java.version>4.7.9-20251224.161447</weixin-java.version> 77 <weixin-java.version>4.7.9-20251224.161447</weixin-java.version>
78 <alipay-sdk-java.version>4.40.607.ALL</alipay-sdk-java.version> 78 <alipay-sdk-java.version>4.40.607.ALL</alipay-sdk-java.version>
  79 + <!-- OkHttp -->
  80 + <okhttp.version>4.12.0</okhttp.version>
79 </properties> 81 </properties>
80 82
81 <dependencyManagement> 83 <dependencyManagement>
@@ -653,6 +655,19 @@ @@ -653,6 +655,19 @@
653 <artifactId>org.eclipse.paho.client.mqttv3</artifactId> 655 <artifactId>org.eclipse.paho.client.mqttv3</artifactId>
654 <version>${mqtt.version}</version> 656 <version>${mqtt.version}</version>
655 </dependency> 657 </dependency>
  658 +
  659 + <!-- OkHttp -->
  660 + <dependency>
  661 + <groupId>com.squareup.okhttp3</groupId>
  662 + <artifactId>okhttp</artifactId>
  663 + <version>${okhttp.version}</version>
  664 + </dependency>
  665 + <dependency>
  666 + <groupId>com.squareup.okhttp3</groupId>
  667 + <artifactId>mockwebserver</artifactId>
  668 + <version>${okhttp.version}</version>
  669 + <scope>test</scope>
  670 + </dependency>
656 </dependencies> 671 </dependencies>
657 </dependencyManagement> 672 </dependencyManagement>
658 673
yudao-module-iot/yudao-module-iot-biz/pom.xml
@@ -73,6 +73,17 @@ @@ -73,6 +73,17 @@
73 <artifactId>yudao-spring-boot-starter-excel</artifactId> 73 <artifactId>yudao-spring-boot-starter-excel</artifactId>
74 </dependency> 74 </dependency>
75 75
  76 + <!-- OkHttp -->
  77 + <dependency>
  78 + <groupId>com.squareup.okhttp3</groupId>
  79 + <artifactId>okhttp</artifactId>
  80 + </dependency>
  81 + <dependency>
  82 + <groupId>com.squareup.okhttp3</groupId>
  83 + <artifactId>mockwebserver</artifactId>
  84 + <scope>test</scope>
  85 + </dependency>
  86 +
76 <!-- 消息队列相关 --> 87 <!-- 消息队列相关 -->
77 <!-- TODO @芋艿:临时打开 --> 88 <!-- TODO @芋艿:临时打开 -->
78 <dependency> 89 <dependency>
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/RedisKeyConstants.java
@@ -84,4 +84,12 @@ public interface RedisKeyConstants { @@ -84,4 +84,12 @@ public interface RedisKeyConstants {
84 */ 84 */
85 String SCENE_RULE_LIST = "iot:scene_rule_list"; 85 String SCENE_RULE_LIST = "iot:scene_rule_list";
86 86
  87 + /**
  88 + * WebSocket 连接分布式锁
  89 + * <p>
  90 + * KEY 格式:websocket_connect_lock:${serverUrl}
  91 + * 用于保证 WebSocket 重连操作的线程安全
  92 + */
  93 + String WEBSOCKET_CONNECT_LOCK = "iot:websocket_connect_lock:%s";
  94 +
87 } 95 }
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/rule/IotWebSocketLockRedisDAO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.dal.redis.rule;
  2 +
  3 +import jakarta.annotation.Resource;
  4 +import org.redisson.api.RLock;
  5 +import org.redisson.api.RedissonClient;
  6 +import org.springframework.stereotype.Repository;
  7 +
  8 +import java.util.concurrent.TimeUnit;
  9 +
  10 +import static cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants.WEBSOCKET_CONNECT_LOCK;
  11 +
  12 +/**
  13 + * IoT WebSocket 连接锁 Redis DAO
  14 + * <p>
  15 + * 用于保证 WebSocket 重连操作的线程安全,避免多线程同时重连导致的资源竞争
  16 + *
  17 + * @author HUIHUI
  18 + */
  19 +@Repository
  20 +public class IotWebSocketLockRedisDAO {
  21 +
  22 + /**
  23 + * 锁等待超时时间(毫秒)
  24 + */
  25 + private static final long LOCK_WAIT_TIME_MS = 5000;
  26 +
  27 + /**
  28 + * 锁持有超时时间(毫秒)
  29 + */
  30 + private static final long LOCK_LEASE_TIME_MS = 10000;
  31 +
  32 + @Resource
  33 + private RedissonClient redissonClient;
  34 +
  35 + /**
  36 + * 在分布式锁保护下执行操作
  37 + *
  38 + * @param serverUrl WebSocket 服务器地址
  39 + * @param runnable 需要执行的操作
  40 + * @throws Exception 如果获取锁超时或执行操作时发生异常
  41 + */
  42 + public void lock(String serverUrl, Runnable runnable) throws Exception {
  43 + String lockKey = formatKey(serverUrl);
  44 + RLock lock = redissonClient.getLock(lockKey);
  45 +
  46 + try {
  47 + // 尝试获取分布式锁
  48 + boolean acquired = lock.tryLock(LOCK_WAIT_TIME_MS, LOCK_LEASE_TIME_MS, TimeUnit.MILLISECONDS);
  49 + if (!acquired) {
  50 + throw new RuntimeException("获取 WebSocket 连接锁超时,服务器: " + serverUrl);
  51 + }
  52 +
  53 + // 执行操作
  54 + runnable.run();
  55 + } finally {
  56 + // 释放锁
  57 + if (lock.isHeldByCurrentThread()) {
  58 + lock.unlock();
  59 + }
  60 + }
  61 + }
  62 +
  63 + private static String formatKey(String serverUrl) {
  64 + return String.format(WEBSOCKET_CONNECT_LOCK, serverUrl);
  65 + }
  66 +
  67 +}
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java
@@ -3,8 +3,10 @@ package cn.iocoder.yudao.module.iot.service.rule.data.action; @@ -3,8 +3,10 @@ package cn.iocoder.yudao.module.iot.service.rule.data.action;
3 import cn.hutool.core.util.StrUtil; 3 import cn.hutool.core.util.StrUtil;
4 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; 4 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
5 import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig; 5 import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig;
  6 +import cn.iocoder.yudao.module.iot.dal.redis.rule.IotWebSocketLockRedisDAO;
6 import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; 7 import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum;
7 import cn.iocoder.yudao.module.iot.service.rule.data.action.websocket.IotWebSocketClient; 8 import cn.iocoder.yudao.module.iot.service.rule.data.action.websocket.IotWebSocketClient;
  9 +import jakarta.annotation.Resource;
8 import lombok.extern.slf4j.Slf4j; 10 import lombok.extern.slf4j.Slf4j;
9 import org.springframework.stereotype.Component; 11 import org.springframework.stereotype.Component;
10 12
@@ -22,6 +24,9 @@ import org.springframework.stereotype.Component; @@ -22,6 +24,9 @@ import org.springframework.stereotype.Component;
22 public class IotWebSocketDataRuleAction extends 24 public class IotWebSocketDataRuleAction extends
23 IotDataRuleCacheableAction<IotDataSinkWebSocketConfig, IotWebSocketClient> { 25 IotDataRuleCacheableAction<IotDataSinkWebSocketConfig, IotWebSocketClient> {
24 26
  27 + @Resource
  28 + private IotWebSocketLockRedisDAO webSocketLockRedisDAO;
  29 +
25 @Override 30 @Override
26 public Integer getType() { 31 public Integer getType() {
27 return IotDataSinkTypeEnum.WEBSOCKET.getType(); 32 return IotDataSinkTypeEnum.WEBSOCKET.getType();
@@ -62,12 +67,11 @@ public class IotWebSocketDataRuleAction extends @@ -62,12 +67,11 @@ public class IotWebSocketDataRuleAction extends
62 protected void execute(IotDeviceMessage message, IotDataSinkWebSocketConfig config) throws Exception { 67 protected void execute(IotDeviceMessage message, IotDataSinkWebSocketConfig config) throws Exception {
63 try { 68 try {
64 // 1.1 获取或创建 WebSocket 客户端 69 // 1.1 获取或创建 WebSocket 客户端
65 - // TODO @puhui999:需要加锁,保证必须连接上;  
66 IotWebSocketClient webSocketClient = getProducer(config); 70 IotWebSocketClient webSocketClient = getProducer(config);
67 - // 1.2 检查连接状态,如果断开则重新连接 71 +
  72 + // 1.2 检查连接状态,如果断开则使用分布式锁保证重连的线程安全
68 if (!webSocketClient.isConnected()) { 73 if (!webSocketClient.isConnected()) {
69 - log.warn("[execute][WebSocket 连接已断开,尝试重新连接,服务器: {}]", config.getServerUrl());  
70 - webSocketClient.connect(); 74 + reconnectWithLock(webSocketClient, config);
71 } 75 }
72 76
73 // 2.1 发送消息 77 // 2.1 发送消息
@@ -82,4 +86,24 @@ public class IotWebSocketDataRuleAction extends @@ -82,4 +86,24 @@ public class IotWebSocketDataRuleAction extends
82 } 86 }
83 } 87 }
84 88
  89 + /**
  90 + * 使用分布式锁进行重连
  91 + *
  92 + * @param webSocketClient WebSocket 客户端
  93 + * @param config 配置信息
  94 + */
  95 + private void reconnectWithLock(IotWebSocketClient webSocketClient, IotDataSinkWebSocketConfig config) throws Exception {
  96 + webSocketLockRedisDAO.lock(config.getServerUrl(), () -> {
  97 + // 双重检查:获取锁后再次检查连接状态,避免重复连接
  98 + if (!webSocketClient.isConnected()) {
  99 + log.warn("[reconnectWithLock][WebSocket 连接已断开,尝试重新连接,服务器: {}]", config.getServerUrl());
  100 + try {
  101 + webSocketClient.connect();
  102 + } catch (Exception e) {
  103 + throw new RuntimeException("WebSocket 重连失败,服务器: " + config.getServerUrl(), e);
  104 + }
  105 + }
  106 + });
  107 + }
  108 +
85 } 109 }
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java
@@ -4,13 +4,9 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils; @@ -4,13 +4,9 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
4 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; 4 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
5 import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig; 5 import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig;
6 import lombok.extern.slf4j.Slf4j; 6 import lombok.extern.slf4j.Slf4j;
  7 +import okhttp3.*;
7 8
8 -import java.net.URI;  
9 -import java.net.http.HttpClient;  
10 -import java.net.http.WebSocket;  
11 -import java.time.Duration;  
12 -import java.util.concurrent.CompletableFuture;  
13 -import java.util.concurrent.CompletionStage; 9 +import java.util.concurrent.CountDownLatch;
14 import java.util.concurrent.TimeUnit; 10 import java.util.concurrent.TimeUnit;
15 import java.util.concurrent.atomic.AtomicBoolean; 11 import java.util.concurrent.atomic.AtomicBoolean;
16 12
@@ -19,21 +15,23 @@ import java.util.concurrent.atomic.AtomicBoolean; @@ -19,21 +15,23 @@ import java.util.concurrent.atomic.AtomicBoolean;
19 * <p> 15 * <p>
20 * 负责与外部 WebSocket 服务器建立连接并发送设备消息 16 * 负责与外部 WebSocket 服务器建立连接并发送设备消息
21 * 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式 17 * 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式
22 - * 基于 Java 11+ 内置的 java.net.http.WebSocket 实现 18 + * 基于 OkHttp WebSocket 实现,兼容 JDK 8+
  19 + * <p>
  20 + * 注意:该类的线程安全由调用方(IotWebSocketDataRuleAction)通过分布式锁保证
23 * 21 *
24 * @author HUIHUI 22 * @author HUIHUI
25 */ 23 */
26 @Slf4j 24 @Slf4j
27 -public class IotWebSocketClient implements WebSocket.Listener { 25 +public class IotWebSocketClient {
28 26
29 private final String serverUrl; 27 private final String serverUrl;
30 private final Integer connectTimeoutMs; 28 private final Integer connectTimeoutMs;
31 private final Integer sendTimeoutMs; 29 private final Integer sendTimeoutMs;
32 private final String dataFormat; 30 private final String dataFormat;
33 31
34 - private WebSocket webSocket; 32 + private OkHttpClient okHttpClient;
  33 + private volatile WebSocket webSocket;
35 private final AtomicBoolean connected = new AtomicBoolean(false); 34 private final AtomicBoolean connected = new AtomicBoolean(false);
36 - private final StringBuilder messageBuffer = new StringBuilder();  
37 35
38 public IotWebSocketClient(String serverUrl, Integer connectTimeoutMs, Integer sendTimeoutMs, String dataFormat) { 36 public IotWebSocketClient(String serverUrl, Integer connectTimeoutMs, Integer sendTimeoutMs, String dataFormat) {
39 this.serverUrl = serverUrl; 37 this.serverUrl = serverUrl;
@@ -44,8 +42,9 @@ public class IotWebSocketClient implements WebSocket.Listener { @@ -44,8 +42,9 @@ public class IotWebSocketClient implements WebSocket.Listener {
44 42
45 /** 43 /**
46 * 连接到 WebSocket 服务器 44 * 连接到 WebSocket 服务器
  45 + * <p>
  46 + * 注意:调用方需要通过分布式锁保证并发安全
47 */ 47 */
48 - @SuppressWarnings("resource")  
49 public void connect() throws Exception { 48 public void connect() throws Exception {
50 if (connected.get()) { 49 if (connected.get()) {
51 log.warn("[connect][WebSocket 客户端已经连接,无需重复连接]"); 50 log.warn("[connect][WebSocket 客户端已经连接,无需重复连接]");
@@ -53,17 +52,32 @@ public class IotWebSocketClient implements WebSocket.Listener { @@ -53,17 +52,32 @@ public class IotWebSocketClient implements WebSocket.Listener {
53 } 52 }
54 53
55 try { 54 try {
56 - HttpClient httpClient = HttpClient.newBuilder()  
57 - .connectTimeout(Duration.ofMillis(connectTimeoutMs)) 55 + // 创建 OkHttpClient
  56 + okHttpClient = new OkHttpClient.Builder()
  57 + .connectTimeout(connectTimeoutMs, TimeUnit.MILLISECONDS)
  58 + .readTimeout(sendTimeoutMs, TimeUnit.MILLISECONDS)
  59 + .writeTimeout(sendTimeoutMs, TimeUnit.MILLISECONDS)
  60 + .build();
  61 +
  62 + // 创建 WebSocket 请求
  63 + Request request = new Request.Builder()
  64 + .url(serverUrl)
58 .build(); 65 .build();
59 66
60 - CompletableFuture<WebSocket> future = httpClient.newWebSocketBuilder()  
61 - .connectTimeout(Duration.ofMillis(connectTimeoutMs))  
62 - .buildAsync(URI.create(serverUrl), this); 67 + // 使用 CountDownLatch 等待连接完成
  68 + CountDownLatch connectLatch = new CountDownLatch(1);
  69 + AtomicBoolean connectSuccess = new AtomicBoolean(false);
  70 +
  71 + // 创建 WebSocket 连接
  72 + webSocket = okHttpClient.newWebSocket(request, new IotWebSocketListener(connectLatch, connectSuccess));
63 73
64 // 等待连接完成 74 // 等待连接完成
65 - webSocket = future.get(connectTimeoutMs, TimeUnit.MILLISECONDS);  
66 - connected.set(true); 75 + boolean await = connectLatch.await(connectTimeoutMs, TimeUnit.MILLISECONDS);
  76 + if (!await || !connectSuccess.get()) {
  77 + close();
  78 + throw new Exception("WebSocket 连接超时或失败,服务器地址: " + serverUrl);
  79 + }
  80 +
67 log.info("[connect][WebSocket 客户端连接成功,服务器地址: {}]", serverUrl); 81 log.info("[connect][WebSocket 客户端连接成功,服务器地址: {}]", serverUrl);
68 } catch (Exception e) { 82 } catch (Exception e) {
69 close(); 83 close();
@@ -72,36 +86,6 @@ public class IotWebSocketClient implements WebSocket.Listener { @@ -72,36 +86,6 @@ public class IotWebSocketClient implements WebSocket.Listener {
72 } 86 }
73 } 87 }
74 88
75 - @Override  
76 - public void onOpen(WebSocket webSocket) {  
77 - log.debug("[onOpen][WebSocket 连接已打开]");  
78 - webSocket.request(1);  
79 - }  
80 -  
81 - @Override  
82 - public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {  
83 - messageBuffer.append(data);  
84 - if (last) {  
85 - log.debug("[onText][收到 WebSocket 消息: {}]", messageBuffer);  
86 - messageBuffer.setLength(0);  
87 - }  
88 - webSocket.request(1);  
89 - return null;  
90 - }  
91 -  
92 - @Override  
93 - public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {  
94 - connected.set(false);  
95 - log.info("[onClose][WebSocket 连接已关闭,状态码: {},原因: {}]", statusCode, reason);  
96 - return null;  
97 - }  
98 -  
99 - @Override  
100 - public void onError(WebSocket webSocket, Throwable error) {  
101 - connected.set(false);  
102 - log.error("[onError][WebSocket 发生错误]", error);  
103 - }  
104 -  
105 /** 89 /**
106 * 发送设备消息 90 * 发送设备消息
107 * 91 *
@@ -109,7 +93,8 @@ public class IotWebSocketClient implements WebSocket.Listener { @@ -109,7 +93,8 @@ public class IotWebSocketClient implements WebSocket.Listener {
109 * @throws Exception 发送异常 93 * @throws Exception 发送异常
110 */ 94 */
111 public void sendMessage(IotDeviceMessage message) throws Exception { 95 public void sendMessage(IotDeviceMessage message) throws Exception {
112 - if (!connected.get() || webSocket == null) { 96 + WebSocket ws = this.webSocket;
  97 + if (!connected.get() || ws == null) {
113 throw new IllegalStateException("WebSocket 客户端未连接"); 98 throw new IllegalStateException("WebSocket 客户端未连接");
114 } 99 }
115 100
@@ -121,9 +106,11 @@ public class IotWebSocketClient implements WebSocket.Listener { @@ -121,9 +106,11 @@ public class IotWebSocketClient implements WebSocket.Listener {
121 messageData = message.toString(); 106 messageData = message.toString();
122 } 107 }
123 108
124 - // 发送消息并等待完成  
125 - CompletableFuture<WebSocket> future = webSocket.sendText(messageData, true);  
126 - future.get(sendTimeoutMs, TimeUnit.MILLISECONDS); 109 + // 发送消息
  110 + boolean success = ws.send(messageData);
  111 + if (!success) {
  112 + throw new Exception("WebSocket 发送消息失败,消息队列已满或连接已关闭");
  113 + }
127 log.debug("[sendMessage][发送消息成功,设备 ID: {},消息长度: {}]", 114 log.debug("[sendMessage][发送消息成功,设备 ID: {},消息长度: {}]",
128 message.getDeviceId(), messageData.length()); 115 message.getDeviceId(), messageData.length());
129 } catch (Exception e) { 116 } catch (Exception e) {
@@ -136,18 +123,17 @@ public class IotWebSocketClient implements WebSocket.Listener { @@ -136,18 +123,17 @@ public class IotWebSocketClient implements WebSocket.Listener {
136 * 关闭连接 123 * 关闭连接
137 */ 124 */
138 public void close() { 125 public void close() {
139 - if (!connected.get() && webSocket == null) {  
140 - return;  
141 - }  
142 -  
143 try { 126 try {
144 if (webSocket != null) { 127 if (webSocket != null) {
145 - webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "客户端主动关闭")  
146 - .orTimeout(5, TimeUnit.SECONDS)  
147 - .exceptionally(e -> {  
148 - log.warn("[close][发送关闭帧失败]", e);  
149 - return null;  
150 - }); 128 + // 发送正常关闭帧,状态码 1000 表示正常关闭
  129 + webSocket.close(1000, "客户端主动关闭");
  130 + webSocket = null;
  131 + }
  132 + if (okHttpClient != null) {
  133 + // 关闭连接池和调度器
  134 + okHttpClient.dispatcher().executorService().shutdown();
  135 + okHttpClient.connectionPool().evictAll();
  136 + okHttpClient = null;
151 } 137 }
152 connected.set(false); 138 connected.set(false);
153 log.info("[close][WebSocket 客户端连接已关闭,服务器地址: {}]", serverUrl); 139 log.info("[close][WebSocket 客户端连接已关闭,服务器地址: {}]", serverUrl);
@@ -174,4 +160,50 @@ public class IotWebSocketClient implements WebSocket.Listener { @@ -174,4 +160,50 @@ public class IotWebSocketClient implements WebSocket.Listener {
174 '}'; 160 '}';
175 } 161 }
176 162
  163 + /**
  164 + * OkHttp WebSocket 监听器
  165 + */
  166 + private class IotWebSocketListener extends WebSocketListener {
  167 +
  168 + private final CountDownLatch connectLatch;
  169 + private final AtomicBoolean connectSuccess;
  170 +
  171 + public IotWebSocketListener(CountDownLatch connectLatch, AtomicBoolean connectSuccess) {
  172 + this.connectLatch = connectLatch;
  173 + this.connectSuccess = connectSuccess;
  174 + }
  175 +
  176 + @Override
  177 + public void onOpen(WebSocket webSocket, Response response) {
  178 + connected.set(true);
  179 + connectSuccess.set(true);
  180 + connectLatch.countDown();
  181 + log.info("[onOpen][WebSocket 连接已打开,服务器: {}]", serverUrl);
  182 + }
  183 +
  184 + @Override
  185 + public void onMessage(WebSocket webSocket, String text) {
  186 + log.debug("[onMessage][收到消息: {}]", text);
  187 + }
  188 +
  189 + @Override
  190 + public void onClosing(WebSocket webSocket, int code, String reason) {
  191 + connected.set(false);
  192 + log.info("[onClosing][WebSocket 正在关闭,code: {}, reason: {}]", code, reason);
  193 + }
  194 +
  195 + @Override
  196 + public void onClosed(WebSocket webSocket, int code, String reason) {
  197 + connected.set(false);
  198 + log.info("[onClosed][WebSocket 已关闭,code: {}, reason: {}]", code, reason);
  199 + }
  200 +
  201 + @Override
  202 + public void onFailure(WebSocket webSocket, Throwable t, Response response) {
  203 + connected.set(false);
  204 + connectLatch.countDown(); // 确保连接失败时也释放等待
  205 + log.error("[onFailure][WebSocket 连接失败]", t);
  206 + }
  207 + }
  208 +
177 } 209 }
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClientTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.service.rule.data.action.websocket;
  2 +
  3 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  4 +import okhttp3.Response;
  5 +import okhttp3.WebSocket;
  6 +import okhttp3.WebSocketListener;
  7 +import okhttp3.mockwebserver.MockResponse;
  8 +import okhttp3.mockwebserver.MockWebServer;
  9 +import org.jetbrains.annotations.NotNull;
  10 +import org.jetbrains.annotations.Nullable;
  11 +import org.junit.jupiter.api.AfterEach;
  12 +import org.junit.jupiter.api.BeforeEach;
  13 +import org.junit.jupiter.api.Test;
  14 +
  15 +import static org.junit.jupiter.api.Assertions.*;
  16 +
  17 +/**
  18 + * {@link IotWebSocketClient} 的单元测试
  19 + *
  20 + * @author HUIHUI
  21 + */
  22 +class IotWebSocketClientTest {
  23 +
  24 + private MockWebServer mockWebServer;
  25 +
  26 + @BeforeEach
  27 + public void setUp() throws Exception {
  28 + mockWebServer = new MockWebServer();
  29 + mockWebServer.start();
  30 + }
  31 +
  32 + @AfterEach
  33 + public void tearDown() throws Exception {
  34 + if (mockWebServer != null) {
  35 + mockWebServer.shutdown();
  36 + }
  37 + }
  38 +
  39 + /**
  40 + * 简单的 WebSocket 监听器,用于测试
  41 + */
  42 + private static class TestWebSocketListener extends WebSocketListener {
  43 + @Override
  44 + public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) {
  45 + // 连接打开
  46 + }
  47 +
  48 + @Override
  49 + public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) {
  50 + // 收到消息
  51 + }
  52 +
  53 + @Override
  54 + public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) {
  55 + webSocket.close(code, reason);
  56 + }
  57 +
  58 + @Override
  59 + public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t, @Nullable Response response) {
  60 + // 连接失败
  61 + }
  62 + }
  63 +
  64 + @Test
  65 + public void testConstructor_defaultValues() {
  66 + // 准备参数
  67 + String serverUrl = "ws://localhost:8080";
  68 +
  69 + // 调用
  70 + IotWebSocketClient client = new IotWebSocketClient(serverUrl, null, null, null);
  71 +
  72 + // 断言:验证默认值被正确设置
  73 + assertNotNull(client);
  74 + assertFalse(client.isConnected());
  75 + }
  76 +
  77 + @Test
  78 + public void testConstructor_customValues() {
  79 + // 准备参数
  80 + String serverUrl = "ws://localhost:8080";
  81 + Integer connectTimeoutMs = 3000;
  82 + Integer sendTimeoutMs = 5000;
  83 + String dataFormat = "TEXT";
  84 +
  85 + // 调用
  86 + IotWebSocketClient client = new IotWebSocketClient(serverUrl, connectTimeoutMs, sendTimeoutMs, dataFormat);
  87 +
  88 + // 断言
  89 + assertNotNull(client);
  90 + assertFalse(client.isConnected());
  91 + }
  92 +
  93 + @Test
  94 + public void testConnect_success() throws Exception {
  95 + // 准备参数:使用 MockWebServer 的 WebSocket 端点
  96 + String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
  97 + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
  98 +
  99 + // mock:设置 MockWebServer 响应 WebSocket 升级请求
  100 + mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
  101 +
  102 + // 调用
  103 + client.connect();
  104 +
  105 + // 断言
  106 + assertTrue(client.isConnected());
  107 +
  108 + // 清理
  109 + client.close();
  110 + }
  111 +
  112 + @Test
  113 + public void testConnect_alreadyConnected() throws Exception {
  114 + // 准备参数
  115 + String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
  116 + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
  117 +
  118 + // mock
  119 + mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
  120 +
  121 + // 调用:第一次连接
  122 + client.connect();
  123 + assertTrue(client.isConnected());
  124 +
  125 + // 调用:第二次连接(应该不会重复连接)
  126 + client.connect();
  127 + assertTrue(client.isConnected());
  128 +
  129 + // 清理
  130 + client.close();
  131 + }
  132 +
  133 + @Test
  134 + public void testSendMessage_success() throws Exception {
  135 + // 准备参数
  136 + String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
  137 + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
  138 +
  139 + IotDeviceMessage message = IotDeviceMessage.builder()
  140 + .deviceId(123L)
  141 + .method("thing.property.report")
  142 + .params("{\"temperature\": 25.5}")
  143 + .build();
  144 +
  145 + // mock
  146 + mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
  147 +
  148 + // 调用
  149 + client.connect();
  150 + client.sendMessage(message);
  151 +
  152 + // 断言:消息发送成功不抛异常
  153 + assertTrue(client.isConnected());
  154 +
  155 + // 清理
  156 + client.close();
  157 + }
  158 +
  159 + @Test
  160 + public void testSendMessage_notConnected() {
  161 + // 准备参数
  162 + String serverUrl = "ws://localhost:8080";
  163 + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
  164 +
  165 + IotDeviceMessage message = IotDeviceMessage.builder()
  166 + .deviceId(123L)
  167 + .method("thing.property.report")
  168 + .params("{\"temperature\": 25.5}")
  169 + .build();
  170 +
  171 + // 调用 & 断言:未连接时发送消息应抛出异常
  172 + assertThrows(IllegalStateException.class, () -> client.sendMessage(message));
  173 + }
  174 +
  175 + @Test
  176 + public void testClose_success() throws Exception {
  177 + // 准备参数
  178 + String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
  179 + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
  180 +
  181 + // mock
  182 + mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
  183 +
  184 + // 调用
  185 + client.connect();
  186 + assertTrue(client.isConnected());
  187 +
  188 + client.close();
  189 +
  190 + // 断言
  191 + assertFalse(client.isConnected());
  192 + }
  193 +
  194 + @Test
  195 + public void testClose_notConnected() {
  196 + // 准备参数
  197 + String serverUrl = "ws://localhost:8080";
  198 + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
  199 +
  200 + // 调用:关闭未连接的客户端不应抛异常
  201 + assertDoesNotThrow(client::close);
  202 + assertFalse(client.isConnected());
  203 + }
  204 +
  205 + @Test
  206 + public void testIsConnected_initialState() {
  207 + // 准备参数
  208 + String serverUrl = "ws://localhost:8080";
  209 + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
  210 +
  211 + // 断言:初始状态应为未连接
  212 + assertFalse(client.isConnected());
  213 + }
  214 +
  215 + @Test
  216 + public void testToString() {
  217 + // 准备参数
  218 + String serverUrl = "ws://localhost:8080";
  219 + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
  220 +
  221 + // 调用
  222 + String result = client.toString();
  223 +
  224 + // 断言
  225 + assertNotNull(result);
  226 + assertTrue(result.contains("serverUrl='ws://localhost:8080'"));
  227 + assertTrue(result.contains("dataFormat='JSON'"));
  228 + assertTrue(result.contains("connected=false"));
  229 + }
  230 +
  231 + @Test
  232 + public void testSendMessage_textFormat() throws Exception {
  233 + // 准备参数
  234 + String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
  235 + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "TEXT");
  236 +
  237 + IotDeviceMessage message = IotDeviceMessage.builder()
  238 + .deviceId(123L)
  239 + .method("thing.property.report")
  240 + .params("{\"temperature\": 25.5}")
  241 + .build();
  242 +
  243 + // mock
  244 + mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
  245 +
  246 + // 调用
  247 + client.connect();
  248 + client.sendMessage(message);
  249 +
  250 + // 断言:消息发送成功不抛异常
  251 + assertTrue(client.isConnected());
  252 +
  253 + // 清理
  254 + client.close();
  255 + }
  256 +
  257 +}