Commit 44838510c950098e14a86fc47795cba72fe0c0ba
1 parent
45638b35
feat:【iot】WebSocket 连接线程安全与 JDK 8 兼容
Showing
7 changed files
with
480 additions
and
66 deletions
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 | +} |