Commit 971c5a651511e244187cc6b3b0eadf9d9f29c573
1 parent
30a0175e
feat(iot): 支持设备消息透传与 JavaScript 编解码
支持按产品配置数据解析脚本及在线调试,网关统一处理透传上下行。 原始报文随业务消息保存,兼容 IoTDB、TDengine,补充协议与脚本执行回归。 简化同步消息服务、任务终态与 Modbus 事务字段,统一 encode 输出校验。
Showing
133 changed files
with
13016 additions
and
274 deletions
yudao-dependencies/pom.xml
| ... | ... | @@ -76,6 +76,7 @@ |
| 76 | 76 | <okhttp.version>4.12.0</okhttp.version> |
| 77 | 77 | <californium.version>3.14.0</californium.version> |
| 78 | 78 | <j2mod.version>3.4.0</j2mod.version> |
| 79 | + <rhino.version>1.7.15.1</rhino.version> | |
| 79 | 80 | <!-- 三方云服务相关 --> |
| 80 | 81 | <awssdk.version>2.57.3</awssdk.version> |
| 81 | 82 | <justauth.version>1.16.7</justauth.version> |
| ... | ... | @@ -660,6 +661,13 @@ |
| 660 | 661 | <version>${j2mod.version}</version> |
| 661 | 662 | </dependency> |
| 662 | 663 | |
| 664 | + <!-- JS 脚本引擎 --> | |
| 665 | + <dependency> | |
| 666 | + <groupId>org.mozilla</groupId> | |
| 667 | + <artifactId>rhino</artifactId> | |
| 668 | + <version>${rhino.version}</version> | |
| 669 | + </dependency> | |
| 670 | + | |
| 663 | 671 | <!-- 三方云服务相关 --> |
| 664 | 672 | <dependency> |
| 665 | 673 | <groupId>software.amazon.awssdk</groupId> | ... | ... |
yudao-module-iot/pom.xml
| ... | ... | @@ -18,8 +18,8 @@ |
| 18 | 18 | |
| 19 | 19 | <name>${project.artifactId}</name> |
| 20 | 20 | <description> |
| 21 | - 物联网模块 | |
| 22 | - <!-- TODO 芋艿:需要补充下说明! --> | |
| 21 | + 物联网大模块,由 biz 业务、gateway 设备网关、core 核心等组成 | |
| 22 | + 例如说:产品、物模型、设备、规则引擎、OTA 升级、设备接入等等 | |
| 23 | 23 | </description> |
| 24 | 24 | |
| 25 | 25 | </project> |
| 26 | 26 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-biz/pom.xml
| ... | ... | @@ -13,8 +13,11 @@ |
| 13 | 13 | |
| 14 | 14 | <name>${project.artifactId}</name> |
| 15 | 15 | <description> |
| 16 | - 物联网 模块,主要实现 产品管理、设备管理、协议管理等功能。 | |
| 17 | - <!-- TODO 芋艿:后续补充下 --> | |
| 16 | + iot 模块下,物联网的业务模块,提供管理后台与平台侧的处理。例如说: | |
| 17 | + 1. 产品管理:产品分类、产品、物模型、数据解析脚本 | |
| 18 | + 2. 设备管理:设备、设备分组、设备消息、设备属性、设备影子、Modbus 点位,以及 OTA 升级 | |
| 19 | + 3. 规则引擎:数据流转(数据规则、数据目的)、场景联动、告警 | |
| 20 | + 4. 数据统计,以及提供给 iot-gateway 的设备 API;设备消息与属性的时序数据可存储到 TDengine、IoTDB | |
| 18 | 21 | </description> |
| 19 | 22 | |
| 20 | 23 | <dependencies> |
| ... | ... | @@ -96,11 +99,10 @@ |
| 96 | 99 | </dependency> |
| 97 | 100 | |
| 98 | 101 | <!-- 消息队列相关 --> |
| 99 | - <!-- TODO @芋艿:临时打开 --> | |
| 100 | 102 | <dependency> |
| 101 | 103 | <groupId>org.apache.rocketmq</groupId> |
| 102 | 104 | <artifactId>rocketmq-spring-boot-starter</artifactId> |
| 103 | -<!-- <optional>true</optional>--> | |
| 105 | + <optional>true</optional> | |
| 104 | 106 | </dependency> |
| 105 | 107 | <dependency> |
| 106 | 108 | <groupId>org.springframework.kafka</groupId> | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java
| ... | ... | @@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.RequestBody; |
| 30 | 30 | import org.springframework.web.bind.annotation.RestController; |
| 31 | 31 | |
| 32 | 32 | import java.util.ArrayList; |
| 33 | +import java.util.Collections; | |
| 33 | 34 | import java.util.List; |
| 34 | 35 | import java.util.Map; |
| 35 | 36 | import java.util.Set; |
| ... | ... | @@ -74,7 +75,10 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi { |
| 74 | 75 | return success(BeanUtils.toBean(device, IotDeviceRespDTO.class, deviceDTO -> { |
| 75 | 76 | IotProductDO product = productService.getProductFromCache(deviceDTO.getProductId()); |
| 76 | 77 | if (product != null) { |
| 77 | - deviceDTO.setProtocolType(product.getProtocolType()).setSerializeType(product.getSerializeType()); | |
| 78 | + // 数据解析脚本随设备信息返回(网关按设备缓存),只在启用时返回内容 | |
| 79 | + deviceDTO.setProtocolType(product.getProtocolType()).setSerializeType(product.getSerializeType()) | |
| 80 | + .setScriptEnabled(product.getScriptEnabled()) | |
| 81 | + .setScriptContent(Boolean.TRUE.equals(product.getScriptEnabled()) ? product.getScriptContent() : null); | |
| 78 | 82 | } |
| 79 | 83 | })); |
| 80 | 84 | } |
| ... | ... | @@ -114,10 +118,8 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi { |
| 114 | 118 | } |
| 115 | 119 | } |
| 116 | 120 | // 3.3 获取启用的点位列表 |
| 117 | - List<IotDeviceModbusPointDO> pointList = pointMap.get(config.getDeviceId()); | |
| 118 | - if (CollUtil.isEmpty(pointList)) { | |
| 119 | - continue; | |
| 120 | - } | |
| 121 | + // 点位为空也返回:连接配置有效即可认证、收发透传(与脚本开关无关);没有点位时,轮询调度器本身会跳过 | |
| 122 | + List<IotDeviceModbusPointDO> pointList = pointMap.getOrDefault(config.getDeviceId(), Collections.emptyList()); | |
| 121 | 123 | |
| 122 | 124 | // 3.4 构建 IotModbusDeviceConfigRespDTO 对象 |
| 123 | 125 | IotModbusDeviceConfigRespDTO configDTO = BeanUtils.toBean(config, IotModbusDeviceConfigRespDTO.class, o -> | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.java
| ... | ... | @@ -4,10 +4,12 @@ import cn.hutool.core.collection.CollUtil; |
| 4 | 4 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 5 | 5 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 6 | 6 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| 7 | +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; | |
| 7 | 8 | import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessagePageReqVO; |
| 8 | 9 | import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessageRespPairVO; |
| 9 | 10 | import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessageRespVO; |
| 10 | 11 | import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessageSendReqVO; |
| 12 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 11 | 13 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 12 | 14 | import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; |
| 13 | 15 | import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; |
| ... | ... | @@ -24,9 +26,12 @@ import org.springframework.web.bind.annotation.*; |
| 24 | 26 | import java.util.List; |
| 25 | 27 | import java.util.Map; |
| 26 | 28 | |
| 29 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN; | |
| 30 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 27 | 31 | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; |
| 28 | 32 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; |
| 29 | 33 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; |
| 34 | +import static cn.iocoder.yudao.module.iot.enums.IotPermissionConstants.DEVICE_MESSAGE_RAW_SEND; | |
| 30 | 35 | |
| 31 | 36 | @Tag(name = "管理后台 - IoT 设备消息") |
| 32 | 37 | @RestController |
| ... | ... | @@ -40,6 +45,8 @@ public class IotDeviceMessageController { |
| 40 | 45 | private IotDeviceService deviceService; |
| 41 | 46 | @Resource |
| 42 | 47 | private IotThingModelService thingModelService; |
| 48 | + @Resource | |
| 49 | + private SecurityFrameworkService securityFrameworkService; | |
| 43 | 50 | |
| 44 | 51 | @GetMapping("/page") |
| 45 | 52 | @Operation(summary = "获得设备消息分页") |
| ... | ... | @@ -82,6 +89,12 @@ public class IotDeviceMessageController { |
| 82 | 89 | @Operation(summary = "发送消息", description = "可用于设备模拟") |
| 83 | 90 | @PreAuthorize("@ss.hasPermission('iot:device:message-send')") |
| 84 | 91 | public CommonResult<Boolean> sendDeviceMessage(@Valid @RequestBody IotDeviceMessageSendReqVO sendReqVO) { |
| 92 | + // 1. 校验透传下发的权限:透传下发是新增的危险能力(可以直接驱动 Modbus 写),需要独立的按钮权限;raw.post 不提供模拟(会改写在线状态和路由) | |
| 93 | + if (IotDeviceMessageMethodEnum.RAW_PUSH.getMethod().equals(sendReqVO.getMethod()) | |
| 94 | + && !securityFrameworkService.hasPermission(DEVICE_MESSAGE_RAW_SEND)) { | |
| 95 | + throw exception(FORBIDDEN); | |
| 96 | + } | |
| 97 | + // 2. 发送消息 | |
| 85 | 98 | deviceMessageService.sendDeviceMessage(BeanUtils.toBean(sendReqVO, IotDeviceMessage.class)); |
| 86 | 99 | return success(true); |
| 87 | 100 | } | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessagePageReqVO.java
| ... | ... | @@ -34,6 +34,9 @@ public class IotDeviceMessagePageReqVO extends PageParam { |
| 34 | 34 | @Schema(description = "标识符", example = "temperature") |
| 35 | 35 | private String identifier; |
| 36 | 36 | |
| 37 | + @Schema(description = "是否有原始帧:true 只看带原始帧(raw 非空)的消息,false 只看没有原始帧的消息", example = "true") | |
| 38 | + private Boolean raw; | |
| 39 | + | |
| 37 | 40 | @Schema(description = "时间范围", requiredMode = Schema.RequiredMode.NOT_REQUIRED) |
| 38 | 41 | @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) |
| 39 | 42 | @Size(min = 2, max = 2, message = "请选择时间范围") | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespVO.java
| ... | ... | @@ -53,4 +53,7 @@ public class IotDeviceMessageRespVO { |
| 53 | 53 | @Schema(description = "响应提示", example = "操作成功") |
| 54 | 54 | private String msg; |
| 55 | 55 | |
| 56 | + @Schema(description = "与设备实际收发的原始字节(Base64);为空表示没有原始帧", example = "AQMAAAAK") | |
| 57 | + private String raw; | |
| 58 | + | |
| 56 | 59 | } |
| 57 | 60 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.java
| ... | ... | @@ -10,6 +10,9 @@ import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; |
| 10 | 10 | import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductPageReqVO; |
| 11 | 11 | import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductRespVO; |
| 12 | 12 | import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductSaveReqVO; |
| 13 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugReqVO; | |
| 14 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugRespVO; | |
| 15 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptUpdateReqVO; | |
| 13 | 16 | import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductCategoryDO; |
| 14 | 17 | import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; |
| 15 | 18 | import cn.iocoder.yudao.module.iot.service.product.IotProductCategoryService; |
| ... | ... | @@ -69,6 +72,22 @@ public class IotProductController { |
| 69 | 72 | return success(true); |
| 70 | 73 | } |
| 71 | 74 | |
| 75 | + @PutMapping("/update-script") | |
| 76 | + @Operation(summary = "更新产品数据解析脚本", description = "部分更新:只修改本次提交的字段;保存即生效") | |
| 77 | + @PreAuthorize("@ss.hasPermission('iot:product:update')") | |
| 78 | + public CommonResult<Boolean> updateProductScript(@Valid @RequestBody IotProductScriptUpdateReqVO updateReqVO) { | |
| 79 | + productService.updateProductScript(updateReqVO); | |
| 80 | + return success(true); | |
| 81 | + } | |
| 82 | + | |
| 83 | + @PostMapping("/debug-script") | |
| 84 | + @Operation(summary = "调试产品数据解析脚本", description = "执行当前编辑内容;不保存、不发消息、不改属性") | |
| 85 | + @PreAuthorize("@ss.hasPermission('iot:product:update')") | |
| 86 | + public CommonResult<IotProductScriptDebugRespVO> debugProductScript( | |
| 87 | + @Valid @RequestBody IotProductScriptDebugReqVO debugReqVO) { | |
| 88 | + return success(productService.debugProductScript(debugReqVO)); | |
| 89 | + } | |
| 90 | + | |
| 72 | 91 | @DeleteMapping("/delete") |
| 73 | 92 | @Operation(summary = "删除产品") |
| 74 | 93 | @Parameter(name = "id", description = "编号", required = true) | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductRespVO.java
| ... | ... | @@ -77,6 +77,12 @@ public class IotProductRespVO { |
| 77 | 77 | @DictFormat(DictTypeConstants.SERIALIZE_TYPE) |
| 78 | 78 | private String serializeType; |
| 79 | 79 | |
| 80 | + @Schema(description = "数据解析脚本(JS ES5)") | |
| 81 | + private String scriptContent; | |
| 82 | + | |
| 83 | + @Schema(description = "是否启用数据解析脚本", example = "false") | |
| 84 | + private Boolean scriptEnabled; | |
| 85 | + | |
| 80 | 86 | @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) |
| 81 | 87 | @ExcelProperty("创建时间") |
| 82 | 88 | private LocalDateTime createTime; | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptDebugReqVO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.controller.admin.product.vo.product; | |
| 2 | + | |
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | |
| 4 | +import jakarta.validation.constraints.NotEmpty; | |
| 5 | +import jakarta.validation.constraints.NotNull; | |
| 6 | +import jakarta.validation.constraints.Pattern; | |
| 7 | +import lombok.Data; | |
| 8 | + | |
| 9 | +import java.util.Map; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * 管理后台 - IoT 产品数据解析脚本调试 Request VO | |
| 13 | + * <p> | |
| 14 | + * 执行当前编辑内容:不保存、不发消息、不改属性 | |
| 15 | + * | |
| 16 | + * @author 芋道源码 | |
| 17 | + */ | |
| 18 | +@Schema(description = "管理后台 - IoT 产品数据解析脚本调试 Request VO") | |
| 19 | +@Data | |
| 20 | +public class IotProductScriptDebugReqVO { | |
| 21 | + | |
| 22 | + @Schema(description = "产品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") | |
| 23 | + @NotNull(message = "产品编号不能为空") | |
| 24 | + private Long id; | |
| 25 | + | |
| 26 | + @Schema(description = "数据解析脚本(JS ES5)", requiredMode = Schema.RequiredMode.REQUIRED) | |
| 27 | + @NotEmpty(message = "脚本内容不能为空") | |
| 28 | + private String scriptContent; | |
| 29 | + | |
| 30 | + @Schema(description = "方向:decode 上行解码,encode 下行编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "decode") | |
| 31 | + @NotEmpty(message = "调试方向不能为空") | |
| 32 | + @Pattern(regexp = "decode|encode", message = "调试方向只能是 decode 或 encode") | |
| 33 | + private String direction; | |
| 34 | + | |
| 35 | + @Schema(description = "上行原始字节的 HEX(decode 时必填,允许空格)", example = "01 03 00 00") | |
| 36 | + private String inputHex; | |
| 37 | + | |
| 38 | + @Schema(description = "下行指令(encode 时必填):{method, requestId?, params?}") | |
| 39 | + private Map<String, Object> message; | |
| 40 | + | |
| 41 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptDebugRespVO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.controller.admin.product.vo.product; | |
| 2 | + | |
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | |
| 4 | +import lombok.Data; | |
| 5 | + | |
| 6 | +import java.util.Map; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * 管理后台 - IoT 产品数据解析脚本调试 Response VO | |
| 10 | + * | |
| 11 | + * @author 芋道源码 | |
| 12 | + */ | |
| 13 | +@Schema(description = "管理后台 - IoT 产品数据解析脚本调试 Response VO") | |
| 14 | +@Data | |
| 15 | +public class IotProductScriptDebugRespVO { | |
| 16 | + | |
| 17 | + @Schema(description = "是否成功", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") | |
| 18 | + private Boolean success; | |
| 19 | + | |
| 20 | + @Schema(description = "执行耗时(毫秒)", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") | |
| 21 | + private Long durationMs; | |
| 22 | + | |
| 23 | + @Schema(description = "decode:解析出的消息;为空表示没有业务消息(只记录原始消息)") | |
| 24 | + private Map<String, Object> message; | |
| 25 | + | |
| 26 | + @Schema(description = "encode:是否走标准 JSON 下发", example = "false") | |
| 27 | + private Boolean standard; | |
| 28 | + | |
| 29 | + @Schema(description = "encode:下行字节的 HEX", example = "0103000A0001") | |
| 30 | + private String outputHex; | |
| 31 | + | |
| 32 | + @Schema(description = "失败信息") | |
| 33 | + private ErrorInfo error; | |
| 34 | + | |
| 35 | + @Schema(description = "调试失败信息") | |
| 36 | + @Data | |
| 37 | + public static class ErrorInfo { | |
| 38 | + | |
| 39 | + @Schema(description = "类型:ERROR、TIMEOUT、REJECTED、OUTPUT_INVALID、ENCODE_EMPTY", example = "ERROR") | |
| 40 | + private String type; | |
| 41 | + | |
| 42 | + @Schema(description = "错误信息") | |
| 43 | + private String message; | |
| 44 | + | |
| 45 | + @Schema(description = "出错行号", example = "3") | |
| 46 | + private Integer line; | |
| 47 | + | |
| 48 | + } | |
| 49 | + | |
| 50 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptUpdateReqVO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.controller.admin.product.vo.product; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonIgnore; | |
| 4 | +import io.swagger.v3.oas.annotations.media.Schema; | |
| 5 | +import jakarta.validation.constraints.AssertTrue; | |
| 6 | +import jakarta.validation.constraints.NotNull; | |
| 7 | +import lombok.Data; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 管理后台 - IoT 产品数据解析脚本更新 Request VO | |
| 11 | + * <p> | |
| 12 | + * 部分更新:scriptContent、scriptEnabled 为 null 时不修改 | |
| 13 | + * | |
| 14 | + * @author 芋道源码 | |
| 15 | + */ | |
| 16 | +@Schema(description = "管理后台 - IoT 产品数据解析脚本更新 Request VO") | |
| 17 | +@Data | |
| 18 | +public class IotProductScriptUpdateReqVO { | |
| 19 | + | |
| 20 | + @Schema(description = "产品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") | |
| 21 | + @NotNull(message = "产品编号不能为空") | |
| 22 | + private Long id; | |
| 23 | + | |
| 24 | + @Schema(description = "数据解析脚本(JS ES5);空字符串表示清空,仅在停用时允许") | |
| 25 | + private String scriptContent; | |
| 26 | + | |
| 27 | + @Schema(description = "是否启用数据解析脚本", example = "true") | |
| 28 | + private Boolean scriptEnabled; | |
| 29 | + | |
| 30 | + @AssertTrue(message = "脚本内容和启用状态至少提交一个") | |
| 31 | + @JsonIgnore | |
| 32 | + public boolean isAnyFieldPresent() { | |
| 33 | + return scriptContent != null || scriptEnabled != null; | |
| 34 | + } | |
| 35 | + | |
| 36 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java
| ... | ... | @@ -106,4 +106,13 @@ public class IotDeviceMessageDO { |
| 106 | 106 | */ |
| 107 | 107 | private String msg; |
| 108 | 108 | |
| 109 | + // ========== 透传(raw)字段 ========== | |
| 110 | + | |
| 111 | + /** | |
| 112 | + * 与设备之间实际收发的原始字节(Base64) | |
| 113 | + * | |
| 114 | + * 只由服务端可信赋值,见 {@link IotDeviceMessage#getRaw()};为空表示没有原始帧 | |
| 115 | + */ | |
| 116 | + private String raw; | |
| 117 | + | |
| 109 | 118 | } | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/product/IotProductDO.java
| ... | ... | @@ -90,4 +90,13 @@ public class IotProductDO extends TenantBaseDO { |
| 90 | 90 | */ |
| 91 | 91 | private String serializeType; |
| 92 | 92 | |
| 93 | + /** | |
| 94 | + * 数据解析脚本(JS ES5),UTF-8 不超过 32KB | |
| 95 | + */ | |
| 96 | + private String scriptContent; | |
| 97 | + /** | |
| 98 | + * 是否启用数据解析脚本 | |
| 99 | + */ | |
| 100 | + private Boolean scriptEnabled; | |
| 101 | + | |
| 93 | 102 | } |
| 94 | 103 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodec.java
| ... | ... | @@ -41,7 +41,8 @@ public class IotDeviceMessageIoTDBCodec { |
| 41 | 41 | .setParams(encodeString(toJsonString(message.getParams()))) |
| 42 | 42 | .setData(encodeString(toJsonString(message.getData()))) |
| 43 | 43 | .setMsg(encodeString(message.getMsg())).setReportTime(encodeString(message.getReportTime())) |
| 44 | - .setTenantId(encodeString(message.getTenantId())).setCode(encodeString(message.getCode())); | |
| 44 | + .setTenantId(encodeString(message.getTenantId())).setCode(encodeString(message.getCode())) | |
| 45 | + .setRaw(encodeString(message.getRaw())); | |
| 45 | 46 | } |
| 46 | 47 | |
| 47 | 48 | public static IotDeviceMessageDO decode(IotDeviceMessageIoTDBRow row) { |
| ... | ... | @@ -57,7 +58,8 @@ public class IotDeviceMessageIoTDBCodec { |
| 57 | 58 | .setMsg(decodeString(row.getMsg())) |
| 58 | 59 | .setReportTime(reportTime != null ? Long.valueOf(reportTime) : null) |
| 59 | 60 | .setTenantId(tenantId != null ? Long.valueOf(tenantId) : null) |
| 60 | - .setCode(code != null ? Integer.valueOf(code) : null); | |
| 61 | + .setCode(code != null ? Integer.valueOf(code) : null) | |
| 62 | + .setRaw(decodeString(row.getRaw())); | |
| 61 | 63 | } |
| 62 | 64 | |
| 63 | 65 | /** | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBDAO.java
| ... | ... | @@ -50,6 +50,7 @@ public class IotDeviceMessageIoTDBDAO implements IotDeviceMessageDAO { |
| 50 | 50 | .put("method", "STRING").put("identifier", "STRING").put("request_id", "STRING").put("server_id", "STRING") |
| 51 | 51 | .put("params", "STRING").put("data", "STRING").put("msg", "STRING") |
| 52 | 52 | .put("report_time", "STRING").put("tenant_id", "STRING").put("code", "STRING") |
| 53 | + .put("raw", "STRING") | |
| 53 | 54 | .build(); |
| 54 | 55 | |
| 55 | 56 | @Resource |
| ... | ... | @@ -152,15 +153,20 @@ public class IotDeviceMessageIoTDBDAO implements IotDeviceMessageDAO { |
| 152 | 153 | Integer upstream = reqVO.getUpstream() != null ? encodeBoolean(reqVO.getUpstream()) : null; |
| 153 | 154 | Integer reply = reqVO.getReply() != null ? encodeBoolean(reqVO.getReply()) : null; |
| 154 | 155 | String identifier = StrUtil.isNotEmpty(reqVO.getIdentifier()) ? encodeString(reqVO.getIdentifier()) : null; |
| 156 | + // 是否有原始帧:true 为 v: 开头;false 为 n: | |
| 157 | + Boolean raw = reqVO.getRaw(); | |
| 158 | + String rawValuePattern = PREFIX_VALUE + "%"; | |
| 155 | 159 | try { |
| 156 | 160 | // 2.1 查询总数 |
| 157 | - Long total = deviceMessageMapper.selectCount(deviceId, method, upstream, reply, identifier); | |
| 161 | + Long total = deviceMessageMapper.selectCount(deviceId, method, upstream, reply, identifier, | |
| 162 | + raw, PREFIX_NULL, rawValuePattern); | |
| 158 | 163 | if (total == null || total == 0) { |
| 159 | 164 | return PageResult.empty(); |
| 160 | 165 | } |
| 161 | 166 | // 2.2 查询列表:pageSize 为 -1 时,不分页 |
| 162 | 167 | boolean pageable = ObjUtil.notEqual(reqVO.getPageSize(), PageParam.PAGE_SIZE_NONE); |
| 163 | 168 | List<IotDeviceMessageIoTDBRow> rows = deviceMessageMapper.selectList(deviceId, method, upstream, reply, identifier, |
| 169 | + raw, PREFIX_NULL, rawValuePattern, | |
| 164 | 170 | pageable ? PageUtils.getStart(reqVO) : null, pageable ? reqVO.getPageSize() : null); |
| 165 | 171 | return new PageResult<>(convertList(rows, IotDeviceMessageIoTDBCodec::decode), total); |
| 166 | 172 | } catch (Exception exception) { | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBMapper.java
| ... | ... | @@ -32,11 +32,21 @@ public interface IotDeviceMessageIoTDBMapper { |
| 32 | 32 | |
| 33 | 33 | void insert(IotDeviceMessageIoTDBRow row); |
| 34 | 34 | |
| 35 | + /** | |
| 36 | + * 分页查询 | |
| 37 | + * | |
| 38 | + * @param raw 是否有原始帧:true 为 raw 匹配 rawValuePattern;false 为 raw 等于 rawNull | |
| 39 | + * @param rawNull raw 为 null 的存储值 | |
| 40 | + * @param rawValuePattern raw 非 null 的存储值的 LIKE 模式 | |
| 41 | + */ | |
| 35 | 42 | List<IotDeviceMessageIoTDBRow> selectList(@Param("deviceId") String deviceId, |
| 36 | 43 | @Param("method") String method, |
| 37 | 44 | @Param("upstream") Integer upstream, |
| 38 | 45 | @Param("reply") Integer reply, |
| 39 | 46 | @Param("identifier") String identifier, |
| 47 | + @Param("raw") Boolean raw, | |
| 48 | + @Param("rawNull") String rawNull, | |
| 49 | + @Param("rawValuePattern") String rawValuePattern, | |
| 40 | 50 | @Param("offset") Integer offset, |
| 41 | 51 | @Param("limit") Integer limit); |
| 42 | 52 | |
| ... | ... | @@ -44,7 +54,10 @@ public interface IotDeviceMessageIoTDBMapper { |
| 44 | 54 | @Param("method") String method, |
| 45 | 55 | @Param("upstream") Integer upstream, |
| 46 | 56 | @Param("reply") Integer reply, |
| 47 | - @Param("identifier") String identifier); | |
| 57 | + @Param("identifier") String identifier, | |
| 58 | + @Param("raw") Boolean raw, | |
| 59 | + @Param("rawNull") String rawNull, | |
| 60 | + @Param("rawValuePattern") String rawValuePattern); | |
| 48 | 61 | |
| 49 | 62 | List<IotDeviceMessageIoTDBRow> selectListByRequestIdsAndReply(@Param("deviceId") String deviceId, |
| 50 | 63 | @Param("requestIds") Collection<String> requestIds, | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBRow.java
| ... | ... | @@ -38,15 +38,49 @@ public class IotDeviceMessageIoTDBRow { |
| 38 | 38 | |
| 39 | 39 | // ========== 以下字段,统一使用 n:/v: 编码 ========== |
| 40 | 40 | |
| 41 | + /** | |
| 42 | + * 请求方法 | |
| 43 | + */ | |
| 41 | 44 | private String method; |
| 45 | + /** | |
| 46 | + * 标识符 | |
| 47 | + */ | |
| 42 | 48 | private String identifier; |
| 49 | + /** | |
| 50 | + * 请求编号 | |
| 51 | + */ | |
| 43 | 52 | private String requestId; |
| 53 | + /** | |
| 54 | + * 服务编号,该消息由哪个 server 发送 | |
| 55 | + */ | |
| 44 | 56 | private String serverId; |
| 57 | + /** | |
| 58 | + * 请求参数(JSON) | |
| 59 | + */ | |
| 45 | 60 | private String params; |
| 61 | + /** | |
| 62 | + * 响应结果(JSON) | |
| 63 | + */ | |
| 46 | 64 | private String data; |
| 65 | + /** | |
| 66 | + * 响应提示 | |
| 67 | + */ | |
| 47 | 68 | private String msg; |
| 69 | + /** | |
| 70 | + * 上报时间戳(毫秒) | |
| 71 | + */ | |
| 48 | 72 | private String reportTime; |
| 73 | + /** | |
| 74 | + * 租户编号 | |
| 75 | + */ | |
| 49 | 76 | private String tenantId; |
| 77 | + /** | |
| 78 | + * 响应错误码 | |
| 79 | + */ | |
| 50 | 80 | private String code; |
| 81 | + /** | |
| 82 | + * 原始帧(Base64) | |
| 83 | + */ | |
| 84 | + private String raw; | |
| 51 | 85 | |
| 52 | 86 | } | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDevicePropertyDAO.java
| ... | ... | @@ -8,7 +8,6 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; |
| 8 | 8 | import java.util.List; |
| 9 | 9 | import java.util.Map; |
| 10 | 10 | |
| 11 | -// TODO DONE @AI:storage 包下,是不是加个 package-info.java,说明这个包的作用?已新增 package-info.java | |
| 12 | 11 | /** |
| 13 | 12 | * IoT 设备【属性】历史数据的存储 DAO 接口 |
| 14 | 13 | * | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java
| ... | ... | @@ -51,6 +51,10 @@ public interface ErrorCodeConstants { |
| 51 | 51 | ErrorCode DEVICE_PROPERTY_FIELD_NAME_INVALID = new ErrorCode(1_050_003_301, "属性标识符({}) 不合法,无法作为存储字段"); |
| 52 | 52 | ErrorCode DEVICE_PROPERTY_FIELD_NAME_RESERVED = new ErrorCode(1_050_003_302, "属性标识符({}) 与存储保留字段({}) 冲突"); |
| 53 | 53 | ErrorCode DEVICE_PROPERTY_FIELD_NAME_DUPLICATE = new ErrorCode(1_050_003_303, "属性标识符({}) 与({}) 的存储字段({}) 重复"); |
| 54 | + // 设备消息相关错误码 1-050-003-400 | |
| 55 | + ErrorCode DEVICE_MESSAGE_RAW_PAYLOAD_INVALID = new ErrorCode(1_050_003_400, "透传载荷不合法:{}"); | |
| 56 | + ErrorCode DEVICE_MESSAGE_RAW_PROTOCOL_NOT_SUPPORTED = new ErrorCode(1_050_003_401, "协议({}) 不支持透传下发"); | |
| 57 | + ErrorCode DEVICE_MESSAGE_RAW_METHOD_RESERVED = new ErrorCode(1_050_003_402, "透传上报只能由网关创建"); | |
| 54 | 58 | |
| 55 | 59 | // ========== 产品分类 1-050-004-000 ========== |
| 56 | 60 | ErrorCode PRODUCT_CATEGORY_NOT_EXISTS = new ErrorCode(1_050_004_000, "产品分类不存在"); |
| ... | ... | @@ -96,6 +100,12 @@ public interface ErrorCodeConstants { |
| 96 | 100 | ErrorCode OTA_TASK_RECORD_CANCEL_FAIL_STATUS_ERROR = new ErrorCode(1_050_008_201, "取消 OTA 升级记录失败,原因:记录状态不是进行中"); |
| 97 | 101 | ErrorCode OTA_TASK_RECORD_UPDATE_PROGRESS_FAIL_NO_EXISTS = new ErrorCode(1_050_008_202, "更新 OTA 升级记录进度失败,原因:该设备没有进行中的升级记录"); |
| 98 | 102 | |
| 103 | + // ========== IoT 产品数据解析脚本 1-050-009-000 ========== | |
| 104 | + ErrorCode PRODUCT_SCRIPT_TOO_LONG = new ErrorCode(1_050_009_000, "数据解析脚本不能超过 {} 字节"); | |
| 105 | + ErrorCode PRODUCT_SCRIPT_COMPILE_FAILED = new ErrorCode(1_050_009_001, "数据解析脚本语法错误:{}"); | |
| 106 | + ErrorCode PRODUCT_SCRIPT_EMPTY = new ErrorCode(1_050_009_002, "启用数据解析脚本时,脚本内容不能为空"); | |
| 107 | + ErrorCode PRODUCT_SCRIPT_DEBUG_BUSY = new ErrorCode(1_050_009_003, "脚本调试繁忙或已熔断,请稍后再试"); | |
| 108 | + | |
| 99 | 109 | // ========== IoT 数据流转规则 1-050-010-000 ========== |
| 100 | 110 | ErrorCode DATA_RULE_NOT_EXISTS = new ErrorCode(1_050_010_000, "数据流转规则不存在"); |
| 101 | 111 | ErrorCode DATA_RULE_NAME_EXISTS = new ErrorCode(1_050_010_001, "数据流转规则名称已存在"); | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/IotPermissionConstants.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.enums; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * IoT 权限标识常量 | |
| 5 | + * | |
| 6 | + * 只收录业务代码中动态校验的权限标识,与菜单配置的权限标识保持一致;接口上的 {@code @PreAuthorize} 仍直接书写权限标识 | |
| 7 | + * | |
| 8 | + * @author 芋道源码 | |
| 9 | + */ | |
| 10 | +public interface IotPermissionConstants { | |
| 11 | + | |
| 12 | + /** | |
| 13 | + * 设备消息的透传下发(raw.push):可以直接驱动 Modbus 写,需要独立的按钮权限 | |
| 14 | + */ | |
| 15 | + String DEVICE_MESSAGE_RAW_SEND = "iot:device:message-raw-send"; | |
| 16 | + | |
| 17 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageConfiguration.java
| ... | ... | @@ -3,7 +3,6 @@ package cn.iocoder.yudao.module.iot.framework.storage.config; |
| 3 | 3 | import org.springframework.boot.context.properties.EnableConfigurationProperties; |
| 4 | 4 | import org.springframework.context.annotation.Configuration; |
| 5 | 5 | |
| 6 | -// TODO DONE @AI:按照项目的习惯,这个是不是不用写单测?已移除重复的装配单测 IotStorageConfigurationTest,实际装配由实库验收覆盖 | |
| 7 | 6 | /** |
| 8 | 7 | * IoT 时序存储的配置类 |
| 9 | 8 | * | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageInitRunner.java
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java
| ... | ... | @@ -16,6 +16,7 @@ import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceM |
| 16 | 16 | import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageReqVO; |
| 17 | 17 | import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageSummaryByDateRespVO; |
| 18 | 18 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 19 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 19 | 20 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 20 | 21 | import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; |
| 21 | 22 | import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; |
| ... | ... | @@ -25,11 +26,13 @@ import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqD |
| 25 | 26 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; |
| 26 | 27 | import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; |
| 27 | 28 | import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; |
| 29 | +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; | |
| 28 | 30 | import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; |
| 29 | 31 | import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; |
| 30 | 32 | import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; |
| 31 | 33 | import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService; |
| 32 | 34 | import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; |
| 35 | +import cn.iocoder.yudao.module.iot.service.product.IotProductService; | |
| 33 | 36 | import com.google.common.base.Objects; |
| 34 | 37 | import jakarta.annotation.Resource; |
| 35 | 38 | import lombok.extern.slf4j.Slf4j; |
| ... | ... | @@ -46,6 +49,10 @@ import java.util.Map; |
| 46 | 49 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 47 | 50 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; |
| 48 | 51 | import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_DOWNSTREAM_FAILED_SERVER_ID_NULL; |
| 52 | +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_MESSAGE_RAW_METHOD_RESERVED; | |
| 53 | +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_MESSAGE_RAW_PAYLOAD_INVALID; | |
| 54 | +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_MESSAGE_RAW_PROTOCOL_NOT_SUPPORTED; | |
| 55 | +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.PRODUCT_NOT_EXISTS; | |
| 49 | 56 | |
| 50 | 57 | /** |
| 51 | 58 | * IoT 设备消息 Service 实现类 |
| ... | ... | @@ -67,6 +74,9 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 67 | 74 | @Resource |
| 68 | 75 | @Lazy // 延迟加载,避免循环依赖 |
| 69 | 76 | private IotDeviceShadowService deviceShadowService; |
| 77 | + @Resource | |
| 78 | + @Lazy // 延迟加载,避免循环依赖 | |
| 79 | + private IotProductService productService; | |
| 70 | 80 | |
| 71 | 81 | @Resource |
| 72 | 82 | private IotDeviceMessageDAO deviceMessageDAO; |
| ... | ... | @@ -116,6 +126,12 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 116 | 126 | |
| 117 | 127 | @Override |
| 118 | 128 | public IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId) { |
| 129 | + // 0. 透传:raw.post 只能由网关创建;raw.push 校验后改写为 raw,网关从 raw 取字节;其它消息不能携带 raw | |
| 130 | + byte[] rawPayload = validateRawMessage(message, device); | |
| 131 | + message.setRaw(rawPayload != null ? IotDeviceMessageUtils.encodeRaw(rawPayload) : null); | |
| 132 | + if (rawPayload != null) { | |
| 133 | + message.setParams(null); | |
| 134 | + } | |
| 119 | 135 | // 1. 补充信息 |
| 120 | 136 | appendDeviceMessage(message, device); |
| 121 | 137 | |
| ... | ... | @@ -147,6 +163,44 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 147 | 163 | } |
| 148 | 164 | |
| 149 | 165 | /** |
| 166 | + * 校验透传消息:raw.post 只能由网关创建;raw.push 校验 params.payload 与产品协议 | |
| 167 | + * | |
| 168 | + * @param message 消息 | |
| 169 | + * @param device 设备信息 | |
| 170 | + * @return raw.push 请求的载荷;其它消息返回 null | |
| 171 | + */ | |
| 172 | + private byte[] validateRawMessage(IotDeviceMessage message, IotDeviceDO device) { | |
| 173 | + // 1.1 raw.post 只能由网关创建 | |
| 174 | + if (IotDeviceMessageMethodEnum.RAW_POST.getMethod().equals(message.getMethod())) { | |
| 175 | + throw exception(DEVICE_MESSAGE_RAW_METHOD_RESERVED); | |
| 176 | + } | |
| 177 | + // 1.2 只校验 raw.push 请求,其它消息直接跳过 | |
| 178 | + if (!Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.RAW_PUSH.getMethod()) | |
| 179 | + || IotDeviceMessageUtils.isReplyMessage(message)) { | |
| 180 | + return null; | |
| 181 | + } | |
| 182 | + | |
| 183 | + // 2. 校验产品协议:HTTP、CoAP 没有长连接,不支持透传下发 | |
| 184 | + IotProductDO product = productService.getProductFromCache(device.getProductId()); | |
| 185 | + if (product == null) { | |
| 186 | + throw exception(PRODUCT_NOT_EXISTS); | |
| 187 | + } | |
| 188 | + IotProtocolTypeEnum protocol = IotProtocolTypeEnum.of(product.getProtocolType()); | |
| 189 | + if (protocol == IotProtocolTypeEnum.HTTP || protocol == IotProtocolTypeEnum.COAP) { | |
| 190 | + throw exception(DEVICE_MESSAGE_RAW_PROTOCOL_NOT_SUPPORTED, product.getProtocolType()); | |
| 191 | + } | |
| 192 | + | |
| 193 | + // 3. 校验载荷:按产品协议的字节规则,与网关 encode 输出、脚本调试是同一份 | |
| 194 | + try { | |
| 195 | + byte[] payload = IotDeviceMessageUtils.decodeRawPayload(message.getParams(), IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES); | |
| 196 | + IotDeviceMessageUtils.validateRawPayload(protocol, payload); | |
| 197 | + return payload; | |
| 198 | + } catch (IllegalArgumentException e) { | |
| 199 | + throw exception(DEVICE_MESSAGE_RAW_PAYLOAD_INVALID, e.getMessage()); | |
| 200 | + } | |
| 201 | + } | |
| 202 | + | |
| 203 | + /** | |
| 150 | 204 | * 补充消息的后端字段 |
| 151 | 205 | * |
| 152 | 206 | * @param message 消息 |
| ... | ... | @@ -180,8 +234,10 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 180 | 234 | getSelf().createDeviceLogAsync(message); |
| 181 | 235 | |
| 182 | 236 | // 3. 回复消息。前提:非 _reply 消息、非禁用回复的消息 |
| 237 | + // 另外:带原始帧(raw)的是网关透传解析出的消息,不自动回复;设备经标准路径上报的 raw 由网关清空,不能伪造 | |
| 183 | 238 | if (IotDeviceMessageUtils.isReplyMessage(message) |
| 184 | 239 | || IotDeviceMessageMethodEnum.isReplyDisabled(message.getMethod()) |
| 240 | + || message.getRaw() != null | |
| 185 | 241 | || StrUtil.isEmpty(message.getServerId())) { |
| 186 | 242 | return; |
| 187 | 243 | } |
| ... | ... | @@ -335,6 +391,8 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 335 | 391 | |
| 336 | 392 | @Override |
| 337 | 393 | public PageResult<IotDeviceMessageDO> getDeviceMessagePage(IotDeviceMessagePageReqVO pageReqVO) { |
| 394 | + // 先校验设备属于当前租户(MySQL 租户插件生效);时序库查询本身只按 deviceId 限定 | |
| 395 | + deviceService.validateDeviceExists(pageReqVO.getDeviceId()); | |
| 338 | 396 | return deviceMessageDAO.selectPage(pageReqVO); |
| 339 | 397 | } |
| 340 | 398 | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductService.java
| ... | ... | @@ -3,6 +3,9 @@ package cn.iocoder.yudao.module.iot.service.product; |
| 3 | 3 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductPageReqVO; |
| 5 | 5 | import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductSaveReqVO; |
| 6 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugReqVO; | |
| 7 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugRespVO; | |
| 8 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptUpdateReqVO; | |
| 6 | 9 | import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; |
| 7 | 10 | import jakarta.validation.Valid; |
| 8 | 11 | |
| ... | ... | @@ -37,6 +40,21 @@ public interface IotProductService { |
| 37 | 40 | void updateProduct(@Valid IotProductSaveReqVO updateReqVO); |
| 38 | 41 | |
| 39 | 42 | /** |
| 43 | + * 更新产品数据解析脚本:只修改非 null 的字段(脚本内容、是否启用),最后保存者生效 | |
| 44 | + * | |
| 45 | + * @param updateReqVO 更新信息 | |
| 46 | + */ | |
| 47 | + void updateProductScript(@Valid IotProductScriptUpdateReqVO updateReqVO); | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * 调试产品数据解析脚本:执行当前编辑内容,不保存、不发消息、不改属性 | |
| 51 | + * | |
| 52 | + * @param debugReqVO 调试信息 | |
| 53 | + * @return 调试结果 | |
| 54 | + */ | |
| 55 | + IotProductScriptDebugRespVO debugProductScript(@Valid IotProductScriptDebugReqVO debugReqVO); | |
| 56 | + | |
| 57 | + /** | |
| 40 | 58 | * 删除产品 |
| 41 | 59 | * |
| 42 | 60 | * @param id 编号 | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.service.product; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.bean.BeanUtil; | |
| 3 | 4 | import cn.hutool.core.collection.CollUtil; |
| 5 | +import cn.hutool.core.map.MapUtil; | |
| 6 | +import cn.hutool.core.util.HexUtil; | |
| 7 | +import cn.hutool.core.util.ObjUtil; | |
| 8 | +import cn.hutool.core.util.ReUtil; | |
| 9 | +import cn.hutool.core.util.StrUtil; | |
| 4 | 10 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 5 | 11 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| 6 | 12 | import cn.hutool.core.util.IdUtil; |
| 7 | 13 | import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; |
| 8 | 14 | import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductPageReqVO; |
| 9 | 15 | import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductSaveReqVO; |
| 16 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugReqVO; | |
| 17 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugRespVO; | |
| 18 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptUpdateReqVO; | |
| 19 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 20 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 21 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptException; | |
| 22 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptExecutor; | |
| 23 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptUtils; | |
| 24 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 25 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 26 | +import cn.iocoder.yudao.module.iot.core.script.rhino.IotRhinoScriptExecutor; | |
| 27 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 10 | 28 | import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; |
| 11 | 29 | import cn.iocoder.yudao.module.iot.dal.mysql.product.IotProductMapper; |
| 12 | 30 | import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; |
| ... | ... | @@ -14,6 +32,8 @@ import cn.iocoder.yudao.module.iot.enums.product.IotProductStatusEnum; |
| 14 | 32 | import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; |
| 15 | 33 | import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; |
| 16 | 34 | import com.baomidou.dynamic.datasource.annotation.DSTransactional; |
| 35 | +import jakarta.annotation.PostConstruct; | |
| 36 | +import jakarta.annotation.PreDestroy; | |
| 17 | 37 | import jakarta.annotation.Resource; |
| 18 | 38 | import lombok.extern.slf4j.Slf4j; |
| 19 | 39 | import org.springframework.cache.annotation.CacheEvict; |
| ... | ... | @@ -25,9 +45,12 @@ import org.springframework.validation.annotation.Validated; |
| 25 | 45 | import java.time.LocalDateTime; |
| 26 | 46 | import java.util.Collection; |
| 27 | 47 | import java.util.List; |
| 48 | +import java.util.Map; | |
| 28 | 49 | import java.util.Objects; |
| 50 | +import java.util.concurrent.TimeUnit; | |
| 29 | 51 | |
| 30 | 52 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 53 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; | |
| 31 | 54 | import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; |
| 32 | 55 | |
| 33 | 56 | /** |
| ... | ... | @@ -40,6 +63,23 @@ import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; |
| 40 | 63 | @Validated |
| 41 | 64 | public class IotProductServiceImpl implements IotProductService { |
| 42 | 65 | |
| 66 | + /** | |
| 67 | + * 数据解析脚本的最大字节数(UTF-8) | |
| 68 | + */ | |
| 69 | + private static final int SCRIPT_MAX_BYTES = 32 * 1024; | |
| 70 | + /** | |
| 71 | + * 调试时 ctx.deviceName 的固定值:ctx 中不带任何真实设备的信息或秘密 | |
| 72 | + */ | |
| 73 | + private static final String DEBUG_DEVICE_NAME = "debug-device"; | |
| 74 | + /** | |
| 75 | + * 调试的截止时间:覆盖排队与执行,也是请求线程的最长等待时间 | |
| 76 | + */ | |
| 77 | + private static final long DEBUG_DEADLINE_MILLIS = 2000; | |
| 78 | + /** | |
| 79 | + * 保存时语法检查的最长等待时间 | |
| 80 | + */ | |
| 81 | + private static final long COMPILE_TIMEOUT_MILLIS = 2000; | |
| 82 | + | |
| 43 | 83 | @Resource |
| 44 | 84 | private IotProductMapper productMapper; |
| 45 | 85 | |
| ... | ... | @@ -49,6 +89,24 @@ public class IotProductServiceImpl implements IotProductService { |
| 49 | 89 | @Lazy // 延迟加载,避免循环依赖 |
| 50 | 90 | private IotDevicePropertyService devicePropertyDataService; |
| 51 | 91 | |
| 92 | + /** | |
| 93 | + * 调试池:2 个线程、队列 4;请求线程阻塞等待结果,最长到截止时间 | |
| 94 | + * <p> | |
| 95 | + * Rhino 下死循环会被预算中断、线程可复用;不经过检查点的原生长操作仍可能卡住线程 → 熔断调试功能,需要重启 biz | |
| 96 | + */ | |
| 97 | + private IotScriptExecutor debugScriptExecutor; | |
| 98 | + | |
| 99 | + @PostConstruct | |
| 100 | + public void initDebugScriptExecutor() { | |
| 101 | + debugScriptExecutor = new IotRhinoScriptExecutor(new IotRhinoScriptExecutor.Options().setName("iot-script-debug") | |
| 102 | + .setPoolSize(2).setQueueCapacity(4).setMaxStuckThreads(2)); | |
| 103 | + } | |
| 104 | + | |
| 105 | + @PreDestroy | |
| 106 | + public void destroyDebugScriptExecutor() { | |
| 107 | + debugScriptExecutor.shutdown(); | |
| 108 | + } | |
| 109 | + | |
| 52 | 110 | @Override |
| 53 | 111 | public Long createProduct(IotProductSaveReqVO createReqVO) { |
| 54 | 112 | // 1. 校验 ProductKey |
| ... | ... | @@ -204,4 +262,157 @@ public class IotProductServiceImpl implements IotProductService { |
| 204 | 262 | } |
| 205 | 263 | } |
| 206 | 264 | |
| 265 | + // ========== 数据解析脚本 ========== | |
| 266 | + | |
| 267 | + @Override | |
| 268 | + @CacheEvict(value = RedisKeyConstants.PRODUCT, key = "#updateReqVO.id") | |
| 269 | + public void updateProductScript(IotProductScriptUpdateReqVO updateReqVO) { | |
| 270 | + // 1.1 校验存在 | |
| 271 | + IotProductDO product = validateProductExists(updateReqVO.getId()); | |
| 272 | + // 1.2 校验脚本:UTF-8 不超过 32KB;语法只编译、不执行 | |
| 273 | + if (updateReqVO.getScriptContent() != null) { | |
| 274 | + validateProductScript(updateReqVO.getScriptContent()); | |
| 275 | + } | |
| 276 | + // 1.3 启用时内容不能为空:没提交的字段取库里的值 | |
| 277 | + String content = ObjUtil.defaultIfNull(updateReqVO.getScriptContent(), product.getScriptContent()); | |
| 278 | + Boolean enabled = ObjUtil.defaultIfNull(updateReqVO.getScriptEnabled(), product.getScriptEnabled()); | |
| 279 | + if (Boolean.TRUE.equals(enabled) && StrUtil.isBlank(content)) { | |
| 280 | + throw exception(PRODUCT_SCRIPT_EMPTY); | |
| 281 | + } | |
| 282 | + | |
| 283 | + // 2. 部分更新:只写本次提交的字段(null 跳过),保存内容不会顺带改启用状态 | |
| 284 | + IotProductDO updateObj = BeanUtils.toBean(updateReqVO, IotProductDO.class); | |
| 285 | + productMapper.updateById(updateObj); | |
| 286 | + } | |
| 287 | + | |
| 288 | + private void validateProductScriptLength(String content) { | |
| 289 | + if (StrUtil.utf8Bytes(content).length > SCRIPT_MAX_BYTES) { | |
| 290 | + throw exception(PRODUCT_SCRIPT_TOO_LONG, SCRIPT_MAX_BYTES); | |
| 291 | + } | |
| 292 | + } | |
| 293 | + | |
| 294 | + private void validateProductScript(String content) { | |
| 295 | + validateProductScriptLength(content); | |
| 296 | + // 空字符串表示清空 | |
| 297 | + if (StrUtil.isEmpty(content)) { | |
| 298 | + return; | |
| 299 | + } | |
| 300 | + // 在调试池线程上编译:只做语法检查,不执行任何用户代码 | |
| 301 | + IotScriptResult result = debugScriptExecutor.compileOnly(content, COMPILE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); | |
| 302 | + if (result.getStatus() == IotScriptResult.Status.REJECTED || result.isTimeout()) { | |
| 303 | + throw exception(PRODUCT_SCRIPT_DEBUG_BUSY); | |
| 304 | + } | |
| 305 | + if (!result.isSuccess()) { | |
| 306 | + throw exception(PRODUCT_SCRIPT_COMPILE_FAILED, result.getLineNumber() > 0 | |
| 307 | + ? StrUtil.format("第 {} 行:{}", result.getLineNumber(), result.getMessage()) : result.getMessage()); | |
| 308 | + } | |
| 309 | + } | |
| 310 | + | |
| 311 | + @Override | |
| 312 | + public IotProductScriptDebugRespVO debugProductScript(IotProductScriptDebugReqVO debugReqVO) { | |
| 313 | + // 1.1 校验当前租户内的产品 | |
| 314 | + IotProductDO product = validateProductExists(debugReqVO.getId()); | |
| 315 | + // 1.2 校验脚本长度 | |
| 316 | + validateProductScriptLength(debugReqVO.getScriptContent()); | |
| 317 | + // 1.3 校验调试输入,构建 ctx | |
| 318 | + String ctx = buildDebugContext(debugReqVO, product); | |
| 319 | + | |
| 320 | + // 2.1 执行:与网关相同的运行时和预算;请求线程阻塞等待,最长到截止时间 | |
| 321 | + IotScriptResult result = debugScriptExecutor.execute(IotScriptSource.ofDebug(debugReqVO.getScriptContent()), | |
| 322 | + debugReqVO.getDirection(), ctx, System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(DEBUG_DEADLINE_MILLIS)); | |
| 323 | + // 2.2 构建结果:与网关相同的输出校验 | |
| 324 | + return buildDebugResult(debugReqVO, product, result); | |
| 325 | + } | |
| 326 | + | |
| 327 | + /** | |
| 328 | + * 构建调试的 ctx:与网关的结构一致,但设备名固定为 {@link #DEBUG_DEVICE_NAME},不带任何真实设备的信息或秘密 | |
| 329 | + * | |
| 330 | + * @param reqVO 调试请求 | |
| 331 | + * @param product 产品 | |
| 332 | + * @return ctx(JSON) | |
| 333 | + */ | |
| 334 | + private static String buildDebugContext(IotProductScriptDebugReqVO reqVO, IotProductDO product) { | |
| 335 | + // 1. decode:上行原始字节 | |
| 336 | + if (IotScriptUtils.FUNCTION_DECODE.equals(reqVO.getDirection())) { | |
| 337 | + // 1.1 校验 HEX(允许空格) | |
| 338 | + String hex = StrUtil.cleanBlank(reqVO.getInputHex()); | |
| 339 | + if (StrUtil.isEmpty(hex) || hex.length() % 2 != 0 || !ReUtil.isMatch("[0-9a-fA-F]+", hex)) { | |
| 340 | + throw invalidParamException("上行数据必须是偶数位的 HEX"); | |
| 341 | + } | |
| 342 | + // 1.2 校验长度:与透传上报的上限一致 | |
| 343 | + byte[] bytes = HexUtil.decodeHex(hex); | |
| 344 | + if (bytes.length > IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES) { | |
| 345 | + throw invalidParamException("上行数据不能超过 {} 字节", IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES); | |
| 346 | + } | |
| 347 | + // 1.3 构建 decode 的 ctx | |
| 348 | + return IotScriptUtils.buildDecodeContext(product.getProtocolType(), product.getProductKey(), | |
| 349 | + DEBUG_DEVICE_NAME, System.currentTimeMillis(), bytes, null); | |
| 350 | + } | |
| 351 | + | |
| 352 | + // 2.1 encode:与网关一致,只编码 property.set / service.invoke 请求 | |
| 353 | + Map<String, Object> message = reqVO.getMessage(); | |
| 354 | + if (message == null) { | |
| 355 | + throw invalidParamException("下行指令不能为空"); | |
| 356 | + } | |
| 357 | + IotDeviceMessage request = IotDeviceMessage.requestOf( | |
| 358 | + StrUtil.blankToDefault(MapUtil.getStr(message, "requestId"), "debug"), | |
| 359 | + MapUtil.getStr(message, "method"), message.get("params")); | |
| 360 | + if (!IotDeviceMessageUtils.isCommandRequest(request)) { | |
| 361 | + throw invalidParamException("下行编码只支持 thing.property.set、thing.service.invoke"); | |
| 362 | + } | |
| 363 | + // 2.2 构建 encode 的 ctx | |
| 364 | + return IotScriptUtils.buildEncodeContext(product.getProtocolType(), product.getProductKey(), | |
| 365 | + DEBUG_DEVICE_NAME, request); | |
| 366 | + } | |
| 367 | + | |
| 368 | + /** | |
| 369 | + * 构建调试结果:输出按与网关相同的规则校验 | |
| 370 | + * | |
| 371 | + * @param reqVO 调试请求 | |
| 372 | + * @param product 产品:encode 输出的透传字节,按产品协议的规则校验 | |
| 373 | + * @param result 脚本的执行结果 | |
| 374 | + * @return 调试结果 | |
| 375 | + */ | |
| 376 | + private static IotProductScriptDebugRespVO buildDebugResult(IotProductScriptDebugReqVO reqVO, IotProductDO product, | |
| 377 | + IotScriptResult result) { | |
| 378 | + // 1. 执行失败:异常、超时、拒绝 | |
| 379 | + IotProductScriptDebugRespVO respVO = new IotProductScriptDebugRespVO().setDurationMs(result.getDurationMillis()); | |
| 380 | + if (!result.isSuccess()) { | |
| 381 | + return failDebugResult(respVO, result.getStatus().name(), result.getMessage(), result.getLineNumber()); | |
| 382 | + } | |
| 383 | + | |
| 384 | + // 2. 校验输出:不合法时返回 OUTPUT_INVALID 等错误 | |
| 385 | + try { | |
| 386 | + if (IotScriptUtils.FUNCTION_DECODE.equals(reqVO.getDirection())) { | |
| 387 | + // 2.1 decode | |
| 388 | + // 与网关一致:decode 未定义或返回 null,表示没有业务消息(只记录原始消息),message 为空 | |
| 389 | + IotDeviceMessage message = IotScriptUtils.validateDecode(result.getJson(), null); | |
| 390 | + respVO.setMessage(message != null ? buildDebugMessage(message) : null); | |
| 391 | + } else { | |
| 392 | + // 2.2 encode | |
| 393 | + // 与网关一致:字节按产品协议的规则校验(Modbus 必须是合法的请求 PDU) | |
| 394 | + IotScriptUtils.EncodeOutput output = IotScriptUtils.validateEncode(result.getJson(), | |
| 395 | + IotProtocolTypeEnum.of(product.getProtocolType())); | |
| 396 | + if (output == null) { // 与网关一致:未生成下行数据 | |
| 397 | + return failDebugResult(respVO, "ENCODE_EMPTY", "数据解析脚本未生成下行数据", -1); | |
| 398 | + } | |
| 399 | + respVO.setStandard(output.isStandard()); | |
| 400 | + respVO.setOutputHex(output.getBytes() != null ? HexUtil.encodeHexStr(output.getBytes(), false) : null); | |
| 401 | + } | |
| 402 | + } catch (IotScriptException e) { | |
| 403 | + return failDebugResult(respVO, e.getStatus().name(), e.getMessage(), e.getLineNumber()); | |
| 404 | + } | |
| 405 | + // 3. 成功 | |
| 406 | + return respVO.setSuccess(true); | |
| 407 | + } | |
| 408 | + | |
| 409 | + private static IotProductScriptDebugRespVO failDebugResult(IotProductScriptDebugRespVO respVO, String type, String message, int lineNumber) { | |
| 410 | + return respVO.setSuccess(false).setError(new IotProductScriptDebugRespVO.ErrorInfo() | |
| 411 | + .setType(type).setMessage(message).setLine(lineNumber > 0 ? lineNumber : null)); | |
| 412 | + } | |
| 413 | + | |
| 414 | + private static Map<String, Object> buildDebugMessage(IotDeviceMessage message) { | |
| 415 | + return BeanUtil.beanToMap(message, "method", "requestId", "params", "data", "code", "msg"); | |
| 416 | + } | |
| 417 | + | |
| 207 | 418 | } | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDeviceMessageMapper.xml
| ... | ... | @@ -19,7 +19,8 @@ |
| 19 | 19 | params VARCHAR(8192), |
| 20 | 20 | data VARCHAR(8192), |
| 21 | 21 | code INT, |
| 22 | - msg NCHAR(256) | |
| 22 | + msg NCHAR(256), | |
| 23 | + raw VARCHAR(8192) | |
| 23 | 24 | ) TAGS ( |
| 24 | 25 | device_id BIGINT |
| 25 | 26 | ) |
| ... | ... | @@ -33,21 +34,21 @@ |
| 33 | 34 | INSERT INTO device_message_${deviceId} ( |
| 34 | 35 | ts, id, report_time, tenant_id, server_id, |
| 35 | 36 | upstream, reply, identifier, request_id, method, |
| 36 | - params, data, code, msg | |
| 37 | + params, data, code, msg, raw | |
| 37 | 38 | ) |
| 38 | 39 | USING device_message |
| 39 | 40 | TAGS (#{deviceId}) |
| 40 | 41 | VALUES ( |
| 41 | 42 | #{ts}, #{id}, #{reportTime}, #{tenantId}, #{serverId}, |
| 42 | - #{upstream}, #{reply}, #{identifier}, #{requestId}, #{method}, | |
| 43 | - #{params}, #{data}, #{code}, #{msg} | |
| 43 | + #{upstream}, #{reply}, #{identifier}, #{requestId}, #{method}, | |
| 44 | + #{params}, #{data}, #{code}, #{msg}, #{raw} | |
| 44 | 45 | ) |
| 45 | 46 | </insert> |
| 46 | 47 | |
| 47 | 48 | <select id="selectPage" resultType="cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO"> |
| 48 | 49 | SELECT ts, id, report_time, tenant_id, server_id, |
| 49 | - upstream, reply, identifier, request_id, method, | |
| 50 | - params, data, code, msg | |
| 50 | + upstream, reply, identifier, request_id, method, | |
| 51 | + params, data, code, msg, raw | |
| 51 | 52 | FROM device_message_${reqVO.deviceId} |
| 52 | 53 | <where> |
| 53 | 54 | <if test="reqVO.method != null and reqVO.method != ''"> |
| ... | ... | @@ -62,14 +63,21 @@ |
| 62 | 63 | <if test="reqVO.identifier != null and reqVO.identifier != ''"> |
| 63 | 64 | AND identifier = #{reqVO.identifier} |
| 64 | 65 | </if> |
| 66 | + <!-- 是否有原始帧:按 raw 列是否为空过滤,不再按 method 过滤 --> | |
| 67 | + <if test="reqVO.raw != null and reqVO.raw"> | |
| 68 | + AND raw IS NOT NULL | |
| 69 | + </if> | |
| 70 | + <if test="reqVO.raw != null and !reqVO.raw"> | |
| 71 | + AND raw IS NULL | |
| 72 | + </if> | |
| 65 | 73 | </where> |
| 66 | 74 | ORDER BY ts DESC, id DESC |
| 67 | 75 | </select> |
| 68 | 76 | |
| 69 | 77 | <select id="selectListByRequestIdsAndReply" resultType="cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO"> |
| 70 | 78 | SELECT ts, id, report_time, tenant_id, server_id, |
| 71 | - upstream, reply, identifier, request_id, method, | |
| 72 | - params, data, code, msg | |
| 79 | + upstream, reply, identifier, request_id, method, | |
| 80 | + params, data, code, msg, raw | |
| 73 | 81 | FROM device_message_${deviceId} |
| 74 | 82 | WHERE reply = #{reply} |
| 75 | 83 | AND request_id IN | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/iotdb/IotDeviceMessageIoTDBMapper.xml
| ... | ... | @@ -32,7 +32,8 @@ |
| 32 | 32 | msg STRING FIELD, |
| 33 | 33 | report_time STRING FIELD, |
| 34 | 34 | tenant_id STRING FIELD, |
| 35 | - code STRING FIELD | |
| 35 | + code STRING FIELD, | |
| 36 | + raw STRING FIELD | |
| 36 | 37 | ) |
| 37 | 38 | </update> |
| 38 | 39 | |
| ... | ... | @@ -40,19 +41,22 @@ |
| 40 | 41 | INSERT INTO device_message ( |
| 41 | 42 | time, device_id, id, upstream, reply, |
| 42 | 43 | method, identifier, request_id, server_id, params, |
| 43 | - data, msg, report_time, tenant_id, code | |
| 44 | + data, msg, report_time, tenant_id, code, | |
| 45 | + raw | |
| 44 | 46 | ) |
| 45 | 47 | VALUES ( |
| 46 | 48 | #{ts}, #{deviceId}, #{id}, #{upstream}, #{reply}, |
| 47 | 49 | #{method}, #{identifier}, #{requestId}, #{serverId}, #{params}, |
| 48 | - #{data}, #{msg}, #{reportTime}, #{tenantId}, #{code} | |
| 50 | + #{data}, #{msg}, #{reportTime}, #{tenantId}, #{code}, | |
| 51 | + #{raw} | |
| 49 | 52 | ) |
| 50 | 53 | </insert> |
| 51 | 54 | |
| 52 | 55 | <sql id="selectColumns"> |
| 53 | 56 | SELECT time AS ts, device_id, id, upstream, reply, |
| 54 | 57 | method, identifier, request_id, server_id, params, |
| 55 | - data, msg, report_time, tenant_id, code | |
| 58 | + data, msg, report_time, tenant_id, code, | |
| 59 | + raw | |
| 56 | 60 | FROM device_message |
| 57 | 61 | </sql> |
| 58 | 62 | |
| ... | ... | @@ -70,6 +74,13 @@ |
| 70 | 74 | <if test="identifier != null"> |
| 71 | 75 | AND identifier = #{identifier} |
| 72 | 76 | </if> |
| 77 | + <!-- 是否有原始帧:有值为 v: 开头;没有为 n: --> | |
| 78 | + <if test="raw != null and raw"> | |
| 79 | + AND raw LIKE #{rawValuePattern} | |
| 80 | + </if> | |
| 81 | + <if test="raw != null and !raw"> | |
| 82 | + AND raw = #{rawNull} | |
| 83 | + </if> | |
| 73 | 84 | </sql> |
| 74 | 85 | |
| 75 | 86 | <select id="selectList" resultType="cn.iocoder.yudao.module.iot.dal.iotdb.IotDeviceMessageIoTDBRow"> | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImplTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.api.device; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.pojo.CommonResult; | |
| 4 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; | |
| 9 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; | |
| 10 | +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; | |
| 11 | +import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusConfigService; | |
| 12 | +import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusPointService; | |
| 13 | +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; | |
| 14 | +import cn.iocoder.yudao.module.iot.service.product.IotProductService; | |
| 15 | +import jakarta.validation.Validation; | |
| 16 | +import org.junit.jupiter.api.BeforeEach; | |
| 17 | +import org.junit.jupiter.api.Test; | |
| 18 | +import org.mockito.InjectMocks; | |
| 19 | +import org.mockito.Mock; | |
| 20 | +import org.springframework.aop.support.AopUtils; | |
| 21 | +import org.springframework.validation.beanvalidation.MethodValidationPostProcessor; | |
| 22 | + | |
| 23 | +import static org.junit.jupiter.api.Assertions.*; | |
| 24 | +import static org.mockito.ArgumentMatchers.eq; | |
| 25 | +import static org.mockito.Mockito.*; | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * {@link IoTDeviceApiImpl} 的单元测试:经过真实的 Spring 方法校验代理(与 @Validated 的 Bean 在容器中一致) | |
| 29 | + * | |
| 30 | + * @author 芋道源码 | |
| 31 | + */ | |
| 32 | +public class IoTDeviceApiImplTest extends BaseMockitoUnitTest { | |
| 33 | + | |
| 34 | + @InjectMocks | |
| 35 | + private IoTDeviceApiImpl deviceApiImpl; | |
| 36 | + | |
| 37 | + @Mock | |
| 38 | + private IotDeviceService deviceService; | |
| 39 | + @Mock | |
| 40 | + private IotProductService productService; | |
| 41 | + @Mock | |
| 42 | + private IotDeviceModbusConfigService modbusConfigService; | |
| 43 | + @Mock | |
| 44 | + private IotDeviceModbusPointService modbusPointService; | |
| 45 | + | |
| 46 | + /** | |
| 47 | + * 方法校验代理:参数约束继承不合法时,代理上的任何方法调用都会抛 ConstraintDeclarationException(HV000151) | |
| 48 | + */ | |
| 49 | + private IotDeviceCommonApi deviceApi; | |
| 50 | + | |
| 51 | + @BeforeEach | |
| 52 | + public void setUp() { | |
| 53 | + MethodValidationPostProcessor processor = new MethodValidationPostProcessor(); | |
| 54 | + processor.setValidator(Validation.buildDefaultValidatorFactory().getValidator()); | |
| 55 | + processor.afterPropertiesSet(); | |
| 56 | + deviceApi = (IotDeviceCommonApi) processor.postProcessAfterInitialization(deviceApiImpl, "ioTDeviceApiImpl"); | |
| 57 | + assertTrue(AopUtils.isAopProxy(deviceApi)); | |
| 58 | + } | |
| 59 | + | |
| 60 | + @Test | |
| 61 | + public void testAuthDevice() { | |
| 62 | + // 准备参数 | |
| 63 | + IotDeviceAuthReqDTO reqDTO = new IotDeviceAuthReqDTO("client", "dn&pk", "password"); | |
| 64 | + // mock 方法 | |
| 65 | + when(deviceService.authDevice(same(reqDTO))).thenReturn(true); | |
| 66 | + | |
| 67 | + // 调用 | |
| 68 | + CommonResult<Boolean> result = deviceApi.authDevice(reqDTO); | |
| 69 | + // 断言:方法校验代理正常放行 | |
| 70 | + assertTrue(result.isSuccess()); | |
| 71 | + assertTrue(result.getData()); | |
| 72 | + } | |
| 73 | + | |
| 74 | + @Test | |
| 75 | + public void testGetDevice_scriptEnabled() { | |
| 76 | + // 准备参数 | |
| 77 | + IotDeviceGetReqDTO reqDTO = new IotDeviceGetReqDTO().setId(10L); | |
| 78 | + // mock 方法:产品启用数据解析脚本 | |
| 79 | + when(deviceService.getDeviceFromCache(eq(10L))).thenReturn(IotDeviceDO.builder().id(10L).productId(1L) | |
| 80 | + .productKey("pk").deviceName("dn").build()); | |
| 81 | + when(productService.getProductFromCache(eq(1L))).thenReturn(IotProductDO.builder().id(1L) | |
| 82 | + .protocolType("tcp").serializeType("json") | |
| 83 | + .scriptEnabled(true).scriptContent("function decode(ctx) {}").build()); | |
| 84 | + | |
| 85 | + // 调用 | |
| 86 | + CommonResult<IotDeviceRespDTO> result = deviceApi.getDevice(reqDTO); | |
| 87 | + // 断言:产品字段随设备信息返回,启用时带上脚本内容 | |
| 88 | + assertTrue(result.isSuccess()); | |
| 89 | + IotDeviceRespDTO device = result.getData(); | |
| 90 | + assertEquals(10L, device.getId()); | |
| 91 | + assertEquals(1L, device.getProductId()); | |
| 92 | + assertEquals("tcp", device.getProtocolType()); | |
| 93 | + assertEquals("json", device.getSerializeType()); | |
| 94 | + assertTrue(device.getScriptEnabled()); | |
| 95 | + assertEquals("function decode(ctx) {}", device.getScriptContent()); | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Test | |
| 99 | + public void testGetDevice_scriptDisabled() { | |
| 100 | + // 准备参数 | |
| 101 | + IotDeviceGetReqDTO reqDTO = new IotDeviceGetReqDTO().setProductKey("pk").setDeviceName("dn"); | |
| 102 | + // mock 方法:产品停用数据解析脚本,但保留了脚本内容 | |
| 103 | + when(deviceService.getDeviceFromCache(eq("pk"), eq("dn"))).thenReturn(IotDeviceDO.builder().id(10L) | |
| 104 | + .productId(1L).productKey("pk").deviceName("dn").build()); | |
| 105 | + when(productService.getProductFromCache(eq(1L))).thenReturn(IotProductDO.builder().id(1L) | |
| 106 | + .scriptEnabled(false).scriptContent("function decode(ctx) {}").build()); | |
| 107 | + | |
| 108 | + // 调用 | |
| 109 | + CommonResult<IotDeviceRespDTO> result = deviceApi.getDevice(reqDTO); | |
| 110 | + // 断言:停用时不返回脚本内容 | |
| 111 | + assertTrue(result.isSuccess()); | |
| 112 | + assertFalse(result.getData().getScriptEnabled()); | |
| 113 | + assertNull(result.getData().getScriptContent()); | |
| 114 | + } | |
| 115 | + | |
| 116 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodecTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.dal.iotdb; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; | |
| 4 | +import org.junit.jupiter.api.Test; | |
| 5 | + | |
| 6 | +import static org.junit.jupiter.api.Assertions.*; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * {@link IotDeviceMessageIoTDBCodec} 的单元测试 | |
| 10 | + * | |
| 11 | + * @author 芋道源码 | |
| 12 | + */ | |
| 13 | +public class IotDeviceMessageIoTDBCodecTest { | |
| 14 | + | |
| 15 | + @Test | |
| 16 | + public void testEncode_raw() { | |
| 17 | + // 调用 + 断言:有原始帧 | |
| 18 | + IotDeviceMessageIoTDBRow row = IotDeviceMessageIoTDBCodec.encode(buildMessage().setRaw("AQID")); | |
| 19 | + assertEquals("v:AQID", row.getRaw()); | |
| 20 | + // 调用 + 断言:没有原始帧,也写入非 NULL 值,保证同一 (device_id, time) 整行替换 | |
| 21 | + IotDeviceMessageIoTDBRow nullRow = IotDeviceMessageIoTDBCodec.encode(buildMessage()); | |
| 22 | + assertEquals("n:", nullRow.getRaw()); | |
| 23 | + } | |
| 24 | + | |
| 25 | + @Test | |
| 26 | + public void testDecode_raw() { | |
| 27 | + // 调用 + 断言:v: 为值,n: 为 null | |
| 28 | + assertEquals("AQID", IotDeviceMessageIoTDBCodec.decode(buildRow().setRaw("v:AQID")).getRaw()); | |
| 29 | + assertNull(IotDeviceMessageIoTDBCodec.decode(buildRow().setRaw("n:")).getRaw()); | |
| 30 | + } | |
| 31 | + | |
| 32 | + @Test | |
| 33 | + public void testDecode_rawCorrupted() { | |
| 34 | + // 调用 + 断言:不是本系统的编码,按损坏处理,不静默 | |
| 35 | + assertThrows(IllegalArgumentException.class, | |
| 36 | + () -> IotDeviceMessageIoTDBCodec.decode(buildRow().setRaw("AQID"))); | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Test | |
| 40 | + public void testEncodeDecode_roundTrip() { | |
| 41 | + // 准备参数 | |
| 42 | + IotDeviceMessageDO message = buildMessage().setRaw("AQID"); | |
| 43 | + | |
| 44 | + // 调用 | |
| 45 | + IotDeviceMessageDO result = IotDeviceMessageIoTDBCodec.decode(IotDeviceMessageIoTDBCodec.encode(message)); | |
| 46 | + // 断言 | |
| 47 | + assertEquals(message.getRaw(), result.getRaw()); | |
| 48 | + assertEquals(message.getId(), result.getId()); | |
| 49 | + assertEquals(message.getDeviceId(), result.getDeviceId()); | |
| 50 | + assertEquals(message.getMethod(), result.getMethod()); | |
| 51 | + assertEquals(message.getUpstream(), result.getUpstream()); | |
| 52 | + assertNull(result.getParams()); | |
| 53 | + } | |
| 54 | + | |
| 55 | + private static IotDeviceMessageDO buildMessage() { | |
| 56 | + return new IotDeviceMessageDO().setTs(1000L).setDeviceId(2L).setId("msg-1") | |
| 57 | + .setMethod("thing.property.post").setUpstream(true).setReply(false); | |
| 58 | + } | |
| 59 | + | |
| 60 | + private static IotDeviceMessageIoTDBRow buildRow() { | |
| 61 | + return new IotDeviceMessageIoTDBRow().setTs(1000L).setDeviceId("2").setId("msg-1") | |
| 62 | + .setUpstream(1).setReply(0).setMethod("v:thing.property.post").setIdentifier("n:") | |
| 63 | + .setRequestId("n:").setServerId("n:").setParams("n:").setData("n:").setMsg("n:") | |
| 64 | + .setReportTime("n:").setTenantId("n:").setCode("n:"); | |
| 65 | + } | |
| 66 | + | |
| 67 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriberTest.java
| ... | ... | @@ -85,6 +85,27 @@ public class IotDeviceMessageSubscriberTest extends BaseMockitoUnitTest { |
| 85 | 85 | verifyNoInteractions(devicePropertyService, deviceMessageService); |
| 86 | 86 | } |
| 87 | 87 | |
| 88 | + @Test | |
| 89 | + public void testOnMessage_replyForceOnline() { | |
| 90 | + // 准备参数:设备实际返回的回复(例如 Modbus 异常响应),按原逻辑作为设备活动 | |
| 91 | + IotDeviceMessage message = new IotDeviceMessage().setDeviceId(randomLongId()).setTenantId(randomLongId()) | |
| 92 | + .setServerId("test-server").setMethod(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod()) | |
| 93 | + .setCode(1_051_003_111); | |
| 94 | + // mock 方法:设备离线 | |
| 95 | + IotDeviceDO device = new IotDeviceDO().setId(message.getDeviceId()) | |
| 96 | + .setState(IotDeviceStateEnum.OFFLINE.getState()); | |
| 97 | + when(deviceService.getDeviceFromCache(eq(message.getDeviceId()))).thenReturn(device); | |
| 98 | + | |
| 99 | + // 调用 | |
| 100 | + deviceMessageSubscriber.onMessage(message); | |
| 101 | + // 断言:更新上报时间、连接 server,并强制上线 | |
| 102 | + verify(devicePropertyService).updateDeviceReportTimeAsync(eq(device.getId()), any(LocalDateTime.class)); | |
| 103 | + verify(devicePropertyService).updateDeviceServerIdAsync(eq(device.getId()), eq(message.getServerId())); | |
| 104 | + verify(deviceMessageService).sendDeviceMessage(argThat((IotDeviceMessage m) -> | |
| 105 | + IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(m.getMethod()))); | |
| 106 | + verify(deviceMessageService).handleUpstreamDeviceMessage(eq(message), eq(device)); | |
| 107 | + } | |
| 108 | + | |
| 88 | 109 | private static IotDeviceMessage buildPropertyPostMessage() { |
| 89 | 110 | return new IotDeviceMessage().setDeviceId(randomLongId()).setTenantId(randomLongId()) |
| 90 | 111 | .setServerId("test-server").setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.service.device.message; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.date.LocalDateTimeUtil; |
| 4 | +import cn.hutool.extra.spring.SpringUtil; | |
| 4 | 5 | import cn.iocoder.yudao.framework.common.exception.ServiceException; |
| 5 | 6 | import cn.iocoder.yudao.framework.common.enums.DateIntervalEnum; |
| 6 | 7 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| ... | ... | @@ -13,24 +14,31 @@ import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 13 | 14 | import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; |
| 14 | 15 | import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; |
| 15 | 16 | import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; |
| 17 | +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; | |
| 16 | 18 | import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; |
| 17 | 19 | import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; |
| 18 | 20 | import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; |
| 19 | 21 | import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; |
| 22 | +import cn.iocoder.yudao.module.iot.service.product.IotProductService; | |
| 20 | 23 | import org.junit.jupiter.api.Test; |
| 21 | 24 | import org.mockito.ArgumentCaptor; |
| 22 | 25 | import org.mockito.InjectMocks; |
| 23 | 26 | import org.mockito.Mock; |
| 27 | +import org.mockito.MockedStatic; | |
| 24 | 28 | |
| 25 | 29 | import java.sql.Timestamp; |
| 26 | 30 | import java.time.LocalDateTime; |
| 27 | 31 | import java.util.Arrays; |
| 32 | +import java.util.Base64; | |
| 28 | 33 | import java.util.Collections; |
| 29 | 34 | import java.util.HashMap; |
| 30 | 35 | import java.util.List; |
| 31 | 36 | |
| 37 | +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; | |
| 38 | +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; | |
| 32 | 39 | import static org.junit.jupiter.api.Assertions.*; |
| 33 | 40 | import static org.mockito.ArgumentMatchers.any; |
| 41 | +import static org.mockito.ArgumentMatchers.eq; | |
| 34 | 42 | import static org.mockito.ArgumentMatchers.isNull; |
| 35 | 43 | import static org.mockito.ArgumentMatchers.same; |
| 36 | 44 | import static org.mockito.Mockito.*; |
| ... | ... | @@ -40,7 +48,7 @@ import static org.mockito.Mockito.*; |
| 40 | 48 | * |
| 41 | 49 | * 注:时序库没有 embedded 替代,存储 DAO 与依赖 service 走 mock; |
| 42 | 50 | * handleUpstreamDeviceMessage 与 sendDeviceMessage 下行成功路径依赖 SpringUtil.getBean 的自调用 |
| 43 | - * createDeviceLogAsync,更适合放到集成测试,本类不展开。 | |
| 51 | + * createDeviceLogAsync,通过 mockStatic 返回本实例(同步执行)。 | |
| 44 | 52 | * |
| 45 | 53 | * @author 芋道源码 |
| 46 | 54 | */ |
| ... | ... | @@ -59,6 +67,8 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { |
| 59 | 67 | private IotDeviceMessageDAO deviceMessageDAO; |
| 60 | 68 | @Mock |
| 61 | 69 | private IotDeviceMessageProducer deviceMessageProducer; |
| 70 | + @Mock | |
| 71 | + private IotProductService productService; | |
| 62 | 72 | |
| 63 | 73 | // ========== createDeviceLogAsync ========== |
| 64 | 74 | |
| ... | ... | @@ -245,6 +255,208 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { |
| 245 | 255 | assertEquals(1, list.get(0).getDownstreamCount()); |
| 246 | 256 | } |
| 247 | 257 | |
| 258 | + // ========== 透传校验 ========== | |
| 259 | + | |
| 260 | + @Test | |
| 261 | + public void testSendDeviceMessage_rawPostReserved() { | |
| 262 | + // 准备参数:raw.post 只能由网关 raw 入口创建 | |
| 263 | + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.RAW_POST.getMethod()); | |
| 264 | + | |
| 265 | + // 调用,并断言异常 | |
| 266 | + assertServiceException(() -> service.sendDeviceMessage(message, buildDevice(), null), | |
| 267 | + DEVICE_MESSAGE_RAW_METHOD_RESERVED); | |
| 268 | + verifyNoInteractions(deviceMessageProducer); | |
| 269 | + } | |
| 270 | + | |
| 271 | + @Test | |
| 272 | + public void testSendDeviceMessage_rawPushProtocolNotSupported() { | |
| 273 | + // 准备参数 | |
| 274 | + IotDeviceMessage message = buildRawPush("AQID"); | |
| 275 | + // mock 方法:HTTP 产品 | |
| 276 | + when(productService.getProductFromCache(3L)).thenReturn(IotProductDO.builder().id(3L).protocolType("http").build()); | |
| 277 | + | |
| 278 | + // 调用,并断言异常 | |
| 279 | + assertServiceException(() -> service.sendDeviceMessage(message, buildDevice(), null), | |
| 280 | + DEVICE_MESSAGE_RAW_PROTOCOL_NOT_SUPPORTED, "http"); | |
| 281 | + verifyNoInteractions(deviceMessageProducer); | |
| 282 | + } | |
| 283 | + | |
| 284 | + @Test | |
| 285 | + public void testSendDeviceMessage_rawPushPayloadInvalid() { | |
| 286 | + // mock 方法:TCP 产品 | |
| 287 | + when(productService.getProductFromCache(3L)).thenReturn(IotProductDO.builder().id(3L).protocolType("tcp").build()); | |
| 288 | + | |
| 289 | + // 调用,并断言异常:非法 Base64、空载荷 | |
| 290 | + assertRawPayloadInvalid(buildRawPush("not base64!")); | |
| 291 | + assertRawPayloadInvalid(buildRawPush("")); | |
| 292 | + verifyNoInteractions(deviceMessageProducer); | |
| 293 | + } | |
| 294 | + | |
| 295 | + @Test | |
| 296 | + public void testSendDeviceMessage_rawPushModbusPduInvalid() { | |
| 297 | + // mock 方法:Modbus 产品 | |
| 298 | + when(productService.getProductFromCache(3L)).thenReturn(IotProductDO.builder().id(3L).protocolType("modbus_tcp_client").build()); | |
| 299 | + | |
| 300 | + // 调用,并断言异常:功能码 08 不在白名单;03 数量 126 超限 | |
| 301 | + assertRawPayloadInvalid(buildRawPush(Base64.getEncoder().encodeToString(new byte[]{8, 0, 0, 0, 1}))); | |
| 302 | + assertRawPayloadInvalid(buildRawPush(Base64.getEncoder().encodeToString(new byte[]{3, 0, 0, 0, 126}))); | |
| 303 | + verifyNoInteractions(deviceMessageProducer); | |
| 304 | + } | |
| 305 | + | |
| 306 | + @Test | |
| 307 | + public void testGetDeviceMessagePage_validateDevice() { | |
| 308 | + // 准备参数 | |
| 309 | + IotDeviceMessagePageReqVO reqVO = new IotDeviceMessagePageReqVO(); | |
| 310 | + reqVO.setDeviceId(2L); | |
| 311 | + // mock 方法:设备不属于当前租户 | |
| 312 | + when(deviceService.validateDeviceExists(2L)).thenThrow(new ServiceException(DEVICE_NOT_EXISTS)); | |
| 313 | + | |
| 314 | + // 调用,并断言异常:不查时序库 | |
| 315 | + assertServiceException(() -> service.getDeviceMessagePage(reqVO), DEVICE_NOT_EXISTS); | |
| 316 | + verifyNoInteractions(deviceMessageDAO); | |
| 317 | + } | |
| 318 | + | |
| 319 | + @Test | |
| 320 | + public void testSendDeviceMessage_rawPushRewriteToRaw() { | |
| 321 | + // 准备参数:未带 padding 的 Base64,规范化为标准 Base64 | |
| 322 | + IotDeviceMessage message = buildRawPush("AQI"); | |
| 323 | + // mock 方法:TCP 产品,设备已连接网关 | |
| 324 | + when(productService.getProductFromCache(3L)).thenReturn(IotProductDO.builder().id(3L).protocolType("tcp").build()); | |
| 325 | + when(devicePropertyService.getDeviceServerId(2L)).thenReturn("server-1"); | |
| 326 | + | |
| 327 | + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) { | |
| 328 | + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); | |
| 329 | + // 调用 | |
| 330 | + service.sendDeviceMessage(message, buildDevice(), null); | |
| 331 | + } | |
| 332 | + // 断言:发给网关的消息,载荷在 raw,params 置空 | |
| 333 | + ArgumentCaptor<IotDeviceMessage> messageCaptor = ArgumentCaptor.forClass(IotDeviceMessage.class); | |
| 334 | + verify(deviceMessageProducer).sendDeviceMessageToGateway(eq("server-1"), messageCaptor.capture()); | |
| 335 | + assertEquals(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(), messageCaptor.getValue().getMethod()); | |
| 336 | + assertEquals("AQI=", messageCaptor.getValue().getRaw()); | |
| 337 | + assertNull(messageCaptor.getValue().getParams()); | |
| 338 | + // 断言:消息日志带上 raw | |
| 339 | + ArgumentCaptor<IotDeviceMessageDO> logCaptor = ArgumentCaptor.forClass(IotDeviceMessageDO.class); | |
| 340 | + verify(deviceMessageDAO).insert(logCaptor.capture()); | |
| 341 | + assertEquals("AQI=", logCaptor.getValue().getRaw()); | |
| 342 | + assertNull(logCaptor.getValue().getParams()); | |
| 343 | + assertFalse(logCaptor.getValue().getUpstream()); | |
| 344 | + } | |
| 345 | + | |
| 346 | + @Test | |
| 347 | + public void testSendDeviceMessage_clearForgedRaw() { | |
| 348 | + // 准备参数:非 raw.push 的下行消息,携带伪造的 raw | |
| 349 | + HashMap<String, Object> params = new HashMap<>(); | |
| 350 | + params.put("power", 1); | |
| 351 | + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod()) | |
| 352 | + .setParams(params).setRaw("AQID"); | |
| 353 | + // mock 方法:设备已连接网关 | |
| 354 | + when(devicePropertyService.getDeviceServerId(2L)).thenReturn("server-1"); | |
| 355 | + | |
| 356 | + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) { | |
| 357 | + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); | |
| 358 | + // 调用 | |
| 359 | + service.sendDeviceMessage(message, buildDevice(), null); | |
| 360 | + } | |
| 361 | + // 断言:raw 被清空,params 保持不变 | |
| 362 | + ArgumentCaptor<IotDeviceMessage> messageCaptor = ArgumentCaptor.forClass(IotDeviceMessage.class); | |
| 363 | + verify(deviceMessageProducer).sendDeviceMessageToGateway(eq("server-1"), messageCaptor.capture()); | |
| 364 | + assertNull(messageCaptor.getValue().getRaw()); | |
| 365 | + assertSame(params, messageCaptor.getValue().getParams()); | |
| 366 | + verifyNoInteractions(productService); | |
| 367 | + } | |
| 368 | + | |
| 369 | + @Test | |
| 370 | + public void testSendDeviceMessage_upstreamClearForgedRaw() { | |
| 371 | + // 准备参数:模拟上报,携带伪造的 raw(否则可绕过标准自动回复) | |
| 372 | + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()).setRaw("AQID"); | |
| 373 | + | |
| 374 | + // 调用 | |
| 375 | + service.sendDeviceMessage(message, buildDevice(), null); | |
| 376 | + // 断言:raw 被清空后再发到消息总线 | |
| 377 | + verify(deviceMessageProducer).sendDeviceMessage(same(message)); | |
| 378 | + assertNull(message.getRaw()); | |
| 379 | + } | |
| 380 | + | |
| 381 | + // ========== handleUpstreamDeviceMessage ========== | |
| 382 | + | |
| 383 | + @Test | |
| 384 | + public void testHandleUpstreamDeviceMessage_reply() { | |
| 385 | + // 准备参数:设备经标准路径上报的属性(没有 raw) | |
| 386 | + IotDeviceDO device = buildDevice(); | |
| 387 | + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()) | |
| 388 | + .setRequestId("req-1").setServerId("server-1"); | |
| 389 | + | |
| 390 | + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) { | |
| 391 | + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); | |
| 392 | + // 调用 | |
| 393 | + service.handleUpstreamDeviceMessage(message, device); | |
| 394 | + } | |
| 395 | + // 断言:保存属性,并自动回复 | |
| 396 | + verify(devicePropertyService).saveDeviceProperty(same(device), same(message)); | |
| 397 | + ArgumentCaptor<IotDeviceMessage> replyCaptor = ArgumentCaptor.forClass(IotDeviceMessage.class); | |
| 398 | + verify(deviceMessageProducer).sendDeviceMessageToGateway(eq("server-1"), replyCaptor.capture()); | |
| 399 | + assertEquals("req-1", replyCaptor.getValue().getRequestId()); | |
| 400 | + assertEquals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), replyCaptor.getValue().getMethod()); | |
| 401 | + assertEquals(0, replyCaptor.getValue().getCode()); | |
| 402 | + assertNull(replyCaptor.getValue().getRaw()); | |
| 403 | + } | |
| 404 | + | |
| 405 | + @Test | |
| 406 | + public void testHandleUpstreamDeviceMessage_rawNoReply() { | |
| 407 | + // 准备参数:网关透传解析出的属性(带原始帧) | |
| 408 | + IotDeviceDO device = buildDevice(); | |
| 409 | + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()) | |
| 410 | + .setRequestId("req-1").setServerId("server-1").setRaw("AQID"); | |
| 411 | + | |
| 412 | + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) { | |
| 413 | + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); | |
| 414 | + // 调用 | |
| 415 | + service.handleUpstreamDeviceMessage(message, device); | |
| 416 | + } | |
| 417 | + // 断言:照常保存属性,日志带上 raw;不自动回复 | |
| 418 | + verify(devicePropertyService).saveDeviceProperty(same(device), same(message)); | |
| 419 | + ArgumentCaptor<IotDeviceMessageDO> logCaptor = ArgumentCaptor.forClass(IotDeviceMessageDO.class); | |
| 420 | + verify(deviceMessageDAO).insert(logCaptor.capture()); | |
| 421 | + assertEquals("AQID", logCaptor.getValue().getRaw()); | |
| 422 | + assertTrue(logCaptor.getValue().getUpstream()); | |
| 423 | + verify(deviceMessageProducer, never()).sendDeviceMessageToGateway(any(), any()); | |
| 424 | + } | |
| 425 | + | |
| 426 | + @Test | |
| 427 | + public void testHandleUpstreamDeviceMessage_rawPostNoReply() { | |
| 428 | + // 准备参数:网关未解析的透传上报 | |
| 429 | + IotDeviceDO device = buildDevice(); | |
| 430 | + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.RAW_POST.getMethod()) | |
| 431 | + .setParams(null).setServerId("server-1").setRaw("AQID"); | |
| 432 | + | |
| 433 | + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) { | |
| 434 | + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); | |
| 435 | + // 调用 | |
| 436 | + service.handleUpstreamDeviceMessage(message, device); | |
| 437 | + } | |
| 438 | + // 断言:只记录日志(带 raw),不自动回复 | |
| 439 | + ArgumentCaptor<IotDeviceMessageDO> logCaptor = ArgumentCaptor.forClass(IotDeviceMessageDO.class); | |
| 440 | + verify(deviceMessageDAO).insert(logCaptor.capture()); | |
| 441 | + assertEquals("AQID", logCaptor.getValue().getRaw()); | |
| 442 | + assertNull(logCaptor.getValue().getParams()); | |
| 443 | + verify(deviceMessageProducer, never()).sendDeviceMessageToGateway(any(), any()); | |
| 444 | + } | |
| 445 | + | |
| 446 | + private void assertRawPayloadInvalid(IotDeviceMessage message) { | |
| 447 | + ServiceException ex = assertThrows(ServiceException.class, | |
| 448 | + () -> service.sendDeviceMessage(message, buildDevice(), null)); | |
| 449 | + assertEquals(DEVICE_MESSAGE_RAW_PAYLOAD_INVALID.getCode(), ex.getCode()); | |
| 450 | + } | |
| 451 | + | |
| 452 | + private IotDeviceMessage buildRawPush(String payload) { | |
| 453 | + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod()); | |
| 454 | + HashMap<String, Object> params = new HashMap<>(); | |
| 455 | + params.put("payload", payload); | |
| 456 | + message.setParams(params); | |
| 457 | + return message; | |
| 458 | + } | |
| 459 | + | |
| 248 | 460 | // ========== 辅助方法 ========== |
| 249 | 461 | |
| 250 | 462 | /** 构造一条最简消息(指定 method 决定上下行分支) */ |
| ... | ... | @@ -259,7 +471,7 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { |
| 259 | 471 | |
| 260 | 472 | /** 构造最简设备 */ |
| 261 | 473 | private IotDeviceDO buildDevice() { |
| 262 | - return IotDeviceDO.builder().id(2L).build(); | |
| 474 | + return IotDeviceDO.builder().id(2L).productId(3L).build(); | |
| 263 | 475 | } |
| 264 | 476 | |
| 265 | 477 | } | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImplTest.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.service.product; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.util.StrUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 3 | 5 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 6 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductSaveReqVO; | |
| 7 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugReqVO; | |
| 8 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugRespVO; | |
| 9 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptUpdateReqVO; | |
| 4 | 10 | import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; |
| 5 | 11 | import cn.iocoder.yudao.module.iot.dal.mysql.product.IotProductMapper; |
| 6 | 12 | import cn.iocoder.yudao.module.iot.enums.product.IotProductStatusEnum; |
| 7 | 13 | import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; |
| 8 | 14 | import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; |
| 9 | 15 | import org.junit.jupiter.api.Test; |
| 16 | +import org.mockito.ArgumentCaptor; | |
| 10 | 17 | import org.mockito.InOrder; |
| 11 | 18 | import org.mockito.InjectMocks; |
| 12 | 19 | import org.mockito.Mock; |
| 13 | 20 | |
| 14 | 21 | import java.io.Serializable; |
| 22 | +import java.util.Collections; | |
| 23 | +import java.util.HashMap; | |
| 24 | +import java.util.Map; | |
| 15 | 25 | |
| 16 | 26 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; |
| 17 | 27 | import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; |
| 28 | +import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 29 | +import static org.junit.jupiter.api.Assertions.assertFalse; | |
| 30 | +import static org.junit.jupiter.api.Assertions.assertNotNull; | |
| 31 | +import static org.junit.jupiter.api.Assertions.assertNull; | |
| 18 | 32 | import static org.junit.jupiter.api.Assertions.assertSame; |
| 19 | 33 | import static org.junit.jupiter.api.Assertions.assertThrows; |
| 34 | +import static org.junit.jupiter.api.Assertions.assertTrue; | |
| 20 | 35 | import static org.mockito.ArgumentMatchers.any; |
| 21 | 36 | import static org.mockito.Mockito.*; |
| 22 | 37 | |
| ... | ... | @@ -127,6 +142,231 @@ public class IotProductServiceImplTest extends BaseMockitoUnitTest { |
| 127 | 142 | verify(productMapper, times(2)).deleteById(id); |
| 128 | 143 | } |
| 129 | 144 | |
| 145 | + @Test | |
| 146 | + public void testUpdateProduct_notOverwriteScript() { | |
| 147 | + // 准备参数:普通产品表单不含脚本字段 | |
| 148 | + IotProductSaveReqVO reqVO = new IotProductSaveReqVO(); | |
| 149 | + reqVO.setId(1L); | |
| 150 | + reqVO.setName("温湿度"); | |
| 151 | + // mock 方法 | |
| 152 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, IotProductStatusEnum.UNPUBLISHED.getStatus())); | |
| 153 | + | |
| 154 | + // 调用 | |
| 155 | + productService.updateProduct(reqVO); | |
| 156 | + // 断言:脚本两列为 null,按默认 NOT_NULL 策略不会写回 | |
| 157 | + IotProductDO updateObj = captureUpdate(); | |
| 158 | + assertEquals("温湿度", updateObj.getName()); | |
| 159 | + assertNull(updateObj.getScriptContent()); | |
| 160 | + assertNull(updateObj.getScriptEnabled()); | |
| 161 | + } | |
| 162 | + | |
| 163 | + @Test | |
| 164 | + public void testUpdateProductScript_contentOnly() { | |
| 165 | + // 准备参数 | |
| 166 | + productService.initDebugScriptExecutor(); | |
| 167 | + try { | |
| 168 | + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); | |
| 169 | + reqVO.setId(1L); | |
| 170 | + reqVO.setScriptContent("function decode(ctx) { return {method: 'thing.property.post', params: {}}; }"); | |
| 171 | + // mock 方法:已启用旧脚本 | |
| 172 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null).setScriptEnabled(true).setScriptContent("old")); | |
| 173 | + | |
| 174 | + // 调用 | |
| 175 | + productService.updateProductScript(reqVO); | |
| 176 | + // 断言:只写内容,不顺带改启用状态 | |
| 177 | + IotProductDO updateObj = captureUpdate(); | |
| 178 | + assertEquals(reqVO.getScriptContent(), updateObj.getScriptContent()); | |
| 179 | + assertNull(updateObj.getScriptEnabled()); | |
| 180 | + assertNull(updateObj.getName()); | |
| 181 | + } finally { | |
| 182 | + productService.destroyDebugScriptExecutor(); | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + @Test | |
| 187 | + public void testUpdateProductScript_enableOnly() { | |
| 188 | + // 准备参数 | |
| 189 | + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); | |
| 190 | + reqVO.setId(1L); | |
| 191 | + reqVO.setScriptEnabled(true); | |
| 192 | + // mock 方法:库里已有内容 | |
| 193 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null).setScriptContent("function decode(ctx) {}")); | |
| 194 | + | |
| 195 | + // 调用 | |
| 196 | + productService.updateProductScript(reqVO); | |
| 197 | + // 断言:只写启用状态 | |
| 198 | + IotProductDO updateObj = captureUpdate(); | |
| 199 | + assertEquals(true, updateObj.getScriptEnabled()); | |
| 200 | + assertNull(updateObj.getScriptContent()); | |
| 201 | + } | |
| 202 | + | |
| 203 | + @Test | |
| 204 | + public void testUpdateProductScript_enableWithEmptyContent() { | |
| 205 | + // 准备参数 | |
| 206 | + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); | |
| 207 | + reqVO.setId(1L); | |
| 208 | + reqVO.setScriptEnabled(true); | |
| 209 | + // mock 方法:库里没有内容 | |
| 210 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null)); | |
| 211 | + | |
| 212 | + // 调用,并断言异常 | |
| 213 | + assertServiceException(() -> productService.updateProductScript(reqVO), PRODUCT_SCRIPT_EMPTY); | |
| 214 | + verify(productMapper, never()).updateById(any(IotProductDO.class)); | |
| 215 | + } | |
| 216 | + | |
| 217 | + @Test | |
| 218 | + public void testUpdateProductScript_clearWhileEnabled() { | |
| 219 | + // 准备参数:启用状态下清空内容 | |
| 220 | + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); | |
| 221 | + reqVO.setId(1L); | |
| 222 | + reqVO.setScriptContent(""); | |
| 223 | + // mock 方法 | |
| 224 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null).setScriptEnabled(true).setScriptContent("x")); | |
| 225 | + | |
| 226 | + // 调用,并断言异常 | |
| 227 | + assertServiceException(() -> productService.updateProductScript(reqVO), PRODUCT_SCRIPT_EMPTY); | |
| 228 | + verify(productMapper, never()).updateById(any(IotProductDO.class)); | |
| 229 | + } | |
| 230 | + | |
| 231 | + @Test | |
| 232 | + public void testUpdateProductScript_tooLong() { | |
| 233 | + // 准备参数:超过 32KB(UTF-8) | |
| 234 | + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); | |
| 235 | + reqVO.setId(1L); | |
| 236 | + reqVO.setScriptContent(StrUtil.repeat('a', 32 * 1024 + 1)); | |
| 237 | + // mock 方法 | |
| 238 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null)); | |
| 239 | + | |
| 240 | + // 调用,并断言异常 | |
| 241 | + assertServiceException(() -> productService.updateProductScript(reqVO), PRODUCT_SCRIPT_TOO_LONG, 32 * 1024); | |
| 242 | + verify(productMapper, never()).updateById(any(IotProductDO.class)); | |
| 243 | + } | |
| 244 | + | |
| 245 | + @Test | |
| 246 | + public void testUpdateProductScript_compileFailed() { | |
| 247 | + // 准备参数 | |
| 248 | + productService.initDebugScriptExecutor(); | |
| 249 | + try { | |
| 250 | + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); | |
| 251 | + reqVO.setId(1L); | |
| 252 | + reqVO.setScriptContent("function decode(ctx) {\n return {;\n}"); | |
| 253 | + // mock 方法 | |
| 254 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null)); | |
| 255 | + | |
| 256 | + // 调用,并断言异常:只编译、不执行,语法错误带行号 | |
| 257 | + ServiceException ex = assertThrows(ServiceException.class, () -> productService.updateProductScript(reqVO)); | |
| 258 | + assertEquals(PRODUCT_SCRIPT_COMPILE_FAILED.getCode(), ex.getCode()); | |
| 259 | + verify(productMapper, never()).updateById(any(IotProductDO.class)); | |
| 260 | + } finally { | |
| 261 | + productService.destroyDebugScriptExecutor(); | |
| 262 | + } | |
| 263 | + } | |
| 264 | + | |
| 265 | + @Test | |
| 266 | + public void testDebugProductScript_decodeMessage() { | |
| 267 | + // 准备参数:decode 返回一个标准消息对象 | |
| 268 | + IotProductScriptDebugReqVO reqVO = buildDecodeDebugReqVO( | |
| 269 | + "function decode(ctx) { return {method: 'thing.property.post', params: {temperature: ctx.bytes[0]}}; }"); | |
| 270 | + | |
| 271 | + // 调用 | |
| 272 | + IotProductScriptDebugRespVO respVO = debugProductScript(reqVO); | |
| 273 | + // 断言:单条消息 | |
| 274 | + assertTrue(respVO.getSuccess()); | |
| 275 | + assertNull(respVO.getError()); | |
| 276 | + assertNotNull(respVO.getMessage()); | |
| 277 | + assertEquals("thing.property.post", respVO.getMessage().get("method")); | |
| 278 | + assertEquals(25, ((Map<?, ?>) respVO.getMessage().get("params")).get("temperature")); | |
| 279 | + } | |
| 280 | + | |
| 281 | + @Test | |
| 282 | + public void testDebugProductScript_decodeNull() { | |
| 283 | + // 准备参数:decode 返回 null,表示没有业务消息 | |
| 284 | + IotProductScriptDebugReqVO reqVO = buildDecodeDebugReqVO("function decode(ctx) { return null; }"); | |
| 285 | + | |
| 286 | + // 调用 | |
| 287 | + IotProductScriptDebugRespVO respVO = debugProductScript(reqVO); | |
| 288 | + // 断言:成功,没有消息 | |
| 289 | + assertTrue(respVO.getSuccess()); | |
| 290 | + assertNull(respVO.getError()); | |
| 291 | + assertNull(respVO.getMessage()); | |
| 292 | + } | |
| 293 | + | |
| 294 | + @Test | |
| 295 | + public void testDebugProductScript_decodeOutputInvalid() { | |
| 296 | + // 准备参数:旧的 messages 包装,不再支持 | |
| 297 | + IotProductScriptDebugReqVO reqVO = buildDecodeDebugReqVO( | |
| 298 | + "function decode(ctx) { return {messages: [{method: 'thing.property.post', params: {}}]}; }"); | |
| 299 | + | |
| 300 | + // 调用 | |
| 301 | + IotProductScriptDebugRespVO respVO = debugProductScript(reqVO); | |
| 302 | + // 断言:输出不合法 | |
| 303 | + assertFalse(respVO.getSuccess()); | |
| 304 | + assertNull(respVO.getMessage()); | |
| 305 | + assertEquals("OUTPUT_INVALID", respVO.getError().getType()); | |
| 306 | + } | |
| 307 | + | |
| 308 | + @Test | |
| 309 | + public void testDebugProductScript_encodeModbusPduInvalid() { | |
| 310 | + // 准备参数:Modbus 产品,encode 输出的字节不是合法的请求 PDU(缺数量);合法的 06 写单个寄存器 | |
| 311 | + IotProductScriptDebugReqVO invalidReqVO = buildEncodeDebugReqVO("function encode(ctx) { return {bytes: [6, 0, 16]}; }"); | |
| 312 | + IotProductScriptDebugReqVO validReqVO = buildEncodeDebugReqVO( | |
| 313 | + "function encode(ctx) { return {bytes: [6, 0, 16, 0, ctx.message.params.power]}; }"); | |
| 314 | + | |
| 315 | + // 调用 | |
| 316 | + IotProductScriptDebugRespVO invalidRespVO = debugProductScript(invalidReqVO, "modbus_tcp_client"); | |
| 317 | + IotProductScriptDebugRespVO validRespVO = debugProductScript(validReqVO, "modbus_tcp_client"); | |
| 318 | + // 断言:与网关一致,按产品协议的字节规则判定输出不合法(带原规则的错误信息);合法的 PDU 照常输出 | |
| 319 | + assertFalse(invalidRespVO.getSuccess()); | |
| 320 | + assertEquals("OUTPUT_INVALID", invalidRespVO.getError().getType()); | |
| 321 | + assertEquals("PDU 长度必须在 5~253 字节之间", invalidRespVO.getError().getMessage()); | |
| 322 | + assertTrue(validRespVO.getSuccess()); | |
| 323 | + assertEquals("0600100001", validRespVO.getOutputHex()); | |
| 324 | + } | |
| 325 | + | |
| 326 | + private IotProductScriptDebugRespVO debugProductScript(IotProductScriptDebugReqVO reqVO) { | |
| 327 | + return debugProductScript(reqVO, "tcp"); | |
| 328 | + } | |
| 329 | + | |
| 330 | + /** | |
| 331 | + * 执行调试:真实的调试池;mock 产品 | |
| 332 | + */ | |
| 333 | + private IotProductScriptDebugRespVO debugProductScript(IotProductScriptDebugReqVO reqVO, String protocolType) { | |
| 334 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null).setProtocolType(protocolType).setProductKey("pk")); | |
| 335 | + productService.initDebugScriptExecutor(); | |
| 336 | + try { | |
| 337 | + return productService.debugProductScript(reqVO); | |
| 338 | + } finally { | |
| 339 | + productService.destroyDebugScriptExecutor(); | |
| 340 | + } | |
| 341 | + } | |
| 342 | + | |
| 343 | + private static IotProductScriptDebugReqVO buildDecodeDebugReqVO(String scriptContent) { | |
| 344 | + IotProductScriptDebugReqVO reqVO = new IotProductScriptDebugReqVO(); | |
| 345 | + reqVO.setId(1L); | |
| 346 | + reqVO.setScriptContent(scriptContent); | |
| 347 | + reqVO.setDirection("decode"); | |
| 348 | + reqVO.setInputHex("19 01"); | |
| 349 | + return reqVO; | |
| 350 | + } | |
| 351 | + | |
| 352 | + private static IotProductScriptDebugReqVO buildEncodeDebugReqVO(String scriptContent) { | |
| 353 | + IotProductScriptDebugReqVO reqVO = new IotProductScriptDebugReqVO(); | |
| 354 | + reqVO.setId(1L); | |
| 355 | + reqVO.setScriptContent(scriptContent); | |
| 356 | + reqVO.setDirection("encode"); | |
| 357 | + Map<String, Object> message = new HashMap<>(); | |
| 358 | + message.put("method", "thing.property.set"); | |
| 359 | + message.put("params", Collections.singletonMap("power", 1)); | |
| 360 | + reqVO.setMessage(message); | |
| 361 | + return reqVO; | |
| 362 | + } | |
| 363 | + | |
| 364 | + private IotProductDO captureUpdate() { | |
| 365 | + ArgumentCaptor<IotProductDO> captor = ArgumentCaptor.forClass(IotProductDO.class); | |
| 366 | + verify(productMapper).updateById(captor.capture()); | |
| 367 | + return captor.getValue(); | |
| 368 | + } | |
| 369 | + | |
| 130 | 370 | private void assertNoDelete() { |
| 131 | 371 | verifyNoInteractions(devicePropertyDataService); |
| 132 | 372 | verify(productMapper, never()).deleteById(any(Serializable.class)); | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql
| ... | ... | @@ -24,6 +24,8 @@ CREATE TABLE IF NOT EXISTS "iot_product" ( |
| 24 | 24 | "pic_url" varchar(255) DEFAULT NULL, |
| 25 | 25 | "protocol_type" varchar(100) DEFAULT NULL, |
| 26 | 26 | "serialize_type" varchar(100) DEFAULT NULL, |
| 27 | + "script_content" text DEFAULT NULL, | |
| 28 | + "script_enabled" bit NOT NULL DEFAULT FALSE, | |
| 27 | 29 | "category_id" bigint DEFAULT NULL, |
| 28 | 30 | "description" varchar(500) DEFAULT NULL, |
| 29 | 31 | "data_format" tinyint NOT NULL DEFAULT '0', | ... | ... |
yudao-module-iot/yudao-module-iot-core/pom.xml
| ... | ... | @@ -17,6 +17,7 @@ |
| 17 | 17 | iot 模块下,提供 iot-biz 和 iot-gateway 模块的核心功能。例如说: |
| 18 | 18 | 1. 消息总线:跨 iot-biz 和 iot-gateway 的设备消息。可选择使用 spring event、redis stream、rocketmq、kafka、rabbitmq 等。 |
| 19 | 19 | 2. 查询设备信息的通用 API |
| 20 | + 3. 数据解析脚本:执行产品配置的 JS 脚本,供 iot-biz 和 iot-gateway 共用 | |
| 20 | 21 | </description> |
| 21 | 22 | |
| 22 | 23 | <dependencies> |
| ... | ... | @@ -61,6 +62,12 @@ |
| 61 | 62 | <optional>true</optional> |
| 62 | 63 | </dependency> |
| 63 | 64 | |
| 65 | + <!-- JS 脚本引擎 --> | |
| 66 | + <dependency> | |
| 67 | + <groupId>org.mozilla</groupId> | |
| 68 | + <artifactId>rhino</artifactId> | |
| 69 | + </dependency> | |
| 70 | + | |
| 64 | 71 | <!-- 测试依赖 --> |
| 65 | 72 | <dependency> |
| 66 | 73 | <groupId>org.springframework.boot</groupId> | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceRespDTO.java
| ... | ... | @@ -41,5 +41,13 @@ public class IotDeviceRespDTO { |
| 41 | 41 | * 序列化类型 |
| 42 | 42 | */ |
| 43 | 43 | private String serializeType; |
| 44 | + /** | |
| 45 | + * 是否启用数据解析脚本 | |
| 46 | + */ | |
| 47 | + private Boolean scriptEnabled; | |
| 48 | + /** | |
| 49 | + * 数据解析脚本(JS ES5):只在启用时返回 | |
| 50 | + */ | |
| 51 | + private String scriptContent; | |
| 44 | 52 | |
| 45 | 53 | } |
| 46 | 54 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java
| ... | ... | @@ -71,6 +71,11 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable<String> { |
| 71 | 71 | |
| 72 | 72 | SHADOW_UPDATE("thing.shadow.update", "设备影子更新", true), |
| 73 | 73 | |
| 74 | + // ========== 透传 ========== | |
| 75 | + | |
| 76 | + RAW_POST("thing.raw.post", "透传上报", true), // 只能由网关的 raw 入口创建 | |
| 77 | + RAW_PUSH("thing.raw.push", "透传下发", false), | |
| 78 | + | |
| 74 | 79 | ; |
| 75 | 80 | |
| 76 | 81 | public static final String[] ARRAYS = Arrays.stream(values()).map(IotDeviceMessageMethodEnum::getMethod) |
| ... | ... | @@ -82,7 +87,8 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable<String> { |
| 82 | 87 | public static final Set<String> REPLY_DISABLED = SetUtils.asSet( |
| 83 | 88 | STATE_UPDATE.getMethod(), |
| 84 | 89 | OTA_PROGRESS.getMethod(), // 参考阿里云,OTA 升级进度上报,不进行回复 |
| 85 | - SHADOW_UPDATE.getMethod() // 设备影子由影子 Service 主动下发回复(thing/shadow/update_reply) | |
| 90 | + SHADOW_UPDATE.getMethod(), // 设备影子由影子 Service 主动下发回复(thing/shadow/update_reply) | |
| 91 | + RAW_POST.getMethod() // 透传原始消息(未解析为业务消息)只记日志 | |
| 86 | 92 | ); |
| 87 | 93 | |
| 88 | 94 | private final String method; | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java
| ... | ... | @@ -44,4 +44,11 @@ public enum IotProtocolTypeEnum implements ArrayValuable<String> { |
| 44 | 44 | return ArrayUtil.firstMatch(e -> e.getType().equals(type), values()); |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | + /** | |
| 48 | + * @return 是否是 Modbus 协议(client、server):透传字节按请求 PDU 校验 | |
| 49 | + */ | |
| 50 | + public boolean isModbus() { | |
| 51 | + return this == MODBUS_TCP_CLIENT || this == MODBUS_TCP_SERVER; | |
| 52 | + } | |
| 53 | + | |
| 47 | 54 | } | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java
| ... | ... | @@ -94,6 +94,16 @@ public class IotDeviceMessage { |
| 94 | 94 | */ |
| 95 | 95 | private String msg; |
| 96 | 96 | |
| 97 | + // ========== 透传(raw)相关字段 ========== | |
| 98 | + | |
| 99 | + /** | |
| 100 | + * 与设备之间实际收发的原始字节(Base64) | |
| 101 | + * | |
| 102 | + * 只由服务端可信赋值:网关的透传上行(解析成功的业务消息、或未解析的 thing.raw.post)、Modbus 设备的实际响应, | |
| 103 | + * 以及 biz 下发的 thing.raw.push;设备经标准路径上报的消息,网关会清空该字段 | |
| 104 | + */ | |
| 105 | + private String raw; | |
| 106 | + | |
| 97 | 107 | // ========== 基础方法:只传递"serialize(序列化)相关字段" ========== |
| 98 | 108 | |
| 99 | 109 | public static IotDeviceMessage requestOf(String method) { | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptException.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 4 | +import lombok.Getter; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * IoT 数据解析脚本异常:脚本执行错误({@link IotScriptResult.Status#ERROR}),或输出不合法({@link IotScriptResult.Status#OUTPUT_INVALID}) | |
| 8 | + * | |
| 9 | + * @author 芋道源码 | |
| 10 | + */ | |
| 11 | +@Getter | |
| 12 | +public class IotScriptException extends Exception { | |
| 13 | + | |
| 14 | + private final IotScriptResult.Status status; | |
| 15 | + /** | |
| 16 | + * 出错行号;未知时为 -1 | |
| 17 | + */ | |
| 18 | + private final int lineNumber; | |
| 19 | + | |
| 20 | + public IotScriptException(IotScriptResult.Status status, String message, int lineNumber) { | |
| 21 | + super(message, null, false, false); | |
| 22 | + this.status = status; | |
| 23 | + this.lineNumber = lineNumber; | |
| 24 | + } | |
| 25 | + | |
| 26 | + /** | |
| 27 | + * 创建输出不合法的异常 | |
| 28 | + * | |
| 29 | + * @param message 错误信息 | |
| 30 | + * @return 异常 | |
| 31 | + */ | |
| 32 | + public static IotScriptException outputInvalid(String message) { | |
| 33 | + return new IotScriptException(IotScriptResult.Status.OUTPUT_INVALID, message, -1); | |
| 34 | + } | |
| 35 | + | |
| 36 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptExecutor.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 5 | + | |
| 6 | +import java.util.concurrent.TimeUnit; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * IoT 数据解析脚本执行器:网关的 decode / encode,biz 的保存语法检查、调试共用 | |
| 10 | + * <p> | |
| 11 | + * 目前只有 Rhino 实现 {@link cn.iocoder.yudao.module.iot.core.script.rhino.IotRhinoScriptExecutor},由使用方直接创建 | |
| 12 | + * | |
| 13 | + * @author 芋道源码 | |
| 14 | + */ | |
| 15 | +public interface IotScriptExecutor { | |
| 16 | + | |
| 17 | + /** | |
| 18 | + * 执行一次脚本调用:调用方线程阻塞等待,最长到截止时间 | |
| 19 | + * | |
| 20 | + * @param source 脚本 | |
| 21 | + * @param function "decode" 或 "encode" | |
| 22 | + * @param ctxJson 平台构造的入参 JSON | |
| 23 | + * @param deadlineNanos 截止时间(System.nanoTime):覆盖排队与执行 | |
| 24 | + * @return 结果,状态见 {@link IotScriptResult.Status} | |
| 25 | + */ | |
| 26 | + IotScriptResult execute(IotScriptSource source, String function, String ctxJson, long deadlineNanos); | |
| 27 | + | |
| 28 | + /** | |
| 29 | + * 只做语法检查(不执行任何用户代码):调用方线程有界等待 | |
| 30 | + * | |
| 31 | + * @param content 脚本内容 | |
| 32 | + * @param timeout 等待时间 | |
| 33 | + * @param unit 时间单位 | |
| 34 | + * @return 结果:SUCCESS / ERROR(带行号)/ REJECTED / TIMEOUT | |
| 35 | + */ | |
| 36 | + IotScriptResult compileOnly(String content, long timeout, TimeUnit unit); | |
| 37 | + | |
| 38 | + /** | |
| 39 | + * 预热:启动时、接收流量之前调用一次 | |
| 40 | + */ | |
| 41 | + void warmUp(); | |
| 42 | + | |
| 43 | + /** | |
| 44 | + * 停止:不再接收新任务 | |
| 45 | + */ | |
| 46 | + void shutdown(); | |
| 47 | + | |
| 48 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptUtils.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.HexUtil; | |
| 4 | +import cn.hutool.core.util.StrUtil; | |
| 5 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 9 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 10 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 11 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 12 | +import lombok.AccessLevel; | |
| 13 | +import lombok.AllArgsConstructor; | |
| 14 | +import lombok.Getter; | |
| 15 | +import lombok.NoArgsConstructor; | |
| 16 | + | |
| 17 | +import java.nio.charset.StandardCharsets; | |
| 18 | +import java.util.Arrays; | |
| 19 | +import java.util.HashSet; | |
| 20 | +import java.util.Iterator; | |
| 21 | +import java.util.LinkedHashMap; | |
| 22 | +import java.util.Map; | |
| 23 | +import java.util.Objects; | |
| 24 | +import java.util.Set; | |
| 25 | +import java.util.stream.IntStream; | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * IoT 数据解析脚本的契约:入参构造与输出校验 | |
| 29 | + * <p> | |
| 30 | + * 网关所有协议和 biz 调试共用同一份规则,结果一致。任何一项不合法就整体拒绝({@link IotScriptResult.Status#OUTPUT_INVALID}),不截断、不部分写出 | |
| 31 | + * | |
| 32 | + * @author 芋道源码 | |
| 33 | + */ | |
| 34 | +@NoArgsConstructor(access = AccessLevel.PRIVATE) | |
| 35 | +public final class IotScriptUtils { | |
| 36 | + | |
| 37 | + public static final String FUNCTION_DECODE = "decode"; | |
| 38 | + public static final String FUNCTION_ENCODE = "encode"; | |
| 39 | + | |
| 40 | + /** | |
| 41 | + * 消息 params / data 序列化后的最大 UTF-8 字节数 | |
| 42 | + */ | |
| 43 | + public static final int MAX_FIELD_BYTES = 8192; | |
| 44 | + public static final int MAX_REQUEST_ID_LENGTH = 50; | |
| 45 | + public static final int MAX_IDENTIFIER_LENGTH = 100; | |
| 46 | + public static final int MAX_MSG_LENGTH = 256; | |
| 47 | + | |
| 48 | + private static final Set<String> TOP_ENCODE_KEYS = new HashSet<>(Arrays.asList("bytes", "standard")); | |
| 49 | + /** | |
| 50 | + * decode 输出的消息允许的键:身份、路由与可信字段(deviceId、tenantId、serverId、productKey、deviceName、id、reportTime、raw) | |
| 51 | + * 和其它未知键都不允许 | |
| 52 | + */ | |
| 53 | + private static final Set<String> MESSAGE_KEYS = new HashSet<>(Arrays.asList( | |
| 54 | + "method", "params", "requestId", "code", "msg", "data")); | |
| 55 | + /** | |
| 56 | + * decode 允许的请求方法 | |
| 57 | + */ | |
| 58 | + private static final Set<String> DECODE_REQUEST_METHODS = new HashSet<>(Arrays.asList( | |
| 59 | + IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), IotDeviceMessageMethodEnum.EVENT_POST.getMethod())); | |
| 60 | + | |
| 61 | + // ========== 入参构造 ========== | |
| 62 | + | |
| 63 | + /** | |
| 64 | + * 构造 decode 的入参 JSON | |
| 65 | + * | |
| 66 | + * @param protocol 协议类型 | |
| 67 | + * @param productKey 产品标识 | |
| 68 | + * @param deviceName 设备名称 | |
| 69 | + * @param receivedAt 接收时间(毫秒时间戳) | |
| 70 | + * @param bytes 原始字节 | |
| 71 | + * @param request encode 下行的原请求(协议回复设备的实际响应时);普通上行为 null | |
| 72 | + * @return JSON | |
| 73 | + */ | |
| 74 | + public static String buildDecodeContext(String protocol, String productKey, String deviceName, | |
| 75 | + long receivedAt, byte[] bytes, IotDeviceMessage request) { | |
| 76 | + Map<String, Object> ctx = new LinkedHashMap<>(); | |
| 77 | + ctx.put("direction", FUNCTION_DECODE); | |
| 78 | + ctx.put("protocol", protocol); | |
| 79 | + ctx.put("productKey", productKey); | |
| 80 | + ctx.put("deviceName", deviceName); | |
| 81 | + ctx.put("receivedAt", receivedAt); | |
| 82 | + ctx.put("bytes", toUnsignedArray(bytes)); | |
| 83 | + ctx.put("hex", HexUtil.encodeHexStr(bytes, false)); | |
| 84 | + if (request != null) { | |
| 85 | + ctx.put("request", buildMessageContext(request)); | |
| 86 | + } | |
| 87 | + return JsonUtils.toJsonString(ctx); | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** | |
| 91 | + * 构造 encode 的入参 JSON | |
| 92 | + * | |
| 93 | + * @param protocol 协议类型 | |
| 94 | + * @param productKey 产品标识 | |
| 95 | + * @param deviceName 设备名称 | |
| 96 | + * @param message 下行指令(property.set / service.invoke) | |
| 97 | + * @return JSON | |
| 98 | + */ | |
| 99 | + public static String buildEncodeContext(String protocol, String productKey, String deviceName, | |
| 100 | + IotDeviceMessage message) { | |
| 101 | + Map<String, Object> ctx = new LinkedHashMap<>(); | |
| 102 | + ctx.put("direction", FUNCTION_ENCODE); | |
| 103 | + ctx.put("protocol", protocol); | |
| 104 | + ctx.put("productKey", productKey); | |
| 105 | + ctx.put("deviceName", deviceName); | |
| 106 | + ctx.put("message", buildMessageContext(message)); | |
| 107 | + return JsonUtils.toJsonString(ctx); | |
| 108 | + } | |
| 109 | + | |
| 110 | + private static Map<String, Object> buildMessageContext(IotDeviceMessage message) { | |
| 111 | + Map<String, Object> map = new LinkedHashMap<>(); | |
| 112 | + map.put("method", message.getMethod()); | |
| 113 | + map.put("requestId", message.getRequestId()); | |
| 114 | + map.put("params", message.getParams()); | |
| 115 | + return map; | |
| 116 | + } | |
| 117 | + | |
| 118 | + private static int[] toUnsignedArray(byte[] bytes) { | |
| 119 | + return IntStream.range(0, bytes.length).map(i -> bytes[i] & 0xFF).toArray(); | |
| 120 | + } | |
| 121 | + | |
| 122 | + // ========== 输出校验 ========== | |
| 123 | + | |
| 124 | + /** | |
| 125 | + * 校验 decode 输出:一个标准消息对象(method、params、requestId、code、msg、data),与阿里云 rawDataToProtocol 一致 | |
| 126 | + * | |
| 127 | + * @param json 脚本输出;null 表示没有定义 decode,或返回了 null / undefined,即没有业务消息 | |
| 128 | + * @param request encode 下行的原请求(协议回复设备的实际响应时):此时必须返回回执,方法和 requestId 必须与原请求一致;普通上行为 null | |
| 129 | + * @return 业务消息(编号、时间、身份、raw 由调用方可信设置);null 表示没有业务消息(只有普通上行允许) | |
| 130 | + * @throws IotScriptException 输出不合法 | |
| 131 | + */ | |
| 132 | + public static IotDeviceMessage validateDecode(String json, IotDeviceMessage request) throws IotScriptException { | |
| 133 | + // 1. 没有业务消息:普通上行允许(只记录原始消息);事务响应必须有回执 | |
| 134 | + if (json == null) { | |
| 135 | + if (request != null) { | |
| 136 | + throw IotScriptException.outputInvalid("事务响应必须返回回执"); | |
| 137 | + } | |
| 138 | + return null; | |
| 139 | + } | |
| 140 | + // 2. 校验消息 | |
| 141 | + IotDeviceMessage message = validateMessage(parseObject(json, MESSAGE_KEYS)); | |
| 142 | + if (request != null) { | |
| 143 | + validateTransactionReply(message, request); | |
| 144 | + } | |
| 145 | + return message; | |
| 146 | + } | |
| 147 | + | |
| 148 | + /** | |
| 149 | + * 校验 encode 输出:脚本输出的结构,以及透传字节是否符合协议的规则({@link IotDeviceMessageUtils#validateRawPayload},Modbus 必须是合法的请求 PDU) | |
| 150 | + * <p> | |
| 151 | + * 网关的 encode、biz 的脚本调试共用;raw.push 不经过 encode,由调用方自己校验 | |
| 152 | + * | |
| 153 | + * @param json 脚本输出;null 表示没有定义 encode,或返回了 null / undefined | |
| 154 | + * @param protocol 协议类型:透传字节按它的规则校验 | |
| 155 | + * @return null 表示没有生成下行数据(调用方只记日志,不写设备) | |
| 156 | + * @throws IotScriptException 输出不合法 | |
| 157 | + */ | |
| 158 | + public static EncodeOutput validateEncode(String json, IotProtocolTypeEnum protocol) throws IotScriptException { | |
| 159 | + // 1. 没有生成下行数据 | |
| 160 | + if (json == null) { | |
| 161 | + return null; | |
| 162 | + } | |
| 163 | + // 2. 解析对象:只允许 bytes、standard,且只能返回一个 | |
| 164 | + JsonNode root = parseObject(json, TOP_ENCODE_KEYS); | |
| 165 | + JsonNode standard = root.get("standard"); | |
| 166 | + JsonNode bytesNode = root.get("bytes"); | |
| 167 | + if (standard != null && bytesNode != null) { | |
| 168 | + throw IotScriptException.outputInvalid("bytes 和 standard 只能返回一个"); | |
| 169 | + } | |
| 170 | + // 3.1 走现有的标准路径 | |
| 171 | + if (standard != null) { | |
| 172 | + if (!standard.isBoolean() || !standard.booleanValue()) { | |
| 173 | + throw IotScriptException.outputInvalid("standard 只能是 true"); | |
| 174 | + } | |
| 175 | + return new EncodeOutput(true, null); | |
| 176 | + } | |
| 177 | + // 3.2 透传字节:按协议的规则校验 | |
| 178 | + byte[] bytes = parseBytes(bytesNode, "bytes"); | |
| 179 | + try { | |
| 180 | + IotDeviceMessageUtils.validateRawPayload(protocol, bytes); | |
| 181 | + } catch (IllegalArgumentException e) { | |
| 182 | + throw IotScriptException.outputInvalid(e.getMessage()); | |
| 183 | + } | |
| 184 | + return new EncodeOutput(false, bytes); | |
| 185 | + } | |
| 186 | + | |
| 187 | + private static JsonNode parseObject(String json, Set<String> allowedKeys) throws IotScriptException { | |
| 188 | + // 1. 解析 JSON | |
| 189 | + JsonNode root; | |
| 190 | + try { | |
| 191 | + root = JsonUtils.parseTree(json); | |
| 192 | + } catch (RuntimeException e) { | |
| 193 | + throw IotScriptException.outputInvalid("脚本输出不是合法的 JSON"); | |
| 194 | + } | |
| 195 | + // 2. 必须是对象,且只包含允许的键 | |
| 196 | + if (root == null || !root.isObject()) { | |
| 197 | + throw IotScriptException.outputInvalid("脚本输出必须是对象"); | |
| 198 | + } | |
| 199 | + validateKeys(root, allowedKeys, "脚本输出"); | |
| 200 | + return root; | |
| 201 | + } | |
| 202 | + | |
| 203 | + private static void validateKeys(JsonNode node, Set<String> allowedKeys, String name) throws IotScriptException { | |
| 204 | + Iterator<String> it = node.fieldNames(); | |
| 205 | + while (it.hasNext()) { | |
| 206 | + String key = it.next(); | |
| 207 | + if (!allowedKeys.contains(key)) { | |
| 208 | + throw IotScriptException.outputInvalid(name + " 不允许包含字段 " + StrUtil.maxLength(key, 50)); | |
| 209 | + } | |
| 210 | + } | |
| 211 | + } | |
| 212 | + | |
| 213 | + private static IotDeviceMessage validateMessage(JsonNode node) throws IotScriptException { | |
| 214 | + // 1.1 method 必填 | |
| 215 | + String name = "message"; | |
| 216 | + String method = textOrNull(node, "method", name); | |
| 217 | + if (StrUtil.isEmpty(method)) { | |
| 218 | + throw IotScriptException.outputInvalid(name + ".method 不能为空"); | |
| 219 | + } | |
| 220 | + // 1.2 requestId、msg 的长度 | |
| 221 | + String requestId = textOrNull(node, "requestId", name); | |
| 222 | + if (requestId != null && requestId.length() > MAX_REQUEST_ID_LENGTH) { | |
| 223 | + throw IotScriptException.outputInvalid(name + ".requestId 不能超过 " + MAX_REQUEST_ID_LENGTH + " 个字符"); | |
| 224 | + } | |
| 225 | + String msg = textOrNull(node, "msg", name); | |
| 226 | + if (msg != null && msg.length() > MAX_MSG_LENGTH) { | |
| 227 | + throw IotScriptException.outputInvalid(name + ".msg 不能超过 " + MAX_MSG_LENGTH + " 个字符"); | |
| 228 | + } | |
| 229 | + // 1.3 params、data 序列化后的大小 | |
| 230 | + JsonNode params = nullIfJsonNull(node.get("params")); | |
| 231 | + JsonNode data = nullIfJsonNull(node.get("data")); | |
| 232 | + validateFieldSize(params, name + ".params"); | |
| 233 | + validateFieldSize(data, name + ".data"); | |
| 234 | + // 1.4 code:有值表示回执 | |
| 235 | + JsonNode codeNode = nullIfJsonNull(node.get("code")); | |
| 236 | + Integer code = null; | |
| 237 | + if (codeNode != null) { | |
| 238 | + if (!codeNode.isIntegralNumber() || !codeNode.canConvertToInt()) { | |
| 239 | + throw IotScriptException.outputInvalid(name + ".code 必须是整数"); | |
| 240 | + } | |
| 241 | + code = codeNode.intValue(); | |
| 242 | + } | |
| 243 | + // 2. 按请求、回执分别校验方法 | |
| 244 | + if (code == null) { | |
| 245 | + // 2.1 请求:属性上报、事件上报;事件必须带 identifier | |
| 246 | + if (!DECODE_REQUEST_METHODS.contains(method)) { | |
| 247 | + throw IotScriptException.outputInvalid(name + ".method 不支持 " + StrUtil.maxLength(method, 50)); | |
| 248 | + } | |
| 249 | + if (params == null || !params.isObject()) { | |
| 250 | + throw IotScriptException.outputInvalid(name + ".params 必须是对象"); | |
| 251 | + } | |
| 252 | + if (IotDeviceMessageMethodEnum.EVENT_POST.getMethod().equals(method)) { | |
| 253 | + JsonNode identifier = params.get("identifier"); | |
| 254 | + if (identifier == null || !identifier.isTextual() || StrUtil.isEmpty(identifier.textValue()) | |
| 255 | + || identifier.textValue().length() > MAX_IDENTIFIER_LENGTH) { | |
| 256 | + throw IotScriptException.outputInvalid(name + ".params.identifier 必须是 1~" + MAX_IDENTIFIER_LENGTH + " 个字符"); | |
| 257 | + } | |
| 258 | + } | |
| 259 | + } else { | |
| 260 | + // 2.2 回执:属性设置、服务调用;requestId 不能为空 | |
| 261 | + if (!IotDeviceMessageUtils.isCommandMethod(method)) { | |
| 262 | + throw IotScriptException.outputInvalid(name + ".method 不支持回执 " + StrUtil.maxLength(method, 50)); | |
| 263 | + } | |
| 264 | + if (StrUtil.isEmpty(requestId)) { | |
| 265 | + throw IotScriptException.outputInvalid(name + ".requestId 不能为空"); | |
| 266 | + } | |
| 267 | + } | |
| 268 | + return IotDeviceMessage.of(requestId, method, toValue(params), toValue(data), code, msg); | |
| 269 | + } | |
| 270 | + | |
| 271 | + private static void validateTransactionReply(IotDeviceMessage reply, IotDeviceMessage request) | |
| 272 | + throws IotScriptException { | |
| 273 | + if (reply.getCode() == null || !Objects.equals(reply.getMethod(), request.getMethod()) | |
| 274 | + || !Objects.equals(reply.getRequestId(), request.getRequestId())) { | |
| 275 | + throw IotScriptException.outputInvalid("事务响应的回执方法和 requestId 必须与原请求一致"); | |
| 276 | + } | |
| 277 | + } | |
| 278 | + | |
| 279 | + private static String textOrNull(JsonNode node, String field, String name) throws IotScriptException { | |
| 280 | + JsonNode value = nullIfJsonNull(node.get(field)); | |
| 281 | + if (value == null) { | |
| 282 | + return null; | |
| 283 | + } | |
| 284 | + if (!value.isTextual()) { | |
| 285 | + throw IotScriptException.outputInvalid(name + "." + field + " 必须是字符串"); | |
| 286 | + } | |
| 287 | + return value.textValue(); | |
| 288 | + } | |
| 289 | + | |
| 290 | + private static void validateFieldSize(JsonNode value, String name) throws IotScriptException { | |
| 291 | + if (value != null && value.toString().getBytes(StandardCharsets.UTF_8).length > MAX_FIELD_BYTES) { | |
| 292 | + throw IotScriptException.outputInvalid(name + " 不能超过 " + MAX_FIELD_BYTES + " 字节"); | |
| 293 | + } | |
| 294 | + } | |
| 295 | + | |
| 296 | + /** | |
| 297 | + * 解析字节数组:每个元素都是 0~255 的整数;长度之后按协议的规则校验(脚本输出最多 65536 字符,数组不会过大) | |
| 298 | + */ | |
| 299 | + private static byte[] parseBytes(JsonNode node, String name) throws IotScriptException { | |
| 300 | + node = nullIfJsonNull(node); | |
| 301 | + if (node == null) { | |
| 302 | + throw IotScriptException.outputInvalid(name + " 不能为空"); | |
| 303 | + } | |
| 304 | + if (!node.isArray()) { | |
| 305 | + throw IotScriptException.outputInvalid(name + " 必须是数组"); | |
| 306 | + } | |
| 307 | + byte[] bytes = new byte[node.size()]; | |
| 308 | + for (int i = 0; i < node.size(); i++) { | |
| 309 | + JsonNode item = node.get(i); | |
| 310 | + if (!item.isIntegralNumber() || !item.canConvertToInt() || item.intValue() < 0 || item.intValue() > 255) { | |
| 311 | + throw IotScriptException.outputInvalid(name + "[" + i + "] 必须是 0~255 的整数"); | |
| 312 | + } | |
| 313 | + bytes[i] = (byte) item.intValue(); | |
| 314 | + } | |
| 315 | + return bytes; | |
| 316 | + } | |
| 317 | + | |
| 318 | + private static JsonNode nullIfJsonNull(JsonNode node) { | |
| 319 | + return node == null || node.isNull() ? null : node; | |
| 320 | + } | |
| 321 | + | |
| 322 | + private static Object toValue(JsonNode node) { | |
| 323 | + return node == null ? null : JsonUtils.getObjectMapper().convertValue(node, Object.class); | |
| 324 | + } | |
| 325 | + | |
| 326 | + /** | |
| 327 | + * encode 输出:二选一 | |
| 328 | + */ | |
| 329 | + @Getter | |
| 330 | + @AllArgsConstructor | |
| 331 | + public static final class EncodeOutput { | |
| 332 | + | |
| 333 | + /** | |
| 334 | + * true = 走现有的标准路径 | |
| 335 | + */ | |
| 336 | + private final boolean standard; | |
| 337 | + /** | |
| 338 | + * 下行字节 | |
| 339 | + */ | |
| 340 | + private final byte[] bytes; | |
| 341 | + | |
| 342 | + } | |
| 343 | + | |
| 344 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/dto/IotScriptResult.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script.dto; | |
| 2 | + | |
| 3 | +import lombok.AccessLevel; | |
| 4 | +import lombok.AllArgsConstructor; | |
| 5 | +import lombok.Getter; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * IoT 数据解析脚本的一次调用结果 | |
| 9 | + * <p> | |
| 10 | + * core 不依赖 biz / gateway 的错误码:由调用方按 {@link Status} 翻译为各自的 ErrorCode | |
| 11 | + * | |
| 12 | + * @author 芋道源码 | |
| 13 | + */ | |
| 14 | +@Getter | |
| 15 | +@AllArgsConstructor(access = AccessLevel.PRIVATE) | |
| 16 | +public final class IotScriptResult { | |
| 17 | + | |
| 18 | + /** | |
| 19 | + * 调用结果的状态 | |
| 20 | + */ | |
| 21 | + public enum Status { | |
| 22 | + | |
| 23 | + /** | |
| 24 | + * 执行成功(json 可能为 null:没有定义该函数,或返回了 null / undefined) | |
| 25 | + */ | |
| 26 | + SUCCESS, | |
| 27 | + /** | |
| 28 | + * 脚本异常:语法错误、运行错误、throw、超出栈深等 | |
| 29 | + */ | |
| 30 | + ERROR, | |
| 31 | + /** | |
| 32 | + * 超时:排队超过截止时间、超过执行预算(时间或指令) | |
| 33 | + */ | |
| 34 | + TIMEOUT, | |
| 35 | + /** | |
| 36 | + * 拒绝:JS 线程池已满、已熔断 | |
| 37 | + */ | |
| 38 | + REJECTED, | |
| 39 | + /** | |
| 40 | + * 输出不合法 | |
| 41 | + */ | |
| 42 | + OUTPUT_INVALID | |
| 43 | + | |
| 44 | + } | |
| 45 | + | |
| 46 | + // DONE @AI:字段注释,还是要写下 | |
| 47 | + /** | |
| 48 | + * 状态 | |
| 49 | + */ | |
| 50 | + private final Status status; | |
| 51 | + /** | |
| 52 | + * 结果 JSON;只有 SUCCESS 时可能非空 | |
| 53 | + */ | |
| 54 | + private final String json; | |
| 55 | + /** | |
| 56 | + * 错误信息 | |
| 57 | + */ | |
| 58 | + private final String message; | |
| 59 | + /** | |
| 60 | + * 出错行号;未知时为 -1 | |
| 61 | + */ | |
| 62 | + private final int lineNumber; | |
| 63 | + /** | |
| 64 | + * 执行耗时(毫秒) | |
| 65 | + */ | |
| 66 | + private final long durationMillis; | |
| 67 | + | |
| 68 | + public static IotScriptResult success(String json, long durationMillis) { | |
| 69 | + return new IotScriptResult(Status.SUCCESS, json, null, -1, durationMillis); | |
| 70 | + } | |
| 71 | + | |
| 72 | + public static IotScriptResult error(String message, long durationMillis) { | |
| 73 | + return new IotScriptResult(Status.ERROR, null, message, -1, durationMillis); | |
| 74 | + } | |
| 75 | + | |
| 76 | + public static IotScriptResult timeout(String message) { | |
| 77 | + return new IotScriptResult(Status.TIMEOUT, null, message, -1, 0); | |
| 78 | + } | |
| 79 | + | |
| 80 | + public static IotScriptResult rejected(String message) { | |
| 81 | + return new IotScriptResult(Status.REJECTED, null, message, -1, 0); | |
| 82 | + } | |
| 83 | + | |
| 84 | + public static IotScriptResult of(Status status, String message, int lineNumber, long durationMillis) { | |
| 85 | + return new IotScriptResult(status, null, message, lineNumber, durationMillis); | |
| 86 | + } | |
| 87 | + | |
| 88 | + public boolean isSuccess() { | |
| 89 | + return status == Status.SUCCESS; | |
| 90 | + } | |
| 91 | + | |
| 92 | + public boolean isTimeout() { | |
| 93 | + return status == Status.TIMEOUT; | |
| 94 | + } | |
| 95 | + | |
| 96 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/dto/IotScriptSource.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script.dto; | |
| 2 | + | |
| 3 | +import cn.hutool.crypto.digest.DigestUtil; | |
| 4 | +import lombok.Getter; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * IoT 数据解析脚本:不可变对象 {productId, hash, content} | |
| 8 | + * <p> | |
| 9 | + * hash = 源码 UTF-8 的完整 SHA-256(64 位 hex),作为缓存和编译身份;{@link #getShortHash()} 只用于日志和界面。 | |
| 10 | + * 源码字符串可以被多个线程只读共享,编译结果不共享 | |
| 11 | + * | |
| 12 | + * @author 芋道源码 | |
| 13 | + */ | |
| 14 | +@Getter | |
| 15 | +public final class IotScriptSource { | |
| 16 | + | |
| 17 | + /** | |
| 18 | + * 产品编号;调试时为 null | |
| 19 | + */ | |
| 20 | + private final Long productId; | |
| 21 | + /** | |
| 22 | + * 源码 UTF-8 的完整 SHA-256 | |
| 23 | + */ | |
| 24 | + private final String hash; | |
| 25 | + /** | |
| 26 | + * 源码 | |
| 27 | + */ | |
| 28 | + private final String content; | |
| 29 | + /** | |
| 30 | + * 编译缓存的 key:productId:hash;调试时为 debug:hash | |
| 31 | + */ | |
| 32 | + private final String key; | |
| 33 | + | |
| 34 | + private IotScriptSource(Long productId, String content) { | |
| 35 | + this.productId = productId; | |
| 36 | + this.content = content; | |
| 37 | + this.hash = DigestUtil.sha256Hex(content); | |
| 38 | + this.key = (productId != null ? productId.toString() : "debug") + ":" + hash; | |
| 39 | + } | |
| 40 | + | |
| 41 | + public static IotScriptSource of(Long productId, String content) { | |
| 42 | + return new IotScriptSource(productId, content); | |
| 43 | + } | |
| 44 | + | |
| 45 | + public static IotScriptSource ofDebug(String content) { | |
| 46 | + return new IotScriptSource(null, content); | |
| 47 | + } | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * @return hash 前 12 位:只用于日志和界面,不作为唯一标识 | |
| 51 | + */ | |
| 52 | + public String getShortHash() { | |
| 53 | + return hash.substring(0, 12); | |
| 54 | + } | |
| 55 | + | |
| 56 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/package-info.java
0 → 100644
| 1 | +/** | |
| 2 | + * IoT 数据解析脚本:网关所有协议与 biz 调试共用 | |
| 3 | + * <ul> | |
| 4 | + * <li>本包:定义。{@link cn.iocoder.yudao.module.iot.core.script.IotScriptExecutor} 执行器接口、 | |
| 5 | + * {@link cn.iocoder.yudao.module.iot.core.script.IotScriptUtils} 脚本契约(入参构造、输出校验)、 | |
| 6 | + * {@link cn.iocoder.yudao.module.iot.core.script.IotScriptException} 脚本异常;</li> | |
| 7 | + * <li>dto:调用双方传递的数据。{@link cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource} 脚本、 | |
| 8 | + * {@link cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult} 结果;</li> | |
| 9 | + * <li>rhino:Rhino 实现。{@link cn.iocoder.yudao.module.iot.core.script.rhino.IotRhinoScriptExecutor}, | |
| 10 | + * 包内其余类(运行时、ContextFactory、执行预算)为包私有的内部实现。</li> | |
| 11 | + * </ul> | |
| 12 | + */ | |
| 13 | +package cn.iocoder.yudao.module.iot.core.script; | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotRhinoScriptExecutor.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script.rhino; | |
| 2 | + | |
| 3 | +import cn.hutool.core.thread.NamedThreadFactory; | |
| 4 | +import cn.hutool.core.util.StrUtil; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptException; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptExecutor; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 9 | +import lombok.Data; | |
| 10 | +import lombok.Getter; | |
| 11 | +import lombok.Setter; | |
| 12 | +import lombok.experimental.Accessors; | |
| 13 | +import lombok.extern.slf4j.Slf4j; | |
| 14 | + | |
| 15 | +import java.util.ArrayDeque; | |
| 16 | +import java.util.ArrayList; | |
| 17 | +import java.util.Deque; | |
| 18 | +import java.util.List; | |
| 19 | +import java.util.Map; | |
| 20 | +import java.util.Set; | |
| 21 | +import java.util.concurrent.ArrayBlockingQueue; | |
| 22 | +import java.util.concurrent.CompletableFuture; | |
| 23 | +import java.util.concurrent.ConcurrentHashMap; | |
| 24 | +import java.util.concurrent.CyclicBarrier; | |
| 25 | +import java.util.concurrent.ExecutionException; | |
| 26 | +import java.util.concurrent.Future; | |
| 27 | +import java.util.concurrent.RejectedExecutionException; | |
| 28 | +import java.util.concurrent.ScheduledFuture; | |
| 29 | +import java.util.concurrent.ScheduledThreadPoolExecutor; | |
| 30 | +import java.util.concurrent.ThreadPoolExecutor; | |
| 31 | +import java.util.concurrent.TimeUnit; | |
| 32 | +import java.util.concurrent.TimeoutException; | |
| 33 | +import java.util.concurrent.atomic.AtomicInteger; | |
| 34 | + | |
| 35 | +// DONE @AI:字段注释; | |
| 36 | +/** | |
| 37 | + * IoT 数据解析脚本执行器:同一个 JVM 内执行,不是沙箱 | |
| 38 | + * <p> | |
| 39 | + * Rhino 下,经过指令检查点的超限会真正中断执行、释放线程;不经过检查点的原生长操作只能被标记为超时,线程计入卡住检测。 | |
| 40 | + * 本节点的熔断(按 productId:hash,或卡住线程过多时全局)只在内存中生效,不写库,重启后复位 | |
| 41 | + * | |
| 42 | + * @author 芋道源码 | |
| 43 | + */ | |
| 44 | +@Slf4j | |
| 45 | +public class IotRhinoScriptExecutor implements IotScriptExecutor { | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * 熔断:1 分钟内超时达到该次数 | |
| 49 | + */ | |
| 50 | + private static final int BREAKER_TIMEOUT_COUNT = 3; | |
| 51 | + // DONE @AI:字段注释; | |
| 52 | + /** | |
| 53 | + * 熔断:统计超时次数的时间窗口(1 分钟) | |
| 54 | + */ | |
| 55 | + private static final long BREAKER_TIMEOUT_WINDOW_NANOS = TimeUnit.MINUTES.toNanos(1); | |
| 56 | + | |
| 57 | + // DONE @AI:字段注释; | |
| 58 | + /** | |
| 59 | + * IoT 专用的 Rhino ContextFactory:所有 JS 线程共用,不使用全局 ContextFactory | |
| 60 | + */ | |
| 61 | + private final IotScriptContextFactory contextFactory = new IotScriptContextFactory(); | |
| 62 | + // DONE @AI:字段注释; | |
| 63 | + /** | |
| 64 | + * 每个 JS 线程独占的 Rhino 运行时:预热或首次调用时创建,不跨线程共享 | |
| 65 | + */ | |
| 66 | + private final ThreadLocal<IotScriptRuntime> runtimes = new ThreadLocal<>(); | |
| 67 | + | |
| 68 | + // DONE @AI:字段注释; | |
| 69 | + /** | |
| 70 | + * 执行器名称:线程名前缀,也用于日志 | |
| 71 | + */ | |
| 72 | + private final String name; | |
| 73 | + /** | |
| 74 | + * JS 线程池:核心数 = 最大数,有界队列,AbortPolicy | |
| 75 | + */ | |
| 76 | + private final ThreadPoolExecutor jsPool; | |
| 77 | + /** | |
| 78 | + * 单线程计时器:执行预算兜底、卡住扫描;不执行业务回调。由本执行器创建,{@link #shutdown()} 时关闭 | |
| 79 | + */ | |
| 80 | + private final ScheduledThreadPoolExecutor timer; | |
| 81 | + /** | |
| 82 | + * 单次执行的时间预算(纳秒):实际预算取它与截止时间中较早的一个 | |
| 83 | + */ | |
| 84 | + private final long execBudgetNanos; | |
| 85 | + /** | |
| 86 | + * 单次执行的指令上限 | |
| 87 | + */ | |
| 88 | + private final long maxInstructions; | |
| 89 | + /** | |
| 90 | + * 运行超过该时长视为卡住(纳秒) | |
| 91 | + */ | |
| 92 | + private final long stuckNanos; | |
| 93 | + /** | |
| 94 | + * 卡住的线程达到该数量时全局熔断 | |
| 95 | + */ | |
| 96 | + private final int maxStuckThreads; | |
| 97 | + /** | |
| 98 | + * 卡住扫描的计时任务:每秒一次,{@link #shutdown()} 时取消 | |
| 99 | + */ | |
| 100 | + private final ScheduledFuture<?> stuckScanner; | |
| 101 | + | |
| 102 | + /** | |
| 103 | + * 正在执行的任务 → 开始时间 | |
| 104 | + */ | |
| 105 | + private final Map<IotScriptTask, Long> running = new ConcurrentHashMap<>(); | |
| 106 | + /** | |
| 107 | + * 已熔断的脚本 key(productId:hash) | |
| 108 | + */ | |
| 109 | + private final Set<String> openKeys = ConcurrentHashMap.newKeySet(); | |
| 110 | + /** | |
| 111 | + * 最近的执行超时时间:key → 时间(nanoTime) | |
| 112 | + */ | |
| 113 | + private final Map<String, Deque<Long>> recentTimeouts = new ConcurrentHashMap<>(); | |
| 114 | + /** | |
| 115 | + * 当前卡住的线程数:每秒扫描一次 | |
| 116 | + */ | |
| 117 | + private volatile int stuckThreads; | |
| 118 | + /** | |
| 119 | + * 预热失败:全局熔断,需要重启 | |
| 120 | + */ | |
| 121 | + private volatile boolean warmUpFailed; | |
| 122 | + | |
| 123 | + public IotRhinoScriptExecutor(Options options) { | |
| 124 | + this.name = options.getName(); | |
| 125 | + this.jsPool = new ThreadPoolExecutor(options.getPoolSize(), options.getPoolSize(), 0L, TimeUnit.MILLISECONDS, | |
| 126 | + new ArrayBlockingQueue<>(options.getQueueCapacity()), new NamedThreadFactory(options.getName() + "-", true), | |
| 127 | + new ThreadPoolExecutor.AbortPolicy()); | |
| 128 | + this.timer = new ScheduledThreadPoolExecutor(1, new NamedThreadFactory(options.getName() + "-timer-", true)); | |
| 129 | + this.timer.setRemoveOnCancelPolicy(true); // 被取消的计时任务立即移出队列,不留到原截止时间 | |
| 130 | + this.execBudgetNanos = TimeUnit.MILLISECONDS.toNanos(options.getExecBudgetMillis()); | |
| 131 | + this.maxInstructions = options.getMaxInstructions(); | |
| 132 | + this.stuckNanos = TimeUnit.MILLISECONDS.toNanos(options.getStuckMillis()); | |
| 133 | + this.maxStuckThreads = Math.max(1, options.getMaxStuckThreads()); | |
| 134 | + this.stuckScanner = timer.scheduleWithFixedDelay(this::scanStuck, 1, 1, TimeUnit.SECONDS); | |
| 135 | + } | |
| 136 | + | |
| 137 | + /** | |
| 138 | + * 执行一次脚本调用:调用方线程(网关为 Vert.x worker、biz 调试为请求线程)阻塞等待,最长到截止时间 | |
| 139 | + * <p> | |
| 140 | + * 脚本在 JS 线程池执行,受执行预算(时间、指令)约束;等待到期时,还在排队的任务被终结,之后不会再启动 | |
| 141 | + * | |
| 142 | + * @param source 脚本 | |
| 143 | + * @param function "decode" 或 "encode" | |
| 144 | + * @param ctxJson 平台构造的入参 JSON | |
| 145 | + * @param deadlineNanos 截止时间(System.nanoTime):覆盖排队与执行 | |
| 146 | + * @return 结果:SUCCESS / ERROR / TIMEOUT / REJECTED | |
| 147 | + */ | |
| 148 | + @Override | |
| 149 | + public IotScriptResult execute(IotScriptSource source, String function, String ctxJson, long deadlineNanos) { | |
| 150 | + if (isOpen(source)) { | |
| 151 | + return IotScriptResult.rejected("脚本已熔断"); | |
| 152 | + } | |
| 153 | + long remain = deadlineNanos - System.nanoTime(); | |
| 154 | + if (remain <= 0) { | |
| 155 | + return IotScriptResult.timeout("进入 JS 池前已超过截止时间"); | |
| 156 | + } | |
| 157 | + IotScriptTask task = new IotScriptTask(source); | |
| 158 | + try { | |
| 159 | + jsPool.execute(() -> run(task, function, ctxJson, deadlineNanos)); | |
| 160 | + } catch (RejectedExecutionException e) { | |
| 161 | + return IotScriptResult.rejected("脚本线程池已满"); | |
| 162 | + } | |
| 163 | + try { | |
| 164 | + return task.getResult().get(remain, TimeUnit.NANOSECONDS); | |
| 165 | + } catch (TimeoutException e) { | |
| 166 | + // 到期:排队中 PENDING → DONE(结果为超时),之后 markRunning 失败,不会启动;执行中的由预算中断或计入卡住检测 | |
| 167 | + finish(task, IotScriptResult.timeout("超过截止时间")); | |
| 168 | + } catch (InterruptedException e) { | |
| 169 | + Thread.currentThread().interrupt(); | |
| 170 | + finish(task, IotScriptResult.error("等待脚本结果被中断", 0)); | |
| 171 | + } catch (ExecutionException e) { | |
| 172 | + finish(task, IotScriptResult.error(StrUtil.maxLength(String.valueOf(e.getCause()), 200), 0)); | |
| 173 | + } | |
| 174 | + return task.getResult().join(); // finish 只会成功一次:这里拿到的是最终终结的那个结果 | |
| 175 | + } | |
| 176 | + | |
| 177 | + /** | |
| 178 | + * 只做语法检查(不执行任何用户代码):在 JS 线程上编译,调用方线程有界等待 | |
| 179 | + * | |
| 180 | + * @param content 脚本内容 | |
| 181 | + * @param timeout 等待时间 | |
| 182 | + * @param unit 时间单位 | |
| 183 | + * @return 结果:SUCCESS / ERROR(带行号)/ REJECTED / TIMEOUT | |
| 184 | + */ | |
| 185 | + @Override | |
| 186 | + public IotScriptResult compileOnly(String content, long timeout, TimeUnit unit) { | |
| 187 | + if (isGlobalOpen()) { | |
| 188 | + return IotScriptResult.rejected("脚本已熔断"); | |
| 189 | + } | |
| 190 | + Future<IotScriptResult> future; | |
| 191 | + try { | |
| 192 | + future = jsPool.submit(() -> { | |
| 193 | + try { | |
| 194 | + runtime().compileOnly(content); | |
| 195 | + return IotScriptResult.success(null, 0); | |
| 196 | + } catch (IotScriptException e) { | |
| 197 | + return IotScriptResult.of(e.getStatus(), e.getMessage(), e.getLineNumber(), 0); | |
| 198 | + } | |
| 199 | + }); | |
| 200 | + } catch (RejectedExecutionException e) { | |
| 201 | + return IotScriptResult.rejected("脚本线程池已满"); | |
| 202 | + } | |
| 203 | + try { | |
| 204 | + return future.get(timeout, unit); | |
| 205 | + } catch (TimeoutException e) { | |
| 206 | + future.cancel(false); | |
| 207 | + return IotScriptResult.timeout("编译超时"); | |
| 208 | + } catch (InterruptedException e) { | |
| 209 | + Thread.currentThread().interrupt(); | |
| 210 | + return IotScriptResult.error("编译被中断", 0); | |
| 211 | + } catch (ExecutionException e) { | |
| 212 | + return IotScriptResult.error(StrUtil.maxLength(String.valueOf(e.getCause()), 200), 0); | |
| 213 | + } | |
| 214 | + } | |
| 215 | + | |
| 216 | + private void run(IotScriptTask task, String function, String ctxJson, long deadlineNanos) { | |
| 217 | + if (System.nanoTime() >= deadlineNanos) { // 出队时已过期(等待方通常已先终结;finish 只会成功一次) | |
| 218 | + finish(task, IotScriptResult.timeout("排队期间超过截止时间")); | |
| 219 | + return; | |
| 220 | + } | |
| 221 | + if (!task.markRunning()) { // 已被超时或拒绝终结:不启动 | |
| 222 | + return; | |
| 223 | + } | |
| 224 | + long start = System.nanoTime(); | |
| 225 | + long budgetDeadline = Math.min(start + execBudgetNanos, deadlineNanos); | |
| 226 | + // 兜底:观察器到不了的原生长操作 | |
| 227 | + try { | |
| 228 | + task.setBudgetTimer(timer.schedule(() -> finishTimeoutRunning(task, "执行超过预算"), | |
| 229 | + Math.max(0L, budgetDeadline - start), TimeUnit.NANOSECONDS)); | |
| 230 | + } catch (RejectedExecutionException e) { // 停机时 timer 已关闭:不执行,直接终结 | |
| 231 | + finish(task, IotScriptResult.rejected("脚本执行器已关闭")); | |
| 232 | + return; | |
| 233 | + } | |
| 234 | + running.put(task, start); | |
| 235 | + try { | |
| 236 | + IotScriptRuntime runtime = runtime(); // 正常已在启动时预热;线程被替换后为冷启动,耗时计入本次预算 | |
| 237 | + if (System.nanoTime() >= budgetDeadline || task.isFinished()) { // 实际调用前再校验一次 | |
| 238 | + finish(task, IotScriptResult.timeout("调用前已超过截止时间")); | |
| 239 | + return; | |
| 240 | + } | |
| 241 | + // 顶层代码、入参原生化、调用、序列化共用这一个预算 | |
| 242 | + String json = runtime.invoke(task.getSource(), function, ctxJson, | |
| 243 | + new IotScriptBudget(budgetDeadline, maxInstructions)); | |
| 244 | + finish(task, IotScriptResult.success(json, elapsedMillis(start))); | |
| 245 | + } catch (IotScriptBudget.BudgetExceededError e) { | |
| 246 | + finishTimeoutRunning(task, e.getMessage()); // 观察器中断:线程已经回到这里,可以继续使用 | |
| 247 | + } catch (IotScriptException e) { | |
| 248 | + finish(task, IotScriptResult.of(e.getStatus(), e.getMessage(), e.getLineNumber(), elapsedMillis(start))); | |
| 249 | + } catch (StackOverflowError e) { | |
| 250 | + finish(task, IotScriptResult.error("调用层级过深", elapsedMillis(start))); // 原生递归可能绕过解释器栈深限制 | |
| 251 | + } catch (RuntimeException e) { | |
| 252 | + finish(task, IotScriptResult.error(StrUtil.maxLength(e.getMessage(), 200), elapsedMillis(start))); | |
| 253 | + } finally { | |
| 254 | + running.remove(task); // OutOfMemoryError 等其它 Error 不捕获,交给 JVM 现有的处理 | |
| 255 | + } | |
| 256 | + } | |
| 257 | + | |
| 258 | + /** | |
| 259 | + * 执行中的超时:计入熔断统计 | |
| 260 | + */ | |
| 261 | + private void finishTimeoutRunning(IotScriptTask task, String message) { | |
| 262 | + if (finish(task, IotScriptResult.timeout(message))) { | |
| 263 | + recordTimeout(task.getSource()); | |
| 264 | + } | |
| 265 | + } | |
| 266 | + | |
| 267 | + /** | |
| 268 | + * 只终结一次:终结的那个结果交给等待中的调用方 | |
| 269 | + * | |
| 270 | + * @return 是否由本次调用终结 | |
| 271 | + */ | |
| 272 | + private boolean finish(IotScriptTask task, IotScriptResult result) { | |
| 273 | + if (!task.finish()) { | |
| 274 | + return false; // 已终结:迟到的结果直接丢弃 | |
| 275 | + } | |
| 276 | + task.getResult().complete(result); | |
| 277 | + return true; | |
| 278 | + } | |
| 279 | + | |
| 280 | + private IotScriptRuntime runtime() { | |
| 281 | + IotScriptRuntime runtime = runtimes.get(); | |
| 282 | + if (runtime == null) { // 只有预热,或线程被替换后的首次调用才会进入:创建封存的标准对象 | |
| 283 | + runtime = new IotScriptRuntime(contextFactory); | |
| 284 | + runtimes.set(runtime); | |
| 285 | + } | |
| 286 | + return runtime; | |
| 287 | + } | |
| 288 | + | |
| 289 | + /** | |
| 290 | + * 启动时调用一次,在接收流量之前:N 个任务通过屏障同时占住 N 个不同的 worker,保证每个 worker 都实际创建了 runtime | |
| 291 | + * <p> | |
| 292 | + * 失败 → 全局熔断(记 ERROR),之后的调用都按「已熔断」拒绝 | |
| 293 | + */ | |
| 294 | + @Override | |
| 295 | + public void warmUp() { | |
| 296 | + int n = jsPool.getCorePoolSize(); | |
| 297 | + try { | |
| 298 | + jsPool.prestartAllCoreThreads(); | |
| 299 | + CyclicBarrier barrier = new CyclicBarrier(n); | |
| 300 | + List<Future<?>> futures = new ArrayList<>(n); | |
| 301 | + for (int i = 0; i < n; i++) { | |
| 302 | + futures.add(jsPool.submit(() -> { | |
| 303 | + barrier.await(10, TimeUnit.SECONDS); | |
| 304 | + return runtime(); | |
| 305 | + })); | |
| 306 | + } | |
| 307 | + for (Future<?> future : futures) { | |
| 308 | + future.get(30, TimeUnit.SECONDS); | |
| 309 | + } | |
| 310 | + } catch (Exception e) { | |
| 311 | + if (e instanceof InterruptedException) { | |
| 312 | + Thread.currentThread().interrupt(); | |
| 313 | + } | |
| 314 | + warmUpFailed = true; | |
| 315 | + log.error("[warmUp][{} 预热失败,本节点脚本执行全局熔断]", name, e); | |
| 316 | + } | |
| 317 | + } | |
| 318 | + | |
| 319 | + /** | |
| 320 | + * 停止:不再接收新任务,正在执行的脚本由预算中断后退出;同时关闭本执行器的计时器(停机后提交的预算计时被拒绝,任务直接终结) | |
| 321 | + */ | |
| 322 | + @Override | |
| 323 | + public void shutdown() { | |
| 324 | + stuckScanner.cancel(false); | |
| 325 | + jsPool.shutdown(); | |
| 326 | + timer.shutdownNow(); | |
| 327 | + } | |
| 328 | + | |
| 329 | + // ========== 熔断(只在本节点生效,不写库) ========== | |
| 330 | + | |
| 331 | + /** | |
| 332 | + * 是否已熔断:预热失败、卡住的线程过多(全局),或该脚本已被熔断 | |
| 333 | + */ | |
| 334 | + public boolean isOpen(IotScriptSource source) { | |
| 335 | + return isGlobalOpen() || openKeys.contains(source.getKey()); | |
| 336 | + } | |
| 337 | + | |
| 338 | + private boolean isGlobalOpen() { | |
| 339 | + return warmUpFailed || stuckThreads >= maxStuckThreads; | |
| 340 | + } | |
| 341 | + | |
| 342 | + private void recordTimeout(IotScriptSource source) { | |
| 343 | + long now = System.nanoTime(); | |
| 344 | + Deque<Long> times = recentTimeouts.computeIfAbsent(source.getKey(), key -> new ArrayDeque<>()); | |
| 345 | + synchronized (times) { | |
| 346 | + times.addLast(now); | |
| 347 | + while (!times.isEmpty() && now - times.peekFirst() > BREAKER_TIMEOUT_WINDOW_NANOS) { | |
| 348 | + times.pollFirst(); | |
| 349 | + } | |
| 350 | + if (times.size() < BREAKER_TIMEOUT_COUNT) { | |
| 351 | + return; | |
| 352 | + } | |
| 353 | + } | |
| 354 | + if (openKeys.add(source.getKey())) { | |
| 355 | + log.error("[recordTimeout][{} 脚本 1 分钟内超时 {} 次,本节点熔断 product({}) hash({})]", name, | |
| 356 | + BREAKER_TIMEOUT_COUNT, source.getProductId(), source.getShortHash()); | |
| 357 | + } | |
| 358 | + } | |
| 359 | + | |
| 360 | + /** | |
| 361 | + * 每秒执行一次:运行超过 stuckNanos 的任务记为卡住 → 本节点熔断这个 (productId, hash),记 ERROR;不调用 RPC、不改数据库开关 | |
| 362 | + */ | |
| 363 | + private void scanStuck() { | |
| 364 | + try { | |
| 365 | + long now = System.nanoTime(); | |
| 366 | + int stuck = 0; | |
| 367 | + for (Map.Entry<IotScriptTask, Long> entry : running.entrySet()) { | |
| 368 | + if (now - entry.getValue() <= stuckNanos) { | |
| 369 | + continue; | |
| 370 | + } | |
| 371 | + stuck++; | |
| 372 | + IotScriptSource source = entry.getKey().getSource(); | |
| 373 | + if (openKeys.add(source.getKey())) { | |
| 374 | + log.error("[scanStuck][{} 脚本卡住一个线程,本节点熔断 product({}) hash({})]", name, | |
| 375 | + source.getProductId(), source.getShortHash()); | |
| 376 | + } | |
| 377 | + } | |
| 378 | + if (stuck >= maxStuckThreads && stuckThreads < maxStuckThreads) { | |
| 379 | + log.error("[scanStuck][{} 卡住的线程数({}) 达到上限,本节点全局熔断;线程恢复后自动解除]", name, stuck); | |
| 380 | + } | |
| 381 | + stuckThreads = stuck; | |
| 382 | + } catch (RuntimeException e) { | |
| 383 | + log.error("[scanStuck][{} 卡住扫描异常]", name, e); | |
| 384 | + } | |
| 385 | + } | |
| 386 | + | |
| 387 | + private static long elapsedMillis(long startNanos) { | |
| 388 | + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); | |
| 389 | + } | |
| 390 | + | |
| 391 | + /** | |
| 392 | + * 执行器参数 | |
| 393 | + */ | |
| 394 | + @Data | |
| 395 | + @Accessors(chain = true) | |
| 396 | + public static class Options { | |
| 397 | + | |
| 398 | + /** | |
| 399 | + * 线程名前缀,如 iot-script-js | |
| 400 | + */ | |
| 401 | + private String name = "iot-script-js"; | |
| 402 | + /** | |
| 403 | + * 线程数 | |
| 404 | + */ | |
| 405 | + private int poolSize = 4; | |
| 406 | + /** | |
| 407 | + * 队列容量 | |
| 408 | + */ | |
| 409 | + private int queueCapacity = 200; | |
| 410 | + /** | |
| 411 | + * 单次执行的时间预算(毫秒) | |
| 412 | + */ | |
| 413 | + private long execBudgetMillis = 500; | |
| 414 | + /** | |
| 415 | + * 单次执行的指令上限 | |
| 416 | + */ | |
| 417 | + private long maxInstructions = 1_000_000; | |
| 418 | + /** | |
| 419 | + * 运行超过该时长视为卡住(毫秒) | |
| 420 | + */ | |
| 421 | + private long stuckMillis = 5000; | |
| 422 | + /** | |
| 423 | + * 卡住的线程达到该数量时全局熔断 | |
| 424 | + */ | |
| 425 | + private int maxStuckThreads = 3; | |
| 426 | + | |
| 427 | + } | |
| 428 | + | |
| 429 | + /** | |
| 430 | + * JS 任务:一个 AtomicInteger 状态机;JS 线程的「开始」与超时、拒绝、完成的「终结」竞争同一个状态 | |
| 431 | + * <p> | |
| 432 | + * 只代表「这一次脚本调用」的终态。终结时取消的是本任务自己的预算计时器,不涉及调用方(协议)的响应计时器 | |
| 433 | + */ | |
| 434 | + static final class IotScriptTask { | |
| 435 | + | |
| 436 | + /** | |
| 437 | + * 状态:排队中,还没开始 | |
| 438 | + */ | |
| 439 | + static final int PENDING = 0; | |
| 440 | + /** | |
| 441 | + * 状态:JS 线程执行中 | |
| 442 | + */ | |
| 443 | + static final int RUNNING = 1; | |
| 444 | + /** | |
| 445 | + * 状态:已终结(成功、错误、拒绝或超时;具体原因见终结的结果) | |
| 446 | + */ | |
| 447 | + static final int DONE = 2; | |
| 448 | + | |
| 449 | + /** | |
| 450 | + * 当前状态:PENDING → RUNNING(markRunning);PENDING 或 RUNNING → DONE(finish),只能终结一次 | |
| 451 | + */ | |
| 452 | + private final AtomicInteger state = new AtomicInteger(PENDING); | |
| 453 | + /** | |
| 454 | + * 本次调用的脚本 | |
| 455 | + */ | |
| 456 | + @Getter | |
| 457 | + private final IotScriptSource source; | |
| 458 | + /** | |
| 459 | + * 终结的结果:调用方在 {@link #execute} 中等待 | |
| 460 | + */ | |
| 461 | + @Getter | |
| 462 | + private final CompletableFuture<IotScriptResult> result = new CompletableFuture<>(); | |
| 463 | + /** | |
| 464 | + * 执行中的时间兜底(原生长操作不经过观察器时) | |
| 465 | + */ | |
| 466 | + @Setter | |
| 467 | + private volatile ScheduledFuture<?> budgetTimer; | |
| 468 | + | |
| 469 | + IotScriptTask(IotScriptSource source) { | |
| 470 | + this.source = source; | |
| 471 | + } | |
| 472 | + | |
| 473 | + /** | |
| 474 | + * JS 线程:只有 PENDING 才能开始;已被超时或拒绝终结的任务返回 false,不再启动 | |
| 475 | + */ | |
| 476 | + boolean markRunning() { | |
| 477 | + return state.compareAndSet(PENDING, RUNNING); | |
| 478 | + } | |
| 479 | + | |
| 480 | + /** | |
| 481 | + * 任意线程:PENDING 或 RUNNING → DONE;并发调用时只有一个返回 true | |
| 482 | + */ | |
| 483 | + boolean finish() { | |
| 484 | + for (;;) { | |
| 485 | + int s = state.get(); | |
| 486 | + if (s == DONE) { | |
| 487 | + return false; | |
| 488 | + } | |
| 489 | + if (state.compareAndSet(s, DONE)) { | |
| 490 | + ScheduledFuture<?> timer = budgetTimer; | |
| 491 | + if (timer != null) { | |
| 492 | + timer.cancel(false); | |
| 493 | + } | |
| 494 | + return true; | |
| 495 | + } | |
| 496 | + } | |
| 497 | + } | |
| 498 | + | |
| 499 | + boolean isFinished() { | |
| 500 | + return state.get() == DONE; | |
| 501 | + } | |
| 502 | + | |
| 503 | + } | |
| 504 | + | |
| 505 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptBudget.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script.rhino; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 一次脚本调用的执行预算:用户源码顶层、ctx 原生化、函数调用、结果序列化共用同一个预算对象 | |
| 5 | + * <p> | |
| 6 | + * 只在本次调用的 JS 线程上使用,不需要同步 | |
| 7 | + * | |
| 8 | + * @author 芋道源码 | |
| 9 | + */ | |
| 10 | +final class IotScriptBudget { | |
| 11 | + | |
| 12 | + /** | |
| 13 | + * 截止时间(System.nanoTime)= min(开始执行 + execBudget, 调用方传入的截止时间) | |
| 14 | + */ | |
| 15 | + private final long deadlineNanos; | |
| 16 | + /** | |
| 17 | + * 指令上限 | |
| 18 | + */ | |
| 19 | + private final long maxInstructions; | |
| 20 | + /** | |
| 21 | + * 已执行的指令数 | |
| 22 | + */ | |
| 23 | + private long usedInstructions; | |
| 24 | + | |
| 25 | + IotScriptBudget(long deadlineNanos, long maxInstructions) { | |
| 26 | + this.deadlineNanos = deadlineNanos; | |
| 27 | + this.maxInstructions = maxInstructions; | |
| 28 | + } | |
| 29 | + | |
| 30 | + void consume(int count) { | |
| 31 | + usedInstructions += count; | |
| 32 | + if (usedInstructions >= maxInstructions) { | |
| 33 | + throw new BudgetExceededError("超过指令预算"); | |
| 34 | + } | |
| 35 | + if (System.nanoTime() >= deadlineNanos) { | |
| 36 | + throw new BudgetExceededError("超过执行时间预算"); | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + /** | |
| 41 | + * 观察器抛出的宿主 Error:Rhino 原样向上抛,脚本的 catch/finally 拦不住 | |
| 42 | + * <p> | |
| 43 | + * 一旦超限,之后每个检查点都会再抛,所以 finally 中的死循环也会被中断 | |
| 44 | + */ | |
| 45 | + static final class BudgetExceededError extends Error { | |
| 46 | + | |
| 47 | + BudgetExceededError(String message) { | |
| 48 | + super(message, null, false, false); // 不收集栈:开销小 | |
| 49 | + } | |
| 50 | + | |
| 51 | + } | |
| 52 | + | |
| 53 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptContextFactory.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script.rhino; | |
| 2 | + | |
| 3 | +import org.mozilla.javascript.Context; | |
| 4 | +import org.mozilla.javascript.ContextFactory; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * IoT 脚本专用的 Rhino ContextFactory:不使用 ContextFactory.getGlobal(),不影响进程内其它 Rhino 使用方 | |
| 8 | + * | |
| 9 | + * @author 芋道源码 | |
| 10 | + */ | |
| 11 | +final class IotScriptContextFactory extends ContextFactory { | |
| 12 | + | |
| 13 | + /** | |
| 14 | + * 每执行这么多条解释器指令,回调一次 observeInstructionCount | |
| 15 | + */ | |
| 16 | + static final int INSTRUCTION_THRESHOLD = 1000; | |
| 17 | + /** | |
| 18 | + * 解释器最大栈深(递归深度) | |
| 19 | + */ | |
| 20 | + static final int MAX_STACK_DEPTH = 64; | |
| 21 | + | |
| 22 | + @Override | |
| 23 | + protected Context makeContext() { | |
| 24 | + Context cx = super.makeContext(); | |
| 25 | + cx.setOptimizationLevel(-1); // 解释模式:指令观察器只在解释模式下生效 | |
| 26 | + cx.setLanguageVersion(Context.VERSION_1_8); | |
| 27 | + cx.setInstructionObserverThreshold(INSTRUCTION_THRESHOLD); | |
| 28 | + cx.setMaximumInterpreterStackDepth(MAX_STACK_DEPTH); // 超出 → EvaluatorException「Exceeded maximum stack depth」 | |
| 29 | + cx.setClassShutter(className -> false); // 拒绝任何 Java 类 | |
| 30 | + cx.setGeneratingDebug(false); | |
| 31 | + return cx; | |
| 32 | + } | |
| 33 | + | |
| 34 | + @Override | |
| 35 | + protected void observeInstructionCount(Context cx, int instructionCount) { | |
| 36 | + IotScriptBudget budget = (IotScriptBudget) cx.getThreadLocal(IotScriptBudget.class); | |
| 37 | + if (budget == null) { | |
| 38 | + throw new IotScriptBudget.BudgetExceededError("缺少执行预算"); // fail-closed:没有预算的 Context 不允许继续执行 | |
| 39 | + } | |
| 40 | + budget.consume(instructionCount); // 超限时抛 BudgetExceededError | |
| 41 | + } | |
| 42 | + | |
| 43 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptRuntime.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script.rhino; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.StrUtil; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptException; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 7 | +import org.mozilla.javascript.Context; | |
| 8 | +import org.mozilla.javascript.Function; | |
| 9 | +import org.mozilla.javascript.RhinoException; | |
| 10 | +import org.mozilla.javascript.Script; | |
| 11 | +import org.mozilla.javascript.Scriptable; | |
| 12 | +import org.mozilla.javascript.ScriptableObject; | |
| 13 | +import org.mozilla.javascript.Undefined; | |
| 14 | + | |
| 15 | +import java.util.LinkedHashMap; | |
| 16 | +import java.util.Map; | |
| 17 | + | |
| 18 | +// DONE @AI:字段注释; | |
| 19 | +/** | |
| 20 | + * 单个 JS worker 线程独占的 Rhino 运行时 | |
| 21 | + * <ul> | |
| 22 | + * <li>sealedStandard:initSafeStandardObjects(null, true) 创建的标准对象,不含 Packages / java / JavaAdapter / getClass,已封存;</li> | |
| 23 | + * <li>userScripts:按 productId:hash 的编译缓存,只在本线程使用;</li> | |
| 24 | + * <li>每次调用新建一个可写作用域,用完即丢,调用之间不保留任何状态。</li> | |
| 25 | + * </ul> | |
| 26 | + * 不跨线程共享 Script,也不跨线程共享作用域 | |
| 27 | + * | |
| 28 | + * @author 芋道源码 | |
| 29 | + */ | |
| 30 | +final class IotScriptRuntime { | |
| 31 | + | |
| 32 | + /** | |
| 33 | + * 脚本输出(序列化后的 JSON)的最大字符数,超过按输出不合法处理 | |
| 34 | + */ | |
| 35 | + static final int MAX_RESULT_CHARS = 65536; | |
| 36 | + /** | |
| 37 | + * 编译缓存最多保留的脚本数:超过时淘汰最久未使用的 | |
| 38 | + */ | |
| 39 | + private static final int MAX_COMPILED_SCRIPTS = 64; | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * IoT 专用的 Rhino ContextFactory:每次编译、调用都由它进入 Context | |
| 43 | + */ | |
| 44 | + private final IotScriptContextFactory factory; | |
| 45 | + /** | |
| 46 | + * 创建本运行时的 JS 线程:只能在该线程上使用 | |
| 47 | + */ | |
| 48 | + private final Thread owner = Thread.currentThread(); | |
| 49 | + /** | |
| 50 | + * 封存的标准对象:每次调用的作用域都以它为原型(prototype),作用域自身没有父作用域 | |
| 51 | + */ | |
| 52 | + private final ScriptableObject sealedStandard; | |
| 53 | + /** | |
| 54 | + * 封存的标准对象里的 JSON 对象:宿主只用它做 parse/stringify,用户脚本里的同名变量影响不到 | |
| 55 | + */ | |
| 56 | + private final Scriptable json; | |
| 57 | + /** | |
| 58 | + * 封存的 JSON.parse:把平台构造的入参 JSON 转为脚本里的 ctx 对象 | |
| 59 | + */ | |
| 60 | + private final Function jsonParse; | |
| 61 | + /** | |
| 62 | + * 封存的 JSON.stringify:把脚本的返回值序列化为结果 JSON | |
| 63 | + */ | |
| 64 | + private final Function jsonStringify; | |
| 65 | + /** | |
| 66 | + * 编译缓存:key = productId:sha256(调试时为 debug:sha256);只在本线程访问 | |
| 67 | + */ | |
| 68 | + private final Map<String, Script> userScripts = new LinkedHashMap<String, Script>(16, 0.75f, true) { | |
| 69 | + | |
| 70 | + @Override | |
| 71 | + protected boolean removeEldestEntry(Map.Entry<String, Script> eldest) { | |
| 72 | + return size() > MAX_COMPILED_SCRIPTS; | |
| 73 | + } | |
| 74 | + | |
| 75 | + }; | |
| 76 | + | |
| 77 | + IotScriptRuntime(IotScriptContextFactory factory) { | |
| 78 | + this.factory = factory; | |
| 79 | + Context cx = factory.enterContext(); | |
| 80 | + try { | |
| 81 | + this.sealedStandard = cx.initSafeStandardObjects(null, true); // 第二个参数 true = 封存 | |
| 82 | + this.json = (Scriptable) ScriptableObject.getProperty(sealedStandard, "JSON"); | |
| 83 | + this.jsonParse = (Function) ScriptableObject.getProperty(json, "parse"); | |
| 84 | + this.jsonStringify = (Function) ScriptableObject.getProperty(json, "stringify"); | |
| 85 | + } finally { | |
| 86 | + Context.exit(); | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** | |
| 91 | + * 只编译、不执行任何用户代码:保存前的语法检查(biz)和首次调用前的编译(gateway)都用它 | |
| 92 | + * | |
| 93 | + * @throws org.mozilla.javascript.EvaluatorException 语法错误,带行号 | |
| 94 | + */ | |
| 95 | + static Script compile(Context cx, String name, String source) { | |
| 96 | + return cx.compileString(source, name, 1, null); | |
| 97 | + } | |
| 98 | + | |
| 99 | + /** | |
| 100 | + * 只做语法检查:不执行任何用户代码,也不放入编译缓存 | |
| 101 | + * | |
| 102 | + * @param content 脚本内容 | |
| 103 | + * @throws IotScriptException 语法错误 | |
| 104 | + */ | |
| 105 | + void compileOnly(String content) throws IotScriptException { | |
| 106 | + checkOwner(); | |
| 107 | + Context cx = factory.enterContext(); | |
| 108 | + try { | |
| 109 | + compile(cx, "product", content); | |
| 110 | + } catch (RhinoException e) { | |
| 111 | + throw new IotScriptException(IotScriptResult.Status.ERROR, | |
| 112 | + StrUtil.maxLength(e.details(), 200), e.lineNumber()); | |
| 113 | + } finally { | |
| 114 | + Context.exit(); | |
| 115 | + } | |
| 116 | + } | |
| 117 | + | |
| 118 | + /** | |
| 119 | + * 执行一次 | |
| 120 | + * | |
| 121 | + * @param source 脚本 | |
| 122 | + * @param function "decode" 或 "encode" | |
| 123 | + * @param ctxJson 平台构造的 JSON 字符串(不含 Java 对象、byte[]、Map、List 或任何秘密;字节是数字数组) | |
| 124 | + * @param budget 执行器传入的本次预算:下面的第 2~5 步都受它约束 | |
| 125 | + * @return 结果 JSON 字符串;null 表示脚本返回了 null/undefined,或没有定义该函数 | |
| 126 | + */ | |
| 127 | + String invoke(IotScriptSource source, String function, String ctxJson, IotScriptBudget budget) throws IotScriptException { | |
| 128 | + checkOwner(); | |
| 129 | + Context cx = factory.enterContext(); | |
| 130 | + cx.putThreadLocal(IotScriptBudget.class, budget); // 先挂预算,之后的一切解释执行都计数 | |
| 131 | + try { | |
| 132 | + // 1. 编译缓存:首次使用时在本线程编译(编译本身不经过指令观察器,只受 JS 任务的时间兜底约束;源码不超过 32KB) | |
| 133 | + Script user = userScripts.get(source.getKey()); | |
| 134 | + if (user == null) { | |
| 135 | + user = compile(cx, "product-" + source.getProductId(), source.getContent()); | |
| 136 | + userScripts.put(source.getKey(), user); | |
| 137 | + } | |
| 138 | + // 2. 新的可写作用域:原型是封存的标准对象、没有父作用域,脚本能定义自己的变量,但改不了 Array.prototype 等标准对象 | |
| 139 | + Scriptable scope = cx.newObject(sealedStandard); | |
| 140 | + scope.setPrototype(sealedStandard); | |
| 141 | + scope.setParentScope(null); | |
| 142 | + // 3. 用户源码顶层在这个作用域中执行(每次都执行),定义 decode / encode | |
| 143 | + user.exec(cx, scope); | |
| 144 | + Object fn = ScriptableObject.getProperty(scope, function); | |
| 145 | + if (!(fn instanceof Function)) { | |
| 146 | + return null; // 没有定义这个方向的函数:等同于返回 null | |
| 147 | + } | |
| 148 | + // 4. 入参原生化:JSON 字符串 → JS 原生对象(字节是原生数字数组);不传任何 Java 对象 | |
| 149 | + Object ctx = jsonParse.call(cx, scope, json, new Object[]{ctxJson}); | |
| 150 | + Object result = ((Function) fn).call(cx, scope, scope, new Object[]{ctx}); | |
| 151 | + if (result == null || result instanceof Undefined) { | |
| 152 | + return null; | |
| 153 | + } | |
| 154 | + // 5. 结果序列化:result 的 toJSON / getter 也在这里执行,同样受预算约束 | |
| 155 | + Object out = jsonStringify.call(cx, scope, json, new Object[]{result}); | |
| 156 | + if (!(out instanceof String)) { // 函数等不能序列化的值得到 undefined:按输出不合法处理 | |
| 157 | + throw new IotScriptException(IotScriptResult.Status.OUTPUT_INVALID, "脚本返回值不能序列化为 JSON", -1); | |
| 158 | + } | |
| 159 | + String text = (String) out; | |
| 160 | + if (text.length() > MAX_RESULT_CHARS) { | |
| 161 | + throw new IotScriptException(IotScriptResult.Status.OUTPUT_INVALID, "脚本输出超过 " + MAX_RESULT_CHARS + " 字符", -1); | |
| 162 | + } | |
| 163 | + return text; | |
| 164 | + } catch (RhinoException e) { // 语法错误、运行错误、throw、封存对象被修改、超出栈深 | |
| 165 | + throw new IotScriptException(IotScriptResult.Status.ERROR, | |
| 166 | + StrUtil.maxLength(e.details(), 200), e.lineNumber()); | |
| 167 | + } finally { | |
| 168 | + cx.removeThreadLocal(IotScriptBudget.class); // 清理预算 | |
| 169 | + Context.exit(); // 退出 Context;BudgetExceededError、OOM 等 Error 照常向上抛 | |
| 170 | + } | |
| 171 | + } | |
| 172 | + | |
| 173 | + private void checkOwner() { | |
| 174 | + if (Thread.currentThread() != owner) { | |
| 175 | + throw new IllegalStateException("IotScriptRuntime 只能在创建它的 JS 线程上使用"); | |
| 176 | + } | |
| 177 | + } | |
| 178 | + | |
| 179 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java
| ... | ... | @@ -8,8 +8,10 @@ import cn.hutool.core.util.StrUtil; |
| 8 | 8 | import cn.hutool.system.SystemUtil; |
| 9 | 9 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; |
| 10 | 10 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 11 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 11 | 12 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 12 | 13 | |
| 14 | +import java.util.Base64; | |
| 13 | 15 | import java.util.LinkedHashSet; |
| 14 | 16 | import java.util.Map; |
| 15 | 17 | import java.util.Set; |
| ... | ... | @@ -52,6 +54,150 @@ public class IotDeviceMessageUtils { |
| 52 | 54 | } |
| 53 | 55 | |
| 54 | 56 | /** |
| 57 | + * 是否是下行指令的请求:property.set / service.invoke(数据解析脚本 encode 只处理这两种) | |
| 58 | + * | |
| 59 | + * @param message 消息 | |
| 60 | + * @return 是否 | |
| 61 | + */ | |
| 62 | + public static boolean isCommandRequest(IotDeviceMessage message) { | |
| 63 | + return !isReplyMessage(message) && isCommandMethod(message.getMethod()); | |
| 64 | + } | |
| 65 | + | |
| 66 | + /** | |
| 67 | + * 是否是下行指令的方法:property.set / service.invoke | |
| 68 | + * | |
| 69 | + * @param method 消息方法 | |
| 70 | + * @return 是否 | |
| 71 | + */ | |
| 72 | + public static boolean isCommandMethod(String method) { | |
| 73 | + return StrUtil.equalsAny(method, IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), | |
| 74 | + IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod()); | |
| 75 | + } | |
| 76 | + | |
| 77 | + // ========== 透传(raw)相关 ========== | |
| 78 | + | |
| 79 | + /** | |
| 80 | + * 通用协议 raw 载荷的最大字节数(Modbus 另按 PDU 不超过 {@link #MODBUS_PDU_MAX_BYTES} 校验) | |
| 81 | + */ | |
| 82 | + public static final int RAW_PAYLOAD_MAX_BYTES = 4096; | |
| 83 | + | |
| 84 | + /** | |
| 85 | + * Modbus PDU 的最大字节数(不带 slaveId、CRC、MBAP) | |
| 86 | + */ | |
| 87 | + public static final int MODBUS_PDU_MAX_BYTES = 253; | |
| 88 | + | |
| 89 | + /** | |
| 90 | + * 是否是透传方法:thing.raw.post、thing.raw.push | |
| 91 | + * | |
| 92 | + * @param method 消息方法 | |
| 93 | + * @return 是否 | |
| 94 | + */ | |
| 95 | + public static boolean isRawMethod(String method) { | |
| 96 | + return StrUtil.equalsAny(method, IotDeviceMessageMethodEnum.RAW_POST.getMethod(), | |
| 97 | + IotDeviceMessageMethodEnum.RAW_PUSH.getMethod()); | |
| 98 | + } | |
| 99 | + | |
| 100 | + /** | |
| 101 | + * 编码原始字节为 {@link IotDeviceMessage#getRaw()}(Base64) | |
| 102 | + * | |
| 103 | + * @param bytes 原始字节 | |
| 104 | + * @return Base64 | |
| 105 | + */ | |
| 106 | + public static String encodeRaw(byte[] bytes) { | |
| 107 | + return Base64.getEncoder().encodeToString(bytes); | |
| 108 | + } | |
| 109 | + | |
| 110 | + /** | |
| 111 | + * 解码 {@link IotDeviceMessage#getRaw()}(Base64);严格 Base64(必须与 {@link #encodeRaw} 的结果一致,带 padding), | |
| 112 | + * 非法或超长抛 {@link IllegalArgumentException} | |
| 113 | + * | |
| 114 | + * @param raw Base64 | |
| 115 | + * @param maxBytes 最大字节数 | |
| 116 | + * @return 原始字节 | |
| 117 | + */ | |
| 118 | + public static byte[] decodeRaw(String raw, int maxBytes) { | |
| 119 | + Assert.notBlank(raw, "raw 不能为空"); | |
| 120 | + Assert.isTrue(raw.length() <= (maxBytes + 2) / 3 * 4, "raw 长度必须在 1~{} 字节之间", maxBytes); // 先按长度拦截,不解码超长串 | |
| 121 | + byte[] bytes = Base64.getDecoder().decode(raw); | |
| 122 | + Assert.isTrue(bytes.length >= 1 && bytes.length <= maxBytes, "raw 长度必须在 1~{} 字节之间", maxBytes); | |
| 123 | + Assert.isTrue(raw.equals(encodeRaw(bytes)), "raw 必须是标准 Base64(带 padding)"); | |
| 124 | + return bytes; | |
| 125 | + } | |
| 126 | + | |
| 127 | + /** | |
| 128 | + * 从 params({"payload": Base64})解出透传载荷:管理后台下发 thing.raw.push 的入参格式; | |
| 129 | + * 标准 Base64(允许省略 padding,由调用方用 {@link #encodeRaw} 规范化),非法或超长抛 {@link IllegalArgumentException} | |
| 130 | + * | |
| 131 | + * @param params 消息参数 | |
| 132 | + * @param maxBytes 最大字节数 | |
| 133 | + * @return 载荷 | |
| 134 | + */ | |
| 135 | + public static byte[] decodeRawPayload(Object params, int maxBytes) { | |
| 136 | + String payload = StrUtil.toStringOrNull(readField(params, "payload")); | |
| 137 | + Assert.notBlank(payload, "payload 不能为空"); | |
| 138 | + byte[] bytes = Base64.getDecoder().decode(payload); | |
| 139 | + Assert.isTrue(bytes.length >= 1 && bytes.length <= maxBytes, "payload 长度必须在 1~{} 字节之间", maxBytes); | |
| 140 | + return bytes; | |
| 141 | + } | |
| 142 | + | |
| 143 | + /** | |
| 144 | + * 校验透传下行的字节:按协议的规则,Modbus 必须是合法的请求 PDU({@link #validateModbusRequestPdu}),其它协议 1~4096 字节 | |
| 145 | + * <p> | |
| 146 | + * biz 的 /send、脚本调试的 encode 输出、gateway 的 encode 输出调用同一份规则;不合法抛 {@link IllegalArgumentException} | |
| 147 | + * | |
| 148 | + * @param protocol 协议类型 | |
| 149 | + * @param bytes 下行字节 | |
| 150 | + */ | |
| 151 | + public static void validateRawPayload(IotProtocolTypeEnum protocol, byte[] bytes) { | |
| 152 | + if (protocol != null && protocol.isModbus()) { | |
| 153 | + validateModbusRequestPdu(bytes); | |
| 154 | + return; | |
| 155 | + } | |
| 156 | + Assert.isTrue(bytes != null && bytes.length >= 1 && bytes.length <= RAW_PAYLOAD_MAX_BYTES, | |
| 157 | + "raw 长度必须在 1~{} 字节之间", RAW_PAYLOAD_MAX_BYTES); | |
| 158 | + } | |
| 159 | + | |
| 160 | + /** | |
| 161 | + * 校验 Modbus 原始请求 PDU 的格式:功能码白名单、长度、数量范围、05 的取值、0F/10 的 byteCount | |
| 162 | + * <p> | |
| 163 | + * 透传下行的字节规则({@link #validateRawPayload})与 Modbus 协议写出前调用同一份规则;不合法抛 {@link IllegalArgumentException} | |
| 164 | + * | |
| 165 | + * @param pdu PDU(功能码 + 数据,不带 slaveId、CRC、MBAP) | |
| 166 | + */ | |
| 167 | + public static void validateModbusRequestPdu(byte[] pdu) { | |
| 168 | + Assert.isTrue(pdu != null && pdu.length >= 5 && pdu.length <= MODBUS_PDU_MAX_BYTES, | |
| 169 | + "PDU 长度必须在 5~{} 字节之间", MODBUS_PDU_MAX_BYTES); | |
| 170 | + int functionCode = pdu[0] & 0xFF; | |
| 171 | + int quantity = ((pdu[3] & 0xFF) << 8) | (pdu[4] & 0xFF); | |
| 172 | + switch (functionCode) { | |
| 173 | + case 0x01: | |
| 174 | + case 0x02: | |
| 175 | + Assert.isTrue(pdu.length == 5 && quantity >= 1 && quantity <= 2000, "PDU 不合法:读线圈数量必须在 1~2000 之间"); | |
| 176 | + break; | |
| 177 | + case 0x03: | |
| 178 | + case 0x04: | |
| 179 | + Assert.isTrue(pdu.length == 5 && quantity >= 1 && quantity <= 125, "PDU 不合法:读寄存器数量必须在 1~125 之间"); | |
| 180 | + break; | |
| 181 | + case 0x05: | |
| 182 | + Assert.isTrue(pdu.length == 5 && (quantity == 0xFF00 || quantity == 0x0000), "PDU 不合法:写单个线圈的值只能是 FF00 或 0000"); | |
| 183 | + break; | |
| 184 | + case 0x06: | |
| 185 | + Assert.isTrue(pdu.length == 5, "PDU 不合法:写单个寄存器必须是 5 字节"); | |
| 186 | + break; | |
| 187 | + case 0x0F: | |
| 188 | + Assert.isTrue(quantity >= 1 && quantity <= 1968 && pdu.length >= 6 && pdu.length == 6 + (pdu[5] & 0xFF) | |
| 189 | + && (pdu[5] & 0xFF) == (quantity + 7) / 8, "PDU 不合法:写多个线圈的数量或字节数不一致"); | |
| 190 | + break; | |
| 191 | + case 0x10: | |
| 192 | + Assert.isTrue(quantity >= 1 && quantity <= 123 && pdu.length >= 6 && pdu.length == 6 + (pdu[5] & 0xFF) | |
| 193 | + && (pdu[5] & 0xFF) == quantity * 2, "PDU 不合法:写多个寄存器的数量或字节数不一致"); | |
| 194 | + break; | |
| 195 | + default: | |
| 196 | + throw new IllegalArgumentException("PDU 不合法:不支持的功能码 " + functionCode); | |
| 197 | + } | |
| 198 | + } | |
| 199 | + | |
| 200 | + /** | |
| 55 | 201 | * 提取消息中的标识符 |
| 56 | 202 | * |
| 57 | 203 | * @param message 消息 | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/script/IotScriptUtilsTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | + | |
| 8 | +import java.util.Map; | |
| 9 | + | |
| 10 | +import static org.junit.jupiter.api.Assertions.*; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * {@link IotScriptUtils} 的单元测试 | |
| 14 | + * | |
| 15 | + * @author 芋道源码 | |
| 16 | + */ | |
| 17 | +public class IotScriptUtilsTest { | |
| 18 | + | |
| 19 | + @Test | |
| 20 | + public void testValidateDecode_success() throws IotScriptException { | |
| 21 | + // 调用:一个标准消息对象 | |
| 22 | + IotDeviceMessage message = IotScriptUtils.validateDecode( | |
| 23 | + "{\"method\":\"thing.property.post\",\"params\":{\"t\":25}}", null); | |
| 24 | + // 断言 | |
| 25 | + assertEquals("thing.property.post", message.getMethod()); | |
| 26 | + assertEquals(25, ((Map<?, ?>) message.getParams()).get("t")); | |
| 27 | + assertNull(message.getCode()); | |
| 28 | + assertNull(message.getRaw()); | |
| 29 | + // 调用 + 断言:事件 | |
| 30 | + IotDeviceMessage event = IotScriptUtils.validateDecode( | |
| 31 | + "{\"method\":\"thing.event.post\",\"params\":{\"identifier\":\"alarm\",\"value\":{}}}", null); | |
| 32 | + assertEquals("thing.event.post", event.getMethod()); | |
| 33 | + } | |
| 34 | + | |
| 35 | + @Test | |
| 36 | + public void testValidateDecode_null() throws IotScriptException { | |
| 37 | + // 调用 + 断言:decode 未定义或返回 null → 没有业务消息 | |
| 38 | + assertNull(IotScriptUtils.validateDecode(null, null)); | |
| 39 | + } | |
| 40 | + | |
| 41 | + @Test | |
| 42 | + public void testValidateDecode_invalid() { | |
| 43 | + // 不是对象、旧的 messages / ack 包装 | |
| 44 | + assertOutputInvalid("[]"); | |
| 45 | + assertOutputInvalid("{\"messages\":[{\"method\":\"thing.property.post\",\"params\":{}}]}"); | |
| 46 | + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"ack\":[1]}"); | |
| 47 | + // 身份、路由、可信字段和未知键不允许:脚本不能伪造 raw、设备、租户、编号 | |
| 48 | + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"deviceId\":1}"); | |
| 49 | + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"tenantId\":1}"); | |
| 50 | + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"id\":\"x\"}"); | |
| 51 | + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"raw\":\"AQI=\"}"); | |
| 52 | + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"unknown\":true}"); | |
| 53 | + // 不支持的方法;事件缺 identifier;回执缺 requestId | |
| 54 | + assertOutputInvalid("{\"method\":\"thing.raw.post\",\"params\":{}}"); | |
| 55 | + assertOutputInvalid("{\"method\":\"thing.event.post\",\"params\":{}}"); | |
| 56 | + assertOutputInvalid("{\"method\":\"thing.property.set\",\"code\":0}"); | |
| 57 | + } | |
| 58 | + | |
| 59 | + @Test | |
| 60 | + public void testValidateDecode_transactionReply() throws IotScriptException { | |
| 61 | + // 准备参数 | |
| 62 | + IotDeviceMessage request = IotDeviceMessage.requestOf("req-1", "thing.property.set", null); | |
| 63 | + | |
| 64 | + // 调用 + 断言:方法、requestId 一致的回执 | |
| 65 | + IotDeviceMessage reply = IotScriptUtils.validateDecode( | |
| 66 | + "{\"method\":\"thing.property.set\",\"requestId\":\"req-1\",\"code\":0}", request); | |
| 67 | + assertEquals(0, reply.getCode()); | |
| 68 | + // 调用 + 断言:requestId 不一致、不是回执、没有输出 | |
| 69 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateDecode( | |
| 70 | + "{\"method\":\"thing.property.set\",\"requestId\":\"req-2\",\"code\":0}", request)); | |
| 71 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateDecode( | |
| 72 | + "{\"method\":\"thing.property.post\",\"params\":{}}", request)); | |
| 73 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateDecode(null, request)); | |
| 74 | + } | |
| 75 | + | |
| 76 | + @Test | |
| 77 | + public void testValidateEncode() throws IotScriptException { | |
| 78 | + // 未定义或返回 null → 不写设备 | |
| 79 | + assertNull(IotScriptUtils.validateEncode(null, IotProtocolTypeEnum.TCP)); | |
| 80 | + // 走标准路径 | |
| 81 | + assertTrue(IotScriptUtils.validateEncode("{\"standard\":true}", IotProtocolTypeEnum.TCP).isStandard()); | |
| 82 | + // 字节 | |
| 83 | + IotScriptUtils.EncodeOutput output = IotScriptUtils.validateEncode("{\"bytes\":[1,2,255]}", IotProtocolTypeEnum.TCP); | |
| 84 | + assertFalse(output.isStandard()); | |
| 85 | + assertArrayEquals(new byte[]{1, 2, (byte) 0xFF}, output.getBytes()); | |
| 86 | + assertEquals(4096, IotScriptUtils.validateEncode("{\"bytes\":" + buildArray(4096, 0) + "}", | |
| 87 | + IotProtocolTypeEnum.TCP).getBytes().length); | |
| 88 | + // 不合法:standard=false、两者都有、没有 bytes、不是数组、超过 255 | |
| 89 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{\"standard\":false}", IotProtocolTypeEnum.TCP)); | |
| 90 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{\"standard\":true,\"bytes\":[1]}", IotProtocolTypeEnum.TCP)); | |
| 91 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{}", IotProtocolTypeEnum.TCP)); | |
| 92 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{\"bytes\":\"AQI=\"}", IotProtocolTypeEnum.TCP)); | |
| 93 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{\"bytes\":[256]}", IotProtocolTypeEnum.TCP)); | |
| 94 | + } | |
| 95 | + | |
| 96 | + @Test | |
| 97 | + public void testValidateEncode_rawPayload() throws IotScriptException { | |
| 98 | + // 调用 + 断言:其它协议 1~4096 字节,不合法为 OUTPUT_INVALID,带原规则的错误信息 | |
| 99 | + assertEncodeOutputInvalid("{\"bytes\":[]}", IotProtocolTypeEnum.TCP, "raw 长度必须在 1~4096 字节之间"); | |
| 100 | + assertEncodeOutputInvalid("{\"bytes\":" + buildArray(4097, 0) + "}", IotProtocolTypeEnum.TCP, | |
| 101 | + "raw 长度必须在 1~4096 字节之间"); | |
| 102 | + | |
| 103 | + // 调用 + 断言:Modbus 必须是合法的请求 PDU | |
| 104 | + assertArrayEquals(new byte[]{3, 0, 0, 0, 10}, IotScriptUtils.validateEncode("{\"bytes\":[3,0,0,0,10]}", | |
| 105 | + IotProtocolTypeEnum.MODBUS_TCP_CLIENT).getBytes()); | |
| 106 | + assertEncodeOutputInvalid("{\"bytes\":[3,0,0,0,0]}", IotProtocolTypeEnum.MODBUS_TCP_SERVER, | |
| 107 | + "PDU 不合法:读寄存器数量必须在 1~125 之间"); | |
| 108 | + // 调用 + 断言:走标准路径时没有字节,不按协议的规则校验 | |
| 109 | + assertTrue(IotScriptUtils.validateEncode("{\"standard\":true}", IotProtocolTypeEnum.MODBUS_TCP_CLIENT).isStandard()); | |
| 110 | + } | |
| 111 | + | |
| 112 | + private static void assertOutputInvalid(String json) { | |
| 113 | + IotScriptException ex = assertThrows(IotScriptException.class, | |
| 114 | + () -> IotScriptUtils.validateDecode(json, null), json); | |
| 115 | + assertEquals(IotScriptResult.Status.OUTPUT_INVALID, ex.getStatus()); | |
| 116 | + } | |
| 117 | + | |
| 118 | + private static void assertEncodeOutputInvalid(String json, IotProtocolTypeEnum protocol, String message) { | |
| 119 | + IotScriptException ex = assertThrows(IotScriptException.class, | |
| 120 | + () -> IotScriptUtils.validateEncode(json, protocol)); | |
| 121 | + assertEquals(IotScriptResult.Status.OUTPUT_INVALID, ex.getStatus()); | |
| 122 | + assertEquals(message, ex.getMessage()); | |
| 123 | + } | |
| 124 | + | |
| 125 | + private static String buildArray(int size, int value) { | |
| 126 | + StringBuilder sb = new StringBuilder("["); | |
| 127 | + for (int i = 0; i < size; i++) { | |
| 128 | + sb.append(i > 0 ? "," : "").append(value); | |
| 129 | + } | |
| 130 | + return sb.append("]").toString(); | |
| 131 | + } | |
| 132 | + | |
| 133 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotRhinoScriptExecutorTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script.rhino; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.ReflectUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptUtils; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 8 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 9 | +import org.junit.jupiter.api.AfterEach; | |
| 10 | +import org.junit.jupiter.api.BeforeEach; | |
| 11 | +import org.junit.jupiter.api.Test; | |
| 12 | + | |
| 13 | +import java.util.concurrent.CountDownLatch; | |
| 14 | +import java.util.concurrent.Executors; | |
| 15 | +import java.util.concurrent.Future; | |
| 16 | +import java.util.concurrent.ScheduledExecutorService; | |
| 17 | +import java.util.concurrent.ScheduledFuture; | |
| 18 | +import java.util.concurrent.ThreadPoolExecutor; | |
| 19 | +import java.util.concurrent.TimeUnit; | |
| 20 | + | |
| 21 | +import static org.junit.jupiter.api.Assertions.*; | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * {@link IotRhinoScriptExecutor} 的单元测试:真实的 Rhino 解释执行 | |
| 25 | + * | |
| 26 | + * @author 芋道源码 | |
| 27 | + */ | |
| 28 | +public class IotRhinoScriptExecutorTest { | |
| 29 | + | |
| 30 | + private static final String CTX = "{\"bytes\":[1,2,3],\"hex\":\"010203\"}"; | |
| 31 | + | |
| 32 | + private IotRhinoScriptExecutor executor; | |
| 33 | + | |
| 34 | + @BeforeEach | |
| 35 | + public void setUp() { | |
| 36 | + executor = new IotRhinoScriptExecutor(new IotRhinoScriptExecutor.Options().setName("test-script-js").setPoolSize(1) | |
| 37 | + .setQueueCapacity(4).setExecBudgetMillis(500).setMaxInstructions(1_000_000)); | |
| 38 | + } | |
| 39 | + | |
| 40 | + @AfterEach | |
| 41 | + public void tearDown() { | |
| 42 | + executor.shutdown(); | |
| 43 | + } | |
| 44 | + | |
| 45 | + private IotScriptResult invoke(String content, String function) { | |
| 46 | + return executor.execute(IotScriptSource.of(1L, content), function, CTX, | |
| 47 | + System.nanoTime() + TimeUnit.SECONDS.toNanos(3)); | |
| 48 | + } | |
| 49 | + | |
| 50 | + @Test | |
| 51 | + public void testExecute_decodeSuccess() { | |
| 52 | + // 准备参数:宿主侧 JSON.parse 入参,函数返回原生对象,宿主侧 JSON.stringify | |
| 53 | + String content = "function decode(ctx) { return {method: 'thing.property.post'," | |
| 54 | + + " params: {sum: ctx.bytes[0] + ctx.bytes[1] + ctx.bytes[2], hex: ctx.hex}}; }"; | |
| 55 | + | |
| 56 | + // 调用 | |
| 57 | + IotScriptResult result = invoke(content, IotScriptUtils.FUNCTION_DECODE); | |
| 58 | + // 断言 | |
| 59 | + assertEquals(IotScriptResult.Status.SUCCESS, result.getStatus()); | |
| 60 | + JsonNode root = JsonUtils.parseTree(result.getJson()); | |
| 61 | + assertEquals(6, root.get("params").get("sum").intValue()); | |
| 62 | + assertEquals("010203", root.get("params").get("hex").textValue()); | |
| 63 | + } | |
| 64 | + | |
| 65 | + @Test | |
| 66 | + public void testExecute_functionMissing() { | |
| 67 | + // 调用:没有定义 encode | |
| 68 | + IotScriptResult result = invoke("function decode(ctx) { return null; }", IotScriptUtils.FUNCTION_ENCODE); | |
| 69 | + // 断言:成功但没有输出,由调用方处理 | |
| 70 | + assertEquals(IotScriptResult.Status.SUCCESS, result.getStatus()); | |
| 71 | + assertNull(result.getJson()); | |
| 72 | + } | |
| 73 | + | |
| 74 | + @Test | |
| 75 | + public void testExecute_javaAccessDenied() { | |
| 76 | + // 调用:java、Packages、getClass 都不可用 | |
| 77 | + String content = "function decode(ctx) { return {method: 'thing.property.post', params: {" | |
| 78 | + + "java: typeof java === 'undefined' ? 1 : 0, packages: typeof Packages === 'undefined' ? 1 : 0}}; }"; | |
| 79 | + IotScriptResult result = invoke(content, IotScriptUtils.FUNCTION_DECODE); | |
| 80 | + // 断言 | |
| 81 | + assertEquals(IotScriptResult.Status.SUCCESS, result.getStatus()); | |
| 82 | + assertEquals("{\"java\":1,\"packages\":1}", JsonUtils.parseTree(result.getJson()).get("params").toString()); | |
| 83 | + | |
| 84 | + // 调用:显式访问 Java 类 | |
| 85 | + IotScriptResult result2 = invoke("function decode(ctx) { return {method: 'thing.property.post'," | |
| 86 | + + " params: {home: java.lang.System.getProperty('user.home').length}}; }", IotScriptUtils.FUNCTION_DECODE); | |
| 87 | + // 断言:脚本错误,不是成功 | |
| 88 | + assertEquals(IotScriptResult.Status.ERROR, result2.getStatus()); | |
| 89 | + } | |
| 90 | + | |
| 91 | + @Test | |
| 92 | + public void testExecute_infiniteLoopTimeoutAndReuse() { | |
| 93 | + // 调用:死循环被预算中断 | |
| 94 | + IotScriptResult result = invoke("function decode(ctx) { while (true) {} }", IotScriptUtils.FUNCTION_DECODE); | |
| 95 | + // 断言 | |
| 96 | + assertEquals(IotScriptResult.Status.TIMEOUT, result.getStatus()); | |
| 97 | + | |
| 98 | + // 调用:同一个线程继续可用(线程池只有 1 个线程) | |
| 99 | + IotScriptResult result2 = invoke("function decode(ctx) { return null; }", IotScriptUtils.FUNCTION_DECODE); | |
| 100 | + // 断言 | |
| 101 | + assertEquals(IotScriptResult.Status.SUCCESS, result2.getStatus()); | |
| 102 | + } | |
| 103 | + | |
| 104 | + @Test | |
| 105 | + public void testExecute_breakerOpenAfterTimeouts() { | |
| 106 | + // 准备参数:同一个脚本 1 分钟内超时 3 次 | |
| 107 | + String content = "function decode(ctx) { for (;;) {} }"; | |
| 108 | + for (int i = 0; i < 3; i++) { | |
| 109 | + assertEquals(IotScriptResult.Status.TIMEOUT, invoke(content, IotScriptUtils.FUNCTION_DECODE).getStatus()); | |
| 110 | + } | |
| 111 | + | |
| 112 | + // 调用 + 断言:之后直接拒绝;其它脚本不受影响 | |
| 113 | + assertTrue(executor.isOpen(IotScriptSource.of(1L, content))); | |
| 114 | + assertEquals(IotScriptResult.Status.REJECTED, invoke(content, IotScriptUtils.FUNCTION_DECODE).getStatus()); | |
| 115 | + assertEquals(IotScriptResult.Status.SUCCESS, invoke("function decode(ctx) { return null; }", | |
| 116 | + IotScriptUtils.FUNCTION_DECODE).getStatus()); | |
| 117 | + } | |
| 118 | + | |
| 119 | + @Test | |
| 120 | + public void testExecute_deadlinePassed() { | |
| 121 | + // 调用:截止时间已过 | |
| 122 | + IotScriptResult result = executor.execute(IotScriptSource.of(1L, "function decode(ctx) { return null; }"), | |
| 123 | + IotScriptUtils.FUNCTION_DECODE, CTX, System.nanoTime() - 1); | |
| 124 | + // 断言:不进入 JS 池 | |
| 125 | + assertEquals(IotScriptResult.Status.TIMEOUT, result.getStatus()); | |
| 126 | + } | |
| 127 | + | |
| 128 | + @Test | |
| 129 | + public void testExecute_pendingTimeoutNeverStarts() throws Exception { | |
| 130 | + // 准备参数:用一个 Java 任务确定地占住唯一的 JS 线程(不依赖脚本预算与睡眠); | |
| 131 | + // 该线程还没有执行过脚本(没有预热),所以它的 runtime 为空:任何脚本一旦在它上面启动,都会创建 runtime | |
| 132 | + ThreadPoolExecutor jsPool = (ThreadPoolExecutor) ReflectUtil.getFieldValue(executor, "jsPool"); | |
| 133 | + ThreadLocal<?> runtimes = (ThreadLocal<?>) ReflectUtil.getFieldValue(executor, "runtimes"); | |
| 134 | + CountDownLatch entered = new CountDownLatch(1); | |
| 135 | + CountDownLatch release = new CountDownLatch(1); | |
| 136 | + jsPool.execute(() -> { | |
| 137 | + entered.countDown(); | |
| 138 | + try { | |
| 139 | + release.await(10, TimeUnit.SECONDS); | |
| 140 | + } catch (InterruptedException e) { | |
| 141 | + Thread.currentThread().interrupt(); | |
| 142 | + } | |
| 143 | + }); | |
| 144 | + assertTrue(entered.await(5, TimeUnit.SECONDS)); | |
| 145 | + | |
| 146 | + // 调用:排队中的任务 100ms 后到期 | |
| 147 | + IotScriptSource pending = IotScriptSource.of(2L, "function decode(ctx) { return {method: 'thing.property.post'}; }"); | |
| 148 | + IotScriptResult result = executor.execute(pending, IotScriptUtils.FUNCTION_DECODE, CTX, | |
| 149 | + System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(100)); | |
| 150 | + // 断言:调用方按截止时间返回超时;任务仍在 JS 池的队列里,没有启动 | |
| 151 | + assertEquals(IotScriptResult.Status.TIMEOUT, result.getStatus()); | |
| 152 | + assertEquals(1, jsPool.getQueue().size()); | |
| 153 | + | |
| 154 | + // 调用:放开 JS 线程,再提交一个探测任务;单线程 FIFO,探测任务执行时,排队超时的任务已经出队 | |
| 155 | + release.countDown(); | |
| 156 | + Future<Boolean> runtimeCreated = jsPool.submit(() -> runtimes.get() != null); | |
| 157 | + // 断言:排队超时的任务出队后没有启动脚本(JS 线程上仍没有创建 runtime) | |
| 158 | + assertFalse(runtimeCreated.get(5, TimeUnit.SECONDS)); | |
| 159 | + // 断言:排队超时的任务不计入熔断;JS 线程可以继续使用,之后真正执行了脚本 | |
| 160 | + assertFalse(executor.isOpen(pending)); | |
| 161 | + assertEquals(IotScriptResult.Status.SUCCESS, invoke("function decode(ctx) { return null; }", | |
| 162 | + IotScriptUtils.FUNCTION_DECODE).getStatus()); | |
| 163 | + assertTrue(jsPool.submit(() -> runtimes.get() != null).get(5, TimeUnit.SECONDS)); | |
| 164 | + } | |
| 165 | + | |
| 166 | + @Test | |
| 167 | + public void testScriptTask_finishOnce() { | |
| 168 | + // 准备参数:执行中的任务,预算计时器 1 小时后才会触发 | |
| 169 | + ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(); | |
| 170 | + try { | |
| 171 | + IotScriptSource source = IotScriptSource.of(1L, "function decode(ctx) { return null; }"); | |
| 172 | + IotRhinoScriptExecutor.IotScriptTask task = new IotRhinoScriptExecutor.IotScriptTask(source); | |
| 173 | + assertTrue(task.markRunning()); | |
| 174 | + ScheduledFuture<?> budgetTimer = timer.schedule(() -> { }, 1, TimeUnit.HOURS); | |
| 175 | + task.setBudgetTimer(budgetTimer); | |
| 176 | + | |
| 177 | + // 调用 + 断言:第一次终结成功,并取消预算计时器 | |
| 178 | + assertTrue(task.finish()); | |
| 179 | + assertTrue(task.isFinished()); | |
| 180 | + assertTrue(budgetTimer.isCancelled()); | |
| 181 | + // 调用 + 断言:重复终结失败(迟到的结果直接丢弃) | |
| 182 | + assertFalse(task.finish()); | |
| 183 | + | |
| 184 | + // 调用 + 断言:排队中已被终结的任务,之后不能再开始 | |
| 185 | + IotRhinoScriptExecutor.IotScriptTask pending = new IotRhinoScriptExecutor.IotScriptTask(source); | |
| 186 | + assertTrue(pending.finish()); | |
| 187 | + assertFalse(pending.markRunning()); | |
| 188 | + } finally { | |
| 189 | + timer.shutdownNow(); | |
| 190 | + } | |
| 191 | + } | |
| 192 | + | |
| 193 | + @Test | |
| 194 | + public void testCompileOnly() { | |
| 195 | + // 调用 + 断言:语法正确,不执行顶层代码(死循环也不会卡住) | |
| 196 | + assertTrue(executor.compileOnly("while (true) {} function decode(ctx) {}", 2, TimeUnit.SECONDS).isSuccess()); | |
| 197 | + // 调用 + 断言:语法错误带行号(第 2 行只有一个不能作为语句开头的 ")",解析器在该 token 上立即报错,行号无歧义) | |
| 198 | + IotScriptResult result = executor.compileOnly("var a = 1;\n)\nfunction decode(ctx) {}", 2, TimeUnit.SECONDS); | |
| 199 | + assertEquals(IotScriptResult.Status.ERROR, result.getStatus()); | |
| 200 | + assertEquals(2, result.getLineNumber()); | |
| 201 | + } | |
| 202 | + | |
| 203 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.core.util; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 4 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 4 | 5 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 5 | 6 | import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO; |
| 6 | 7 | import org.junit.jupiter.api.Test; |
| ... | ... | @@ -348,4 +349,78 @@ public class IotDeviceMessageUtilsTest { |
| 348 | 349 | assertTrue(identifiers.contains("humidity")); |
| 349 | 350 | } |
| 350 | 351 | |
| 352 | + @Test | |
| 353 | + public void testEncodeRaw_decodeRaw() { | |
| 354 | + // 标准 Base64(带 padding)往返 | |
| 355 | + byte[] bytes = new byte[]{0x01, 0x02, (byte) 0xFF, 0x00}; | |
| 356 | + String raw = IotDeviceMessageUtils.encodeRaw(bytes); | |
| 357 | + assertEquals("AQL/AA==", raw); | |
| 358 | + assertArrayEquals(bytes, IotDeviceMessageUtils.decodeRaw(raw, 4)); | |
| 359 | + // 上限边界:4096 字节 | |
| 360 | + byte[] max = new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES]; | |
| 361 | + assertArrayEquals(max, IotDeviceMessageUtils.decodeRaw(IotDeviceMessageUtils.encodeRaw(max), | |
| 362 | + IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES)); | |
| 363 | + } | |
| 364 | + | |
| 365 | + @Test | |
| 366 | + public void testDecodeRaw_invalid() { | |
| 367 | + // 为空、不是 Base64 | |
| 368 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw(null, 10)); | |
| 369 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw("", 10)); | |
| 370 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw("!!not-base64!!", 10)); | |
| 371 | + // 不规范:省略 padding、末尾多余的位不为 0(与 AQI= 解出相同字节) | |
| 372 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw("AQI", 10)); | |
| 373 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw("AQJ=", 10)); | |
| 374 | + // 超过上限:Modbus PDU 253 字节 | |
| 375 | + String tooLong = IotDeviceMessageUtils.encodeRaw(new byte[IotDeviceMessageUtils.MODBUS_PDU_MAX_BYTES + 1]); | |
| 376 | + assertThrows(IllegalArgumentException.class, | |
| 377 | + () -> IotDeviceMessageUtils.decodeRaw(tooLong, IotDeviceMessageUtils.MODBUS_PDU_MAX_BYTES)); | |
| 378 | + } | |
| 379 | + | |
| 380 | + @Test | |
| 381 | + public void testDecodeRawPayload_withoutPadding() { | |
| 382 | + // 管理后台的 payload 允许省略 padding,由调用方规范化 | |
| 383 | + Map<String, Object> params = new HashMap<>(); | |
| 384 | + params.put("payload", "AQI"); | |
| 385 | + assertArrayEquals(new byte[]{0x01, 0x02}, IotDeviceMessageUtils.decodeRawPayload(params, 10)); | |
| 386 | + } | |
| 387 | + | |
| 388 | + @Test | |
| 389 | + public void testIsCommandRequest() { | |
| 390 | + // 下行指令的请求:property.set / service.invoke | |
| 391 | + assertTrue(IotDeviceMessageUtils.isCommandRequest(IotDeviceMessage.requestOf("thing.property.set"))); | |
| 392 | + assertTrue(IotDeviceMessageUtils.isCommandRequest(IotDeviceMessage.requestOf("thing.service.invoke"))); | |
| 393 | + // 其它方法、回执不是 | |
| 394 | + assertFalse(IotDeviceMessageUtils.isCommandRequest(IotDeviceMessage.requestOf("thing.ota.upgrade"))); | |
| 395 | + assertFalse(IotDeviceMessageUtils.isCommandRequest( | |
| 396 | + IotDeviceMessage.replyOf("1", "thing.property.set", null, 0, null))); | |
| 397 | + } | |
| 398 | + | |
| 399 | + @Test | |
| 400 | + public void testValidateRawPayload() { | |
| 401 | + // 通用协议:1~4096 字节 | |
| 402 | + IotDeviceMessageUtils.validateRawPayload(IotProtocolTypeEnum.TCP, new byte[]{0x01}); | |
| 403 | + IotDeviceMessageUtils.validateRawPayload(IotProtocolTypeEnum.MQTT, | |
| 404 | + new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES]); | |
| 405 | + assertThrows(IllegalArgumentException.class, | |
| 406 | + () -> IotDeviceMessageUtils.validateRawPayload(IotProtocolTypeEnum.TCP, new byte[0])); | |
| 407 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.validateRawPayload( | |
| 408 | + IotProtocolTypeEnum.TCP, new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES + 1])); | |
| 409 | + } | |
| 410 | + | |
| 411 | + @Test | |
| 412 | + public void testValidateRawPayload_modbus() { | |
| 413 | + // 合法的 03 读保持寄存器:地址 0、数量 10 | |
| 414 | + IotDeviceMessageUtils.validateRawPayload(IotProtocolTypeEnum.MODBUS_TCP_CLIENT, new byte[]{3, 0, 0, 0, 10}); | |
| 415 | + // 不合法:不支持的功能码、数量超限、超过 253 字节(通用协议允许的长度,Modbus 也拒绝) | |
| 416 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.validateRawPayload( | |
| 417 | + IotProtocolTypeEnum.MODBUS_TCP_CLIENT, new byte[]{8, 0, 0, 0, 1})); | |
| 418 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.validateRawPayload( | |
| 419 | + IotProtocolTypeEnum.MODBUS_TCP_SERVER, new byte[]{3, 0, 0, 0, 126})); | |
| 420 | + byte[] tooLong = new byte[IotDeviceMessageUtils.MODBUS_PDU_MAX_BYTES + 1]; | |
| 421 | + tooLong[0] = 0x10; | |
| 422 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.validateRawPayload( | |
| 423 | + IotProtocolTypeEnum.MODBUS_TCP_CLIENT, tooLong)); | |
| 424 | + } | |
| 425 | + | |
| 351 | 426 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/pom.xml
| ... | ... | @@ -12,8 +12,9 @@ |
| 12 | 12 | <name>${project.artifactId}</name> |
| 13 | 13 | <description> |
| 14 | 14 | iot 模块下,设备网关: |
| 15 | - ① 功能一:接收来自设备的消息,并进行解码(decode)后,发送到消息网关,提供给 iot-biz 进行处理 | |
| 16 | - ② 功能二:接收来自消息网关的消息(由 iot-biz 发送),并进行编码(encode)后,发送给设备 | |
| 15 | + ① 功能一:接收来自设备的消息,并进行解码(decode)后,发送到消息总线,提供给 iot-biz 进行处理 | |
| 16 | + ② 功能二:接收来自消息总线的消息(由 iot-biz 发送),并进行编码(encode)后,发送给设备 | |
| 17 | + 目前已支持 HTTP、MQTT、EMQX、TCP、UDP、WebSocket、CoAP、Modbus 等协议 | |
| 17 | 18 | </description> |
| 18 | 19 | |
| 19 | 20 | <dependencies> | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java
| ... | ... | @@ -13,6 +13,7 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketConfig |
| 13 | 13 | import jakarta.validation.Valid; |
| 14 | 14 | import jakarta.validation.constraints.NotEmpty; |
| 15 | 15 | import jakarta.validation.constraints.NotNull; |
| 16 | +import jakarta.validation.constraints.Positive; | |
| 16 | 17 | import lombok.Data; |
| 17 | 18 | import org.springframework.boot.context.properties.ConfigurationProperties; |
| 18 | 19 | import org.springframework.validation.annotation.Validated; |
| ... | ... | @@ -33,6 +34,11 @@ public class IotGatewayProperties { |
| 33 | 34 | * Token 配置 |
| 34 | 35 | */ |
| 35 | 36 | private TokenProperties token; |
| 37 | + /** | |
| 38 | + * 数据解析脚本配置 | |
| 39 | + */ | |
| 40 | + @Valid | |
| 41 | + private ScriptProperties script = new ScriptProperties(); | |
| 36 | 42 | |
| 37 | 43 | /** |
| 38 | 44 | * 协议实例列表 |
| ... | ... | @@ -77,6 +83,41 @@ public class IotGatewayProperties { |
| 77 | 83 | } |
| 78 | 84 | |
| 79 | 85 | /** |
| 86 | + * 数据解析脚本配置 | |
| 87 | + */ | |
| 88 | + @Data | |
| 89 | + public static class ScriptProperties { | |
| 90 | + | |
| 91 | + /** | |
| 92 | + * 是否开启数据解析脚本功能(运维开关,默认关闭) | |
| 93 | + * <p> | |
| 94 | + * 关闭时整体绕开脚本:不执行、不预热;raw 上行纯透传,标准下行与未开启时相同,raw.push 照常写出 | |
| 95 | + */ | |
| 96 | + private boolean enabled = false; | |
| 97 | + /** | |
| 98 | + * 透传处理的截止时间:raw 上行(含 HTTP / CoAP 同一请求内应答)、脚本下行、透传回复,从收到(入队)开始计算, | |
| 99 | + * 覆盖排队与执行脚本 | |
| 100 | + */ | |
| 101 | + @NotNull(message = "透传处理截止时间不能为空") | |
| 102 | + private Duration deadline = Duration.ofMillis(2000); | |
| 103 | + /** | |
| 104 | + * 单次脚本执行的时间预算:与截止时间取较早者 | |
| 105 | + */ | |
| 106 | + @NotNull(message = "脚本执行时间预算不能为空") | |
| 107 | + private Duration execBudget = Duration.ofMillis(500); | |
| 108 | + /** | |
| 109 | + * 单次脚本执行的指令上限 | |
| 110 | + */ | |
| 111 | + private long maxInstructions = 1_000_000; | |
| 112 | + /** | |
| 113 | + * 每个设备最多排队的脚本下行指令数(不含执行中的一条):超过时丢弃并记日志 | |
| 114 | + */ | |
| 115 | + @Positive(message = "每个设备最多排队的下行指令数必须大于 0") | |
| 116 | + private int maxPendingCommandsPerDevice = 256; | |
| 117 | + | |
| 118 | + } | |
| 119 | + | |
| 120 | + /** | |
| 80 | 121 | * 协议实例配置 |
| 81 | 122 | */ |
| 82 | 123 | @Data | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/enums/ErrorCodeConstants.java
| ... | ... | @@ -16,4 +16,19 @@ public interface ErrorCodeConstants { |
| 16 | 16 | // ========== 设备信息 1-050-002-000 ============ |
| 17 | 17 | ErrorCode DEVICE_NOT_EXISTS = new ErrorCode(1_051_002_001, "设备({}/{}) 不存在"); |
| 18 | 18 | |
| 19 | + // ========== 设备透传与数据解析脚本 1-051-003-000 ============ | |
| 20 | + ErrorCode DEVICE_MESSAGE_RAW_METHOD_RESERVED = new ErrorCode(1_051_003_000, "透传方法只能由网关 raw 入口使用"); | |
| 21 | + ErrorCode SCRIPT_EXECUTE_FAILED = new ErrorCode(1_051_003_100, "数据解析脚本执行失败:{}"); | |
| 22 | + ErrorCode SCRIPT_TIMEOUT = new ErrorCode(1_051_003_101, "处理超时:未在截止时间内完成(排队、加载配置或脚本执行)"); | |
| 23 | + ErrorCode SCRIPT_REJECTED = new ErrorCode(1_051_003_102, "数据解析脚本繁忙或已熔断"); | |
| 24 | + ErrorCode SCRIPT_OUTPUT_INVALID = new ErrorCode(1_051_003_103, "数据解析脚本输出不合法:{}"); | |
| 25 | + ErrorCode RAW_NOT_SUPPORTED = new ErrorCode(1_051_003_105, "当前连接不支持透传下发"); | |
| 26 | + ErrorCode DEVICE_OFFLINE_OR_WRITE_FAILED = new ErrorCode(1_051_003_106, "设备不在线或写入失败"); | |
| 27 | + ErrorCode MODBUS_TIMEOUT = new ErrorCode(1_051_003_110, "Modbus 响应超时"); | |
| 28 | + ErrorCode MODBUS_EXCEPTION = new ErrorCode(1_051_003_111, "Modbus 异常响应(异常码 {})"); | |
| 29 | + ErrorCode MODBUS_PDU_INVALID = new ErrorCode(1_051_003_112, "Modbus PDU 不合法:{}"); | |
| 30 | + ErrorCode MODBUS_QUEUE_FULL = new ErrorCode(1_051_003_113, "Modbus 事务队列已满"); | |
| 31 | + ErrorCode MODBUS_RESPONSE_INVALID = new ErrorCode(1_051_003_114, "Modbus 响应不合法"); | |
| 32 | + ErrorCode MODBUS_DISCONNECTED = new ErrorCode(1_051_003_115, "Modbus 连接已断开"); | |
| 33 | + | |
| 19 | 34 | } |
| 20 | 35 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/AbstractIotProtocolDownstreamSubscriber.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.util.StrUtil; |
| 4 | +import cn.hutool.extra.spring.SpringUtil; | |
| 5 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 4 | 8 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; |
| 5 | 9 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; |
| 6 | 10 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 11 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 7 | 12 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 14 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO; | |
| 16 | +import io.vertx.core.Future; | |
| 8 | 17 | import lombok.AllArgsConstructor; |
| 9 | 18 | import lombok.extern.slf4j.Slf4j; |
| 10 | 19 | |
| 20 | +import java.util.ArrayDeque; | |
| 21 | +import java.util.Collections; | |
| 22 | +import java.util.Deque; | |
| 23 | +import java.util.HashMap; | |
| 24 | +import java.util.Map; | |
| 25 | + | |
| 26 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 27 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.RAW_NOT_SUPPORTED; | |
| 28 | + | |
| 11 | 29 | /** |
| 12 | 30 | * IoT 协议下行消息订阅者抽象类 |
| 13 | 31 | * |
| ... | ... | @@ -23,6 +41,34 @@ public abstract class AbstractIotProtocolDownstreamSubscriber implements IotMess |
| 23 | 41 | |
| 24 | 42 | private final IotMessageBus messageBus; |
| 25 | 43 | |
| 44 | + /** | |
| 45 | + * 有排队 / 执行中脚本指令的设备:设备编号 → 待执行的指令(第一条已在 worker 上执行时,队列可为空) | |
| 46 | + * <p> | |
| 47 | + * 总线的消费线程与 worker 都会访问,用它自身加锁;设备的指令全部执行完后移除 | |
| 48 | + */ | |
| 49 | + private final Map<Long, Deque<Runnable>> deviceCommandQueues = new HashMap<>(); | |
| 50 | + | |
| 51 | + /** | |
| 52 | + * @return 协议类型:encode 的入参与字节规则按协议区分 | |
| 53 | + */ | |
| 54 | + public IotProtocolTypeEnum getProtocolType() { | |
| 55 | + return protocol.getType(); | |
| 56 | + } | |
| 57 | + | |
| 58 | + /** | |
| 59 | + * @return 是否支持透传下发:不支持时不执行编码脚本,指令直接走标准路径 | |
| 60 | + */ | |
| 61 | + public boolean supportsRawDownstream() { | |
| 62 | + return false; | |
| 63 | + } | |
| 64 | + | |
| 65 | + /** | |
| 66 | + * @return 本协议实例的 serverId(可信来源):回复设备响应时显式传入 | |
| 67 | + */ | |
| 68 | + public String getServerId() { | |
| 69 | + return protocol.getServerId(); | |
| 70 | + } | |
| 71 | + | |
| 26 | 72 | @Override |
| 27 | 73 | public String getTopic() { |
| 28 | 74 | return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId()); |
| ... | ... | @@ -45,6 +91,10 @@ public abstract class AbstractIotProtocolDownstreamSubscriber implements IotMess |
| 45 | 91 | @Override |
| 46 | 92 | public void stop() { |
| 47 | 93 | messageBus.unregister(this); |
| 94 | + // 丢弃排队中的指令:执行中的那一条照常结束,之后不再执行 | |
| 95 | + synchronized (deviceCommandQueues) { | |
| 96 | + deviceCommandQueues.clear(); | |
| 97 | + } | |
| 48 | 98 | log.info("[stop][{} 下行消息订阅已停止,Topic:{}]", protocol.getType().name(), getTopic()); |
| 49 | 99 | } |
| 50 | 100 | |
| ... | ... | @@ -61,7 +111,27 @@ public abstract class AbstractIotProtocolDownstreamSubscriber implements IotMess |
| 61 | 111 | return; |
| 62 | 112 | } |
| 63 | 113 | |
| 64 | - // 2. 处理下行消息 | |
| 114 | + // 2.1 透传下发(raw.push):字节在 raw 字段,不经过脚本,直接交给协议写出(功能开关关闭时也照常); | |
| 115 | + // 协议自己的字节规则(例如请求 PDU)由协议写出前校验 | |
| 116 | + if (IotDeviceMessageMethodEnum.RAW_PUSH.getMethod().equals(method) | |
| 117 | + && !IotDeviceMessageUtils.isReplyMessage(message)) { | |
| 118 | + writeRawMessage(message, IotDeviceMessageUtils.decodeRaw(message.getRaw(), | |
| 119 | + IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES), null); | |
| 120 | + return; | |
| 121 | + } | |
| 122 | + // 2.2 指令(property.set / service.invoke):功能开关开启、协议支持透传下发时,按设备排队,切到协议的 worker 上 | |
| 123 | + // 按产品的数据解析脚本 encode(加载设备信息、执行脚本会阻塞),再透传写出或走标准路径。 | |
| 124 | + // 同一设备的指令(包括 encode 后走标准路径的)按收到的顺序执行,不同设备之间并行 | |
| 125 | + IotGatewayProperties.ScriptProperties script = SpringUtil.getBean(IotGatewayProperties.class).getScript(); | |
| 126 | + if (script.isEnabled() && supportsRawDownstream() && IotDeviceMessageUtils.isCommandRequest(message)) { | |
| 127 | + // 截止时间从收到(按设备排队之前)开始计算 | |
| 128 | + IotDeviceMessageService deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); | |
| 129 | + long receivedNanos = System.nanoTime(); | |
| 130 | + enqueueDeviceCommand(message, () -> handleScriptCommand(deviceMessageService, message, receivedNanos), | |
| 131 | + script.getMaxPendingCommandsPerDevice()); | |
| 132 | + return; | |
| 133 | + } | |
| 134 | + // 2.3 其它(OTA、影子、配置、回复等,功能开关关闭,或协议不支持透传下发):标准路径 | |
| 65 | 135 | handleMessage(message); |
| 66 | 136 | } catch (Exception e) { |
| 67 | 137 | log.error("[onMessage][处理下行消息失败, messageId: {}, method: {}, deviceId: {}]", |
| ... | ... | @@ -70,10 +140,182 @@ public abstract class AbstractIotProtocolDownstreamSubscriber implements IotMess |
| 70 | 140 | } |
| 71 | 141 | |
| 72 | 142 | /** |
| 143 | + * 按设备排队脚本指令:同一设备按提交顺序在 worker 上逐条执行,不同设备之间并行; | |
| 144 | + * 每个设备最多排队 maxPending 条(不含执行中的一条),超过时丢弃并记日志 | |
| 145 | + * | |
| 146 | + * @param message 下行指令 | |
| 147 | + * @param task 阻塞任务 | |
| 148 | + * @param maxPending 每个设备最多排队的指令数 | |
| 149 | + */ | |
| 150 | + private void enqueueDeviceCommand(IotDeviceMessage message, Runnable task, int maxPending) { | |
| 151 | + // 1. 没有设备编号(不应出现):无法保序,直接执行 | |
| 152 | + Long deviceId = message.getDeviceId(); | |
| 153 | + if (deviceId == null) { | |
| 154 | + executeCommands(null, new ArrayDeque<>(Collections.singletonList(task))); | |
| 155 | + return; | |
| 156 | + } | |
| 157 | + ArrayDeque<Runnable> tasks; | |
| 158 | + synchronized (deviceCommandQueues) { | |
| 159 | + // 2. 设备还有排队 / 执行中的指令:排在它们之后,由正在执行的 worker 依次执行;队列已满时丢弃 | |
| 160 | + Deque<Runnable> pending = deviceCommandQueues.get(deviceId); | |
| 161 | + if (pending != null) { | |
| 162 | + if (pending.size() >= maxPending) { | |
| 163 | + log.error("[enqueueDeviceCommand][设备({}) 待处理的下行指令已达上限 {},丢弃 messageId: {}, method: {}]", | |
| 164 | + deviceId, maxPending, message.getId(), message.getMethod()); | |
| 165 | + } else { | |
| 166 | + pending.add(task); | |
| 167 | + } | |
| 168 | + return; | |
| 169 | + } | |
| 170 | + // 3. 设备没有在途的指令:新建队列,交给一个 worker 执行 | |
| 171 | + tasks = new ArrayDeque<>(); | |
| 172 | + tasks.add(task); | |
| 173 | + deviceCommandQueues.put(deviceId, tasks); | |
| 174 | + } | |
| 175 | + executeCommands(deviceId, tasks); | |
| 176 | + } | |
| 177 | + | |
| 178 | + /** | |
| 179 | + * 在一个 worker 上依次执行设备的指令队列,直到队列为空(或已被 {@link #stop()} 清空);单条失败只记日志,不影响后续指令 | |
| 180 | + * | |
| 181 | + * @param deviceId 设备编号(为 null 时只执行给定的任务) | |
| 182 | + * @param tasks 设备的指令队列 | |
| 183 | + */ | |
| 184 | + private void executeCommands(Long deviceId, Deque<Runnable> tasks) { | |
| 185 | + try { | |
| 186 | + protocol.getVertx().executeBlocking(() -> { | |
| 187 | + while (true) { | |
| 188 | + // 1. 取下一条:队列空了就移除(之后的指令新建队列);队列已被 stop 清掉或换成新的一代,不再执行 | |
| 189 | + Runnable task; | |
| 190 | + synchronized (deviceCommandQueues) { | |
| 191 | + task = tasks.poll(); | |
| 192 | + if (task == null) { | |
| 193 | + if (deviceId != null) { | |
| 194 | + deviceCommandQueues.remove(deviceId, tasks); | |
| 195 | + } | |
| 196 | + return null; | |
| 197 | + } | |
| 198 | + // 已停止 | |
| 199 | + if (deviceId != null && deviceCommandQueues.get(deviceId) != tasks) { | |
| 200 | + return null; | |
| 201 | + } | |
| 202 | + } | |
| 203 | + // 2. 执行:单条失败只记日志,不影响后续指令 | |
| 204 | + try { | |
| 205 | + task.run(); | |
| 206 | + } catch (Exception e) { | |
| 207 | + log.error("[executeCommands][设备({}) 处理下行指令失败]", deviceId, e); | |
| 208 | + } | |
| 209 | + } | |
| 210 | + }, false).onFailure(e -> { | |
| 211 | + // 3. 提交到 worker 失败:丢弃排队的指令,移除队列 | |
| 212 | + log.error("[executeCommands][设备({}) 下行指令无法提交到 worker,丢弃排队的指令]", deviceId, e); | |
| 213 | + removeDeviceCommandQueue(deviceId, tasks); | |
| 214 | + }); | |
| 215 | + } catch (Exception e) { | |
| 216 | + // Vertx 已关闭时 worker 池会同步拒绝 | |
| 217 | + log.error("[executeCommands][设备({}) 下行指令无法提交到 worker,丢弃排队的指令]", deviceId, e); | |
| 218 | + removeDeviceCommandQueue(deviceId, tasks); | |
| 219 | + } | |
| 220 | + } | |
| 221 | + | |
| 222 | + /** | |
| 223 | + * 移除设备的指令队列:只移除给定的这一代(已被 stop 清掉或换成新一代时不影响新队列);排队中的指令随之丢弃 | |
| 224 | + * | |
| 225 | + * @param deviceId 设备编号(为 null 时没有队列,直接返回) | |
| 226 | + * @param tasks 设备的指令队列 | |
| 227 | + */ | |
| 228 | + private void removeDeviceCommandQueue(Long deviceId, Deque<Runnable> tasks) { | |
| 229 | + if (deviceId == null) { | |
| 230 | + return; | |
| 231 | + } | |
| 232 | + synchronized (deviceCommandQueues) { | |
| 233 | + deviceCommandQueues.remove(deviceId, tasks); | |
| 234 | + } | |
| 235 | + } | |
| 236 | + | |
| 237 | + /** | |
| 238 | + * 在 worker 上处理脚本指令:按 encode 的结果走标准路径、透传写出,或者不下发(设备消息 Service 已记日志) | |
| 239 | + * | |
| 240 | + * @param deviceMessageService 设备消息 Service(在总线消费线程获取) | |
| 241 | + * @param message 下行指令 | |
| 242 | + * @param receivedNanos 收到指令的时刻(System.nanoTime):按设备排队之前取得;设备消息 Service 据此计算截止, | |
| 243 | + * 返回结果之前已核对,拿到结果立即下发 | |
| 244 | + */ | |
| 245 | + private void handleScriptCommand(IotDeviceMessageService deviceMessageService, IotDeviceMessage message, | |
| 246 | + long receivedNanos) { | |
| 247 | + // 1. encode:null 表示不下发(设备消息 Service 已记日志) | |
| 248 | + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(message, getProtocolType(), | |
| 249 | + receivedNanos); | |
| 250 | + if (result == null) { | |
| 251 | + return; | |
| 252 | + } | |
| 253 | + // 2.1 走标准路径:与未启用脚本一致,由子类按协议的标准格式下发 | |
| 254 | + if (result.isStandard()) { | |
| 255 | + handleMessage(message); | |
| 256 | + return; | |
| 257 | + } | |
| 258 | + // 2.2 透传写出 encode 的字节,带上 encode 时固定的脚本(设备响应用它解析) | |
| 259 | + writeRawMessage(message, result.getBytes(), result.getSource()); | |
| 260 | + } | |
| 261 | + | |
| 262 | + /** | |
| 73 | 263 | * 处理下行消息 |
| 74 | 264 | * |
| 75 | 265 | * @param message 下行消息 |
| 76 | 266 | */ |
| 77 | 267 | protected abstract void handleMessage(IotDeviceMessage message); |
| 78 | 268 | |
| 269 | + /** | |
| 270 | + * 透传写出:交给协议写出({@link #handleRawMessage}),写出失败只记日志 | |
| 271 | + * | |
| 272 | + * @param message 下行消息(raw.push,或 encode 的 property.set / service.invoke) | |
| 273 | + * @param bytes 要写出的字节 | |
| 274 | + * @param source encode 时使用的脚本(固定 hash);raw.push 为 null | |
| 275 | + */ | |
| 276 | + private void writeRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 277 | + Future<Void> write = handleRawMessage(message, bytes, source); | |
| 278 | + if (write == null) { | |
| 279 | + log.warn("[writeRawMessage][下行({}) 设备不在线或写入失败]", message.getRequestId()); | |
| 280 | + return; | |
| 281 | + } | |
| 282 | + write.onFailure(e -> log.warn("[writeRawMessage][下行({}) 写出失败:{}]", message.getRequestId(), e.getMessage())); | |
| 283 | + } | |
| 284 | + | |
| 285 | + /** | |
| 286 | + * 透传写出。约定:只返回 Future,自己不发消息给 biz;设备的实际响应由协议经 {@link #replyRawResult} 回复 | |
| 287 | + * <p> | |
| 288 | + * 失败 = ServiceException(ErrorCode),只记日志;协议自己的字节规则(例如请求 PDU)在写出之前校验,不合法返回失败。 | |
| 289 | + * 成功:一般为本地异步写出已完成(MQTT 为 publish 已提交,晚到的写出失败由协议记日志),不代表设备已确认;有请求-响应事务的协议,提交事务之前的失败返回失败 Future, | |
| 290 | + * 提交之后 Future 在事务终态(响应 / 超时 / 断线 / 写失败)成功完成,网关判定的失败只记日志 | |
| 291 | + * | |
| 292 | + * @param message 下行消息(raw.push,或 encode 的 property.set / service.invoke) | |
| 293 | + * @param bytes 要写出的字节 | |
| 294 | + * @param source encode 时使用的脚本(固定 hash);raw.push 为 null | |
| 295 | + * @return 写出结果 | |
| 296 | + */ | |
| 297 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 298 | + return Future.failedFuture(exception(RAW_NOT_SUPPORTED)); | |
| 299 | + } | |
| 300 | + | |
| 301 | + /** | |
| 302 | + * 回复透传下行的设备实际响应:切到协议的 worker,交给设备消息 Service 回复给 biz | |
| 303 | + * <p> | |
| 304 | + * 只用于设备的实际响应(包括协议的异常响应);网关判定的失败(超时、断线、写失败等)由协议只记日志, | |
| 305 | + * 不调用本方法,也不给 biz 构造回复 | |
| 306 | + * | |
| 307 | + * @param request 原请求 | |
| 308 | + * @param source encode 时固定的脚本;raw.push 为 null | |
| 309 | + * @param payload 设备的响应字节 | |
| 310 | + * @param deviceError 协议判定的设备错误(例如异常响应);正常响应为 null | |
| 311 | + */ | |
| 312 | + protected final void replyRawResult(IotDeviceMessage request, IotScriptSource source, | |
| 313 | + byte[] payload, ErrorCode deviceError) { | |
| 314 | + String serverId = getServerId(); | |
| 315 | + protocol.getVertx().executeBlocking(() -> { | |
| 316 | + SpringUtil.getBean(IotDeviceMessageService.class).sendRawReply(request, source, payload, deviceError, serverId); | |
| 317 | + return null; | |
| 318 | + }, false).onFailure(e -> log.error("[replyRawResult][下行({}) 回复失败]", request.getRequestId(), e)); | |
| 319 | + } | |
| 320 | + | |
| 79 | 321 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocol.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; |
| 4 | +import io.vertx.core.Vertx; | |
| 4 | 5 | |
| 5 | 6 | /** |
| 6 | 7 | * IoT 协议接口 |
| ... | ... | @@ -49,4 +50,13 @@ public interface IotProtocol { |
| 49 | 50 | */ |
| 50 | 51 | boolean isRunning(); |
| 51 | 52 | |
| 53 | + /** | |
| 54 | + * 获取协议的 Vert.x 实例:阻塞的处理(数据解析脚本、发送消息)切到它的 worker 上执行 | |
| 55 | + * | |
| 56 | + * @return Vert.x 实例;没有使用 Vert.x 的协议(CoAP)返回 null | |
| 57 | + */ | |
| 58 | + default Vertx getVertx() { | |
| 59 | + return null; | |
| 60 | + } | |
| 61 | + | |
| 52 | 62 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapProtocol.java
| ... | ... | @@ -62,6 +62,11 @@ public class IotCoapProtocol implements IotProtocol { |
| 62 | 62 | private CoapServer coapServer; |
| 63 | 63 | |
| 64 | 64 | /** |
| 65 | + * 上行处理器:停止时关闭它的透传截止计时器 | |
| 66 | + */ | |
| 67 | + private IotCoapUpstreamHandler upstreamHandler; | |
| 68 | + | |
| 69 | + /** | |
| 65 | 70 | * 下行消息订阅者 |
| 66 | 71 | */ |
| 67 | 72 | private IotCoapDownstreamSubscriber downstreamSubscriber; |
| ... | ... | @@ -116,7 +121,7 @@ public class IotCoapProtocol implements IotProtocol { |
| 116 | 121 | add(registerSubResource); |
| 117 | 122 | }}); |
| 118 | 123 | // 2.4 添加 /topic 根资源(用于上行消息) |
| 119 | - IotCoapUpstreamHandler upstreamHandler = new IotCoapUpstreamHandler(serverId); | |
| 124 | + upstreamHandler = new IotCoapUpstreamHandler(serverId); | |
| 120 | 125 | IotCoapUpstreamTopicResource topicResource = new IotCoapUpstreamTopicResource(serverId, upstreamHandler); |
| 121 | 126 | coapServer.add(topicResource); |
| 122 | 127 | |
| ... | ... | @@ -168,6 +173,11 @@ public class IotCoapProtocol implements IotProtocol { |
| 168 | 173 | log.error("[stop][IoT CoAP 协议 {} 服务器停止失败]", getId(), e); |
| 169 | 174 | } |
| 170 | 175 | } |
| 176 | + // 3. 关闭上行处理器的透传截止计时器 | |
| 177 | + if (upstreamHandler != null) { | |
| 178 | + upstreamHandler.stop(); | |
| 179 | + upstreamHandler = null; | |
| 180 | + } | |
| 171 | 181 | running = false; |
| 172 | 182 | log.info("[stop][IoT CoAP 协议 {} 已停止]", getId()); |
| 173 | 183 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapAbstractHandler.java
| ... | ... | @@ -36,6 +36,11 @@ public abstract class IotCoapAbstractHandler { |
| 36 | 36 | */ |
| 37 | 37 | public static final int OPTION_TOKEN = 2088; |
| 38 | 38 | |
| 39 | + /** | |
| 40 | + * handle0 返回这个哨兵:表示响应由子类异步写出(透传上报:worker 处理结果与截止计时只响应一次),基类不再写响应 | |
| 41 | + */ | |
| 42 | + protected static final CommonResult<Object> ASYNC_RESPONSE = new CommonResult<>(); | |
| 43 | + | |
| 39 | 44 | private final IotDeviceTokenService deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); |
| 40 | 45 | |
| 41 | 46 | /** |
| ... | ... | @@ -50,6 +55,9 @@ public abstract class IotCoapAbstractHandler { |
| 50 | 55 | |
| 51 | 56 | // 2. 执行业务逻辑 |
| 52 | 57 | CommonResult<Object> result = handle0(exchange); |
| 58 | + if (result == ASYNC_RESPONSE) { // 响应由子类异步写出 | |
| 59 | + return; | |
| 60 | + } | |
| 53 | 61 | writeResponse(exchange, result); |
| 54 | 62 | } catch (ServiceException e) { |
| 55 | 63 | // 业务异常,返回对应的错误码和消息 | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamHandler.java
| ... | ... | @@ -4,14 +4,35 @@ import cn.hutool.core.collection.CollUtil; |
| 4 | 4 | import cn.hutool.core.lang.Assert; |
| 5 | 5 | import cn.hutool.core.map.MapUtil; |
| 6 | 6 | import cn.hutool.core.text.StrPool; |
| 7 | +import cn.hutool.core.thread.NamedThreadFactory; | |
| 8 | +import cn.hutool.core.util.StrUtil; | |
| 7 | 9 | import cn.hutool.extra.spring.SpringUtil; |
| 10 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 8 | 11 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 12 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 13 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 9 | 14 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 10 | 16 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 11 | 17 | import lombok.extern.slf4j.Slf4j; |
| 18 | +import org.eclipse.californium.core.coap.MediaTypeRegistry; | |
| 12 | 19 | import org.eclipse.californium.core.server.resources.CoapExchange; |
| 13 | 20 | |
| 21 | +import java.time.Duration; | |
| 14 | 22 | import java.util.List; |
| 23 | +import java.util.concurrent.Executors; | |
| 24 | +import java.util.concurrent.LinkedBlockingQueue; | |
| 25 | +import java.util.concurrent.RejectedExecutionException; | |
| 26 | +import java.util.concurrent.ScheduledExecutorService; | |
| 27 | +import java.util.concurrent.ScheduledFuture; | |
| 28 | +import java.util.concurrent.ThreadPoolExecutor; | |
| 29 | +import java.util.concurrent.TimeUnit; | |
| 30 | +import java.util.concurrent.atomic.AtomicBoolean; | |
| 31 | + | |
| 32 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; | |
| 33 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; | |
| 34 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.TOO_MANY_REQUESTS; | |
| 35 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT; | |
| 15 | 36 | |
| 16 | 37 | /** |
| 17 | 38 | * IoT 网关 CoAP 协议的【上行】处理器 |
| ... | ... | @@ -19,6 +40,7 @@ import java.util.List; |
| 19 | 40 | * 处理设备通过 CoAP 协议发送的上行消息,包括: |
| 20 | 41 | * 1. 属性上报:POST /topic/sys/{productKey}/{deviceName}/thing/property/post |
| 21 | 42 | * 2. 事件上报:POST /topic/sys/{productKey}/{deviceName}/thing/event/post |
| 43 | + * 3. 透传上报:POST /topic/sys/{productKey}/{deviceName}/thing/raw/post,content-format 为 42(octet-stream) | |
| 22 | 44 | * |
| 23 | 45 | * Token 通过自定义 CoAP Option 2088 携带 |
| 24 | 46 | * |
| ... | ... | @@ -27,13 +49,41 @@ import java.util.List; |
| 27 | 49 | @Slf4j |
| 28 | 50 | public class IotCoapUpstreamHandler extends IotCoapAbstractHandler { |
| 29 | 51 | |
| 52 | + /** | |
| 53 | + * 透传 worker 的线程数:阻塞在 IO 上,取 CPU 核数的 2 倍 | |
| 54 | + */ | |
| 55 | + private static final int RAW_WORKER_THREADS = Runtime.getRuntime().availableProcessors() * 2; | |
| 56 | + /** | |
| 57 | + * 透传上报最多排队的请求数:超过时立即响应 429 | |
| 58 | + */ | |
| 59 | + private static final int RAW_MAX_PENDING = 1024; | |
| 60 | + | |
| 30 | 61 | private final String serverId; |
| 31 | 62 | |
| 63 | + /** | |
| 64 | + * 透传上报的应答计时时长:同一请求内应答(截止判断由设备消息 Service 负责) | |
| 65 | + */ | |
| 66 | + private final Duration deadline; | |
| 67 | + | |
| 68 | + /** | |
| 69 | + * 透传上报的截止计时器:到期先返回超时,与处理结果谁先到谁响应 | |
| 70 | + */ | |
| 71 | + private final ScheduledExecutorService deadlineTimer = Executors.newSingleThreadScheduledExecutor( | |
| 72 | + new NamedThreadFactory("iot-coap-raw-deadline-", true)); | |
| 73 | + /** | |
| 74 | + * 透传上报的 worker(有界):解析与发送会阻塞,不能占着 Californium 处理请求的线程(Exchange 的响应排在它之后) | |
| 75 | + */ | |
| 76 | + private final ThreadPoolExecutor rawExecutor = new ThreadPoolExecutor(RAW_WORKER_THREADS, RAW_WORKER_THREADS, | |
| 77 | + 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(RAW_MAX_PENDING), | |
| 78 | + new NamedThreadFactory("iot-coap-raw-", true)); | |
| 79 | + | |
| 32 | 80 | private final IotDeviceMessageService deviceMessageService; |
| 33 | 81 | |
| 34 | 82 | public IotCoapUpstreamHandler(String serverId) { |
| 35 | 83 | this.serverId = serverId; |
| 84 | + this.deadline = SpringUtil.getBean(IotGatewayProperties.class).getScript().getDeadline(); | |
| 36 | 85 | this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); |
| 86 | + this.rawExecutor.allowCoreThreadTimeOut(true); | |
| 37 | 87 | } |
| 38 | 88 | |
| 39 | 89 | @Override |
| ... | ... | @@ -44,7 +94,11 @@ public class IotCoapUpstreamHandler extends IotCoapAbstractHandler { |
| 44 | 94 | String productKey = getProductKey(uriPath); |
| 45 | 95 | String deviceName = getDeviceName(uriPath); |
| 46 | 96 | String method = String.join(StrPool.DOT, uriPath.subList(4, uriPath.size())); |
| 47 | - // 1.2 解析消息 | |
| 97 | + // 1.2 透传上报 | |
| 98 | + if (isRawPost(exchange, method)) { | |
| 99 | + return handleRawPost(exchange, productKey, deviceName); | |
| 100 | + } | |
| 101 | + // 1.3 解析消息 | |
| 48 | 102 | IotDeviceMessage message = deserializeRequest(exchange, IotDeviceMessage.class); |
| 49 | 103 | Assert.notNull(message, "请求参数不能为空"); |
| 50 | 104 | Assert.equals(method, message.getMethod(), "method 不匹配"); |
| ... | ... | @@ -56,6 +110,95 @@ public class IotCoapUpstreamHandler extends IotCoapAbstractHandler { |
| 56 | 110 | return CommonResult.success(MapUtil.of("messageId", message.getId())); |
| 57 | 111 | } |
| 58 | 112 | |
| 113 | + /** | |
| 114 | + * 是否透传上报:路径为 thing/raw/post,并且 content-format 为 42(application/octet-stream) | |
| 115 | + * <p> | |
| 116 | + * 其它 content-format 走 JSON 分支,由标准路径拒绝透传方法 | |
| 117 | + */ | |
| 118 | + private boolean isRawPost(CoapExchange exchange, String method) { | |
| 119 | + return StrUtil.equals(IotDeviceMessageMethodEnum.RAW_POST.getMethod(), method) | |
| 120 | + && exchange.getRequestOptions().getContentFormat() == MediaTypeRegistry.APPLICATION_OCTET_STREAM; | |
| 121 | + } | |
| 122 | + | |
| 123 | + /** | |
| 124 | + * 透传上报:身份来自前置认证(token 与路径一致),与 JSON 分支相同 | |
| 125 | + * <p> | |
| 126 | + * Californium 在 Exchange 的串行执行器上处理请求,其它线程写的响应要排在本次处理之后才发出; | |
| 127 | + * 所以这里不阻塞:解析与发送交给透传 worker,立即返回。截止计时归协议:到期先返回超时(101), | |
| 128 | + * 之后完成的结果丢弃,只响应一次。已开始的投递无法撤销;其它情况由 service 的截止检查决定是否投递,超时响应不等待它 | |
| 129 | + */ | |
| 130 | + private CommonResult<Object> handleRawPost(CoapExchange exchange, String productKey, String deviceName) { | |
| 131 | + // 1. CON 请求:先回空 ACK,避免处理期间设备重传;之后的响应作为分离响应发送 | |
| 132 | + if (exchange.advanced().getRequest().isConfirmable()) { | |
| 133 | + exchange.accept(); | |
| 134 | + } | |
| 135 | + | |
| 136 | + // 2. 应答计时:在计时器线程上到期响应;截止时间从收到(交给 worker 之前)开始计算 | |
| 137 | + AtomicBoolean responded = new AtomicBoolean(); | |
| 138 | + long receivedNanos = System.nanoTime(); | |
| 139 | + ScheduledFuture<?> timer = deadlineTimer.schedule(() -> writeRawResponse(exchange, responded, | |
| 140 | + CommonResult.error(SCRIPT_TIMEOUT)), deadline.toNanos(), TimeUnit.NANOSECONDS); | |
| 141 | + | |
| 142 | + // 3. 交给透传 worker:按产品的数据解析脚本 decode 后发送;队列已满时立即响应 | |
| 143 | + byte[] payload = exchange.getRequestPayload(); | |
| 144 | + try { | |
| 145 | + rawExecutor.execute(() -> { | |
| 146 | + // 排队期间已到截止时,由 service 的截止分支处理:不执行脚本、不投递,只记错误日志; | |
| 147 | + // 超时响应已由计时器写出,这里的结果被门闩丢弃 | |
| 148 | + CommonResult<Object> result = sendRawMessage(payload, productKey, deviceName, receivedNanos); | |
| 149 | + timer.cancel(false); | |
| 150 | + writeRawResponse(exchange, responded, result); | |
| 151 | + }); | |
| 152 | + } catch (RejectedExecutionException e) { | |
| 153 | + log.warn("[handleRawPost][productKey({}) deviceName({}) 透传上报积压,拒绝处理]", productKey, deviceName); | |
| 154 | + timer.cancel(false); | |
| 155 | + writeRawResponse(exchange, responded, CommonResult.error(TOO_MANY_REQUESTS)); | |
| 156 | + } | |
| 157 | + return ASYNC_RESPONSE; | |
| 158 | + } | |
| 159 | + | |
| 160 | + /** | |
| 161 | + * 发送透传上报(透传 worker 上执行):结果(含失败)转为响应,与基类的异常处理一致 | |
| 162 | + */ | |
| 163 | + private CommonResult<Object> sendRawMessage(byte[] payload, String productKey, String deviceName, | |
| 164 | + long receivedNanos) { | |
| 165 | + try { | |
| 166 | + String messageId = deviceMessageService.sendRawMessage(payload, productKey, deviceName, serverId, | |
| 167 | + IotProtocolTypeEnum.COAP, receivedNanos); | |
| 168 | + return CommonResult.success(MapUtil.of("messageId", messageId)); | |
| 169 | + } catch (ServiceException e) { | |
| 170 | + return CommonResult.error(e.getCode(), e.getMessage()); | |
| 171 | + } catch (IllegalArgumentException e) { | |
| 172 | + return CommonResult.error(BAD_REQUEST.getCode(), e.getMessage()); | |
| 173 | + } catch (Exception e) { | |
| 174 | + log.error("[sendRawMessage][productKey({}) deviceName({}) 处理异常]", productKey, deviceName, e); | |
| 175 | + return CommonResult.error(INTERNAL_SERVER_ERROR); | |
| 176 | + } | |
| 177 | + } | |
| 178 | + | |
| 179 | + /** | |
| 180 | + * 写透传上报的响应:截止计时器与处理线程竞争,只有第一个响应,迟到的结果丢弃 | |
| 181 | + */ | |
| 182 | + private void writeRawResponse(CoapExchange exchange, AtomicBoolean responded, CommonResult<Object> result) { | |
| 183 | + if (!responded.compareAndSet(false, true)) { | |
| 184 | + log.warn("[writeRawResponse][已响应(超时),丢弃结果({})]", result.getCode()); | |
| 185 | + return; | |
| 186 | + } | |
| 187 | + try { | |
| 188 | + writeResponse(exchange, result); | |
| 189 | + } catch (Exception e) { | |
| 190 | + log.error("[writeRawResponse][写响应异常]", e); | |
| 191 | + } | |
| 192 | + } | |
| 193 | + | |
| 194 | + /** | |
| 195 | + * 停止:关闭透传 worker(排队中的不再执行)与截止计时器 | |
| 196 | + */ | |
| 197 | + public void stop() { | |
| 198 | + rawExecutor.shutdownNow(); | |
| 199 | + deadlineTimer.shutdownNow(); | |
| 200 | + } | |
| 201 | + | |
| 59 | 202 | @Override |
| 60 | 203 | protected boolean requiresAuthentication() { |
| 61 | 204 | return true; | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocol.java
| ... | ... | @@ -14,6 +14,7 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmq |
| 14 | 14 | import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmqxUpstreamHandler; |
| 15 | 15 | import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; |
| 16 | 16 | import io.netty.handler.codec.mqtt.MqttQoS; |
| 17 | +import io.vertx.core.Future; | |
| 17 | 18 | import io.vertx.core.Vertx; |
| 18 | 19 | import io.vertx.core.buffer.Buffer; |
| 19 | 20 | import io.vertx.core.http.HttpServer; |
| ... | ... | @@ -31,7 +32,9 @@ import java.util.List; |
| 31 | 32 | import java.util.Map; |
| 32 | 33 | import java.util.concurrent.TimeUnit; |
| 33 | 34 | |
| 35 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 34 | 36 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; |
| 37 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 35 | 38 | |
| 36 | 39 | /** |
| 37 | 40 | * IoT 网关 EMQX 协议实现: |
| ... | ... | @@ -67,6 +70,7 @@ public class IotEmqxProtocol implements IotProtocol { |
| 67 | 70 | /** |
| 68 | 71 | * Vert.x 实例 |
| 69 | 72 | */ |
| 73 | + @Getter | |
| 70 | 74 | private Vertx vertx; |
| 71 | 75 | /** |
| 72 | 76 | * HTTP Hook 服务器 |
| ... | ... | @@ -519,6 +523,35 @@ public class IotEmqxProtocol implements IotProtocol { |
| 519 | 523 | } |
| 520 | 524 | |
| 521 | 525 | /** |
| 526 | + * 异步发布消息到 MQTT Broker:返回 publish 真实的结果(透传下发使用) | |
| 527 | + * <p> | |
| 528 | + * 成功只表示已交给 Broker,不代表设备已收到;未连接或发布失败时,返回 ServiceException(DEVICE_OFFLINE_OR_WRITE_FAILED) | |
| 529 | + * | |
| 530 | + * @param topic 主题 | |
| 531 | + * @param payload 消息内容 | |
| 532 | + * @return 发布结果 | |
| 533 | + */ | |
| 534 | + public Future<Void> publishMessageAsync(String topic, byte[] payload) { | |
| 535 | + MqttClient client = this.mqttClient; | |
| 536 | + if (client == null || !client.isConnected()) { | |
| 537 | + log.warn("[publishMessageAsync][IoT EMQX 协议 {} MQTT Client 未连接, 无法发布消息]", getId()); | |
| 538 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 539 | + } | |
| 540 | + try { | |
| 541 | + MqttQoS qos = MqttQoS.valueOf(emqxConfig.getMqttQos()); | |
| 542 | + return client.publish(topic, Buffer.buffer(payload), qos, false, false) | |
| 543 | + .<Void>mapEmpty() | |
| 544 | + .recover(e -> { | |
| 545 | + log.warn("[publishMessageAsync][IoT EMQX 协议 {} 发布失败, topic: {}, 错误: {}]", getId(), topic, e.getMessage()); | |
| 546 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 547 | + }); | |
| 548 | + } catch (Exception e) { | |
| 549 | + log.error("[publishMessageAsync][IoT EMQX 协议 {} 发布异常, topic: {}]", getId(), topic, e); | |
| 550 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 551 | + } | |
| 552 | + } | |
| 553 | + | |
| 554 | + /** | |
| 522 | 555 | * 延迟发布消息到 MQTT Broker |
| 523 | 556 | * |
| 524 | 557 | * @param topic 主题 | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamHandler.java
| ... | ... | @@ -9,8 +9,12 @@ 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; |
| 12 | +import io.vertx.core.Future; | |
| 12 | 13 | import lombok.extern.slf4j.Slf4j; |
| 13 | 14 | |
| 15 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 16 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 17 | + | |
| 14 | 18 | /** |
| 15 | 19 | * IoT 网关 EMQX 下行消息处理器 |
| 16 | 20 | * <p> |
| ... | ... | @@ -61,6 +65,26 @@ public class IotEmqxDownstreamHandler { |
| 61 | 65 | } |
| 62 | 66 | |
| 63 | 67 | /** |
| 68 | + * 处理透传下行(raw.push 或编码脚本的输出):原始字节发布到 raw/push 主题 | |
| 69 | + * | |
| 70 | + * @param message 下行消息 | |
| 71 | + * @param bytes 要写出的原始字节 | |
| 72 | + * @return 真实的发布结果 | |
| 73 | + */ | |
| 74 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes) { | |
| 75 | + // 1. 获取设备信息 | |
| 76 | + IotDeviceRespDTO deviceInfo = deviceService.getDeviceFromCache(message.getDeviceId()); | |
| 77 | + if (deviceInfo == null) { | |
| 78 | + log.error("[handleRawMessage][设备信息({})不存在]", message.getDeviceId()); | |
| 79 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 80 | + } | |
| 81 | + | |
| 82 | + // 2. 发布到 raw/push 主题 | |
| 83 | + String topic = IotMqttTopicUtils.buildRawPushTopic(deviceInfo.getProductKey(), deviceInfo.getDeviceName()); | |
| 84 | + return protocol.publishMessageAsync(topic, bytes); | |
| 85 | + } | |
| 86 | + | |
| 87 | + /** | |
| 64 | 88 | * 根据消息方法和回复状态构建主题 |
| 65 | 89 | * |
| 66 | 90 | * @param message 设备消息 | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamSubscriber.java
| ... | ... | @@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.downstream; |
| 2 | 2 | |
| 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 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol; |
| 8 | +import io.vertx.core.Future; | |
| 7 | 9 | import lombok.extern.slf4j.Slf4j; |
| 8 | 10 | |
| 9 | 11 | /** |
| ... | ... | @@ -26,4 +28,14 @@ public class IotEmqxDownstreamSubscriber extends AbstractIotProtocolDownstreamSu |
| 26 | 28 | downstreamHandler.handle(message); |
| 27 | 29 | } |
| 28 | 30 | |
| 31 | + @Override | |
| 32 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 33 | + return downstreamHandler.handleRawMessage(message, bytes); | |
| 34 | + } | |
| 35 | + | |
| 36 | + @Override | |
| 37 | + public boolean supportsRawDownstream() { | |
| 38 | + return true; | |
| 39 | + } | |
| 40 | + | |
| 29 | 41 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandler.java
| ... | ... | @@ -229,7 +229,7 @@ public class IotEmqxAuthEventHandler { |
| 229 | 229 | return; |
| 230 | 230 | } |
| 231 | 231 | |
| 232 | - // 2. 执行 ACL 校验 | |
| 232 | + // 2. 执行 ACL 校验(透传的 raw/post 发布、raw/push 订阅同样只放行设备自己的 /sys/{productKey}/{deviceName}/ 前缀) | |
| 233 | 233 | boolean allowed = subscribe |
| 234 | 234 | ? IotMqttTopicUtils.isTopicSubscribeAllowed(topic, deviceInfo.getProductKey(), deviceInfo.getDeviceName()) |
| 235 | 235 | : IotMqttTopicUtils.isTopicPublishAllowed(topic, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxUpstreamHandler.java
| ... | ... | @@ -3,9 +3,13 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream; |
| 3 | 3 | import cn.hutool.core.util.ArrayUtil; |
| 4 | 4 | import cn.hutool.core.util.StrUtil; |
| 5 | 5 | import cn.hutool.extra.spring.SpringUtil; |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 6 | 7 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 7 | 8 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 8 | 9 | import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; |
| 10 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 11 | +import io.vertx.core.Context; | |
| 12 | +import io.vertx.core.Vertx; | |
| 9 | 13 | import io.vertx.mqtt.messages.MqttPublishMessage; |
| 10 | 14 | import lombok.extern.slf4j.Slf4j; |
| 11 | 15 | |
| ... | ... | @@ -17,10 +21,24 @@ import lombok.extern.slf4j.Slf4j; |
| 17 | 21 | @Slf4j |
| 18 | 22 | public class IotEmqxUpstreamHandler { |
| 19 | 23 | |
| 24 | + /** | |
| 25 | + * 标准消息、透传上报最多排队的消息数:超过时丢弃 | |
| 26 | + */ | |
| 27 | + private static final int MAX_PENDING = 1024; | |
| 28 | + | |
| 20 | 29 | private final IotDeviceMessageService deviceMessageService; |
| 21 | 30 | |
| 22 | 31 | private final String serverId; |
| 23 | 32 | |
| 33 | + /** | |
| 34 | + * 标准消息的 worker 队列(有序、有界):所有设备共用一个 MQTT Client,与原来在 event loop 上一样按收到的顺序逐条处理 | |
| 35 | + */ | |
| 36 | + private IotWorkerTaskQueue queue; | |
| 37 | + /** | |
| 38 | + * 透传上报的 worker 队列(无序、有界):执行脚本耗时较长,消息之间并行 | |
| 39 | + */ | |
| 40 | + private IotWorkerTaskQueue rawQueue; | |
| 41 | + | |
| 24 | 42 | public IotEmqxUpstreamHandler(String serverId) { |
| 25 | 43 | this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); |
| 26 | 44 | this.serverId = serverId; |
| ... | ... | @@ -33,8 +51,12 @@ public class IotEmqxUpstreamHandler { |
| 33 | 51 | log.debug("[handle][收到 MQTT 消息, topic: {}, payload: {}]", mqttMessage.topicName(), mqttMessage.payload()); |
| 34 | 52 | String topic = mqttMessage.topicName(); |
| 35 | 53 | byte[] payload = mqttMessage.payload().getBytes(); |
| 54 | + // 0. 网关自己发布的透传下发(raw/push)被自己订阅到:直接忽略(回环) | |
| 55 | + if (IotMqttTopicUtils.isRawPushTopic(topic)) { | |
| 56 | + return; | |
| 57 | + } | |
| 36 | 58 | try { |
| 37 | - // 1. 解析主题,一次性获取所有信息 | |
| 59 | + // 1.1 解析主题,一次性获取所有信息 | |
| 38 | 60 | String[] topicParts = topic.split("/"); |
| 39 | 61 | String productKey = ArrayUtil.get(topicParts, 2); |
| 40 | 62 | String deviceName = ArrayUtil.get(topicParts, 3); |
| ... | ... | @@ -42,21 +64,74 @@ public class IotEmqxUpstreamHandler { |
| 42 | 64 | log.warn("[handle][topic({}) 格式不正确,无法解析有效的 productKey 和 deviceName]", topic); |
| 43 | 65 | return; |
| 44 | 66 | } |
| 45 | - | |
| 46 | - // 2.1 反序列化消息 | |
| 47 | - IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); | |
| 48 | - if (message == null) { | |
| 49 | - log.warn("[handle][topic({}) payload({}) 消息解码失败]", topic, new String(payload)); | |
| 67 | + // 1.2 透传上报:身份来自 Topic(由 EMQX ACL 保证设备只能发布自己的 Topic),跳过反序列化 | |
| 68 | + if (IotMqttTopicUtils.isRawPostTopic(topic)) { | |
| 69 | + handleRawRequest(topic, productKey, deviceName, payload); | |
| 50 | 70 | return; |
| 51 | 71 | } |
| 52 | - // 2.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀) | |
| 53 | - IotMqttTopicUtils.normalizeReplyMethod(message); | |
| 54 | 72 | |
| 55 | - // 3. 发送消息到队列 | |
| 56 | - deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); | |
| 73 | + // 2. 标准消息:反序列化(获取设备)、发送总线会阻塞,交给 worker 按收到的顺序执行 | |
| 74 | + getQueue().submit(() -> { | |
| 75 | + handleStandardRequest(topic, productKey, deviceName, payload); | |
| 76 | + return null; | |
| 77 | + }).onFailure(e -> log.error("[handle][topic({}) payload({}) 处理异常]", topic, new String(payload), e)); | |
| 57 | 78 | } catch (Exception e) { |
| 58 | 79 | log.error("[handle][topic({}) payload({}) 处理异常]", topic, new String(payload), e); |
| 59 | 80 | } |
| 60 | 81 | } |
| 61 | 82 | |
| 83 | + /** | |
| 84 | + * 处理标准消息(worker 线程):反序列化后发送 | |
| 85 | + * | |
| 86 | + * @param topic 主题 | |
| 87 | + * @param productKey 产品 Key | |
| 88 | + * @param deviceName 设备名称 | |
| 89 | + * @param payload 消息内容 | |
| 90 | + */ | |
| 91 | + private void handleStandardRequest(String topic, String productKey, String deviceName, byte[] payload) { | |
| 92 | + // 1.1 反序列化消息 | |
| 93 | + IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); | |
| 94 | + if (message == null) { | |
| 95 | + log.warn("[handleStandardRequest][topic({}) payload({}) 消息解码失败]", topic, new String(payload)); | |
| 96 | + return; | |
| 97 | + } | |
| 98 | + // 1.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀) | |
| 99 | + IotMqttTopicUtils.normalizeReplyMethod(message); | |
| 100 | + | |
| 101 | + // 2. 发送消息到队列 | |
| 102 | + deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); | |
| 103 | + } | |
| 104 | + | |
| 105 | + /** | |
| 106 | + * 获取标准消息的 worker 队列:按当前(MQTT Client 所在的)context 创建;协议重启后 context 变化时重建 | |
| 107 | + */ | |
| 108 | + private IotWorkerTaskQueue getQueue() { | |
| 109 | + Context context = Vertx.currentContext(); | |
| 110 | + if (queue == null || queue.getContext() != context) { | |
| 111 | + queue = new IotWorkerTaskQueue(context, true, MAX_PENDING); | |
| 112 | + } | |
| 113 | + return queue; | |
| 114 | + } | |
| 115 | + | |
| 116 | + /** | |
| 117 | + * 处理透传上报:原始字节按产品的数据解析脚本 decode 后发送;会阻塞,交给 worker 执行(不同消息之间并行) | |
| 118 | + * | |
| 119 | + * @param topic 主题 | |
| 120 | + * @param productKey 产品 Key | |
| 121 | + * @param deviceName 设备名称 | |
| 122 | + * @param payload 原始字节 | |
| 123 | + */ | |
| 124 | + private void handleRawRequest(String topic, String productKey, String deviceName, byte[] payload) { | |
| 125 | + Context context = Vertx.currentContext(); | |
| 126 | + if (rawQueue == null || rawQueue.getContext() != context) { // 协议重启后 MQTT Client 换了 Vertx:按新的 context 重建 | |
| 127 | + rawQueue = new IotWorkerTaskQueue(context, false, MAX_PENDING); | |
| 128 | + } | |
| 129 | + // 截止时间从收到(入队之前)开始计算 | |
| 130 | + long receivedNanos = System.nanoTime(); | |
| 131 | + rawQueue.submit(() -> deviceMessageService.sendRawMessage(payload, productKey, deviceName, serverId, | |
| 132 | + IotProtocolTypeEnum.EMQX, receivedNanos)).onFailure(e -> | |
| 133 | + log.warn("[handleRawRequest][topic({}) 透传上报处理失败,数据长度: {} 字节,错误: {}]", | |
| 134 | + topic, payload.length, e.getMessage())); | |
| 135 | + } | |
| 136 | + | |
| 62 | 137 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpAbstractHandler.java
| ... | ... | @@ -29,6 +29,11 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU |
| 29 | 29 | @Slf4j |
| 30 | 30 | public abstract class IotHttpAbstractHandler implements Handler<RoutingContext> { |
| 31 | 31 | |
| 32 | + /** | |
| 33 | + * handle0 返回这个哨兵:表示响应已由子类异步接管(上报在 worker 上处理完成后写一次,透传上报到期先写超时),基类不再写响应 | |
| 34 | + */ | |
| 35 | + protected static final CommonResult<Object> ASYNC_RESPONSE = new CommonResult<>(); | |
| 36 | + | |
| 32 | 37 | private final IotDeviceTokenService deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); |
| 33 | 38 | |
| 34 | 39 | @Override |
| ... | ... | @@ -39,6 +44,9 @@ public abstract class IotHttpAbstractHandler implements Handler<RoutingContext> |
| 39 | 44 | |
| 40 | 45 | // 2. 执行逻辑 |
| 41 | 46 | CommonResult<Object> result = handle0(context); |
| 47 | + if (result == ASYNC_RESPONSE) { // 响应由子类异步写出 | |
| 48 | + return; | |
| 49 | + } | |
| 42 | 50 | writeResponse(context, result); |
| 43 | 51 | } catch (ServiceException e) { |
| 44 | 52 | // 已知异常,返回对应的错误码和错误信息 | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpUpstreamHandler.java
| ... | ... | @@ -3,16 +3,34 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream; |
| 3 | 3 | import cn.hutool.core.lang.Assert; |
| 4 | 4 | import cn.hutool.core.map.MapUtil; |
| 5 | 5 | import cn.hutool.core.text.StrPool; |
| 6 | +import cn.hutool.core.util.StrUtil; | |
| 6 | 7 | import cn.hutool.extra.spring.SpringUtil; |
| 8 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 7 | 9 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 10 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 11 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 8 | 12 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 9 | 14 | import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol; |
| 10 | 15 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 16 | +import io.vertx.core.AsyncResult; | |
| 17 | +import io.vertx.core.Vertx; | |
| 18 | +import io.vertx.core.http.HttpHeaders; | |
| 11 | 19 | import io.vertx.ext.web.RoutingContext; |
| 12 | 20 | import lombok.extern.slf4j.Slf4j; |
| 21 | +import org.springframework.http.MediaType; | |
| 22 | + | |
| 23 | +import java.time.Duration; | |
| 24 | + | |
| 25 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; | |
| 26 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; | |
| 27 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT; | |
| 13 | 28 | |
| 14 | 29 | /** |
| 15 | 30 | * IoT 网关 HTTP 协议的【上行】处理器 |
| 31 | + * <p> | |
| 32 | + * 透传上报:POST /topic/sys/{productKey}/{deviceName}/thing/raw/post,Content-Type 为 application/octet-stream, | |
| 33 | + * 请求体是原始字节;同一请求内应答 JSON 结果(与标准上报一致,data 为 messageId) | |
| 16 | 34 | * |
| 17 | 35 | * @author 芋道源码 |
| 18 | 36 | */ |
| ... | ... | @@ -23,10 +41,16 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { |
| 23 | 41 | |
| 24 | 42 | private final String serverId; |
| 25 | 43 | |
| 44 | + /** | |
| 45 | + * 透传上报的应答计时时长:同一请求内应答,到期先返回超时(截止判断由设备消息 Service 负责) | |
| 46 | + */ | |
| 47 | + private final Duration deadline; | |
| 48 | + | |
| 26 | 49 | private final IotDeviceMessageService deviceMessageService; |
| 27 | 50 | |
| 28 | 51 | public IotHttpUpstreamHandler(IotHttpProtocol protocol) { |
| 29 | 52 | this.serverId = protocol.getServerId(); |
| 53 | + this.deadline = SpringUtil.getBean(IotGatewayProperties.class).getScript().getDeadline(); | |
| 30 | 54 | this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); |
| 31 | 55 | } |
| 32 | 56 | |
| ... | ... | @@ -36,17 +60,90 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { |
| 36 | 60 | String productKey = context.pathParam("productKey"); |
| 37 | 61 | String deviceName = context.pathParam("deviceName"); |
| 38 | 62 | String method = context.pathParam("*").replaceAll(StrPool.SLASH, StrPool.DOT); |
| 39 | - // 1.2 根据 Content-Type 反序列化消息 | |
| 63 | + // 1.2 透传上报:在 worker 上处理,响应异步写一次 | |
| 64 | + if (isRawPost(context, method)) { | |
| 65 | + return handleRawPost(context, productKey, deviceName); | |
| 66 | + } | |
| 67 | + // 1.3 根据 Content-Type 反序列化消息 | |
| 40 | 68 | IotDeviceMessage message = deserializeRequest(context, IotDeviceMessage.class); |
| 41 | 69 | Assert.notNull(message, "请求参数不能为空"); |
| 42 | 70 | Assert.equals(method, message.getMethod(), "method 不匹配"); |
| 43 | 71 | |
| 44 | - // 2. 发送消息 | |
| 45 | - deviceMessageService.sendDeviceMessage(message, | |
| 46 | - productKey, deviceName, serverId); | |
| 72 | + // 2. 发送消息:获取设备、发送总线会阻塞,交给 worker 执行,完成后在 event loop 上响应(与原来一样等发送完成) | |
| 73 | + context.vertx().executeBlocking(() -> { | |
| 74 | + deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); | |
| 75 | + return message.getId(); | |
| 76 | + }, false).onComplete(ar -> writeAsyncResponse(context, buildResult(context, ar))); | |
| 77 | + return ASYNC_RESPONSE; | |
| 78 | + } | |
| 79 | + | |
| 80 | + /** | |
| 81 | + * 是否透传上报:路径为 thing/raw/post,并且 Content-Type 为 application/octet-stream | |
| 82 | + * <p> | |
| 83 | + * 其它 Content-Type 走 JSON 分支,由标准路径拒绝透传方法 | |
| 84 | + */ | |
| 85 | + private boolean isRawPost(RoutingContext context, String method) { | |
| 86 | + if (!StrUtil.equals(IotDeviceMessageMethodEnum.RAW_POST.getMethod(), method)) { | |
| 87 | + return false; | |
| 88 | + } | |
| 89 | + String contentType = context.request().getHeader(HttpHeaders.CONTENT_TYPE); | |
| 90 | + return StrUtil.equalsIgnoreCase(StrUtil.trim(StrUtil.subBefore(contentType, ';', false)), | |
| 91 | + MediaType.APPLICATION_OCTET_STREAM_VALUE); | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** | |
| 95 | + * 透传上报:身份来自前置认证(token 与路径一致),与 JSON 分支相同 | |
| 96 | + * <p> | |
| 97 | + * 解析与发送会阻塞,交给 worker 执行;截止时间到期先返回超时(101),之后完成的结果不再响应。 | |
| 98 | + * 计时器与 worker 的结果都回到请求所在的 event loop,以「响应是否已结束」保证只响应一次 | |
| 99 | + */ | |
| 100 | + private CommonResult<Object> handleRawPost(RoutingContext context, String productKey, String deviceName) { | |
| 101 | + byte[] body = context.body().buffer() != null ? context.body().buffer().getBytes() : null; | |
| 102 | + // 截止时间从收到(交给 worker 之前)开始计算 | |
| 103 | + long receivedNanos = System.nanoTime(); | |
| 104 | + Vertx vertx = context.vertx(); | |
| 105 | + long timerId = vertx.setTimer(Math.max(1L, deadline.toMillis()), | |
| 106 | + id -> writeAsyncResponse(context, CommonResult.error(SCRIPT_TIMEOUT))); | |
| 107 | + vertx.executeBlocking(() -> deviceMessageService.sendRawMessage(body, productKey, deviceName, serverId, | |
| 108 | + IotProtocolTypeEnum.HTTP, receivedNanos), false).onComplete(ar -> { | |
| 109 | + vertx.cancelTimer(timerId); | |
| 110 | + writeAsyncResponse(context, buildResult(context, ar)); | |
| 111 | + }); | |
| 112 | + return ASYNC_RESPONSE; | |
| 113 | + } | |
| 114 | + | |
| 115 | + /** | |
| 116 | + * worker 的处理结果转为响应:与基类的异常处理一致 | |
| 117 | + */ | |
| 118 | + private static CommonResult<Object> buildResult(RoutingContext context, AsyncResult<String> ar) { | |
| 119 | + if (ar.succeeded()) { | |
| 120 | + return CommonResult.success(MapUtil.of("messageId", ar.result())); | |
| 121 | + } | |
| 122 | + if (ar.cause() instanceof ServiceException) { | |
| 123 | + ServiceException e = (ServiceException) ar.cause(); | |
| 124 | + return CommonResult.error(e.getCode(), e.getMessage()); | |
| 125 | + } | |
| 126 | + if (ar.cause() instanceof IllegalArgumentException) { | |
| 127 | + return CommonResult.error(BAD_REQUEST.getCode(), ar.cause().getMessage()); | |
| 128 | + } | |
| 129 | + log.error("[buildResult][path({}) 处理异常]", context.request().path(), ar.cause()); | |
| 130 | + return CommonResult.error(INTERNAL_SERVER_ERROR); | |
| 131 | + } | |
| 47 | 132 | |
| 48 | - // 3. 返回结果 | |
| 49 | - return CommonResult.success(MapUtil.of("messageId", message.getId())); | |
| 133 | + /** | |
| 134 | + * 写 worker 处理结果的响应(event loop 上执行):响应已结束(透传已超时返回)或连接已关闭时丢弃 | |
| 135 | + */ | |
| 136 | + private static void writeAsyncResponse(RoutingContext context, CommonResult<Object> result) { | |
| 137 | + if (context.response().ended() || context.response().closed()) { | |
| 138 | + log.warn("[writeAsyncResponse][path({}) 响应已结束或连接已关闭,丢弃结果({})]", | |
| 139 | + context.request().path(), result.getCode()); | |
| 140 | + return; | |
| 141 | + } | |
| 142 | + try { | |
| 143 | + writeResponse(context, result); | |
| 144 | + } catch (Exception e) { | |
| 145 | + log.error("[writeAsyncResponse][path({}) 写响应异常]", context.request().path(), e); | |
| 146 | + } | |
| 50 | 147 | } |
| 51 | 148 | |
| 52 | 149 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/manager/IotModbusTx.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 5 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; | |
| 6 | +import lombok.AccessLevel; | |
| 7 | +import lombok.Getter; | |
| 8 | +import lombok.extern.slf4j.Slf4j; | |
| 9 | + | |
| 10 | +import java.util.concurrent.TimeUnit; | |
| 11 | +import java.util.concurrent.atomic.AtomicBoolean; | |
| 12 | +import java.util.function.BiConsumer; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * IoT Modbus 透传事务(thing.raw.push,或编码脚本输出的 PDU),server、client 共用 | |
| 16 | + * <p> | |
| 17 | + * 标准轮询、标准写不创建事务,保持原有的发送与匹配方式。 | |
| 18 | + * 只有一个终态:{@link #complete} / {@link #fail} 通过 CAS 只生效一次,之后的终态信号(迟到的响应、重复的超时、断线)直接忽略。 | |
| 19 | + * 终态回调在触发终态的线程上执行(server:连接的 owner;client:executeBlocking 的完成回调),只能做非阻塞动作 | |
| 20 | + * | |
| 21 | + * @author 芋道源码 | |
| 22 | + */ | |
| 23 | +@Getter | |
| 24 | +@Slf4j | |
| 25 | +public class IotModbusTx { | |
| 26 | + | |
| 27 | + /** | |
| 28 | + * 原下行请求 | |
| 29 | + */ | |
| 30 | + private final IotDeviceMessage request; | |
| 31 | + /** | |
| 32 | + * 请求 PDU(功能码 + 数据,不带 slaveId、CRC、MBAP) | |
| 33 | + */ | |
| 34 | + private final byte[] pdu; | |
| 35 | + /** | |
| 36 | + * 截止时间(System.nanoTime):排队超过它就不再发送 | |
| 37 | + */ | |
| 38 | + private final long deadlineNanos; | |
| 39 | + | |
| 40 | + /** | |
| 41 | + * 终态回调:responsePdu = 设备响应 PDU(含异常响应 fc|0x80 + 异常码),failure = 网关判定的失败,二者只有一个非空 | |
| 42 | + */ | |
| 43 | + @Getter(AccessLevel.NONE) | |
| 44 | + private final BiConsumer<byte[], ErrorCode> callback; | |
| 45 | + @Getter(AccessLevel.NONE) | |
| 46 | + private final AtomicBoolean done = new AtomicBoolean(); | |
| 47 | + | |
| 48 | + /** | |
| 49 | + * 只通过 {@link #ofServer} / {@link #ofClient} 创建 | |
| 50 | + */ | |
| 51 | + private IotModbusTx(IotDeviceMessage request, byte[] pdu, long deadlineNanos, | |
| 52 | + BiConsumer<byte[], ErrorCode> callback) { | |
| 53 | + this.request = request; | |
| 54 | + this.pdu = pdu; | |
| 55 | + this.deadlineNanos = deadlineNanos; | |
| 56 | + this.callback = callback; | |
| 57 | + } | |
| 58 | + | |
| 59 | + /** | |
| 60 | + * 创建 server 端的透传事务:截止时间 = 现在 + 通道的 requestTimeout | |
| 61 | + * | |
| 62 | + * @param channel 提交它的连接通道:只用于读取 requestTimeout | |
| 63 | + * @param pdu 请求 PDU | |
| 64 | + * @param request 原下行请求 | |
| 65 | + * @param callback 终态回调(可为 null) | |
| 66 | + * @return 事务 | |
| 67 | + */ | |
| 68 | + public static IotModbusTx ofServer(IotModbusTcpServerTransactionChannel channel, byte[] pdu, | |
| 69 | + IotDeviceMessage request, BiConsumer<byte[], ErrorCode> callback) { | |
| 70 | + long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(channel.getRequestTimeout()); | |
| 71 | + return new IotModbusTx(request, pdu, deadlineNanos, callback); | |
| 72 | + } | |
| 73 | + | |
| 74 | + /** | |
| 75 | + * 创建 client 端的透传事务 | |
| 76 | + * | |
| 77 | + * @param request 原下行请求 | |
| 78 | + * @param pdu 请求 PDU | |
| 79 | + * @param deadlineNanos 截止时间(System.nanoTime) | |
| 80 | + * @param callback 终态回调 | |
| 81 | + * @return 事务 | |
| 82 | + */ | |
| 83 | + public static IotModbusTx ofClient(IotDeviceMessage request, byte[] pdu, long deadlineNanos, | |
| 84 | + BiConsumer<byte[], ErrorCode> callback) { | |
| 85 | + return new IotModbusTx(request, pdu, deadlineNanos, callback); | |
| 86 | + } | |
| 87 | + | |
| 88 | + /** | |
| 89 | + * 收到设备响应(包括异常响应):进入成功终态 | |
| 90 | + * | |
| 91 | + * @param responsePdu 响应 PDU | |
| 92 | + * @return 是否由本次调用进入终态 | |
| 93 | + */ | |
| 94 | + public boolean complete(byte[] responsePdu) { | |
| 95 | + return terminate(responsePdu, null); | |
| 96 | + } | |
| 97 | + | |
| 98 | + /** | |
| 99 | + * 网关判定的失败(超时、断线、写失败、队列满、响应不合法):进入失败终态 | |
| 100 | + * | |
| 101 | + * @param failure 错误码 | |
| 102 | + * @return 是否由本次调用进入终态 | |
| 103 | + */ | |
| 104 | + public boolean fail(ErrorCode failure) { | |
| 105 | + return terminate(null, failure); | |
| 106 | + } | |
| 107 | + | |
| 108 | + /** | |
| 109 | + * @return 是否已经超过截止时间 | |
| 110 | + */ | |
| 111 | + public boolean isExpired() { | |
| 112 | + return System.nanoTime() - deadlineNanos >= 0; | |
| 113 | + } | |
| 114 | + | |
| 115 | + /** | |
| 116 | + * 进入终态:只有第一次调用生效,并执行终态回调 | |
| 117 | + * | |
| 118 | + * @param responsePdu 响应 PDU;失败时为 null | |
| 119 | + * @param failure 网关判定的失败;成功时为 null | |
| 120 | + * @return 是否由本次调用进入终态 | |
| 121 | + */ | |
| 122 | + private boolean terminate(byte[] responsePdu, ErrorCode failure) { | |
| 123 | + // 1. CAS:已进入终态时直接忽略(迟到的响应、重复的超时、断线) | |
| 124 | + if (!done.compareAndSet(false, true)) { | |
| 125 | + return false; | |
| 126 | + } | |
| 127 | + if (callback == null) { | |
| 128 | + return true; | |
| 129 | + } | |
| 130 | + // 2. 执行终态回调:异常只记日志,不影响调用方(owner 上的清理、executeBlocking 的完成回调) | |
| 131 | + try { | |
| 132 | + callback.accept(responsePdu, failure); | |
| 133 | + } catch (Exception e) { | |
| 134 | + log.error("[terminate][Modbus 透传事务终态回调异常, deviceId={}]", | |
| 135 | + request != null ? request.getDeviceId() : null, e); | |
| 136 | + } | |
| 137 | + return true; | |
| 138 | + } | |
| 139 | + | |
| 140 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusCommonUtils.java
| ... | ... | @@ -4,11 +4,13 @@ import cn.hutool.core.collection.CollUtil; |
| 4 | 4 | import cn.hutool.core.util.ArrayUtil; |
| 5 | 5 | import cn.hutool.core.util.ObjectUtil; |
| 6 | 6 | import cn.hutool.core.util.StrUtil; |
| 7 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 7 | 8 | import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; |
| 8 | 9 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; |
| 9 | 10 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; |
| 10 | 11 | import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusByteOrderEnum; |
| 11 | 12 | import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusRawDataTypeEnum; |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants; | |
| 12 | 14 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; |
| 13 | 15 | import lombok.experimental.UtilityClass; |
| 14 | 16 | import lombok.extern.slf4j.Slf4j; |
| ... | ... | @@ -17,6 +19,7 @@ import java.math.BigDecimal; |
| 17 | 19 | import java.math.RoundingMode; |
| 18 | 20 | import java.nio.ByteBuffer; |
| 19 | 21 | import java.nio.ByteOrder; |
| 22 | +import java.util.Arrays; | |
| 20 | 23 | |
| 21 | 24 | /** |
| 22 | 25 | * IoT Modbus 协议工具类 |
| ... | ... | @@ -524,6 +527,113 @@ public class IotModbusCommonUtils { |
| 524 | 527 | return -1; |
| 525 | 528 | } |
| 526 | 529 | |
| 530 | + // ==================== 事务响应:PDU 还原与格式校验 ==================== | |
| 531 | + | |
| 532 | + /** | |
| 533 | + * 构建异常响应 PDU:[fc | 0x80, 异常码] | |
| 534 | + * | |
| 535 | + * @param functionCode 原功能码 | |
| 536 | + * @param exceptionCode 异常码 | |
| 537 | + * @return 异常响应 PDU | |
| 538 | + */ | |
| 539 | + public static byte[] buildExceptionPdu(int functionCode, int exceptionCode) { | |
| 540 | + return new byte[]{(byte) (functionCode | FC_EXCEPTION_MASK), (byte) exceptionCode}; | |
| 541 | + } | |
| 542 | + | |
| 543 | + /** | |
| 544 | + * 从解码后的响应帧还原响应 PDU(不带 slaveId、CRC、MBAP) | |
| 545 | + * <p> | |
| 546 | + * 解码器的 {@link IotModbusFrame#getPdu()} 是功能码之后的全部数据(读响应含 byteCount); | |
| 547 | + * 异常响应时解码器已把功能码还原为原功能码、异常码单独存放,这里重新加上 0x80 异常位 | |
| 548 | + * | |
| 549 | + * @param frame 响应帧 | |
| 550 | + * @return 异常 → [fc | 0x80, 异常码];正常 → [fc] + pdu | |
| 551 | + */ | |
| 552 | + public static byte[] buildResponsePdu(IotModbusFrame frame) { | |
| 553 | + if (frame.isException()) { | |
| 554 | + return buildExceptionPdu(frame.getFunctionCode(), frame.getExceptionCode()); | |
| 555 | + } | |
| 556 | + byte[] data = frame.getPdu() != null ? frame.getPdu() : new byte[0]; | |
| 557 | + byte[] responsePdu = new byte[1 + data.length]; | |
| 558 | + responsePdu[0] = (byte) frame.getFunctionCode(); | |
| 559 | + System.arraycopy(data, 0, responsePdu, 1, data.length); | |
| 560 | + return responsePdu; | |
| 561 | + } | |
| 562 | + | |
| 563 | + /** | |
| 564 | + * 获取透传响应对应的设备错误:用于回复 biz 时的结果码 | |
| 565 | + * | |
| 566 | + * @param responsePdu 设备响应 PDU(非空) | |
| 567 | + * @return 异常响应 [fc | 0x80, 异常码] → {@link ErrorCodeConstants#MODBUS_EXCEPTION}(带异常码);正常响应 → null | |
| 568 | + */ | |
| 569 | + public static ErrorCode getRawResponseError(byte[] responsePdu) { | |
| 570 | + if (!isExceptionResponse(responsePdu[0] & 0xFF) || responsePdu.length < 2) { | |
| 571 | + return null; | |
| 572 | + } | |
| 573 | + ErrorCode exception = ErrorCodeConstants.MODBUS_EXCEPTION; | |
| 574 | + return new ErrorCode(exception.getCode(), StrUtil.format(exception.getMsg(), responsePdu[1] & 0xFF)); | |
| 575 | + } | |
| 576 | + | |
| 577 | + /** | |
| 578 | + * 获取请求 PDU 中的数量:FC01-04 / FC15 / FC16 取第 3-4 字节;FC05 / FC06 固定为 1 | |
| 579 | + * | |
| 580 | + * @param requestPdu 请求 PDU(已通过格式校验,至少 5 字节) | |
| 581 | + * @return 数量 | |
| 582 | + */ | |
| 583 | + public static int getRequestQuantity(byte[] requestPdu) { | |
| 584 | + int functionCode = requestPdu[0] & 0xFF; | |
| 585 | + if (functionCode == FC_WRITE_SINGLE_COIL || functionCode == FC_WRITE_SINGLE_REGISTER) { | |
| 586 | + return 1; | |
| 587 | + } | |
| 588 | + return ((requestPdu[3] & 0xFF) << 8) | (requestPdu[4] & 0xFF); | |
| 589 | + } | |
| 590 | + | |
| 591 | + /** | |
| 592 | + * 校验正常响应 PDU 的格式是否与请求一致(异常响应不经过这里) | |
| 593 | + * <ul> | |
| 594 | + * <li>FC01 / FC02:byteCount = ceil(数量 / 8),总长 = 2 + byteCount;</li> | |
| 595 | + * <li>FC03 / FC04:byteCount = 2 × 数量,总长 = 2 + byteCount;</li> | |
| 596 | + * <li>FC05 / FC06:原样回显请求;</li> | |
| 597 | + * <li>FC15 / FC16:回显地址和数量。</li> | |
| 598 | + * </ul> | |
| 599 | + * | |
| 600 | + * @param requestPdu 请求 PDU | |
| 601 | + * @param responsePdu 响应 PDU | |
| 602 | + * @return 是否合法 | |
| 603 | + */ | |
| 604 | + @SuppressWarnings("EnhancedSwitchMigration") | |
| 605 | + public static boolean isResponseShapeValid(byte[] requestPdu, byte[] responsePdu) { | |
| 606 | + if (requestPdu == null || requestPdu.length < 5 || responsePdu == null || responsePdu.length < 1) { | |
| 607 | + return false; | |
| 608 | + } | |
| 609 | + int functionCode = requestPdu[0] & 0xFF; | |
| 610 | + if ((responsePdu[0] & 0xFF) != functionCode) { | |
| 611 | + return false; | |
| 612 | + } | |
| 613 | + int quantity = getRequestQuantity(requestPdu); | |
| 614 | + switch (functionCode) { | |
| 615 | + case FC_READ_COILS: | |
| 616 | + case FC_READ_DISCRETE_INPUTS: | |
| 617 | + return isReadResponseShapeValid(responsePdu, (quantity + 7) / 8); | |
| 618 | + case FC_READ_HOLDING_REGISTERS: | |
| 619 | + case FC_READ_INPUT_REGISTERS: | |
| 620 | + return isReadResponseShapeValid(responsePdu, quantity * 2); | |
| 621 | + case FC_WRITE_SINGLE_COIL: | |
| 622 | + case FC_WRITE_SINGLE_REGISTER: | |
| 623 | + return responsePdu.length == 5 && Arrays.equals(Arrays.copyOf(requestPdu, 5), responsePdu); | |
| 624 | + case FC_WRITE_MULTIPLE_COILS: | |
| 625 | + case FC_WRITE_MULTIPLE_REGISTERS: | |
| 626 | + return responsePdu.length == 5 | |
| 627 | + && Arrays.equals(Arrays.copyOfRange(requestPdu, 1, 5), Arrays.copyOfRange(responsePdu, 1, 5)); | |
| 628 | + default: | |
| 629 | + return false; | |
| 630 | + } | |
| 631 | + } | |
| 632 | + | |
| 633 | + private static boolean isReadResponseShapeValid(byte[] responsePdu, int expectedByteCount) { | |
| 634 | + return responsePdu.length == 2 + expectedByteCount && (responsePdu[1] & 0xFF) == expectedByteCount; | |
| 635 | + } | |
| 636 | + | |
| 527 | 637 | // ==================== 点位查找 ==================== |
| 528 | 638 | |
| 529 | 639 | /** | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusTcpClientUtils.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.util.StrUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 5 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 3 | 6 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; |
| 7 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream.IotModbusRawRequest; | |
| 4 | 9 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager; |
| 10 | +import com.ghgande.j2mod.modbus.ModbusException; | |
| 11 | +import com.ghgande.j2mod.modbus.ModbusIOException; | |
| 12 | +import com.ghgande.j2mod.modbus.ModbusSlaveException; | |
| 5 | 13 | import com.ghgande.j2mod.modbus.io.ModbusTCPTransaction; |
| 6 | 14 | import com.ghgande.j2mod.modbus.msg.*; |
| 7 | 15 | import com.ghgande.j2mod.modbus.procimg.InputRegister; |
| ... | ... | @@ -12,6 +20,13 @@ import io.vertx.core.Future; |
| 12 | 20 | import lombok.experimental.UtilityClass; |
| 13 | 21 | import lombok.extern.slf4j.Slf4j; |
| 14 | 22 | |
| 23 | +import java.io.IOException; | |
| 24 | +import java.net.SocketTimeoutException; | |
| 25 | + | |
| 26 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 27 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 28 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_RESPONSE_INVALID; | |
| 29 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_TIMEOUT; | |
| 15 | 30 | import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.*; |
| 16 | 31 | |
| 17 | 32 | /** |
| ... | ... | @@ -124,6 +139,118 @@ public class IotModbusTcpClientUtils { |
| 124 | 139 | } |
| 125 | 140 | |
| 126 | 141 | /** |
| 142 | + * 执行透传请求(raw.push / encode 输出的 PDU) | |
| 143 | + * <p> | |
| 144 | + * 设备的正常、异常响应都从 transaction.getResponse() 取真实响应,先核对事务号、unit、功能码,再还原 PDU: | |
| 145 | + * 异常响应还原为 [fc | 0x80, 异常码] 作为成功结果;在有序队列里排队超过截止 → 110; | |
| 146 | + * 身份不一致 → 在本有序任务内关闭连接再 114(下一个任务开始时重连,不会读到错位的响应);格式与请求不一致 → 114; | |
| 147 | + * 传输失败由 executeRawBlocking 的守卫先断开连接再抛出(只作用于透传;标准读写超时不断开共享连接) | |
| 148 | + * | |
| 149 | + * @param connection Modbus 连接 | |
| 150 | + * @param slaveId 从站地址 | |
| 151 | + * @param tx 透传事务 | |
| 152 | + * @return 响应 PDU | |
| 153 | + */ | |
| 154 | + public static Future<byte[]> executeRaw(IotModbusTcpClientConnectionManager.ModbusConnection connection, | |
| 155 | + Integer slaveId, IotModbusTx tx) { | |
| 156 | + return connection.executeRawBlocking(tcpConnection -> { | |
| 157 | + // 1. 在有序队列里排得太久(超过本连接的 timeout):不再执行 | |
| 158 | + if (tx.isExpired()) { | |
| 159 | + throw exception(MODBUS_TIMEOUT); | |
| 160 | + } | |
| 161 | + // 2.1 构造事务:retries = 1 表示只尝试 1 次(0 会被 j2mod 当成默认的 5 次;写功能码重试会重复写出) | |
| 162 | + IotModbusRawRequest request = new IotModbusRawRequest(tx.getPdu()); | |
| 163 | + request.setUnitID(slaveId); | |
| 164 | + ModbusTCPTransaction transaction = new ModbusTCPTransaction(tcpConnection); | |
| 165 | + transaction.setRetries(1); | |
| 166 | + transaction.setRequest(request); | |
| 167 | + // 2.2 保存发出的身份:setRequest 分配事务号,execute 结束后 j2mod 会把请求的事务号自增 | |
| 168 | + int transactionId = request.getTransactionID(); | |
| 169 | + int unitId = request.getUnitID(); | |
| 170 | + int functionCode = request.getFunctionCode(); | |
| 171 | + // 2.3 执行事务 | |
| 172 | + try { | |
| 173 | + transaction.execute(); | |
| 174 | + } catch (ModbusSlaveException e) { | |
| 175 | + // 设备异常响应:不在这里拼异常 PDU,下面用 transaction.getResponse() 的真实异常响应核对身份 | |
| 176 | + log.debug("[executeRaw][设备异常响应, slaveId={}, functionCode={}]", slaveId, functionCode); | |
| 177 | + } catch (ModbusException e) { | |
| 178 | + throw new RuntimeException(String.format("Modbus 透传失败 [slaveId=%d, functionCode=%d]", | |
| 179 | + slaveId, functionCode), e); | |
| 180 | + } | |
| 181 | + // 3.1 核对真实响应的身份:不一致说明读到的不是这次请求的响应,在本有序任务内关闭连接 | |
| 182 | + ModbusResponse response = transaction.getResponse(); | |
| 183 | + if (!IotModbusRawRequest.isResponseOf(response, transactionId, unitId, functionCode)) { | |
| 184 | + log.warn("[executeRaw][响应与请求不一致, 关闭连接, 请求(tid={}, unit={}, fc={}), 响应(tid={}, unit={}, fc={})]", | |
| 185 | + transactionId, unitId, functionCode, | |
| 186 | + response != null ? response.getTransactionID() : null, | |
| 187 | + response != null ? response.getUnitID() : null, | |
| 188 | + response != null ? response.getFunctionCode() : null); | |
| 189 | + tcpConnection.close(); | |
| 190 | + throw exception(MODBUS_RESPONSE_INVALID); | |
| 191 | + } | |
| 192 | + // 3.2 还原响应 PDU,并核对响应格式 | |
| 193 | + return request.buildResponsePdu(response); | |
| 194 | + }); | |
| 195 | + } | |
| 196 | + | |
| 197 | + /** | |
| 198 | + * 是否是传输失败(读超时、IO 异常):沿 cause 链查找 ModbusIOException / IOException; | |
| 199 | + * 遇到 ModbusSlaveException(设备异常响应,说明链路正常)返回 false | |
| 200 | + * | |
| 201 | + * @param e 异常 | |
| 202 | + * @return 是否传输失败 | |
| 203 | + */ | |
| 204 | + public static boolean isTransportFailure(Throwable e) { | |
| 205 | + int depth = 0; | |
| 206 | + for (Throwable cause = e; cause != null && depth < 16; cause = cause.getCause(), depth++) { | |
| 207 | + if (cause instanceof ModbusSlaveException) { | |
| 208 | + return false; | |
| 209 | + } | |
| 210 | + if (cause instanceof ModbusIOException || cause instanceof IOException) { | |
| 211 | + return true; | |
| 212 | + } | |
| 213 | + } | |
| 214 | + return false; | |
| 215 | + } | |
| 216 | + | |
| 217 | + /** | |
| 218 | + * 是否是读超时:j2mod 的 ModbusTCPTransport.readResponse 把 SocketTimeoutException 转为 | |
| 219 | + * ModbusIOException("Socket timeout reading response - ..."),不保留 cause,所以同时按消息判断 | |
| 220 | + * | |
| 221 | + * @param e 异常 | |
| 222 | + * @return 是否读超时 | |
| 223 | + */ | |
| 224 | + public static boolean isTimeoutFailure(Throwable e) { | |
| 225 | + int depth = 0; | |
| 226 | + for (Throwable cause = e; cause != null && depth < 16; cause = cause.getCause(), depth++) { | |
| 227 | + if (cause instanceof SocketTimeoutException) { | |
| 228 | + return true; | |
| 229 | + } | |
| 230 | + if (cause instanceof ModbusIOException && StrUtil.containsIgnoreCase(cause.getMessage(), "timeout")) { | |
| 231 | + return true; | |
| 232 | + } | |
| 233 | + } | |
| 234 | + return false; | |
| 235 | + } | |
| 236 | + | |
| 237 | + /** | |
| 238 | + * 把透传任务的失败转换为网关判定的错误码:ServiceException 保留其错误码(110 / 114 / 115),读超时 → 110,其它 → 106 | |
| 239 | + * | |
| 240 | + * @param e 异常 | |
| 241 | + * @return 错误码 | |
| 242 | + */ | |
| 243 | + public static ErrorCode toRawFailure(Throwable e) { | |
| 244 | + if (e instanceof ServiceException) { | |
| 245 | + return new ErrorCode(((ServiceException) e).getCode(), e.getMessage()); | |
| 246 | + } | |
| 247 | + if (isTimeoutFailure(e)) { | |
| 248 | + return MODBUS_TIMEOUT; | |
| 249 | + } | |
| 250 | + return DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 251 | + } | |
| 252 | + | |
| 253 | + /** | |
| 127 | 254 | * 创建读取请求 |
| 128 | 255 | */ |
| 129 | 256 | @SuppressWarnings("EnhancedSwitchMigration") | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/IotModbusTcpClientProtocol.java
| ... | ... | @@ -52,6 +52,7 @@ public class IotModbusTcpClientProtocol implements IotProtocol { |
| 52 | 52 | /** |
| 53 | 53 | * Vert.x 实例 |
| 54 | 54 | */ |
| 55 | + @Getter | |
| 55 | 56 | private final Vertx vertx; |
| 56 | 57 | /** |
| 57 | 58 | * 配置刷新定时器 ID |
| ... | ... | @@ -127,7 +128,8 @@ public class IotModbusTcpClientProtocol implements IotProtocol { |
| 127 | 128 | IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); |
| 128 | 129 | IotModbusTcpClientDownstreamHandler downstreamHandler = new IotModbusTcpClientDownstreamHandler(connectionManager, |
| 129 | 130 | configCacheService); |
| 130 | - this.downstreamSubscriber = new IotModbusTcpClientDownstreamSubscriber(this, downstreamHandler, messageBus); | |
| 131 | + this.downstreamSubscriber = new IotModbusTcpClientDownstreamSubscriber(this, downstreamHandler, | |
| 132 | + connectionManager, messageBus); | |
| 131 | 133 | this.downstreamSubscriber.start(); |
| 132 | 134 | } catch (Exception e) { |
| 133 | 135 | log.error("[start][IoT Modbus TCP Client 协议 {} 启动失败]", getId(), e); | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusRawRequest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; | |
| 4 | +import com.ghgande.j2mod.modbus.msg.ExceptionResponse; | |
| 5 | +import com.ghgande.j2mod.modbus.msg.ModbusRequest; | |
| 6 | +import com.ghgande.j2mod.modbus.msg.ModbusResponse; | |
| 7 | +import com.ghgande.j2mod.modbus.net.AbstractModbusListener; | |
| 8 | + | |
| 9 | +import java.io.DataInput; | |
| 10 | +import java.io.DataOutput; | |
| 11 | +import java.io.IOException; | |
| 12 | +import java.util.Arrays; | |
| 13 | + | |
| 14 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 15 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_RESPONSE_INVALID; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * Modbus 原始 PDU 请求(只用于透传,主站发送) | |
| 19 | + * <p> | |
| 20 | + * j2mod 3.4.0 的 ModbusTCPTransport.writeMessage 按 [MBAP][unitId][getFunctionCode()][getMessage()] 写出, | |
| 21 | + * 所以 {@link #getMessage()} 返回 PDU 去掉功能码之后的数据部分(不带 unit、MBAP)。 | |
| 22 | + * 响应由 ModbusTCPTransport.readResponse 按功能码用 ModbusResponse.createModbusResponse 创建(不经过 {@link #getResponse()}), | |
| 23 | + * 白名单内的 8 个功能码(01-06、0F、10)都有对应的标准响应类,{@link #buildResponsePdu} 用它的 getMessage 还原数据部分; | |
| 24 | + * 异常响应由 ModbusTCPTransaction.execute 转为 ModbusSlaveException,调用方再用 transaction.getResponse() 拿到真实的 ExceptionResponse 交给这里。 | |
| 25 | + * 正常、异常响应都先经过 {@link #isResponseOf} 核对身份 | |
| 26 | + * | |
| 27 | + * @author 芋道源码 | |
| 28 | + */ | |
| 29 | +public class IotModbusRawRequest extends ModbusRequest { | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * 完整的请求 PDU(功能码 + 数据) | |
| 33 | + */ | |
| 34 | + private final byte[] pdu; | |
| 35 | + /** | |
| 36 | + * PDU 去掉功能码之后的部分 | |
| 37 | + */ | |
| 38 | + private final byte[] data; | |
| 39 | + | |
| 40 | + public IotModbusRawRequest(byte[] pdu) { | |
| 41 | + this.pdu = pdu.clone(); | |
| 42 | + this.data = Arrays.copyOfRange(pdu, 1, pdu.length); | |
| 43 | + setFunctionCode(pdu[0] & 0xFF); | |
| 44 | + setDataLength(data.length); | |
| 45 | + } | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * TCP 主站不经过这里;按 j2mod 的约定返回带请求头的空响应 | |
| 49 | + */ | |
| 50 | + @Override | |
| 51 | + public ModbusResponse getResponse() { | |
| 52 | + ModbusResponse response = ModbusResponse.createModbusResponse(getFunctionCode()); | |
| 53 | + response.setHeadless(isHeadless()); | |
| 54 | + response.setTransactionID(getTransactionID()); | |
| 55 | + response.setProtocolID(getProtocolID()); | |
| 56 | + response.setUnitID(getUnitID()); | |
| 57 | + return response; | |
| 58 | + } | |
| 59 | + | |
| 60 | + @Override | |
| 61 | + public ModbusResponse createResponse(AbstractModbusListener listener) { | |
| 62 | + throw new UnsupportedOperationException("透传请求只用于主站发送"); | |
| 63 | + } | |
| 64 | + | |
| 65 | + @Override | |
| 66 | + public void writeData(DataOutput dout) throws IOException { | |
| 67 | + dout.write(data); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Override | |
| 71 | + public void readData(DataInput din) { | |
| 72 | + throw new UnsupportedOperationException("透传请求只用于主站发送"); | |
| 73 | + } | |
| 74 | + | |
| 75 | + @Override | |
| 76 | + public byte[] getMessage() { | |
| 77 | + return data; | |
| 78 | + } | |
| 79 | + | |
| 80 | + /** | |
| 81 | + * 响应是否属于这次发出的请求:事务号、unit、功能码(异常响应为 fc | 0x80)都一致 | |
| 82 | + * <p> | |
| 83 | + * j2mod 只在正常响应时核对事务号(异常响应在核对之前就抛出 ModbusSlaveException),unit 都不核对,所以这里逐项核对。 | |
| 84 | + * 发出的身份要在 setRequest 之后、execute 之前保存:execute 结束后 j2mod 会把请求的事务号自增,不能再用请求本身核对 | |
| 85 | + * | |
| 86 | + * @param response j2mod 响应(transaction.getResponse(),正常或异常响应) | |
| 87 | + * @param transactionId 发出的事务号 | |
| 88 | + * @param unitId 发出的 unit | |
| 89 | + * @param functionCode 发出的功能码 | |
| 90 | + * @return 是否属于这次请求 | |
| 91 | + */ | |
| 92 | + public static boolean isResponseOf(ModbusResponse response, int transactionId, int unitId, int functionCode) { | |
| 93 | + if (response == null) { | |
| 94 | + return false; | |
| 95 | + } | |
| 96 | + int expectedFunctionCode = response instanceof ExceptionResponse | |
| 97 | + ? functionCode | IotModbusCommonUtils.FC_EXCEPTION_MASK : functionCode; | |
| 98 | + return response.getTransactionID() == transactionId | |
| 99 | + && response.getUnitID() == unitId | |
| 100 | + && response.getFunctionCode() == expectedFunctionCode; | |
| 101 | + } | |
| 102 | + | |
| 103 | + /** | |
| 104 | + * 从 j2mod 解析出的响应还原响应 PDU,并核对功能码和响应格式 | |
| 105 | + * | |
| 106 | + * @param response j2mod 响应(正常或异常响应) | |
| 107 | + * @return 响应 PDU:异常 → [fc | 0x80, 异常码];正常 → 功能码 + 数据 | |
| 108 | + */ | |
| 109 | + public byte[] buildResponsePdu(ModbusResponse response) { | |
| 110 | + // 1.1 异常响应:功能码必须是 fc | 0x80(ExceptionResponse.getMessage 是 1 字节异常码) | |
| 111 | + if (response instanceof ExceptionResponse) { | |
| 112 | + if (response.getFunctionCode() != (getFunctionCode() | IotModbusCommonUtils.FC_EXCEPTION_MASK)) { | |
| 113 | + throw exception(MODBUS_RESPONSE_INVALID); | |
| 114 | + } | |
| 115 | + return IotModbusCommonUtils.buildExceptionPdu(getFunctionCode(), | |
| 116 | + ((ExceptionResponse) response).getExceptionCode()); | |
| 117 | + } | |
| 118 | + // 1.2 正常响应:功能码必须与请求一致 | |
| 119 | + if (response == null || response.getFunctionCode() != getFunctionCode()) { | |
| 120 | + throw exception(MODBUS_RESPONSE_INVALID); | |
| 121 | + } | |
| 122 | + // 2. 还原 PDU:功能码 + 标准响应类重新序列化的数据部分 | |
| 123 | + byte[] message = response.getMessage(); | |
| 124 | + byte[] responsePdu = new byte[1 + message.length]; | |
| 125 | + responsePdu[0] = (byte) getFunctionCode(); | |
| 126 | + System.arraycopy(message, 0, responsePdu, 1, message.length); | |
| 127 | + // 3. 核对响应格式(数量、回显) | |
| 128 | + if (!IotModbusCommonUtils.isResponseShapeValid(pdu, responsePdu)) { | |
| 129 | + throw exception(MODBUS_RESPONSE_INVALID); | |
| 130 | + } | |
| 131 | + return responsePdu; | |
| 132 | + } | |
| 133 | + | |
| 134 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamSubscriber.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.util.ArrayUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 3 | 5 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; |
| 4 | 6 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 7 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 5 | 9 | import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; |
| 10 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; | |
| 12 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusTcpClientUtils; | |
| 6 | 13 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.IotModbusTcpClientProtocol; |
| 14 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager; | |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager.ModbusConnection; | |
| 16 | +import io.vertx.core.Future; | |
| 17 | +import io.vertx.core.Promise; | |
| 7 | 18 | import lombok.extern.slf4j.Slf4j; |
| 8 | 19 | |
| 20 | +import java.util.concurrent.TimeUnit; | |
| 21 | + | |
| 22 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 23 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; | |
| 24 | + | |
| 9 | 25 | /** |
| 10 | 26 | * IoT Modbus TCP 下行消息订阅器:订阅消息总线的下行消息并转发给处理器 |
| 11 | 27 | * |
| ... | ... | @@ -14,13 +30,21 @@ import lombok.extern.slf4j.Slf4j; |
| 14 | 30 | @Slf4j |
| 15 | 31 | public class IotModbusTcpClientDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { |
| 16 | 32 | |
| 33 | + /** | |
| 34 | + * 每个连接透传请求的在途 + 排队上限 | |
| 35 | + */ | |
| 36 | + public static final int MAX_RAW_OUTSTANDING = 32; | |
| 37 | + | |
| 17 | 38 | private final IotModbusTcpClientDownstreamHandler downstreamHandler; |
| 39 | + private final IotModbusTcpClientConnectionManager connectionManager; | |
| 18 | 40 | |
| 19 | 41 | public IotModbusTcpClientDownstreamSubscriber(IotModbusTcpClientProtocol protocol, |
| 20 | 42 | IotModbusTcpClientDownstreamHandler downstreamHandler, |
| 43 | + IotModbusTcpClientConnectionManager connectionManager, | |
| 21 | 44 | IotMessageBus messageBus) { |
| 22 | 45 | super(protocol, messageBus); |
| 23 | 46 | this.downstreamHandler = downstreamHandler; |
| 47 | + this.connectionManager = connectionManager; | |
| 24 | 48 | } |
| 25 | 49 | |
| 26 | 50 | @Override |
| ... | ... | @@ -28,4 +52,81 @@ public class IotModbusTcpClientDownstreamSubscriber extends AbstractIotProtocolD |
| 28 | 52 | downstreamHandler.handle(message); |
| 29 | 53 | } |
| 30 | 54 | |
| 55 | + /** | |
| 56 | + * 透传写出:占用 raw 名额,提交到连接的有序 executeBlocking | |
| 57 | + * <p> | |
| 58 | + * 提交之前的失败(PDU 不合法、未连接、raw 名额已满、提交或注册完成回调被同步拒绝)返回失败 Future,只记日志; | |
| 59 | + * 提交之后,executeBlocking 结束(成功、异常、j2mod 超时)就是事务终态:返回的 Future 随即成功完成, | |
| 60 | + * 结果由事务经 {@link #handleRawResult} 处理。raw 名额在事务终态回调里释放,事务只进入一次终态,名额也只释放一次 | |
| 61 | + */ | |
| 62 | + @Override | |
| 63 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 64 | + // 1.1 校验 PDU:与 biz 的 /send、encode 输出校验是同一份规则 | |
| 65 | + try { | |
| 66 | + IotDeviceMessageUtils.validateModbusRequestPdu(bytes); | |
| 67 | + } catch (IllegalArgumentException e) { | |
| 68 | + return Future.failedFuture(exception(MODBUS_PDU_INVALID, e.getMessage())); | |
| 69 | + } | |
| 70 | + // 1.2 获取连接和 slave ID(一个连接可挂多个从站) | |
| 71 | + ModbusConnection connection = connectionManager.getConnection(message.getDeviceId()); | |
| 72 | + Integer slaveId = connection != null ? connection.getSlaveId(message.getDeviceId()) : null; | |
| 73 | + if (slaveId == null) { | |
| 74 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 75 | + } | |
| 76 | + // 1.3 占用 raw 名额:尚未进入事务,失败 Future 只记日志 | |
| 77 | + if (!connection.tryAcquireRawSlot(MAX_RAW_OUTSTANDING)) { | |
| 78 | + return Future.failedFuture(exception(MODBUS_QUEUE_FULL)); | |
| 79 | + } | |
| 80 | + | |
| 81 | + // 2.1 构造事务:截止时间取本连接现有的 timeout(也是 j2mod 的读超时),提交前算一次; | |
| 82 | + // 终态回调先释放名额:事务的终态只生效一次(CAS),名额也只释放一次 | |
| 83 | + Promise<Void> terminal = Promise.promise(); | |
| 84 | + long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(connection.getTimeout()); | |
| 85 | + IotModbusTx tx = IotModbusTx.ofClient(message, bytes, deadlineNanos, (responsePdu, failure) -> { | |
| 86 | + connection.releaseRawSlot(); | |
| 87 | + handleRawResult(terminal, message, source, responsePdu, failure); | |
| 88 | + }); | |
| 89 | + // 2.2 提交到有序 executeBlocking:结束即终态(这里不重连,重连由下一个任务开始时的守卫完成) | |
| 90 | + try { | |
| 91 | + IotModbusTcpClientUtils.executeRaw(connection, slaveId, tx).onComplete(ar -> { | |
| 92 | + if (ar.succeeded()) { | |
| 93 | + tx.complete(ar.result()); | |
| 94 | + } else { | |
| 95 | + log.warn("[handleRawMessage][设备 {} 透传失败]", message.getDeviceId(), ar.cause()); | |
| 96 | + tx.fail(IotModbusTcpClientUtils.toRawFailure(ar.cause())); | |
| 97 | + } | |
| 98 | + }); | |
| 99 | + } catch (RuntimeException e) { | |
| 100 | + // 2.3 提交或注册完成回调被同步拒绝(例如 Context 已关闭):先让返回的 Future 失败,再结束事务释放名额; | |
| 101 | + // 完成回调已经执行过(事务已是终态)时,两步都不生效,不会重复释放 | |
| 102 | + log.warn("[handleRawMessage][设备 {} 透传无法提交到连接的 Context]", message.getDeviceId(), e); | |
| 103 | + terminal.tryFail(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 104 | + tx.fail(DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 105 | + } | |
| 106 | + return terminal.future(); | |
| 107 | + } | |
| 108 | + | |
| 109 | + /** | |
| 110 | + * 透传事务终态:先完成 Future,再处理结果 | |
| 111 | + * <p> | |
| 112 | + * 设备的实际响应(包括异常响应)经 {@link #replyRawResult} 回复给 biz; | |
| 113 | + * 网关判定的失败(超时、断线、写失败、队列满、响应不合法)只记日志,不给 biz 构造回复 | |
| 114 | + */ | |
| 115 | + private void handleRawResult(Promise<Void> terminal, IotDeviceMessage message, IotScriptSource source, | |
| 116 | + byte[] responsePdu, ErrorCode failure) { | |
| 117 | + terminal.tryComplete(); | |
| 118 | + if (failure != null || ArrayUtil.isEmpty(responsePdu)) { | |
| 119 | + ErrorCode code = failure != null ? failure : MODBUS_RESPONSE_INVALID; | |
| 120 | + log.warn("[handleRawResult][下行({}) 事务失败 code({}) msg({})]", message.getRequestId(), | |
| 121 | + code.getCode(), code.getMsg()); | |
| 122 | + return; | |
| 123 | + } | |
| 124 | + replyRawResult(message, source, responsePdu, IotModbusCommonUtils.getRawResponseError(responsePdu)); | |
| 125 | + } | |
| 126 | + | |
| 127 | + @Override | |
| 128 | + public boolean supportsRawDownstream() { | |
| 129 | + return true; | |
| 130 | + } | |
| 131 | + | |
| 31 | 132 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/upstream/IotModbusTcpClientUpstreamHandler.java
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientConnectionManager.java
| ... | ... | @@ -3,12 +3,16 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager; |
| 3 | 3 | import cn.hutool.core.util.ObjUtil; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; |
| 5 | 5 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 6 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusTcpClientUtils; | |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 7 | 9 | import com.ghgande.j2mod.modbus.net.TCPMasterConnection; |
| 8 | 10 | import io.vertx.core.Context; |
| 9 | 11 | import io.vertx.core.Future; |
| 10 | 12 | import io.vertx.core.Vertx; |
| 13 | +import lombok.AccessLevel; | |
| 11 | 14 | import lombok.Data; |
| 15 | +import lombok.Setter; | |
| 12 | 16 | import lombok.extern.slf4j.Slf4j; |
| 13 | 17 | import org.redisson.api.RLock; |
| 14 | 18 | import org.redisson.api.RedissonClient; |
| ... | ... | @@ -18,6 +22,11 @@ import java.util.ArrayList; |
| 18 | 22 | import java.util.List; |
| 19 | 23 | import java.util.Map; |
| 20 | 24 | import java.util.concurrent.ConcurrentHashMap; |
| 25 | +import java.util.concurrent.atomic.AtomicInteger; | |
| 26 | +import java.util.function.Function; | |
| 27 | + | |
| 28 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 29 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_DISCONNECTED; | |
| 21 | 30 | |
| 22 | 31 | /** |
| 23 | 32 | * IoT Modbus TCP 连接管理器 |
| ... | ... | @@ -232,6 +241,8 @@ public class IotModbusTcpClientConnectionManager { |
| 232 | 241 | if (connection == null) { |
| 233 | 242 | return; |
| 234 | 243 | } |
| 244 | + // 先标记失效:已经排进有序队列、尚未执行的任务直接 115,不会在被关闭的连接上重新 connect() | |
| 245 | + connection.setRetired(true); | |
| 235 | 246 | |
| 236 | 247 | try { |
| 237 | 248 | if (connection.getTcpConnection() != null) { |
| ... | ... | @@ -288,6 +299,35 @@ public class IotModbusTcpClientConnectionManager { |
| 288 | 299 | * Vert.x Context,用于 executeBlocking 执行 Modbus 操作,保证同一连接的操作串行执行 |
| 289 | 300 | */ |
| 290 | 301 | private Context context; |
| 302 | + /** | |
| 303 | + * 本连接读取结果的上报队列({@link #context} 上的有序有界 worker 队列,随 {@link #setContext} 创建): | |
| 304 | + * 点位翻译、发送到消息总线会阻塞,交给 worker 按读取完成的顺序执行,不占用 context 的 event loop; | |
| 305 | + * 只在 context 线程上提交(executeBlocking 的结果回调就在它上面) | |
| 306 | + */ | |
| 307 | + @Setter(AccessLevel.NONE) | |
| 308 | + private IotWorkerTaskQueue upstreamQueue; | |
| 309 | + | |
| 310 | + /** | |
| 311 | + * 是否已失效:closeConnection 移出连接池时置为 true;之后开始执行的任务直接 115 | |
| 312 | + */ | |
| 313 | + private volatile boolean retired; | |
| 314 | + | |
| 315 | + /** | |
| 316 | + * 透传请求的在途 + 排队计数(只计 raw;轮询和标准读写不计入) | |
| 317 | + */ | |
| 318 | + private final AtomicInteger rawOutstanding = new AtomicInteger(); | |
| 319 | + | |
| 320 | + /** | |
| 321 | + * 设置执行 Modbus 操作的 Context,同时创建它上面的上报队列 | |
| 322 | + * | |
| 323 | + * @param context Vert.x Context | |
| 324 | + * @return this | |
| 325 | + */ | |
| 326 | + public ModbusConnection setContext(Context context) { | |
| 327 | + this.context = context; | |
| 328 | + this.upstreamQueue = new IotWorkerTaskQueue(context); | |
| 329 | + return this; | |
| 330 | + } | |
| 291 | 331 | |
| 292 | 332 | public Integer addDevice(Long deviceId, Integer slaveId) { |
| 293 | 333 | return deviceSlaveMap.putIfAbsent(deviceId, slaveId); |
| ... | ... | @@ -306,12 +346,75 @@ public class IotModbusTcpClientConnectionManager { |
| 306 | 346 | } |
| 307 | 347 | |
| 308 | 348 | /** |
| 309 | - * 执行 Modbus 读取操作(阻塞方式,在 Vert.x worker 线程执行) | |
| 349 | + * 执行标准 Modbus 操作(轮询读取、标准写入;阻塞方式,在 Vert.x worker 线程执行) | |
| 350 | + * <p> | |
| 351 | + * 与原逻辑一致:操作失败(包括读超时)只抛出,不关闭这个可能被多个从站共用的物理连接, | |
| 352 | + * 未连接时由 j2mod 的 ModbusTCPTransaction.execute 自己 connect。 | |
| 353 | + * 只多一个检查:已被 closeConnection 移出连接池(retired)时,排队中的任务不再执行,直接 115,不会重新连上被关闭的连接 | |
| 310 | 354 | */ |
| 311 | - public <T> Future<T> executeBlocking(java.util.function.Function<TCPMasterConnection, T> operation) { | |
| 355 | + public <T> Future<T> executeBlocking(Function<TCPMasterConnection, T> operation) { | |
| 312 | 356 | // ordered=true 保证同一 Context 的操作串行执行,不同连接之间可并行 |
| 313 | - return context.executeBlocking(() -> operation.apply(tcpConnection), true); | |
| 357 | + return context.executeBlocking(() -> { | |
| 358 | + if (retired) { | |
| 359 | + throw exception(MODBUS_DISCONNECTED); | |
| 360 | + } | |
| 361 | + return operation.apply(tcpConnection); | |
| 362 | + }, true); | |
| 314 | 363 | } |
| 364 | + | |
| 365 | + /** | |
| 366 | + * 执行透传操作(阻塞方式,在 Vert.x worker 线程执行;与标准操作排在同一个有序队列里) | |
| 367 | + * <p> | |
| 368 | + * 只有透传使用的传输失效隔离,在有序边界内完成(不在结果投递或 onComplete 之后重连): | |
| 369 | + * 1. 已被 closeConnection 移出连接池(retired):排队中的任务不再执行,直接 115; | |
| 370 | + * 2. 之前的透传因传输失败关闭了连接:本任务开始时用现有的 connect() 恢复; | |
| 371 | + * 3. 传输失败(读超时、IO 异常):在同一个有序任务里先关闭连接,再抛出异常,迟到的响应不会被下一个事务读到; | |
| 372 | + * 设备异常响应(ModbusSlaveException)说明链路正常,不关闭、不重连 | |
| 373 | + */ | |
| 374 | + public <T> Future<T> executeRawBlocking(Function<TCPMasterConnection, T> operation) { | |
| 375 | + return context.executeBlocking(() -> { | |
| 376 | + // 1. 连接已被 closeConnection 移出连接池:排队中的任务不再执行 | |
| 377 | + if (retired) { | |
| 378 | + throw exception(MODBUS_DISCONNECTED); | |
| 379 | + } | |
| 380 | + // 2. 之前的透传因传输失败关闭了连接:在本任务里用现有的 connect() 恢复 | |
| 381 | + // (j2mod 的 ModbusTCPTransaction.execute 也会在未连接时 connect;这里先连上,它看到已连接就不会再连一次) | |
| 382 | + if (!tcpConnection.isConnected()) { | |
| 383 | + tcpConnection.connect(); | |
| 384 | + } | |
| 385 | + // 3. 执行操作:传输失败时先让连接失效,之后的任务开始时才会重连 | |
| 386 | + try { | |
| 387 | + return operation.apply(tcpConnection); | |
| 388 | + } catch (RuntimeException e) { | |
| 389 | + if (IotModbusTcpClientUtils.isTransportFailure(e)) { | |
| 390 | + tcpConnection.close(); | |
| 391 | + } | |
| 392 | + throw e; | |
| 393 | + } | |
| 394 | + }, true); | |
| 395 | + } | |
| 396 | + | |
| 397 | + /** | |
| 398 | + * 占用一个透传名额 | |
| 399 | + * | |
| 400 | + * @param max 每个连接的上限 | |
| 401 | + * @return 是否成功;false 时调用方直接返回 113 失败,不进入 executeBlocking | |
| 402 | + */ | |
| 403 | + public boolean tryAcquireRawSlot(int max) { | |
| 404 | + if (rawOutstanding.incrementAndGet() <= max) { | |
| 405 | + return true; | |
| 406 | + } | |
| 407 | + rawOutstanding.decrementAndGet(); | |
| 408 | + return false; | |
| 409 | + } | |
| 410 | + | |
| 411 | + /** | |
| 412 | + * 释放一个透传名额(透传任务到达终态时调用) | |
| 413 | + */ | |
| 414 | + public void releaseRawSlot() { | |
| 415 | + rawOutstanding.decrementAndGet(); | |
| 416 | + } | |
| 417 | + | |
| 315 | 418 | } |
| 316 | 419 | |
| 317 | 420 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientPollScheduler.java
| ... | ... | @@ -14,6 +14,7 @@ import lombok.Data; |
| 14 | 14 | import lombok.extern.slf4j.Slf4j; |
| 15 | 15 | |
| 16 | 16 | import java.util.*; |
| 17 | +import java.util.concurrent.RejectedExecutionException; | |
| 17 | 18 | |
| 18 | 19 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; |
| 19 | 20 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; |
| ... | ... | @@ -76,10 +77,11 @@ public class IotModbusTcpClientPollScheduler extends AbstractIotModbusPollSchedu |
| 76 | 77 | Integer slaveId = connectionManager.getSlaveId(deviceId); |
| 77 | 78 | Assert.notNull(slaveId, "设备 {} 没有配置 slaveId", deviceId); |
| 78 | 79 | |
| 79 | - // 3. 执行 Modbus 批量读取 | |
| 80 | + // 3. 执行 Modbus 批量读取:读取结果的上报交给连接的 worker 队列 | |
| 80 | 81 | IotModbusTcpClientUtils.read(connection, slaveId, segment.getFunctionCode(), |
| 81 | 82 | segment.getStartAddress(), segment.getRegisterCount()) |
| 82 | - .onSuccess(rawValues -> handleSegmentReadResult(config, segment, rawValues)) | |
| 83 | + .onSuccess(rawValues -> submitReadResult(connection, deviceId, | |
| 84 | + () -> handleSegmentReadResult(config, segment, rawValues))) | |
| 83 | 85 | .onFailure(e -> log.error("[pollTask][读取点位段失败, deviceId={}, segment={}]", |
| 84 | 86 | deviceId, segment.getKey(), e)); |
| 85 | 87 | } |
| ... | ... | @@ -112,13 +114,38 @@ public class IotModbusTcpClientPollScheduler extends AbstractIotModbusPollSchedu |
| 112 | 114 | Integer slaveId = connectionManager.getSlaveId(deviceId); |
| 113 | 115 | Assert.notNull(slaveId, "设备 {} 没有配置 slaveId", deviceId); |
| 114 | 116 | |
| 115 | - // 3. 执行 Modbus 读取 | |
| 117 | + // 3. 执行 Modbus 读取:读取结果的上报交给连接的 worker 队列 | |
| 116 | 118 | IotModbusTcpClientUtils.read(connection, slaveId, point) |
| 117 | - .onSuccess(rawValue -> upstreamHandler.handleReadResult(config, point, rawValue)) | |
| 119 | + .onSuccess(rawValue -> submitReadResult(connection, deviceId, | |
| 120 | + () -> upstreamHandler.handleReadResult(config, point, rawValue))) | |
| 118 | 121 | .onFailure(e -> log.error("[pollPoint][读取点位失败, deviceId={}, identifier={}]", |
| 119 | 122 | deviceId, point.getIdentifier(), e)); |
| 120 | 123 | } |
| 121 | 124 | |
| 125 | + /** | |
| 126 | + * 提交读取结果的上报(在连接的 context 上调用:读取结果的回调就在它上面) | |
| 127 | + * <p> | |
| 128 | + * 点位翻译、发送到消息总线会阻塞,交给连接的有序 worker 队列,按读取完成的顺序执行,不占用 context 的 event loop; | |
| 129 | + * 读取本身、透传仍在连接原有的有序 executeBlocking 里,不受上报阻塞影响。队列已满时丢弃这次上报,下一轮轮询会再次读取 | |
| 130 | + * | |
| 131 | + * @param connection Modbus 连接 | |
| 132 | + * @param deviceId 设备 ID | |
| 133 | + * @param task 上报任务 | |
| 134 | + */ | |
| 135 | + private void submitReadResult(IotModbusTcpClientConnectionManager.ModbusConnection connection, Long deviceId, | |
| 136 | + Runnable task) { | |
| 137 | + connection.getUpstreamQueue().submit(() -> { | |
| 138 | + task.run(); | |
| 139 | + return null; | |
| 140 | + }).onFailure(e -> { | |
| 141 | + if (e instanceof RejectedExecutionException) { | |
| 142 | + log.warn("[submitReadResult][设备 {} 读取结果上报积压, 丢弃本次上报: {}]", deviceId, e.getMessage()); | |
| 143 | + return; | |
| 144 | + } | |
| 145 | + log.error("[submitReadResult][设备 {} 读取结果上报失败]", deviceId, e); | |
| 146 | + }); | |
| 147 | + } | |
| 148 | + | |
| 122 | 149 | private void handleSegmentReadResult(IotModbusDeviceConfigRespDTO config, |
| 123 | 150 | ReadSegment segment, |
| 124 | 151 | int[] rawValues) { | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerProtocol.java
| ... | ... | @@ -7,7 +7,6 @@ import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; |
| 7 | 7 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; |
| 8 | 8 | import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; |
| 9 | 9 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; |
| 10 | -import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 11 | 10 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; |
| 12 | 11 | import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolProperties; |
| 13 | 12 | import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; |
| ... | ... | @@ -21,6 +20,7 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.Iot |
| 21 | 20 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; |
| 22 | 21 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager; |
| 23 | 22 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPollScheduler; |
| 23 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; | |
| 24 | 24 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 25 | 25 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 26 | 26 | import io.vertx.core.Vertx; |
| ... | ... | @@ -67,6 +67,7 @@ public class IotModbusTcpServerProtocol implements IotProtocol { |
| 67 | 67 | /** |
| 68 | 68 | * Vert.x 实例 |
| 69 | 69 | */ |
| 70 | + @Getter | |
| 70 | 71 | private final Vertx vertx; |
| 71 | 72 | /** |
| 72 | 73 | * TCP Server |
| ... | ... | @@ -98,7 +99,6 @@ public class IotModbusTcpServerProtocol implements IotProtocol { |
| 98 | 99 | private final IotModbusTcpServerPendingRequestManager pendingRequestManager; |
| 99 | 100 | private final IotModbusTcpServerUpstreamHandler upstreamHandler; |
| 100 | 101 | private final IotModbusTcpServerPollScheduler pollScheduler; |
| 101 | - private final IotDeviceMessageService messageService; | |
| 102 | 102 | |
| 103 | 103 | public IotModbusTcpServerProtocol(ProtocolProperties properties) { |
| 104 | 104 | IotModbusTcpServerConfig slaveConfig = properties.getModbusTcpServer(); |
| ... | ... | @@ -119,20 +119,21 @@ public class IotModbusTcpServerProtocol implements IotProtocol { |
| 119 | 119 | this.frameDecoder = new IotModbusFrameDecoder(slaveConfig.getCustomFunctionCode()); |
| 120 | 120 | this.frameEncoder = new IotModbusFrameEncoder(slaveConfig.getCustomFunctionCode()); |
| 121 | 121 | |
| 122 | - // 初始化共享事务 ID 自增器(PollScheduler 和 DownstreamHandler 共用,避免 transactionId 冲突) | |
| 122 | + // 初始化共享事务 ID 自增器(所有连接的事务通道共用:轮询、标准写、透传都从通道分配,避免 transactionId 冲突) | |
| 123 | 123 | AtomicInteger transactionIdCounter = new AtomicInteger(0); |
| 124 | 124 | // 初始化轮询调度器 |
| 125 | 125 | this.pollScheduler = new IotModbusTcpServerPollScheduler( |
| 126 | 126 | vertx, connectionManager, frameEncoder, pendingRequestManager, |
| 127 | - slaveConfig.getRequestTimeout(), transactionIdCounter, configCacheService); | |
| 127 | + slaveConfig.getRequestTimeout(), configCacheService); | |
| 128 | 128 | |
| 129 | 129 | // 初始化 Handler |
| 130 | - this.messageService = SpringUtil.getBean(IotDeviceMessageService.class); | |
| 130 | + IotDeviceMessageService messageService = SpringUtil.getBean(IotDeviceMessageService.class); | |
| 131 | 131 | IotDeviceService deviceService = SpringUtil.getBean(IotDeviceService.class); |
| 132 | 132 | this.upstreamHandler = new IotModbusTcpServerUpstreamHandler( |
| 133 | - deviceApi, this.messageService, frameEncoder, | |
| 133 | + deviceApi, messageService, frameEncoder, | |
| 134 | 134 | connectionManager, configCacheService, pendingRequestManager, |
| 135 | - pollScheduler, deviceService, serverId); | |
| 135 | + pollScheduler, deviceService, serverId, | |
| 136 | + transactionIdCounter, slaveConfig.getRequestTimeout()); | |
| 136 | 137 | } |
| 137 | 138 | |
| 138 | 139 | @Override |
| ... | ... | @@ -165,7 +166,7 @@ public class IotModbusTcpServerProtocol implements IotProtocol { |
| 165 | 166 | // 2.2 启动 PendingRequest 清理定时器 |
| 166 | 167 | requestCleanupTimerId = vertx.setPeriodic( |
| 167 | 168 | slaveConfig.getRequestCleanupInterval(), |
| 168 | - id -> pendingRequestManager.cleanupExpired()); | |
| 169 | + id -> cleanupExpiredRequests()); | |
| 169 | 170 | running = true; |
| 170 | 171 | log.info("[start][IoT Modbus TCP Server 协议 {} 启动成功, serverId={}, port={}]", |
| 171 | 172 | getId(), serverId, properties.getPort()); |
| ... | ... | @@ -173,9 +174,9 @@ public class IotModbusTcpServerProtocol implements IotProtocol { |
| 173 | 174 | // 3. 启动下行消息订阅 |
| 174 | 175 | IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); |
| 175 | 176 | IotModbusTcpServerDownstreamHandler downstreamHandler = new IotModbusTcpServerDownstreamHandler( |
| 176 | - connectionManager, configCacheService, frameEncoder, this.pollScheduler.getTransactionIdCounter()); | |
| 177 | + connectionManager, configCacheService, frameEncoder); | |
| 177 | 178 | this.downstreamSubscriber = new IotModbusTcpServerDownstreamSubscriber( |
| 178 | - this, downstreamHandler, messageBus); | |
| 179 | + this, downstreamHandler, connectionManager, messageBus); | |
| 179 | 180 | downstreamSubscriber.start(); |
| 180 | 181 | } catch (Exception e) { |
| 181 | 182 | log.error("[start][IoT Modbus TCP Server 协议 {} 启动失败]", getId(), e); |
| ... | ... | @@ -281,15 +282,11 @@ public class IotModbusTcpServerProtocol implements IotProtocol { |
| 281 | 282 | return; |
| 282 | 283 | } |
| 283 | 284 | pollScheduler.stopPolling(info.getDeviceId()); |
| 284 | - pendingRequestManager.removeDevice(info.getDeviceId()); | |
| 285 | + // 设备自己断开:排队中、在途的透传全部以 115 收尾,并只清理这条连接登记的待响应请求(不影响同一设备的新连接) | |
| 286 | + info.getChannel().onClose(); | |
| 285 | 287 | configCacheService.removeConfig(info.getDeviceId()); |
| 286 | - // 发送设备下线消息 | |
| 287 | - try { | |
| 288 | - IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); | |
| 289 | - messageService.sendDeviceMessage(offlineMessage, info.getProductKey(), info.getDeviceName(), serverId); | |
| 290 | - } catch (Exception ex) { | |
| 291 | - log.error("[handleConnection][发送设备下线消息失败, deviceId={}]", info.getDeviceId(), ex); | |
| 292 | - } | |
| 288 | + // 发送设备下线消息:交给本连接的 worker 队列,排在已收到的轮询上报之后 | |
| 289 | + upstreamHandler.sendOfflineMessage(info); | |
| 293 | 290 | log.info("[handleConnection][连接关闭, deviceId={}, remoteAddress={}]", |
| 294 | 291 | info.getDeviceId(), socket.remoteAddress()); |
| 295 | 292 | }); |
| ... | ... | @@ -301,6 +298,16 @@ public class IotModbusTcpServerProtocol implements IotProtocol { |
| 301 | 298 | } |
| 302 | 299 | |
| 303 | 300 | /** |
| 301 | + * 清理过期的待响应请求(定时调用) | |
| 302 | + */ | |
| 303 | + private void cleanupExpiredRequests() { | |
| 304 | + // 1. 待响应请求:保持原逻辑,到期移除、只记日志 | |
| 305 | + pendingRequestManager.cleanupExpired(); | |
| 306 | + // 2. 同一个清理周期里,各连接检查透传在途是否超时、排队是否超过截止时间(切回各自的 owner 收尾,不新增定时器) | |
| 307 | + connectionManager.getChannels().forEach(IotModbusTcpServerTransactionChannel::onCleanup); | |
| 308 | + } | |
| 309 | + | |
| 310 | + /** | |
| 304 | 311 | * 刷新已连接设备的配置(定时调用) |
| 305 | 312 | */ |
| 306 | 313 | private synchronized void refreshConfig() { | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/codec/IotModbusFrameEncoder.java
| ... | ... | @@ -155,7 +155,7 @@ public class IotModbusFrameEncoder { |
| 155 | 155 | // ==================== 帧封装 ==================== |
| 156 | 156 | |
| 157 | 157 | /** |
| 158 | - * 将 PDU 封装为完整帧 | |
| 158 | + * 将 PDU 封装为完整帧(透传请求直接封装已校验的 PDU) | |
| 159 | 159 | * |
| 160 | 160 | * @param slaveId 从站地址 |
| 161 | 161 | * @param pdu PDU 数据(含 functionCode) |
| ... | ... | @@ -163,7 +163,7 @@ public class IotModbusFrameEncoder { |
| 163 | 163 | * @param transactionId 事务 ID(TCP 模式下使用,RTU 模式可为 null) |
| 164 | 164 | * @return 完整帧字节数组 |
| 165 | 165 | */ |
| 166 | - private byte[] wrapFrame(int slaveId, byte[] pdu, IotModbusFrameFormatEnum format, Integer transactionId) { | |
| 166 | + public byte[] wrapFrame(int slaveId, byte[] pdu, IotModbusFrameFormatEnum format, Integer transactionId) { | |
| 167 | 167 | if (format == IotModbusFrameFormatEnum.MODBUS_TCP) { |
| 168 | 168 | return wrapTcpFrame(slaveId, pdu, transactionId != null ? transactionId : 0); |
| 169 | 169 | } else { | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamHandler.java
| ... | ... | @@ -12,10 +12,10 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotMo |
| 12 | 12 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConfigCacheService; |
| 13 | 13 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager; |
| 14 | 14 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; | |
| 15 | 16 | import lombok.extern.slf4j.Slf4j; |
| 16 | 17 | |
| 17 | 18 | import java.util.Map; |
| 18 | -import java.util.concurrent.atomic.AtomicInteger; | |
| 19 | 19 | |
| 20 | 20 | /** |
| 21 | 21 | * IoT Modbus TCP Server 下行消息处理器 |
| ... | ... | @@ -33,19 +33,12 @@ public class IotModbusTcpServerDownstreamHandler { |
| 33 | 33 | private final IotModbusTcpServerConfigCacheService configCacheService; |
| 34 | 34 | private final IotModbusFrameEncoder frameEncoder; |
| 35 | 35 | |
| 36 | - /** | |
| 37 | - * TCP 事务 ID 自增器(与 PollScheduler 共享) | |
| 38 | - */ | |
| 39 | - private final AtomicInteger transactionIdCounter; | |
| 40 | - | |
| 41 | 36 | public IotModbusTcpServerDownstreamHandler(IotModbusTcpServerConnectionManager connectionManager, |
| 42 | 37 | IotModbusTcpServerConfigCacheService configCacheService, |
| 43 | - IotModbusFrameEncoder frameEncoder, | |
| 44 | - AtomicInteger transactionIdCounter) { | |
| 38 | + IotModbusFrameEncoder frameEncoder) { | |
| 45 | 39 | this.connectionManager = connectionManager; |
| 46 | 40 | this.configCacheService = configCacheService; |
| 47 | 41 | this.frameEncoder = frameEncoder; |
| 48 | - this.transactionIdCounter = transactionIdCounter; | |
| 49 | 42 | } |
| 50 | 43 | |
| 51 | 44 | /** |
| ... | ... | @@ -109,12 +102,11 @@ public class IotModbusTcpServerDownstreamHandler { |
| 109 | 102 | // 1.1 转换属性值为原始值 |
| 110 | 103 | int[] rawValues = IotModbusCommonUtils.convertToRawValues(value, point); |
| 111 | 104 | |
| 112 | - // 1.2 确定帧格式和事务 ID | |
| 105 | + // 1.2 确定帧格式和事务 ID(事务号由连接的事务通道分配:跳过本连接仍占用的编号) | |
| 113 | 106 | IotModbusFrameFormatEnum frameFormat = connInfo.getFrameFormat(); |
| 114 | 107 | Assert.notNull(frameFormat, "连接帧格式不能为空"); |
| 115 | - Integer transactionId = frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP | |
| 116 | - ? (transactionIdCounter.incrementAndGet() & 0xFFFF) | |
| 117 | - : null; | |
| 108 | + IotModbusTcpServerTransactionChannel channel = connInfo.getChannel(); | |
| 109 | + Integer transactionId = channel.allocateTransactionId(); | |
| 118 | 110 | int slaveId = connInfo.getSlaveId() != null ? connInfo.getSlaveId() : 1; |
| 119 | 111 | // 1.3 编码写请求 |
| 120 | 112 | byte[] data; |
| ... | ... | @@ -139,8 +131,8 @@ public class IotModbusTcpServerDownstreamHandler { |
| 139 | 131 | return; |
| 140 | 132 | } |
| 141 | 133 | |
| 142 | - // 2. 发送消息 | |
| 143 | - connectionManager.sendToDevice(deviceId, data).onSuccess(v -> | |
| 134 | + // 2. 发送消息(用 connInfo 对应的连接写出;socket 写出成功即记成功,不等待设备回显) | |
| 135 | + channel.send(data).onSuccess(v -> | |
| 144 | 136 | log.info("[writeProperty][写入成功, deviceId={}, identifier={}, value={}]", |
| 145 | 137 | deviceId, point.getIdentifier(), value) |
| 146 | 138 | ).onFailure(e -> | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamSubscriber.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.downstream; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.util.ArrayUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 3 | 5 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; |
| 4 | 6 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 7 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 5 | 9 | import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; |
| 10 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; | |
| 6 | 12 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.IotModbusTcpServerProtocol; |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager; | |
| 14 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; | |
| 15 | +import io.vertx.core.Future; | |
| 16 | +import io.vertx.core.Promise; | |
| 7 | 17 | import lombok.extern.slf4j.Slf4j; |
| 8 | 18 | |
| 19 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 20 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 21 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_PDU_INVALID; | |
| 22 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_RESPONSE_INVALID; | |
| 23 | + | |
| 9 | 24 | /** |
| 10 | 25 | * IoT Modbus TCP Server 下行消息订阅器:订阅消息总线的下行消息并转发给处理器 |
| 11 | 26 | * |
| ... | ... | @@ -15,12 +30,15 @@ import lombok.extern.slf4j.Slf4j; |
| 15 | 30 | public class IotModbusTcpServerDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { |
| 16 | 31 | |
| 17 | 32 | private final IotModbusTcpServerDownstreamHandler downstreamHandler; |
| 33 | + private final IotModbusTcpServerConnectionManager connectionManager; | |
| 18 | 34 | |
| 19 | 35 | public IotModbusTcpServerDownstreamSubscriber(IotModbusTcpServerProtocol protocol, |
| 20 | 36 | IotModbusTcpServerDownstreamHandler downstreamHandler, |
| 37 | + IotModbusTcpServerConnectionManager connectionManager, | |
| 21 | 38 | IotMessageBus messageBus) { |
| 22 | 39 | super(protocol, messageBus); |
| 23 | 40 | this.downstreamHandler = downstreamHandler; |
| 41 | + this.connectionManager = connectionManager; | |
| 24 | 42 | } |
| 25 | 43 | |
| 26 | 44 | @Override |
| ... | ... | @@ -28,4 +46,56 @@ public class IotModbusTcpServerDownstreamSubscriber extends AbstractIotProtocolD |
| 28 | 46 | downstreamHandler.handle(message); |
| 29 | 47 | } |
| 30 | 48 | |
| 49 | + /** | |
| 50 | + * 透传写出:提交透传事务到连接的事务通道 | |
| 51 | + * <p> | |
| 52 | + * 提交之前的失败(PDU 不合法、未连接或未认证)返回失败 Future,只记日志; | |
| 53 | + * 提交之后,返回的 Future 在事务终态(响应 / 超时 / 断线 / 写失败 / 队列满)成功完成, | |
| 54 | + * 结果由事务经 {@link #handleRawResult} 处理 | |
| 55 | + */ | |
| 56 | + @Override | |
| 57 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 58 | + // 1.1 校验 PDU:与 biz 的 /send、encode 输出校验是同一份规则 | |
| 59 | + try { | |
| 60 | + IotDeviceMessageUtils.validateModbusRequestPdu(bytes); | |
| 61 | + } catch (IllegalArgumentException e) { | |
| 62 | + return Future.failedFuture(exception(MODBUS_PDU_INVALID, e.getMessage())); | |
| 63 | + } | |
| 64 | + // 1.2 获取连接(未连接、未认证的连接没有事务通道) | |
| 65 | + ConnectionInfo info = connectionManager.getConnectionInfoByDeviceId(message.getDeviceId()); | |
| 66 | + if (info == null || info.getChannel() == null) { | |
| 67 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 68 | + } | |
| 69 | + | |
| 70 | + // 2. 提交事务:终态时先完成 Future,再处理结果(设备响应含异常响应 → responsePdu;网关判定 → failure) | |
| 71 | + Promise<Void> terminal = Promise.promise(); | |
| 72 | + IotModbusTx tx = IotModbusTx.ofServer(info.getChannel(), bytes, message, | |
| 73 | + (responsePdu, failure) -> handleRawResult(terminal, message, source, responsePdu, failure)); | |
| 74 | + info.getChannel().submitRaw(tx); | |
| 75 | + return terminal.future(); | |
| 76 | + } | |
| 77 | + | |
| 78 | + /** | |
| 79 | + * 透传事务终态:先完成 Future,再处理结果 | |
| 80 | + * <p> | |
| 81 | + * 设备的实际响应(包括异常响应)经 {@link #replyRawResult} 回复给 biz; | |
| 82 | + * 网关判定的失败(超时、断线、写失败、队列满、响应不合法)只记日志,不给 biz 构造回复 | |
| 83 | + */ | |
| 84 | + private void handleRawResult(Promise<Void> terminal, IotDeviceMessage message, IotScriptSource source, | |
| 85 | + byte[] responsePdu, ErrorCode failure) { | |
| 86 | + terminal.tryComplete(); | |
| 87 | + if (failure != null || ArrayUtil.isEmpty(responsePdu)) { | |
| 88 | + ErrorCode code = failure != null ? failure : MODBUS_RESPONSE_INVALID; | |
| 89 | + log.warn("[handleRawResult][下行({}) 事务失败 code({}) msg({})]", message.getRequestId(), | |
| 90 | + code.getCode(), code.getMsg()); | |
| 91 | + return; | |
| 92 | + } | |
| 93 | + replyRawResult(message, source, responsePdu, IotModbusCommonUtils.getRawResponseError(responsePdu)); | |
| 94 | + } | |
| 95 | + | |
| 96 | + @Override | |
| 97 | + public boolean supportsRawDownstream() { | |
| 98 | + return true; | |
| 99 | + } | |
| 100 | + | |
| 31 | 101 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/upstream/IotModbusTcpServerUpstreamHandler.java
| ... | ... | @@ -28,12 +28,18 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.Iot |
| 28 | 28 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager; |
| 29 | 29 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; |
| 30 | 30 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPollScheduler; |
| 31 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; | |
| 31 | 32 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 32 | 33 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 34 | +import io.vertx.core.Context; | |
| 35 | +import io.vertx.core.Future; | |
| 36 | +import io.vertx.core.Vertx; | |
| 33 | 37 | import io.vertx.core.net.NetSocket; |
| 34 | 38 | import lombok.extern.slf4j.Slf4j; |
| 35 | 39 | |
| 36 | 40 | import java.util.Map; |
| 41 | +import java.util.concurrent.RejectedExecutionException; | |
| 42 | +import java.util.concurrent.atomic.AtomicInteger; | |
| 37 | 43 | |
| 38 | 44 | import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; |
| 39 | 45 | import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; |
| ... | ... | @@ -64,6 +70,15 @@ public class IotModbusTcpServerUpstreamHandler { |
| 64 | 70 | |
| 65 | 71 | private final String serverId; |
| 66 | 72 | |
| 73 | + /** | |
| 74 | + * TCP 事务 ID 自增器(所有连接的事务通道共享) | |
| 75 | + */ | |
| 76 | + private final AtomicInteger transactionIdCounter; | |
| 77 | + /** | |
| 78 | + * 请求超时时间(毫秒) | |
| 79 | + */ | |
| 80 | + private final int requestTimeout; | |
| 81 | + | |
| 67 | 82 | public IotModbusTcpServerUpstreamHandler(IotDeviceCommonApi deviceApi, |
| 68 | 83 | IotDeviceMessageService messageService, |
| 69 | 84 | IotModbusFrameEncoder frameEncoder, |
| ... | ... | @@ -72,7 +87,9 @@ public class IotModbusTcpServerUpstreamHandler { |
| 72 | 87 | IotModbusTcpServerPendingRequestManager pendingRequestManager, |
| 73 | 88 | IotModbusTcpServerPollScheduler pollScheduler, |
| 74 | 89 | IotDeviceService deviceService, |
| 75 | - String serverId) { | |
| 90 | + String serverId, | |
| 91 | + AtomicInteger transactionIdCounter, | |
| 92 | + int requestTimeout) { | |
| 76 | 93 | this.deviceApi = deviceApi; |
| 77 | 94 | this.messageService = messageService; |
| 78 | 95 | this.frameEncoder = frameEncoder; |
| ... | ... | @@ -82,21 +99,25 @@ public class IotModbusTcpServerUpstreamHandler { |
| 82 | 99 | this.pollScheduler = pollScheduler; |
| 83 | 100 | this.deviceService = deviceService; |
| 84 | 101 | this.serverId = serverId; |
| 102 | + this.transactionIdCounter = transactionIdCounter; | |
| 103 | + this.requestTimeout = requestTimeout; | |
| 85 | 104 | } |
| 86 | 105 | |
| 87 | 106 | // ========== 帧处理入口 ========== |
| 88 | 107 | |
| 89 | 108 | /** |
| 90 | - * 处理帧 | |
| 109 | + * 处理帧(连接的 owner 上调用:RecordParser 回调) | |
| 91 | 110 | */ |
| 92 | 111 | public void handleFrame(NetSocket socket, IotModbusFrame frame, IotModbusFrameFormatEnum frameFormat) { |
| 93 | 112 | if (frame == null) { |
| 94 | 113 | return; |
| 95 | 114 | } |
| 96 | - // 1. 异常响应 | |
| 115 | + // 1. 异常响应:只尝试匹配透传请求,让它以设备的异常响应收尾(例如 83 02); | |
| 116 | + // 标准轮询的异常响应照旧丢弃,它的待响应请求等到期清理 | |
| 97 | 117 | if (frame.isException()) { |
| 98 | 118 | log.warn("[handleFrame][设备异常响应, slaveId={}, FC={}, exceptionCode={}]", |
| 99 | 119 | frame.getSlaveId(), frame.getFunctionCode(), frame.getExceptionCode()); |
| 120 | + handlePollingResponse(socket, frame, frameFormat); | |
| 100 | 121 | return; |
| 101 | 122 | } |
| 102 | 123 | |
| ... | ... | @@ -192,13 +213,19 @@ public class IotModbusTcpServerUpstreamHandler { |
| 192 | 213 | return; |
| 193 | 214 | } |
| 194 | 215 | |
| 195 | - // 3.1 注册连接 | |
| 216 | + // 3.1 注册连接:事务通道在本连接的 owner(当前 event loop Context)上创建 | |
| 217 | + Context owner = Vertx.currentContext(); | |
| 218 | + Assert.notNull(owner, "事务通道必须在连接的 event loop 上创建"); | |
| 219 | + IotModbusTcpServerTransactionChannel channel = new IotModbusTcpServerTransactionChannel(owner, socket, | |
| 220 | + device.getId(), frame.getSlaveId(), frameFormat, frameEncoder, pendingRequestManager, | |
| 221 | + transactionIdCounter, requestTimeout); | |
| 196 | 222 | ConnectionInfo connectionInfo = new ConnectionInfo() |
| 197 | 223 | .setDeviceId(device.getId()) |
| 198 | 224 | .setProductKey(deviceInfo.getProductKey()) |
| 199 | 225 | .setDeviceName(deviceInfo.getDeviceName()) |
| 200 | 226 | .setSlaveId(frame.getSlaveId()) |
| 201 | - .setFrameFormat(frameFormat); | |
| 227 | + .setFrameFormat(frameFormat) | |
| 228 | + .setChannel(channel); | |
| 202 | 229 | connectionManager.registerConnection(socket, connectionInfo); |
| 203 | 230 | // 3.2 发送上线消息 |
| 204 | 231 | IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); |
| ... | ... | @@ -232,6 +259,9 @@ public class IotModbusTcpServerUpstreamHandler { |
| 232 | 259 | |
| 233 | 260 | /** |
| 234 | 261 | * 处理轮询响应(云端轮询模式) |
| 262 | + * <p> | |
| 263 | + * 匹配待响应请求、透传收尾留在连接的 owner 上(与发送、清理同一个线程); | |
| 264 | + * 标准轮询的点位翻译、发送到消息总线会阻塞,交给本连接(事务通道)的 worker 队列按收到的顺序执行 | |
| 235 | 265 | */ |
| 236 | 266 | private void handlePollingResponse(NetSocket socket, IotModbusFrame frame, |
| 237 | 267 | IotModbusFrameFormatEnum frameFormat) { |
| ... | ... | @@ -242,39 +272,94 @@ public class IotModbusTcpServerUpstreamHandler { |
| 242 | 272 | return; |
| 243 | 273 | } |
| 244 | 274 | |
| 245 | - // 2.1 匹配 PendingRequest | |
| 275 | + // 2.1 匹配 PendingRequest:只匹配这条连接自己发出的请求 | |
| 246 | 276 | PendingRequest request = pendingRequestManager.matchResponse( |
| 247 | - info.getDeviceId(), frame, frameFormat); | |
| 277 | + info.getDeviceId(), frame, frameFormat, info.getChannel()); | |
| 248 | 278 | if (request == null) { |
| 249 | 279 | log.debug("[handlePollingResponse][未匹配到 PendingRequest, deviceId={}, FC={}]", |
| 250 | 280 | info.getDeviceId(), frame.getFunctionCode()); |
| 251 | 281 | return; |
| 252 | 282 | } |
| 253 | - // 2.2 提取寄存器值 | |
| 283 | + // 2.2 透传请求:交给事务通道进入唯一终态,不做点位处理 | |
| 284 | + if (request.getTx() != null) { | |
| 285 | + info.getChannel().onRawResponse(frame, request); | |
| 286 | + return; | |
| 287 | + } | |
| 288 | + | |
| 289 | + // 3. 标准轮询:交给本连接的 worker 队列;队列已满时丢弃这次上报(下一轮轮询会再次读取),不断开连接 | |
| 290 | + info.getChannel().getUpstreamQueue().submit(() -> { | |
| 291 | + handlePollingResult(info, frame, request); | |
| 292 | + return null; | |
| 293 | + }).onFailure(e -> { | |
| 294 | + if (e instanceof RejectedExecutionException) { | |
| 295 | + log.warn("[handlePollingResponse][轮询上报积压, 丢弃本次上报, deviceId={}, identifier={}, 错误: {}]", | |
| 296 | + info.getDeviceId(), request.getIdentifier(), e.getMessage()); | |
| 297 | + return; | |
| 298 | + } | |
| 299 | + log.error("[handlePollingResponse][轮询上报失败, deviceId={}, identifier={}]", | |
| 300 | + info.getDeviceId(), request.getIdentifier(), e); | |
| 301 | + }); | |
| 302 | + } | |
| 303 | + | |
| 304 | + /** | |
| 305 | + * 处理标准轮询的响应(worker 上执行):点位翻译 → thing.property.post 发送到消息总线 | |
| 306 | + * | |
| 307 | + * @param info 连接信息 | |
| 308 | + * @param frame 响应帧 | |
| 309 | + * @param request 匹配到的待响应请求 | |
| 310 | + */ | |
| 311 | + private void handlePollingResult(ConnectionInfo info, IotModbusFrame frame, PendingRequest request) { | |
| 312 | + // 1.1 提取寄存器值 | |
| 254 | 313 | int[] rawValues = IotModbusCommonUtils.extractValues(frame); |
| 255 | 314 | if (rawValues == null) { |
| 256 | - log.warn("[handlePollingResponse][提取寄存器值失败, deviceId={}, identifier={}]", | |
| 315 | + log.warn("[handlePollingResult][提取寄存器值失败, deviceId={}, identifier={}]", | |
| 257 | 316 | info.getDeviceId(), request.getIdentifier()); |
| 258 | 317 | return; |
| 259 | 318 | } |
| 260 | - // 2.3 查找点位配置 | |
| 319 | + // 1.2 查找点位配置 | |
| 261 | 320 | IotModbusDeviceConfigRespDTO config = configCacheService.getConfig(info.getDeviceId()); |
| 262 | 321 | IotModbusPointRespDTO point = IotModbusCommonUtils.findPointById(config, request.getPointId()); |
| 263 | 322 | if (point == null) { |
| 264 | 323 | return; |
| 265 | 324 | } |
| 266 | 325 | |
| 267 | - // 3.1 转换原始值为物模型属性值(点位翻译) | |
| 326 | + // 2.1 转换原始值为物模型属性值(点位翻译) | |
| 268 | 327 | Object convertedValue = IotModbusCommonUtils.convertToPropertyValue(rawValues, point); |
| 269 | - // 3.2 构造属性上报消息 | |
| 328 | + // 2.2 构造属性上报消息 | |
| 270 | 329 | Map<String, Object> params = MapUtil.of(request.getIdentifier(), convertedValue); |
| 271 | 330 | IotDeviceMessage message = IotDeviceMessage.requestOf( |
| 272 | 331 | IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), params); |
| 273 | 332 | |
| 274 | - // 4. 发送到消息总线 | |
| 333 | + // 3. 发送到消息总线 | |
| 275 | 334 | messageService.sendDeviceMessage(message, info.getProductKey(), info.getDeviceName(), serverId); |
| 276 | - log.debug("[handlePollingResponse][设备={}, 属性={}, 原始值={}, 转换值={}]", | |
| 335 | + log.debug("[handlePollingResult][设备={}, 属性={}, 原始值={}, 转换值={}]", | |
| 277 | 336 | info.getDeviceId(), request.getIdentifier(), rawValues, convertedValue); |
| 278 | 337 | } |
| 279 | 338 | |
| 339 | + // ========== 连接关闭 ========== | |
| 340 | + | |
| 341 | + /** | |
| 342 | + * 发送设备下线消息(连接的 owner 上调用:closeHandler) | |
| 343 | + * <p> | |
| 344 | + * 发送会阻塞,作为本连接的最后一个任务交给 worker 队列,排在已收到的轮询上报之后; | |
| 345 | + * 执行时设备已在新连接上线就不再发送,发送期间设备在新连接上线则补发上线消息(与 TCP 协议一致) | |
| 346 | + * | |
| 347 | + * @param info 已移除的连接信息 | |
| 348 | + * @return 发送结果(在 owner 上完成);失败只记日志 | |
| 349 | + */ | |
| 350 | + public Future<Void> sendOfflineMessage(ConnectionInfo info) { | |
| 351 | + return info.getChannel().getUpstreamQueue().<Void>submitLast(() -> { | |
| 352 | + if (connectionManager.getConnectionInfoByDeviceId(info.getDeviceId()) != null) { | |
| 353 | + return null; | |
| 354 | + } | |
| 355 | + messageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(), | |
| 356 | + info.getProductKey(), info.getDeviceName(), serverId); | |
| 357 | + if (connectionManager.getConnectionInfoByDeviceId(info.getDeviceId()) != null) { | |
| 358 | + messageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline(), | |
| 359 | + info.getProductKey(), info.getDeviceName(), serverId); | |
| 360 | + } | |
| 361 | + return null; | |
| 362 | + }).onFailure(e -> log.error("[sendOfflineMessage][发送设备下线消息失败, deviceId={}]", info.getDeviceId(), e)); | |
| 363 | + } | |
| 364 | + | |
| 280 | 365 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerConnectionManager.java
| ... | ... | @@ -64,6 +64,11 @@ public class IotModbusTcpServerConnectionManager { |
| 64 | 64 | */ |
| 65 | 65 | private IotModbusFrameFormatEnum frameFormat; |
| 66 | 66 | |
| 67 | + /** | |
| 68 | + * 本连接的事务通道:认证注册时在连接的 owner 上创建;未认证连接没有 | |
| 69 | + */ | |
| 70 | + private IotModbusTcpServerTransactionChannel channel; | |
| 71 | + | |
| 67 | 72 | } |
| 68 | 73 | |
| 69 | 74 | /** |
| ... | ... | @@ -75,7 +80,8 @@ public class IotModbusTcpServerConnectionManager { |
| 75 | 80 | if (oldSocket != null && oldSocket != socket) { |
| 76 | 81 | log.info("[registerConnection][设备 {} 存在旧连接, 关闭旧 socket, oldRemote={}, newRemote={}]", |
| 77 | 82 | info.getDeviceId(), oldSocket.remoteAddress(), socket.remoteAddress()); |
| 78 | - connectionMap.remove(oldSocket); | |
| 83 | + // 旧 socket 的 closeHandler 之后拿不到 info(提前返回),所以在这里收尾旧通道 | |
| 84 | + notifyChannelClosed(connectionMap.remove(oldSocket)); | |
| 79 | 85 | try { |
| 80 | 86 | oldSocket.close(); |
| 81 | 87 | } catch (Exception e) { |
| ... | ... | @@ -84,8 +90,11 @@ public class IotModbusTcpServerConnectionManager { |
| 84 | 90 | } |
| 85 | 91 | } |
| 86 | 92 | |
| 87 | - // 注册新连接 | |
| 88 | - connectionMap.put(socket, info); | |
| 93 | + // 注册新连接(同一个 socket 重复认证时,被替换的旧 info 的通道也要收尾) | |
| 94 | + ConnectionInfo previous = connectionMap.put(socket, info); | |
| 95 | + if (previous != null && previous.getChannel() != info.getChannel()) { | |
| 96 | + notifyChannelClosed(previous); | |
| 97 | + } | |
| 89 | 98 | deviceSocketMap.put(info.getDeviceId(), socket); |
| 90 | 99 | log.info("[registerConnection][设备 {} 连接已注册, remoteAddress={}]", |
| 91 | 100 | info.getDeviceId(), socket.remoteAddress()); |
| ... | ... | @@ -114,6 +123,19 @@ public class IotModbusTcpServerConnectionManager { |
| 114 | 123 | } |
| 115 | 124 | |
| 116 | 125 | /** |
| 126 | + * 获取所有已认证连接的事务通道 | |
| 127 | + */ | |
| 128 | + public List<IotModbusTcpServerTransactionChannel> getChannels() { | |
| 129 | + List<IotModbusTcpServerTransactionChannel> channels = new ArrayList<>(); | |
| 130 | + for (ConnectionInfo info : connectionMap.values()) { | |
| 131 | + if (info.getChannel() != null) { | |
| 132 | + channels.add(info.getChannel()); | |
| 133 | + } | |
| 134 | + } | |
| 135 | + return channels; | |
| 136 | + } | |
| 137 | + | |
| 138 | + /** | |
| 117 | 139 | * 移除连接 |
| 118 | 140 | */ |
| 119 | 141 | public ConnectionInfo removeConnection(NetSocket socket) { |
| ... | ... | @@ -139,7 +161,9 @@ public class IotModbusTcpServerConnectionManager { |
| 139 | 161 | if (socket == null) { |
| 140 | 162 | return; |
| 141 | 163 | } |
| 142 | - connectionMap.remove(socket); | |
| 164 | + // 在 ConnectionInfo 还能取到时通知通道(切回 owner 收尾,幂等),然后照旧 close: | |
| 165 | + // 映射已移除,closeHandler 照旧提前返回,不发普通下线消息 | |
| 166 | + notifyChannelClosed(connectionMap.remove(socket)); | |
| 143 | 167 | try { |
| 144 | 168 | socket.close(); |
| 145 | 169 | log.info("[closeConnection][设备 {} 连接已关闭]", deviceId); |
| ... | ... | @@ -178,6 +202,8 @@ public class IotModbusTcpServerConnectionManager { |
| 178 | 202 | public void closeAll() { |
| 179 | 203 | // 1. 先复制再清空,避免 closeHandler 回调时并发修改 |
| 180 | 204 | List<NetSocket> sockets = new ArrayList<>(connectionMap.keySet()); |
| 205 | + // 清空前逐个通知通道(与 closeConnection 同理:closeHandler 之后拿不到 info) | |
| 206 | + connectionMap.values().forEach(this::notifyChannelClosed); | |
| 181 | 207 | connectionMap.clear(); |
| 182 | 208 | deviceSocketMap.clear(); |
| 183 | 209 | // 2. 关闭所有 socket(closeHandler 中 removeConnection 发现 map 为空会安全跳过) |
| ... | ... | @@ -190,4 +216,13 @@ public class IotModbusTcpServerConnectionManager { |
| 190 | 216 | } |
| 191 | 217 | } |
| 192 | 218 | |
| 219 | + /** | |
| 220 | + * 通知连接的事务通道已关闭(任意线程:通道内部切回自己的 owner;onClose 可以重复调用) | |
| 221 | + */ | |
| 222 | + private void notifyChannelClosed(ConnectionInfo info) { | |
| 223 | + if (info != null && info.getChannel() != null) { | |
| 224 | + info.getChannel().onClose(); | |
| 225 | + } | |
| 226 | + } | |
| 227 | + | |
| 193 | 228 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPendingRequestManager.java
| ... | ... | @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; |
| 5 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; |
| 7 | 8 | import lombok.AllArgsConstructor; |
| ... | ... | @@ -17,9 +18,12 @@ import java.util.concurrent.ConcurrentLinkedDeque; |
| 17 | 18 | /** |
| 18 | 19 | * IoT Modbus TCP Server 待响应请求管理器 |
| 19 | 20 | * <p> |
| 20 | - * 管理轮询下发的请求,用于匹配设备响应: | |
| 21 | + * 管理轮询、透传下发的请求,用于匹配设备响应: | |
| 21 | 22 | * - TCP 模式:按 transactionId 精确匹配 |
| 22 | 23 | * - RTU 模式:按 slaveId + functionCode FIFO 匹配 |
| 24 | + * <p> | |
| 25 | + * 只匹配收到响应的这条连接自己发出的请求({@link PendingRequest#getChannel()} 的引用身份): | |
| 26 | + * 同一设备的新连接替换旧连接后,旧连接的条目不能抢走新连接的响应 | |
| 23 | 27 | * |
| 24 | 28 | * @author 芋道源码 |
| 25 | 29 | */ |
| ... | ... | @@ -47,6 +51,14 @@ public class IotModbusTcpServerPendingRequestManager { |
| 47 | 51 | private int registerCount; |
| 48 | 52 | private Integer transactionId; |
| 49 | 53 | private long expireAt; |
| 54 | + /** | |
| 55 | + * 发出它的连接通道:登记、发送、匹配、清理都用这条连接的身份 | |
| 56 | + */ | |
| 57 | + private IotModbusTcpServerTransactionChannel channel; | |
| 58 | + /** | |
| 59 | + * 透传事务;标准轮询为 null | |
| 60 | + */ | |
| 61 | + private IotModbusTx tx; | |
| 50 | 62 | |
| 51 | 63 | } |
| 52 | 64 | |
| ... | ... | @@ -60,14 +72,19 @@ public class IotModbusTcpServerPendingRequestManager { |
| 60 | 72 | |
| 61 | 73 | /** |
| 62 | 74 | * 匹配响应(TCP 模式按 transactionId,RTU 模式按 FIFO) |
| 75 | + * <p> | |
| 76 | + * 异常响应只用来收尾透传请求:标准轮询的异常响应照旧丢弃,它的条目等到期清理。 | |
| 77 | + * RTU 模式的异常响应保持 FIFO,不会跳过排在前面的标准轮询去配给后面的透传 | |
| 63 | 78 | * |
| 64 | 79 | * @param deviceId 设备 ID |
| 65 | 80 | * @param frame 收到的响应帧 |
| 66 | 81 | * @param frameFormat 帧格式 |
| 82 | + * @param channel 收到响应的这条连接的事务通道 | |
| 67 | 83 | * @return 匹配到的 PendingRequest,没有匹配返回 null |
| 68 | 84 | */ |
| 69 | 85 | public PendingRequest matchResponse(Long deviceId, IotModbusFrame frame, |
| 70 | - IotModbusFrameFormatEnum frameFormat) { | |
| 86 | + IotModbusFrameFormatEnum frameFormat, | |
| 87 | + IotModbusTcpServerTransactionChannel channel) { | |
| 71 | 88 | Deque<PendingRequest> queue = pendingRequests.get(deviceId); |
| 72 | 89 | if (CollUtil.isEmpty(queue)) { |
| 73 | 90 | return null; |
| ... | ... | @@ -75,37 +92,61 @@ public class IotModbusTcpServerPendingRequestManager { |
| 75 | 92 | |
| 76 | 93 | // TCP 模式:按 transactionId 精确匹配 |
| 77 | 94 | if (frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP && frame.getTransactionId() != null) { |
| 78 | - return matchByTransactionId(queue, frame.getTransactionId()); | |
| 95 | + return matchByTransactionId(queue, channel, frame); | |
| 79 | 96 | } |
| 80 | 97 | // RTU 模式:FIFO,匹配 slaveId + functionCode + registerCount |
| 98 | + // 异常响应(解码器已还原原功能码)的 pdu[0] 是异常码,不是 byteCount,所以跳过数量校验,只按 unit + 原功能码找排在最前面的请求 | |
| 99 | + if (frame.isException()) { | |
| 100 | + return matchExceptionByFifo(queue, channel, frame.getSlaveId(), frame.getFunctionCode()); | |
| 101 | + } | |
| 81 | 102 | int responseRegisterCount = IotModbusCommonUtils.extractRegisterCountFromResponse(frame); |
| 82 | - return matchByFifo(queue, frame.getSlaveId(), frame.getFunctionCode(), responseRegisterCount); | |
| 103 | + return matchByFifo(queue, channel, frame.getSlaveId(), frame.getFunctionCode(), responseRegisterCount); | |
| 83 | 104 | } |
| 84 | 105 | |
| 85 | 106 | /** |
| 86 | 107 | * 按 transactionId 匹配 |
| 108 | + * <p> | |
| 109 | + * 透传请求另外核对 unit 与功能码(异常响应的 functionCode 已由解码器还原),不一致就不出队,等它到期;标准轮询保持只按 transactionId | |
| 87 | 110 | */ |
| 88 | - private PendingRequest matchByTransactionId(Deque<PendingRequest> queue, int transactionId) { | |
| 111 | + private PendingRequest matchByTransactionId(Deque<PendingRequest> queue, | |
| 112 | + IotModbusTcpServerTransactionChannel channel, | |
| 113 | + IotModbusFrame frame) { | |
| 114 | + int transactionId = frame.getTransactionId(); | |
| 89 | 115 | Iterator<PendingRequest> it = queue.iterator(); |
| 90 | 116 | while (it.hasNext()) { |
| 91 | 117 | PendingRequest req = it.next(); |
| 92 | - if (req.getTransactionId() != null && req.getTransactionId() == transactionId) { | |
| 93 | - it.remove(); | |
| 94 | - return req; | |
| 118 | + if (req.getChannel() != channel | |
| 119 | + || req.getTransactionId() == null || req.getTransactionId() != transactionId) { | |
| 120 | + continue; | |
| 121 | + } | |
| 122 | + if (req.getTx() == null) { | |
| 123 | + // 标准轮询:异常响应照旧丢弃,不出队 | |
| 124 | + if (frame.isException()) { | |
| 125 | + return null; | |
| 126 | + } | |
| 127 | + } else if (req.getSlaveId() != frame.getSlaveId() || req.getFunctionCode() != frame.getFunctionCode()) { | |
| 128 | + return null; | |
| 95 | 129 | } |
| 130 | + it.remove(); | |
| 131 | + return req; | |
| 96 | 132 | } |
| 97 | 133 | return null; |
| 98 | 134 | } |
| 99 | 135 | |
| 100 | 136 | /** |
| 101 | 137 | * 按 FIFO 匹配(slaveId + functionCode + registerCount) |
| 138 | + * <p> | |
| 139 | + * 这是原有的 RTU 匹配逻辑,透传请求只是加入同一个 FIFO,有原有的限制: | |
| 140 | + * RTU 帧没有事务号,同 unit、同功能码、同数量的迟到响应(例如到期清理后才到的轮询响应), | |
| 141 | + * 或者同功能码的标准写(不登记待响应请求)的回显 / 异常响应,都可能被配给排在前面的另一个请求 | |
| 102 | 142 | */ |
| 103 | - private PendingRequest matchByFifo(Deque<PendingRequest> queue, int slaveId, int functionCode, | |
| 104 | - int responseRegisterCount) { | |
| 143 | + private PendingRequest matchByFifo(Deque<PendingRequest> queue, IotModbusTcpServerTransactionChannel channel, | |
| 144 | + int slaveId, int functionCode, int responseRegisterCount) { | |
| 105 | 145 | Iterator<PendingRequest> it = queue.iterator(); |
| 106 | 146 | while (it.hasNext()) { |
| 107 | 147 | PendingRequest req = it.next(); |
| 108 | - if (req.getSlaveId() == slaveId | |
| 148 | + if (req.getChannel() == channel | |
| 149 | + && req.getSlaveId() == slaveId | |
| 109 | 150 | && req.getFunctionCode() == functionCode |
| 110 | 151 | && (responseRegisterCount <= 0 || req.getRegisterCount() == responseRegisterCount)) { |
| 111 | 152 | it.remove(); |
| ... | ... | @@ -116,6 +157,53 @@ public class IotModbusTcpServerPendingRequestManager { |
| 116 | 157 | } |
| 117 | 158 | |
| 118 | 159 | /** |
| 160 | + * 按 FIFO 匹配 RTU 异常响应(slaveId + 原功能码,异常响应没有数量可核对) | |
| 161 | + * <p> | |
| 162 | + * 只看本连接排在最前面的同 unit、同功能码的请求,不跳过它: | |
| 163 | + * 1. 它是透传请求:出队,由设备的异常响应收尾; | |
| 164 | + * 2. 它是标准轮询:照旧丢弃这个异常响应,轮询请求不出队、等到期清理;排在它后面的透传也不出队(按原有的 FIFO,异常响应属于更早的请求) | |
| 165 | + */ | |
| 166 | + private PendingRequest matchExceptionByFifo(Deque<PendingRequest> queue, IotModbusTcpServerTransactionChannel channel, | |
| 167 | + int slaveId, int functionCode) { | |
| 168 | + Iterator<PendingRequest> it = queue.iterator(); | |
| 169 | + while (it.hasNext()) { | |
| 170 | + PendingRequest req = it.next(); | |
| 171 | + if (req.getChannel() != channel || req.getSlaveId() != slaveId || req.getFunctionCode() != functionCode) { | |
| 172 | + continue; | |
| 173 | + } | |
| 174 | + if (req.getTx() == null) { | |
| 175 | + return null; | |
| 176 | + } | |
| 177 | + it.remove(); | |
| 178 | + return req; | |
| 179 | + } | |
| 180 | + return null; | |
| 181 | + } | |
| 182 | + | |
| 183 | + /** | |
| 184 | + * 本连接是否有占用该 transactionId 的待响应请求(分配事务号时跳过) | |
| 185 | + * | |
| 186 | + * @param deviceId 设备 ID | |
| 187 | + * @param channel 连接的事务通道 | |
| 188 | + * @param transactionId 事务号 | |
| 189 | + * @return 是否占用 | |
| 190 | + */ | |
| 191 | + public boolean containsTransactionId(Long deviceId, IotModbusTcpServerTransactionChannel channel, | |
| 192 | + int transactionId) { | |
| 193 | + Deque<PendingRequest> queue = pendingRequests.get(deviceId); | |
| 194 | + if (CollUtil.isEmpty(queue)) { | |
| 195 | + return false; | |
| 196 | + } | |
| 197 | + for (PendingRequest req : queue) { | |
| 198 | + if (req.getChannel() == channel | |
| 199 | + && req.getTransactionId() != null && req.getTransactionId() == transactionId) { | |
| 200 | + return true; | |
| 201 | + } | |
| 202 | + } | |
| 203 | + return false; | |
| 204 | + } | |
| 205 | + | |
| 206 | + /** | |
| 119 | 207 | * 清理过期请求 |
| 120 | 208 | */ |
| 121 | 209 | public void cleanupExpired() { |
| ... | ... | @@ -138,6 +226,26 @@ public class IotModbusTcpServerPendingRequestManager { |
| 138 | 226 | } |
| 139 | 227 | |
| 140 | 228 | /** |
| 229 | + * 移除指定的待响应请求:按引用(PendingRequest 是 @Data,equals 按字段比较),已被匹配或清理时无副作用 | |
| 230 | + */ | |
| 231 | + public void removeRequest(PendingRequest request) { | |
| 232 | + Deque<PendingRequest> queue = pendingRequests.get(request.getDeviceId()); | |
| 233 | + if (queue != null) { | |
| 234 | + queue.removeIf(req -> req == request); | |
| 235 | + } | |
| 236 | + } | |
| 237 | + | |
| 238 | + /** | |
| 239 | + * 清理指定连接的所有待响应请求:只删这条连接自己的条目,不影响同一设备新连接的条目 | |
| 240 | + */ | |
| 241 | + public void removeChannel(Long deviceId, IotModbusTcpServerTransactionChannel channel) { | |
| 242 | + Deque<PendingRequest> queue = pendingRequests.get(deviceId); | |
| 243 | + if (queue != null) { | |
| 244 | + queue.removeIf(req -> req.getChannel() == channel); | |
| 245 | + } | |
| 246 | + } | |
| 247 | + | |
| 248 | + /** | |
| 141 | 249 | * 清理指定设备的所有待响应请求 |
| 142 | 250 | */ |
| 143 | 251 | public void removeDevice(Long deviceId) { | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPollScheduler.java
| ... | ... | @@ -11,11 +11,8 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotMo |
| 11 | 11 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; |
| 12 | 12 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; |
| 13 | 13 | import io.vertx.core.Vertx; |
| 14 | -import lombok.Getter; | |
| 15 | 14 | import lombok.extern.slf4j.Slf4j; |
| 16 | 15 | |
| 17 | -import java.util.concurrent.atomic.AtomicInteger; | |
| 18 | - | |
| 19 | 16 | /** |
| 20 | 17 | * IoT Modbus TCP Server 轮询调度器:编码读请求帧,通过 TCP 连接发送到设备,注册 PendingRequest 等待响应 |
| 21 | 18 | * |
| ... | ... | @@ -29,25 +26,18 @@ public class IotModbusTcpServerPollScheduler extends AbstractIotModbusPollSchedu |
| 29 | 26 | private final IotModbusTcpServerPendingRequestManager pendingRequestManager; |
| 30 | 27 | private final IotModbusTcpServerConfigCacheService configCacheService; |
| 31 | 28 | private final int requestTimeout; |
| 32 | - /** | |
| 33 | - * TCP 事务 ID 自增器(与 DownstreamHandler 共享) | |
| 34 | - */ | |
| 35 | - @Getter | |
| 36 | - private final AtomicInteger transactionIdCounter; | |
| 37 | 29 | |
| 38 | 30 | public IotModbusTcpServerPollScheduler(Vertx vertx, |
| 39 | 31 | IotModbusTcpServerConnectionManager connectionManager, |
| 40 | 32 | IotModbusFrameEncoder frameEncoder, |
| 41 | 33 | IotModbusTcpServerPendingRequestManager pendingRequestManager, |
| 42 | 34 | int requestTimeout, |
| 43 | - AtomicInteger transactionIdCounter, | |
| 44 | 35 | IotModbusTcpServerConfigCacheService configCacheService) { |
| 45 | 36 | super(vertx); |
| 46 | 37 | this.connectionManager = connectionManager; |
| 47 | 38 | this.frameEncoder = frameEncoder; |
| 48 | 39 | this.pendingRequestManager = pendingRequestManager; |
| 49 | 40 | this.requestTimeout = requestTimeout; |
| 50 | - this.transactionIdCounter = transactionIdCounter; | |
| 51 | 41 | this.configCacheService = configCacheService; |
| 52 | 42 | } |
| 53 | 43 | |
| ... | ... | @@ -84,10 +74,9 @@ public class IotModbusTcpServerPollScheduler extends AbstractIotModbusPollSchedu |
| 84 | 74 | Integer slaveId = connection.getSlaveId(); |
| 85 | 75 | Assert.notNull(connection.getSlaveId(), "设备 {} 的 slaveId 不能为空", deviceId); |
| 86 | 76 | |
| 87 | - // 3.1 编码读请求 | |
| 88 | - Integer transactionId = frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP | |
| 89 | - ? (transactionIdCounter.incrementAndGet() & 0xFFFF) | |
| 90 | - : null; | |
| 77 | + // 3.1 编码读请求(事务号由连接的事务通道分配:跳过本连接仍占用的编号) | |
| 78 | + IotModbusTcpServerTransactionChannel channel = connection.getChannel(); | |
| 79 | + Integer transactionId = channel.allocateTransactionId(); | |
| 91 | 80 | byte[] data = frameEncoder.encodeReadRequest(slaveId, point.getFunctionCode(), |
| 92 | 81 | point.getRegisterAddress(), point.getRegisterCount(), frameFormat, transactionId); |
| 93 | 82 | // 3.2 注册 PendingRequest |
| ... | ... | @@ -96,10 +85,11 @@ public class IotModbusTcpServerPollScheduler extends AbstractIotModbusPollSchedu |
| 96 | 85 | slaveId, point.getFunctionCode(), |
| 97 | 86 | point.getRegisterAddress(), point.getRegisterCount(), |
| 98 | 87 | transactionId, |
| 99 | - System.currentTimeMillis() + requestTimeout); | |
| 88 | + System.currentTimeMillis() + requestTimeout, | |
| 89 | + channel, null); | |
| 100 | 90 | pendingRequestManager.addRequest(pendingRequest); |
| 101 | - // 3.3 发送读请求 | |
| 102 | - connectionManager.sendToDevice(deviceId, data).onSuccess(v -> | |
| 91 | + // 3.3 发送读请求(与登记用同一条连接) | |
| 92 | + channel.send(data).onSuccess(v -> | |
| 103 | 93 | log.debug("[pollPoint][设备={}, 点位={}, FC={}, 地址={}, 数量={}]", |
| 104 | 94 | deviceId, point.getIdentifier(), point.getFunctionCode(), |
| 105 | 95 | point.getRegisterAddress(), point.getRegisterCount()) | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerTransactionChannel.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; | |
| 4 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 5 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; | |
| 6 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; | |
| 7 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; | |
| 9 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 10 | +import io.vertx.core.Context; | |
| 11 | +import io.vertx.core.Future; | |
| 12 | +import io.vertx.core.buffer.Buffer; | |
| 13 | +import io.vertx.core.net.NetSocket; | |
| 14 | +import lombok.Getter; | |
| 15 | +import lombok.extern.slf4j.Slf4j; | |
| 16 | + | |
| 17 | +import java.util.ArrayDeque; | |
| 18 | +import java.util.ArrayList; | |
| 19 | +import java.util.Deque; | |
| 20 | +import java.util.HashMap; | |
| 21 | +import java.util.Iterator; | |
| 22 | +import java.util.List; | |
| 23 | +import java.util.Map; | |
| 24 | +import java.util.concurrent.atomic.AtomicInteger; | |
| 25 | + | |
| 26 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * IoT Modbus TCP Server 单个连接的事务通道 | |
| 30 | + * <p> | |
| 31 | + * 认证注册时捕获这条连接的 socket 和 owner(event loop Context):标准轮询、标准写、透传的登记、发送、匹配、清理都用这一个连接身份, | |
| 32 | + * 不会在 A 连接上登记、从 B 连接发出。 | |
| 33 | + * <ul> | |
| 34 | + * <li>标准轮询、标准写:只通过 {@link #allocateTransactionId()} 取事务号、{@link #send} 写出,其余保持原逻辑: | |
| 35 | + * 不排队、不受透传名额限制,超时只由清理定时器移除、不断开连接,标准写不等待回显;</li> | |
| 36 | + * <li>透传:排队 + 在途不超过 {@link #MAX_RAW_OUTSTANDING};TCP 帧格式可以多个同时在途, | |
| 37 | + * RTU 帧格式同一时刻只有 1 个透传在途(轮询、标准写不受此限制);实际发送时才登记待响应请求, | |
| 38 | + * 排队过期、在途超时、写失败、连接关闭都只进入一次终态,同时释放名额和待响应请求。</li> | |
| 39 | + * </ul> | |
| 40 | + * 透传状态(queue / sent / closed)只在 owner 上读写,不加锁:submitRaw / onCleanup / onClose 可能来自总线消费线程、 | |
| 41 | + * 清理定时器、配置刷新定时器、stop 线程,一律先 runOnContext 切回 owner;onRawResponse(RecordParser 回调)、写出回调本来就在 owner 上 | |
| 42 | + * | |
| 43 | + * @author 芋道源码 | |
| 44 | + */ | |
| 45 | +@Slf4j | |
| 46 | +public class IotModbusTcpServerTransactionChannel { | |
| 47 | + | |
| 48 | + /** | |
| 49 | + * 每个连接透传请求的排队 + 在途上限 | |
| 50 | + */ | |
| 51 | + public static final int MAX_RAW_OUTSTANDING = 32; | |
| 52 | + | |
| 53 | + /** | |
| 54 | + * 本连接的 event loop Context:透传状态只在它上面读写 | |
| 55 | + */ | |
| 56 | + private final Context owner; | |
| 57 | + /** | |
| 58 | + * 认证注册时捕获的连接:标准轮询、标准写、透传都从它写出 | |
| 59 | + */ | |
| 60 | + private final NetSocket socket; | |
| 61 | + /** | |
| 62 | + * 设备编号 | |
| 63 | + */ | |
| 64 | + private final Long deviceId; | |
| 65 | + /** | |
| 66 | + * 从站地址 | |
| 67 | + */ | |
| 68 | + private final int slaveId; | |
| 69 | + /** | |
| 70 | + * 帧格式:TCP 可以多个透传同时在途(按事务号匹配),RTU 同一时刻只有 1 个透传在途(按 FIFO 匹配) | |
| 71 | + */ | |
| 72 | + private final IotModbusFrameFormatEnum frameFormat; | |
| 73 | + /** | |
| 74 | + * 帧编码器:把 PDU 封装成 TCP(MBAP)/ RTU(CRC)帧 | |
| 75 | + */ | |
| 76 | + private final IotModbusFrameEncoder frameEncoder; | |
| 77 | + /** | |
| 78 | + * 协议共享的待响应请求管理器:透传实际发送时登记,终态时按引用移除 | |
| 79 | + */ | |
| 80 | + private final IotModbusTcpServerPendingRequestManager pendingRequestManager; | |
| 81 | + /** | |
| 82 | + * 协议共享的事务号计数器(16 位) | |
| 83 | + */ | |
| 84 | + private final AtomicInteger transactionIdCounter; | |
| 85 | + /** | |
| 86 | + * 请求超时时间(毫秒):透传排队截止、待响应到期都按它计算 | |
| 87 | + */ | |
| 88 | + @Getter | |
| 89 | + private final int requestTimeout; | |
| 90 | + /** | |
| 91 | + * 本连接的 worker 队列(owner 上的有序有界队列,只在 owner 上提交): | |
| 92 | + * 标准轮询响应的点位翻译、发送到消息总线,以及断开后的下线消息会阻塞,按收到的顺序交给 worker 执行,不占用 owner | |
| 93 | + */ | |
| 94 | + @Getter | |
| 95 | + private final IotWorkerTaskQueue upstreamQueue; | |
| 96 | + | |
| 97 | + /** | |
| 98 | + * 排队中的透传 | |
| 99 | + */ | |
| 100 | + private final Deque<IotModbusTx> queue = new ArrayDeque<>(); | |
| 101 | + /** | |
| 102 | + * 在途的透传 → 它登记的待响应请求 | |
| 103 | + */ | |
| 104 | + private final Map<IotModbusTx, PendingRequest> sent = new HashMap<>(); | |
| 105 | + /** | |
| 106 | + * 连接已关闭:不再发送透传 | |
| 107 | + */ | |
| 108 | + private boolean closed; | |
| 109 | + | |
| 110 | + public IotModbusTcpServerTransactionChannel(Context owner, NetSocket socket, | |
| 111 | + Long deviceId, int slaveId, IotModbusFrameFormatEnum frameFormat, | |
| 112 | + IotModbusFrameEncoder frameEncoder, | |
| 113 | + IotModbusTcpServerPendingRequestManager pendingRequestManager, | |
| 114 | + AtomicInteger transactionIdCounter, int requestTimeout) { | |
| 115 | + this.owner = owner; | |
| 116 | + this.socket = socket; | |
| 117 | + this.deviceId = deviceId; | |
| 118 | + this.slaveId = slaveId; | |
| 119 | + this.frameFormat = frameFormat; | |
| 120 | + this.frameEncoder = frameEncoder; | |
| 121 | + this.pendingRequestManager = pendingRequestManager; | |
| 122 | + this.transactionIdCounter = transactionIdCounter; | |
| 123 | + this.requestTimeout = requestTimeout; | |
| 124 | + this.upstreamQueue = new IotWorkerTaskQueue(owner); | |
| 125 | + } | |
| 126 | + | |
| 127 | + // ========== 标准轮询、标准写的入口(任意线程) ========== | |
| 128 | + | |
| 129 | + /** | |
| 130 | + * 分配事务号:RTU 帧格式返回 null | |
| 131 | + * <p> | |
| 132 | + * 复用协议共享的计数器(16 位),跳过本连接仍有待响应请求(轮询、在途透传)占用的编号。 | |
| 133 | + * 标准写不登记待响应请求,它的编号不受保护:计数器回绕时,仍在路上的写回显可能与新编号相同 | |
| 134 | + * | |
| 135 | + * @return 事务号 | |
| 136 | + */ | |
| 137 | + public Integer allocateTransactionId() { | |
| 138 | + if (!isTcpFormat()) { | |
| 139 | + return null; | |
| 140 | + } | |
| 141 | + int id; | |
| 142 | + int attempts = 0; | |
| 143 | + do { | |
| 144 | + id = transactionIdCounter.incrementAndGet() & 0xFFFF; | |
| 145 | + } while (pendingRequestManager.containsTransactionId(deviceId, this, id) && ++attempts <= 0xFFFF); | |
| 146 | + return id; | |
| 147 | + } | |
| 148 | + | |
| 149 | + /** | |
| 150 | + * 写出到本连接捕获的 socket | |
| 151 | + * | |
| 152 | + * @param data 完整帧 | |
| 153 | + * @return 写出结果:只代表 socket 写出成功或失败,不代表设备已经响应 | |
| 154 | + */ | |
| 155 | + public Future<Void> send(byte[] data) { | |
| 156 | + return socket.write(Buffer.buffer(data)); | |
| 157 | + } | |
| 158 | + | |
| 159 | + // ========== 透传的入口(任意线程,先切回 owner) ========== | |
| 160 | + | |
| 161 | + /** | |
| 162 | + * 提交透传事务 | |
| 163 | + * | |
| 164 | + * @param tx 事务 | |
| 165 | + */ | |
| 166 | + public void submitRaw(IotModbusTx tx) { | |
| 167 | + owner.runOnContext(v -> submitRaw0(tx)); | |
| 168 | + } | |
| 169 | + | |
| 170 | + /** | |
| 171 | + * 清理周期(复用待响应请求的清理定时器):在途透传是否超时、排队透传是否超过截止时间 | |
| 172 | + */ | |
| 173 | + public void onCleanup() { | |
| 174 | + owner.runOnContext(v -> onCleanup0()); | |
| 175 | + } | |
| 176 | + | |
| 177 | + /** | |
| 178 | + * 连接关闭(closeHandler,或 ConnectionManager 的 closeConnection / closeAll / 旧连接替换);可以重复调用 | |
| 179 | + */ | |
| 180 | + public void onClose() { | |
| 181 | + owner.runOnContext(v -> onClose0()); | |
| 182 | + } | |
| 183 | + | |
| 184 | + /** | |
| 185 | + * 收到匹配到透传请求的响应(owner:handlePollingResponse 先 matchResponse 出队,再调用这里) | |
| 186 | + * | |
| 187 | + * @param frame 响应帧 | |
| 188 | + * @param request 匹配到的待响应请求 | |
| 189 | + */ | |
| 190 | + public void onRawResponse(IotModbusFrame frame, PendingRequest request) { | |
| 191 | + // 1. 已超时、写失败或已关闭:迟到的响应丢弃 | |
| 192 | + IotModbusTx tx = request.getTx(); | |
| 193 | + if (sent.remove(tx) == null) { | |
| 194 | + log.debug("[onRawResponse][设备 {} 迟到的透传响应, 丢弃, FC={}]", deviceId, frame.getFunctionCode()); | |
| 195 | + return; | |
| 196 | + } | |
| 197 | + // 2. 进入终态:异常响应原样回给透传请求;正常响应核对格式 | |
| 198 | + byte[] responsePdu = IotModbusCommonUtils.buildResponsePdu(frame); | |
| 199 | + if (!frame.isException() && !IotModbusCommonUtils.isResponseShapeValid(tx.getPdu(), responsePdu)) { | |
| 200 | + log.warn("[onRawResponse][设备 {} 透传响应格式与请求不一致, FC={}]", deviceId, frame.getFunctionCode()); | |
| 201 | + tx.fail(MODBUS_RESPONSE_INVALID); | |
| 202 | + } else { | |
| 203 | + tx.complete(responsePdu); | |
| 204 | + } | |
| 205 | + // 3. 释放了在途位置:继续发送排队中的透传 | |
| 206 | + pump(); | |
| 207 | + } | |
| 208 | + | |
| 209 | + // ========== owner 上的处理 ========== | |
| 210 | + | |
| 211 | + /** | |
| 212 | + * owner:透传入队;连接已关闭、排队 + 在途已满时直接失败 | |
| 213 | + */ | |
| 214 | + private void submitRaw0(IotModbusTx tx) { | |
| 215 | + // 1. 校验:连接已关闭、名额已满 | |
| 216 | + if (closed) { | |
| 217 | + tx.fail(MODBUS_DISCONNECTED); | |
| 218 | + return; | |
| 219 | + } | |
| 220 | + if (queue.size() + sent.size() >= MAX_RAW_OUTSTANDING) { | |
| 221 | + tx.fail(MODBUS_QUEUE_FULL); | |
| 222 | + return; | |
| 223 | + } | |
| 224 | + // 2. 入队,尝试发送 | |
| 225 | + queue.add(tx); | |
| 226 | + pump(); | |
| 227 | + } | |
| 228 | + | |
| 229 | + /** | |
| 230 | + * owner:按帧格式允许的在途数量,依次发送排队中的透传(TCP 不限,RTU 只在没有在途时发送 1 个) | |
| 231 | + */ | |
| 232 | + private void pump() { | |
| 233 | + while (!closed && !queue.isEmpty() && (isTcpFormat() || sent.isEmpty())) { | |
| 234 | + IotModbusTx tx = queue.poll(); | |
| 235 | + // 1. 排队超过截止时间:不再发送 | |
| 236 | + if (tx.isExpired()) { | |
| 237 | + tx.fail(MODBUS_TIMEOUT); | |
| 238 | + continue; | |
| 239 | + } | |
| 240 | + // 2.1 分配事务号、封装帧 | |
| 241 | + Integer transactionId = allocateTransactionId(); | |
| 242 | + byte[] data = frameEncoder.wrapFrame(slaveId, tx.getPdu(), frameFormat, transactionId); | |
| 243 | + // 2.2 只在实际发送时登记待响应请求,并占用在途位置 | |
| 244 | + PendingRequest request = buildPendingRequest(tx, transactionId); | |
| 245 | + pendingRequestManager.addRequest(request); | |
| 246 | + sent.put(tx, request); | |
| 247 | + // 3. 写出:回调在 owner 上 | |
| 248 | + send(data).onFailure(e -> onRawWriteFailed(tx, e)); | |
| 249 | + } | |
| 250 | + } | |
| 251 | + | |
| 252 | + /** | |
| 253 | + * owner:透传写出失败,结束这一笔并继续发送排队中的透传 | |
| 254 | + */ | |
| 255 | + private void onRawWriteFailed(IotModbusTx tx, Throwable e) { | |
| 256 | + log.warn("[onRawWriteFailed][设备 {} 透传写出失败]", deviceId, e); | |
| 257 | + // 只结束这一笔透传,不主动关闭连接:连接真的异常时由 closeHandler 收尾 | |
| 258 | + if (release(tx)) { | |
| 259 | + tx.fail(DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 260 | + pump(); | |
| 261 | + } | |
| 262 | + } | |
| 263 | + | |
| 264 | + /** | |
| 265 | + * owner:清理周期,结束超时的在途透传、过期的排队透传 | |
| 266 | + */ | |
| 267 | + private void onCleanup0() { | |
| 268 | + if (closed) { | |
| 269 | + return; | |
| 270 | + } | |
| 271 | + // 1. 在途透传:登记的待响应请求已到期(与 cleanupExpired 的判断一致),不断开连接 | |
| 272 | + long now = System.currentTimeMillis(); | |
| 273 | + List<IotModbusTx> timeoutTxs = new ArrayList<>(); | |
| 274 | + for (Map.Entry<IotModbusTx, PendingRequest> entry : sent.entrySet()) { | |
| 275 | + if (entry.getValue().getExpireAt() < now) { | |
| 276 | + timeoutTxs.add(entry.getKey()); | |
| 277 | + } | |
| 278 | + } | |
| 279 | + for (IotModbusTx tx : timeoutTxs) { | |
| 280 | + if (release(tx)) { | |
| 281 | + tx.fail(MODBUS_TIMEOUT); | |
| 282 | + } | |
| 283 | + } | |
| 284 | + // 2. 排队中的透传:超过截止时间的不再发送 | |
| 285 | + List<IotModbusTx> expiredTxs = new ArrayList<>(); | |
| 286 | + for (Iterator<IotModbusTx> it = queue.iterator(); it.hasNext(); ) { | |
| 287 | + IotModbusTx tx = it.next(); | |
| 288 | + if (tx.isExpired()) { | |
| 289 | + it.remove(); | |
| 290 | + expiredTxs.add(tx); | |
| 291 | + } | |
| 292 | + } | |
| 293 | + for (IotModbusTx tx : expiredTxs) { | |
| 294 | + tx.fail(MODBUS_TIMEOUT); | |
| 295 | + } | |
| 296 | + // 3. 释放了在途位置:继续发送排队中的透传 | |
| 297 | + pump(); | |
| 298 | + } | |
| 299 | + | |
| 300 | + /** | |
| 301 | + * owner:连接关闭,排队中、在途的透传全部以断线结束;可能重复到达,照常清理;清理后各集合为空,重复执行无副作用 | |
| 302 | + */ | |
| 303 | + private void onClose0() { | |
| 304 | + // 1. 标记关闭,取出排队中、在途的透传 | |
| 305 | + closed = true; | |
| 306 | + List<IotModbusTx> txs = new ArrayList<>(queue); | |
| 307 | + txs.addAll(sent.keySet()); | |
| 308 | + queue.clear(); | |
| 309 | + sent.clear(); | |
| 310 | + // 2. 只删这条连接自己登记的待响应请求(轮询 + 透传),不影响同一设备新连接的条目 | |
| 311 | + pendingRequestManager.removeChannel(deviceId, this); | |
| 312 | + // 3. 全部以断线结束 | |
| 313 | + for (IotModbusTx tx : txs) { | |
| 314 | + tx.fail(MODBUS_DISCONNECTED); | |
| 315 | + } | |
| 316 | + } | |
| 317 | + | |
| 318 | + /** | |
| 319 | + * owner:把透传移出在途位置,并按引用移除它登记的待响应请求 | |
| 320 | + * | |
| 321 | + * @return false 表示它已经不在途(重复的终态信号直接忽略) | |
| 322 | + */ | |
| 323 | + private boolean release(IotModbusTx tx) { | |
| 324 | + PendingRequest request = sent.remove(tx); | |
| 325 | + if (request == null) { | |
| 326 | + return false; | |
| 327 | + } | |
| 328 | + pendingRequestManager.removeRequest(request); | |
| 329 | + return true; | |
| 330 | + } | |
| 331 | + | |
| 332 | + /** | |
| 333 | + * 构造透传登记的待响应请求:到期时间 = 现在 + requestTimeout | |
| 334 | + * | |
| 335 | + * @param tx 事务 | |
| 336 | + * @param transactionId 事务号;RTU 帧格式为 null | |
| 337 | + * @return 待响应请求 | |
| 338 | + */ | |
| 339 | + private PendingRequest buildPendingRequest(IotModbusTx tx, Integer transactionId) { | |
| 340 | + byte[] pdu = tx.getPdu(); | |
| 341 | + int functionCode = pdu[0] & 0xFF; | |
| 342 | + int registerAddress = ((pdu[1] & 0xFF) << 8) | (pdu[2] & 0xFF); | |
| 343 | + // registerCount 用于 RTU FIFO 按原逻辑核对 03 / 04 的正常响应 | |
| 344 | + return new PendingRequest(deviceId, null, null, slaveId, functionCode, registerAddress, | |
| 345 | + IotModbusCommonUtils.getRequestQuantity(pdu), transactionId, | |
| 346 | + System.currentTimeMillis() + requestTimeout, this, tx); | |
| 347 | + } | |
| 348 | + | |
| 349 | + /** | |
| 350 | + * @return 是否是 TCP 帧格式(MBAP,按事务号匹配响应) | |
| 351 | + */ | |
| 352 | + private boolean isTcpFormat() { | |
| 353 | + return frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP; | |
| 354 | + } | |
| 355 | + | |
| 356 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttProtocol.java
| ... | ... | @@ -19,6 +19,7 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.upstream.IotMqt |
| 19 | 19 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; |
| 20 | 20 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 21 | 21 | import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; |
| 22 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 22 | 23 | import io.netty.handler.codec.mqtt.MqttConnectReturnCode; |
| 23 | 24 | import io.netty.handler.codec.mqtt.MqttQoS; |
| 24 | 25 | import io.vertx.core.Vertx; |
| ... | ... | @@ -68,6 +69,7 @@ public class IotMqttProtocol implements IotProtocol { |
| 68 | 69 | /** |
| 69 | 70 | * Vert.x 实例 |
| 70 | 71 | */ |
| 72 | + @Getter | |
| 71 | 73 | private Vertx vertx; |
| 72 | 74 | /** |
| 73 | 75 | * MQTT 服务器 |
| ... | ... | @@ -232,21 +234,22 @@ public class IotMqttProtocol implements IotProtocol { |
| 232 | 234 | } |
| 233 | 235 | |
| 234 | 236 | // 2.1 设置异常和关闭处理器 |
| 237 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(vertx.getOrCreateContext()); // 本连接的 worker 队列:阻塞的消息处理按收到的顺序执行,不同连接之间并行 | |
| 235 | 238 | endpoint.exceptionHandler(ex -> { |
| 236 | 239 | log.warn("[handleEndpoint][连接异常,客户端 ID: {},地址: {},异常: {}]", |
| 237 | 240 | clientId, connectionManager.getEndpointAddress(endpoint), ex.getMessage()); |
| 238 | 241 | endpoint.close(); |
| 239 | 242 | }); |
| 240 | - endpoint.closeHandler(v -> cleanupConnection(endpoint)); // 处理底层连接关闭(网络中断、异常等) | |
| 243 | + endpoint.closeHandler(v -> cleanupConnection(endpoint, queue)); // 处理底层连接关闭(网络中断、异常等) | |
| 241 | 244 | endpoint.disconnectHandler(v -> { // 处理 MQTT DISCONNECT 报文 |
| 242 | 245 | log.debug("[handleEndpoint][设备断开连接,客户端 ID: {}]", clientId); |
| 243 | - cleanupConnection(endpoint); | |
| 246 | + cleanupConnection(endpoint, queue); | |
| 244 | 247 | }); |
| 245 | 248 | // 2.2 设置心跳处理器 |
| 246 | 249 | endpoint.pingHandler(v -> log.debug("[handleEndpoint][收到客户端心跳,客户端 ID: {}]", clientId)); |
| 247 | 250 | |
| 248 | 251 | // 3.1 设置消息处理器 |
| 249 | - endpoint.publishHandler(message -> processMessage(endpoint, message)); | |
| 252 | + endpoint.publishHandler(message -> processMessage(endpoint, queue, message)); | |
| 250 | 253 | // 3.2 设置 QoS 2 消息的 PUBREL 处理器 |
| 251 | 254 | endpoint.publishReleaseHandler(endpoint::publishComplete); |
| 252 | 255 | |
| ... | ... | @@ -282,23 +285,24 @@ public class IotMqttProtocol implements IotProtocol { |
| 282 | 285 | * 处理消息(发布) |
| 283 | 286 | * |
| 284 | 287 | * @param endpoint MQTT 连接端点 |
| 288 | + * @param queue 本连接的 worker 队列 | |
| 285 | 289 | * @param message 发布消息 |
| 286 | 290 | */ |
| 287 | - private void processMessage(MqttEndpoint endpoint, MqttPublishMessage message) { | |
| 291 | + private void processMessage(MqttEndpoint endpoint, IotWorkerTaskQueue queue, MqttPublishMessage message) { | |
| 288 | 292 | String clientId = endpoint.clientIdentifier(); |
| 289 | - try { | |
| 290 | - // 1. 处理业务消息 | |
| 291 | - String topic = message.topicName(); | |
| 292 | - byte[] payload = message.payload().getBytes(); | |
| 293 | - upstreamHandler.handleBusinessRequest(endpoint, topic, payload); | |
| 294 | - | |
| 295 | - // 2. 根据 QoS 级别发送相应的确认消息 | |
| 293 | + // 1. 处理业务消息:阻塞的部分在本连接的 worker 队列上按顺序执行,完成后回到 event loop | |
| 294 | + String topic = message.topicName(); | |
| 295 | + byte[] payload = message.payload().getBytes(); | |
| 296 | + upstreamHandler.handleBusinessRequest(endpoint, queue, topic, payload).onComplete(ar -> { | |
| 297 | + if (ar.failed()) { // 队列已满或已关闭:不确认,断开连接,由设备重连后重发 | |
| 298 | + log.error("[processMessage][消息处理失败,断开连接,客户端 ID: {},地址: {},错误: {}]", | |
| 299 | + clientId, connectionManager.getEndpointAddress(endpoint), ar.cause().getMessage()); | |
| 300 | + endpoint.close(); | |
| 301 | + return; | |
| 302 | + } | |
| 303 | + // 2. 处理完成后,根据 QoS 级别发送相应的确认消息:同一连接按收到的顺序完成,确认也按顺序 | |
| 296 | 304 | handleQoSAck(endpoint, message); |
| 297 | - } catch (Exception e) { | |
| 298 | - log.error("[processMessage][消息处理失败,断开连接,客户端 ID: {},地址: {},错误: {}]", | |
| 299 | - clientId, connectionManager.getEndpointAddress(endpoint), e.getMessage()); | |
| 300 | - endpoint.close(); | |
| 301 | - } | |
| 305 | + }); | |
| 302 | 306 | } |
| 303 | 307 | |
| 304 | 308 | /** |
| ... | ... | @@ -308,6 +312,10 @@ public class IotMqttProtocol implements IotProtocol { |
| 308 | 312 | * @param message 发布消息 |
| 309 | 313 | */ |
| 310 | 314 | private void handleQoSAck(MqttEndpoint endpoint, MqttPublishMessage message) { |
| 315 | + // 处理期间连接已断开:无法确认,设备重连后重发 | |
| 316 | + if (!endpoint.isConnected()) { | |
| 317 | + return; | |
| 318 | + } | |
| 311 | 319 | if (message.qosLevel() == MqttQoS.AT_LEAST_ONCE) { |
| 312 | 320 | // QoS 1: 发送 PUBACK 确认 |
| 313 | 321 | endpoint.publishAcknowledge(message.messageId()); |
| ... | ... | @@ -322,23 +330,33 @@ public class IotMqttProtocol implements IotProtocol { |
| 322 | 330 | * 清理连接 |
| 323 | 331 | * |
| 324 | 332 | * @param endpoint MQTT 连接端点 |
| 333 | + * @param queue 本连接的 worker 队列 | |
| 325 | 334 | */ |
| 326 | - private void cleanupConnection(MqttEndpoint endpoint) { | |
| 327 | - try { | |
| 328 | - // 1. 发送设备离线消息 | |
| 329 | - IotMqttConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(endpoint); | |
| 330 | - if (connectionInfo != null) { | |
| 331 | - IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); | |
| 332 | - deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), | |
| 333 | - connectionInfo.getDeviceName(), serverId); | |
| 334 | - } | |
| 335 | - } catch (Exception e) { | |
| 336 | - log.error("[cleanupConnection][清理连接失败,客户端 ID: {},错误: {}]", | |
| 337 | - endpoint.clientIdentifier(), e.getMessage()); | |
| 338 | - } finally { | |
| 339 | - // 2. 注销连接,避免发送离线消息失败时残留连接 | |
| 340 | - connectionManager.unregisterConnection(endpoint); | |
| 335 | + private void cleanupConnection(MqttEndpoint endpoint, IotWorkerTaskQueue queue) { | |
| 336 | + // 1. 注销连接:closeHandler、disconnectHandler 都会调用,第二次取不到连接信息,不再处理 | |
| 337 | + IotMqttConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(endpoint); | |
| 338 | + connectionManager.unregisterConnection(endpoint); | |
| 339 | + if (connectionInfo == null) { | |
| 340 | + return; | |
| 341 | 341 | } |
| 342 | + | |
| 343 | + // 2. 发送设备离线消息:排在本连接已收到的消息之后;执行时设备已在新连接上线,则不再发送 | |
| 344 | + queue.submitLast(() -> { | |
| 345 | + if (connectionManager.getDeviceEndpoint(connectionInfo.getDeviceId()) != null) { | |
| 346 | + log.info("[cleanupConnection][设备已在新连接上线,不发送离线消息,设备 ID: {}]", connectionInfo.getDeviceId()); | |
| 347 | + return null; | |
| 348 | + } | |
| 349 | + IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); | |
| 350 | + deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), | |
| 351 | + connectionInfo.getDeviceName(), serverId); | |
| 352 | + // 发送期间设备在新连接上线(它的上线消息可能先于离线消息):补发上线消息,避免在线设备被标记为离线 | |
| 353 | + if (connectionManager.getDeviceEndpoint(connectionInfo.getDeviceId()) != null) { | |
| 354 | + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline(), | |
| 355 | + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); | |
| 356 | + } | |
| 357 | + return null; | |
| 358 | + }).onFailure(e -> log.error("[cleanupConnection][清理连接失败,客户端 ID: {},错误: {}]", | |
| 359 | + endpoint.clientIdentifier(), e.getMessage())); | |
| 342 | 360 | } |
| 343 | 361 | |
| 344 | 362 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamHandler.java
| ... | ... | @@ -7,9 +7,13 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnecti |
| 7 | 7 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 8 | 8 | import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; |
| 9 | 9 | import io.netty.handler.codec.mqtt.MqttQoS; |
| 10 | +import io.vertx.core.Future; | |
| 10 | 11 | import lombok.RequiredArgsConstructor; |
| 11 | 12 | import lombok.extern.slf4j.Slf4j; |
| 12 | 13 | |
| 14 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 15 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 16 | + | |
| 13 | 17 | /** |
| 14 | 18 | * IoT 网关 MQTT 协议:下行消息处理器 |
| 15 | 19 | * |
| ... | ... | @@ -67,4 +71,28 @@ public class IotMqttDownstreamHandler { |
| 67 | 71 | } |
| 68 | 72 | } |
| 69 | 73 | |
| 74 | + /** | |
| 75 | + * 处理透传下行(raw.push 或编码脚本的输出):原始字节发布到 raw/push 主题 | |
| 76 | + * | |
| 77 | + * @param message 下行消息 | |
| 78 | + * @param bytes 要写出的原始字节 | |
| 79 | + * @return 提交结果:设备离线或发送失败时失败;成功只表示 publish 已提交,晚到的写出失败由连接管理器记日志 | |
| 80 | + */ | |
| 81 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes) { | |
| 82 | + // 1. 检查设备连接 | |
| 83 | + IotMqttConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfoByDeviceId( | |
| 84 | + message.getDeviceId()); | |
| 85 | + if (connectionInfo == null) { | |
| 86 | + log.warn("[handleRawMessage][连接信息不存在,设备 ID: {},方法: {},消息 ID: {}]", | |
| 87 | + message.getDeviceId(), message.getMethod(), message.getId()); | |
| 88 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 89 | + } | |
| 90 | + | |
| 91 | + // 2. 发布到 raw/push 主题 | |
| 92 | + String topic = IotMqttTopicUtils.buildRawPushTopic(connectionInfo.getProductKey(), connectionInfo.getDeviceName()); | |
| 93 | + boolean success = connectionManager.sendToDevice(message.getDeviceId(), topic, bytes, | |
| 94 | + MqttQoS.AT_LEAST_ONCE.value(), false); | |
| 95 | + return success ? Future.succeededFuture() : Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 96 | + } | |
| 97 | + | |
| 70 | 98 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamSubscriber.java
| ... | ... | @@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.downstream; |
| 2 | 2 | |
| 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 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttProtocol; |
| 8 | +import io.vertx.core.Future; | |
| 7 | 9 | import lombok.extern.slf4j.Slf4j; |
| 8 | 10 | |
| 9 | 11 | /** |
| ... | ... | @@ -28,4 +30,14 @@ public class IotMqttDownstreamSubscriber extends AbstractIotProtocolDownstreamSu |
| 28 | 30 | downstreamHandler.handle(message); |
| 29 | 31 | } |
| 30 | 32 | |
| 33 | + @Override | |
| 34 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 35 | + return downstreamHandler.handleRawMessage(message, bytes); | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Override | |
| 39 | + public boolean supportsRawDownstream() { | |
| 40 | + return true; | |
| 41 | + } | |
| 42 | + | |
| 31 | 43 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandler.java
| ... | ... | @@ -2,14 +2,17 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.upstream; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.lang.Assert; |
| 4 | 4 | import cn.hutool.core.util.ArrayUtil; |
| 5 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 5 | 7 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 6 | 8 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; |
| 7 | 9 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 8 | 10 | import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 12 | +import io.vertx.core.Future; | |
| 9 | 13 | import io.vertx.mqtt.MqttEndpoint; |
| 10 | 14 | import lombok.extern.slf4j.Slf4j; |
| 11 | 15 | |
| 12 | - | |
| 13 | 16 | /** |
| 14 | 17 | * IoT 网关 MQTT 上行消息处理器:处理业务消息(属性上报、事件上报等) |
| 15 | 18 | * |
| ... | ... | @@ -29,22 +32,30 @@ public class IotMqttUpstreamHandler extends IotMqttAbstractHandler { |
| 29 | 32 | |
| 30 | 33 | /** |
| 31 | 34 | * 处理业务消息 |
| 35 | + * <p> | |
| 36 | + * 在 event loop 上校验主题与身份;反序列化、透传解析、发送消息会阻塞,交给本连接的 worker 队列按顺序执行。 | |
| 37 | + * 处理失败、校验不通过只记录日志(与原来一致,照常确认);只有队列已满或已关闭时返回失败的 Future | |
| 32 | 38 | * |
| 33 | 39 | * @param endpoint MQTT 连接端点 |
| 40 | + * @param queue 本连接的 worker 队列 | |
| 34 | 41 | * @param topic 主题 |
| 35 | 42 | * @param payload 消息内容 |
| 43 | + * @return 处理完成的结果,在 event loop 上完成:调用方在它完成后再确认(PUBACK / PUBREC) | |
| 36 | 44 | */ |
| 37 | - public void handleBusinessRequest(MqttEndpoint endpoint, String topic, byte[] payload) { | |
| 45 | + public Future<Void> handleBusinessRequest(MqttEndpoint endpoint, IotWorkerTaskQueue queue, | |
| 46 | + String topic, byte[] payload) { | |
| 38 | 47 | String clientId = endpoint.clientIdentifier(); |
| 48 | + String productKey; | |
| 49 | + String deviceName; | |
| 39 | 50 | try { |
| 40 | 51 | // 1.1 基础检查 |
| 41 | 52 | if (ArrayUtil.isEmpty(payload)) { |
| 42 | - return; | |
| 53 | + return skip(queue); | |
| 43 | 54 | } |
| 44 | 55 | // 1.2 解析主题,获取 productKey 和 deviceName |
| 45 | 56 | String[] topicParts = topic.split("/"); |
| 46 | - String productKey = ArrayUtil.get(topicParts, 2); | |
| 47 | - String deviceName = ArrayUtil.get(topicParts, 3); | |
| 57 | + productKey = ArrayUtil.get(topicParts, 2); | |
| 58 | + deviceName = ArrayUtil.get(topicParts, 3); | |
| 48 | 59 | Assert.notBlank(productKey, "产品 Key 不能为空"); |
| 49 | 60 | Assert.notBlank(deviceName, "设备名称不能为空"); |
| 50 | 61 | // 1.3 校验设备信息,防止伪造设备消息 |
| ... | ... | @@ -55,24 +66,70 @@ public class IotMqttUpstreamHandler extends IotMqttAbstractHandler { |
| 55 | 66 | // 1.4 校验 topic 是否允许发布 |
| 56 | 67 | if (!IotMqttTopicUtils.isTopicPublishAllowed(topic, productKey, deviceName)) { |
| 57 | 68 | log.warn("[handleBusinessRequest][topic 不允许发布,客户端 ID: {},主题: {}]", clientId, topic); |
| 58 | - return; | |
| 69 | + return skip(queue); | |
| 59 | 70 | } |
| 71 | + } catch (Exception e) { | |
| 72 | + log.error("[handleBusinessRequest][消息校验失败,客户端 ID: {},主题: {},错误: {}]", | |
| 73 | + clientId, topic, e.getMessage()); | |
| 74 | + return skip(queue); | |
| 75 | + } | |
| 60 | 76 | |
| 61 | - // 2.1 反序列化消息 | |
| 62 | - IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); | |
| 63 | - if (message == null) { | |
| 64 | - log.warn("[handleBusinessRequest][消息解码失败,客户端 ID: {},主题: {}]", clientId, topic); | |
| 65 | - return; | |
| 77 | + // 2. 交给本连接的 worker 队列:透传上报的截止时间从收到(入队之前)开始计算 | |
| 78 | + boolean raw = IotMqttTopicUtils.isRawPostTopic(topic); | |
| 79 | + long receivedNanos = System.nanoTime(); | |
| 80 | + return queue.submit(() -> { | |
| 81 | + try { | |
| 82 | + if (raw) { | |
| 83 | + handleRawRequest(productKey, deviceName, payload, receivedNanos); | |
| 84 | + } else { | |
| 85 | + handleStandardRequest(clientId, topic, productKey, deviceName, payload); | |
| 86 | + } | |
| 87 | + } catch (Exception e) { | |
| 88 | + log.error("[handleBusinessRequest][消息处理异常,客户端 ID: {},主题: {},错误: {}]", | |
| 89 | + clientId, topic, e.getMessage(), e); | |
| 66 | 90 | } |
| 67 | - // 2.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀) | |
| 68 | - IotMqttTopicUtils.normalizeReplyMethod(message); | |
| 91 | + return null; | |
| 92 | + }); | |
| 93 | + } | |
| 69 | 94 | |
| 70 | - // 3. 处理业务消息 | |
| 71 | - deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); | |
| 72 | - log.debug("[handleBusinessRequest][消息处理成功,客户端 ID: {},主题: {}]", clientId, topic); | |
| 73 | - } catch (Exception e) { | |
| 74 | - log.error("[handleBusinessRequest][消息处理异常,客户端 ID: {},主题: {},错误: {}]", | |
| 75 | - clientId, topic, e.getMessage(), e); | |
| 95 | + /** | |
| 96 | + * 不处理的消息:也排在本连接之前的消息之后完成,保证确认(PUBACK / PUBREC)仍按收到的顺序 | |
| 97 | + */ | |
| 98 | + private static Future<Void> skip(IotWorkerTaskQueue queue) { | |
| 99 | + return queue.submit(() -> null); | |
| 100 | + } | |
| 101 | + | |
| 102 | + /** | |
| 103 | + * 处理标准消息(worker 线程):反序列化后发送 | |
| 104 | + */ | |
| 105 | + private void handleStandardRequest(String clientId, String topic, String productKey, String deviceName, | |
| 106 | + byte[] payload) { | |
| 107 | + // 1.1 反序列化消息 | |
| 108 | + IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); | |
| 109 | + if (message == null) { | |
| 110 | + log.warn("[handleStandardRequest][消息解码失败,客户端 ID: {},主题: {}]", clientId, topic); | |
| 111 | + return; | |
| 112 | + } | |
| 113 | + // 1.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀) | |
| 114 | + IotMqttTopicUtils.normalizeReplyMethod(message); | |
| 115 | + | |
| 116 | + // 2. 处理业务消息 | |
| 117 | + deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); | |
| 118 | + log.debug("[handleStandardRequest][消息处理成功,客户端 ID: {},主题: {}]", clientId, topic); | |
| 119 | + } | |
| 120 | + | |
| 121 | + /** | |
| 122 | + * 处理透传上报(worker 线程):原始字节按产品的数据解析脚本 decode 后发送;身份已在 event loop 上与连接比对 | |
| 123 | + * | |
| 124 | + * @param receivedNanos 收到的时刻(System.nanoTime):入队之前取得,截止时间从此刻开始计算 | |
| 125 | + */ | |
| 126 | + private void handleRawRequest(String productKey, String deviceName, byte[] payload, long receivedNanos) { | |
| 127 | + try { | |
| 128 | + deviceMessageService.sendRawMessage(payload, productKey, deviceName, serverId, IotProtocolTypeEnum.MQTT, | |
| 129 | + receivedNanos); | |
| 130 | + } catch (ServiceException e) { | |
| 131 | + log.warn("[handleRawRequest][透传上报处理失败,productKey: {},deviceName: {},数据长度: {} 字节,错误: {}]", | |
| 132 | + productKey, deviceName, payload.length, e.getMessage()); | |
| 76 | 133 | } |
| 77 | 134 | } |
| 78 | 135 | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java
| ... | ... | @@ -142,14 +142,17 @@ public class IotMqttConnectionManager { |
| 142 | 142 | } |
| 143 | 143 | |
| 144 | 144 | /** |
| 145 | - * 发送消息到设备 | |
| 145 | + * 发送消息到设备(标准下行、透传下行共用) | |
| 146 | + * <p> | |
| 147 | + * 不等待写出完成:返回 true 只表示 publish 已提交(设备在线、没有同步异常),不代表已写出或设备已收到; | |
| 148 | + * 之后的写出失败(例如连接已关闭)只记日志 | |
| 146 | 149 | * |
| 147 | 150 | * @param deviceId 设备 ID |
| 148 | 151 | * @param topic 主题 |
| 149 | 152 | * @param payload 消息内容 |
| 150 | 153 | * @param qos 服务质量 |
| 151 | 154 | * @param retain 是否保留消息 |
| 152 | - * @return 是否发送成功 | |
| 155 | + * @return 是否已提交发送 | |
| 153 | 156 | */ |
| 154 | 157 | public boolean sendToDevice(Long deviceId, String topic, byte[] payload, int qos, boolean retain) { |
| 155 | 158 | MqttEndpoint endpoint = deviceEndpointMap.get(deviceId); |
| ... | ... | @@ -159,7 +162,9 @@ public class IotMqttConnectionManager { |
| 159 | 162 | } |
| 160 | 163 | |
| 161 | 164 | try { |
| 162 | - endpoint.publish(topic, Buffer.buffer(payload), MqttQoS.valueOf(qos), false, retain); | |
| 165 | + endpoint.publish(topic, Buffer.buffer(payload), MqttQoS.valueOf(qos), false, retain) | |
| 166 | + .onFailure(e -> log.warn("[sendToDevice][发送消息失败,设备 ID: {},主题: {},错误: {}]", | |
| 167 | + deviceId, topic, e.getMessage())); | |
| 163 | 168 | log.debug("[sendToDevice][发送消息成功,设备 ID: {},主题: {},QoS: {}]", deviceId, topic, qos); |
| 164 | 169 | return true; |
| 165 | 170 | } catch (Exception e) { | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpProtocol.java
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandler.java
| ... | ... | @@ -6,10 +6,16 @@ import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 6 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodec; |
| 7 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; |
| 8 | 8 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; |
| 9 | +import io.vertx.core.Future; | |
| 9 | 10 | import io.vertx.core.buffer.Buffer; |
| 11 | +import io.vertx.core.net.NetSocket; | |
| 10 | 12 | import lombok.RequiredArgsConstructor; |
| 11 | 13 | import lombok.extern.slf4j.Slf4j; |
| 12 | 14 | |
| 15 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 16 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 17 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.RAW_NOT_SUPPORTED; | |
| 18 | + | |
| 13 | 19 | /** |
| 14 | 20 | * IoT 网关 TCP 下行消息处理器 |
| 15 | 21 | * |
| ... | ... | @@ -71,4 +77,40 @@ public class IotTcpDownstreamHandler { |
| 71 | 77 | } |
| 72 | 78 | } |
| 73 | 79 | |
| 80 | + /** | |
| 81 | + * 处理透传下行(raw.push 或编码脚本的输出):原始字节按 codec 加帧后写出 | |
| 82 | + * <p> | |
| 83 | + * 只有认证时声明了 mode=raw 的透传连接才支持;普通 JSON / 二进制连接返回 RAW_NOT_SUPPORTED | |
| 84 | + * | |
| 85 | + * @param message 下行消息 | |
| 86 | + * @param bytes 要写出的原始字节 | |
| 87 | + * @return 真实的写出结果 | |
| 88 | + */ | |
| 89 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes) { | |
| 90 | + // 1. 检查设备连接:之后只写这个检查过的连接(期间设备换成新连接时写出失败,不写到未校验的新连接上) | |
| 91 | + NetSocket socket = connectionManager.getSocketByDeviceId(message.getDeviceId()); | |
| 92 | + IotTcpConnectionManager.ConnectionInfo connectionInfo = socket != null ? connectionManager.getConnectionInfo(socket) : null; | |
| 93 | + if (connectionInfo == null) { | |
| 94 | + log.warn("[handleRawMessage][连接信息不存在,设备 ID: {},方法: {},消息 ID: {}]", | |
| 95 | + message.getDeviceId(), message.getMethod(), message.getId()); | |
| 96 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 97 | + } | |
| 98 | + if (!Boolean.TRUE.equals(connectionInfo.getRawMode())) { | |
| 99 | + log.warn("[handleRawMessage][非透传连接,不支持透传下发,设备 ID: {},方法: {},消息 ID: {}]", | |
| 100 | + message.getDeviceId(), message.getMethod(), message.getId()); | |
| 101 | + return Future.failedFuture(exception(RAW_NOT_SUPPORTED)); | |
| 102 | + } | |
| 103 | + | |
| 104 | + // 2. 帧编码 + 发送到设备 | |
| 105 | + Buffer frameData; | |
| 106 | + try { | |
| 107 | + frameData = codec.encode(bytes); | |
| 108 | + } catch (Exception e) { | |
| 109 | + log.warn("[handleRawMessage][帧编码失败,设备 ID: {},数据长度: {} 字节,错误: {}]", | |
| 110 | + message.getDeviceId(), bytes.length, e.getMessage()); | |
| 111 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 112 | + } | |
| 113 | + return connectionManager.sendToConnectionAsync(message.getDeviceId(), socket, frameData.getBytes()); | |
| 114 | + } | |
| 115 | + | |
| 74 | 116 | } |
| 75 | 117 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamSubscriber.java
| ... | ... | @@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.handler.downstream; |
| 2 | 2 | |
| 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 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; |
| 8 | +import io.vertx.core.Future; | |
| 7 | 9 | import lombok.extern.slf4j.Slf4j; |
| 8 | 10 | |
| 9 | 11 | /** |
| ... | ... | @@ -28,4 +30,14 @@ public class IotTcpDownstreamSubscriber extends AbstractIotProtocolDownstreamSub |
| 28 | 30 | downstreamHandler.handle(message); |
| 29 | 31 | } |
| 30 | 32 | |
| 33 | + @Override | |
| 34 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 35 | + return downstreamHandler.handleRawMessage(message, bytes); | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Override | |
| 39 | + public boolean supportsRawDownstream() { | |
| 40 | + return true; | |
| 41 | + } | |
| 42 | + | |
| 31 | 43 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/upstream/IotTcpUpstreamHandler.java
| ... | ... | @@ -9,6 +9,7 @@ import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; |
| 9 | 9 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; |
| 10 | 10 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; |
| 11 | 11 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 12 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 12 | 13 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 13 | 14 | import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; |
| 14 | 15 | import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO; |
| ... | ... | @@ -19,13 +20,18 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnection |
| 19 | 20 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; |
| 20 | 21 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 21 | 22 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 23 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 22 | 24 | import io.vertx.core.Handler; |
| 25 | +import io.vertx.core.Vertx; | |
| 23 | 26 | import io.vertx.core.buffer.Buffer; |
| 24 | 27 | import io.vertx.core.net.NetSocket; |
| 25 | 28 | import io.vertx.core.parsetools.RecordParser; |
| 26 | 29 | import lombok.extern.slf4j.Slf4j; |
| 27 | 30 | import cn.hutool.core.lang.Assert; |
| 28 | 31 | |
| 32 | +import java.util.Map; | |
| 33 | +import java.util.concurrent.RejectedExecutionException; | |
| 34 | + | |
| 29 | 35 | import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*; |
| 30 | 36 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 31 | 37 | import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; |
| ... | ... | @@ -40,6 +46,15 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 40 | 46 | |
| 41 | 47 | private static final String AUTH_METHOD = "auth"; |
| 42 | 48 | |
| 49 | + /** | |
| 50 | + * 认证参数中的连接模式 Key | |
| 51 | + */ | |
| 52 | + private static final String PARAM_KEY_MODE = "mode"; | |
| 53 | + /** | |
| 54 | + * 连接模式:透传(认证之后的每一帧都是原始字节) | |
| 55 | + */ | |
| 56 | + private static final String MODE_RAW = "raw"; | |
| 57 | + | |
| 43 | 58 | private final String serverId; |
| 44 | 59 | |
| 45 | 60 | /** |
| ... | ... | @@ -82,19 +97,21 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 82 | 97 | log.debug("[handle][设备连接,地址: {}]", remoteAddress); |
| 83 | 98 | |
| 84 | 99 | // 1. 设置异常和关闭处理器 |
| 100 | + // 本连接的 worker 队列:业务消息、透传帧的发送会阻塞,按收到的顺序在 worker 上执行;离线消息排在它们之后 | |
| 101 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(Vertx.currentContext()); | |
| 85 | 102 | socket.exceptionHandler(ex -> { |
| 86 | 103 | log.warn("[handle][连接异常,地址: {}]", remoteAddress, ex); |
| 87 | 104 | socket.close(); |
| 88 | 105 | }); |
| 89 | 106 | socket.closeHandler(v -> { |
| 90 | 107 | log.debug("[handle][连接关闭,地址: {}]", remoteAddress); |
| 91 | - cleanupConnection(socket); | |
| 108 | + cleanupConnection(socket, queue); | |
| 92 | 109 | }); |
| 93 | 110 | |
| 94 | 111 | // 2.1 设置消息处理器 |
| 95 | 112 | Handler<Buffer> messageHandler = buffer -> { |
| 96 | 113 | try { |
| 97 | - processMessage(buffer, socket); | |
| 114 | + processMessage(buffer, socket, queue); | |
| 98 | 115 | } catch (Exception e) { |
| 99 | 116 | log.error("[handle][消息处理失败,地址: {}]", remoteAddress, e); |
| 100 | 117 | socket.close(); |
| ... | ... | @@ -111,8 +128,16 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 111 | 128 | * |
| 112 | 129 | * @param buffer 消息 |
| 113 | 130 | * @param socket 网络连接 |
| 131 | + * @param queue 本连接的 worker 队列 | |
| 114 | 132 | */ |
| 115 | - private void processMessage(Buffer buffer, NetSocket socket) { | |
| 133 | + private void processMessage(Buffer buffer, NetSocket socket, IotWorkerTaskQueue queue) { | |
| 134 | + // 0. 已认证、且认证时声明了 mode=raw 的连接:每一帧都是原始字节(帧仍由 codec 切分),不再反序列化 | |
| 135 | + IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); | |
| 136 | + if (connectionInfo != null && Boolean.TRUE.equals(connectionInfo.getRawMode())) { | |
| 137 | + handleRawRequest(buffer.getBytes(), connectionInfo, socket, queue); | |
| 138 | + return; | |
| 139 | + } | |
| 140 | + | |
| 116 | 141 | IotDeviceMessage message = null; |
| 117 | 142 | try { |
| 118 | 143 | // 1. 反序列化消息 |
| ... | ... | @@ -131,7 +156,7 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 131 | 156 | handleRegisterRequest(message, socket); |
| 132 | 157 | } else { |
| 133 | 158 | // 业务消息 |
| 134 | - handleBusinessRequest(message, socket); | |
| 159 | + handleBusinessRequest(message, socket, queue); | |
| 135 | 160 | } |
| 136 | 161 | } catch (ServiceException e) { |
| 137 | 162 | // 业务异常,返回对应的错误码和错误信息 |
| ... | ... | @@ -183,8 +208,8 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 183 | 208 | IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), deviceInfo.getDeviceName()); |
| 184 | 209 | Assert.notNull(device, "设备不存在"); |
| 185 | 210 | |
| 186 | - // 3.1 注册连接 | |
| 187 | - registerConnection(socket, device); | |
| 211 | + // 3.1 注册连接(params.mode = raw 时为透传连接) | |
| 212 | + registerConnection(socket, device, isRawMode(message.getParams())); | |
| 188 | 213 | // 3.2 发送上线消息 |
| 189 | 214 | sendOnlineMessage(device); |
| 190 | 215 | // 3.3 发送成功响应 |
| ... | ... | @@ -220,12 +245,13 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 220 | 245 | } |
| 221 | 246 | |
| 222 | 247 | /** |
| 223 | - * 处理业务请求 | |
| 248 | + * 处理业务请求:认证检查在 event loop 上;获取设备、发送总线会阻塞,交给本连接的 worker 队列按收到的顺序执行 | |
| 224 | 249 | * |
| 225 | 250 | * @param message 消息信息 |
| 226 | 251 | * @param socket 网络连接 |
| 252 | + * @param queue 本连接的 worker 队列 | |
| 227 | 253 | */ |
| 228 | - private void handleBusinessRequest(IotDeviceMessage message, NetSocket socket) { | |
| 254 | + private void handleBusinessRequest(IotDeviceMessage message, NetSocket socket, IotWorkerTaskQueue queue) { | |
| 229 | 255 | // 1. 获取认证信息并处理业务消息 |
| 230 | 256 | IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); |
| 231 | 257 | if (connectionInfo == null) { |
| ... | ... | @@ -235,10 +261,73 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 235 | 261 | return; |
| 236 | 262 | } |
| 237 | 263 | |
| 238 | - // 2. 发送消息到消息总线 | |
| 239 | - deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), | |
| 240 | - connectionInfo.getDeviceName(), serverId); | |
| 241 | - log.info("[handleBusinessRequest][发送消息到消息总线,地址: {},消息: {}]", socket.remoteAddress(), message); | |
| 264 | + // 2. 发送消息到消息总线(worker):成功不应答(与原来一致);失败回到 event loop 应答错误 | |
| 265 | + queue.submit(() -> { | |
| 266 | + deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), | |
| 267 | + connectionInfo.getDeviceName(), serverId); | |
| 268 | + return null; | |
| 269 | + }).onComplete(ar -> { | |
| 270 | + if (ar.succeeded()) { | |
| 271 | + log.info("[handleBusinessRequest][发送消息到消息总线,地址: {},消息: {}]", socket.remoteAddress(), message); | |
| 272 | + return; | |
| 273 | + } | |
| 274 | + handleBusinessFailure(message, socket, ar.cause()); | |
| 275 | + }); | |
| 276 | + } | |
| 277 | + | |
| 278 | + /** | |
| 279 | + * 业务消息在 worker 上处理失败(event loop 上执行):与同步处理时的异常应答一致 | |
| 280 | + * | |
| 281 | + * @param message 消息信息 | |
| 282 | + * @param socket 网络连接 | |
| 283 | + * @param e 异常 | |
| 284 | + */ | |
| 285 | + private void handleBusinessFailure(IotDeviceMessage message, NetSocket socket, Throwable e) { | |
| 286 | + if (e instanceof RejectedExecutionException) { // 队列已满:断开连接,由设备重连 | |
| 287 | + log.error("[handleBusinessFailure][业务消息积压,断开连接,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); | |
| 288 | + socket.close(); | |
| 289 | + return; | |
| 290 | + } | |
| 291 | + if (e instanceof ServiceException) { | |
| 292 | + log.warn("[handleBusinessFailure][业务异常,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); | |
| 293 | + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), | |
| 294 | + ((ServiceException) e).getCode(), e.getMessage()); | |
| 295 | + return; | |
| 296 | + } | |
| 297 | + if (e instanceof IllegalArgumentException) { | |
| 298 | + log.warn("[handleBusinessFailure][参数校验失败,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); | |
| 299 | + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), BAD_REQUEST.getCode(), e.getMessage()); | |
| 300 | + return; | |
| 301 | + } | |
| 302 | + // 其他异常:返回 500,并断开连接 | |
| 303 | + log.error("[handleBusinessFailure][处理消息失败,地址: {}]", socket.remoteAddress(), e); | |
| 304 | + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), | |
| 305 | + INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg()); | |
| 306 | + socket.close(); | |
| 307 | + } | |
| 308 | + | |
| 309 | + /** | |
| 310 | + * 处理透传上报:原始字节按产品的数据解析脚本 decode 后发送;会阻塞,交给本连接的 worker 队列按顺序执行 | |
| 311 | + * | |
| 312 | + * @param payload 原始字节(一帧) | |
| 313 | + * @param connectionInfo 已认证的连接信息 | |
| 314 | + * @param socket 网络连接 | |
| 315 | + * @param queue 本连接的 worker 队列 | |
| 316 | + */ | |
| 317 | + private void handleRawRequest(byte[] payload, IotTcpConnectionManager.ConnectionInfo connectionInfo, | |
| 318 | + NetSocket socket, IotWorkerTaskQueue queue) { | |
| 319 | + // 截止时间从收到(入队之前)开始计算 | |
| 320 | + long receivedNanos = System.nanoTime(); | |
| 321 | + queue.submit(() -> deviceMessageService.sendRawMessage(payload, connectionInfo.getProductKey(), | |
| 322 | + connectionInfo.getDeviceName(), serverId, IotProtocolTypeEnum.TCP, receivedNanos)).onFailure(e -> { | |
| 323 | + if (e instanceof RejectedExecutionException) { // 队列已满:断开连接,由设备重连 | |
| 324 | + log.error("[handleRawRequest][透传上报积压,断开连接,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); | |
| 325 | + socket.close(); | |
| 326 | + return; | |
| 327 | + } | |
| 328 | + log.warn("[handleRawRequest][透传上报处理失败,地址: {},数据长度: {} 字节,错误: {}]", | |
| 329 | + socket.remoteAddress(), payload.length, e.getMessage()); | |
| 330 | + }); | |
| 242 | 331 | } |
| 243 | 332 | |
| 244 | 333 | /** |
| ... | ... | @@ -246,16 +335,28 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 246 | 335 | * |
| 247 | 336 | * @param socket 网络连接 |
| 248 | 337 | * @param device 设备 |
| 338 | + * @param rawMode 是否为透传连接 | |
| 249 | 339 | */ |
| 250 | - private void registerConnection(NetSocket socket, IotDeviceRespDTO device) { | |
| 340 | + private void registerConnection(NetSocket socket, IotDeviceRespDTO device, boolean rawMode) { | |
| 251 | 341 | IotTcpConnectionManager.ConnectionInfo connectionInfo = new IotTcpConnectionManager.ConnectionInfo() |
| 252 | 342 | .setDeviceId(device.getId()) |
| 253 | 343 | .setProductKey(device.getProductKey()) |
| 254 | - .setDeviceName(device.getDeviceName()); | |
| 344 | + .setDeviceName(device.getDeviceName()) | |
| 345 | + .setRawMode(rawMode); | |
| 255 | 346 | connectionManager.registerConnection(socket, device.getId(), connectionInfo); |
| 256 | 347 | } |
| 257 | 348 | |
| 258 | 349 | /** |
| 350 | + * 认证参数是否声明了透传模式(params.mode = raw) | |
| 351 | + * | |
| 352 | + * @param params 认证参数 | |
| 353 | + * @return 是否为透传模式 | |
| 354 | + */ | |
| 355 | + private static boolean isRawMode(Object params) { | |
| 356 | + return params instanceof Map && MODE_RAW.equals(((Map<?, ?>) params).get(PARAM_KEY_MODE)); | |
| 357 | + } | |
| 358 | + | |
| 359 | + /** | |
| 259 | 360 | * 发送设备上线消息 |
| 260 | 361 | * |
| 261 | 362 | * @param device 设备信息 |
| ... | ... | @@ -270,18 +371,31 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 270 | 371 | * 清理连接 |
| 271 | 372 | * |
| 272 | 373 | * @param socket 网络连接 |
| 374 | + * @param queue 本连接的 worker 队列 | |
| 273 | 375 | */ |
| 274 | - private void cleanupConnection(NetSocket socket) { | |
| 275 | - // 1. 发送离线消息 | |
| 376 | + private void cleanupConnection(NetSocket socket, IotWorkerTaskQueue queue) { | |
| 377 | + // 1. 注销连接 | |
| 276 | 378 | IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); |
| 277 | - if (connectionInfo != null) { | |
| 278 | - IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); | |
| 279 | - deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), | |
| 280 | - connectionInfo.getDeviceName(), serverId); | |
| 379 | + connectionManager.unregisterConnection(socket); | |
| 380 | + if (connectionInfo == null) { | |
| 381 | + return; | |
| 281 | 382 | } |
| 282 | 383 | |
| 283 | - // 2. 注销连接 | |
| 284 | - connectionManager.unregisterConnection(socket); | |
| 384 | + // 2. 发送离线消息:排在本连接已收到的业务消息、透传帧之后;执行时设备已在新连接上线,则不再发送 | |
| 385 | + queue.submitLast(() -> { | |
| 386 | + if (connectionManager.getConnectionInfoByDeviceId(connectionInfo.getDeviceId()) != null) { | |
| 387 | + return null; | |
| 388 | + } | |
| 389 | + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(), | |
| 390 | + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); | |
| 391 | + // 发送期间设备在新连接上线(它的上线消息可能先于离线消息):补发上线消息,避免在线设备被标记为离线 | |
| 392 | + if (connectionManager.getConnectionInfoByDeviceId(connectionInfo.getDeviceId()) != null) { | |
| 393 | + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline(), | |
| 394 | + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); | |
| 395 | + } | |
| 396 | + return null; | |
| 397 | + }).onFailure(e -> log.error("[cleanupConnection][发送离线消息失败,地址: {},错误: {}]", | |
| 398 | + socket.remoteAddress(), e.getMessage())); | |
| 285 | 399 | } |
| 286 | 400 | |
| 287 | 401 | // ===================== 发送响应消息 ===================== | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager; |
| 2 | 2 | |
| 3 | +import io.vertx.core.Future; | |
| 3 | 4 | import io.vertx.core.buffer.Buffer; |
| 4 | 5 | import io.vertx.core.net.NetSocket; |
| 5 | 6 | import lombok.Data; |
| ... | ... | @@ -10,6 +11,9 @@ import java.util.List; |
| 10 | 11 | import java.util.Map; |
| 11 | 12 | import java.util.concurrent.ConcurrentHashMap; |
| 12 | 13 | |
| 14 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 15 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 16 | + | |
| 13 | 17 | /** |
| 14 | 18 | * IoT 网关 TCP 连接管理器 |
| 15 | 19 | * <p> |
| ... | ... | @@ -129,6 +133,42 @@ public class IotTcpConnectionManager { |
| 129 | 133 | } |
| 130 | 134 | |
| 131 | 135 | /** |
| 136 | + * 根据设备 ID 获取当前的连接 | |
| 137 | + */ | |
| 138 | + public NetSocket getSocketByDeviceId(Long deviceId) { | |
| 139 | + return deviceSocketMap.get(deviceId); | |
| 140 | + } | |
| 141 | + | |
| 142 | + /** | |
| 143 | + * 异步发送消息到设备的指定连接:返回 socket.write 真实的写出结果(透传下发使用) | |
| 144 | + * <p> | |
| 145 | + * 只写调用方已检查过的连接(例如已确认是透传连接):设备已换成其它连接时直接失败,不改写新连接,避免把原始字节写到未校验的连接上。 | |
| 146 | + * 成功只表示本地写出完成,不代表设备已收到或已执行;设备未连接、连接已替换或写出失败时,返回 ServiceException(DEVICE_OFFLINE_OR_WRITE_FAILED) | |
| 147 | + * | |
| 148 | + * @param deviceId 设备 ID | |
| 149 | + * @param socket 已检查过的连接 | |
| 150 | + * @param data 数据(已按帧编码) | |
| 151 | + * @return 写出结果 | |
| 152 | + */ | |
| 153 | + public Future<Void> sendToConnectionAsync(Long deviceId, NetSocket socket, byte[] data) { | |
| 154 | + if (socket == null || deviceSocketMap.get(deviceId) != socket) { | |
| 155 | + log.warn("[sendToConnectionAsync][设备未连接或连接已替换,设备 ID: {}]", deviceId); | |
| 156 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 157 | + } | |
| 158 | + | |
| 159 | + try { | |
| 160 | + return socket.write(Buffer.buffer(data)) | |
| 161 | + .recover(e -> { | |
| 162 | + log.warn("[sendToConnectionAsync][发送消息失败,设备 ID: {},错误: {}]", deviceId, e.getMessage()); | |
| 163 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 164 | + }); | |
| 165 | + } catch (Exception e) { | |
| 166 | + log.error("[sendToConnectionAsync][发送消息异常,设备 ID: {}]", deviceId, e); | |
| 167 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + /** | |
| 132 | 172 | * 关闭所有连接 |
| 133 | 173 | */ |
| 134 | 174 | public void closeAll() { |
| ... | ... | @@ -164,6 +204,12 @@ public class IotTcpConnectionManager { |
| 164 | 204 | * 设备名称 |
| 165 | 205 | */ |
| 166 | 206 | private String deviceName; |
| 207 | + /** | |
| 208 | + * 是否为透传连接(认证时 params.mode = raw) | |
| 209 | + * <p> | |
| 210 | + * 透传连接认证之后的每一帧都是原始字节,按数据解析脚本解析;只有透传连接才支持透传下发 | |
| 211 | + */ | |
| 212 | + private Boolean rawMode; | |
| 167 | 213 | |
| 168 | 214 | } |
| 169 | 215 | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocol.java
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamHandler.java
| ... | ... | @@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.downstream; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpProtocol; |
| 5 | +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.upstream.IotUdpUpstreamHandler; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; |
| 8 | +import io.vertx.core.Future; | |
| 7 | 9 | import io.vertx.core.datagram.DatagramSocket; |
| 8 | 10 | import lombok.RequiredArgsConstructor; |
| 9 | 11 | import lombok.extern.slf4j.Slf4j; |
| ... | ... | @@ -62,4 +64,19 @@ public class IotUdpDownstreamHandler { |
| 62 | 64 | } |
| 63 | 65 | } |
| 64 | 66 | |
| 67 | + /** | |
| 68 | + * 处理透传下行(raw.push 或编码脚本的输出):原始字节按透传下行格式包装后发送,不等待 | |
| 69 | + * <p> | |
| 70 | + * 会话不存在(或已过期)、UDP Socket 不可用时,由 {@link IotUdpSessionManager#sendToDeviceAsync} 返回失败 | |
| 71 | + * | |
| 72 | + * @param message 下行消息 | |
| 73 | + * @param bytes 要写出的原始字节 | |
| 74 | + * @return 真实的发送结果 | |
| 75 | + */ | |
| 76 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes) { | |
| 77 | + DatagramSocket socket = protocol.getUdpSocket(); | |
| 78 | + return sessionManager.sendToDeviceAsync(message.getDeviceId(), | |
| 79 | + IotUdpUpstreamHandler.encodeRawDownstream(bytes), socket); | |
| 80 | + } | |
| 81 | + | |
| 65 | 82 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamSubscriber.java
| ... | ... | @@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.downstream; |
| 2 | 2 | |
| 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 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; |
| 8 | +import io.vertx.core.Future; | |
| 7 | 9 | import lombok.extern.slf4j.Slf4j; |
| 8 | 10 | |
| 9 | 11 | /** |
| ... | ... | @@ -28,4 +30,14 @@ public class IotUdpDownstreamSubscriber extends AbstractIotProtocolDownstreamSub |
| 28 | 30 | downstreamHandler.handle(message); |
| 29 | 31 | } |
| 30 | 32 | |
| 33 | + @Override | |
| 34 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 35 | + return downstreamHandler.handleRawMessage(message, bytes); | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Override | |
| 39 | + public boolean supportsRawDownstream() { | |
| 40 | + return true; | |
| 41 | + } | |
| 42 | + | |
| 31 | 43 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandler.java
| ... | ... | @@ -13,27 +13,38 @@ import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; |
| 13 | 13 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; |
| 14 | 14 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; |
| 15 | 15 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 16 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 16 | 17 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 17 | 18 | import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; |
| 18 | 19 | import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO; |
| 19 | 20 | import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO; |
| 20 | 21 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; |
| 22 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 21 | 23 | import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager; |
| 22 | 24 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; |
| 23 | 25 | import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService; |
| 24 | 26 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 25 | 27 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 28 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 29 | +import io.vertx.core.Context; | |
| 30 | +import io.vertx.core.Vertx; | |
| 26 | 31 | import io.vertx.core.buffer.Buffer; |
| 27 | 32 | import io.vertx.core.datagram.DatagramPacket; |
| 28 | 33 | import io.vertx.core.datagram.DatagramSocket; |
| 34 | +import lombok.AllArgsConstructor; | |
| 35 | +import lombok.Getter; | |
| 29 | 36 | import lombok.extern.slf4j.Slf4j; |
| 30 | 37 | import cn.hutool.core.lang.Assert; |
| 31 | 38 | |
| 32 | 39 | import java.net.InetSocketAddress; |
| 40 | +import java.nio.charset.StandardCharsets; | |
| 41 | +import java.util.Arrays; | |
| 33 | 42 | import java.util.Map; |
| 43 | +import java.util.concurrent.RejectedExecutionException; | |
| 34 | 44 | |
| 35 | 45 | import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*; |
| 36 | 46 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 47 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; | |
| 37 | 48 | import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; |
| 38 | 49 | |
| 39 | 50 | /** |
| ... | ... | @@ -44,6 +55,12 @@ import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVIC |
| 44 | 55 | * 2. 返回 Token:服务端验证后返回 JWT token |
| 45 | 56 | * 3. 后续请求:每次请求在 params 中携带 token |
| 46 | 57 | * 4. 服务端验证:每次请求通过 IotDeviceTokenService.verifyToken() 验证 |
| 58 | + * <p> | |
| 59 | + * 透传(raw)报文按首字节区分:'{' 是 JSON,0x7E 是二进制序列化,0x49 0x52("IR")是透传: | |
| 60 | + * <ul> | |
| 61 | + * <li>上行:0x49 0x52 | 0x01 | tokenLen(u16 BE) | token(UTF-8) | payload(1..4096),每个包都携带 token,不凭地址识别设备;</li> | |
| 62 | + * <li>下行:0x49 0x52 | 0x01 | 0x0000 | payload。</li> | |
| 63 | + * </ul> | |
| 47 | 64 | * |
| 48 | 65 | * @author 芋道源码 |
| 49 | 66 | */ |
| ... | ... | @@ -53,6 +70,24 @@ public class IotUdpUpstreamHandler { |
| 53 | 70 | private static final String AUTH_METHOD = "auth"; |
| 54 | 71 | |
| 55 | 72 | /** |
| 73 | + * 透传报文魔术字:'I'、'R' | |
| 74 | + */ | |
| 75 | + private static final byte RAW_MAGIC_0 = 0x49; | |
| 76 | + private static final byte RAW_MAGIC_1 = 0x52; | |
| 77 | + /** | |
| 78 | + * 透传报文版本 | |
| 79 | + */ | |
| 80 | + private static final byte RAW_VERSION = 0x01; | |
| 81 | + /** | |
| 82 | + * 透传报文头长度:魔术字(2) + 版本(1) + tokenLen(2) | |
| 83 | + */ | |
| 84 | + private static final int RAW_HEADER_LENGTH = 5; | |
| 85 | + /** | |
| 86 | + * 业务消息、透传上报最多排队的包数:超过时丢弃 | |
| 87 | + */ | |
| 88 | + private static final int MAX_PENDING = 1024; | |
| 89 | + | |
| 90 | + /** | |
| 56 | 91 | * Token 参数 Key |
| 57 | 92 | */ |
| 58 | 93 | private static final String PARAM_KEY_TOKEN = "token"; |
| ... | ... | @@ -77,6 +112,11 @@ public class IotUdpUpstreamHandler { |
| 77 | 112 | private final IotDeviceTokenService deviceTokenService; |
| 78 | 113 | private final IotDeviceCommonApi deviceApi; |
| 79 | 114 | |
| 115 | + /** | |
| 116 | + * 业务消息、透传上报的 worker 队列(无序、有界):在包到达时,按 socket 所在的 context 创建 | |
| 117 | + */ | |
| 118 | + private IotWorkerTaskQueue queue; | |
| 119 | + | |
| 80 | 120 | public IotUdpUpstreamHandler(String serverId, |
| 81 | 121 | IotUdpSessionManager sessionManager, |
| 82 | 122 | IotMessageSerializer serializer) { |
| ... | ... | @@ -118,8 +158,14 @@ public class IotUdpUpstreamHandler { |
| 118 | 158 | if (ArrayUtil.isEmpty(buffer)) { |
| 119 | 159 | return; |
| 120 | 160 | } |
| 121 | - // 1.2 反序列化消息 | |
| 122 | - IotDeviceMessage message = serializer.deserialize(buffer.getBytes()); | |
| 161 | + // 1.2 透传报文("IR" 开头):每个包都携带 token,跳过反序列化,按数据解析脚本解析 | |
| 162 | + byte[] data = buffer.getBytes(); | |
| 163 | + if (isRawPacket(data)) { | |
| 164 | + handleRawRequest(data, senderAddress, socket); | |
| 165 | + return; | |
| 166 | + } | |
| 167 | + // 1.3 反序列化消息 | |
| 168 | + IotDeviceMessage message = serializer.deserialize(data); | |
| 123 | 169 | if (message == null) { |
| 124 | 170 | sendErrorResponse(socket, senderAddress, null, null, BAD_REQUEST.getCode(), "消息反序列化失败"); |
| 125 | 171 | return; |
| ... | ... | @@ -269,26 +315,130 @@ public class IotUdpUpstreamHandler { |
| 269 | 315 | UNAUTHORIZED.getCode(), "token 无效或已过期"); |
| 270 | 316 | return; |
| 271 | 317 | } |
| 272 | - // 1.3 获取设备详细信息 | |
| 273 | - IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), | |
| 274 | - deviceInfo.getDeviceName()); | |
| 275 | - if (device == null) { | |
| 276 | - log.warn("[handleBusinessRequest][设备不存在,来源: {},productKey: {},deviceName: {}]", | |
| 277 | - addressKey, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); | |
| 318 | + // 2. 获取设备、更新会话地址、发送消息到消息总线:会阻塞,交给 worker 执行(UDP 本身不保证顺序,包之间并行); | |
| 319 | + // 成功不应答(与原来一致),失败回到 event loop 应答错误 | |
| 320 | + Object params = body; | |
| 321 | + getQueue().submit(() -> { | |
| 322 | + // 2.1 获取设备详细信息 | |
| 323 | + IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), | |
| 324 | + deviceInfo.getDeviceName()); | |
| 325 | + if (device == null) { | |
| 326 | + log.warn("[handleBusinessRequest][设备不存在,来源: {},productKey: {},deviceName: {}]", | |
| 327 | + addressKey, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); | |
| 328 | + throw invalidParamException("设备不存在"); | |
| 329 | + } | |
| 330 | + // 2.2 更新会话地址(如有变化) | |
| 331 | + sessionManager.updateSessionAddress(device.getId(), senderAddress); | |
| 332 | + // 2.3 将 body 设置为实际的 params,发送消息到消息总线 | |
| 333 | + message.setParams(params); | |
| 334 | + deviceMessageService.sendDeviceMessage(message, device.getProductKey(), | |
| 335 | + device.getDeviceName(), serverId); | |
| 336 | + log.debug("[handleBusinessRequest][业务消息处理成功,设备 ID: {},方法: {},来源: {}]", | |
| 337 | + device.getId(), message.getMethod(), addressKey); | |
| 338 | + return null; | |
| 339 | + }).onFailure(e -> handleBusinessFailure(message, senderAddress, socket, e)); | |
| 340 | + } | |
| 341 | + | |
| 342 | + /** | |
| 343 | + * 业务消息在 worker 上处理失败(event loop 上执行):与同步处理时的异常应答一致 | |
| 344 | + * | |
| 345 | + * @param message 消息信息 | |
| 346 | + * @param senderAddress 发送者地址 | |
| 347 | + * @param socket UDP Socket | |
| 348 | + * @param e 异常 | |
| 349 | + */ | |
| 350 | + private void handleBusinessFailure(IotDeviceMessage message, InetSocketAddress senderAddress, | |
| 351 | + DatagramSocket socket, Throwable e) { | |
| 352 | + String addressKey = sessionManager.buildAddressKey(senderAddress); | |
| 353 | + if (e instanceof RejectedExecutionException) { // 队列已满:丢弃,应答稍后重试 | |
| 354 | + log.warn("[handleBusinessFailure][业务消息积压,丢弃,来源: {},错误: {}]", addressKey, e.getMessage()); | |
| 278 | 355 | sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), |
| 279 | - BAD_REQUEST.getCode(), "设备不存在"); | |
| 356 | + TOO_MANY_REQUESTS.getCode(), TOO_MANY_REQUESTS.getMsg()); | |
| 280 | 357 | return; |
| 281 | 358 | } |
| 359 | + if (e instanceof ServiceException) { | |
| 360 | + log.warn("[handleBusinessFailure][业务异常,来源: {},requestId: {},method: {},错误: {}]", | |
| 361 | + addressKey, message.getRequestId(), message.getMethod(), e.getMessage()); | |
| 362 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), | |
| 363 | + ((ServiceException) e).getCode(), e.getMessage()); | |
| 364 | + return; | |
| 365 | + } | |
| 366 | + if (e instanceof IllegalArgumentException) { | |
| 367 | + log.warn("[handleBusinessFailure][参数校验失败,来源: {},requestId: {},method: {},错误: {}]", | |
| 368 | + addressKey, message.getRequestId(), message.getMethod(), e.getMessage()); | |
| 369 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), | |
| 370 | + BAD_REQUEST.getCode(), e.getMessage()); | |
| 371 | + return; | |
| 372 | + } | |
| 373 | + log.error("[handleBusinessFailure][处理消息失败,来源: {},requestId: {},method: {}]", | |
| 374 | + addressKey, message.getRequestId(), message.getMethod(), e); | |
| 375 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), | |
| 376 | + INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg()); | |
| 377 | + } | |
| 282 | 378 | |
| 283 | - // 2. 更新会话地址(如有变化) | |
| 284 | - sessionManager.updateSessionAddress(device.getId(), senderAddress); | |
| 379 | + /** | |
| 380 | + * 处理透传上报:校验报文和 token 后,原始字节按产品的数据解析脚本 decode 后发送 | |
| 381 | + * <p> | |
| 382 | + * 报文格式不正确、token 无效或已过期、设备不存在:只记日志并丢弃(不回复 JSON 错误响应) | |
| 383 | + * | |
| 384 | + * @param data 透传报文 | |
| 385 | + * @param senderAddress 发送者地址 | |
| 386 | + * @param socket UDP Socket | |
| 387 | + */ | |
| 388 | + private void handleRawRequest(byte[] data, InetSocketAddress senderAddress, DatagramSocket socket) { | |
| 389 | + String addressKey = sessionManager.buildAddressKey(senderAddress); | |
| 390 | + // 1.1 解析透传报文 | |
| 391 | + RawPacket packet; | |
| 392 | + try { | |
| 393 | + packet = decodeRawPacket(data); | |
| 394 | + } catch (IllegalArgumentException e) { | |
| 395 | + log.warn("[handleRawRequest][透传报文格式不正确,来源: {},错误: {}]", addressKey, e.getMessage()); | |
| 396 | + return; | |
| 397 | + } | |
| 398 | + // 1.2 验证 token,获取设备信息 | |
| 399 | + IotDeviceIdentity deviceInfo; | |
| 400 | + try { | |
| 401 | + deviceInfo = deviceTokenService.verifyToken(packet.getToken()); | |
| 402 | + } catch (Exception e) { | |
| 403 | + log.warn("[handleRawRequest][token 无效或已过期,来源: {},错误: {}]", addressKey, e.getMessage()); | |
| 404 | + return; | |
| 405 | + } | |
| 406 | + if (deviceInfo == null) { | |
| 407 | + log.warn("[handleRawRequest][token 无效或已过期,来源: {}]", addressKey); | |
| 408 | + return; | |
| 409 | + } | |
| 410 | + // 2. 获取设备、更新会话地址、按产品的数据解析脚本 decode 后发送:会阻塞,交给 worker 执行(UDP 本身不保证顺序,包之间并行) | |
| 411 | + IotDeviceIdentity identity = deviceInfo; | |
| 412 | + byte[] payload = packet.getPayload(); | |
| 413 | + // 截止时间从收到(入队之前)开始计算 | |
| 414 | + long receivedNanos = System.nanoTime(); | |
| 415 | + getQueue().submit(() -> { | |
| 416 | + // 2.1 获取设备详细信息:不存在时只记日志并丢弃 | |
| 417 | + IotDeviceRespDTO device = deviceService.getDeviceFromCache(identity.getProductKey(), | |
| 418 | + identity.getDeviceName()); | |
| 419 | + if (device == null) { | |
| 420 | + log.warn("[handleRawRequest][设备不存在,来源: {},productKey: {},deviceName: {}]", | |
| 421 | + addressKey, identity.getProductKey(), identity.getDeviceName()); | |
| 422 | + return null; | |
| 423 | + } | |
| 424 | + // 2.2 更新会话地址(如有变化) | |
| 425 | + sessionManager.updateSessionAddress(device.getId(), senderAddress); | |
| 426 | + // 2.3 decode 后发送 | |
| 427 | + return deviceMessageService.sendRawMessage(payload, device.getProductKey(), | |
| 428 | + device.getDeviceName(), serverId, IotProtocolTypeEnum.UDP, receivedNanos); | |
| 429 | + }).onFailure(e -> log.warn("[handleRawRequest][透传上报处理失败,来源: {},数据长度: {} 字节,错误: {}]", | |
| 430 | + addressKey, payload.length, e.getMessage())); | |
| 431 | + } | |
| 285 | 432 | |
| 286 | - // 3. 将 body 设置为实际的 params,发送消息到消息总线 | |
| 287 | - message.setParams(body); | |
| 288 | - deviceMessageService.sendDeviceMessage(message, device.getProductKey(), | |
| 289 | - device.getDeviceName(), serverId); | |
| 290 | - log.debug("[handleBusinessRequest][业务消息处理成功,设备 ID: {},方法: {},来源: {}]", | |
| 291 | - device.getId(), message.getMethod(), addressKey); | |
| 433 | + /** | |
| 434 | + * 获取 worker 队列:按当前(socket 所在的)context 创建;协议重启后 context 变化时重建 | |
| 435 | + */ | |
| 436 | + private IotWorkerTaskQueue getQueue() { | |
| 437 | + Context context = Vertx.currentContext(); | |
| 438 | + if (queue == null || queue.getContext() != context) { | |
| 439 | + queue = new IotWorkerTaskQueue(context, false, MAX_PENDING); | |
| 440 | + } | |
| 441 | + return queue; | |
| 292 | 442 | } |
| 293 | 443 | |
| 294 | 444 | /** |
| ... | ... | @@ -373,4 +523,75 @@ public class IotUdpUpstreamHandler { |
| 373 | 523 | } |
| 374 | 524 | } |
| 375 | 525 | |
| 526 | + // ===================== 透传报文编解码 ===================== | |
| 527 | + | |
| 528 | + /** | |
| 529 | + * 是否为透传报文:以 0x49 0x52("IR")开头 | |
| 530 | + * | |
| 531 | + * @param data 数据包 | |
| 532 | + * @return 是否为透传报文 | |
| 533 | + */ | |
| 534 | + public static boolean isRawPacket(byte[] data) { | |
| 535 | + return data != null && data.length >= 2 && data[0] == RAW_MAGIC_0 && data[1] == RAW_MAGIC_1; | |
| 536 | + } | |
| 537 | + | |
| 538 | + /** | |
| 539 | + * 解码透传上行报文:0x49 0x52 | 0x01 | tokenLen(u16 BE) | token(UTF-8) | payload(1..4096) | |
| 540 | + * | |
| 541 | + * @param data 数据包 | |
| 542 | + * @return 透传报文 | |
| 543 | + * @throws IllegalArgumentException 格式或长度不正确 | |
| 544 | + */ | |
| 545 | + public static RawPacket decodeRawPacket(byte[] data) { | |
| 546 | + // 1. 校验报文头 | |
| 547 | + Assert.isTrue(isRawPacket(data), "透传报文魔术字不正确"); | |
| 548 | + Assert.isTrue(data.length >= RAW_HEADER_LENGTH, "透传报文长度不足"); | |
| 549 | + Assert.isTrue(data[2] == RAW_VERSION, "透传报文版本不支持: {}", data[2]); | |
| 550 | + // 2.1 解析 token | |
| 551 | + int tokenLength = ((data[3] & 0xFF) << 8) | (data[4] & 0xFF); | |
| 552 | + Assert.isTrue(tokenLength > 0, "透传报文缺少 token"); | |
| 553 | + Assert.isTrue(data.length > RAW_HEADER_LENGTH + tokenLength, "透传报文缺少 payload"); | |
| 554 | + String token = new String(data, RAW_HEADER_LENGTH, tokenLength, StandardCharsets.UTF_8); | |
| 555 | + // 2.2 解析 payload | |
| 556 | + int payloadLength = data.length - RAW_HEADER_LENGTH - tokenLength; | |
| 557 | + Assert.isTrue(payloadLength <= IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES, | |
| 558 | + "透传报文 payload 超过 {} 字节", IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES); | |
| 559 | + byte[] payload = Arrays.copyOfRange(data, RAW_HEADER_LENGTH + tokenLength, data.length); | |
| 560 | + return new RawPacket(token, payload); | |
| 561 | + } | |
| 562 | + | |
| 563 | + /** | |
| 564 | + * 编码透传下行报文:0x49 0x52 | 0x01 | 0x0000 | payload(下行不携带 token) | |
| 565 | + * | |
| 566 | + * @param payload 原始字节 | |
| 567 | + * @return 透传报文 | |
| 568 | + */ | |
| 569 | + public static byte[] encodeRawDownstream(byte[] payload) { | |
| 570 | + byte[] data = new byte[RAW_HEADER_LENGTH + payload.length]; | |
| 571 | + data[0] = RAW_MAGIC_0; | |
| 572 | + data[1] = RAW_MAGIC_1; | |
| 573 | + data[2] = RAW_VERSION; | |
| 574 | + // data[3]、data[4]:tokenLen = 0 | |
| 575 | + System.arraycopy(payload, 0, data, RAW_HEADER_LENGTH, payload.length); | |
| 576 | + return data; | |
| 577 | + } | |
| 578 | + | |
| 579 | + /** | |
| 580 | + * 透传上行报文 | |
| 581 | + */ | |
| 582 | + @Getter | |
| 583 | + @AllArgsConstructor | |
| 584 | + public static class RawPacket { | |
| 585 | + | |
| 586 | + /** | |
| 587 | + * 设备 token | |
| 588 | + */ | |
| 589 | + private final String token; | |
| 590 | + /** | |
| 591 | + * 原始字节 | |
| 592 | + */ | |
| 593 | + private final byte[] payload; | |
| 594 | + | |
| 595 | + } | |
| 596 | + | |
| 376 | 597 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManager.java
| ... | ... | @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager; |
| 3 | 3 | import cn.hutool.core.util.ObjUtil; |
| 4 | 4 | import com.google.common.cache.Cache; |
| 5 | 5 | import com.google.common.cache.CacheBuilder; |
| 6 | +import io.vertx.core.Future; | |
| 6 | 7 | import io.vertx.core.buffer.Buffer; |
| 7 | 8 | import io.vertx.core.datagram.DatagramSocket; |
| 8 | 9 | import lombok.Data; |
| ... | ... | @@ -12,6 +13,9 @@ import java.net.InetSocketAddress; |
| 12 | 13 | import java.util.concurrent.CompletableFuture; |
| 13 | 14 | import java.util.concurrent.TimeUnit; |
| 14 | 15 | |
| 16 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 17 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 18 | + | |
| 15 | 19 | /** |
| 16 | 20 | * IoT 网关 UDP 会话管理器 |
| 17 | 21 | * <p> |
| ... | ... | @@ -136,6 +140,41 @@ public class IotUdpSessionManager { |
| 136 | 140 | } |
| 137 | 141 | |
| 138 | 142 | /** |
| 143 | + * 异步发送消息到设备:直接返回 socket.send 的结果,不等待(透传下发使用) | |
| 144 | + * <p> | |
| 145 | + * 与 {@link #sendToDevice} 不同,不会同步等待发送结果,可以在 event loop 上调用。 | |
| 146 | + * 成功只表示数据报已交给本机发送,UDP 不保证送达;会话不存在(或已过期)、发送失败时,返回 ServiceException(DEVICE_OFFLINE_OR_WRITE_FAILED) | |
| 147 | + * | |
| 148 | + * @param deviceId 设备 ID | |
| 149 | + * @param data 数据 | |
| 150 | + * @param socket UDP Socket | |
| 151 | + * @return 发送结果 | |
| 152 | + */ | |
| 153 | + public Future<Void> sendToDeviceAsync(Long deviceId, byte[] data, DatagramSocket socket) { | |
| 154 | + SessionInfo sessionInfo = deviceSessionCache.getIfPresent(deviceId); | |
| 155 | + if (sessionInfo == null || sessionInfo.getAddress() == null) { | |
| 156 | + log.warn("[sendToDeviceAsync][设备会话不存在,设备 ID: {}]", deviceId); | |
| 157 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 158 | + } | |
| 159 | + if (socket == null) { | |
| 160 | + log.warn("[sendToDeviceAsync][UDP Socket 不可用,设备 ID: {}]", deviceId); | |
| 161 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 162 | + } | |
| 163 | + InetSocketAddress address = sessionInfo.getAddress(); | |
| 164 | + try { | |
| 165 | + return socket.send(Buffer.buffer(data), address.getPort(), address.getHostString()) | |
| 166 | + .recover(e -> { | |
| 167 | + log.warn("[sendToDeviceAsync][发送消息失败,设备 ID: {},地址: {},错误: {}]", | |
| 168 | + deviceId, buildAddressKey(address), e.getMessage()); | |
| 169 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 170 | + }); | |
| 171 | + } catch (Exception e) { | |
| 172 | + log.error("[sendToDeviceAsync][发送消息异常,设备 ID: {}]", deviceId, e); | |
| 173 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 174 | + } | |
| 175 | + } | |
| 176 | + | |
| 177 | + /** | |
| 139 | 178 | * 构建地址 Key(用于日志输出) |
| 140 | 179 | * |
| 141 | 180 | * @param address 地址 | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketProtocol.java
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamHandler.java
| ... | ... | @@ -4,6 +4,7 @@ import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 4 | 4 | import cn.hutool.core.util.StrUtil; |
| 5 | 5 | import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager; |
| 6 | 6 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; |
| 7 | +import io.vertx.core.Future; | |
| 7 | 8 | import lombok.RequiredArgsConstructor; |
| 8 | 9 | import lombok.extern.slf4j.Slf4j; |
| 9 | 10 | |
| ... | ... | @@ -53,4 +54,17 @@ public class IotWebSocketDownstreamHandler { |
| 53 | 54 | } |
| 54 | 55 | } |
| 55 | 56 | |
| 57 | + /** | |
| 58 | + * 处理透传下行(raw.push 或编码脚本的输出):原始字节以 binary 帧写出 | |
| 59 | + * <p> | |
| 60 | + * 设备未连接时,由 {@link IotWebSocketConnectionManager#sendBinaryToDeviceAsync} 返回失败 | |
| 61 | + * | |
| 62 | + * @param message 下行消息 | |
| 63 | + * @param bytes 要写出的原始字节 | |
| 64 | + * @return 真实的写出结果 | |
| 65 | + */ | |
| 66 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes) { | |
| 67 | + return connectionManager.sendBinaryToDeviceAsync(message.getDeviceId(), bytes); | |
| 68 | + } | |
| 69 | + | |
| 56 | 70 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamSubscriber.java
| ... | ... | @@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.handler.downstrea |
| 2 | 2 | |
| 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 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketProtocol; |
| 8 | +import io.vertx.core.Future; | |
| 7 | 9 | import lombok.extern.slf4j.Slf4j; |
| 8 | 10 | |
| 9 | 11 | /** |
| ... | ... | @@ -28,4 +30,14 @@ public class IotWebSocketDownstreamSubscriber extends AbstractIotProtocolDownstr |
| 28 | 30 | downstreamHandler.handle(message); |
| 29 | 31 | } |
| 30 | 32 | |
| 33 | + @Override | |
| 34 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 35 | + return downstreamHandler.handleRawMessage(message, bytes); | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Override | |
| 39 | + public boolean supportsRawDownstream() { | |
| 40 | + return true; | |
| 41 | + } | |
| 42 | + | |
| 31 | 43 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/upstream/IotWebSocketUpstreamHandler.java
| ... | ... | @@ -11,6 +11,7 @@ import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; |
| 11 | 11 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; |
| 12 | 12 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; |
| 13 | 13 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 14 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 14 | 15 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 15 | 16 | import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; |
| 16 | 17 | import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO; |
| ... | ... | @@ -20,11 +21,15 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSock |
| 20 | 21 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; |
| 21 | 22 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 22 | 23 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 24 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 23 | 25 | import io.vertx.core.Handler; |
| 26 | +import io.vertx.core.Vertx; | |
| 24 | 27 | import io.vertx.core.http.ServerWebSocket; |
| 25 | 28 | import lombok.extern.slf4j.Slf4j; |
| 26 | 29 | import cn.hutool.core.lang.Assert; |
| 27 | 30 | |
| 31 | +import java.util.concurrent.RejectedExecutionException; | |
| 32 | + | |
| 28 | 33 | import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*; |
| 29 | 34 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 30 | 35 | import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; |
| ... | ... | @@ -77,20 +82,31 @@ public class IotWebSocketUpstreamHandler implements Handler<ServerWebSocket> { |
| 77 | 82 | log.warn("[handle][连接异常,地址: {}]", remoteAddress, ex); |
| 78 | 83 | socket.close(); |
| 79 | 84 | }); |
| 85 | + // 本连接的 worker 队列:业务消息(text 帧)、透传帧(binary 帧)的发送会阻塞,按收到的顺序在 worker 上执行;离线消息排在它们之后 | |
| 86 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(Vertx.currentContext()); | |
| 80 | 87 | socket.closeHandler(v -> { |
| 81 | 88 | log.debug("[handle][连接关闭,地址: {}]", remoteAddress); |
| 82 | - cleanupConnection(socket); | |
| 89 | + cleanupConnection(socket, queue); | |
| 83 | 90 | }); |
| 84 | 91 | |
| 85 | - // 2. 设置消息处理器(仅支持文本帧) | |
| 92 | + // 2. 设置 text 帧处理器:认证、注册、标准业务消息 | |
| 86 | 93 | socket.textMessageHandler(message -> { |
| 87 | 94 | try { |
| 88 | - processMessage(StrUtil.utf8Bytes(message), socket); | |
| 95 | + processMessage(StrUtil.utf8Bytes(message), socket, queue); | |
| 89 | 96 | } catch (Exception e) { |
| 90 | 97 | log.error("[handle][消息解码失败,断开连接,地址: {},错误: {}]", remoteAddress, e.getMessage()); |
| 91 | 98 | socket.close(); |
| 92 | 99 | } |
| 93 | 100 | }); |
| 101 | + // 3. 设置 binary 帧处理器:透传上报,必须先通过 text 帧认证 | |
| 102 | + socket.binaryMessageHandler(buffer -> { | |
| 103 | + try { | |
| 104 | + processRawMessage(buffer.getBytes(), socket, remoteAddress, queue); | |
| 105 | + } catch (Exception e) { | |
| 106 | + log.error("[handle][透传消息处理失败,断开连接,地址: {},错误: {}]", remoteAddress, e.getMessage()); | |
| 107 | + socket.close(); | |
| 108 | + } | |
| 109 | + }); | |
| 94 | 110 | } |
| 95 | 111 | |
| 96 | 112 | /** |
| ... | ... | @@ -98,8 +114,9 @@ public class IotWebSocketUpstreamHandler implements Handler<ServerWebSocket> { |
| 98 | 114 | * |
| 99 | 115 | * @param payload 消息负载 |
| 100 | 116 | * @param socket WebSocket 连接 |
| 117 | + * @param queue 本连接的 worker 队列 | |
| 101 | 118 | */ |
| 102 | - private void processMessage(byte[] payload, ServerWebSocket socket) { | |
| 119 | + private void processMessage(byte[] payload, ServerWebSocket socket, IotWorkerTaskQueue queue) { | |
| 103 | 120 | IotDeviceMessage message = null; |
| 104 | 121 | try { |
| 105 | 122 | // 1.1 基础检查 |
| ... | ... | @@ -117,7 +134,7 @@ public class IotWebSocketUpstreamHandler implements Handler<ServerWebSocket> { |
| 117 | 134 | } else if (IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod().equals(message.getMethod())) { |
| 118 | 135 | handleRegisterRequest(message, socket); |
| 119 | 136 | } else { |
| 120 | - handleBusinessRequest(message, socket); | |
| 137 | + handleBusinessRequest(message, socket, queue); | |
| 121 | 138 | } |
| 122 | 139 | } catch (ServiceException e) { |
| 123 | 140 | log.warn("[processMessage][业务异常,错误: {}]", e.getMessage()); |
| ... | ... | @@ -202,12 +219,13 @@ public class IotWebSocketUpstreamHandler implements Handler<ServerWebSocket> { |
| 202 | 219 | } |
| 203 | 220 | |
| 204 | 221 | /** |
| 205 | - * 处理业务请求 | |
| 222 | + * 处理业务请求:认证检查在 event loop 上;获取设备、发送总线会阻塞,交给本连接的 worker 队列按收到的顺序执行 | |
| 206 | 223 | * |
| 207 | 224 | * @param message 消息信息 |
| 208 | 225 | * @param socket WebSocket 连接 |
| 226 | + * @param queue 本连接的 worker 队列 | |
| 209 | 227 | */ |
| 210 | - private void handleBusinessRequest(IotDeviceMessage message, ServerWebSocket socket) { | |
| 228 | + private void handleBusinessRequest(IotDeviceMessage message, ServerWebSocket socket, IotWorkerTaskQueue queue) { | |
| 211 | 229 | // 1. 获取认证信息并处理业务消息 |
| 212 | 230 | IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); |
| 213 | 231 | if (connectionInfo == null) { |
| ... | ... | @@ -217,10 +235,81 @@ public class IotWebSocketUpstreamHandler implements Handler<ServerWebSocket> { |
| 217 | 235 | return; |
| 218 | 236 | } |
| 219 | 237 | |
| 220 | - // 2. 发送消息到消息总线 | |
| 221 | - deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), | |
| 222 | - connectionInfo.getDeviceName(), serverId); | |
| 223 | - log.info("[handleBusinessRequest][发送消息到消息总线,消息: {}]", message); | |
| 238 | + // 2. 发送消息到消息总线(worker):成功不应答(与原来一致);失败回到 event loop 应答错误 | |
| 239 | + queue.submit(() -> { | |
| 240 | + deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), | |
| 241 | + connectionInfo.getDeviceName(), serverId); | |
| 242 | + return null; | |
| 243 | + }).onComplete(ar -> { | |
| 244 | + if (ar.succeeded()) { | |
| 245 | + log.info("[handleBusinessRequest][发送消息到消息总线,消息: {}]", message); | |
| 246 | + return; | |
| 247 | + } | |
| 248 | + handleBusinessFailure(message, socket, ar.cause()); | |
| 249 | + }); | |
| 250 | + } | |
| 251 | + | |
| 252 | + /** | |
| 253 | + * 业务消息在 worker 上处理失败(event loop 上执行):与同步处理时的异常应答一致 | |
| 254 | + * | |
| 255 | + * @param message 消息信息 | |
| 256 | + * @param socket WebSocket 连接 | |
| 257 | + * @param e 异常 | |
| 258 | + */ | |
| 259 | + private void handleBusinessFailure(IotDeviceMessage message, ServerWebSocket socket, Throwable e) { | |
| 260 | + if (e instanceof RejectedExecutionException) { // 队列已满:断开连接,由设备重连 | |
| 261 | + log.error("[handleBusinessFailure][业务消息积压,断开连接,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); | |
| 262 | + socket.close(); | |
| 263 | + return; | |
| 264 | + } | |
| 265 | + if (e instanceof ServiceException) { | |
| 266 | + log.warn("[handleBusinessFailure][业务异常,错误: {}]", e.getMessage()); | |
| 267 | + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), | |
| 268 | + ((ServiceException) e).getCode(), e.getMessage()); | |
| 269 | + return; | |
| 270 | + } | |
| 271 | + if (e instanceof IllegalArgumentException) { | |
| 272 | + log.warn("[handleBusinessFailure][参数校验失败,错误: {}]", e.getMessage()); | |
| 273 | + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), BAD_REQUEST.getCode(), e.getMessage()); | |
| 274 | + return; | |
| 275 | + } | |
| 276 | + // 其他异常:返回 500,并断开连接 | |
| 277 | + log.error("[handleBusinessFailure][处理消息失败]", e); | |
| 278 | + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), INTERNAL_SERVER_ERROR.getCode(), | |
| 279 | + INTERNAL_SERVER_ERROR.getMsg()); | |
| 280 | + socket.close(); | |
| 281 | + } | |
| 282 | + | |
| 283 | + /** | |
| 284 | + * 处理透传上报(binary 帧):原始字节按产品的数据解析脚本 decode 后发送;会阻塞,交给本连接的 worker 队列按顺序执行 | |
| 285 | + * | |
| 286 | + * @param payload 原始字节 | |
| 287 | + * @param socket WebSocket 连接 | |
| 288 | + * @param remoteAddress 远程地址 | |
| 289 | + * @param queue 本连接的 worker 队列 | |
| 290 | + */ | |
| 291 | + private void processRawMessage(byte[] payload, ServerWebSocket socket, String remoteAddress, | |
| 292 | + IotWorkerTaskQueue queue) { | |
| 293 | + // 1. 未认证的 binary 帧:断开连接 | |
| 294 | + IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); | |
| 295 | + if (connectionInfo == null) { | |
| 296 | + log.warn("[processRawMessage][未认证的 binary 帧,断开连接,地址: {}]", remoteAddress); | |
| 297 | + socket.close(); | |
| 298 | + return; | |
| 299 | + } | |
| 300 | + | |
| 301 | + // 2. 交给本连接的 worker 队列:截止时间从收到(入队之前)开始计算 | |
| 302 | + long receivedNanos = System.nanoTime(); | |
| 303 | + queue.submit(() -> deviceMessageService.sendRawMessage(payload, connectionInfo.getProductKey(), | |
| 304 | + connectionInfo.getDeviceName(), serverId, IotProtocolTypeEnum.WEBSOCKET, receivedNanos)).onFailure(e -> { | |
| 305 | + if (e instanceof RejectedExecutionException) { // 队列已满:断开连接,由设备重连 | |
| 306 | + log.error("[processRawMessage][透传上报积压,断开连接,地址: {},错误: {}]", remoteAddress, e.getMessage()); | |
| 307 | + socket.close(); | |
| 308 | + return; | |
| 309 | + } | |
| 310 | + log.warn("[processRawMessage][透传上报处理失败,地址: {},数据长度: {} 字节,错误: {}]", | |
| 311 | + remoteAddress, payload.length, e.getMessage()); | |
| 312 | + }); | |
| 224 | 313 | } |
| 225 | 314 | |
| 226 | 315 | /** |
| ... | ... | @@ -256,22 +345,30 @@ public class IotWebSocketUpstreamHandler implements Handler<ServerWebSocket> { |
| 256 | 345 | * 清理连接 |
| 257 | 346 | * |
| 258 | 347 | * @param socket WebSocket 连接 |
| 348 | + * @param queue 本连接的 worker 队列 | |
| 259 | 349 | */ |
| 260 | - private void cleanupConnection(ServerWebSocket socket) { | |
| 261 | - try { | |
| 262 | - // 1. 发送离线消息(如果已认证) | |
| 263 | - IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); | |
| 264 | - if (connectionInfo != null) { | |
| 265 | - IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); | |
| 266 | - deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), | |
| 267 | - connectionInfo.getDeviceName(), serverId); | |
| 268 | - } | |
| 269 | - | |
| 270 | - // 2. 注销连接 | |
| 271 | - connectionManager.unregisterConnection(socket); | |
| 272 | - } catch (Exception e) { | |
| 273 | - log.error("[cleanupConnection][清理连接失败]", e); | |
| 350 | + private void cleanupConnection(ServerWebSocket socket, IotWorkerTaskQueue queue) { | |
| 351 | + // 1. 注销连接 | |
| 352 | + IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); | |
| 353 | + connectionManager.unregisterConnection(socket); | |
| 354 | + if (connectionInfo == null) { | |
| 355 | + return; | |
| 274 | 356 | } |
| 357 | + | |
| 358 | + // 2. 发送离线消息(如果已认证):排在本连接已收到的业务消息、透传帧之后;执行时设备已在新连接上线,则不再发送 | |
| 359 | + queue.submitLast(() -> { | |
| 360 | + if (connectionManager.getConnectionInfoByDeviceId(connectionInfo.getDeviceId()) != null) { | |
| 361 | + return null; | |
| 362 | + } | |
| 363 | + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(), | |
| 364 | + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); | |
| 365 | + // 发送期间设备在新连接上线(它的上线消息可能先于离线消息):补发上线消息,避免在线设备被标记为离线 | |
| 366 | + if (connectionManager.getConnectionInfoByDeviceId(connectionInfo.getDeviceId()) != null) { | |
| 367 | + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline(), | |
| 368 | + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); | |
| 369 | + } | |
| 370 | + return null; | |
| 371 | + }).onFailure(e -> log.error("[cleanupConnection][清理连接失败]", e)); | |
| 275 | 372 | } |
| 276 | 373 | |
| 277 | 374 | // ===================== 发送响应消息 ===================== | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManager.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager; |
| 2 | 2 | |
| 3 | +import io.vertx.core.Future; | |
| 4 | +import io.vertx.core.buffer.Buffer; | |
| 3 | 5 | import io.vertx.core.http.ServerWebSocket; |
| 4 | 6 | import lombok.Data; |
| 5 | 7 | import lombok.experimental.Accessors; |
| ... | ... | @@ -10,6 +12,9 @@ import java.util.List; |
| 10 | 12 | import java.util.Map; |
| 11 | 13 | import java.util.concurrent.ConcurrentHashMap; |
| 12 | 14 | |
| 15 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 16 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 17 | + | |
| 13 | 18 | /** |
| 14 | 19 | * IoT 网关 WebSocket 连接管理器 |
| 15 | 20 | * <p> |
| ... | ... | @@ -121,6 +126,34 @@ public class IotWebSocketConnectionManager { |
| 121 | 126 | } |
| 122 | 127 | |
| 123 | 128 | /** |
| 129 | + * 异步发送 binary 消息到设备:返回 writeBinaryMessage 真实的写出结果(透传下发使用) | |
| 130 | + * <p> | |
| 131 | + * 成功只表示本地写出完成,不代表设备已收到或已执行;设备未连接或写出失败时,返回 ServiceException(DEVICE_OFFLINE_OR_WRITE_FAILED) | |
| 132 | + * | |
| 133 | + * @param deviceId 设备 ID | |
| 134 | + * @param data 原始字节 | |
| 135 | + * @return 写出结果 | |
| 136 | + */ | |
| 137 | + public Future<Void> sendBinaryToDeviceAsync(Long deviceId, byte[] data) { | |
| 138 | + ServerWebSocket socket = deviceSocketMap.get(deviceId); | |
| 139 | + if (socket == null) { | |
| 140 | + log.warn("[sendBinaryToDeviceAsync][设备未连接,设备 ID: {}]", deviceId); | |
| 141 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 142 | + } | |
| 143 | + | |
| 144 | + try { | |
| 145 | + return socket.writeBinaryMessage(Buffer.buffer(data)) | |
| 146 | + .recover(e -> { | |
| 147 | + log.warn("[sendBinaryToDeviceAsync][发送消息失败,设备 ID: {},错误: {}]", deviceId, e.getMessage()); | |
| 148 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 149 | + }); | |
| 150 | + } catch (Exception e) { | |
| 151 | + log.error("[sendBinaryToDeviceAsync][发送消息异常,设备 ID: {}]", deviceId, e); | |
| 152 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 153 | + } | |
| 154 | + } | |
| 155 | + | |
| 156 | + /** | |
| 124 | 157 | * 关闭所有连接 |
| 125 | 158 | */ |
| 126 | 159 | public void closeAll() { | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageService.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.service.device.message; |
| 2 | 2 | |
| 3 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 3 | 5 | import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; |
| 4 | 6 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 7 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO; | |
| 5 | 9 | |
| 6 | 10 | /** |
| 7 | 11 | * IoT 设备消息 Service 接口 |
| ... | ... | @@ -53,6 +57,8 @@ public interface IotDeviceMessageService { |
| 53 | 57 | |
| 54 | 58 | /** |
| 55 | 59 | * 发送消息 |
| 60 | + * <p> | |
| 61 | + * 设备经标准路径上报的消息:不接受透传方法;编号、时间由服务端生成,raw 由服务端清空(只有透传上行才可信赋值) | |
| 56 | 62 | * |
| 57 | 63 | * @param message 消息 |
| 58 | 64 | * @param productKey 产品 Key |
| ... | ... | @@ -62,4 +68,68 @@ public interface IotDeviceMessageService { |
| 62 | 68 | void sendDeviceMessage(IotDeviceMessage message, |
| 63 | 69 | String productKey, String deviceName, String serverId); |
| 64 | 70 | |
| 71 | + // DONE @AI:deadlineNanos 是不是收敛掉到 service 里?已收进来:协议只传入收到的时刻(入队之前取得), | |
| 72 | + // 截止 = 收到时刻 + gateway.script.deadline,由本方法计算;协议不再读取截止配置 | |
| 73 | + /** | |
| 74 | + * 发送透传(raw)上行:与阿里云一致,按产品的数据解析脚本 decode 为一条标准消息;只投递一条消息 | |
| 75 | + * <ul> | |
| 76 | + * <li>解析成功:投递解析出的标准消息,raw 字段带上原始字节;</li> | |
| 77 | + * <li>正常未解析(功能开关关闭、脚本停用 / 未配置、decode 返回 null): | |
| 78 | + * 投递一条 thing.raw.post,raw 字段带上原始字节;</li> | |
| 79 | + * <li>解析失败(已启用但内容为空、执行失败、超时、繁忙、输出不合法、超过截止):不投递,只记错误日志,抛出对应的错误码。</li> | |
| 80 | + * </ul> | |
| 81 | + * 截止时间 = receivedNanos + gateway.script.deadline,覆盖排队、执行脚本,直到投递。 | |
| 82 | + * 会阻塞(加载设备信息、执行脚本、发送总线):只能在 worker 线程调用,不能在 event loop 上调用 | |
| 83 | + * | |
| 84 | + * @param payload 原始字节 | |
| 85 | + * @param productKey 产品 Key | |
| 86 | + * @param deviceName 设备名称 | |
| 87 | + * @param serverId 设备连接的 serverId | |
| 88 | + * @param protocol 协议类型 | |
| 89 | + * @param receivedNanos 协议收到透传上报的时刻(System.nanoTime):在交给 worker 排队之前取得,排队的时间也计入截止 | |
| 90 | + * @return 投递的消息编号 | |
| 91 | + * @throws cn.iocoder.yudao.framework.common.exception.ServiceException 载荷长度不合法(不投递); | |
| 92 | + * 已启用但内容为空、脚本失败、超时、繁忙、输出不合法、超过截止(都不投递) | |
| 93 | + */ | |
| 94 | + String sendRawMessage(byte[] payload, String productKey, String deviceName, String serverId, | |
| 95 | + IotProtocolTypeEnum protocol, long receivedNanos); | |
| 96 | + | |
| 97 | + // DONE @AI:deadlineNanos 是不是收敛掉到 service 里?已收进来:下行订阅者只传入收到指令的时刻(按设备排队之前取得), | |
| 98 | + // 截止 = 收到时刻 + gateway.script.deadline,由本方法计算;订阅者不再读取截止配置 | |
| 99 | + /** | |
| 100 | + * 下行指令(property.set / service.invoke)按产品的数据解析脚本 encode:决定走标准路径,还是透传写出 encode 的字节; | |
| 101 | + * 由协议的下行订阅者按结果下发(本方法不写设备) | |
| 102 | + * <ul> | |
| 103 | + * <li>设备不存在(或查询失败)、产品脚本停用 / 未配置:走标准路径;</li> | |
| 104 | + * <li>不能下发(脚本已启用但内容为空、执行失败、输出不合法、没有输出、超过截止):只记日志,返回 null,不写设备,也不改走标准路径。</li> | |
| 105 | + * </ul> | |
| 106 | + * 只在功能开关开启、协议支持透传下发时调用。截止时间 = receivedNanos + gateway.script.deadline,覆盖排队、执行脚本, | |
| 107 | + * 直到开始写设备:encode 之后、返回之前再核对一次,调用方拿到结果立即下发。 | |
| 108 | + * 会阻塞(加载设备信息、执行脚本):只能在 worker 线程调用 | |
| 109 | + * | |
| 110 | + * @param message 下行指令 | |
| 111 | + * @param protocol 协议类型:encode 的入参与字节规则按它区分 | |
| 112 | + * @param receivedNanos 下行订阅者收到指令的时刻(System.nanoTime):在按设备排队之前取得,排队的时间也计入截止 | |
| 113 | + * @return encode 的结果;null 表示不下发 | |
| 114 | + */ | |
| 115 | + IotDownstreamEncodeRespDTO encodeDownstreamCommand(IotDeviceMessage message, IotProtocolTypeEnum protocol, | |
| 116 | + long receivedNanos); | |
| 117 | + | |
| 118 | + /** | |
| 119 | + * 回复透传下行(raw.push,或 encode 的指令)的设备实际响应:只投递一条回复,raw 字段带上响应字节 | |
| 120 | + * <ul> | |
| 121 | + * <li>raw.push:直接回复,code / msg 取协议判定的设备错误(没有为成功);</li> | |
| 122 | + * <li>encode 的指令:用 encode 时固定的脚本执行 decode,得到与原请求一致的一条回复;期间脚本被停用、解析失败只记日志。</li> | |
| 123 | + * </ul> | |
| 124 | + * 只用于设备的实际响应:网关判定的失败(超时、断线、写失败等)由协议只记日志,不调用本方法。 | |
| 125 | + * 会阻塞(执行脚本、发送总线):只能在 worker 线程调用 | |
| 126 | + * | |
| 127 | + * @param request 原请求 | |
| 128 | + * @param source encode 时固定的脚本;raw.push 为 null | |
| 129 | + * @param payload 设备的响应字节 | |
| 130 | + * @param deviceError 协议判定的设备错误(例如 Modbus 异常响应);正常响应为 null。只用于 raw.push 的回复 | |
| 131 | + * @param serverId 所属协议实例的 serverId | |
| 132 | + */ | |
| 133 | + void sendRawReply(IotDeviceMessage request, IotScriptSource source, byte[] payload, ErrorCode deviceError, String serverId); | |
| 134 | + | |
| 65 | 135 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImpl.java
| ... | ... | @@ -2,25 +2,44 @@ package cn.iocoder.yudao.module.iot.gateway.service.device.message; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.lang.Assert; |
| 4 | 4 | import cn.hutool.core.util.StrUtil; |
| 5 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 6 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 5 | 7 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; |
| 8 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 9 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 6 | 10 | import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; |
| 7 | 11 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 8 | 12 | import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; |
| 13 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptException; | |
| 14 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptExecutor; | |
| 15 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptUtils; | |
| 16 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 17 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 18 | +import cn.iocoder.yudao.module.iot.core.script.rhino.IotRhinoScriptExecutor; | |
| 9 | 19 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; |
| 20 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 10 | 21 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; |
| 11 | 22 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager; |
| 12 | 23 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 24 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO; | |
| 25 | +import jakarta.annotation.PostConstruct; | |
| 26 | +import jakarta.annotation.PreDestroy; | |
| 13 | 27 | import jakarta.annotation.Resource; |
| 14 | 28 | import lombok.extern.slf4j.Slf4j; |
| 15 | 29 | import org.springframework.stereotype.Service; |
| 16 | 30 | |
| 17 | 31 | import java.time.LocalDateTime; |
| 18 | 32 | |
| 33 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.SUCCESS; | |
| 19 | 34 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 20 | -import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_NOT_EXISTS; | |
| 35 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; | |
| 36 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; | |
| 21 | 37 | |
| 22 | 38 | /** |
| 23 | 39 | * IoT 设备消息 Service 实现类 |
| 40 | + * <p> | |
| 41 | + * 透传(raw)上行、下行指令的 encode、透传回复会阻塞(加载设备信息、执行脚本、发送总线), | |
| 42 | + * 由各协议在自己的 Vert.x worker 上调用;本类只做编排,不切线程,也不写设备(下行由协议的订阅者按 encode 结果下发) | |
| 24 | 43 | * |
| 25 | 44 | * @author 芋道源码 |
| 26 | 45 | */ |
| ... | ... | @@ -37,6 +56,31 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 37 | 56 | @Resource |
| 38 | 57 | private IotMessageSerializerManager messageSerializerManager; |
| 39 | 58 | |
| 59 | + @Resource | |
| 60 | + private IotGatewayProperties gatewayProperties; | |
| 61 | + | |
| 62 | + /** | |
| 63 | + * 数据解析脚本执行器:JS 线程池与计时器(执行预算兜底、卡住扫描)由它自己创建和关闭 | |
| 64 | + */ | |
| 65 | + private IotScriptExecutor scriptExecutor; | |
| 66 | + | |
| 67 | + @PostConstruct | |
| 68 | + public void init() { | |
| 69 | + IotGatewayProperties.ScriptProperties script = gatewayProperties.getScript(); | |
| 70 | + scriptExecutor = new IotRhinoScriptExecutor(new IotRhinoScriptExecutor.Options() | |
| 71 | + .setExecBudgetMillis(script.getExecBudget().toMillis()) | |
| 72 | + .setMaxInstructions(script.getMaxInstructions())); | |
| 73 | + // 功能开关关闭:不预热 JS 线程 | |
| 74 | + if (script.isEnabled()) { | |
| 75 | + scriptExecutor.warmUp(); | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + @PreDestroy | |
| 80 | + public void destroy() { | |
| 81 | + scriptExecutor.shutdown(); | |
| 82 | + } | |
| 83 | + | |
| 40 | 84 | @Override |
| 41 | 85 | public byte[] serializeDeviceMessage(IotDeviceMessage message, |
| 42 | 86 | String productKey, String deviceName) { |
| ... | ... | @@ -97,32 +141,258 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 97 | 141 | @Override |
| 98 | 142 | public void sendDeviceMessage(IotDeviceMessage message, |
| 99 | 143 | String productKey, String deviceName, String serverId) { |
| 100 | - // 1. 获取设备信息 | |
| 101 | - IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName); | |
| 102 | - if (device == null) { | |
| 103 | - throw exception(DEVICE_NOT_EXISTS, productKey, deviceName); | |
| 144 | + // 0. 标准 JSON 路径不接受透传方法:thing.raw.* 只能由网关的透传入口创建 | |
| 145 | + if (IotDeviceMessageUtils.isRawMethod(message.getMethod())) { | |
| 146 | + throw exception(DEVICE_MESSAGE_RAW_METHOD_RESERVED); | |
| 104 | 147 | } |
| 148 | + // 1. 获取设备信息 | |
| 149 | + IotDeviceRespDTO device = getDevice(productKey, deviceName); | |
| 105 | 150 | |
| 106 | - // 2. 发送消息 | |
| 107 | - appendDeviceMessage(message, device, serverId); | |
| 108 | - deviceMessageProducer.sendDeviceMessage(message); | |
| 151 | + // 2. 发送消息:raw 只能由服务端可信赋值,设备自己带上的一律清空 | |
| 152 | + message.setRaw(null); | |
| 153 | + sendDeviceMessage(message, device, serverId); | |
| 109 | 154 | } |
| 110 | 155 | |
| 111 | 156 | /** |
| 112 | - * 补充消息的后端字段 | |
| 157 | + * 发送消息:编号、时间由服务端生成,身份由可信的 device 注入;不清空 raw(调用方是网关的可信路径) | |
| 113 | 158 | * |
| 114 | 159 | * @param message 消息 |
| 115 | 160 | * @param device 设备信息 |
| 116 | 161 | * @param serverId 设备连接的 serverId |
| 117 | 162 | */ |
| 118 | - private void appendDeviceMessage(IotDeviceMessage message, | |
| 119 | - IotDeviceRespDTO device, String serverId) { | |
| 163 | + private void sendDeviceMessage(IotDeviceMessage message, IotDeviceRespDTO device, String serverId) { | |
| 120 | 164 | message.setId(IotDeviceMessageUtils.generateMessageId()).setReportTime(LocalDateTime.now()) |
| 121 | 165 | .setDeviceId(device.getId()).setTenantId(device.getTenantId()).setServerId(serverId); |
| 122 | 166 | // 特殊:如果设备没有指定 requestId,则使用 messageId |
| 123 | 167 | if (StrUtil.isEmpty(message.getRequestId())) { |
| 124 | 168 | message.setRequestId(message.getId()); |
| 125 | 169 | } |
| 170 | + deviceMessageProducer.sendDeviceMessage(message); | |
| 171 | + } | |
| 172 | + | |
| 173 | + // ========== 透传上行 ========== | |
| 174 | + | |
| 175 | + @Override | |
| 176 | + public String sendRawMessage(byte[] payload, String productKey, String deviceName, String serverId, | |
| 177 | + IotProtocolTypeEnum protocol, long receivedNanos) { | |
| 178 | + // 截止从协议收到时开始计算:排队的时间也计入 | |
| 179 | + long deadlineNanos = buildDeadlineNanos(receivedNanos); | |
| 180 | + // 1.1 校验载荷长度 | |
| 181 | + if (payload == null || payload.length < 1 || payload.length > IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES) { | |
| 182 | + throw invalidParamException("raw 载荷长度必须在 1~{} 字节之间", IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES); | |
| 183 | + } | |
| 184 | + // 1.2 获取设备信息 | |
| 185 | + IotDeviceRespDTO device = getDevice(productKey, deviceName); | |
| 186 | + String raw = IotDeviceMessageUtils.encodeRaw(payload); | |
| 187 | + // 1.3 排队已超过截止:不执行脚本,不投递,只记错误日志 | |
| 188 | + if (System.nanoTime() >= deadlineNanos) { | |
| 189 | + log.error("[sendRawMessage][设备({}/{}) 排队超过截止时间,不投递]", productKey, deviceName); | |
| 190 | + throw exception(SCRIPT_TIMEOUT); | |
| 191 | + } | |
| 192 | + | |
| 193 | + // 2.1 功能开关关闭:不执行脚本,只记录原始消息 | |
| 194 | + if (!gatewayProperties.getScript().isEnabled()) { | |
| 195 | + return sendRawPostMessage(device, serverId, raw); | |
| 196 | + } | |
| 197 | + // 2.2 产品脚本(随设备信息缓存):停用或从未配置时只记录原始消息 | |
| 198 | + if (!Boolean.TRUE.equals(device.getScriptEnabled())) { | |
| 199 | + return sendRawPostMessage(device, serverId, raw); | |
| 200 | + } | |
| 201 | + // 2.3 已启用但内容为空(配置不合法):不投递,只记错误日志 | |
| 202 | + if (StrUtil.isBlank(device.getScriptContent())) { | |
| 203 | + log.error("[sendRawMessage][设备({}/{}) 数据解析脚本已启用但内容为空,不投递]", productKey, deviceName); | |
| 204 | + throw exception(SCRIPT_EXECUTE_FAILED, "已启用但脚本内容为空"); | |
| 205 | + } | |
| 206 | + | |
| 207 | + // 3.1 执行 decode:失败时不投递,只记错误日志 | |
| 208 | + IotScriptSource source = IotScriptSource.of(device.getProductId(), device.getScriptContent()); | |
| 209 | + String ctx = IotScriptUtils.buildDecodeContext(protocol.getType(), device.getProductKey(), | |
| 210 | + device.getDeviceName(), System.currentTimeMillis(), payload, null); | |
| 211 | + IotScriptResult result = scriptExecutor.execute(source, IotScriptUtils.FUNCTION_DECODE, ctx, deadlineNanos); | |
| 212 | + if (!result.isSuccess()) { | |
| 213 | + log.error("[sendRawMessage][设备({}/{}) 脚本执行失败({}):{},不投递]", productKey, deviceName, | |
| 214 | + result.getStatus(), result.getMessage()); | |
| 215 | + throw scriptException(result.getStatus(), result.getMessage()); | |
| 216 | + } | |
| 217 | + // 3.2 校验输出:不合法时不投递,只记错误日志 | |
| 218 | + IotDeviceMessage message; | |
| 219 | + try { | |
| 220 | + message = IotScriptUtils.validateDecode(result.getJson(), null); | |
| 221 | + } catch (IotScriptException e) { | |
| 222 | + log.error("[sendRawMessage][设备({}/{}) 脚本输出不合法:{},不投递]", productKey, deviceName, e.getMessage()); | |
| 223 | + throw exception(SCRIPT_OUTPUT_INVALID, StrUtil.maxLength(e.getMessage(), 200)); | |
| 224 | + } | |
| 225 | + // 3.3 投递之前核对截止:迟到的结果(包括 decode 返回 null)不投递,只记错误日志 | |
| 226 | + if (System.nanoTime() >= deadlineNanos) { | |
| 227 | + log.error("[sendRawMessage][设备({}/{}) 脚本完成时已超过截止时间,不投递]", productKey, deviceName); | |
| 228 | + throw exception(SCRIPT_TIMEOUT); | |
| 229 | + } | |
| 230 | + // 3.4 decode 返回 null:没有业务消息,只记录原始消息 | |
| 231 | + if (message == null) { | |
| 232 | + return sendRawPostMessage(device, serverId, raw); | |
| 233 | + } | |
| 234 | + | |
| 235 | + // 4. 投递解析出的业务消息,带上原始字节 | |
| 236 | + message.setRaw(raw); | |
| 237 | + sendDeviceMessage(message, device, serverId); | |
| 238 | + return message.getId(); | |
| 239 | + } | |
| 240 | + | |
| 241 | + /** | |
| 242 | + * 投递一条没有解析为业务消息的原始消息 thing.raw.post:只带原始帧 | |
| 243 | + * | |
| 244 | + * @return 消息编号 | |
| 245 | + */ | |
| 246 | + private String sendRawPostMessage(IotDeviceRespDTO device, String serverId, String raw) { | |
| 247 | + IotDeviceMessage message = IotDeviceMessage.of(null, IotDeviceMessageMethodEnum.RAW_POST.getMethod(), | |
| 248 | + null, null, null, null).setRaw(raw); | |
| 249 | + sendDeviceMessage(message, device, serverId); | |
| 250 | + return message.getId(); | |
| 251 | + } | |
| 252 | + | |
| 253 | + // ========== 下行指令 ========== | |
| 254 | + | |
| 255 | + @Override | |
| 256 | + public IotDownstreamEncodeRespDTO encodeDownstreamCommand(IotDeviceMessage message, IotProtocolTypeEnum protocol, | |
| 257 | + long receivedNanos) { | |
| 258 | + // 截止从订阅者收到指令时开始计算:排队的时间也计入 | |
| 259 | + long deadlineNanos = buildDeadlineNanos(receivedNanos); | |
| 260 | + // 1.1 获取设备:不存在(或查询失败)时与未启用脚本一致,走标准路径 | |
| 261 | + IotDeviceRespDTO device = getDeviceQuietly(message.getDeviceId()); | |
| 262 | + if (device == null) { | |
| 263 | + return IotDownstreamEncodeRespDTO.standard(); | |
| 264 | + } | |
| 265 | + // 1.2 产品脚本(随设备信息缓存):停用或从未配置,与未启用脚本一致;已启用但内容为空,不写设备,也不改发 JSON | |
| 266 | + if (!Boolean.TRUE.equals(device.getScriptEnabled())) { | |
| 267 | + return IotDownstreamEncodeRespDTO.standard(); | |
| 268 | + } | |
| 269 | + if (StrUtil.isBlank(device.getScriptContent())) { | |
| 270 | + log.warn("[encodeDownstreamCommand][下行({}) 数据解析脚本已启用但内容为空,不写设备]", message.getRequestId()); | |
| 271 | + return null; | |
| 272 | + } | |
| 273 | + | |
| 274 | + // 2.1 执行 encode | |
| 275 | + IotScriptSource source = IotScriptSource.of(device.getProductId(), device.getScriptContent()); | |
| 276 | + String ctx = IotScriptUtils.buildEncodeContext(protocol.getType(), device.getProductKey(), | |
| 277 | + device.getDeviceName(), message); | |
| 278 | + IotScriptResult result = scriptExecutor.execute(source, IotScriptUtils.FUNCTION_ENCODE, ctx, deadlineNanos); | |
| 279 | + if (!result.isSuccess()) { | |
| 280 | + log.warn("[encodeDownstreamCommand][下行({}) 脚本执行失败({}):{}]", message.getRequestId(), | |
| 281 | + result.getStatus(), result.getMessage()); | |
| 282 | + return null; | |
| 283 | + } | |
| 284 | + // 2.2 校验输出:结构,以及字节是否符合协议的规则(Modbus 必须是合法的请求 PDU) | |
| 285 | + IotScriptUtils.EncodeOutput output; | |
| 286 | + try { | |
| 287 | + output = IotScriptUtils.validateEncode(result.getJson(), protocol); | |
| 288 | + } catch (IotScriptException e) { | |
| 289 | + log.warn("[encodeDownstreamCommand][下行({}) 脚本输出不合法:{}]", message.getRequestId(), e.getMessage()); | |
| 290 | + return null; | |
| 291 | + } | |
| 292 | + if (output == null) { | |
| 293 | + log.warn("[encodeDownstreamCommand][下行({}) 脚本未生成下行数据,不写设备]", message.getRequestId()); | |
| 294 | + return null; | |
| 295 | + } | |
| 296 | + | |
| 297 | + // 3. 下发之前核对截止:迟到的结果不写设备,也不改走标准路径 | |
| 298 | + if (System.nanoTime() >= deadlineNanos) { | |
| 299 | + log.warn("[encodeDownstreamCommand][下行({}) 超过截止,不写设备]", message.getRequestId()); | |
| 300 | + return null; | |
| 301 | + } | |
| 302 | + return output.isStandard() ? IotDownstreamEncodeRespDTO.standard() | |
| 303 | + : IotDownstreamEncodeRespDTO.raw(output.getBytes(), source); | |
| 304 | + } | |
| 305 | + | |
| 306 | + // ========== 透传回复 ========== | |
| 307 | + | |
| 308 | + @Override | |
| 309 | + public void sendRawReply(IotDeviceMessage request, IotScriptSource source, byte[] payload, ErrorCode deviceError, String serverId) { | |
| 310 | + IotDeviceRespDTO device = deviceService.getDeviceFromCache(request.getDeviceId()); | |
| 311 | + if (device == null) { | |
| 312 | + log.warn("[sendRawReply][设备({}) 不存在]", request.getDeviceId()); | |
| 313 | + return; | |
| 314 | + } | |
| 315 | + // 情况一:raw.push,直接回复设备的实际响应;结果码由协议判定(例如 Modbus 异常响应) | |
| 316 | + if (source == null) { | |
| 317 | + ErrorCode code = deviceError != null ? deviceError : SUCCESS; | |
| 318 | + IotDeviceMessage reply = IotDeviceMessage.replyOf(request.getRequestId(), request.getMethod(), null, | |
| 319 | + code.getCode(), code.getMsg()).setRaw(IotDeviceMessageUtils.encodeRaw(payload)); | |
| 320 | + sendDeviceMessage(reply, device, serverId); | |
| 321 | + return; | |
| 322 | + } | |
| 323 | + | |
| 324 | + // 情况二:encode 的指令,用 encode 时固定的脚本执行 decode;功能开关已关闭,或设备缓存里已看到脚本停用时,不解析响应 | |
| 325 | + if (!gatewayProperties.getScript().isEnabled()) { | |
| 326 | + log.warn("[sendRawReply][下行({}) 期间数据解析脚本功能已关闭,不解析响应]", request.getRequestId()); | |
| 327 | + return; | |
| 328 | + } | |
| 329 | + if (!Boolean.TRUE.equals(device.getScriptEnabled())) { | |
| 330 | + log.warn("[sendRawReply][下行({}) 期间数据解析脚本已停用,不解析响应]", request.getRequestId()); | |
| 331 | + return; | |
| 332 | + } | |
| 333 | + long deadlineNanos = buildDeadlineNanos(System.nanoTime()); | |
| 334 | + String ctx = IotScriptUtils.buildDecodeContext(device.getProtocolType(), device.getProductKey(), | |
| 335 | + device.getDeviceName(), System.currentTimeMillis(), payload, request); | |
| 336 | + IotScriptResult result = scriptExecutor.execute(source, IotScriptUtils.FUNCTION_DECODE, ctx, deadlineNanos); | |
| 337 | + if (!result.isSuccess()) { | |
| 338 | + log.warn("[sendRawReply][下行({}) 脚本执行失败({}):{}]", request.getRequestId(), | |
| 339 | + result.getStatus(), result.getMessage()); | |
| 340 | + return; | |
| 341 | + } | |
| 342 | + IotDeviceMessage reply; | |
| 343 | + try { | |
| 344 | + reply = IotScriptUtils.validateDecode(result.getJson(), request); | |
| 345 | + } catch (IotScriptException e) { | |
| 346 | + log.warn("[sendRawReply][下行({}) 脚本输出不合法:{}]", request.getRequestId(), e.getMessage()); | |
| 347 | + return; | |
| 348 | + } | |
| 349 | + reply.setRaw(IotDeviceMessageUtils.encodeRaw(payload)); | |
| 350 | + sendDeviceMessage(reply, device, serverId); | |
| 351 | + } | |
| 352 | + | |
| 353 | + // ========== 工具方法 ========== | |
| 354 | + | |
| 355 | + /** | |
| 356 | + * 计算截止时间(System.nanoTime):开始时刻 + gateway.script.deadline | |
| 357 | + * | |
| 358 | + * @param startNanos 开始计时的时刻(System.nanoTime) | |
| 359 | + */ | |
| 360 | + private long buildDeadlineNanos(long startNanos) { | |
| 361 | + return startNanos + gatewayProperties.getScript().getDeadline().toNanos(); | |
| 362 | + } | |
| 363 | + | |
| 364 | + private IotDeviceRespDTO getDevice(String productKey, String deviceName) { | |
| 365 | + IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName); | |
| 366 | + if (device == null) { | |
| 367 | + throw exception(DEVICE_NOT_EXISTS, productKey, deviceName); | |
| 368 | + } | |
| 369 | + return device; | |
| 370 | + } | |
| 371 | + | |
| 372 | + /** | |
| 373 | + * 下行获取设备:查询失败时按设备不存在处理(走标准路径,由标准路径自己处理),不丢弃指令 | |
| 374 | + */ | |
| 375 | + private IotDeviceRespDTO getDeviceQuietly(Long deviceId) { | |
| 376 | + try { | |
| 377 | + return deviceService.getDeviceFromCache(deviceId); | |
| 378 | + } catch (RuntimeException e) { | |
| 379 | + log.warn("[getDeviceQuietly][设备({}) 查询失败,按标准路径下发]", deviceId, e); | |
| 380 | + return null; | |
| 381 | + } | |
| 382 | + } | |
| 383 | + | |
| 384 | + private static ServiceException scriptException(IotScriptResult.Status status, String detail) { | |
| 385 | + detail = StrUtil.maxLength(StrUtil.nullToEmpty(detail), 200); | |
| 386 | + switch (status) { | |
| 387 | + case TIMEOUT: | |
| 388 | + return exception(SCRIPT_TIMEOUT); | |
| 389 | + case REJECTED: | |
| 390 | + return exception(SCRIPT_REJECTED); | |
| 391 | + case OUTPUT_INVALID: | |
| 392 | + return exception(SCRIPT_OUTPUT_INVALID, detail); | |
| 393 | + default: | |
| 394 | + return exception(SCRIPT_EXECUTE_FAILED, detail); | |
| 395 | + } | |
| 126 | 396 | } |
| 127 | 397 | |
| 128 | 398 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/dto/IotDownstreamEncodeRespDTO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.service.device.message.dto; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 4 | +import lombok.AccessLevel; | |
| 5 | +import lombok.AllArgsConstructor; | |
| 6 | +import lombok.Getter; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * 下行指令按数据解析脚本 encode 的结果:走标准路径,或者透传写出 encode 的字节;由协议的下行订阅者执行 | |
| 10 | + * | |
| 11 | + * @author 芋道源码 | |
| 12 | + */ | |
| 13 | +@Getter | |
| 14 | +@AllArgsConstructor(access = AccessLevel.PRIVATE) | |
| 15 | +public class IotDownstreamEncodeRespDTO { | |
| 16 | + | |
| 17 | + private static final IotDownstreamEncodeRespDTO STANDARD = new IotDownstreamEncodeRespDTO(true, null, null); | |
| 18 | + | |
| 19 | + /** | |
| 20 | + * 是否走标准路径:设备不存在、产品脚本停用 / 未配置,或 encode 返回 standard | |
| 21 | + */ | |
| 22 | + private final boolean standard; | |
| 23 | + /** | |
| 24 | + * 透传写出的字节;走标准路径时为 null | |
| 25 | + */ | |
| 26 | + private final byte[] bytes; | |
| 27 | + /** | |
| 28 | + * encode 时使用的脚本(固定 hash):协议回复设备的实际响应时用它 decode;走标准路径时为 null | |
| 29 | + */ | |
| 30 | + private final IotScriptSource source; | |
| 31 | + | |
| 32 | + public static IotDownstreamEncodeRespDTO standard() { | |
| 33 | + return STANDARD; | |
| 34 | + } | |
| 35 | + | |
| 36 | + public static IotDownstreamEncodeRespDTO raw(byte[] bytes, IotScriptSource source) { | |
| 37 | + return new IotDownstreamEncodeRespDTO(false, bytes, source); | |
| 38 | + } | |
| 39 | + | |
| 40 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java
| ... | ... | @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.iot.gateway.util; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.util.StrUtil; |
| 4 | 4 | import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; |
| 5 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 6 | 7 | |
| 7 | 8 | /** |
| ... | ... | @@ -141,4 +142,53 @@ public final class IotMqttTopicUtils { |
| 141 | 142 | return topic.startsWith(deviceTopicPrefix); |
| 142 | 143 | } |
| 143 | 144 | |
| 145 | + // ========== 透传主题 ========== | |
| 146 | + | |
| 147 | + /** | |
| 148 | + * 是否为透传上报主题:/sys/{productKey}/{deviceName}/thing/raw/post | |
| 149 | + * <p> | |
| 150 | + * 只判断主题格式;设备身份由调用方与已认证的连接(或 EMQX ACL)比对 | |
| 151 | + * | |
| 152 | + * @param topic 主题 | |
| 153 | + * @return 是否为透传上报主题 | |
| 154 | + */ | |
| 155 | + public static boolean isRawPostTopic(String topic) { | |
| 156 | + return isRawTopic(topic, IotDeviceMessageMethodEnum.RAW_POST); | |
| 157 | + } | |
| 158 | + | |
| 159 | + /** | |
| 160 | + * 是否为透传下发主题:/sys/{productKey}/{deviceName}/thing/raw/push | |
| 161 | + * <p> | |
| 162 | + * 网关发布 raw 下行使用;EMQX 网关订阅到自己发布的该主题时需要忽略(回环) | |
| 163 | + * | |
| 164 | + * @param topic 主题 | |
| 165 | + * @return 是否为透传下发主题 | |
| 166 | + */ | |
| 167 | + public static boolean isRawPushTopic(String topic) { | |
| 168 | + return isRawTopic(topic, IotDeviceMessageMethodEnum.RAW_PUSH); | |
| 169 | + } | |
| 170 | + | |
| 171 | + /** | |
| 172 | + * 构建透传下发主题:/sys/{productKey}/{deviceName}/thing/raw/push | |
| 173 | + * | |
| 174 | + * @param productKey 产品 Key | |
| 175 | + * @param deviceName 设备名称 | |
| 176 | + * @return 透传下发主题 | |
| 177 | + */ | |
| 178 | + public static String buildRawPushTopic(String productKey, String deviceName) { | |
| 179 | + return buildTopicByMethod(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(), productKey, deviceName, false); | |
| 180 | + } | |
| 181 | + | |
| 182 | + private static boolean isRawTopic(String topic, IotDeviceMessageMethodEnum method) { | |
| 183 | + if (StrUtil.isBlank(topic)) { | |
| 184 | + return false; | |
| 185 | + } | |
| 186 | + // 格式必须是 /sys/{productKey}/{deviceName}/thing/raw/{post|push}:productKey、deviceName 各占一段且非空 | |
| 187 | + String[] parts = topic.split("/", -1); | |
| 188 | + if (parts.length != 7 || StrUtil.hasBlank(parts[2], parts[3])) { | |
| 189 | + return false; | |
| 190 | + } | |
| 191 | + return topic.equals(buildTopicByMethod(method.getMethod(), parts[2], parts[3], false)); | |
| 192 | + } | |
| 193 | + | |
| 144 | 194 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotWorkerTaskQueue.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.util; | |
| 2 | + | |
| 3 | +import io.vertx.core.Context; | |
| 4 | +import io.vertx.core.Future; | |
| 5 | +import lombok.Getter; | |
| 6 | + | |
| 7 | +import java.util.concurrent.Callable; | |
| 8 | +import java.util.concurrent.RejectedExecutionException; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 阻塞任务队列:任务在 Vert.x worker 上执行,结果回到所在的 context(event loop) | |
| 12 | + * <ul> | |
| 13 | + * <li>有序(一个连接一个):按提交顺序逐个执行、逐个完成(MQTT 的 PUBACK 顺序、TCP / WebSocket 的帧顺序); | |
| 14 | + * 不同连接各自一个队列,互不等待,并行使用 worker 池;</li> | |
| 15 | + * <li>无序(UDP、EMQX 这类没有连接的入口):只限制排队数量,任务之间并行;</li> | |
| 16 | + * <li>有界:排队中的任务达到上限时拒绝,由调用方决定(例如关闭连接、丢弃)。</li> | |
| 17 | + * </ul> | |
| 18 | + * 只能在所在的 context 线程上调用(Vert.x 的处理器都在这个线程上),所以不需要加锁 | |
| 19 | + * | |
| 20 | + * @author 芋道源码 | |
| 21 | + */ | |
| 22 | +public class IotWorkerTaskQueue { | |
| 23 | + | |
| 24 | + /** | |
| 25 | + * 默认每个连接最多排队的任务数 | |
| 26 | + */ | |
| 27 | + public static final int DEFAULT_MAX_PENDING = 256; | |
| 28 | + | |
| 29 | + @Getter | |
| 30 | + private final Context context; | |
| 31 | + private final boolean ordered; | |
| 32 | + private final int maxPending; | |
| 33 | + | |
| 34 | + /** | |
| 35 | + * 最后一个任务的结果:新任务排在它之后 | |
| 36 | + */ | |
| 37 | + private Future<?> tail = Future.succeededFuture(); | |
| 38 | + /** | |
| 39 | + * 排队中(含执行中)的任务数 | |
| 40 | + */ | |
| 41 | + private int pending; | |
| 42 | + /** | |
| 43 | + * 是否已关闭:连接关闭后不再接收新任务 | |
| 44 | + */ | |
| 45 | + private boolean closed; | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * 创建一个连接的有序队列 | |
| 49 | + * | |
| 50 | + * @param context 连接所在的 context | |
| 51 | + */ | |
| 52 | + public IotWorkerTaskQueue(Context context) { | |
| 53 | + this(context, true, DEFAULT_MAX_PENDING); | |
| 54 | + } | |
| 55 | + | |
| 56 | + public IotWorkerTaskQueue(Context context, boolean ordered, int maxPending) { | |
| 57 | + this.context = context; | |
| 58 | + this.ordered = ordered; | |
| 59 | + this.maxPending = maxPending; | |
| 60 | + } | |
| 61 | + | |
| 62 | + /** | |
| 63 | + * 提交一个阻塞任务:有序时排在之前的任务之后执行(前一个任务失败也照常执行) | |
| 64 | + * | |
| 65 | + * @param task 阻塞任务 | |
| 66 | + * @return 任务结果,在连接所在的 context 上完成;队列已满或已关闭时立即失败({@link RejectedExecutionException}) | |
| 67 | + */ | |
| 68 | + public <T> Future<T> submit(Callable<T> task) { | |
| 69 | + if (closed) { | |
| 70 | + return Future.failedFuture(new RejectedExecutionException("连接已关闭")); | |
| 71 | + } | |
| 72 | + if (pending >= maxPending) { | |
| 73 | + return Future.failedFuture(new RejectedExecutionException("连接的待处理消息已达上限 " + maxPending)); | |
| 74 | + } | |
| 75 | + return enqueue(task); | |
| 76 | + } | |
| 77 | + | |
| 78 | + /** | |
| 79 | + * 提交连接的最后一个任务(例如离线通知):不受上限限制,之后不再接收新任务 | |
| 80 | + * | |
| 81 | + * @param task 阻塞任务 | |
| 82 | + * @return 任务结果 | |
| 83 | + */ | |
| 84 | + public <T> Future<T> submitLast(Callable<T> task) { | |
| 85 | + if (closed) { | |
| 86 | + return Future.failedFuture(new RejectedExecutionException("连接已关闭")); | |
| 87 | + } | |
| 88 | + closed = true; | |
| 89 | + return enqueue(task); | |
| 90 | + } | |
| 91 | + | |
| 92 | + /** | |
| 93 | + * 入队:计入排队数,交给 worker 执行;任务结束(成功或失败)时减去 | |
| 94 | + * | |
| 95 | + * @param task 阻塞任务 | |
| 96 | + * @return 任务结果 | |
| 97 | + */ | |
| 98 | + private <T> Future<T> enqueue(Callable<T> task) { | |
| 99 | + pending++; | |
| 100 | + // 1. 无序:直接提交,任务之间并行 | |
| 101 | + if (!ordered) { | |
| 102 | + Future<T> result; | |
| 103 | + try { | |
| 104 | + result = context.executeBlocking(task, false); | |
| 105 | + } catch (RuntimeException e) { // Vertx 关闭时 worker 池会同步拒绝 | |
| 106 | + pending--; | |
| 107 | + return Future.failedFuture(e); | |
| 108 | + } | |
| 109 | + return result.andThen(ar -> pending--); | |
| 110 | + } | |
| 111 | + // 2. 有序:排在上一个任务(tail)之后,上一个结束(无论成败)再提交; | |
| 112 | + // executeBlocking 的 ordered = false:同一个 context 上的其它连接不受影响;本连接的顺序由 tail 串起来 | |
| 113 | + Future<T> result = tail.transform(ignored -> context.<T>executeBlocking(task, false)) | |
| 114 | + .andThen(ar -> pending--); | |
| 115 | + tail = result; | |
| 116 | + return result; | |
| 117 | + } | |
| 118 | + | |
| 119 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/AbstractIotProtocolDownstreamSubscriberTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol; | |
| 2 | + | |
| 3 | +import cn.hutool.extra.spring.SpringUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 5 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 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.mq.message.IotDeviceMessage; | |
| 10 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 12 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO; | |
| 14 | +import io.vertx.core.Future; | |
| 15 | +import io.vertx.core.Vertx; | |
| 16 | +import org.junit.jupiter.api.AfterEach; | |
| 17 | +import org.junit.jupiter.api.BeforeEach; | |
| 18 | +import org.junit.jupiter.api.Test; | |
| 19 | +import org.mockito.ArgumentCaptor; | |
| 20 | +import org.mockito.InOrder; | |
| 21 | +import org.mockito.Mock; | |
| 22 | +import org.mockito.MockedStatic; | |
| 23 | + | |
| 24 | +import java.util.ArrayList; | |
| 25 | +import java.util.Arrays; | |
| 26 | +import java.util.Base64; | |
| 27 | +import java.util.Collections; | |
| 28 | +import java.util.List; | |
| 29 | +import java.util.concurrent.Callable; | |
| 30 | +import java.util.concurrent.CopyOnWriteArrayList; | |
| 31 | +import java.util.concurrent.CountDownLatch; | |
| 32 | +import java.util.concurrent.TimeUnit; | |
| 33 | +import java.util.function.BooleanSupplier; | |
| 34 | + | |
| 35 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_EXCEPTION; | |
| 36 | +import static org.junit.jupiter.api.Assertions.*; | |
| 37 | +import static org.mockito.AdditionalMatchers.aryEq; | |
| 38 | +import static org.mockito.ArgumentMatchers.any; | |
| 39 | +import static org.mockito.ArgumentMatchers.eq; | |
| 40 | +import static org.mockito.Mockito.*; | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * {@link AbstractIotProtocolDownstreamSubscriber} 的单元测试 | |
| 44 | + * | |
| 45 | + * @author 芋道源码 | |
| 46 | + */ | |
| 47 | +public class AbstractIotProtocolDownstreamSubscriberTest extends BaseMockitoUnitTest { | |
| 48 | + | |
| 49 | + private static final Long DEVICE_ID = 1L; | |
| 50 | + private static final String SERVER_ID = "server-1"; | |
| 51 | + | |
| 52 | + @Mock | |
| 53 | + private IotProtocol protocol; | |
| 54 | + @Mock | |
| 55 | + private IotMessageBus messageBus; | |
| 56 | + @Mock | |
| 57 | + private Vertx vertx; | |
| 58 | + @Mock | |
| 59 | + private IotDeviceMessageService deviceMessageService; | |
| 60 | + | |
| 61 | + private final IotGatewayProperties gatewayProperties = new IotGatewayProperties(); | |
| 62 | + private MockedStatic<SpringUtil> springUtilMockedStatic; | |
| 63 | + private TestSubscriber subscriber; | |
| 64 | + | |
| 65 | + @BeforeEach | |
| 66 | + public void setUp() { | |
| 67 | + springUtilMockedStatic = mockStatic(SpringUtil.class); | |
| 68 | + lenient().when(protocol.getType()).thenReturn(IotProtocolTypeEnum.TCP); | |
| 69 | + subscriber = new TestSubscriber(protocol, messageBus); | |
| 70 | + } | |
| 71 | + | |
| 72 | + @AfterEach | |
| 73 | + public void tearDown() { | |
| 74 | + springUtilMockedStatic.close(); | |
| 75 | + } | |
| 76 | + | |
| 77 | + // ========== onMessage:raw.push ========== | |
| 78 | + | |
| 79 | + @Test | |
| 80 | + public void testOnMessage_rawPush() { | |
| 81 | + // 准备参数:字节在 raw 字段;功能开关关闭时也照常写出 | |
| 82 | + byte[] bytes = new byte[]{0x01, 0x02, (byte) 0xFF}; | |
| 83 | + IotDeviceMessage message = buildRawPush(Base64.getEncoder().encodeToString(bytes)); | |
| 84 | + | |
| 85 | + // 调用 | |
| 86 | + subscriber.onMessage(message); | |
| 87 | + | |
| 88 | + // 断言:从 raw 解码后直接交给协议写出,不经过脚本、不读开关 | |
| 89 | + assertEquals(1, subscriber.rawMessages.size()); | |
| 90 | + assertSame(message, subscriber.rawMessages.get(0)); | |
| 91 | + assertArrayEquals(bytes, subscriber.rawBytes.get(0)); | |
| 92 | + assertNull(subscriber.rawSources.get(0)); | |
| 93 | + assertTrue(subscriber.handledMessages.isEmpty()); | |
| 94 | + springUtilMockedStatic.verifyNoInteractions(); | |
| 95 | + verify(protocol, never()).getVertx(); | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Test | |
| 99 | + public void testOnMessage_rawPushPayloadLimit() { | |
| 100 | + // 调用:4097 字节、4096 字节(公共层只按通用上限解码,协议自己的字节规则由协议写出前校验) | |
| 101 | + subscriber.onMessage(buildRawPush(Base64.getEncoder().encodeToString(new byte[4097]))); | |
| 102 | + subscriber.onMessage(buildRawPush(Base64.getEncoder().encodeToString(new byte[4096]))); | |
| 103 | + | |
| 104 | + // 断言:超过上限的不写出(只记日志),不改走标准路径 | |
| 105 | + assertEquals(1, subscriber.rawBytes.size()); | |
| 106 | + assertEquals(4096, subscriber.rawBytes.get(0).length); | |
| 107 | + assertTrue(subscriber.handledMessages.isEmpty()); | |
| 108 | + } | |
| 109 | + | |
| 110 | + @Test | |
| 111 | + public void testOnMessage_rawPushInvalidRaw() { | |
| 112 | + // 调用:raw 为空、不是合法的 Base64 | |
| 113 | + subscriber.onMessage(buildRawPush(null)); | |
| 114 | + subscriber.onMessage(buildRawPush("!!not-base64!!")); | |
| 115 | + | |
| 116 | + // 断言:不写出,也不改走标准路径 | |
| 117 | + assertTrue(subscriber.rawMessages.isEmpty()); | |
| 118 | + assertTrue(subscriber.handledMessages.isEmpty()); | |
| 119 | + } | |
| 120 | + | |
| 121 | + @Test | |
| 122 | + public void testOnMessage_rawPushWriteFailureOnlyLog() { | |
| 123 | + // 准备参数:协议写出失败(设备离线返回 null、异步失败) | |
| 124 | + IotDeviceMessage message = buildRawPush(Base64.getEncoder().encodeToString(new byte[]{0x01})); | |
| 125 | + | |
| 126 | + // 调用,并断言:只记日志,不抛出 | |
| 127 | + subscriber.rawResult = null; | |
| 128 | + assertDoesNotThrow(() -> subscriber.onMessage(message)); | |
| 129 | + subscriber.rawResult = Future.failedFuture(new IllegalStateException("closed")); | |
| 130 | + assertDoesNotThrow(() -> subscriber.onMessage(message)); | |
| 131 | + assertEquals(2, subscriber.rawMessages.size()); | |
| 132 | + assertTrue(subscriber.handledMessages.isEmpty()); | |
| 133 | + } | |
| 134 | + | |
| 135 | + @Test | |
| 136 | + public void testOnMessage_rawPushReply() { | |
| 137 | + // 准备参数:raw.push 的回复(带 code)不是下发 | |
| 138 | + mockScriptProperties(); | |
| 139 | + IotDeviceMessage reply = IotDeviceMessage.replyOf("r1", IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(), | |
| 140 | + null, 0, null).setDeviceId(DEVICE_ID); | |
| 141 | + | |
| 142 | + // 调用 | |
| 143 | + subscriber.onMessage(reply); | |
| 144 | + | |
| 145 | + // 断言:直接走标准路径 | |
| 146 | + assertEquals(Collections.singletonList(reply), subscriber.handledMessages); | |
| 147 | + assertTrue(subscriber.rawMessages.isEmpty()); | |
| 148 | + } | |
| 149 | + | |
| 150 | + // ========== onMessage:指令与标准路径 ========== | |
| 151 | + | |
| 152 | + @Test | |
| 153 | + public void testOnMessage_commandSwitchOff() { | |
| 154 | + // 准备参数:功能开关默认关闭 | |
| 155 | + mockScriptProperties(); | |
| 156 | + IotDeviceMessage message = buildPropertySet(); | |
| 157 | + | |
| 158 | + // 调用 | |
| 159 | + subscriber.onMessage(message); | |
| 160 | + | |
| 161 | + // 断言:与原来一致,直接走标准路径,不切 worker、不调用设备消息 Service | |
| 162 | + assertEquals(Collections.singletonList(message), subscriber.handledMessages); | |
| 163 | + verify(protocol, never()).getVertx(); | |
| 164 | + verifyNoInteractions(deviceMessageService); | |
| 165 | + } | |
| 166 | + | |
| 167 | + @Test | |
| 168 | + public void testOnMessage_commandSwitchOn() { | |
| 169 | + // 准备参数 | |
| 170 | + gatewayProperties.getScript().setEnabled(true); | |
| 171 | + mockScriptProperties(); | |
| 172 | + mockDeviceMessageService(); | |
| 173 | + mockExecuteBlocking(); | |
| 174 | + IotDeviceMessage message = buildPropertySet(); | |
| 175 | + | |
| 176 | + // 调用 | |
| 177 | + long beginNanos = System.nanoTime(); | |
| 178 | + subscriber.onMessage(message); | |
| 179 | + long endNanos = System.nanoTime(); | |
| 180 | + | |
| 181 | + // 断言:切到协议的 worker,交给设备消息 Service 按协议 encode,带上收到指令的时刻 | |
| 182 | + ArgumentCaptor<Long> receivedCaptor = ArgumentCaptor.forClass(Long.class); | |
| 183 | + verify(deviceMessageService).encodeDownstreamCommand(same(message), eq(IotProtocolTypeEnum.TCP), | |
| 184 | + receivedCaptor.capture()); | |
| 185 | + assertTrue(receivedCaptor.getValue() >= beginNanos); | |
| 186 | + assertTrue(receivedCaptor.getValue() <= endNanos); | |
| 187 | + assertTrue(subscriber.handledMessages.isEmpty()); | |
| 188 | + assertTrue(subscriber.rawMessages.isEmpty()); | |
| 189 | + } | |
| 190 | + | |
| 191 | + @Test | |
| 192 | + public void testOnMessage_commandEncodeResult() { | |
| 193 | + // 准备参数:encode 结果分别为标准路径、透传字节、不下发 | |
| 194 | + gatewayProperties.getScript().setEnabled(true); | |
| 195 | + mockScriptProperties(); | |
| 196 | + mockDeviceMessageService(); | |
| 197 | + mockExecuteBlocking(); | |
| 198 | + IotDeviceMessage standard = buildPropertySet("c1", DEVICE_ID); | |
| 199 | + IotDeviceMessage raw = buildPropertySet("c2", DEVICE_ID); | |
| 200 | + IotDeviceMessage notSend = buildPropertySet("c3", DEVICE_ID); | |
| 201 | + byte[] bytes = new byte[]{0x01, 0x02}; | |
| 202 | + IotScriptSource source = IotScriptSource.of(100L, "function encode(ctx) {}"); | |
| 203 | + when(deviceMessageService.encodeDownstreamCommand(any(), any(), anyLong())).thenAnswer(invocation -> { | |
| 204 | + IotDeviceMessage message = invocation.getArgument(0); | |
| 205 | + return message == standard ? IotDownstreamEncodeRespDTO.standard() | |
| 206 | + : message == raw ? IotDownstreamEncodeRespDTO.raw(bytes, source) : null; | |
| 207 | + }); | |
| 208 | + | |
| 209 | + // 调用 | |
| 210 | + subscriber.onMessage(standard); | |
| 211 | + subscriber.onMessage(raw); | |
| 212 | + subscriber.onMessage(notSend); | |
| 213 | + | |
| 214 | + // 断言:标准路径 → handleMessage;透传 → handleRawMessage,带上 encode 时固定的脚本;不下发 → 都不调用 | |
| 215 | + assertEquals(Collections.singletonList(standard), subscriber.handledMessages); | |
| 216 | + assertEquals(Collections.singletonList(raw), subscriber.rawMessages); | |
| 217 | + assertSame(bytes, subscriber.rawBytes.get(0)); | |
| 218 | + assertSame(source, subscriber.rawSources.get(0)); | |
| 219 | + verify(deviceMessageService).encodeDownstreamCommand(same(notSend), any(), anyLong()); | |
| 220 | + } | |
| 221 | + | |
| 222 | + @Test | |
| 223 | + public void testOnMessage_commandNotSupportRaw() { | |
| 224 | + // 准备参数:协议不支持透传下发(HTTP / CoAP) | |
| 225 | + gatewayProperties.getScript().setEnabled(true); | |
| 226 | + mockScriptProperties(); | |
| 227 | + subscriber.supportsRaw = false; | |
| 228 | + IotDeviceMessage message = buildPropertySet(); | |
| 229 | + | |
| 230 | + // 调用 | |
| 231 | + subscriber.onMessage(message); | |
| 232 | + | |
| 233 | + // 断言:直接走标准路径 | |
| 234 | + assertEquals(Collections.singletonList(message), subscriber.handledMessages); | |
| 235 | + verify(protocol, never()).getVertx(); | |
| 236 | + verifyNoInteractions(deviceMessageService); | |
| 237 | + } | |
| 238 | + | |
| 239 | + @Test | |
| 240 | + public void testOnMessage_notCommand() { | |
| 241 | + // 准备参数:功能开关开启,但不是指令(配置推送、OTA、属性设置的回复) | |
| 242 | + gatewayProperties.getScript().setEnabled(true); | |
| 243 | + mockScriptProperties(); | |
| 244 | + List<IotDeviceMessage> messages = new ArrayList<>(); | |
| 245 | + messages.add(IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.CONFIG_PUSH.getMethod(), | |
| 246 | + Collections.emptyMap()).setDeviceId(DEVICE_ID)); | |
| 247 | + messages.add(IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.OTA_UPGRADE.getMethod(), | |
| 248 | + Collections.emptyMap()).setDeviceId(DEVICE_ID)); | |
| 249 | + messages.add(IotDeviceMessage.replyOf("r1", IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), | |
| 250 | + null, 0, null).setDeviceId(DEVICE_ID)); | |
| 251 | + | |
| 252 | + // 调用 | |
| 253 | + messages.forEach(subscriber::onMessage); | |
| 254 | + | |
| 255 | + // 断言:都走标准路径 | |
| 256 | + assertEquals(messages, subscriber.handledMessages); | |
| 257 | + verify(protocol, never()).getVertx(); | |
| 258 | + verifyNoInteractions(deviceMessageService); | |
| 259 | + } | |
| 260 | + | |
| 261 | + @Test | |
| 262 | + public void testOnMessage_blankMethod() { | |
| 263 | + // 调用 | |
| 264 | + subscriber.onMessage(new IotDeviceMessage().setDeviceId(DEVICE_ID)); | |
| 265 | + | |
| 266 | + // 断言:直接忽略 | |
| 267 | + assertTrue(subscriber.handledMessages.isEmpty()); | |
| 268 | + assertTrue(subscriber.rawMessages.isEmpty()); | |
| 269 | + springUtilMockedStatic.verifyNoInteractions(); | |
| 270 | + } | |
| 271 | + | |
| 272 | + // ========== onMessage:同一设备的指令保序 ========== | |
| 273 | + | |
| 274 | + @Test | |
| 275 | + public void testOnMessage_commandsOrderedPerDevice() throws Exception { | |
| 276 | + // 准备参数:设备 1 的指令 c1、c2,encode 后走标准路径的 c3;设备 2 的指令 d1 | |
| 277 | + gatewayProperties.getScript().setEnabled(true); | |
| 278 | + mockScriptProperties(); | |
| 279 | + mockDeviceMessageService(); | |
| 280 | + Vertx realVertx = Vertx.vertx(); | |
| 281 | + when(protocol.getVertx()).thenReturn(realVertx); | |
| 282 | + IotDeviceMessage c1 = buildPropertySet("c1", DEVICE_ID); | |
| 283 | + IotDeviceMessage c2 = buildPropertySet("c2", DEVICE_ID); | |
| 284 | + IotDeviceMessage c3 = buildPropertySet("c3", DEVICE_ID); | |
| 285 | + IotDeviceMessage d1 = buildPropertySet("d1", 2L); | |
| 286 | + // mock 方法:c1 的 worker 阻塞,直到放行;记录执行顺序 | |
| 287 | + List<String> order = new CopyOnWriteArrayList<>(); | |
| 288 | + CountDownLatch entered = new CountDownLatch(1); | |
| 289 | + CountDownLatch release = new CountDownLatch(1); | |
| 290 | + CountDownLatch otherDevice = new CountDownLatch(1); | |
| 291 | + doAnswer(invocation -> { | |
| 292 | + IotDeviceMessage message = invocation.getArgument(0); | |
| 293 | + if (message == c1) { | |
| 294 | + entered.countDown(); | |
| 295 | + assertTrue(release.await(10, TimeUnit.SECONDS)); | |
| 296 | + } | |
| 297 | + if (message == d1) { | |
| 298 | + otherDevice.countDown(); | |
| 299 | + } | |
| 300 | + order.add(message.getRequestId()); | |
| 301 | + return message == c3 ? IotDownstreamEncodeRespDTO.standard() : null; | |
| 302 | + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong()); | |
| 303 | + | |
| 304 | + try { | |
| 305 | + // 调用:c1 阻塞在 worker 上时,依次收到 c2、c3、d1 | |
| 306 | + subscriber.onMessage(c1); | |
| 307 | + assertTrue(entered.await(5, TimeUnit.SECONDS)); | |
| 308 | + long c2BeginNanos = System.nanoTime(); | |
| 309 | + subscriber.onMessage(c2); | |
| 310 | + long c2QueuedNanos = System.nanoTime(); | |
| 311 | + subscriber.onMessage(c3); | |
| 312 | + subscriber.onMessage(d1); | |
| 313 | + | |
| 314 | + // 断言:其它设备不受影响;同一设备的 c2、c3(含标准路径)排在 c1 之后,不越过它 | |
| 315 | + assertTrue(otherDevice.await(5, TimeUnit.SECONDS)); | |
| 316 | + assertFalse(order.contains("c2")); | |
| 317 | + assertFalse(order.contains("c3")); | |
| 318 | + assertTrue(subscriber.handledMessages.isEmpty()); | |
| 319 | + | |
| 320 | + // 放行:按收到的顺序执行 | |
| 321 | + release.countDown(); | |
| 322 | + await(() -> subscriber.handledMessages.size() == 1); | |
| 323 | + List<String> deviceOrder = new ArrayList<>(order); | |
| 324 | + deviceOrder.remove("d1"); | |
| 325 | + assertEquals(Arrays.asList("c1", "c2", "c3"), deviceOrder); | |
| 326 | + assertEquals(Collections.singletonList(c3), subscriber.handledMessages); | |
| 327 | + // 断言:c2 排在 c1 之后才执行,传给 service 的仍是收到时(排队之前)的时刻,排队的时间计入截止 | |
| 328 | + ArgumentCaptor<Long> receivedCaptor = ArgumentCaptor.forClass(Long.class); | |
| 329 | + verify(deviceMessageService).encodeDownstreamCommand(same(c2), eq(IotProtocolTypeEnum.TCP), | |
| 330 | + receivedCaptor.capture()); | |
| 331 | + assertTrue(receivedCaptor.getValue() >= c2BeginNanos); | |
| 332 | + assertTrue(receivedCaptor.getValue() <= c2QueuedNanos); | |
| 333 | + } finally { | |
| 334 | + release.countDown(); | |
| 335 | + realVertx.close(); | |
| 336 | + } | |
| 337 | + } | |
| 338 | + | |
| 339 | + @Test | |
| 340 | + public void testOnMessage_commandFailureNotBlockNext() { | |
| 341 | + // 准备参数 | |
| 342 | + gatewayProperties.getScript().setEnabled(true); | |
| 343 | + mockScriptProperties(); | |
| 344 | + mockDeviceMessageService(); | |
| 345 | + mockExecuteBlocking(); | |
| 346 | + IotDeviceMessage c1 = buildPropertySet("c1", DEVICE_ID); | |
| 347 | + IotDeviceMessage c2 = buildPropertySet("c2", DEVICE_ID); | |
| 348 | + // mock 方法:c1 处理失败 | |
| 349 | + doAnswer(invocation -> { | |
| 350 | + if (invocation.getArgument(0) == c1) { | |
| 351 | + throw new IllegalStateException("boom"); | |
| 352 | + } | |
| 353 | + return IotDownstreamEncodeRespDTO.standard(); | |
| 354 | + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong()); | |
| 355 | + | |
| 356 | + // 调用 | |
| 357 | + subscriber.onMessage(c1); | |
| 358 | + subscriber.onMessage(c2); | |
| 359 | + | |
| 360 | + // 断言:c1 失败只记日志,c2 照常执行 | |
| 361 | + InOrder inOrder = inOrder(deviceMessageService); | |
| 362 | + inOrder.verify(deviceMessageService).encodeDownstreamCommand(same(c1), any(), anyLong()); | |
| 363 | + inOrder.verify(deviceMessageService).encodeDownstreamCommand(same(c2), any(), anyLong()); | |
| 364 | + assertEquals(Collections.singletonList(c2), subscriber.handledMessages); | |
| 365 | + // 断言:队列执行完后已移除,之后的指令新建队列照常执行 | |
| 366 | + IotDeviceMessage c3 = buildPropertySet("c3", DEVICE_ID); | |
| 367 | + subscriber.onMessage(c3); | |
| 368 | + assertEquals(Arrays.asList(c2, c3), subscriber.handledMessages); | |
| 369 | + } | |
| 370 | + | |
| 371 | + @Test | |
| 372 | + public void testOnMessage_commandsCapacity() throws Exception { | |
| 373 | + // 准备参数 | |
| 374 | + gatewayProperties.getScript().setEnabled(true); | |
| 375 | + mockScriptProperties(); | |
| 376 | + mockDeviceMessageService(); | |
| 377 | + Vertx realVertx = Vertx.vertx(); | |
| 378 | + when(protocol.getVertx()).thenReturn(realVertx); | |
| 379 | + IotDeviceMessage first = buildPropertySet("first", DEVICE_ID); | |
| 380 | + IotDeviceMessage overflow = buildPropertySet("overflow", DEVICE_ID); | |
| 381 | + // mock 方法:第一条阻塞 | |
| 382 | + CountDownLatch entered = new CountDownLatch(1); | |
| 383 | + CountDownLatch release = new CountDownLatch(1); | |
| 384 | + doAnswer(invocation -> { | |
| 385 | + if (invocation.getArgument(0) == first) { | |
| 386 | + entered.countDown(); | |
| 387 | + assertTrue(release.await(10, TimeUnit.SECONDS)); | |
| 388 | + } | |
| 389 | + return null; | |
| 390 | + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong()); | |
| 391 | + | |
| 392 | + try { | |
| 393 | + // 调用:第一条执行中,再排 256 条(上限),第 257 条超出 | |
| 394 | + subscriber.onMessage(first); | |
| 395 | + assertTrue(entered.await(5, TimeUnit.SECONDS)); | |
| 396 | + for (int i = 0; i < 256; i++) { | |
| 397 | + subscriber.onMessage(buildPropertySet("q" + i, DEVICE_ID)); | |
| 398 | + } | |
| 399 | + subscriber.onMessage(overflow); | |
| 400 | + release.countDown(); | |
| 401 | + | |
| 402 | + // 断言:上限内的全部执行,超出的丢弃 | |
| 403 | + verify(deviceMessageService, timeout(5000).times(257)).encodeDownstreamCommand(any(), any(), anyLong()); | |
| 404 | + verify(deviceMessageService, never()).encodeDownstreamCommand(same(overflow), any(), anyLong()); | |
| 405 | + } finally { | |
| 406 | + release.countDown(); | |
| 407 | + realVertx.close(); | |
| 408 | + } | |
| 409 | + } | |
| 410 | + | |
| 411 | + @Test | |
| 412 | + public void testOnMessage_commandsCapacityConfigured() throws Exception { | |
| 413 | + // 准备参数:每个设备最多排队 2 条 | |
| 414 | + gatewayProperties.getScript().setEnabled(true).setMaxPendingCommandsPerDevice(2); | |
| 415 | + mockScriptProperties(); | |
| 416 | + mockDeviceMessageService(); | |
| 417 | + Vertx realVertx = Vertx.vertx(); | |
| 418 | + when(protocol.getVertx()).thenReturn(realVertx); | |
| 419 | + IotDeviceMessage first = buildPropertySet("first", DEVICE_ID); | |
| 420 | + IotDeviceMessage overflow = buildPropertySet("overflow", DEVICE_ID); | |
| 421 | + // mock 方法:第一条阻塞 | |
| 422 | + CountDownLatch entered = new CountDownLatch(1); | |
| 423 | + CountDownLatch release = new CountDownLatch(1); | |
| 424 | + doAnswer(invocation -> { | |
| 425 | + if (invocation.getArgument(0) == first) { | |
| 426 | + entered.countDown(); | |
| 427 | + assertTrue(release.await(10, TimeUnit.SECONDS)); | |
| 428 | + } | |
| 429 | + return null; | |
| 430 | + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong()); | |
| 431 | + | |
| 432 | + try { | |
| 433 | + // 调用:第一条执行中,再排 2 条(上限),第 3 条超出 | |
| 434 | + subscriber.onMessage(first); | |
| 435 | + assertTrue(entered.await(5, TimeUnit.SECONDS)); | |
| 436 | + subscriber.onMessage(buildPropertySet("q0", DEVICE_ID)); | |
| 437 | + subscriber.onMessage(buildPropertySet("q1", DEVICE_ID)); | |
| 438 | + subscriber.onMessage(overflow); | |
| 439 | + release.countDown(); | |
| 440 | + | |
| 441 | + // 断言:按配置的上限,执行中 1 条 + 排队 2 条全部执行,超出的丢弃 | |
| 442 | + verify(deviceMessageService, timeout(5000).times(3)).encodeDownstreamCommand(any(), any(), anyLong()); | |
| 443 | + verify(deviceMessageService, never()).encodeDownstreamCommand(same(overflow), any(), anyLong()); | |
| 444 | + } finally { | |
| 445 | + release.countDown(); | |
| 446 | + realVertx.close(); | |
| 447 | + } | |
| 448 | + } | |
| 449 | + | |
| 450 | + @Test | |
| 451 | + public void testStop_dropPendingCommands() throws Exception { | |
| 452 | + // 准备参数 | |
| 453 | + gatewayProperties.getScript().setEnabled(true); | |
| 454 | + mockScriptProperties(); | |
| 455 | + mockDeviceMessageService(); | |
| 456 | + Vertx realVertx = Vertx.vertx(); | |
| 457 | + when(protocol.getVertx()).thenReturn(realVertx); | |
| 458 | + IotDeviceMessage c1 = buildPropertySet("c1", DEVICE_ID); | |
| 459 | + IotDeviceMessage c2 = buildPropertySet("c2", DEVICE_ID); | |
| 460 | + // mock 方法:c1 阻塞 | |
| 461 | + CountDownLatch entered = new CountDownLatch(1); | |
| 462 | + CountDownLatch release = new CountDownLatch(1); | |
| 463 | + doAnswer(invocation -> { | |
| 464 | + entered.countDown(); | |
| 465 | + assertTrue(release.await(10, TimeUnit.SECONDS)); | |
| 466 | + return null; | |
| 467 | + }).when(deviceMessageService).encodeDownstreamCommand(same(c1), any(), anyLong()); | |
| 468 | + | |
| 469 | + try { | |
| 470 | + // 调用:c1 执行中、c2 排队时停止 | |
| 471 | + subscriber.onMessage(c1); | |
| 472 | + assertTrue(entered.await(5, TimeUnit.SECONDS)); | |
| 473 | + subscriber.onMessage(c2); | |
| 474 | + subscriber.stop(); | |
| 475 | + release.countDown(); | |
| 476 | + | |
| 477 | + // 断言:执行中的 c1 照常结束,排队的 c2 不再执行 | |
| 478 | + verify(deviceMessageService, after(500).never()).encodeDownstreamCommand(same(c2), any(), anyLong()); | |
| 479 | + verify(messageBus).unregister(same(subscriber)); | |
| 480 | + } finally { | |
| 481 | + release.countDown(); | |
| 482 | + realVertx.close(); | |
| 483 | + } | |
| 484 | + } | |
| 485 | + | |
| 486 | + @Test | |
| 487 | + public void testStop_restartNotReviveOldCommands() throws Exception { | |
| 488 | + // 准备参数 | |
| 489 | + gatewayProperties.getScript().setEnabled(true); | |
| 490 | + mockScriptProperties(); | |
| 491 | + mockDeviceMessageService(); | |
| 492 | + Vertx realVertx = Vertx.vertx(); | |
| 493 | + when(protocol.getVertx()).thenReturn(realVertx); | |
| 494 | + IotDeviceMessage c1 = buildPropertySet("c1", DEVICE_ID); | |
| 495 | + IotDeviceMessage c2 = buildPropertySet("c2", DEVICE_ID); | |
| 496 | + IotDeviceMessage c3 = buildPropertySet("c3", DEVICE_ID); | |
| 497 | + // mock 方法:c1 阻塞 | |
| 498 | + CountDownLatch entered = new CountDownLatch(1); | |
| 499 | + CountDownLatch release = new CountDownLatch(1); | |
| 500 | + doAnswer(invocation -> { | |
| 501 | + if (invocation.getArgument(0) == c1) { | |
| 502 | + entered.countDown(); | |
| 503 | + assertTrue(release.await(10, TimeUnit.SECONDS)); | |
| 504 | + } | |
| 505 | + return null; | |
| 506 | + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong()); | |
| 507 | + | |
| 508 | + try { | |
| 509 | + // 调用:c1 执行中、c2 排队时停止,再启动后收到 c3 | |
| 510 | + subscriber.onMessage(c1); | |
| 511 | + assertTrue(entered.await(5, TimeUnit.SECONDS)); | |
| 512 | + subscriber.onMessage(c2); | |
| 513 | + subscriber.stop(); | |
| 514 | + subscriber.start(); | |
| 515 | + subscriber.onMessage(c3); | |
| 516 | + | |
| 517 | + // 断言:c3 新建队列照常执行,不等待旧的 c1 | |
| 518 | + verify(deviceMessageService, timeout(5000)).encodeDownstreamCommand(same(c3), any(), anyLong()); | |
| 519 | + // 断言:c1 结束后,旧队列里的 c2 不会因为新队列而复活 | |
| 520 | + release.countDown(); | |
| 521 | + verify(deviceMessageService, after(500).never()).encodeDownstreamCommand(same(c2), any(), anyLong()); | |
| 522 | + } finally { | |
| 523 | + release.countDown(); | |
| 524 | + realVertx.close(); | |
| 525 | + } | |
| 526 | + } | |
| 527 | + | |
| 528 | + // ========== replyRawResult ========== | |
| 529 | + | |
| 530 | + @Test | |
| 531 | + public void testReplyRawResult() { | |
| 532 | + // 准备参数:设备的异常响应(协议判定的设备错误) | |
| 533 | + IotDeviceMessage request = buildRawPush(Base64.getEncoder().encodeToString(new byte[]{0x03, 0x00, 0x10, 0x00, 0x02})); | |
| 534 | + IotScriptSource source = IotScriptSource.of(100L, "function decode(ctx) {}"); | |
| 535 | + byte[] payload = new byte[]{(byte) 0x83, 0x02}; | |
| 536 | + // mock 方法 | |
| 537 | + when(protocol.getServerId()).thenReturn(SERVER_ID); | |
| 538 | + mockDeviceMessageService(); | |
| 539 | + mockExecuteBlocking(); | |
| 540 | + | |
| 541 | + // 调用 | |
| 542 | + subscriber.reply(request, source, payload, MODBUS_EXCEPTION); | |
| 543 | + | |
| 544 | + // 断言:切到协议的 worker 回复给 biz,serverId 为本协议实例,设备错误原样传入 | |
| 545 | + verify(vertx).executeBlocking(any(Callable.class), eq(false)); | |
| 546 | + verify(deviceMessageService).sendRawReply(same(request), same(source), aryEq(payload), same(MODBUS_EXCEPTION), | |
| 547 | + eq(SERVER_ID)); | |
| 548 | + } | |
| 549 | + | |
| 550 | + // ========== 辅助方法 ========== | |
| 551 | + | |
| 552 | + private void mockScriptProperties() { | |
| 553 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotGatewayProperties.class)).thenReturn(gatewayProperties); | |
| 554 | + } | |
| 555 | + | |
| 556 | + private void mockDeviceMessageService() { | |
| 557 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceMessageService.class)).thenReturn(deviceMessageService); | |
| 558 | + } | |
| 559 | + | |
| 560 | + /** | |
| 561 | + * executeBlocking 在当前线程同步执行(SpringUtil 的静态 mock 只在当前线程生效) | |
| 562 | + */ | |
| 563 | + @SuppressWarnings("unchecked") | |
| 564 | + private void mockExecuteBlocking() { | |
| 565 | + when(protocol.getVertx()).thenReturn(vertx); | |
| 566 | + when(vertx.executeBlocking(any(Callable.class), eq(false))).thenAnswer(invocation -> { | |
| 567 | + Callable<Object> callable = invocation.getArgument(0); | |
| 568 | + return Future.succeededFuture(callable.call()); | |
| 569 | + }); | |
| 570 | + } | |
| 571 | + | |
| 572 | + private static IotDeviceMessage buildRawPush(String raw) { | |
| 573 | + return IotDeviceMessage.requestOf("r1", IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(), null) | |
| 574 | + .setDeviceId(DEVICE_ID).setRaw(raw); | |
| 575 | + } | |
| 576 | + | |
| 577 | + private static IotDeviceMessage buildPropertySet() { | |
| 578 | + return buildPropertySet("r2", DEVICE_ID); | |
| 579 | + } | |
| 580 | + | |
| 581 | + private static IotDeviceMessage buildPropertySet(String requestId, Long deviceId) { | |
| 582 | + return IotDeviceMessage.requestOf(requestId, IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), | |
| 583 | + Collections.singletonMap("power", 1)).setDeviceId(deviceId); | |
| 584 | + } | |
| 585 | + | |
| 586 | + /** | |
| 587 | + * 等待条件成立(最长 5 秒) | |
| 588 | + */ | |
| 589 | + private static void await(BooleanSupplier condition) throws InterruptedException { | |
| 590 | + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); | |
| 591 | + while (!condition.getAsBoolean()) { | |
| 592 | + assertTrue(System.nanoTime() < deadline, "等待超时"); | |
| 593 | + Thread.sleep(10); | |
| 594 | + } | |
| 595 | + } | |
| 596 | + | |
| 597 | + /** | |
| 598 | + * 记录调用的测试订阅者 | |
| 599 | + */ | |
| 600 | + private static class TestSubscriber extends AbstractIotProtocolDownstreamSubscriber { | |
| 601 | + | |
| 602 | + // worker 上也会调用(指令保序的用例),所以使用线程安全的 List | |
| 603 | + private final List<IotDeviceMessage> handledMessages = new CopyOnWriteArrayList<>(); | |
| 604 | + private final List<IotDeviceMessage> rawMessages = new CopyOnWriteArrayList<>(); | |
| 605 | + private final List<byte[]> rawBytes = new CopyOnWriteArrayList<>(); | |
| 606 | + private final List<IotScriptSource> rawSources = new CopyOnWriteArrayList<>(); | |
| 607 | + private boolean supportsRaw = true; | |
| 608 | + private Future<Void> rawResult = Future.succeededFuture(); | |
| 609 | + | |
| 610 | + TestSubscriber(IotProtocol protocol, IotMessageBus messageBus) { | |
| 611 | + super(protocol, messageBus); | |
| 612 | + } | |
| 613 | + | |
| 614 | + @Override | |
| 615 | + protected void handleMessage(IotDeviceMessage message) { | |
| 616 | + handledMessages.add(message); | |
| 617 | + } | |
| 618 | + | |
| 619 | + @Override | |
| 620 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 621 | + rawMessages.add(message); | |
| 622 | + rawBytes.add(bytes); | |
| 623 | + rawSources.add(source); | |
| 624 | + return rawResult; | |
| 625 | + } | |
| 626 | + | |
| 627 | + @Override | |
| 628 | + public boolean supportsRawDownstream() { | |
| 629 | + return supportsRaw; | |
| 630 | + } | |
| 631 | + | |
| 632 | + void reply(IotDeviceMessage request, IotScriptSource source, byte[] payload, ErrorCode deviceError) { | |
| 633 | + replyRawResult(request, source, payload, deviceError); | |
| 634 | + } | |
| 635 | + | |
| 636 | + } | |
| 637 | + | |
| 638 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamHandlerTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.ReflectUtil; | |
| 4 | +import cn.hutool.extra.spring.SpringUtil; | |
| 5 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 6 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 9 | +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; | |
| 10 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService; | |
| 12 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 13 | +import org.eclipse.californium.core.coap.CoAP; | |
| 14 | +import org.eclipse.californium.core.coap.MediaTypeRegistry; | |
| 15 | +import org.eclipse.californium.core.coap.Option; | |
| 16 | +import org.eclipse.californium.core.coap.OptionSet; | |
| 17 | +import org.eclipse.californium.core.coap.Request; | |
| 18 | +import org.eclipse.californium.core.network.Exchange; | |
| 19 | +import org.eclipse.californium.core.server.resources.CoapExchange; | |
| 20 | +import org.junit.jupiter.api.AfterEach; | |
| 21 | +import org.junit.jupiter.api.BeforeEach; | |
| 22 | +import org.junit.jupiter.api.Test; | |
| 23 | +import org.mockito.ArgumentCaptor; | |
| 24 | +import org.mockito.InOrder; | |
| 25 | +import org.mockito.Mock; | |
| 26 | +import org.mockito.MockedStatic; | |
| 27 | + | |
| 28 | +import java.nio.charset.StandardCharsets; | |
| 29 | +import java.time.Duration; | |
| 30 | +import java.util.Arrays; | |
| 31 | +import java.util.Collections; | |
| 32 | +import java.util.List; | |
| 33 | +import java.util.Map; | |
| 34 | +import java.util.concurrent.CopyOnWriteArrayList; | |
| 35 | +import java.util.concurrent.CountDownLatch; | |
| 36 | +import java.util.concurrent.ExecutorService; | |
| 37 | +import java.util.concurrent.Executors; | |
| 38 | +import java.util.concurrent.ThreadPoolExecutor; | |
| 39 | +import java.util.concurrent.TimeUnit; | |
| 40 | + | |
| 41 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; | |
| 42 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN; | |
| 43 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; | |
| 44 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.TOO_MANY_REQUESTS; | |
| 45 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED; | |
| 46 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 47 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; | |
| 48 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT; | |
| 49 | +import static org.junit.jupiter.api.Assertions.*; | |
| 50 | +import static org.mockito.AdditionalMatchers.aryEq; | |
| 51 | +import static org.mockito.ArgumentMatchers.*; | |
| 52 | +import static org.mockito.Mockito.*; | |
| 53 | + | |
| 54 | +/** | |
| 55 | + * {@link IotCoapUpstreamHandler} 的单元测试 | |
| 56 | + * | |
| 57 | + * @author 芋道源码 | |
| 58 | + */ | |
| 59 | +public class IotCoapUpstreamHandlerTest extends BaseMockitoUnitTest { | |
| 60 | + | |
| 61 | + private static final String PRODUCT_KEY = "pk"; | |
| 62 | + private static final String DEVICE_NAME = "dn"; | |
| 63 | + private static final String TOKEN = "token"; | |
| 64 | + private static final String SERVER_ID = "coap-server"; | |
| 65 | + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, 0x03}; | |
| 66 | + private static final List<String> RAW_POST_PATH = Arrays.asList("topic", "sys", PRODUCT_KEY, DEVICE_NAME, | |
| 67 | + "thing", "raw", "post"); | |
| 68 | + private static final List<String> PROPERTY_POST_PATH = Arrays.asList("topic", "sys", PRODUCT_KEY, DEVICE_NAME, | |
| 69 | + "thing", "property", "post"); | |
| 70 | + | |
| 71 | + @Mock | |
| 72 | + private IotDeviceTokenService deviceTokenService; | |
| 73 | + @Mock | |
| 74 | + private IotDeviceMessageService deviceMessageService; | |
| 75 | + @Mock | |
| 76 | + private CoapExchange exchange; | |
| 77 | + @Mock | |
| 78 | + private Exchange advancedExchange; | |
| 79 | + @Mock | |
| 80 | + private OptionSet options; | |
| 81 | + @Mock | |
| 82 | + private Option tokenOption; | |
| 83 | + | |
| 84 | + private final IotGatewayProperties gatewayProperties = new IotGatewayProperties(); | |
| 85 | + private MockedStatic<SpringUtil> springUtilMockedStatic; | |
| 86 | + private IotCoapUpstreamHandler handler; | |
| 87 | + | |
| 88 | + @BeforeEach | |
| 89 | + public void setUp() { | |
| 90 | + springUtilMockedStatic = mockStatic(SpringUtil.class); | |
| 91 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceTokenService.class)).thenReturn(deviceTokenService); | |
| 92 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceMessageService.class)).thenReturn(deviceMessageService); | |
| 93 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotGatewayProperties.class)).thenReturn(gatewayProperties); | |
| 94 | + handler = new IotCoapUpstreamHandler(SERVER_ID); | |
| 95 | + } | |
| 96 | + | |
| 97 | + @AfterEach | |
| 98 | + public void tearDown() { | |
| 99 | + handler.stop(); | |
| 100 | + springUtilMockedStatic.close(); | |
| 101 | + } | |
| 102 | + | |
| 103 | + @Test | |
| 104 | + public void testHandle_rawPostConfirmable() { | |
| 105 | + // 准备参数 | |
| 106 | + mockRawPostRequest(CoAP.Type.CON); | |
| 107 | + // mock 方法 | |
| 108 | + when(deviceMessageService.sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID), | |
| 109 | + eq(IotProtocolTypeEnum.COAP), anyLong())).thenReturn("M1"); | |
| 110 | + | |
| 111 | + // 调用 | |
| 112 | + handler.handle(exchange); | |
| 113 | + | |
| 114 | + // 断言:CON 先回空 ACK,再(在透传 worker 上)处理,最后以分离响应返回一次 JSON 结果,data = {messageId} | |
| 115 | + Map<String, Object> json = captureResponse(); | |
| 116 | + InOrder inOrder = inOrder(exchange, deviceMessageService); | |
| 117 | + inOrder.verify(exchange).accept(); | |
| 118 | + inOrder.verify(deviceMessageService).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); | |
| 119 | + inOrder.verify(exchange).respond(any(CoAP.ResponseCode.class), anyString(), anyInt()); | |
| 120 | + assertEquals(0, json.get("code")); | |
| 121 | + @SuppressWarnings("unchecked") | |
| 122 | + Map<String, Object> data = (Map<String, Object>) json.get("data"); | |
| 123 | + assertEquals("M1", data.get("messageId")); | |
| 124 | + assertEquals(1, data.size()); | |
| 125 | + verify(exchange, times(1)).respond(any(CoAP.ResponseCode.class), anyString(), anyInt()); | |
| 126 | + verify(exchange, never()).respond(any(CoAP.ResponseCode.class), any(byte[].class), anyInt()); | |
| 127 | + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any()); | |
| 128 | + } | |
| 129 | + | |
| 130 | + @Test | |
| 131 | + public void testHandle_rawPostNonConfirmable() { | |
| 132 | + // 准备参数 | |
| 133 | + mockRawPostRequest(CoAP.Type.NON); | |
| 134 | + // mock 方法 | |
| 135 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenReturn("M2"); | |
| 136 | + | |
| 137 | + // 调用 | |
| 138 | + handler.handle(exchange); | |
| 139 | + | |
| 140 | + // 断言:NON 不回空 ACK,直接响应一次 JSON 结果 | |
| 141 | + verify(exchange, never()).accept(); | |
| 142 | + Map<String, Object> json = captureResponse(); | |
| 143 | + assertEquals(0, json.get("code")); | |
| 144 | + @SuppressWarnings("unchecked") | |
| 145 | + Map<String, Object> data = (Map<String, Object>) json.get("data"); | |
| 146 | + assertEquals("M2", data.get("messageId")); | |
| 147 | + verify(exchange, never()).respond(any(CoAP.ResponseCode.class), any(byte[].class), anyInt()); | |
| 148 | + } | |
| 149 | + | |
| 150 | + @Test | |
| 151 | + public void testHandle_rawPostServiceException() { | |
| 152 | + // 准备参数 | |
| 153 | + mockRawPostRequest(CoAP.Type.CON); | |
| 154 | + // mock 方法:处理超时(service 不投递,只记错误日志) | |
| 155 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) | |
| 156 | + .thenThrow(exception(SCRIPT_TIMEOUT)); | |
| 157 | + | |
| 158 | + // 调用 | |
| 159 | + handler.handle(exchange); | |
| 160 | + | |
| 161 | + // 断言:CON 已先回空 ACK;截止前失败,响应一次错误 JSON | |
| 162 | + verify(exchange).accept(); | |
| 163 | + Map<String, Object> json = captureResponse(); | |
| 164 | + assertEquals(SCRIPT_TIMEOUT.getCode(), json.get("code")); | |
| 165 | + assertEquals(SCRIPT_TIMEOUT.getMsg(), json.get("msg")); | |
| 166 | + verify(exchange, never()).respond(any(CoAP.ResponseCode.class), any(byte[].class), anyInt()); | |
| 167 | + } | |
| 168 | + | |
| 169 | + @Test | |
| 170 | + public void testHandle_rawPostDeadline() throws Exception { | |
| 171 | + // 准备参数:截止时长 100ms | |
| 172 | + gatewayProperties.getScript().setDeadline(Duration.ofMillis(100)); | |
| 173 | + IotCoapUpstreamHandler deadlineHandler = new IotCoapUpstreamHandler(SERVER_ID); | |
| 174 | + mockRawPostRequest(CoAP.Type.CON); | |
| 175 | + // mock 方法:处理阻塞,直到放行后才返回(迟到的结果) | |
| 176 | + CountDownLatch entered = new CountDownLatch(1); | |
| 177 | + CountDownLatch release = new CountDownLatch(1); | |
| 178 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenAnswer(invocation -> { | |
| 179 | + entered.countDown(); | |
| 180 | + assertTrue(release.await(10, TimeUnit.SECONDS)); | |
| 181 | + return "LATE"; | |
| 182 | + }); | |
| 183 | + | |
| 184 | + // mock 方法:与 Californium 一致,非处理线程写的响应排到 Exchange 的串行执行器(owner)上才发出 | |
| 185 | + ExecutorService owner = Executors.newSingleThreadExecutor(); | |
| 186 | + List<String> sent = new CopyOnWriteArrayList<>(); | |
| 187 | + CountDownLatch sentLatch = new CountDownLatch(1); | |
| 188 | + doAnswer(invocation -> { | |
| 189 | + String json = invocation.getArgument(1); | |
| 190 | + owner.execute(() -> { | |
| 191 | + sent.add(json); | |
| 192 | + sentLatch.countDown(); | |
| 193 | + }); | |
| 194 | + return null; | |
| 195 | + }).when(exchange).respond(any(CoAP.ResponseCode.class), anyString(), anyInt()); | |
| 196 | + | |
| 197 | + try { | |
| 198 | + // 调用:在 owner 上处理请求;处理入口立即返回,不等待阻塞中的发送 | |
| 199 | + owner.submit(() -> deadlineHandler.handle(exchange)).get(5, TimeUnit.SECONDS); | |
| 200 | + assertTrue(entered.await(5, TimeUnit.SECONDS)); | |
| 201 | + | |
| 202 | + // 断言:发送仍阻塞时,截止计时器的超时响应已经在 owner 上发出 | |
| 203 | + assertTrue(sentLatch.await(5, TimeUnit.SECONDS)); | |
| 204 | + assertEquals(1, release.getCount()); | |
| 205 | + Map<String, Object> json = JsonUtils.parseMap(sent.get(0)); | |
| 206 | + assertEquals(SCRIPT_TIMEOUT.getCode(), json.get("code")); | |
| 207 | + assertEquals(SCRIPT_TIMEOUT.getMsg(), json.get("msg")); | |
| 208 | + | |
| 209 | + // 放行:等透传 worker 处理完,迟到的结果丢弃,不再响应 | |
| 210 | + release.countDown(); | |
| 211 | + ThreadPoolExecutor rawExecutor = getRawExecutor(deadlineHandler); | |
| 212 | + rawExecutor.shutdown(); | |
| 213 | + assertTrue(rawExecutor.awaitTermination(5, TimeUnit.SECONDS)); | |
| 214 | + owner.submit(() -> null).get(5, TimeUnit.SECONDS); | |
| 215 | + assertEquals(1, sent.size()); | |
| 216 | + verify(exchange).accept(); | |
| 217 | + verify(exchange, times(1)).respond(any(CoAP.ResponseCode.class), anyString(), anyInt()); | |
| 218 | + verify(deviceMessageService, times(1)).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); | |
| 219 | + } finally { | |
| 220 | + release.countDown(); | |
| 221 | + deadlineHandler.stop(); | |
| 222 | + owner.shutdownNow(); | |
| 223 | + } | |
| 224 | + } | |
| 225 | + | |
| 226 | + @Test | |
| 227 | + public void testHandle_rawPostQueueExpired() throws Exception { | |
| 228 | + // 准备参数:截止时长 100ms;透传 worker 只留 1 个线程,并被占住 | |
| 229 | + gatewayProperties.getScript().setDeadline(Duration.ofMillis(100)); | |
| 230 | + IotCoapUpstreamHandler deadlineHandler = new IotCoapUpstreamHandler(SERVER_ID); | |
| 231 | + mockRawPostRequest(CoAP.Type.NON); | |
| 232 | + ThreadPoolExecutor rawExecutor = getRawExecutor(deadlineHandler); | |
| 233 | + rawExecutor.setCorePoolSize(1); | |
| 234 | + rawExecutor.setMaximumPoolSize(1); | |
| 235 | + CountDownLatch blocked = new CountDownLatch(1); | |
| 236 | + CountDownLatch release = new CountDownLatch(1); | |
| 237 | + rawExecutor.execute(() -> { | |
| 238 | + blocked.countDown(); | |
| 239 | + try { | |
| 240 | + release.await(10, TimeUnit.SECONDS); | |
| 241 | + } catch (InterruptedException e) { | |
| 242 | + Thread.currentThread().interrupt(); | |
| 243 | + } | |
| 244 | + }); | |
| 245 | + assertTrue(blocked.await(5, TimeUnit.SECONDS)); | |
| 246 | + // mock 方法:与 service 的截止分支一致(已过截止:不执行脚本、不投递,只记错误日志,并返回超时) | |
| 247 | + List<Long> nowAtCall = new CopyOnWriteArrayList<>(); | |
| 248 | + when(deviceMessageService.sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID), | |
| 249 | + eq(IotProtocolTypeEnum.COAP), anyLong())).thenAnswer(invocation -> { | |
| 250 | + nowAtCall.add(System.nanoTime()); | |
| 251 | + throw exception(SCRIPT_TIMEOUT); | |
| 252 | + }); | |
| 253 | + | |
| 254 | + try { | |
| 255 | + // 调用:请求排在 worker 队列中 | |
| 256 | + long beginNanos = System.nanoTime(); | |
| 257 | + deadlineHandler.handle(exchange); | |
| 258 | + long queuedNanos = System.nanoTime(); | |
| 259 | + | |
| 260 | + // 断言:排队期间到期,计时器响应一次超时 | |
| 261 | + Map<String, Object> json = captureResponse(); | |
| 262 | + assertEquals(SCRIPT_TIMEOUT.getCode(), json.get("code")); | |
| 263 | + | |
| 264 | + // 放行:排队的请求带着入队之前的收到时刻交给 service(已过截止:不执行脚本、不投递,只记错误日志并抛出超时); | |
| 265 | + // 协议不再响应第二次 | |
| 266 | + release.countDown(); | |
| 267 | + rawExecutor.shutdown(); | |
| 268 | + assertTrue(rawExecutor.awaitTermination(5, TimeUnit.SECONDS)); | |
| 269 | + ArgumentCaptor<Long> receivedCaptor = ArgumentCaptor.forClass(Long.class); | |
| 270 | + verify(deviceMessageService, times(1)).sendRawMessage(any(), any(), any(), any(), any(), | |
| 271 | + receivedCaptor.capture()); | |
| 272 | + assertTrue(receivedCaptor.getValue() >= beginNanos); | |
| 273 | + assertTrue(receivedCaptor.getValue() <= queuedNanos); | |
| 274 | + assertTrue(nowAtCall.get(0) >= receivedCaptor.getValue() + TimeUnit.MILLISECONDS.toNanos(100)); | |
| 275 | + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any()); | |
| 276 | + verify(exchange, times(1)).respond(any(CoAP.ResponseCode.class), anyString(), anyInt()); | |
| 277 | + } finally { | |
| 278 | + release.countDown(); | |
| 279 | + deadlineHandler.stop(); | |
| 280 | + } | |
| 281 | + } | |
| 282 | + | |
| 283 | + @Test | |
| 284 | + public void testHandle_rawPostRejected() { | |
| 285 | + // 准备参数:透传 worker 拒绝(已关闭) | |
| 286 | + mockRawPostRequest(CoAP.Type.NON); | |
| 287 | + getRawExecutor(handler).shutdown(); | |
| 288 | + | |
| 289 | + // 调用 | |
| 290 | + handler.handle(exchange); | |
| 291 | + | |
| 292 | + // 断言:立即响应一次 429,不调用发送 | |
| 293 | + assertEquals(TOO_MANY_REQUESTS.getCode(), captureResponse().get("code")); | |
| 294 | + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); | |
| 295 | + } | |
| 296 | + | |
| 297 | + @Test | |
| 298 | + public void testHandle_rawPostUnknownException() { | |
| 299 | + // 准备参数 | |
| 300 | + mockRawPostRequest(CoAP.Type.NON); | |
| 301 | + // mock 方法:未知异常 | |
| 302 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) | |
| 303 | + .thenThrow(new IllegalStateException("boom")); | |
| 304 | + | |
| 305 | + // 调用 | |
| 306 | + handler.handle(exchange); | |
| 307 | + | |
| 308 | + // 断言:响应一次 500 | |
| 309 | + assertEquals(INTERNAL_SERVER_ERROR.getCode(), captureResponse().get("code")); | |
| 310 | + } | |
| 311 | + | |
| 312 | + @Test | |
| 313 | + public void testHandle_rawPostPayloadInvalid() { | |
| 314 | + // 准备参数 | |
| 315 | + mockRawPostRequest(CoAP.Type.NON); | |
| 316 | + // mock 方法:载荷长度不合法(不投递) | |
| 317 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) | |
| 318 | + .thenThrow(invalidParamException("raw 载荷长度必须在 1~{} 字节之间", 4096)); | |
| 319 | + | |
| 320 | + // 调用 | |
| 321 | + handler.handle(exchange); | |
| 322 | + | |
| 323 | + // 断言 | |
| 324 | + assertEquals(BAD_REQUEST.getCode(), captureResponse().get("code")); | |
| 325 | + } | |
| 326 | + | |
| 327 | + @Test | |
| 328 | + public void testHandle_rawPathNotOctetStream() { | |
| 329 | + // 准备参数:raw/post 路径,但 content-format 不是 42:走 JSON 分支,交给标准路径(由它拒绝透传方法) | |
| 330 | + mockRequest(RAW_POST_PATH, "{\"method\":\"thing.raw.post\"}".getBytes(StandardCharsets.UTF_8)); | |
| 331 | + when(options.getContentFormat()).thenReturn(MediaTypeRegistry.APPLICATION_JSON); | |
| 332 | + | |
| 333 | + // 调用 | |
| 334 | + handler.handle(exchange); | |
| 335 | + | |
| 336 | + // 断言:不走透传 | |
| 337 | + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); | |
| 338 | + verify(deviceMessageService).sendDeviceMessage(any(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); | |
| 339 | + verify(exchange, never()).accept(); | |
| 340 | + } | |
| 341 | + | |
| 342 | + // ========== 标准上报、认证 ========== | |
| 343 | + | |
| 344 | + @Test | |
| 345 | + public void testHandle_propertyPost() { | |
| 346 | + // 准备参数:JSON 属性上报 | |
| 347 | + mockRequest(PROPERTY_POST_PATH, "{\"method\":\"thing.property.post\",\"params\":{\"t\":25}}" | |
| 348 | + .getBytes(StandardCharsets.UTF_8)); | |
| 349 | + | |
| 350 | + // 调用 | |
| 351 | + handler.handle(exchange); | |
| 352 | + | |
| 353 | + // 断言:同步响应成功;按路径的设备身份交给标准路径,不走透传 | |
| 354 | + assertEquals(0, captureResponse().get("code")); | |
| 355 | + ArgumentCaptor<IotDeviceMessage> messageCaptor = ArgumentCaptor.forClass(IotDeviceMessage.class); | |
| 356 | + verify(deviceMessageService).sendDeviceMessage(messageCaptor.capture(), eq(PRODUCT_KEY), eq(DEVICE_NAME), | |
| 357 | + eq(SERVER_ID)); | |
| 358 | + assertEquals("thing.property.post", messageCaptor.getValue().getMethod()); | |
| 359 | + assertEquals(25, ((Map<?, ?>) messageCaptor.getValue().getParams()).get("t")); | |
| 360 | + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); | |
| 361 | + verify(exchange, never()).accept(); | |
| 362 | + } | |
| 363 | + | |
| 364 | + @Test | |
| 365 | + public void testHandle_propertyPostEmptyPayload() { | |
| 366 | + // 准备参数:没有请求体 | |
| 367 | + mockRequest(PROPERTY_POST_PATH, new byte[0]); | |
| 368 | + | |
| 369 | + // 调用 | |
| 370 | + handler.handle(exchange); | |
| 371 | + | |
| 372 | + // 断言:400,不投递 | |
| 373 | + assertEquals(BAD_REQUEST.getCode(), captureResponse().get("code")); | |
| 374 | + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any()); | |
| 375 | + } | |
| 376 | + | |
| 377 | + @Test | |
| 378 | + public void testHandle_methodMismatch() { | |
| 379 | + // 准备参数:路径是属性上报,请求体是事件上报 | |
| 380 | + mockRequest(PROPERTY_POST_PATH, "{\"method\":\"thing.event.post\",\"params\":{}}" | |
| 381 | + .getBytes(StandardCharsets.UTF_8)); | |
| 382 | + | |
| 383 | + // 调用 | |
| 384 | + handler.handle(exchange); | |
| 385 | + | |
| 386 | + // 断言:400,不投递 | |
| 387 | + assertEquals(BAD_REQUEST.getCode(), captureResponse().get("code")); | |
| 388 | + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any()); | |
| 389 | + } | |
| 390 | + | |
| 391 | + @Test | |
| 392 | + public void testHandle_tokenMissing() { | |
| 393 | + // 准备参数:没有 token | |
| 394 | + when(exchange.getRequestOptions()).thenReturn(options); | |
| 395 | + when(options.getOthers()).thenReturn(Collections.emptyList()); | |
| 396 | + | |
| 397 | + // 调用 | |
| 398 | + handler.handle(exchange); | |
| 399 | + | |
| 400 | + // 断言:401,不投递 | |
| 401 | + assertEquals(UNAUTHORIZED.getCode(), captureResponse().get("code")); | |
| 402 | + verifyNoInteractions(deviceMessageService); | |
| 403 | + } | |
| 404 | + | |
| 405 | + @Test | |
| 406 | + public void testHandle_tokenInvalid() { | |
| 407 | + // 准备参数:token 校验不通过 | |
| 408 | + mockToken(); | |
| 409 | + when(deviceTokenService.verifyToken(TOKEN)).thenReturn(null); | |
| 410 | + | |
| 411 | + // 调用 | |
| 412 | + handler.handle(exchange); | |
| 413 | + | |
| 414 | + // 断言:401,不投递 | |
| 415 | + assertEquals(UNAUTHORIZED.getCode(), captureResponse().get("code")); | |
| 416 | + verifyNoInteractions(deviceMessageService); | |
| 417 | + } | |
| 418 | + | |
| 419 | + @Test | |
| 420 | + public void testHandle_deviceMismatch() { | |
| 421 | + // 准备参数:token 属于其它设备 | |
| 422 | + mockToken(); | |
| 423 | + when(options.getUriPath()).thenReturn(PROPERTY_POST_PATH); | |
| 424 | + when(deviceTokenService.verifyToken(TOKEN)).thenReturn(new IotDeviceIdentity(PRODUCT_KEY, "other")); | |
| 425 | + | |
| 426 | + // 调用 | |
| 427 | + handler.handle(exchange); | |
| 428 | + | |
| 429 | + // 断言:403,不投递 | |
| 430 | + assertEquals(FORBIDDEN.getCode(), captureResponse().get("code")); | |
| 431 | + verifyNoInteractions(deviceMessageService); | |
| 432 | + } | |
| 433 | + | |
| 434 | + // ========== 辅助方法 ========== | |
| 435 | + | |
| 436 | + private void mockRawPostRequest(CoAP.Type type) { | |
| 437 | + // 请求:thing/raw/post、content-format 42 | |
| 438 | + mockRequest(RAW_POST_PATH, PAYLOAD); | |
| 439 | + when(options.getContentFormat()).thenReturn(MediaTypeRegistry.APPLICATION_OCTET_STREAM); | |
| 440 | + when(exchange.advanced()).thenReturn(advancedExchange); | |
| 441 | + when(advancedExchange.getRequest()).thenReturn(new Request(CoAP.Code.POST, type)); | |
| 442 | + } | |
| 443 | + | |
| 444 | + private void mockRequest(List<String> uriPath, byte[] payload) { | |
| 445 | + // 请求:已认证(token 与路径一致) | |
| 446 | + mockToken(); | |
| 447 | + when(options.getUriPath()).thenReturn(uriPath); | |
| 448 | + when(exchange.getRequestPayload()).thenReturn(payload); | |
| 449 | + when(deviceTokenService.verifyToken(TOKEN)).thenReturn(new IotDeviceIdentity(PRODUCT_KEY, DEVICE_NAME)); | |
| 450 | + } | |
| 451 | + | |
| 452 | + private void mockToken() { | |
| 453 | + // 请求:带 token 的自定义 Option | |
| 454 | + when(exchange.getRequestOptions()).thenReturn(options); | |
| 455 | + when(options.getOthers()).thenReturn(Collections.singletonList(tokenOption)); | |
| 456 | + when(tokenOption.getNumber()).thenReturn(IotCoapAbstractHandler.OPTION_TOKEN); | |
| 457 | + when(tokenOption.getValue()).thenReturn(TOKEN.getBytes(StandardCharsets.UTF_8)); | |
| 458 | + } | |
| 459 | + | |
| 460 | + /** | |
| 461 | + * 等待(透传在 worker 上异步响应)并断言只响应一次 JSON,返回它 | |
| 462 | + */ | |
| 463 | + private Map<String, Object> captureResponse() { | |
| 464 | + ArgumentCaptor<String> jsonCaptor = ArgumentCaptor.forClass(String.class); | |
| 465 | + verify(exchange, timeout(5000)).respond(eq(CoAP.ResponseCode.CONTENT), jsonCaptor.capture(), | |
| 466 | + eq(MediaTypeRegistry.APPLICATION_JSON)); | |
| 467 | + verify(exchange, times(1)).respond(any(CoAP.ResponseCode.class), anyString(), anyInt()); | |
| 468 | + return JsonUtils.parseMap(jsonCaptor.getValue()); | |
| 469 | + } | |
| 470 | + | |
| 471 | + /** | |
| 472 | + * 获取处理器的透传 worker | |
| 473 | + */ | |
| 474 | + private static ThreadPoolExecutor getRawExecutor(IotCoapUpstreamHandler handler) { | |
| 475 | + return (ThreadPoolExecutor) ReflectUtil.getFieldValue(handler, "rawExecutor"); | |
| 476 | + } | |
| 477 | + | |
| 478 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocolTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.ReflectUtil; | |
| 4 | +import cn.hutool.extra.spring.SpringUtil; | |
| 5 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 6 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 7 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 9 | +import io.netty.handler.codec.mqtt.MqttQoS; | |
| 10 | +import io.vertx.core.Future; | |
| 11 | +import io.vertx.core.buffer.Buffer; | |
| 12 | +import io.vertx.mqtt.MqttClient; | |
| 13 | +import org.junit.jupiter.api.AfterEach; | |
| 14 | +import org.junit.jupiter.api.BeforeEach; | |
| 15 | +import org.junit.jupiter.api.Test; | |
| 16 | +import org.mockito.ArgumentCaptor; | |
| 17 | +import org.mockito.Mock; | |
| 18 | +import org.mockito.MockedStatic; | |
| 19 | + | |
| 20 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 21 | +import static org.junit.jupiter.api.Assertions.*; | |
| 22 | +import static org.mockito.ArgumentMatchers.any; | |
| 23 | +import static org.mockito.ArgumentMatchers.eq; | |
| 24 | +import static org.mockito.Mockito.*; | |
| 25 | + | |
| 26 | +/** | |
| 27 | + * {@link IotEmqxProtocol} 的单元测试 | |
| 28 | + * | |
| 29 | + * @author 芋道源码 | |
| 30 | + */ | |
| 31 | +public class IotEmqxProtocolTest extends BaseMockitoUnitTest { | |
| 32 | + | |
| 33 | + private static final String RAW_PUSH_TOPIC = "/sys/pk/dn/thing/raw/push"; | |
| 34 | + private static final byte[] BYTES = new byte[]{0x01, 0x02, 0x03}; | |
| 35 | + | |
| 36 | + @Mock | |
| 37 | + private MqttClient mqttClient; | |
| 38 | + | |
| 39 | + private MockedStatic<SpringUtil> springUtilMockedStatic; | |
| 40 | + private IotEmqxProtocol protocol; | |
| 41 | + | |
| 42 | + @BeforeEach | |
| 43 | + public void setUp() { | |
| 44 | + // 构造方法里的 handler 通过 SpringUtil 获取 Bean:这里不需要真实的 Bean | |
| 45 | + springUtilMockedStatic = mockStatic(SpringUtil.class); | |
| 46 | + IotGatewayProperties.ProtocolProperties properties = new IotGatewayProperties.ProtocolProperties() | |
| 47 | + .setId("emqx-test").setPort(18083).setEmqx(new IotEmqxConfig()); | |
| 48 | + protocol = new IotEmqxProtocol(properties); | |
| 49 | + } | |
| 50 | + | |
| 51 | + @AfterEach | |
| 52 | + public void tearDown() { | |
| 53 | + springUtilMockedStatic.close(); | |
| 54 | + } | |
| 55 | + | |
| 56 | + @Test | |
| 57 | + public void testPublishMessageAsync_success() { | |
| 58 | + // mock 方法:已连接,publish 成功 | |
| 59 | + ReflectUtil.setFieldValue(protocol, "mqttClient", mqttClient); | |
| 60 | + when(mqttClient.isConnected()).thenReturn(true); | |
| 61 | + when(mqttClient.publish(eq(RAW_PUSH_TOPIC), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false))) | |
| 62 | + .thenReturn(Future.succeededFuture(1)); | |
| 63 | + | |
| 64 | + // 调用 | |
| 65 | + Future<Void> future = protocol.publishMessageAsync(RAW_PUSH_TOPIC, BYTES); | |
| 66 | + // 断言 | |
| 67 | + assertTrue(future.succeeded()); | |
| 68 | + ArgumentCaptor<Buffer> payloadCaptor = ArgumentCaptor.forClass(Buffer.class); | |
| 69 | + verify(mqttClient).publish(eq(RAW_PUSH_TOPIC), payloadCaptor.capture(), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false)); | |
| 70 | + assertArrayEquals(BYTES, payloadCaptor.getValue().getBytes()); | |
| 71 | + } | |
| 72 | + | |
| 73 | + @Test | |
| 74 | + public void testPublishMessageAsync_notConnected() { | |
| 75 | + // 调用:MQTT Client 未创建 | |
| 76 | + Future<Void> future = protocol.publishMessageAsync(RAW_PUSH_TOPIC, BYTES); | |
| 77 | + // 断言 | |
| 78 | + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 79 | + | |
| 80 | + // mock 方法:MQTT Client 已断开 | |
| 81 | + ReflectUtil.setFieldValue(protocol, "mqttClient", mqttClient); | |
| 82 | + when(mqttClient.isConnected()).thenReturn(false); | |
| 83 | + // 调用,并断言 | |
| 84 | + assertFailed(protocol.publishMessageAsync(RAW_PUSH_TOPIC, BYTES), DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 85 | + verify(mqttClient, never()).publish(any(), any(Buffer.class), any(), anyBoolean(), anyBoolean()); | |
| 86 | + } | |
| 87 | + | |
| 88 | + @Test | |
| 89 | + public void testPublishMessageAsync_publishFailed() { | |
| 90 | + // mock 方法:已连接,但 publish 异步失败 | |
| 91 | + ReflectUtil.setFieldValue(protocol, "mqttClient", mqttClient); | |
| 92 | + when(mqttClient.isConnected()).thenReturn(true); | |
| 93 | + when(mqttClient.publish(eq(RAW_PUSH_TOPIC), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false))) | |
| 94 | + .thenReturn(Future.failedFuture(new IllegalStateException("Connection is closed"))); | |
| 95 | + | |
| 96 | + // 调用 | |
| 97 | + Future<Void> future = protocol.publishMessageAsync(RAW_PUSH_TOPIC, BYTES); | |
| 98 | + // 断言:异步失败转成 106,而不是成功 | |
| 99 | + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 100 | + } | |
| 101 | + | |
| 102 | + private static void assertFailed(Future<Void> future, ErrorCode errorCode) { | |
| 103 | + assertTrue(future.failed()); | |
| 104 | + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause()); | |
| 105 | + assertEquals(errorCode.getCode(), exception.getCode()); | |
| 106 | + } | |
| 107 | + | |
| 108 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpUpstreamHandlerTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream; | |
| 2 | + | |
| 3 | +import cn.hutool.extra.spring.SpringUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 5 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; | |
| 9 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 10 | +import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol; | |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService; | |
| 12 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 13 | +import io.vertx.core.Context; | |
| 14 | +import io.vertx.core.Vertx; | |
| 15 | +import io.vertx.core.VertxOptions; | |
| 16 | +import io.vertx.core.buffer.Buffer; | |
| 17 | +import io.vertx.core.http.HttpHeaders; | |
| 18 | +import io.vertx.core.http.HttpServerRequest; | |
| 19 | +import io.vertx.core.http.HttpServerResponse; | |
| 20 | +import io.vertx.ext.web.RequestBody; | |
| 21 | +import io.vertx.ext.web.RoutingContext; | |
| 22 | +import org.junit.jupiter.api.AfterEach; | |
| 23 | +import org.junit.jupiter.api.BeforeEach; | |
| 24 | +import org.junit.jupiter.api.Test; | |
| 25 | +import org.mockito.ArgumentCaptor; | |
| 26 | +import org.mockito.Mock; | |
| 27 | +import org.mockito.MockedStatic; | |
| 28 | + | |
| 29 | +import java.time.Duration; | |
| 30 | +import java.util.Map; | |
| 31 | +import java.util.concurrent.CountDownLatch; | |
| 32 | +import java.util.concurrent.TimeUnit; | |
| 33 | +import java.util.concurrent.atomic.AtomicBoolean; | |
| 34 | + | |
| 35 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; | |
| 36 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; | |
| 37 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 38 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; | |
| 39 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_OUTPUT_INVALID; | |
| 40 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_REJECTED; | |
| 41 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT; | |
| 42 | +import static org.junit.jupiter.api.Assertions.*; | |
| 43 | +import static org.mockito.AdditionalMatchers.aryEq; | |
| 44 | +import static org.mockito.ArgumentMatchers.any; | |
| 45 | +import static org.mockito.ArgumentMatchers.anyLong; | |
| 46 | +import static org.mockito.ArgumentMatchers.anyString; | |
| 47 | +import static org.mockito.ArgumentMatchers.eq; | |
| 48 | +import static org.mockito.Mockito.*; | |
| 49 | + | |
| 50 | +/** | |
| 51 | + * {@link IotHttpUpstreamHandler} 的单元测试 | |
| 52 | + * | |
| 53 | + * @author 芋道源码 | |
| 54 | + */ | |
| 55 | +public class IotHttpUpstreamHandlerTest extends BaseMockitoUnitTest { | |
| 56 | + | |
| 57 | + private static final String PRODUCT_KEY = "pk"; | |
| 58 | + private static final String DEVICE_NAME = "dn"; | |
| 59 | + private static final String TOKEN = "token"; | |
| 60 | + private static final String SERVER_ID = "http-server"; | |
| 61 | + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, 0x03}; | |
| 62 | + | |
| 63 | + @Mock | |
| 64 | + private IotDeviceTokenService deviceTokenService; | |
| 65 | + @Mock | |
| 66 | + private IotDeviceMessageService deviceMessageService; | |
| 67 | + @Mock | |
| 68 | + private IotHttpProtocol protocol; | |
| 69 | + @Mock | |
| 70 | + private RoutingContext context; | |
| 71 | + @Mock | |
| 72 | + private HttpServerRequest request; | |
| 73 | + @Mock | |
| 74 | + private RequestBody requestBody; | |
| 75 | + | |
| 76 | + private final IotGatewayProperties gatewayProperties = new IotGatewayProperties(); | |
| 77 | + /** | |
| 78 | + * 响应是否已结束:end 之后 ended() 返回 true,与真实的 HttpServerResponse 一致 | |
| 79 | + */ | |
| 80 | + private final AtomicBoolean responseEnded = new AtomicBoolean(); | |
| 81 | + private HttpServerResponse response; | |
| 82 | + private MockedStatic<SpringUtil> springUtilMockedStatic; | |
| 83 | + private Vertx vertx; | |
| 84 | + | |
| 85 | + @BeforeEach | |
| 86 | + public void setUp() { | |
| 87 | + springUtilMockedStatic = mockStatic(SpringUtil.class); | |
| 88 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceTokenService.class)).thenReturn(deviceTokenService); | |
| 89 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceMessageService.class)).thenReturn(deviceMessageService); | |
| 90 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotGatewayProperties.class)).thenReturn(gatewayProperties); | |
| 91 | + when(protocol.getServerId()).thenReturn(SERVER_ID); | |
| 92 | + // 链式调用(setStatusCode、putHeader)返回自身 | |
| 93 | + response = mock(HttpServerResponse.class, RETURNS_SELF); | |
| 94 | + lenient().when(response.ended()).thenAnswer(invocation -> responseEnded.get()); | |
| 95 | + lenient().doAnswer(invocation -> { | |
| 96 | + responseEnded.set(true); | |
| 97 | + return null; | |
| 98 | + }).when(response).end(anyString()); | |
| 99 | + vertx = Vertx.vertx(); | |
| 100 | + } | |
| 101 | + | |
| 102 | + @AfterEach | |
| 103 | + public void tearDown() { | |
| 104 | + springUtilMockedStatic.close(); | |
| 105 | + vertx.close(); | |
| 106 | + } | |
| 107 | + | |
| 108 | + @Test | |
| 109 | + public void testHandle_rawPostSuccess() throws Exception { | |
| 110 | + // 准备参数 | |
| 111 | + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); | |
| 112 | + mockRawPostRequest(); | |
| 113 | + // mock 方法:记录执行线程 | |
| 114 | + AtomicBoolean onWorker = new AtomicBoolean(); | |
| 115 | + when(deviceMessageService.sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID), | |
| 116 | + eq(IotProtocolTypeEnum.HTTP), anyLong())).thenAnswer(invocation -> { | |
| 117 | + onWorker.set(Context.isOnWorkerThread()); | |
| 118 | + return "M1"; | |
| 119 | + }); | |
| 120 | + | |
| 121 | + // 调用 | |
| 122 | + handleOnEventLoop(handler); | |
| 123 | + | |
| 124 | + // 断言:只写一次 JSON 结果,data = {messageId} | |
| 125 | + Map<String, Object> json = captureResponse(); | |
| 126 | + assertEquals(0, json.get("code")); | |
| 127 | + @SuppressWarnings("unchecked") | |
| 128 | + Map<String, Object> data = (Map<String, Object>) json.get("data"); | |
| 129 | + assertEquals("M1", data.get("messageId")); | |
| 130 | + assertEquals(1, data.size()); | |
| 131 | + verify(response, never()).end(any(Buffer.class)); | |
| 132 | + // 断言:在 worker 上处理 | |
| 133 | + assertTrue(onWorker.get()); | |
| 134 | + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any()); | |
| 135 | + } | |
| 136 | + | |
| 137 | + @Test | |
| 138 | + public void testHandle_rawPostReceivedBeforeQueue() throws Exception { | |
| 139 | + // 准备参数:worker 池只有 1 个线程,并被占住,请求只能排队 | |
| 140 | + vertx.close(); | |
| 141 | + vertx = Vertx.vertx(new VertxOptions().setWorkerPoolSize(1)); | |
| 142 | + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); | |
| 143 | + mockRawPostRequest(); | |
| 144 | + CountDownLatch blocked = new CountDownLatch(1); | |
| 145 | + CountDownLatch release = new CountDownLatch(1); | |
| 146 | + vertx.executeBlocking(() -> { | |
| 147 | + blocked.countDown(); | |
| 148 | + return release.await(10, TimeUnit.SECONDS); | |
| 149 | + }, false); | |
| 150 | + assertTrue(blocked.await(5, TimeUnit.SECONDS)); | |
| 151 | + // mock 方法 | |
| 152 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenReturn("M5"); | |
| 153 | + | |
| 154 | + long beginNanos; | |
| 155 | + long queuedNanos; | |
| 156 | + try { | |
| 157 | + // 调用:请求排在 worker 队列中 | |
| 158 | + beginNanos = System.nanoTime(); | |
| 159 | + handleOnEventLoop(handler); | |
| 160 | + queuedNanos = System.nanoTime(); | |
| 161 | + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); | |
| 162 | + } finally { | |
| 163 | + release.countDown(); | |
| 164 | + } | |
| 165 | + | |
| 166 | + // 断言:放行后照常响应;传给 service 的收到时刻在交给 worker 之前取得(排队的时间计入截止),不是 worker 开始执行时 | |
| 167 | + assertEquals(0, captureResponse().get("code")); | |
| 168 | + ArgumentCaptor<Long> receivedCaptor = ArgumentCaptor.forClass(Long.class); | |
| 169 | + verify(deviceMessageService).sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID), | |
| 170 | + eq(IotProtocolTypeEnum.HTTP), receivedCaptor.capture()); | |
| 171 | + assertTrue(receivedCaptor.getValue() >= beginNanos); | |
| 172 | + assertTrue(receivedCaptor.getValue() <= queuedNanos); | |
| 173 | + } | |
| 174 | + | |
| 175 | + @Test | |
| 176 | + public void testHandle_rawPostTimerCancelled() throws Exception { | |
| 177 | + // 准备参数:同步截止 100ms,处理很快完成 | |
| 178 | + gatewayProperties.getScript().setDeadline(Duration.ofMillis(100)); | |
| 179 | + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); | |
| 180 | + mockRawPostRequest(); | |
| 181 | + // mock 方法 | |
| 182 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenReturn("M2"); | |
| 183 | + | |
| 184 | + // 调用 | |
| 185 | + handleOnEventLoop(handler); | |
| 186 | + | |
| 187 | + // 断言:先返回成功;过了截止时间也不再写超时 | |
| 188 | + assertEquals(0, captureResponse().get("code")); | |
| 189 | + Thread.sleep(300); | |
| 190 | + verify(response, times(1)).end(anyString()); | |
| 191 | + } | |
| 192 | + | |
| 193 | + @Test | |
| 194 | + public void testHandle_rawPostServiceException() throws Exception { | |
| 195 | + // 准备参数 | |
| 196 | + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); | |
| 197 | + mockRawPostRequest(); | |
| 198 | + // mock 方法:脚本繁忙(service 不投递,只记错误日志) | |
| 199 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) | |
| 200 | + .thenThrow(exception(SCRIPT_REJECTED)); | |
| 201 | + | |
| 202 | + // 调用 | |
| 203 | + handleOnEventLoop(handler); | |
| 204 | + | |
| 205 | + // 断言:返回对应的错误码 | |
| 206 | + Map<String, Object> json = captureResponse(); | |
| 207 | + assertEquals(SCRIPT_REJECTED.getCode(), json.get("code")); | |
| 208 | + assertEquals(SCRIPT_REJECTED.getMsg(), json.get("msg")); | |
| 209 | + } | |
| 210 | + | |
| 211 | + @Test | |
| 212 | + public void testHandle_rawPostOutputInvalid() throws Exception { | |
| 213 | + // 准备参数 | |
| 214 | + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); | |
| 215 | + mockRawPostRequest(); | |
| 216 | + // mock 方法 | |
| 217 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) | |
| 218 | + .thenThrow(exception(SCRIPT_OUTPUT_INVALID, "脚本输出 不允许包含字段 raw")); | |
| 219 | + | |
| 220 | + // 调用 | |
| 221 | + handleOnEventLoop(handler); | |
| 222 | + | |
| 223 | + // 断言 | |
| 224 | + Map<String, Object> json = captureResponse(); | |
| 225 | + assertEquals(SCRIPT_OUTPUT_INVALID.getCode(), json.get("code")); | |
| 226 | + assertEquals("数据解析脚本输出不合法:脚本输出 不允许包含字段 raw", json.get("msg")); | |
| 227 | + } | |
| 228 | + | |
| 229 | + @Test | |
| 230 | + public void testHandle_rawPostPayloadInvalid() throws Exception { | |
| 231 | + // 准备参数 | |
| 232 | + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); | |
| 233 | + mockRawPostRequest(); | |
| 234 | + // mock 方法:载荷长度不合法(不投递) | |
| 235 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) | |
| 236 | + .thenThrow(invalidParamException("raw 载荷长度必须在 1~{} 字节之间", 4096)); | |
| 237 | + | |
| 238 | + // 调用 | |
| 239 | + handleOnEventLoop(handler); | |
| 240 | + | |
| 241 | + // 断言 | |
| 242 | + assertEquals(BAD_REQUEST.getCode(), captureResponse().get("code")); | |
| 243 | + } | |
| 244 | + | |
| 245 | + @Test | |
| 246 | + public void testHandle_rawPostUnknownException() throws Exception { | |
| 247 | + // 准备参数 | |
| 248 | + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); | |
| 249 | + mockRawPostRequest(); | |
| 250 | + // mock 方法 | |
| 251 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) | |
| 252 | + .thenThrow(new IllegalStateException("boom")); | |
| 253 | + | |
| 254 | + // 调用 | |
| 255 | + handleOnEventLoop(handler); | |
| 256 | + | |
| 257 | + // 断言 | |
| 258 | + assertEquals(INTERNAL_SERVER_ERROR.getCode(), captureResponse().get("code")); | |
| 259 | + } | |
| 260 | + | |
| 261 | + @Test | |
| 262 | + public void testHandle_rawPostSyncDeadline() throws Exception { | |
| 263 | + // 准备参数:同步截止 100ms,处理阻塞到截止之后 | |
| 264 | + gatewayProperties.getScript().setDeadline(Duration.ofMillis(100)); | |
| 265 | + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); | |
| 266 | + mockRawPostRequest(); | |
| 267 | + CountDownLatch release = new CountDownLatch(1); | |
| 268 | + // mock 方法 | |
| 269 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenAnswer(invocation -> { | |
| 270 | + assertTrue(release.await(10, TimeUnit.SECONDS)); | |
| 271 | + return "M-late"; | |
| 272 | + }); | |
| 273 | + | |
| 274 | + try { | |
| 275 | + // 调用 | |
| 276 | + handleOnEventLoop(handler); | |
| 277 | + | |
| 278 | + // 断言:到期先返回 SCRIPT_TIMEOUT | |
| 279 | + Map<String, Object> json = captureResponse(); | |
| 280 | + assertEquals(SCRIPT_TIMEOUT.getCode(), json.get("code")); | |
| 281 | + } finally { | |
| 282 | + release.countDown(); | |
| 283 | + } | |
| 284 | + // 断言:之后完成的结果不再写(第二次检查 ended() 时丢弃) | |
| 285 | + verify(response, timeout(5000).times(2)).ended(); | |
| 286 | + verify(response, times(1)).end(anyString()); | |
| 287 | + verify(response, never()).end(any(Buffer.class)); | |
| 288 | + } | |
| 289 | + | |
| 290 | + @Test | |
| 291 | + public void testHandle_rawPostClientClosed() throws Exception { | |
| 292 | + // 准备参数 | |
| 293 | + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); | |
| 294 | + mockRawPostRequest(); | |
| 295 | + // mock 方法:客户端在处理期间断开 | |
| 296 | + when(response.closed()).thenReturn(true); | |
| 297 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenReturn("M3"); | |
| 298 | + | |
| 299 | + // 调用 | |
| 300 | + handleOnEventLoop(handler); | |
| 301 | + | |
| 302 | + // 断言:丢弃结果,不写响应 | |
| 303 | + verify(response, timeout(5000)).closed(); | |
| 304 | + verify(response, never()).end(anyString()); | |
| 305 | + verify(response, never()).end(any(Buffer.class)); | |
| 306 | + } | |
| 307 | + | |
| 308 | + @Test | |
| 309 | + public void testHandle_standardPost() throws Exception { | |
| 310 | + // 准备参数:标准 JSON 上报 | |
| 311 | + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); | |
| 312 | + mockStandardPostRequest(); | |
| 313 | + // mock 方法:记录执行线程 | |
| 314 | + AtomicBoolean onWorker = new AtomicBoolean(); | |
| 315 | + doAnswer(invocation -> { | |
| 316 | + onWorker.set(Context.isOnWorkerThread()); | |
| 317 | + ((IotDeviceMessage) invocation.getArgument(0)).setId("M4"); | |
| 318 | + return null; | |
| 319 | + }).when(deviceMessageService).sendDeviceMessage(any(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); | |
| 320 | + | |
| 321 | + // 调用 | |
| 322 | + handleOnEventLoop(handler); | |
| 323 | + | |
| 324 | + // 断言:发送完成后才响应,data = {messageId} | |
| 325 | + Map<String, Object> json = captureResponse(); | |
| 326 | + assertEquals(0, json.get("code")); | |
| 327 | + @SuppressWarnings("unchecked") | |
| 328 | + Map<String, Object> data = (Map<String, Object>) json.get("data"); | |
| 329 | + assertEquals("M4", data.get("messageId")); | |
| 330 | + verify(response, times(1)).end(anyString()); | |
| 331 | + // 断言:获取设备、发送总线在 worker 上执行,不阻塞 event loop | |
| 332 | + assertTrue(onWorker.get()); | |
| 333 | + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); | |
| 334 | + } | |
| 335 | + | |
| 336 | + @Test | |
| 337 | + public void testHandle_standardPostServiceException() throws Exception { | |
| 338 | + // 准备参数 | |
| 339 | + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); | |
| 340 | + mockStandardPostRequest(); | |
| 341 | + // mock 方法:设备不存在 | |
| 342 | + doThrow(invalidParamException("设备不存在")).when(deviceMessageService) | |
| 343 | + .sendDeviceMessage(any(), any(), any(), any()); | |
| 344 | + | |
| 345 | + // 调用 | |
| 346 | + handleOnEventLoop(handler); | |
| 347 | + | |
| 348 | + // 断言:与原来一致,返回 CommonResult 的错误码(HTTP 状态码不变) | |
| 349 | + Map<String, Object> json = captureResponse(); | |
| 350 | + assertEquals(BAD_REQUEST.getCode(), json.get("code")); | |
| 351 | + assertEquals("设备不存在", json.get("msg")); | |
| 352 | + } | |
| 353 | + | |
| 354 | + @Test | |
| 355 | + public void testHandle_standardPostUnknownException() throws Exception { | |
| 356 | + // 准备参数 | |
| 357 | + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); | |
| 358 | + mockStandardPostRequest(); | |
| 359 | + // mock 方法 | |
| 360 | + doThrow(new IllegalStateException("boom")).when(deviceMessageService) | |
| 361 | + .sendDeviceMessage(any(), any(), any(), any()); | |
| 362 | + | |
| 363 | + // 调用 | |
| 364 | + handleOnEventLoop(handler); | |
| 365 | + | |
| 366 | + // 断言 | |
| 367 | + assertEquals(INTERNAL_SERVER_ERROR.getCode(), captureResponse().get("code")); | |
| 368 | + } | |
| 369 | + | |
| 370 | + // ========== 辅助方法 ========== | |
| 371 | + | |
| 372 | + private void mockRawPostRequest() { | |
| 373 | + mockRequest("thing/raw/post", "application/octet-stream", PAYLOAD); | |
| 374 | + when(context.vertx()).thenReturn(vertx); | |
| 375 | + } | |
| 376 | + | |
| 377 | + private void mockStandardPostRequest() { | |
| 378 | + mockRequest("thing/property/post", "application/json", | |
| 379 | + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1}}".getBytes()); | |
| 380 | + when(context.vertx()).thenReturn(vertx); | |
| 381 | + } | |
| 382 | + | |
| 383 | + private void mockRequest(String method, String contentType, byte[] body) { | |
| 384 | + // 请求:已认证(token 与路径一致) | |
| 385 | + when(context.request()).thenReturn(request); | |
| 386 | + when(context.response()).thenReturn(response); | |
| 387 | + lenient().when(request.path()).thenReturn("/topic/sys/" + PRODUCT_KEY + "/" + DEVICE_NAME + "/" + method); | |
| 388 | + when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn(TOKEN); | |
| 389 | + lenient().when(request.getHeader(HttpHeaders.CONTENT_TYPE)).thenReturn(contentType); | |
| 390 | + when(context.pathParam("productKey")).thenReturn(PRODUCT_KEY); | |
| 391 | + when(context.pathParam("deviceName")).thenReturn(DEVICE_NAME); | |
| 392 | + when(context.pathParam("*")).thenReturn(method); | |
| 393 | + when(context.body()).thenReturn(requestBody); | |
| 394 | + when(requestBody.buffer()).thenReturn(Buffer.buffer(body)); | |
| 395 | + when(deviceTokenService.verifyToken(TOKEN)).thenReturn(new IotDeviceIdentity(PRODUCT_KEY, DEVICE_NAME)); | |
| 396 | + } | |
| 397 | + | |
| 398 | + /** | |
| 399 | + * 在 Vert.x event loop 上执行 handle,与真实的 HTTP 请求一致 | |
| 400 | + */ | |
| 401 | + private void handleOnEventLoop(IotHttpUpstreamHandler handler) throws InterruptedException { | |
| 402 | + CountDownLatch latch = new CountDownLatch(1); | |
| 403 | + vertx.runOnContext(v -> { | |
| 404 | + try { | |
| 405 | + handler.handle(context); | |
| 406 | + } finally { | |
| 407 | + latch.countDown(); | |
| 408 | + } | |
| 409 | + }); | |
| 410 | + assertTrue(latch.await(5, TimeUnit.SECONDS)); | |
| 411 | + } | |
| 412 | + | |
| 413 | + /** | |
| 414 | + * 等待并返回写出的 JSON 响应(只写一次) | |
| 415 | + */ | |
| 416 | + private Map<String, Object> captureResponse() { | |
| 417 | + ArgumentCaptor<String> jsonCaptor = ArgumentCaptor.forClass(String.class); | |
| 418 | + verify(response, timeout(5000)).end(jsonCaptor.capture()); | |
| 419 | + return JsonUtils.parseMap(jsonCaptor.getValue()); | |
| 420 | + } | |
| 421 | + | |
| 422 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusCommonUtilsTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.HexUtil; | |
| 4 | +import cn.hutool.core.util.StrUtil; | |
| 5 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 6 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; | |
| 7 | +import org.junit.jupiter.api.Test; | |
| 8 | + | |
| 9 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_EXCEPTION; | |
| 10 | +import static org.junit.jupiter.api.Assertions.*; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * {@link IotModbusCommonUtils} 的单元测试 | |
| 14 | + * | |
| 15 | + * @author 芋道源码 | |
| 16 | + */ | |
| 17 | +public class IotModbusCommonUtilsTest { | |
| 18 | + | |
| 19 | + @Test | |
| 20 | + public void testBuildResponsePdu_exception() { | |
| 21 | + // 准备参数:解码器已把 0x83 还原为原功能码 3,异常码单独存放 | |
| 22 | + IotModbusFrame frame = new IotModbusFrame().setSlaveId(1).setFunctionCode(3) | |
| 23 | + .setPdu(new byte[]{0x02}).setExceptionCode(2); | |
| 24 | + | |
| 25 | + // 调用 + 断言:还原 0x80 异常位 | |
| 26 | + assertArrayEquals(HexUtil.decodeHex("8302"), IotModbusCommonUtils.buildResponsePdu(frame)); | |
| 27 | + } | |
| 28 | + | |
| 29 | + @Test | |
| 30 | + public void testBuildResponsePdu_normal() { | |
| 31 | + // 准备参数:解码器的 pdu 是功能码之后的数据(含 byteCount) | |
| 32 | + IotModbusFrame frame = new IotModbusFrame().setSlaveId(1).setFunctionCode(3) | |
| 33 | + .setPdu(HexUtil.decodeHex("0400FA0258")); | |
| 34 | + | |
| 35 | + // 调用 + 断言 | |
| 36 | + assertArrayEquals(HexUtil.decodeHex("030400FA0258"), IotModbusCommonUtils.buildResponsePdu(frame)); | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Test | |
| 40 | + public void testIsResponseShapeValid() { | |
| 41 | + // FC03 读 2 个寄存器:byteCount = 4 | |
| 42 | + assertTrue(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("0300100002"), | |
| 43 | + HexUtil.decodeHex("030400FA0258"))); | |
| 44 | + assertFalse(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("0300100002"), | |
| 45 | + HexUtil.decodeHex("030200FA"))); | |
| 46 | + // FC01 读 10 个线圈:byteCount = 2 | |
| 47 | + assertTrue(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("010000000A"), | |
| 48 | + HexUtil.decodeHex("01020103"))); | |
| 49 | + // FC06:原样回显 | |
| 50 | + assertTrue(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("06001000DC"), | |
| 51 | + HexUtil.decodeHex("06001000DC"))); | |
| 52 | + assertFalse(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("06001000DC"), | |
| 53 | + HexUtil.decodeHex("06001000DD"))); | |
| 54 | + // FC16:回显地址和数量 | |
| 55 | + assertTrue(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("100010000204000A0102"), | |
| 56 | + HexUtil.decodeHex("1000100002"))); | |
| 57 | + assertFalse(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("100010000204000A0102"), | |
| 58 | + HexUtil.decodeHex("1000100001"))); | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 62 | + public void testGetRawResponseError() { | |
| 63 | + // 异常响应 83 02:MODBUS_EXCEPTION,文案带异常码 | |
| 64 | + ErrorCode error = IotModbusCommonUtils.getRawResponseError(HexUtil.decodeHex("8302")); | |
| 65 | + assertNotNull(error); | |
| 66 | + assertEquals(MODBUS_EXCEPTION.getCode(), error.getCode()); | |
| 67 | + assertEquals(StrUtil.format(MODBUS_EXCEPTION.getMsg(), 2), error.getMsg()); | |
| 68 | + // 正常响应:没有设备错误 | |
| 69 | + assertNull(IotModbusCommonUtils.getRawResponseError(HexUtil.decodeHex("030400FA0258"))); | |
| 70 | + } | |
| 71 | + | |
| 72 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusTcpClientUtilsTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.HexUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 5 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 6 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager.ModbusConnection; | |
| 7 | +import com.ghgande.j2mod.modbus.ModbusIOException; | |
| 8 | +import com.ghgande.j2mod.modbus.ModbusSlaveException; | |
| 9 | +import com.ghgande.j2mod.modbus.io.AbstractModbusTransport; | |
| 10 | +import com.ghgande.j2mod.modbus.msg.ModbusRequest; | |
| 11 | +import com.ghgande.j2mod.modbus.msg.ModbusResponse; | |
| 12 | +import com.ghgande.j2mod.modbus.net.TCPMasterConnection; | |
| 13 | +import io.vertx.core.Future; | |
| 14 | +import io.vertx.core.Vertx; | |
| 15 | +import org.junit.jupiter.api.AfterEach; | |
| 16 | +import org.junit.jupiter.api.BeforeEach; | |
| 17 | +import org.junit.jupiter.api.Test; | |
| 18 | + | |
| 19 | +import java.io.ByteArrayInputStream; | |
| 20 | +import java.io.DataInputStream; | |
| 21 | +import java.io.IOException; | |
| 22 | +import java.net.ConnectException; | |
| 23 | +import java.net.SocketTimeoutException; | |
| 24 | +import java.util.concurrent.CountDownLatch; | |
| 25 | +import java.util.concurrent.TimeUnit; | |
| 26 | +import java.util.concurrent.atomic.AtomicReference; | |
| 27 | + | |
| 28 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 29 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; | |
| 30 | +import static org.junit.jupiter.api.Assertions.*; | |
| 31 | +import static org.mockito.ArgumentMatchers.any; | |
| 32 | +import static org.mockito.Mockito.*; | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * {@link IotModbusTcpClientUtils} 的单元测试 | |
| 36 | + * | |
| 37 | + * @author 芋道源码 | |
| 38 | + */ | |
| 39 | +public class IotModbusTcpClientUtilsTest { | |
| 40 | + | |
| 41 | + private static final int SLAVE_ID = 1; | |
| 42 | + /** | |
| 43 | + * 03 00 10 00 02:读 2 个保持寄存器 | |
| 44 | + */ | |
| 45 | + private static final String READ_PDU = "0300100002"; | |
| 46 | + | |
| 47 | + private Vertx vertx; | |
| 48 | + private TCPMasterConnection tcpConnection; | |
| 49 | + private AbstractModbusTransport transport; | |
| 50 | + private ModbusConnection connection; | |
| 51 | + | |
| 52 | + /** | |
| 53 | + * 写出时请求上的事务号(setRequest 分配) | |
| 54 | + */ | |
| 55 | + private final AtomicReference<Integer> sentTransactionId = new AtomicReference<>(); | |
| 56 | + /** | |
| 57 | + * 写出的请求:execute 结束后它的事务号会被 j2mod 自增 | |
| 58 | + */ | |
| 59 | + private final AtomicReference<ModbusRequest> sentRequest = new AtomicReference<>(); | |
| 60 | + | |
| 61 | + @BeforeEach | |
| 62 | + public void setUp() throws Exception { | |
| 63 | + vertx = Vertx.vertx(); | |
| 64 | + tcpConnection = mock(TCPMasterConnection.class); | |
| 65 | + transport = mock(AbstractModbusTransport.class); | |
| 66 | + when(tcpConnection.isConnected()).thenReturn(true); | |
| 67 | + when(tcpConnection.getModbusTransport()).thenReturn(transport); | |
| 68 | + when(tcpConnection.getTimeout()).thenReturn(1000); | |
| 69 | + doAnswer(invocation -> { | |
| 70 | + ModbusRequest request = invocation.getArgument(0); | |
| 71 | + sentRequest.set(request); | |
| 72 | + sentTransactionId.set(request.getTransactionID()); | |
| 73 | + return null; | |
| 74 | + }).when(transport).writeRequest(any(ModbusRequest.class)); | |
| 75 | + connection = new ModbusConnection(); | |
| 76 | + connection.setTcpConnection(tcpConnection); | |
| 77 | + connection.setContext(vertx.getOrCreateContext()); | |
| 78 | + } | |
| 79 | + | |
| 80 | + @AfterEach | |
| 81 | + public void tearDown() throws Exception { | |
| 82 | + vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Test | |
| 86 | + public void testIsTransportFailure() { | |
| 87 | + // 传输失败:沿 cause 链找到 ModbusIOException / IOException(read / write 会包一层 RuntimeException) | |
| 88 | + assertTrue(IotModbusTcpClientUtils.isTransportFailure( | |
| 89 | + new RuntimeException("Modbus 读取失败", new ModbusIOException("Socket timeout reading response - Read timed out")))); | |
| 90 | + assertTrue(IotModbusTcpClientUtils.isTransportFailure(new RuntimeException(new SocketTimeoutException()))); | |
| 91 | + assertTrue(IotModbusTcpClientUtils.isTransportFailure(new ConnectException("Connection refused"))); | |
| 92 | + // 设备异常响应:链路正常 | |
| 93 | + assertFalse(IotModbusTcpClientUtils.isTransportFailure(new RuntimeException(new ModbusSlaveException(2)))); | |
| 94 | + // 网关自己判定的失败(排队过期、响应不合法、连接已失效) | |
| 95 | + assertFalse(IotModbusTcpClientUtils.isTransportFailure(exception(MODBUS_RESPONSE_INVALID))); | |
| 96 | + assertFalse(IotModbusTcpClientUtils.isTransportFailure(null)); | |
| 97 | + } | |
| 98 | + | |
| 99 | + @Test | |
| 100 | + public void testToRawFailure() { | |
| 101 | + // ServiceException:保留错误码 | |
| 102 | + assertEquals(MODBUS_DISCONNECTED.getCode(), | |
| 103 | + IotModbusTcpClientUtils.toRawFailure(exception(MODBUS_DISCONNECTED)).getCode()); | |
| 104 | + // j2mod 读超时(cause 已丢失,只能按消息判断)→ 110 | |
| 105 | + assertEquals(MODBUS_TIMEOUT.getCode(), IotModbusTcpClientUtils.toRawFailure(new RuntimeException( | |
| 106 | + new ModbusIOException("Executing transaction 00 failed (tried 1 times) Socket timeout reading response - Read timed out"))) | |
| 107 | + .getCode()); | |
| 108 | + // 其它 IO 失败 → 106 | |
| 109 | + assertEquals(DEVICE_OFFLINE_OR_WRITE_FAILED.getCode(), | |
| 110 | + IotModbusTcpClientUtils.toRawFailure(new ConnectException("Connection refused")).getCode()); | |
| 111 | + } | |
| 112 | + | |
| 113 | + // ========== 标准读写:保持原逻辑,超时不关闭共享连接 ========== | |
| 114 | + | |
| 115 | + @Test | |
| 116 | + public void testExecuteBlocking_standardTimeoutKeepSharedConnection() throws Exception { | |
| 117 | + // mock 方法:同一个物理连接上的从站 2 照常回正常响应 | |
| 118 | + mockResponse(0, SLAVE_ID + 1, "0304" + "00FA0258"); | |
| 119 | + | |
| 120 | + // 调用:从站 1 的标准读取超时(与 read 一样,把 j2mod 的 ModbusIOException 包一层 RuntimeException 抛出) | |
| 121 | + Future<int[]> timeout = await(connection.<int[]>executeBlocking(wire -> { | |
| 122 | + throw new RuntimeException("Modbus 读取失败 [slaveId=1]", | |
| 123 | + new ModbusIOException("Socket timeout reading response - Read timed out")); | |
| 124 | + })); | |
| 125 | + // 调用:之后从站 2 的标准读取 | |
| 126 | + Future<int[]> next = await(IotModbusTcpClientUtils.read(connection, SLAVE_ID + 1, 3, 0x10, 2)); | |
| 127 | + | |
| 128 | + // 断言:超时照常失败,但不关闭、不重连共享的物理连接;从站 2 的读取照常完成 | |
| 129 | + assertTrue(timeout.failed()); | |
| 130 | + assertTrue(IotModbusTcpClientUtils.isTransportFailure(timeout.cause())); | |
| 131 | + assertTrue(next.succeeded()); | |
| 132 | + assertArrayEquals(new int[]{0x00FA, 0x0258}, next.result()); | |
| 133 | + verify(tcpConnection, never()).close(); | |
| 134 | + verify(tcpConnection, never()).connect(); | |
| 135 | + } | |
| 136 | + | |
| 137 | + @Test | |
| 138 | + public void testExecuteBlocking_retired() throws Exception { | |
| 139 | + // 准备参数:连接已被 closeConnection 移出连接池 | |
| 140 | + connection.setRetired(true); | |
| 141 | + | |
| 142 | + // 调用 | |
| 143 | + Future<int[]> result = await(IotModbusTcpClientUtils.read(connection, SLAVE_ID, 3, 0x10, 2)); | |
| 144 | + | |
| 145 | + // 断言:排队中的标准任务不再执行,直接 115,不会重新连上被关闭的连接 | |
| 146 | + assertTrue(result.failed()); | |
| 147 | + assertInstanceOf(ServiceException.class, result.cause()); | |
| 148 | + assertEquals(MODBUS_DISCONNECTED.getCode(), ((ServiceException) result.cause()).getCode()); | |
| 149 | + verify(transport, never()).writeRequest(any(ModbusRequest.class)); | |
| 150 | + verify(tcpConnection, never()).connect(); | |
| 151 | + } | |
| 152 | + | |
| 153 | + // ========== executeRaw:传输失败 ========== | |
| 154 | + | |
| 155 | + @Test | |
| 156 | + public void testExecuteRaw_timeoutCloseInOrderedTask() throws Exception { | |
| 157 | + // mock 方法:读响应超时(j2mod 的 ModbusTCPTransport 把 SocketTimeoutException 转为 ModbusIOException) | |
| 158 | + when(transport.readResponse()).thenThrow(new ModbusIOException("Socket timeout reading response - Read timed out")); | |
| 159 | + | |
| 160 | + // 调用 | |
| 161 | + Future<byte[]> result = executeRaw(); | |
| 162 | + | |
| 163 | + // 断言:只尝试 1 次;透传的守卫在本有序任务内关闭连接(迟到的响应不会被下一个事务读到),转换为 110 | |
| 164 | + assertTrue(result.failed()); | |
| 165 | + assertEquals(MODBUS_TIMEOUT.getCode(), IotModbusTcpClientUtils.toRawFailure(result.cause()).getCode()); | |
| 166 | + verify(transport, times(1)).writeRequest(any(ModbusRequest.class)); | |
| 167 | + verify(tcpConnection, times(1)).close(); | |
| 168 | + } | |
| 169 | + | |
| 170 | + // ========== executeRaw:正常响应 ========== | |
| 171 | + | |
| 172 | + @Test | |
| 173 | + public void testExecuteRaw_normalUseSavedIdentity() throws Exception { | |
| 174 | + // mock 方法:设备按发出的事务号、unit 回正常响应 | |
| 175 | + mockResponse(0, SLAVE_ID, "0304" + "00FA0258"); | |
| 176 | + | |
| 177 | + // 调用 | |
| 178 | + Future<byte[]> result = executeRaw(); | |
| 179 | + | |
| 180 | + // 断言:用 execute 之前保存的身份核对通过;j2mod 已在 execute 之后把请求的事务号自增 | |
| 181 | + assertTrue(result.succeeded()); | |
| 182 | + assertArrayEquals(HexUtil.decodeHex("030400FA0258"), result.result()); | |
| 183 | + assertNotEquals(sentTransactionId.get().intValue(), sentRequest.get().getTransactionID()); | |
| 184 | + verify(tcpConnection, never()).close(); | |
| 185 | + // 断言:只尝试 1 次 | |
| 186 | + verify(transport, times(1)).writeRequest(any(ModbusRequest.class)); | |
| 187 | + } | |
| 188 | + | |
| 189 | + @Test | |
| 190 | + public void testExecuteRaw_normalUnitMismatch() throws Exception { | |
| 191 | + // mock 方法:事务号正确,unit 不对(j2mod 不核对 unit) | |
| 192 | + mockResponse(0, SLAVE_ID + 1, "0304" + "00FA0258"); | |
| 193 | + | |
| 194 | + // 调用 | |
| 195 | + Future<byte[]> result = executeRaw(); | |
| 196 | + | |
| 197 | + // 断言:114,并在本有序任务内关闭连接 | |
| 198 | + assertFailedWith(result, MODBUS_RESPONSE_INVALID.getCode()); | |
| 199 | + verify(tcpConnection, times(1)).close(); | |
| 200 | + } | |
| 201 | + | |
| 202 | + @Test | |
| 203 | + public void testExecuteRaw_normalTransactionIdMismatch() throws Exception { | |
| 204 | + // mock 方法:事务号不对(j2mod 核对正常响应的事务号,只尝试 1 次就失败) | |
| 205 | + mockResponse(1, SLAVE_ID, "0304" + "00FA0258"); | |
| 206 | + | |
| 207 | + // 调用 | |
| 208 | + Future<byte[]> result = executeRaw(); | |
| 209 | + | |
| 210 | + // 断言:传输失败,守卫在本有序任务内关闭连接;不重发 | |
| 211 | + assertTrue(result.failed()); | |
| 212 | + assertFalse(result.cause() instanceof ServiceException); | |
| 213 | + verify(tcpConnection, times(1)).close(); | |
| 214 | + verify(transport, times(1)).writeRequest(any(ModbusRequest.class)); | |
| 215 | + } | |
| 216 | + | |
| 217 | + @Test | |
| 218 | + public void testExecuteRaw_normalFunctionCodeMismatch() throws Exception { | |
| 219 | + // mock 方法:事务号、unit 正确,功能码不对 | |
| 220 | + mockResponse(0, SLAVE_ID, "0404" + "00FA0258"); | |
| 221 | + | |
| 222 | + // 调用 | |
| 223 | + Future<byte[]> result = executeRaw(); | |
| 224 | + | |
| 225 | + // 断言 | |
| 226 | + assertFailedWith(result, MODBUS_RESPONSE_INVALID.getCode()); | |
| 227 | + verify(tcpConnection, times(1)).close(); | |
| 228 | + } | |
| 229 | + | |
| 230 | + // ========== executeRaw:异常响应 ========== | |
| 231 | + | |
| 232 | + @Test | |
| 233 | + public void testExecuteRaw_exceptionCorrelated() throws Exception { | |
| 234 | + // mock 方法:设备按发出的事务号、unit 回 83 02 | |
| 235 | + mockResponse(0, SLAVE_ID, "8302"); | |
| 236 | + | |
| 237 | + // 调用 | |
| 238 | + Future<byte[]> result = executeRaw(); | |
| 239 | + | |
| 240 | + // 断言:取 transaction.getResponse() 的真实异常响应,还原为 83 02,作为成功结果;链路正常不关闭 | |
| 241 | + assertTrue(result.succeeded()); | |
| 242 | + assertArrayEquals(HexUtil.decodeHex("8302"), result.result()); | |
| 243 | + verify(tcpConnection, never()).close(); | |
| 244 | + } | |
| 245 | + | |
| 246 | + @Test | |
| 247 | + public void testExecuteRaw_exceptionTransactionIdMismatch() throws Exception { | |
| 248 | + // mock 方法:异常响应的事务号不对(j2mod 在核对事务号之前就抛出 ModbusSlaveException) | |
| 249 | + mockResponse(1, SLAVE_ID, "8302"); | |
| 250 | + | |
| 251 | + // 调用 | |
| 252 | + Future<byte[]> result = executeRaw(); | |
| 253 | + | |
| 254 | + // 断言:不按当前功能码 + 异常码拼出归属:114,并关闭连接 | |
| 255 | + assertFailedWith(result, MODBUS_RESPONSE_INVALID.getCode()); | |
| 256 | + verify(tcpConnection, times(1)).close(); | |
| 257 | + } | |
| 258 | + | |
| 259 | + @Test | |
| 260 | + public void testExecuteRaw_exceptionUnitOrFunctionCodeMismatch() throws Exception { | |
| 261 | + // mock 方法:unit 不对 | |
| 262 | + mockResponse(0, SLAVE_ID + 1, "8302"); | |
| 263 | + | |
| 264 | + // 调用 + 断言 | |
| 265 | + assertFailedWith(executeRaw(), MODBUS_RESPONSE_INVALID.getCode()); | |
| 266 | + verify(tcpConnection, times(1)).close(); | |
| 267 | + | |
| 268 | + // mock 方法:功能码不对(FC03 的请求收到 84 02) | |
| 269 | + mockResponse(0, SLAVE_ID, "8402"); | |
| 270 | + | |
| 271 | + // 调用 + 断言 | |
| 272 | + assertFailedWith(executeRaw(), MODBUS_RESPONSE_INVALID.getCode()); | |
| 273 | + verify(tcpConnection, times(2)).close(); | |
| 274 | + } | |
| 275 | + | |
| 276 | + // ========== 辅助方法 ========== | |
| 277 | + | |
| 278 | + /** | |
| 279 | + * mock 传输层的读响应:事务号 = 写出的事务号 + transactionIdOffset | |
| 280 | + * | |
| 281 | + * @param transactionIdOffset 事务号偏移(0 表示正确) | |
| 282 | + * @param unitId 响应的 unit | |
| 283 | + * @param pduHex 响应 PDU | |
| 284 | + */ | |
| 285 | + private void mockResponse(int transactionIdOffset, int unitId, String pduHex) throws Exception { | |
| 286 | + doAnswer(invocation -> { | |
| 287 | + byte[] pdu = HexUtil.decodeHex(pduHex); | |
| 288 | + int transactionId = (sentTransactionId.get() + transactionIdOffset) & 0xFFFF; | |
| 289 | + return readResponse(String.format("%04X0000%04X%02X", transactionId, pdu.length + 1, unitId) + pduHex); | |
| 290 | + }).when(transport).readResponse(); | |
| 291 | + } | |
| 292 | + | |
| 293 | + private Future<byte[]> executeRaw() throws InterruptedException { | |
| 294 | + IotModbusTx tx = IotModbusTx.ofClient(null, HexUtil.decodeHex(READ_PDU), | |
| 295 | + System.nanoTime() + TimeUnit.SECONDS.toNanos(60), null); | |
| 296 | + return await(IotModbusTcpClientUtils.executeRaw(connection, SLAVE_ID, tx)); | |
| 297 | + } | |
| 298 | + | |
| 299 | + /** | |
| 300 | + * 等待 Future 完成(成功或失败) | |
| 301 | + */ | |
| 302 | + private static <T> Future<T> await(Future<T> result) throws InterruptedException { | |
| 303 | + CountDownLatch latch = new CountDownLatch(1); | |
| 304 | + result.onComplete(ar -> latch.countDown()); | |
| 305 | + assertTrue(latch.await(5, TimeUnit.SECONDS)); | |
| 306 | + return result; | |
| 307 | + } | |
| 308 | + | |
| 309 | + /** | |
| 310 | + * 与 ModbusTCPTransport.readResponse 相同:按功能码创建标准响应,再从完整 MBAP 帧读入 | |
| 311 | + */ | |
| 312 | + private static ModbusResponse readResponse(String hexFrame) throws IOException { | |
| 313 | + byte[] frame = HexUtil.decodeHex(hexFrame); | |
| 314 | + ModbusResponse response = ModbusResponse.createModbusResponse(frame[7] & 0xFF); | |
| 315 | + response.readFrom(new DataInputStream(new ByteArrayInputStream(frame))); | |
| 316 | + // ModbusTCPTransport.readResponse 读完后再设置一次事务号 | |
| 317 | + response.setTransactionID(((frame[0] & 0xFF) << 8) | (frame[1] & 0xFF)); | |
| 318 | + return response; | |
| 319 | + } | |
| 320 | + | |
| 321 | + private static void assertFailedWith(Future<byte[]> result, Integer code) { | |
| 322 | + assertTrue(result.failed()); | |
| 323 | + assertInstanceOf(ServiceException.class, result.cause()); | |
| 324 | + assertEquals(code, ((ServiceException) result.cause()).getCode()); | |
| 325 | + } | |
| 326 | + | |
| 327 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusRawRequestTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.HexUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 5 | +import com.ghgande.j2mod.modbus.msg.ModbusResponse; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | + | |
| 8 | +import java.io.ByteArrayInputStream; | |
| 9 | +import java.io.ByteArrayOutputStream; | |
| 10 | +import java.io.DataInputStream; | |
| 11 | +import java.io.DataOutputStream; | |
| 12 | +import java.io.IOException; | |
| 13 | + | |
| 14 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_RESPONSE_INVALID; | |
| 15 | +import static org.junit.jupiter.api.Assertions.*; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * {@link IotModbusRawRequest} 的单元测试 | |
| 19 | + * | |
| 20 | + * @author 芋道源码 | |
| 21 | + */ | |
| 22 | +public class IotModbusRawRequestTest { | |
| 23 | + | |
| 24 | + @Test | |
| 25 | + public void testWriteTo_fc03() throws IOException { | |
| 26 | + // 准备参数 | |
| 27 | + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002")); | |
| 28 | + request.setUnitID(1); | |
| 29 | + request.setTransactionID(7); | |
| 30 | + | |
| 31 | + // 调用 | |
| 32 | + ByteArrayOutputStream out = new ByteArrayOutputStream(); | |
| 33 | + request.writeTo(new DataOutputStream(out)); | |
| 34 | + | |
| 35 | + // 断言:[MBAP: 事务号 7、协议 0、长度 6][unit 1][03 00 10 00 02] | |
| 36 | + assertArrayEquals(HexUtil.decodeHex("000700000006010300100002"), out.toByteArray()); | |
| 37 | + // ModbusTCPTransport.writeMessage 写出的是 getMessage():PDU 去掉功能码之后的部分 | |
| 38 | + assertArrayEquals(HexUtil.decodeHex("00100002"), request.getMessage()); | |
| 39 | + assertEquals(3, request.getFunctionCode()); | |
| 40 | + } | |
| 41 | + | |
| 42 | + @Test | |
| 43 | + public void testBuildResponsePdu_fc03() throws IOException { | |
| 44 | + // 准备参数 | |
| 45 | + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002")); | |
| 46 | + ModbusResponse response = readResponse("000700000007010304" + "00FA0258"); | |
| 47 | + | |
| 48 | + // 调用 + 断言 | |
| 49 | + assertArrayEquals(HexUtil.decodeHex("030400FA0258"), request.buildResponsePdu(response)); | |
| 50 | + } | |
| 51 | + | |
| 52 | + @Test | |
| 53 | + public void testBuildResponsePdu_fc06() throws IOException { | |
| 54 | + // 准备参数 | |
| 55 | + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("06001000DC")); | |
| 56 | + ModbusResponse response = readResponse("0007000000060106001000DC"); | |
| 57 | + | |
| 58 | + // 调用 + 断言:原样回显 | |
| 59 | + assertArrayEquals(HexUtil.decodeHex("06001000DC"), request.buildResponsePdu(response)); | |
| 60 | + } | |
| 61 | + | |
| 62 | + @Test | |
| 63 | + public void testBuildResponsePdu_fc10() throws IOException { | |
| 64 | + // 准备参数 | |
| 65 | + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("100010000204000A0102")); | |
| 66 | + ModbusResponse response = readResponse("000700000006011000100002"); | |
| 67 | + | |
| 68 | + // 调用 + 断言:回显地址和数量 | |
| 69 | + assertArrayEquals(HexUtil.decodeHex("1000100002"), request.buildResponsePdu(response)); | |
| 70 | + } | |
| 71 | + | |
| 72 | + @Test | |
| 73 | + public void testBuildResponsePdu_shapeInvalid() throws IOException { | |
| 74 | + // 准备参数:读 2 个寄存器,设备只回了 1 个 | |
| 75 | + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002")); | |
| 76 | + ModbusResponse response = readResponse("00070000000501030200FA"); | |
| 77 | + | |
| 78 | + // 调用 + 断言 | |
| 79 | + ServiceException ex = assertThrows(ServiceException.class, () -> request.buildResponsePdu(response)); | |
| 80 | + assertEquals(MODBUS_RESPONSE_INVALID.getCode(), ex.getCode()); | |
| 81 | + } | |
| 82 | + | |
| 83 | + @Test | |
| 84 | + public void testBuildResponsePdu_exception() throws IOException { | |
| 85 | + // 准备参数:transaction.getResponse() 拿到的真实异常响应 83 02 | |
| 86 | + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002")); | |
| 87 | + ModbusResponse response = readResponse("00070000000301" + "8302"); | |
| 88 | + | |
| 89 | + // 调用 + 断言:还原为 0x83 + 异常码 | |
| 90 | + assertArrayEquals(HexUtil.decodeHex("8302"), request.buildResponsePdu(response)); | |
| 91 | + } | |
| 92 | + | |
| 93 | + @Test | |
| 94 | + public void testBuildResponsePdu_exceptionFunctionCodeMismatch() throws IOException { | |
| 95 | + // 准备参数:FC03 的请求收到 84 02 | |
| 96 | + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002")); | |
| 97 | + ModbusResponse response = readResponse("00070000000301" + "8402"); | |
| 98 | + | |
| 99 | + // 调用 + 断言 | |
| 100 | + ServiceException ex = assertThrows(ServiceException.class, () -> request.buildResponsePdu(response)); | |
| 101 | + assertEquals(MODBUS_RESPONSE_INVALID.getCode(), ex.getCode()); | |
| 102 | + } | |
| 103 | + | |
| 104 | + @Test | |
| 105 | + public void testIsResponseOf_normal() throws IOException { | |
| 106 | + // 准备参数:发出的身份 = 事务号 7、unit 1、FC03 | |
| 107 | + ModbusResponse response = readResponse("000700000007010304" + "00FA0258"); | |
| 108 | + | |
| 109 | + // 调用 + 断言:逐项核对事务号、unit、功能码 | |
| 110 | + assertTrue(IotModbusRawRequest.isResponseOf(response, 7, 1, 3)); | |
| 111 | + assertFalse(IotModbusRawRequest.isResponseOf(response, 8, 1, 3)); | |
| 112 | + assertFalse(IotModbusRawRequest.isResponseOf(response, 7, 2, 3)); | |
| 113 | + assertFalse(IotModbusRawRequest.isResponseOf(response, 7, 1, 4)); | |
| 114 | + assertFalse(IotModbusRawRequest.isResponseOf(null, 7, 1, 3)); | |
| 115 | + } | |
| 116 | + | |
| 117 | + @Test | |
| 118 | + public void testIsResponseOf_exception() throws IOException { | |
| 119 | + // 准备参数:j2mod 不核对异常响应的事务号,这里同样逐项核对 | |
| 120 | + ModbusResponse response = readResponse("00070000000301" + "8302"); | |
| 121 | + | |
| 122 | + // 调用 + 断言:异常响应的功能码是 fc | 0x80 | |
| 123 | + assertTrue(IotModbusRawRequest.isResponseOf(response, 7, 1, 3)); | |
| 124 | + assertFalse(IotModbusRawRequest.isResponseOf(response, 6, 1, 3)); | |
| 125 | + assertFalse(IotModbusRawRequest.isResponseOf(response, 7, 2, 3)); | |
| 126 | + assertFalse(IotModbusRawRequest.isResponseOf(response, 7, 1, 4)); | |
| 127 | + } | |
| 128 | + | |
| 129 | + /** | |
| 130 | + * 与 ModbusTCPTransport.readResponse 相同:按功能码创建标准响应,再从完整 MBAP 帧读入 | |
| 131 | + */ | |
| 132 | + private static ModbusResponse readResponse(String hexFrame) throws IOException { | |
| 133 | + byte[] frame = HexUtil.decodeHex(hexFrame); | |
| 134 | + ModbusResponse response = ModbusResponse.createModbusResponse(frame[7] & 0xFF); | |
| 135 | + response.readFrom(new DataInputStream(new ByteArrayInputStream(frame))); | |
| 136 | + return response; | |
| 137 | + } | |
| 138 | + | |
| 139 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamSubscriberTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 6 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.IotModbusTcpClientProtocol; | |
| 7 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager.ModbusConnection; | |
| 9 | +import io.vertx.core.AsyncResult; | |
| 10 | +import io.vertx.core.Context; | |
| 11 | +import io.vertx.core.Future; | |
| 12 | +import io.vertx.core.Handler; | |
| 13 | +import org.junit.jupiter.api.BeforeEach; | |
| 14 | +import org.junit.jupiter.api.Test; | |
| 15 | +import org.junit.jupiter.api.extension.ExtendWith; | |
| 16 | +import org.mockito.Mock; | |
| 17 | +import org.mockito.junit.jupiter.MockitoExtension; | |
| 18 | + | |
| 19 | +import java.util.concurrent.Callable; | |
| 20 | +import java.util.concurrent.RejectedExecutionException; | |
| 21 | + | |
| 22 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; | |
| 23 | +import static org.junit.jupiter.api.Assertions.*; | |
| 24 | +import static org.mockito.ArgumentMatchers.any; | |
| 25 | +import static org.mockito.ArgumentMatchers.eq; | |
| 26 | +import static org.mockito.Mockito.*; | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * {@link IotModbusTcpClientDownstreamSubscriber} 的单元测试 | |
| 30 | + * | |
| 31 | + * @author 芋道源码 | |
| 32 | + */ | |
| 33 | +@ExtendWith(MockitoExtension.class) | |
| 34 | +public class IotModbusTcpClientDownstreamSubscriberTest { | |
| 35 | + | |
| 36 | + private static final Long DEVICE_ID = 1L; | |
| 37 | + /** | |
| 38 | + * 03 00 10 00 02:读 2 个保持寄存器 | |
| 39 | + */ | |
| 40 | + private static final byte[] READ_PDU = {0x03, 0x00, 0x10, 0x00, 0x02}; | |
| 41 | + | |
| 42 | + @Mock | |
| 43 | + private IotModbusTcpClientProtocol protocol; | |
| 44 | + @Mock | |
| 45 | + private IotModbusTcpClientDownstreamHandler downstreamHandler; | |
| 46 | + @Mock | |
| 47 | + private IotModbusTcpClientConnectionManager connectionManager; | |
| 48 | + @Mock | |
| 49 | + private IotMessageBus messageBus; | |
| 50 | + | |
| 51 | + private IotModbusTcpClientDownstreamSubscriber subscriber; | |
| 52 | + | |
| 53 | + @BeforeEach | |
| 54 | + public void setUp() { | |
| 55 | + subscriber = new IotModbusTcpClientDownstreamSubscriber(protocol, downstreamHandler, connectionManager, messageBus); | |
| 56 | + } | |
| 57 | + | |
| 58 | + @Test | |
| 59 | + public void testHandleRawMessage_pduInvalid() { | |
| 60 | + // 准备参数:开头多带了 slaveId 的 PDU(01 06 00 10 00 DC) | |
| 61 | + byte[] pdu = {0x01, 0x06, 0x00, 0x10, 0x00, (byte) 0xDC}; | |
| 62 | + | |
| 63 | + // 调用 | |
| 64 | + Future<Void> result = subscriber.handleRawMessage(buildMessage(), pdu, null); | |
| 65 | + | |
| 66 | + // 断言 | |
| 67 | + assertFailedWith(result, MODBUS_PDU_INVALID.getCode()); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Test | |
| 71 | + public void testHandleRawMessage_notConnected() { | |
| 72 | + // mock 方法 | |
| 73 | + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(null); | |
| 74 | + | |
| 75 | + // 调用 | |
| 76 | + Future<Void> result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null); | |
| 77 | + | |
| 78 | + // 断言 | |
| 79 | + assertFailedWith(result, DEVICE_OFFLINE_OR_WRITE_FAILED.getCode()); | |
| 80 | + } | |
| 81 | + | |
| 82 | + @Test | |
| 83 | + public void testHandleRawMessage_rawSlotFull() { | |
| 84 | + // 准备参数:同一连接已有 32 个 raw 在途或排队 | |
| 85 | + ModbusConnection connection = new ModbusConnection(); | |
| 86 | + connection.addDevice(DEVICE_ID, 1); | |
| 87 | + for (int i = 0; i < IotModbusTcpClientDownstreamSubscriber.MAX_RAW_OUTSTANDING; i++) { | |
| 88 | + assertTrue(connection.tryAcquireRawSlot(IotModbusTcpClientDownstreamSubscriber.MAX_RAW_OUTSTANDING)); | |
| 89 | + } | |
| 90 | + // mock 方法 | |
| 91 | + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection); | |
| 92 | + | |
| 93 | + // 调用:第 33 个 | |
| 94 | + Future<Void> result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null); | |
| 95 | + | |
| 96 | + // 断言:立即 113,不进入 executeBlocking(connection 没有 Context),名额不泄漏 | |
| 97 | + assertFailedWith(result, MODBUS_QUEUE_FULL.getCode()); | |
| 98 | + assertEquals(IotModbusTcpClientDownstreamSubscriber.MAX_RAW_OUTSTANDING, connection.getRawOutstanding().get()); | |
| 99 | + // 释放一个后可以再次占用 | |
| 100 | + connection.releaseRawSlot(); | |
| 101 | + assertTrue(connection.tryAcquireRawSlot(IotModbusTcpClientDownstreamSubscriber.MAX_RAW_OUTSTANDING)); | |
| 102 | + } | |
| 103 | + | |
| 104 | + @Test | |
| 105 | + @SuppressWarnings("unchecked") | |
| 106 | + public void testHandleRawMessage_submitRejected() { | |
| 107 | + // 准备参数:连接的 Context 已关闭,提交 executeBlocking 时同步拒绝 | |
| 108 | + Context context = mock(Context.class); | |
| 109 | + ModbusConnection connection = new ModbusConnection().setTimeout(1000).setContext(context); | |
| 110 | + connection.addDevice(DEVICE_ID, 1); | |
| 111 | + // mock 方法 | |
| 112 | + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection); | |
| 113 | + when(context.executeBlocking(any(Callable.class), eq(true))) | |
| 114 | + .thenThrow(new RejectedExecutionException("context closed")); | |
| 115 | + | |
| 116 | + // 调用:连续 2 次 | |
| 117 | + for (int i = 0; i < 2; i++) { | |
| 118 | + Future<Void> result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null); | |
| 119 | + | |
| 120 | + // 断言:返回失败 Future(由调用方记日志),名额只释放一次,不泄漏、不变负 | |
| 121 | + assertFailedWith(result, DEVICE_OFFLINE_OR_WRITE_FAILED.getCode()); | |
| 122 | + assertEquals(0, connection.getRawOutstanding().get()); | |
| 123 | + } | |
| 124 | + // 断言:没有进入事务,不回复 biz | |
| 125 | + verify(context, times(2)).executeBlocking(any(Callable.class), eq(true)); | |
| 126 | + verify(protocol, never()).getVertx(); | |
| 127 | + } | |
| 128 | + | |
| 129 | + @Test | |
| 130 | + @SuppressWarnings("unchecked") | |
| 131 | + public void testHandleRawMessage_onCompleteRejected() { | |
| 132 | + // 准备参数:提交成功,但注册完成回调时同步拒绝(Context 已关闭,无法把回调投递到 owner) | |
| 133 | + Context context = mock(Context.class); | |
| 134 | + ModbusConnection connection = new ModbusConnection().setTimeout(1000).setContext(context); | |
| 135 | + connection.addDevice(DEVICE_ID, 1); | |
| 136 | + Future<byte[]> execute = mock(Future.class); | |
| 137 | + // mock 方法 | |
| 138 | + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection); | |
| 139 | + doReturn(execute).when(context).executeBlocking(any(Callable.class), eq(true)); | |
| 140 | + when(execute.onComplete(any())).thenThrow(new RejectedExecutionException("event executor terminated")); | |
| 141 | + | |
| 142 | + // 调用:连续 2 次 | |
| 143 | + for (int i = 0; i < 2; i++) { | |
| 144 | + Future<Void> result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null); | |
| 145 | + | |
| 146 | + // 断言:返回失败 Future,名额释放,不泄漏、不变负 | |
| 147 | + assertFailedWith(result, DEVICE_OFFLINE_OR_WRITE_FAILED.getCode()); | |
| 148 | + assertEquals(0, connection.getRawOutstanding().get()); | |
| 149 | + } | |
| 150 | + // 断言:不回复 biz | |
| 151 | + verify(protocol, never()).getVertx(); | |
| 152 | + } | |
| 153 | + | |
| 154 | + @Test | |
| 155 | + @SuppressWarnings("unchecked") | |
| 156 | + public void testHandleRawMessage_onCompleteThrowsAfterCallback() { | |
| 157 | + // 准备参数:注册完成回调时,回调已经执行(事务已是终态),之后才抛出异常 | |
| 158 | + Context context = mock(Context.class); | |
| 159 | + ModbusConnection connection = new ModbusConnection().setTimeout(1000).setContext(context); | |
| 160 | + connection.addDevice(DEVICE_ID, 1); | |
| 161 | + Future<byte[]> execute = mock(Future.class); | |
| 162 | + // mock 方法 | |
| 163 | + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection); | |
| 164 | + doReturn(execute).when(context).executeBlocking(any(Callable.class), eq(true)); | |
| 165 | + doAnswer(invocation -> { | |
| 166 | + Handler<AsyncResult<byte[]>> handler = invocation.getArgument(0); | |
| 167 | + handler.handle(Future.failedFuture(new RuntimeException("Connection reset"))); | |
| 168 | + throw new RejectedExecutionException("event executor terminated"); | |
| 169 | + }).when(execute).onComplete(any()); | |
| 170 | + | |
| 171 | + // 调用 | |
| 172 | + Future<Void> result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null); | |
| 173 | + | |
| 174 | + // 断言:事务终态时已成功完成 Future(网关判定的失败只记日志),之后的异常不改变它;名额只释放一次,不变负 | |
| 175 | + assertTrue(result.succeeded()); | |
| 176 | + assertEquals(0, connection.getRawOutstanding().get()); | |
| 177 | + // 断言:不回复 biz | |
| 178 | + verify(protocol, never()).getVertx(); | |
| 179 | + } | |
| 180 | + | |
| 181 | + private static IotDeviceMessage buildMessage() { | |
| 182 | + return new IotDeviceMessage().setDeviceId(DEVICE_ID).setRequestId("r1").setMethod("thing.raw.push"); | |
| 183 | + } | |
| 184 | + | |
| 185 | + private static void assertFailedWith(Future<Void> result, Integer code) { | |
| 186 | + assertTrue(result.failed()); | |
| 187 | + assertInstanceOf(ServiceException.class, result.cause()); | |
| 188 | + assertEquals(code, ((ServiceException) result.cause()).getCode()); | |
| 189 | + } | |
| 190 | + | |
| 191 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientPollSchedulerTest.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager; |
| 2 | 2 | |
| 3 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 3 | 4 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; |
| 4 | 5 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; | |
| 9 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.upstream.IotModbusTcpClientUpstreamHandler; | |
| 10 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager.ModbusConnection; | |
| 5 | 11 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientPollScheduler.ReadSegment; |
| 12 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 14 | +import io.vertx.core.Context; | |
| 15 | +import io.vertx.core.Vertx; | |
| 6 | 16 | import org.junit.jupiter.api.Test; |
| 17 | +import org.mockito.ArgumentCaptor; | |
| 18 | +import org.mockito.Mock; | |
| 7 | 19 | |
| 8 | 20 | import java.util.Arrays; |
| 9 | 21 | import java.util.Collections; |
| 10 | 22 | import java.util.List; |
| 23 | +import java.util.concurrent.CompletableFuture; | |
| 24 | +import java.util.concurrent.CountDownLatch; | |
| 25 | +import java.util.concurrent.TimeUnit; | |
| 26 | +import java.util.concurrent.atomic.AtomicBoolean; | |
| 11 | 27 | |
| 12 | 28 | import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_COILS; |
| 13 | 29 | import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_HOLDING_REGISTERS; |
| 14 | 30 | import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_INPUT_REGISTERS; |
| 15 | 31 | import static org.junit.jupiter.api.Assertions.*; |
| 32 | +import static org.mockito.ArgumentMatchers.any; | |
| 33 | +import static org.mockito.ArgumentMatchers.eq; | |
| 34 | +import static org.mockito.Mockito.*; | |
| 16 | 35 | |
| 17 | 36 | /** |
| 18 | 37 | * {@link IotModbusTcpClientPollScheduler} 的单元测试 |
| 19 | 38 | * |
| 20 | 39 | * @author 芋道源码 |
| 21 | 40 | */ |
| 22 | -public class IotModbusTcpClientPollSchedulerTest { | |
| 41 | +public class IotModbusTcpClientPollSchedulerTest extends BaseMockitoUnitTest { | |
| 42 | + | |
| 43 | + private static final long TIMEOUT_SECONDS = 10; | |
| 44 | + private static final Long DEVICE_ID = 1L; | |
| 45 | + private static final Long POINT_ID = 1L; | |
| 46 | + private static final int SLAVE_ID = 1; | |
| 47 | + private static final String PRODUCT_KEY = "pk"; | |
| 48 | + private static final String DEVICE_NAME = "dn"; | |
| 49 | + private static final String SERVER_ID = "modbus-tcp-client"; | |
| 50 | + | |
| 51 | + @Mock | |
| 52 | + private IotModbusTcpClientConnectionManager connectionManager; | |
| 53 | + @Mock | |
| 54 | + private IotModbusTcpClientConfigCacheService configCacheService; | |
| 55 | + @Mock | |
| 56 | + private IotDeviceMessageService messageService; | |
| 57 | + @Mock | |
| 58 | + private ModbusConnection connection; | |
| 59 | + | |
| 60 | + @Test | |
| 61 | + public void testPollPoint_reportNotBlockConnectionContext() throws Exception { | |
| 62 | + Vertx vertx = Vertx.vertx(); | |
| 63 | + try { | |
| 64 | + // 准备参数:连接的 context 与它上面的上报队列;点位 FC03 读 1 个寄存器 | |
| 65 | + Context context = vertx.getOrCreateContext(); | |
| 66 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context); | |
| 67 | + IotModbusPointRespDTO point = randomPoint(POINT_ID, FC_READ_HOLDING_REGISTERS, 0, 1, 1000); | |
| 68 | + point.setIdentifier("temperature"); | |
| 69 | + point.setRawDataType("INT16"); | |
| 70 | + IotModbusDeviceConfigRespDTO config = randomConfig(point); | |
| 71 | + config.setProductKey(PRODUCT_KEY); | |
| 72 | + config.setDeviceName(DEVICE_NAME); | |
| 73 | + IotModbusTcpClientPollScheduler pollScheduler = new IotModbusTcpClientPollScheduler(vertx, connectionManager, | |
| 74 | + new IotModbusTcpClientUpstreamHandler(messageService, SERVER_ID), configCacheService); | |
| 75 | + CountDownLatch entered = new CountDownLatch(1); | |
| 76 | + CountDownLatch release = new CountDownLatch(1); | |
| 77 | + CountDownLatch delivered = new CountDownLatch(2); | |
| 78 | + AtomicBoolean onWorker = new AtomicBoolean(true); | |
| 79 | + // mock 方法:配置、连接、slave ID | |
| 80 | + when(configCacheService.getConfig(DEVICE_ID)).thenReturn(config); | |
| 81 | + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection); | |
| 82 | + when(connectionManager.getSlaveId(DEVICE_ID)).thenReturn(SLAVE_ID); | |
| 83 | + when(connection.getUpstreamQueue()).thenReturn(queue); | |
| 84 | + // mock 方法:与真实连接一样,读取在连接 context 的有序 executeBlocking 上完成,结果回调回到 context;两次读到 250、251 | |
| 85 | + when(connection.executeBlocking(any())).thenAnswer(invocation -> context.executeBlocking(() -> new int[]{250}, true)) | |
| 86 | + .thenAnswer(invocation -> context.executeBlocking(() -> new int[]{251}, true)); | |
| 87 | + // mock 方法:发送到消息总线,第一条阻塞直到放行 | |
| 88 | + doAnswer(invocation -> { | |
| 89 | + onWorker.compareAndSet(true, Context.isOnWorkerThread()); | |
| 90 | + entered.countDown(); | |
| 91 | + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { | |
| 92 | + throw new IllegalStateException("等待放行超时"); | |
| 93 | + } | |
| 94 | + delivered.countDown(); | |
| 95 | + return null; | |
| 96 | + }).doAnswer(invocation -> { | |
| 97 | + onWorker.compareAndSet(true, Context.isOnWorkerThread()); | |
| 98 | + delivered.countDown(); | |
| 99 | + return null; | |
| 100 | + }).when(messageService).sendDeviceMessage(any(IotDeviceMessage.class), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); | |
| 101 | + | |
| 102 | + try { | |
| 103 | + // 调用:第一次轮询,发送阻塞在 worker 上 | |
| 104 | + pollScheduler.pollPoint(DEVICE_ID, POINT_ID); | |
| 105 | + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); | |
| 106 | + // 调用:发送仍阻塞时,第二次轮询;再在连接的 context 上执行一个哨兵任务 | |
| 107 | + pollScheduler.pollPoint(DEVICE_ID, POINT_ID); | |
| 108 | + CompletableFuture<Boolean> sentinel = new CompletableFuture<>(); | |
| 109 | + context.runOnContext(v -> sentinel.complete(true)); | |
| 110 | + | |
| 111 | + // 断言:context 没有被发送阻塞,哨兵及时执行;第二条上报排在第一条之后,还没有发送 | |
| 112 | + assertTrue(sentinel.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); | |
| 113 | + verify(connection, timeout(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS)).times(2)).executeBlocking(any()); | |
| 114 | + verify(messageService, times(1)).sendDeviceMessage(any(), any(), any(), any()); | |
| 115 | + } finally { | |
| 116 | + release.countDown(); | |
| 117 | + } | |
| 118 | + // 断言:放行后两条上报按读取顺序各投递一次,都在 worker 上发送 | |
| 119 | + assertTrue(delivered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); | |
| 120 | + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class); | |
| 121 | + verify(messageService, times(2)).sendDeviceMessage(captor.capture(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); | |
| 122 | + assertTrue(onWorker.get()); | |
| 123 | + List<IotDeviceMessage> messages = captor.getAllValues(); | |
| 124 | + assertEquals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), messages.get(0).getMethod()); | |
| 125 | + assertEquals(Collections.singletonMap("temperature", IotModbusCommonUtils.convertToPropertyValue(new int[]{250}, point)), | |
| 126 | + messages.get(0).getParams()); | |
| 127 | + assertEquals(Collections.singletonMap("temperature", IotModbusCommonUtils.convertToPropertyValue(new int[]{251}, point)), | |
| 128 | + messages.get(1).getParams()); | |
| 129 | + } finally { | |
| 130 | + vertx.close().toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 131 | + } | |
| 132 | + } | |
| 23 | 133 | |
| 24 | 134 | @Test |
| 25 | 135 | public void testBuildReadSegments_mergeContinuousPoints() { | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamSubscriberTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.downstream; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.StrUtil; | |
| 4 | +import cn.hutool.extra.spring.SpringUtil; | |
| 5 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 6 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 9 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 10 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.IotModbusTcpServerProtocol; | |
| 12 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager; | |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; | |
| 14 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; | |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 16 | +import io.vertx.core.Future; | |
| 17 | +import io.vertx.core.Vertx; | |
| 18 | +import org.junit.jupiter.api.AfterEach; | |
| 19 | +import org.junit.jupiter.api.BeforeEach; | |
| 20 | +import org.junit.jupiter.api.Test; | |
| 21 | +import org.mockito.ArgumentCaptor; | |
| 22 | +import org.mockito.Mock; | |
| 23 | +import org.mockito.MockedStatic; | |
| 24 | + | |
| 25 | +import java.util.concurrent.Callable; | |
| 26 | + | |
| 27 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_EXCEPTION; | |
| 28 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_TIMEOUT; | |
| 29 | +import static org.junit.jupiter.api.Assertions.*; | |
| 30 | +import static org.mockito.AdditionalMatchers.aryEq; | |
| 31 | +import static org.mockito.ArgumentMatchers.any; | |
| 32 | +import static org.mockito.ArgumentMatchers.eq; | |
| 33 | +import static org.mockito.Mockito.*; | |
| 34 | + | |
| 35 | +/** | |
| 36 | + * {@link IotModbusTcpServerDownstreamSubscriber} 的单元测试 | |
| 37 | + * | |
| 38 | + * @author 芋道源码 | |
| 39 | + */ | |
| 40 | +public class IotModbusTcpServerDownstreamSubscriberTest extends BaseMockitoUnitTest { | |
| 41 | + | |
| 42 | + private static final Long DEVICE_ID = 1L; | |
| 43 | + private static final String SERVER_ID = "server-1"; | |
| 44 | + /** | |
| 45 | + * 06 00 10 00 01:写单个寄存器 | |
| 46 | + */ | |
| 47 | + private static final byte[] WRITE_PDU = {0x06, 0x00, 0x10, 0x00, 0x01}; | |
| 48 | + | |
| 49 | + @Mock | |
| 50 | + private IotModbusTcpServerProtocol protocol; | |
| 51 | + @Mock | |
| 52 | + private IotModbusTcpServerDownstreamHandler downstreamHandler; | |
| 53 | + @Mock | |
| 54 | + private IotModbusTcpServerConnectionManager connectionManager; | |
| 55 | + @Mock | |
| 56 | + private IotMessageBus messageBus; | |
| 57 | + @Mock | |
| 58 | + private IotModbusTcpServerTransactionChannel channel; | |
| 59 | + @Mock | |
| 60 | + private Vertx vertx; | |
| 61 | + @Mock | |
| 62 | + private IotDeviceMessageService deviceMessageService; | |
| 63 | + | |
| 64 | + private MockedStatic<SpringUtil> springUtilMockedStatic; | |
| 65 | + private IotModbusTcpServerDownstreamSubscriber subscriber; | |
| 66 | + | |
| 67 | + private final IotScriptSource source = IotScriptSource.of(100L, "function decode(ctx) {}"); | |
| 68 | + | |
| 69 | + @BeforeEach | |
| 70 | + public void setUp() { | |
| 71 | + springUtilMockedStatic = mockStatic(SpringUtil.class); | |
| 72 | + subscriber = new IotModbusTcpServerDownstreamSubscriber(protocol, downstreamHandler, connectionManager, messageBus); | |
| 73 | + when(connectionManager.getConnectionInfoByDeviceId(DEVICE_ID)).thenReturn(new ConnectionInfo().setChannel(channel)); | |
| 74 | + } | |
| 75 | + | |
| 76 | + @AfterEach | |
| 77 | + public void tearDown() { | |
| 78 | + springUtilMockedStatic.close(); | |
| 79 | + } | |
| 80 | + | |
| 81 | + @Test | |
| 82 | + @SuppressWarnings("unchecked") | |
| 83 | + public void testHandleRawMessage_deviceExceptionResponse() { | |
| 84 | + // 准备参数 | |
| 85 | + IotDeviceMessage message = buildMessage(); | |
| 86 | + byte[] responsePdu = new byte[]{(byte) 0x86, 0x02}; | |
| 87 | + // mock 方法:回复在 worker 上执行(这里在当前线程同步执行) | |
| 88 | + when(protocol.getServerId()).thenReturn(SERVER_ID); | |
| 89 | + when(protocol.getVertx()).thenReturn(vertx); | |
| 90 | + when(vertx.executeBlocking(any(Callable.class), eq(false))).thenAnswer(invocation -> { | |
| 91 | + Callable<Object> callable = invocation.getArgument(0); | |
| 92 | + return Future.succeededFuture(callable.call()); | |
| 93 | + }); | |
| 94 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceMessageService.class)).thenReturn(deviceMessageService); | |
| 95 | + | |
| 96 | + // 调用:提交事务,事务终态之前 Future 不完成 | |
| 97 | + Future<Void> result = subscriber.handleRawMessage(message, WRITE_PDU, source); | |
| 98 | + assertFalse(result.isComplete()); | |
| 99 | + // 调用:设备返回异常响应 86 02 | |
| 100 | + captureSubmittedTx().complete(responsePdu); | |
| 101 | + | |
| 102 | + // 断言:Future 在事务终态成功完成 | |
| 103 | + assertTrue(result.succeeded()); | |
| 104 | + // 断言:设备的实际响应回复给 biz,设备错误为 MODBUS_EXCEPTION(带异常码),带上 encode 时固定的脚本 | |
| 105 | + ArgumentCaptor<ErrorCode> errorCaptor = ArgumentCaptor.forClass(ErrorCode.class); | |
| 106 | + verify(deviceMessageService).sendRawReply(same(message), same(source), aryEq(responsePdu), | |
| 107 | + errorCaptor.capture(), eq(SERVER_ID)); | |
| 108 | + assertEquals(MODBUS_EXCEPTION.getCode(), errorCaptor.getValue().getCode()); | |
| 109 | + assertEquals(StrUtil.format(MODBUS_EXCEPTION.getMsg(), 2), errorCaptor.getValue().getMsg()); | |
| 110 | + } | |
| 111 | + | |
| 112 | + @Test | |
| 113 | + public void testHandleRawMessage_gatewayFailureOnlyLog() { | |
| 114 | + // 调用:网关判定的失败(超时)、响应为空 | |
| 115 | + Future<Void> timeout = subscriber.handleRawMessage(buildMessage(), WRITE_PDU, source); | |
| 116 | + captureSubmittedTx().fail(MODBUS_TIMEOUT); | |
| 117 | + Future<Void> empty = subscriber.handleRawMessage(buildMessage(), WRITE_PDU, null); | |
| 118 | + captureSubmittedTx().complete(new byte[0]); | |
| 119 | + | |
| 120 | + // 断言:Future 照常在事务终态成功完成;只记日志,不给 biz 构造回复 | |
| 121 | + assertTrue(timeout.succeeded()); | |
| 122 | + assertTrue(empty.succeeded()); | |
| 123 | + verify(protocol, never()).getVertx(); | |
| 124 | + springUtilMockedStatic.verifyNoInteractions(); | |
| 125 | + verifyNoInteractions(deviceMessageService); | |
| 126 | + } | |
| 127 | + | |
| 128 | + private IotModbusTx captureSubmittedTx() { | |
| 129 | + ArgumentCaptor<IotModbusTx> captor = ArgumentCaptor.forClass(IotModbusTx.class); | |
| 130 | + verify(channel, atLeastOnce()).submitRaw(captor.capture()); | |
| 131 | + return captor.getValue(); | |
| 132 | + } | |
| 133 | + | |
| 134 | + private static IotDeviceMessage buildMessage() { | |
| 135 | + return new IotDeviceMessage().setDeviceId(DEVICE_ID).setRequestId("r1").setMethod("thing.property.set"); | |
| 136 | + } | |
| 137 | + | |
| 138 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/upstream/IotModbusTcpServerUpstreamHandlerTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.upstream; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.HexUtil; | |
| 4 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 9 | +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; | |
| 10 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 12 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; | |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; | |
| 14 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder; | |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConfigCacheService; | |
| 16 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager; | |
| 17 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; | |
| 18 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager; | |
| 19 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; | |
| 20 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPollScheduler; | |
| 21 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; | |
| 22 | +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; | |
| 23 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 24 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 25 | +import io.vertx.core.Context; | |
| 26 | +import io.vertx.core.Future; | |
| 27 | +import io.vertx.core.Vertx; | |
| 28 | +import io.vertx.core.net.NetSocket; | |
| 29 | +import org.junit.jupiter.api.AfterEach; | |
| 30 | +import org.junit.jupiter.api.BeforeEach; | |
| 31 | +import org.junit.jupiter.api.Test; | |
| 32 | +import org.mockito.ArgumentCaptor; | |
| 33 | +import org.mockito.Mock; | |
| 34 | + | |
| 35 | +import java.util.Collections; | |
| 36 | +import java.util.List; | |
| 37 | +import java.util.concurrent.Callable; | |
| 38 | +import java.util.concurrent.CompletableFuture; | |
| 39 | +import java.util.concurrent.CountDownLatch; | |
| 40 | +import java.util.concurrent.TimeUnit; | |
| 41 | +import java.util.concurrent.atomic.AtomicBoolean; | |
| 42 | +import java.util.concurrent.atomic.AtomicInteger; | |
| 43 | + | |
| 44 | +import static org.junit.jupiter.api.Assertions.*; | |
| 45 | +import static org.mockito.ArgumentMatchers.any; | |
| 46 | +import static org.mockito.ArgumentMatchers.eq; | |
| 47 | +import static org.mockito.Mockito.*; | |
| 48 | + | |
| 49 | +/** | |
| 50 | + * {@link IotModbusTcpServerUpstreamHandler} 的单元测试 | |
| 51 | + * | |
| 52 | + * @author 芋道源码 | |
| 53 | + */ | |
| 54 | +public class IotModbusTcpServerUpstreamHandlerTest extends BaseMockitoUnitTest { | |
| 55 | + | |
| 56 | + private static final long TIMEOUT_SECONDS = 10; | |
| 57 | + private static final Long DEVICE_ID = 1L; | |
| 58 | + private static final Long POINT_ID = 10L; | |
| 59 | + private static final int SLAVE_ID = 1; | |
| 60 | + private static final String PRODUCT_KEY = "pk"; | |
| 61 | + private static final String DEVICE_NAME = "dn"; | |
| 62 | + private static final String SERVER_ID = "modbus-tcp-server"; | |
| 63 | + | |
| 64 | + @Mock | |
| 65 | + private IotDeviceCommonApi deviceApi; | |
| 66 | + @Mock | |
| 67 | + private IotDeviceMessageService messageService; | |
| 68 | + @Mock | |
| 69 | + private IotModbusTcpServerConfigCacheService configCacheService; | |
| 70 | + @Mock | |
| 71 | + private IotModbusTcpServerPollScheduler pollScheduler; | |
| 72 | + @Mock | |
| 73 | + private IotDeviceService deviceService; | |
| 74 | + @Mock | |
| 75 | + private IotModbusTcpServerTransactionChannel channel; | |
| 76 | + @Mock | |
| 77 | + private NetSocket socket; | |
| 78 | + | |
| 79 | + private final IotModbusTcpServerConnectionManager connectionManager = new IotModbusTcpServerConnectionManager(); | |
| 80 | + private final IotModbusTcpServerPendingRequestManager pendingRequestManager = new IotModbusTcpServerPendingRequestManager(); | |
| 81 | + private IotModbusTcpServerUpstreamHandler upstreamHandler; | |
| 82 | + private ConnectionInfo connectionInfo; | |
| 83 | + private Vertx vertx; | |
| 84 | + /** | |
| 85 | + * 连接的 owner(event loop Context) | |
| 86 | + */ | |
| 87 | + private Context owner; | |
| 88 | + | |
| 89 | + @BeforeEach | |
| 90 | + public void setUp() { | |
| 91 | + upstreamHandler = new IotModbusTcpServerUpstreamHandler(deviceApi, messageService, new IotModbusFrameEncoder(65), | |
| 92 | + connectionManager, configCacheService, pendingRequestManager, pollScheduler, deviceService, | |
| 93 | + SERVER_ID, new AtomicInteger(), 60_000); | |
| 94 | + // 已认证的 RTU 连接 | |
| 95 | + connectionInfo = new ConnectionInfo().setDeviceId(DEVICE_ID).setProductKey(PRODUCT_KEY).setDeviceName(DEVICE_NAME) | |
| 96 | + .setSlaveId(SLAVE_ID).setFrameFormat(IotModbusFrameFormatEnum.MODBUS_RTU).setChannel(channel); | |
| 97 | + connectionManager.registerConnection(socket, connectionInfo); | |
| 98 | + vertx = Vertx.vertx(); | |
| 99 | + owner = vertx.getOrCreateContext(); | |
| 100 | + } | |
| 101 | + | |
| 102 | + @AfterEach | |
| 103 | + public void tearDown() throws Exception { | |
| 104 | + vertx.close().toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 105 | + } | |
| 106 | + | |
| 107 | + @Test | |
| 108 | + public void testHandleFrame_pollingReportNotBlockOwner() throws Exception { | |
| 109 | + // 准备参数:本连接的轮询请求 P(FC03 读 1 个)、透传请求 R(FC04 读 1 个)都在等响应 | |
| 110 | + IotModbusPointRespDTO point = buildPoint(); | |
| 111 | + PendingRequest poll = buildPollRequest(); | |
| 112 | + PendingRequest raw = buildRawRequest(); | |
| 113 | + pendingRequestManager.addRequest(poll); | |
| 114 | + pendingRequestManager.addRequest(raw); | |
| 115 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(owner); | |
| 116 | + CountDownLatch entered = new CountDownLatch(1); | |
| 117 | + CountDownLatch release = new CountDownLatch(1); | |
| 118 | + CountDownLatch delivered = new CountDownLatch(1); | |
| 119 | + AtomicBoolean onWorker = new AtomicBoolean(); | |
| 120 | + // mock 方法:事务通道的 worker 队列(在 owner 上);点位配置;发送到消息总线阻塞,直到放行 | |
| 121 | + when(channel.getUpstreamQueue()).thenReturn(queue); | |
| 122 | + when(configCacheService.getConfig(DEVICE_ID)).thenReturn(buildConfig(point)); | |
| 123 | + doAnswer(invocation -> { | |
| 124 | + onWorker.set(Context.isOnWorkerThread()); | |
| 125 | + entered.countDown(); | |
| 126 | + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { | |
| 127 | + throw new IllegalStateException("等待放行超时"); | |
| 128 | + } | |
| 129 | + delivered.countDown(); | |
| 130 | + return null; | |
| 131 | + }).when(messageService).sendDeviceMessage(any(IotDeviceMessage.class), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); | |
| 132 | + | |
| 133 | + IotModbusFrame rawResponse = buildReadResponseFrame(4, 0x0001); | |
| 134 | + try { | |
| 135 | + // 调用:owner 上收到 P 的响应,发送阻塞在 worker 上 | |
| 136 | + runOnOwner(() -> upstreamHandler.handleFrame(socket, buildReadResponseFrame(3, 0x00FA), | |
| 137 | + IotModbusFrameFormatEnum.MODBUS_RTU)); | |
| 138 | + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); | |
| 139 | + // 调用:发送仍阻塞时,owner 上收到 R 的响应,再执行一个哨兵任务 | |
| 140 | + runOnOwner(() -> upstreamHandler.handleFrame(socket, rawResponse, IotModbusFrameFormatEnum.MODBUS_RTU)); | |
| 141 | + Boolean sentinel = callOnOwner(() -> Boolean.TRUE); | |
| 142 | + | |
| 143 | + // 断言:owner 没有被发送阻塞:R 的响应照常匹配并交给事务通道收尾,哨兵及时执行 | |
| 144 | + verify(channel).onRawResponse(same(rawResponse), same(raw)); | |
| 145 | + assertTrue(sentinel); | |
| 146 | + assertEquals(1, delivered.getCount()); | |
| 147 | + } finally { | |
| 148 | + release.countDown(); | |
| 149 | + } | |
| 150 | + // 断言:放行后属性上报只投递一次,在 worker 上发送 | |
| 151 | + assertTrue(delivered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); | |
| 152 | + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class); | |
| 153 | + verify(messageService, times(1)).sendDeviceMessage(captor.capture(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); | |
| 154 | + assertTrue(onWorker.get()); | |
| 155 | + assertEquals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), captor.getValue().getMethod()); | |
| 156 | + assertEquals(Collections.singletonMap("temperature", IotModbusCommonUtils.convertToPropertyValue(new int[]{0x00FA}, point)), | |
| 157 | + captor.getValue().getParams()); | |
| 158 | + } | |
| 159 | + | |
| 160 | + @Test | |
| 161 | + public void testSendOfflineMessage_afterPollingReport() throws Exception { | |
| 162 | + // 准备参数:本连接的轮询请求 P | |
| 163 | + pendingRequestManager.addRequest(buildPollRequest()); | |
| 164 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(owner); | |
| 165 | + CountDownLatch entered = new CountDownLatch(1); | |
| 166 | + CountDownLatch release = new CountDownLatch(1); | |
| 167 | + // mock 方法:事务通道的 worker 队列;点位配置;第一条(属性上报)阻塞直到放行 | |
| 168 | + when(channel.getUpstreamQueue()).thenReturn(queue); | |
| 169 | + when(configCacheService.getConfig(DEVICE_ID)).thenReturn(buildConfig(buildPoint())); | |
| 170 | + doAnswer(invocation -> { | |
| 171 | + entered.countDown(); | |
| 172 | + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { | |
| 173 | + throw new IllegalStateException("等待放行超时"); | |
| 174 | + } | |
| 175 | + return null; | |
| 176 | + }).doNothing().when(messageService).sendDeviceMessage(any(IotDeviceMessage.class), | |
| 177 | + eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); | |
| 178 | + | |
| 179 | + Future<Void> offline; | |
| 180 | + try { | |
| 181 | + // 调用:owner 上收到 P 的响应,发送阻塞在 worker 上 | |
| 182 | + runOnOwner(() -> upstreamHandler.handleFrame(socket, buildReadResponseFrame(3, 0x00FA), | |
| 183 | + IotModbusFrameFormatEnum.MODBUS_RTU)); | |
| 184 | + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); | |
| 185 | + // 调用:连接关闭(closeHandler 在 owner 上移除连接、提交下线消息),不等待发送 | |
| 186 | + offline = callOnOwner(() -> { | |
| 187 | + connectionManager.removeConnection(socket); | |
| 188 | + return upstreamHandler.sendOfflineMessage(connectionInfo); | |
| 189 | + }); | |
| 190 | + | |
| 191 | + // 断言:下线消息排在被阻塞的属性上报之后,还没有发送;之后本连接不再接收新任务 | |
| 192 | + assertFalse(offline.isComplete()); | |
| 193 | + verify(messageService, times(1)).sendDeviceMessage(any(), any(), any(), any()); | |
| 194 | + assertTrue(callOnOwner(() -> queue.submit(() -> null)).failed()); | |
| 195 | + } finally { | |
| 196 | + release.countDown(); | |
| 197 | + } | |
| 198 | + // 断言:放行后先发属性上报,再发下线消息,各一次 | |
| 199 | + await(offline); | |
| 200 | + assertTrue(offline.succeeded()); | |
| 201 | + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class); | |
| 202 | + verify(messageService, times(2)).sendDeviceMessage(captor.capture(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); | |
| 203 | + List<IotDeviceMessage> messages = captor.getAllValues(); | |
| 204 | + assertEquals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), messages.get(0).getMethod()); | |
| 205 | + assertEquals(IotDeviceMessage.buildStateOffline().getMethod(), messages.get(1).getMethod()); | |
| 206 | + assertEquals(IotDeviceMessage.buildStateOffline().getParams(), messages.get(1).getParams()); | |
| 207 | + } | |
| 208 | + | |
| 209 | + @Test | |
| 210 | + public void testSendOfflineMessage_skipWhenReconnected() throws Exception { | |
| 211 | + // 准备参数:旧连接关闭后,设备已在新连接上认证注册 | |
| 212 | + NetSocket newSocket = mock(NetSocket.class); | |
| 213 | + connectionManager.removeConnection(socket); | |
| 214 | + connectionManager.registerConnection(newSocket, new ConnectionInfo().setDeviceId(DEVICE_ID) | |
| 215 | + .setProductKey(PRODUCT_KEY).setDeviceName(DEVICE_NAME).setSlaveId(SLAVE_ID) | |
| 216 | + .setFrameFormat(IotModbusFrameFormatEnum.MODBUS_RTU).setChannel(mock(IotModbusTcpServerTransactionChannel.class))); | |
| 217 | + // mock 方法:旧连接事务通道的 worker 队列 | |
| 218 | + when(channel.getUpstreamQueue()).thenReturn(new IotWorkerTaskQueue(owner)); | |
| 219 | + | |
| 220 | + // 调用:旧连接的下线消息 | |
| 221 | + Future<Void> offline = callOnOwner(() -> upstreamHandler.sendOfflineMessage(connectionInfo)); | |
| 222 | + await(offline); | |
| 223 | + | |
| 224 | + // 断言:执行时设备已在新连接上线,不发送下线消息 | |
| 225 | + assertTrue(offline.succeeded()); | |
| 226 | + verify(messageService, never()).sendDeviceMessage(any(), any(), any(), any()); | |
| 227 | + } | |
| 228 | + | |
| 229 | + // ========== 辅助方法 ========== | |
| 230 | + | |
| 231 | + /** | |
| 232 | + * 点位:FC03 读 1 个寄存器(INT16) | |
| 233 | + */ | |
| 234 | + private static IotModbusPointRespDTO buildPoint() { | |
| 235 | + IotModbusPointRespDTO point = new IotModbusPointRespDTO(); | |
| 236 | + point.setId(POINT_ID); | |
| 237 | + point.setIdentifier("temperature"); | |
| 238 | + point.setFunctionCode(3); | |
| 239 | + point.setRegisterAddress(0); | |
| 240 | + point.setRegisterCount(1); | |
| 241 | + point.setRawDataType("INT16"); | |
| 242 | + return point; | |
| 243 | + } | |
| 244 | + | |
| 245 | + private static IotModbusDeviceConfigRespDTO buildConfig(IotModbusPointRespDTO point) { | |
| 246 | + IotModbusDeviceConfigRespDTO config = new IotModbusDeviceConfigRespDTO(); | |
| 247 | + config.setDeviceId(DEVICE_ID); | |
| 248 | + config.setPoints(Collections.singletonList(point)); | |
| 249 | + return config; | |
| 250 | + } | |
| 251 | + | |
| 252 | + private PendingRequest buildPollRequest() { | |
| 253 | + return new PendingRequest(DEVICE_ID, POINT_ID, "temperature", SLAVE_ID, 3, 0, 1, | |
| 254 | + null, System.currentTimeMillis() + 60_000, channel, null); | |
| 255 | + } | |
| 256 | + | |
| 257 | + private PendingRequest buildRawRequest() { | |
| 258 | + IotModbusTx tx = IotModbusTx.ofClient(null, HexUtil.decodeHex("0400000001"), | |
| 259 | + System.nanoTime() + TimeUnit.SECONDS.toNanos(60), null); | |
| 260 | + return new PendingRequest(DEVICE_ID, null, null, SLAVE_ID, 4, 0, 1, | |
| 261 | + null, System.currentTimeMillis() + 60_000, channel, tx); | |
| 262 | + } | |
| 263 | + | |
| 264 | + /** | |
| 265 | + * RTU 读响应帧:读 1 个寄存器(pdu = byteCount + 值) | |
| 266 | + */ | |
| 267 | + private static IotModbusFrame buildReadResponseFrame(int functionCode, int value) { | |
| 268 | + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(functionCode) | |
| 269 | + .setPdu(new byte[]{0x02, (byte) (value >> 8), (byte) value}); | |
| 270 | + } | |
| 271 | + | |
| 272 | + /** | |
| 273 | + * 在 owner 上执行(与 RecordParser、closeHandler 回调一致),等待执行完;owner 被阻塞时超时失败 | |
| 274 | + */ | |
| 275 | + private void runOnOwner(Runnable action) throws Exception { | |
| 276 | + callOnOwner(() -> { | |
| 277 | + action.run(); | |
| 278 | + return null; | |
| 279 | + }); | |
| 280 | + } | |
| 281 | + | |
| 282 | + private <T> T callOnOwner(Callable<T> action) throws Exception { | |
| 283 | + CompletableFuture<T> result = new CompletableFuture<>(); | |
| 284 | + owner.runOnContext(v -> { | |
| 285 | + try { | |
| 286 | + result.complete(action.call()); | |
| 287 | + } catch (Throwable e) { | |
| 288 | + result.completeExceptionally(e); | |
| 289 | + } | |
| 290 | + }); | |
| 291 | + return result.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 292 | + } | |
| 293 | + | |
| 294 | + /** | |
| 295 | + * 等待 Future 完成(成功或失败) | |
| 296 | + */ | |
| 297 | + private static void await(Future<?> future) throws InterruptedException { | |
| 298 | + CountDownLatch latch = new CountDownLatch(1); | |
| 299 | + future.onComplete(ar -> latch.countDown()); | |
| 300 | + assertTrue(latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); | |
| 301 | + } | |
| 302 | + | |
| 303 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPendingRequestManagerTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.HexUtil; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; | |
| 5 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 6 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; | |
| 7 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; | |
| 8 | +import org.junit.jupiter.api.Test; | |
| 9 | + | |
| 10 | +import static org.junit.jupiter.api.Assertions.*; | |
| 11 | +import static org.mockito.Mockito.mock; | |
| 12 | +import static org.mockito.Mockito.when; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * {@link IotModbusTcpServerPendingRequestManager} 的单元测试 | |
| 16 | + * | |
| 17 | + * @author 芋道源码 | |
| 18 | + */ | |
| 19 | +public class IotModbusTcpServerPendingRequestManagerTest { | |
| 20 | + | |
| 21 | + private static final Long DEVICE_ID = 1L; | |
| 22 | + private static final int SLAVE_ID = 1; | |
| 23 | + | |
| 24 | + private final IotModbusTcpServerPendingRequestManager pendingRequestManager = new IotModbusTcpServerPendingRequestManager(); | |
| 25 | + | |
| 26 | + /** | |
| 27 | + * 旧连接 A 被新连接 B 替换 | |
| 28 | + */ | |
| 29 | + private final IotModbusTcpServerTransactionChannel channelA = mock(IotModbusTcpServerTransactionChannel.class); | |
| 30 | + private final IotModbusTcpServerTransactionChannel channelB = mock(IotModbusTcpServerTransactionChannel.class); | |
| 31 | + | |
| 32 | + // ========== TCP 帧:按 transactionId ========== | |
| 33 | + | |
| 34 | + @Test | |
| 35 | + public void testMatchResponse_tcpOnlyMatchCurrentChannel() { | |
| 36 | + // 准备参数:A、B 两条连接上各有一个事务号相同的轮询请求 | |
| 37 | + PendingRequest requestA = buildPollRequest(channelA, 5, 3, 2, System.currentTimeMillis() + 60_000); | |
| 38 | + PendingRequest requestB = buildPollRequest(channelB, 5, 3, 2, System.currentTimeMillis() + 60_000); | |
| 39 | + pendingRequestManager.addRequest(requestA); | |
| 40 | + pendingRequestManager.addRequest(requestB); | |
| 41 | + | |
| 42 | + // 调用:B 收到响应 | |
| 43 | + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 3, 2), | |
| 44 | + IotModbusFrameFormatEnum.MODBUS_TCP, channelB); | |
| 45 | + | |
| 46 | + // 断言:只配给 B 的请求,A 的请求不被取走 | |
| 47 | + assertSame(requestB, matched); | |
| 48 | + assertSame(requestA, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 3, 2), | |
| 49 | + IotModbusFrameFormatEnum.MODBUS_TCP, channelA)); | |
| 50 | + } | |
| 51 | + | |
| 52 | + @Test | |
| 53 | + public void testMatchResponse_tcpRawUnitOrFunctionCodeMismatch() { | |
| 54 | + // 准备参数 | |
| 55 | + PendingRequest request = buildRawRequest(channelA, 5, 3, 2, System.currentTimeMillis() + 60_000); | |
| 56 | + pendingRequestManager.addRequest(request); | |
| 57 | + | |
| 58 | + // 调用 + 断言:事务号相同,但功能码或 unit 不同:不出队 | |
| 59 | + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 4, 2), | |
| 60 | + IotModbusFrameFormatEnum.MODBUS_TCP, channelA)); | |
| 61 | + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 3, 2).setSlaveId(SLAVE_ID + 1), | |
| 62 | + IotModbusFrameFormatEnum.MODBUS_TCP, channelA)); | |
| 63 | + assertSame(request, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 3, 2), | |
| 64 | + IotModbusFrameFormatEnum.MODBUS_TCP, channelA)); | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Test | |
| 68 | + public void testMatchResponse_tcpExceptionOnlyMatchRaw() { | |
| 69 | + // 准备参数:轮询请求 5 号、透传请求 6 号 | |
| 70 | + PendingRequest poll = buildPollRequest(channelA, 5, 3, 2, System.currentTimeMillis() + 60_000); | |
| 71 | + PendingRequest raw = buildRawRequest(channelA, 6, 3, 2, System.currentTimeMillis() + 60_000); | |
| 72 | + pendingRequestManager.addRequest(poll); | |
| 73 | + pendingRequestManager.addRequest(raw); | |
| 74 | + | |
| 75 | + // 调用 + 断言:轮询的异常响应照旧丢弃,不出队 | |
| 76 | + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(5, 3, 2), | |
| 77 | + IotModbusFrameFormatEnum.MODBUS_TCP, channelA)); | |
| 78 | + // 调用 + 断言:透传的异常响应配给透传请求 | |
| 79 | + assertSame(raw, pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(6, 3, 2), | |
| 80 | + IotModbusFrameFormatEnum.MODBUS_TCP, channelA)); | |
| 81 | + // 断言:轮询请求仍在,留给到期清理 | |
| 82 | + assertTrue(pendingRequestManager.containsTransactionId(DEVICE_ID, channelA, 5)); | |
| 83 | + } | |
| 84 | + | |
| 85 | + // ========== RTU 帧:原有的 FIFO(slaveId + functionCode + registerCount) ========== | |
| 86 | + | |
| 87 | + @Test | |
| 88 | + public void testMatchResponse_rtuFifoBySlaveFunctionCodeCount() { | |
| 89 | + // 准备参数:同功能码、数量不同的两个轮询请求 | |
| 90 | + PendingRequest request2 = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); | |
| 91 | + PendingRequest request1 = buildPollRequest(channelA, null, 3, 1, System.currentTimeMillis() + 60_000); | |
| 92 | + pendingRequestManager.addRequest(request2); | |
| 93 | + pendingRequestManager.addRequest(request1); | |
| 94 | + | |
| 95 | + // 调用 + 断言:unit 不同不匹配 | |
| 96 | + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 1).setSlaveId(SLAVE_ID + 1), | |
| 97 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); | |
| 98 | + // 调用 + 断言:按数量配给对应的请求,与先后顺序无关 | |
| 99 | + assertSame(request1, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 1), | |
| 100 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); | |
| 101 | + assertSame(request2, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), | |
| 102 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); | |
| 103 | + } | |
| 104 | + | |
| 105 | + /** | |
| 106 | + * 原有逻辑的限制(不是安全保证):RTU 帧没有事务号,同 unit、同功能码、同数量的请求无法区分,响应按 FIFO 配给排在前面的请求 | |
| 107 | + */ | |
| 108 | + @Test | |
| 109 | + public void testMatchResponse_rtuSameShapeMatchFirst_originalLimit() { | |
| 110 | + // 准备参数:轮询请求 P 在前,同形状的透传请求 R 在后 | |
| 111 | + PendingRequest poll = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); | |
| 112 | + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); | |
| 113 | + pendingRequestManager.addRequest(poll); | |
| 114 | + pendingRequestManager.addRequest(raw); | |
| 115 | + | |
| 116 | + // 调用:无论这个响应实际是谁的,都配给排在前面的 P | |
| 117 | + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), | |
| 118 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA); | |
| 119 | + | |
| 120 | + // 断言:下一个同形状的正常响应才配给 R | |
| 121 | + assertSame(poll, matched); | |
| 122 | + assertSame(raw, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), | |
| 123 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); | |
| 124 | + } | |
| 125 | + | |
| 126 | + @Test | |
| 127 | + public void testMatchResponse_rtuRawThenPollNormal() { | |
| 128 | + // 准备参数:透传请求 R 在前,同形状的轮询请求 P 在后 | |
| 129 | + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); | |
| 130 | + PendingRequest poll = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); | |
| 131 | + pendingRequestManager.addRequest(raw); | |
| 132 | + pendingRequestManager.addRequest(poll); | |
| 133 | + | |
| 134 | + // 调用 | |
| 135 | + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), | |
| 136 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA); | |
| 137 | + | |
| 138 | + // 断言:按 FIFO 先配给 R,下一个再配给 P | |
| 139 | + assertSame(raw, matched); | |
| 140 | + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), | |
| 141 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); | |
| 142 | + } | |
| 143 | + | |
| 144 | + @Test | |
| 145 | + public void testMatchResponse_rtuSkipOldConnection() { | |
| 146 | + // 准备参数:旧连接 A 的请求排在前面,新连接 B 发出同 unit、同功能码、同数量的请求 | |
| 147 | + PendingRequest requestA = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); | |
| 148 | + PendingRequest requestB = buildPollRequest(channelB, null, 3, 2, System.currentTimeMillis() + 60_000); | |
| 149 | + pendingRequestManager.addRequest(requestA); | |
| 150 | + pendingRequestManager.addRequest(requestB); | |
| 151 | + | |
| 152 | + // 调用:B 收到响应 | |
| 153 | + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), | |
| 154 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelB); | |
| 155 | + | |
| 156 | + // 断言:响应配给 B 的请求,不是 A 的旧条目 | |
| 157 | + assertSame(requestB, matched); | |
| 158 | + } | |
| 159 | + | |
| 160 | + @Test | |
| 161 | + public void testMatchResponse_rtuPollThenRawException() { | |
| 162 | + // 准备参数:FC03,轮询请求 P(读 1 个)在前,透传请求 R(读 2 个)在后 | |
| 163 | + PendingRequest poll = buildPollRequest(channelA, null, 3, 1, System.currentTimeMillis() + 60_000); | |
| 164 | + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); | |
| 165 | + pendingRequestManager.addRequest(poll); | |
| 166 | + pendingRequestManager.addRequest(raw); | |
| 167 | + | |
| 168 | + // 调用:设备回 83 02(解码器已还原原功能码,pdu[0] 是异常码) | |
| 169 | + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2), | |
| 170 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA); | |
| 171 | + | |
| 172 | + // 断言:按 FIFO 属于排在前面的 P:照旧丢弃,不跳过 P 去收尾 R;P、R 都不出队 | |
| 173 | + assertNull(matched); | |
| 174 | + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 1), | |
| 175 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); | |
| 176 | + // 调用 + 断言:P 出队后 R 排在最前面,下一个异常响应收尾 R | |
| 177 | + assertSame(raw, pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2), | |
| 178 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); | |
| 179 | + } | |
| 180 | + | |
| 181 | + @Test | |
| 182 | + public void testMatchResponse_rtuRawThenPollException() { | |
| 183 | + // 准备参数:FC03,透传请求 R 在前,轮询请求 P 在后 | |
| 184 | + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); | |
| 185 | + PendingRequest poll = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); | |
| 186 | + pendingRequestManager.addRequest(raw); | |
| 187 | + pendingRequestManager.addRequest(poll); | |
| 188 | + | |
| 189 | + // 调用:设备回 83 02 | |
| 190 | + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2), | |
| 191 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA); | |
| 192 | + | |
| 193 | + // 断言:R 在最前面,由异常响应收尾;P 不被消费 | |
| 194 | + assertSame(raw, matched); | |
| 195 | + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), | |
| 196 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); | |
| 197 | + } | |
| 198 | + | |
| 199 | + @Test | |
| 200 | + public void testMatchResponse_rtuExceptionIgnoreOtherUnitOrFunctionCode() { | |
| 201 | + // 准备参数:排在前面的轮询请求是其它 unit、其它功能码,透传请求 R 在后 | |
| 202 | + PendingRequest otherUnit = new PendingRequest(DEVICE_ID, 10L, "temperature", SLAVE_ID + 1, 3, 0, 2, | |
| 203 | + null, System.currentTimeMillis() + 60_000, channelA, null); | |
| 204 | + PendingRequest otherFunctionCode = buildPollRequest(channelA, null, 4, 2, System.currentTimeMillis() + 60_000); | |
| 205 | + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); | |
| 206 | + pendingRequestManager.addRequest(otherUnit); | |
| 207 | + pendingRequestManager.addRequest(otherFunctionCode); | |
| 208 | + pendingRequestManager.addRequest(raw); | |
| 209 | + | |
| 210 | + // 调用:设备回 83 02 | |
| 211 | + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2), | |
| 212 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA); | |
| 213 | + | |
| 214 | + // 断言:只按同 unit、同功能码排队,R 是其中最前面的 | |
| 215 | + assertSame(raw, matched); | |
| 216 | + } | |
| 217 | + | |
| 218 | + @Test | |
| 219 | + public void testMatchResponse_rtuExceptionWithoutRawDrop() { | |
| 220 | + // 准备参数:只有轮询请求 | |
| 221 | + PendingRequest poll = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); | |
| 222 | + pendingRequestManager.addRequest(poll); | |
| 223 | + | |
| 224 | + // 调用:轮询的异常响应 | |
| 225 | + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2), | |
| 226 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA); | |
| 227 | + | |
| 228 | + // 断言:照旧丢弃,轮询请求留给到期清理 | |
| 229 | + assertNull(matched); | |
| 230 | + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), | |
| 231 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); | |
| 232 | + } | |
| 233 | + | |
| 234 | + // ========== 清理 ========== | |
| 235 | + | |
| 236 | + @Test | |
| 237 | + public void testCleanupExpired() { | |
| 238 | + // 准备参数 | |
| 239 | + PendingRequest expired = buildPollRequest(channelA, 1, 3, 2, System.currentTimeMillis() - 1); | |
| 240 | + PendingRequest alive = buildPollRequest(channelA, 2, 3, 2, System.currentTimeMillis() + 60_000); | |
| 241 | + pendingRequestManager.addRequest(expired); | |
| 242 | + pendingRequestManager.addRequest(alive); | |
| 243 | + | |
| 244 | + // 调用 | |
| 245 | + pendingRequestManager.cleanupExpired(); | |
| 246 | + | |
| 247 | + // 断言:只移除到期的条目 | |
| 248 | + assertFalse(pendingRequestManager.containsTransactionId(DEVICE_ID, channelA, 1)); | |
| 249 | + assertTrue(pendingRequestManager.containsTransactionId(DEVICE_ID, channelA, 2)); | |
| 250 | + } | |
| 251 | + | |
| 252 | + @Test | |
| 253 | + public void testRemoveChannel_keepNewChannel() { | |
| 254 | + // 准备参数:旧连接 A、新连接 B 各有一个条目 | |
| 255 | + pendingRequestManager.addRequest(buildPollRequest(channelA, 1, 3, 2, System.currentTimeMillis() + 60_000)); | |
| 256 | + pendingRequestManager.addRequest(buildRawRequest(channelB, 2, 3, 2, System.currentTimeMillis() + 60_000)); | |
| 257 | + | |
| 258 | + // 调用:旧连接关闭 | |
| 259 | + pendingRequestManager.removeChannel(DEVICE_ID, channelA); | |
| 260 | + | |
| 261 | + // 断言:只删 A 的条目,B 的条目仍可匹配 | |
| 262 | + assertFalse(pendingRequestManager.containsTransactionId(DEVICE_ID, channelA, 1)); | |
| 263 | + assertTrue(pendingRequestManager.containsTransactionId(DEVICE_ID, channelB, 2)); | |
| 264 | + } | |
| 265 | + | |
| 266 | + @Test | |
| 267 | + public void testRemoveRequest_byReference() { | |
| 268 | + // 准备参数:两个字段完全相同的条目(PendingRequest 的 equals 按字段比较) | |
| 269 | + long expireAt = System.currentTimeMillis() + 60_000; | |
| 270 | + PendingRequest request1 = buildPollRequest(channelA, null, 3, 2, expireAt); | |
| 271 | + PendingRequest request2 = buildPollRequest(channelA, null, 3, 2, expireAt); | |
| 272 | + pendingRequestManager.addRequest(request1); | |
| 273 | + pendingRequestManager.addRequest(request2); | |
| 274 | + | |
| 275 | + // 调用 | |
| 276 | + pendingRequestManager.removeRequest(request1); | |
| 277 | + | |
| 278 | + // 断言:只移除 request1 这个引用 | |
| 279 | + assertSame(request2, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), | |
| 280 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); | |
| 281 | + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), | |
| 282 | + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); | |
| 283 | + } | |
| 284 | + | |
| 285 | + // ========== 辅助方法 ========== | |
| 286 | + | |
| 287 | + private static PendingRequest buildPollRequest(IotModbusTcpServerTransactionChannel channel, Integer transactionId, | |
| 288 | + int functionCode, int registerCount, long expireAt) { | |
| 289 | + return new PendingRequest(DEVICE_ID, 10L, "temperature", SLAVE_ID, functionCode, 0, registerCount, | |
| 290 | + transactionId, expireAt, channel, null); | |
| 291 | + } | |
| 292 | + | |
| 293 | + private static PendingRequest buildRawRequest(IotModbusTcpServerTransactionChannel channel, Integer transactionId, | |
| 294 | + int functionCode, int registerCount, long expireAt) { | |
| 295 | + when(channel.getRequestTimeout()).thenReturn(60_000); | |
| 296 | + byte[] pdu = HexUtil.decodeHex(String.format("%02X0000%04X", functionCode, registerCount)); | |
| 297 | + IotModbusTx tx = IotModbusTx.ofServer(channel, pdu, null, null); | |
| 298 | + return new PendingRequest(DEVICE_ID, null, null, SLAVE_ID, functionCode, 0, registerCount, | |
| 299 | + transactionId, expireAt, channel, tx); | |
| 300 | + } | |
| 301 | + | |
| 302 | + private static IotModbusFrame buildReadFrame(Integer transactionId, int functionCode, int registerCount) { | |
| 303 | + byte[] pdu = new byte[1 + registerCount * 2]; | |
| 304 | + pdu[0] = (byte) (registerCount * 2); | |
| 305 | + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(functionCode) | |
| 306 | + .setPdu(pdu).setTransactionId(transactionId); | |
| 307 | + } | |
| 308 | + | |
| 309 | + /** | |
| 310 | + * 异常响应帧:解码器已还原原功能码,pdu[0] 是异常码 | |
| 311 | + */ | |
| 312 | + private static IotModbusFrame buildExceptionFrame(Integer transactionId, int functionCode, int exceptionCode) { | |
| 313 | + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(functionCode) | |
| 314 | + .setPdu(new byte[]{(byte) exceptionCode}).setExceptionCode(exceptionCode) | |
| 315 | + .setTransactionId(transactionId); | |
| 316 | + } | |
| 317 | + | |
| 318 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerTransactionChannelTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager; | |
| 2 | + | |
| 3 | +import cn.hutool.core.map.MapUtil; | |
| 4 | +import cn.hutool.core.util.HexUtil; | |
| 5 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 9 | +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; | |
| 10 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 12 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; | |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder; | |
| 14 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.downstream.IotModbusTcpServerDownstreamHandler; | |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; | |
| 16 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; | |
| 17 | +import io.vertx.core.Context; | |
| 18 | +import io.vertx.core.Future; | |
| 19 | +import io.vertx.core.Vertx; | |
| 20 | +import io.vertx.core.buffer.Buffer; | |
| 21 | +import io.vertx.core.net.NetSocket; | |
| 22 | +import org.junit.jupiter.api.AfterEach; | |
| 23 | +import org.junit.jupiter.api.BeforeEach; | |
| 24 | +import org.junit.jupiter.api.Test; | |
| 25 | +import org.mockito.ArgumentCaptor; | |
| 26 | + | |
| 27 | +import java.util.ArrayList; | |
| 28 | +import java.util.Arrays; | |
| 29 | +import java.util.Collections; | |
| 30 | +import java.util.List; | |
| 31 | +import java.util.concurrent.CountDownLatch; | |
| 32 | +import java.util.concurrent.TimeUnit; | |
| 33 | +import java.util.concurrent.atomic.AtomicInteger; | |
| 34 | +import java.util.concurrent.atomic.AtomicReference; | |
| 35 | + | |
| 36 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; | |
| 37 | +import static org.junit.jupiter.api.Assertions.*; | |
| 38 | +import static org.mockito.ArgumentMatchers.any; | |
| 39 | +import static org.mockito.Mockito.*; | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * {@link IotModbusTcpServerTransactionChannel} 的单元测试 | |
| 43 | + * | |
| 44 | + * @author 芋道源码 | |
| 45 | + */ | |
| 46 | +public class IotModbusTcpServerTransactionChannelTest { | |
| 47 | + | |
| 48 | + private static final Long DEVICE_ID = 1L; | |
| 49 | + private static final int SLAVE_ID = 1; | |
| 50 | + private static final int REQUEST_TIMEOUT = 60_000; | |
| 51 | + private static final Long POINT_ID = 10L; | |
| 52 | + | |
| 53 | + private Vertx vertx; | |
| 54 | + private Context owner; | |
| 55 | + private NetSocket socket; | |
| 56 | + private RecordingPendingRequestManager pendingRequestManager; | |
| 57 | + | |
| 58 | + /** | |
| 59 | + * socket.write 被调用时所在的线程 | |
| 60 | + */ | |
| 61 | + private final List<Thread> writeThreads = Collections.synchronizedList(new ArrayList<>()); | |
| 62 | + | |
| 63 | + @BeforeEach | |
| 64 | + public void setUp() { | |
| 65 | + vertx = Vertx.vertx(); | |
| 66 | + owner = vertx.getOrCreateContext(); | |
| 67 | + socket = mock(NetSocket.class); | |
| 68 | + when(socket.write(any(Buffer.class))).thenAnswer(invocation -> { | |
| 69 | + writeThreads.add(Thread.currentThread()); | |
| 70 | + return Future.succeededFuture(); | |
| 71 | + }); | |
| 72 | + pendingRequestManager = new RecordingPendingRequestManager(); | |
| 73 | + } | |
| 74 | + | |
| 75 | + @AfterEach | |
| 76 | + public void tearDown() throws Exception { | |
| 77 | + vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); | |
| 78 | + } | |
| 79 | + | |
| 80 | + // ========== 透传:提交与名额 ========== | |
| 81 | + | |
| 82 | + @Test | |
| 83 | + public void testSubmitRaw_singleOwner() throws Exception { | |
| 84 | + // 准备参数 | |
| 85 | + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, | |
| 86 | + new AtomicInteger(), REQUEST_TIMEOUT); | |
| 87 | + Thread ownerThread = getOwnerThread(); | |
| 88 | + | |
| 89 | + // 调用:4 个非 owner 线程并发提交,共 20 个透传 | |
| 90 | + List<Thread> submitters = new ArrayList<>(); | |
| 91 | + for (int i = 0; i < 4; i++) { | |
| 92 | + Thread submitter = new Thread(() -> { | |
| 93 | + for (int j = 0; j < 5; j++) { | |
| 94 | + channel.submitRaw(newTx(channel, new TxResult())); | |
| 95 | + } | |
| 96 | + }); | |
| 97 | + submitters.add(submitter); | |
| 98 | + submitter.start(); | |
| 99 | + } | |
| 100 | + for (Thread submitter : submitters) { | |
| 101 | + submitter.join(); | |
| 102 | + } | |
| 103 | + awaitOwner(); | |
| 104 | + | |
| 105 | + // 断言:写出和登记待响应请求都只发生在 owner 上 | |
| 106 | + assertEquals(20, writeThreads.size()); | |
| 107 | + writeThreads.forEach(thread -> assertSame(ownerThread, thread)); | |
| 108 | + assertEquals(20, pendingRequestManager.addThreads.size()); | |
| 109 | + pendingRequestManager.addThreads.forEach(thread -> assertSame(ownerThread, thread)); | |
| 110 | + } | |
| 111 | + | |
| 112 | + @Test | |
| 113 | + public void testSubmitRaw_queueFull() throws Exception { | |
| 114 | + // 准备参数 | |
| 115 | + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, | |
| 116 | + new AtomicInteger(), REQUEST_TIMEOUT); | |
| 117 | + TxResult result = new TxResult(); | |
| 118 | + | |
| 119 | + // 调用:设备不回应,第 33 个时排队 + 在途已满 | |
| 120 | + for (int i = 0; i < IotModbusTcpServerTransactionChannel.MAX_RAW_OUTSTANDING + 1; i++) { | |
| 121 | + channel.submitRaw(newTx(channel, result)); | |
| 122 | + } | |
| 123 | + awaitOwner(); | |
| 124 | + | |
| 125 | + // 断言 | |
| 126 | + assertEquals(IotModbusTcpServerTransactionChannel.MAX_RAW_OUTSTANDING, writeThreads.size()); | |
| 127 | + assertFailures(result, MODBUS_QUEUE_FULL); | |
| 128 | + } | |
| 129 | + | |
| 130 | + @Test | |
| 131 | + public void testAllocateTransactionId_skipPending() throws Exception { | |
| 132 | + // 准备参数:计数器从 65535 绕回 0 | |
| 133 | + AtomicInteger transactionIdCounter = new AtomicInteger(65535); | |
| 134 | + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, | |
| 135 | + transactionIdCounter, REQUEST_TIMEOUT); | |
| 136 | + channel.submitRaw(newTx(channel, new TxResult())); | |
| 137 | + awaitOwner(); | |
| 138 | + // 本连接还有一个 2 号的轮询请求 | |
| 139 | + pendingRequestManager.addRequest(buildPollRequest(channel, 2)); | |
| 140 | + | |
| 141 | + // 调用 + 断言:0 号仍被在途透传占用,取下一个空闲编号 1 | |
| 142 | + assertEquals(0, pendingRequestManager.added.get(0).getTransactionId()); | |
| 143 | + assertEquals(0, captureWrites(1).get(0).getUnsignedShort(0)); | |
| 144 | + transactionIdCounter.set(65535); | |
| 145 | + assertEquals(1, channel.allocateTransactionId()); | |
| 146 | + // 调用 + 断言:2 号被轮询请求占用,跳过 | |
| 147 | + assertEquals(3, channel.allocateTransactionId()); | |
| 148 | + // 调用 + 断言:RTU 帧格式没有事务号 | |
| 149 | + assertNull(createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, transactionIdCounter, REQUEST_TIMEOUT) | |
| 150 | + .allocateTransactionId()); | |
| 151 | + } | |
| 152 | + | |
| 153 | + // ========== 标准轮询、标准写:保持原逻辑,不受透传影响 ========== | |
| 154 | + | |
| 155 | + @Test | |
| 156 | + public void testPollPoint_notLimitedByRawQueue() throws Exception { | |
| 157 | + // 准备参数:RTU 下 1 个透传在途、31 个排队,第 33 个被拒 | |
| 158 | + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, | |
| 159 | + new AtomicInteger(), REQUEST_TIMEOUT); | |
| 160 | + TxResult result = new TxResult(); | |
| 161 | + for (int i = 0; i < IotModbusTcpServerTransactionChannel.MAX_RAW_OUTSTANDING + 1; i++) { | |
| 162 | + channel.submitRaw(newTx(channel, result)); | |
| 163 | + } | |
| 164 | + awaitOwner(); | |
| 165 | + assertFailures(result, MODBUS_QUEUE_FULL); | |
| 166 | + IotModbusTcpServerPollScheduler pollScheduler = createPollScheduler(channel, IotModbusFrameFormatEnum.MODBUS_RTU); | |
| 167 | + | |
| 168 | + // 调用:照常轮询两次 | |
| 169 | + pollScheduler.pollPoint(DEVICE_ID, POINT_ID); | |
| 170 | + pollScheduler.pollPoint(DEVICE_ID, POINT_ID); | |
| 171 | + | |
| 172 | + // 断言:轮询不排队、不受透传名额限制,直接写出,并按原逻辑登记属于本连接的待响应请求 | |
| 173 | + assertEquals(3, writeThreads.size()); | |
| 174 | + List<PendingRequest> polls = pendingRequestManager.getPolls(); | |
| 175 | + assertEquals(2, polls.size()); | |
| 176 | + polls.forEach(poll -> assertSame(channel, poll.getChannel())); | |
| 177 | + } | |
| 178 | + | |
| 179 | + @Test | |
| 180 | + public void testOnCleanup_rtuTimeoutNotCloseAndPollContinues() throws Exception { | |
| 181 | + // 准备参数:RTU 下轮询 P、透传 R 都已发出 | |
| 182 | + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, | |
| 183 | + new AtomicInteger(), REQUEST_TIMEOUT); | |
| 184 | + IotModbusTcpServerPollScheduler pollScheduler = createPollScheduler(channel, IotModbusFrameFormatEnum.MODBUS_RTU); | |
| 185 | + pollScheduler.pollPoint(DEVICE_ID, POINT_ID); | |
| 186 | + TxResult result = new TxResult(); | |
| 187 | + channel.submitRaw(newTx(channel, result)); | |
| 188 | + awaitOwner(); | |
| 189 | + // 设备都不回应:两个待响应请求到期 | |
| 190 | + pendingRequestManager.added.forEach(request -> request.setExpireAt(System.currentTimeMillis() - 1)); | |
| 191 | + | |
| 192 | + // 调用:同一个清理周期,先按原逻辑清理待响应请求,再通知通道;之后清理信号重复到达 | |
| 193 | + pendingRequestManager.cleanupExpired(); | |
| 194 | + channel.onCleanup(); | |
| 195 | + channel.onCleanup(); | |
| 196 | + awaitOwner(); | |
| 197 | + | |
| 198 | + // 断言:R 只以 110 收尾一次,不断开连接 | |
| 199 | + assertFailures(result, MODBUS_TIMEOUT); | |
| 200 | + verify(socket, never()).close(); | |
| 201 | + // 调用 + 断言:下一次轮询照常写出;R 的在途位置已释放,下一个透传也照常写出 | |
| 202 | + pollScheduler.pollPoint(DEVICE_ID, POINT_ID); | |
| 203 | + channel.submitRaw(newTx(channel, new TxResult())); | |
| 204 | + awaitOwner(); | |
| 205 | + assertEquals(4, writeThreads.size()); | |
| 206 | + } | |
| 207 | + | |
| 208 | + @Test | |
| 209 | + public void testWriteProperty_logOnSocketWriteWithoutWaitingResponse() throws Exception { | |
| 210 | + // 准备参数 | |
| 211 | + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, | |
| 212 | + new AtomicInteger(), REQUEST_TIMEOUT); | |
| 213 | + IotModbusTcpServerConnectionManager connectionManager = createConnectionManager(channel, | |
| 214 | + IotModbusFrameFormatEnum.MODBUS_TCP); | |
| 215 | + IotModbusTcpServerDownstreamHandler downstreamHandler = new IotModbusTcpServerDownstreamHandler( | |
| 216 | + connectionManager, mockConfigCacheService(), new IotModbusFrameEncoder(65)); | |
| 217 | + IotDeviceMessage message = IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), | |
| 218 | + MapUtil.of("temperature", 220)); | |
| 219 | + message.setDeviceId(DEVICE_ID); | |
| 220 | + | |
| 221 | + // 调用 | |
| 222 | + downstreamHandler.handle(message); | |
| 223 | + | |
| 224 | + // 断言:写出 FC06 请求(socket 写出成功即记成功);不登记待响应请求,不等待设备回显 | |
| 225 | + List<Buffer> writes = captureWrites(1); | |
| 226 | + assertArrayEquals(HexUtil.decodeHex("06000000DC"), writes.get(0).getBytes(7, 12)); | |
| 227 | + assertTrue(pendingRequestManager.added.isEmpty()); | |
| 228 | + } | |
| 229 | + | |
| 230 | + // ========== 透传:响应关联 ========== | |
| 231 | + | |
| 232 | + @Test | |
| 233 | + public void testOnRawResponse_tcpNormalAndException() throws Exception { | |
| 234 | + // 准备参数:两个透传同时在途 | |
| 235 | + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, | |
| 236 | + new AtomicInteger(), REQUEST_TIMEOUT); | |
| 237 | + TxResult result1 = new TxResult(); | |
| 238 | + TxResult result2 = new TxResult(); | |
| 239 | + channel.submitRaw(newTx(channel, result1)); | |
| 240 | + channel.submitRaw(newTx(channel, result2)); | |
| 241 | + awaitOwner(); | |
| 242 | + int transactionId1 = pendingRequestManager.added.get(0).getTransactionId(); | |
| 243 | + int transactionId2 = pendingRequestManager.added.get(1).getTransactionId(); | |
| 244 | + | |
| 245 | + // 调用:先回第 2 个的异常响应,再回第 1 个的正常响应 | |
| 246 | + receive(channel, IotModbusFrameFormatEnum.MODBUS_TCP, exceptionFrame(transactionId2)); | |
| 247 | + receive(channel, IotModbusFrameFormatEnum.MODBUS_TCP, readResponseFrame(transactionId1)); | |
| 248 | + | |
| 249 | + // 断言:按事务号各自回到原请求 | |
| 250 | + assertResponse(result1, "030400FA0258"); | |
| 251 | + assertResponse(result2, "8302"); | |
| 252 | + } | |
| 253 | + | |
| 254 | + @Test | |
| 255 | + public void testOnRawResponse_rtuDeviceException() throws Exception { | |
| 256 | + // 准备参数:RTU 下透传 R 在途,之后才发出轮询 P(同功能码) | |
| 257 | + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, | |
| 258 | + new AtomicInteger(), REQUEST_TIMEOUT); | |
| 259 | + TxResult result = new TxResult(); | |
| 260 | + channel.submitRaw(newTx(channel, result)); | |
| 261 | + awaitOwner(); | |
| 262 | + PendingRequest poll = buildPollRequest(channel, null); | |
| 263 | + pendingRequestManager.addRequest(poll); | |
| 264 | + | |
| 265 | + // 调用:设备回 83 02 | |
| 266 | + receive(channel, IotModbusFrameFormatEnum.MODBUS_RTU, exceptionFrame(null)); | |
| 267 | + | |
| 268 | + // 断言:R 排在最前面,设备的真实异常响应回到透传请求;轮询的待响应请求不被提前消费 | |
| 269 | + assertResponse(result, "8302"); | |
| 270 | + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, readResponseFrame(null), | |
| 271 | + IotModbusFrameFormatEnum.MODBUS_RTU, channel)); | |
| 272 | + } | |
| 273 | + | |
| 274 | + @Test | |
| 275 | + public void testOnRawResponse_rtuPollExceptionNotSkipped() throws Exception { | |
| 276 | + // 准备参数:RTU 下轮询 P(同功能码)在前,透传 R 在途 | |
| 277 | + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, | |
| 278 | + new AtomicInteger(), REQUEST_TIMEOUT); | |
| 279 | + PendingRequest poll = buildPollRequest(channel, null); | |
| 280 | + pendingRequestManager.addRequest(poll); | |
| 281 | + TxResult result = new TxResult(); | |
| 282 | + channel.submitRaw(newTx(channel, result)); | |
| 283 | + awaitOwner(); | |
| 284 | + | |
| 285 | + // 调用:设备回 83 02(按 FIFO 是 P 的异常响应) | |
| 286 | + AtomicReference<PendingRequest> matched = new AtomicReference<>(); | |
| 287 | + owner.runOnContext(v -> matched.set(pendingRequestManager.matchResponse(DEVICE_ID, exceptionFrame(null), | |
| 288 | + IotModbusFrameFormatEnum.MODBUS_RTU, channel))); | |
| 289 | + awaitOwner(); | |
| 290 | + | |
| 291 | + // 断言:照旧丢弃,不跳过 P 去收尾 R;R 仍在途 | |
| 292 | + assertNull(matched.get()); | |
| 293 | + assertTrue(result.responses.isEmpty()); | |
| 294 | + assertTrue(result.failures.isEmpty()); | |
| 295 | + // 调用 + 断言:P 照旧按 FIFO 先出队;之后 R 的响应照常回到 R | |
| 296 | + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, readResponseFrame(null), | |
| 297 | + IotModbusFrameFormatEnum.MODBUS_RTU, channel)); | |
| 298 | + receive(channel, IotModbusFrameFormatEnum.MODBUS_RTU, readResponseFrame(null)); | |
| 299 | + assertResponse(result, "030400FA0258"); | |
| 300 | + } | |
| 301 | + | |
| 302 | + // ========== 透传:唯一终态 ========== | |
| 303 | + | |
| 304 | + @Test | |
| 305 | + public void testOnRawWriteFailed_releaseOnceAndKeepConnection() throws Exception { | |
| 306 | + // 准备参数:透传写出失败 | |
| 307 | + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, | |
| 308 | + new AtomicInteger(), REQUEST_TIMEOUT); | |
| 309 | + when(socket.write(any(Buffer.class))).thenReturn(Future.failedFuture("写出失败")); | |
| 310 | + TxResult result = new TxResult(); | |
| 311 | + | |
| 312 | + // 调用 | |
| 313 | + channel.submitRaw(newTx(channel, result)); | |
| 314 | + awaitOwner(); | |
| 315 | + | |
| 316 | + // 断言:只以 106 收尾一次,按引用移除待响应请求,不主动关闭连接 | |
| 317 | + assertFailures(result, DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 318 | + assertFalse(pendingRequestManager.containsTransactionId(DEVICE_ID, channel, | |
| 319 | + pendingRequestManager.added.get(0).getTransactionId())); | |
| 320 | + verify(socket, never()).close(); | |
| 321 | + // 调用 + 断言:名额已释放,写出恢复后可以再提交满 MAX_RAW_OUTSTANDING 个 | |
| 322 | + when(socket.write(any(Buffer.class))).thenReturn(Future.succeededFuture()); | |
| 323 | + TxResult next = new TxResult(); | |
| 324 | + for (int i = 0; i < IotModbusTcpServerTransactionChannel.MAX_RAW_OUTSTANDING; i++) { | |
| 325 | + channel.submitRaw(newTx(channel, next)); | |
| 326 | + } | |
| 327 | + awaitOwner(); | |
| 328 | + assertTrue(next.failures.isEmpty()); | |
| 329 | + } | |
| 330 | + | |
| 331 | + @Test | |
| 332 | + public void testOnCleanup_queuedExpired() throws Exception { | |
| 333 | + // 准备参数:RTU 下 R1 在途、R2 排队,超时时间很短 | |
| 334 | + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, | |
| 335 | + new AtomicInteger(), 200); | |
| 336 | + TxResult result1 = new TxResult(); | |
| 337 | + TxResult result2 = new TxResult(); | |
| 338 | + channel.submitRaw(newTx(channel, result1)); | |
| 339 | + channel.submitRaw(newTx(channel, result2)); | |
| 340 | + awaitOwner(); | |
| 341 | + Thread.sleep(300); | |
| 342 | + | |
| 343 | + // 调用:清理周期到达两次 | |
| 344 | + channel.onCleanup(); | |
| 345 | + channel.onCleanup(); | |
| 346 | + awaitOwner(); | |
| 347 | + | |
| 348 | + // 断言:在途的 R1、排队的 R2 都只以 110 收尾一次;R2 从未写出 | |
| 349 | + assertFailures(result1, MODBUS_TIMEOUT); | |
| 350 | + assertFailures(result2, MODBUS_TIMEOUT); | |
| 351 | + assertEquals(1, writeThreads.size()); | |
| 352 | + } | |
| 353 | + | |
| 354 | + @Test | |
| 355 | + public void testOnClose_idempotent() throws Exception { | |
| 356 | + // 准备参数:RTU 下 A 在途,B、C 排队;本连接还有一个轮询请求 | |
| 357 | + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, | |
| 358 | + new AtomicInteger(), REQUEST_TIMEOUT); | |
| 359 | + pendingRequestManager.addRequest(buildPollRequest(channel, null)); | |
| 360 | + TxResult resultA = new TxResult(); | |
| 361 | + TxResult resultB = new TxResult(); | |
| 362 | + TxResult resultC = new TxResult(); | |
| 363 | + channel.submitRaw(newTx(channel, resultA)); | |
| 364 | + channel.submitRaw(newTx(channel, resultB)); | |
| 365 | + channel.submitRaw(newTx(channel, resultC)); | |
| 366 | + awaitOwner(); | |
| 367 | + | |
| 368 | + // 调用:closeHandler 与主动关闭各通知一次,之后清理信号迟到 | |
| 369 | + channel.onClose(); | |
| 370 | + channel.onClose(); | |
| 371 | + channel.onCleanup(); | |
| 372 | + awaitOwner(); | |
| 373 | + | |
| 374 | + // 断言:每个透传都只以 115 收尾一次;本连接的待响应请求全部清理 | |
| 375 | + for (TxResult result : Arrays.asList(resultA, resultB, resultC)) { | |
| 376 | + assertFailures(result, MODBUS_DISCONNECTED); | |
| 377 | + } | |
| 378 | + verify(socket, times(1)).write(any(Buffer.class)); | |
| 379 | + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, readResponseFrame(null), | |
| 380 | + IotModbusFrameFormatEnum.MODBUS_RTU, channel)); | |
| 381 | + // 调用 + 断言:关闭之后提交的透传直接 115,不写出 | |
| 382 | + TxResult late = new TxResult(); | |
| 383 | + channel.submitRaw(newTx(channel, late)); | |
| 384 | + awaitOwner(); | |
| 385 | + assertFailures(late, MODBUS_DISCONNECTED); | |
| 386 | + verify(socket, times(1)).write(any(Buffer.class)); | |
| 387 | + } | |
| 388 | + | |
| 389 | + @Test | |
| 390 | + public void testOnClose_oldNotClearNew() throws Exception { | |
| 391 | + // 准备参数:同一设备的旧连接 A、新连接 B 共享计数器,各有一个透传在途 | |
| 392 | + AtomicInteger transactionIdCounter = new AtomicInteger(); | |
| 393 | + IotModbusTcpServerTransactionChannel channelA = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, | |
| 394 | + transactionIdCounter, REQUEST_TIMEOUT); | |
| 395 | + IotModbusTcpServerTransactionChannel channelB = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, | |
| 396 | + transactionIdCounter, REQUEST_TIMEOUT); | |
| 397 | + TxResult resultA = new TxResult(); | |
| 398 | + TxResult resultB = new TxResult(); | |
| 399 | + channelA.submitRaw(newTx(channelA, resultA)); | |
| 400 | + channelB.submitRaw(newTx(channelB, resultB)); | |
| 401 | + awaitOwner(); | |
| 402 | + int transactionIdB = pendingRequestManager.added.get(1).getTransactionId(); | |
| 403 | + | |
| 404 | + // 调用:旧连接 A 关闭 | |
| 405 | + channelA.onClose(); | |
| 406 | + awaitOwner(); | |
| 407 | + | |
| 408 | + // 断言:A 以 115 收尾;B 的待响应请求仍在,响应照常回到 B 的透传 | |
| 409 | + assertFailures(resultA, MODBUS_DISCONNECTED); | |
| 410 | + receive(channelB, IotModbusFrameFormatEnum.MODBUS_TCP, readResponseFrame(transactionIdB)); | |
| 411 | + assertResponse(resultB, "030400FA0258"); | |
| 412 | + } | |
| 413 | + | |
| 414 | + // ========== 辅助方法 ========== | |
| 415 | + | |
| 416 | + private IotModbusTcpServerTransactionChannel createChannel(IotModbusFrameFormatEnum frameFormat, | |
| 417 | + AtomicInteger transactionIdCounter, | |
| 418 | + int requestTimeout) { | |
| 419 | + return new IotModbusTcpServerTransactionChannel(owner, socket, DEVICE_ID, SLAVE_ID, frameFormat, | |
| 420 | + new IotModbusFrameEncoder(65), pendingRequestManager, transactionIdCounter, requestTimeout); | |
| 421 | + } | |
| 422 | + | |
| 423 | + private IotModbusTcpServerConnectionManager createConnectionManager(IotModbusTcpServerTransactionChannel channel, | |
| 424 | + IotModbusFrameFormatEnum frameFormat) { | |
| 425 | + IotModbusTcpServerConnectionManager connectionManager = new IotModbusTcpServerConnectionManager(); | |
| 426 | + connectionManager.registerConnection(socket, new ConnectionInfo().setDeviceId(DEVICE_ID) | |
| 427 | + .setSlaveId(SLAVE_ID).setFrameFormat(frameFormat).setChannel(channel)); | |
| 428 | + return connectionManager; | |
| 429 | + } | |
| 430 | + | |
| 431 | + private IotModbusTcpServerPollScheduler createPollScheduler(IotModbusTcpServerTransactionChannel channel, | |
| 432 | + IotModbusFrameFormatEnum frameFormat) { | |
| 433 | + return new IotModbusTcpServerPollScheduler(vertx, createConnectionManager(channel, frameFormat), | |
| 434 | + new IotModbusFrameEncoder(65), pendingRequestManager, REQUEST_TIMEOUT, mockConfigCacheService()); | |
| 435 | + } | |
| 436 | + | |
| 437 | + /** | |
| 438 | + * 设备配置:一个 FC03 读 1 个寄存器的点位(INT16,可写,单值用 FC06) | |
| 439 | + */ | |
| 440 | + private static IotModbusTcpServerConfigCacheService mockConfigCacheService() { | |
| 441 | + IotModbusPointRespDTO point = new IotModbusPointRespDTO(); | |
| 442 | + point.setId(POINT_ID); | |
| 443 | + point.setIdentifier("temperature"); | |
| 444 | + point.setFunctionCode(3); | |
| 445 | + point.setRegisterAddress(0); | |
| 446 | + point.setRegisterCount(1); | |
| 447 | + point.setRawDataType("INT16"); | |
| 448 | + IotModbusDeviceConfigRespDTO config = new IotModbusDeviceConfigRespDTO(); | |
| 449 | + config.setDeviceId(DEVICE_ID); | |
| 450 | + config.setPoints(Collections.singletonList(point)); | |
| 451 | + IotModbusTcpServerConfigCacheService configCacheService = mock(IotModbusTcpServerConfigCacheService.class); | |
| 452 | + when(configCacheService.getConfig(DEVICE_ID)).thenReturn(config); | |
| 453 | + return configCacheService; | |
| 454 | + } | |
| 455 | + | |
| 456 | + private static IotModbusTx newTx(IotModbusTcpServerTransactionChannel channel, TxResult result) { | |
| 457 | + return IotModbusTx.ofServer(channel, HexUtil.decodeHex("0300100002"), null, | |
| 458 | + (responsePdu, failure) -> { | |
| 459 | + if (failure != null) { | |
| 460 | + result.failures.add(failure); | |
| 461 | + } else { | |
| 462 | + result.responses.add(responsePdu); | |
| 463 | + } | |
| 464 | + }); | |
| 465 | + } | |
| 466 | + | |
| 467 | + private static PendingRequest buildPollRequest(IotModbusTcpServerTransactionChannel channel, Integer transactionId) { | |
| 468 | + return new PendingRequest(DEVICE_ID, POINT_ID, "temperature", SLAVE_ID, 3, 0x10, 2, | |
| 469 | + transactionId, System.currentTimeMillis() + REQUEST_TIMEOUT, channel, null); | |
| 470 | + } | |
| 471 | + | |
| 472 | + private static IotModbusFrame readResponseFrame(Integer transactionId) { | |
| 473 | + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(3) | |
| 474 | + .setPdu(new byte[]{0x04, 0x00, (byte) 0xFA, 0x02, 0x58}).setTransactionId(transactionId); | |
| 475 | + } | |
| 476 | + | |
| 477 | + /** | |
| 478 | + * 异常响应帧 83 02:解码器已还原原功能码,pdu[0] 是异常码 | |
| 479 | + */ | |
| 480 | + private static IotModbusFrame exceptionFrame(Integer transactionId) { | |
| 481 | + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(3) | |
| 482 | + .setPdu(new byte[]{0x02}).setExceptionCode(2).setTransactionId(transactionId); | |
| 483 | + } | |
| 484 | + | |
| 485 | + /** | |
| 486 | + * 在 owner 上模拟 handlePollingResponse:先匹配出队,命中透传就交给通道 | |
| 487 | + */ | |
| 488 | + private void receive(IotModbusTcpServerTransactionChannel channel, IotModbusFrameFormatEnum frameFormat, | |
| 489 | + IotModbusFrame frame) throws InterruptedException { | |
| 490 | + owner.runOnContext(v -> { | |
| 491 | + PendingRequest request = pendingRequestManager.matchResponse(DEVICE_ID, frame, frameFormat, channel); | |
| 492 | + assertNotNull(request); | |
| 493 | + assertNotNull(request.getTx()); | |
| 494 | + channel.onRawResponse(frame, request); | |
| 495 | + }); | |
| 496 | + awaitOwner(); | |
| 497 | + } | |
| 498 | + | |
| 499 | + private List<Buffer> captureWrites(int times) { | |
| 500 | + ArgumentCaptor<Buffer> captor = ArgumentCaptor.forClass(Buffer.class); | |
| 501 | + verify(socket, times(times)).write(captor.capture()); | |
| 502 | + return captor.getAllValues(); | |
| 503 | + } | |
| 504 | + | |
| 505 | + private static void assertFailures(TxResult result, ErrorCode expected) { | |
| 506 | + assertEquals(1, result.failures.size()); | |
| 507 | + assertEquals(expected.getCode(), result.failures.get(0).getCode()); | |
| 508 | + } | |
| 509 | + | |
| 510 | + private static void assertResponse(TxResult result, String expectedHex) { | |
| 511 | + assertTrue(result.failures.isEmpty()); | |
| 512 | + assertEquals(1, result.responses.size()); | |
| 513 | + assertArrayEquals(HexUtil.decodeHex(expectedHex), result.responses.get(0)); | |
| 514 | + } | |
| 515 | + | |
| 516 | + /** | |
| 517 | + * 等待 owner 上已经排队的任务全部执行完:同一个 Context 上的任务按提交顺序执行 | |
| 518 | + */ | |
| 519 | + private void awaitOwner() throws InterruptedException { | |
| 520 | + CountDownLatch latch = new CountDownLatch(1); | |
| 521 | + owner.runOnContext(v -> latch.countDown()); | |
| 522 | + assertTrue(latch.await(5, TimeUnit.SECONDS)); | |
| 523 | + } | |
| 524 | + | |
| 525 | + private Thread getOwnerThread() throws InterruptedException { | |
| 526 | + AtomicReference<Thread> thread = new AtomicReference<>(); | |
| 527 | + CountDownLatch latch = new CountDownLatch(1); | |
| 528 | + owner.runOnContext(v -> { | |
| 529 | + thread.set(Thread.currentThread()); | |
| 530 | + latch.countDown(); | |
| 531 | + }); | |
| 532 | + assertTrue(latch.await(5, TimeUnit.SECONDS)); | |
| 533 | + return thread.get(); | |
| 534 | + } | |
| 535 | + | |
| 536 | + /** | |
| 537 | + * 透传事务的终态结果 | |
| 538 | + */ | |
| 539 | + private static class TxResult { | |
| 540 | + | |
| 541 | + private final List<byte[]> responses = Collections.synchronizedList(new ArrayList<>()); | |
| 542 | + private final List<ErrorCode> failures = Collections.synchronizedList(new ArrayList<>()); | |
| 543 | + | |
| 544 | + } | |
| 545 | + | |
| 546 | + /** | |
| 547 | + * 记录 addRequest 调用线程与条目的待响应请求管理器 | |
| 548 | + */ | |
| 549 | + private static class RecordingPendingRequestManager extends IotModbusTcpServerPendingRequestManager { | |
| 550 | + | |
| 551 | + private final List<Thread> addThreads = Collections.synchronizedList(new ArrayList<>()); | |
| 552 | + private final List<PendingRequest> added = Collections.synchronizedList(new ArrayList<>()); | |
| 553 | + | |
| 554 | + @Override | |
| 555 | + public void addRequest(PendingRequest request) { | |
| 556 | + addThreads.add(Thread.currentThread()); | |
| 557 | + added.add(request); | |
| 558 | + super.addRequest(request); | |
| 559 | + } | |
| 560 | + | |
| 561 | + private List<PendingRequest> getPolls() { | |
| 562 | + List<PendingRequest> polls = new ArrayList<>(); | |
| 563 | + for (PendingRequest request : added) { | |
| 564 | + if (request.getTx() == null) { | |
| 565 | + polls.add(request); | |
| 566 | + } | |
| 567 | + } | |
| 568 | + return polls; | |
| 569 | + } | |
| 570 | + | |
| 571 | + } | |
| 572 | + | |
| 573 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamHandlerTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.downstream; | |
| 2 | + | |
| 3 | +import ch.qos.logback.classic.Level; | |
| 4 | +import ch.qos.logback.classic.Logger; | |
| 5 | +import ch.qos.logback.classic.spi.ILoggingEvent; | |
| 6 | +import ch.qos.logback.core.read.ListAppender; | |
| 7 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 8 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 9 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 10 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 11 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 12 | +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; | |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 14 | +import io.netty.handler.codec.mqtt.MqttQoS; | |
| 15 | +import io.vertx.core.Future; | |
| 16 | +import io.vertx.core.Promise; | |
| 17 | +import io.vertx.core.buffer.Buffer; | |
| 18 | +import io.vertx.mqtt.MqttEndpoint; | |
| 19 | +import org.junit.jupiter.api.BeforeEach; | |
| 20 | +import org.junit.jupiter.api.Test; | |
| 21 | +import org.mockito.ArgumentCaptor; | |
| 22 | +import org.mockito.Mock; | |
| 23 | +import org.slf4j.LoggerFactory; | |
| 24 | + | |
| 25 | +import java.util.List; | |
| 26 | +import java.util.stream.Collectors; | |
| 27 | + | |
| 28 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 29 | +import static org.junit.jupiter.api.Assertions.*; | |
| 30 | +import static org.mockito.ArgumentMatchers.any; | |
| 31 | +import static org.mockito.ArgumentMatchers.anyBoolean; | |
| 32 | +import static org.mockito.ArgumentMatchers.anyString; | |
| 33 | +import static org.mockito.ArgumentMatchers.eq; | |
| 34 | +import static org.mockito.Mockito.*; | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * {@link IotMqttDownstreamHandler} 的单元测试 | |
| 38 | + * | |
| 39 | + * @author 芋道源码 | |
| 40 | + */ | |
| 41 | +public class IotMqttDownstreamHandlerTest extends BaseMockitoUnitTest { | |
| 42 | + | |
| 43 | + private static final Long DEVICE_ID = 1024L; | |
| 44 | + private static final String RAW_PUSH_TOPIC = "/sys/pk/dn/thing/raw/push"; | |
| 45 | + private static final String PROPERTY_SET_TOPIC = "/sys/pk/dn/thing/property/set"; | |
| 46 | + private static final byte[] BYTES = new byte[]{0x01, 0x02, 0x03}; | |
| 47 | + | |
| 48 | + @Mock | |
| 49 | + private IotDeviceMessageService deviceMessageService; | |
| 50 | + @Mock | |
| 51 | + private MqttEndpoint endpoint; | |
| 52 | + | |
| 53 | + private IotMqttConnectionManager connectionManager; | |
| 54 | + private IotMqttDownstreamHandler downstreamHandler; | |
| 55 | + | |
| 56 | + @BeforeEach | |
| 57 | + public void setUp() { | |
| 58 | + connectionManager = new IotMqttConnectionManager(); | |
| 59 | + downstreamHandler = new IotMqttDownstreamHandler(deviceMessageService, connectionManager); | |
| 60 | + } | |
| 61 | + | |
| 62 | + @Test | |
| 63 | + public void testHandle_propertySet() { | |
| 64 | + // mock 方法:设备在线,序列化成功 | |
| 65 | + registerConnection(); | |
| 66 | + IotDeviceMessage message = buildPropertySetMessage(); | |
| 67 | + byte[] payload = "{\"id\":\"m1\"}".getBytes(); | |
| 68 | + when(deviceMessageService.serializeDeviceMessage(message, "pk", "dn")).thenReturn(payload); | |
| 69 | + when(endpoint.publish(anyString(), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false))) | |
| 70 | + .thenReturn(Future.succeededFuture(1)); | |
| 71 | + | |
| 72 | + // 调用 | |
| 73 | + downstreamHandler.handle(message); | |
| 74 | + // 断言:发布到 property/set 主题,载荷为序列化结果 | |
| 75 | + ArgumentCaptor<Buffer> payloadCaptor = ArgumentCaptor.forClass(Buffer.class); | |
| 76 | + verify(endpoint).publish(eq(PROPERTY_SET_TOPIC), payloadCaptor.capture(), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false)); | |
| 77 | + assertArrayEquals(payload, payloadCaptor.getValue().getBytes()); | |
| 78 | + } | |
| 79 | + | |
| 80 | + @Test | |
| 81 | + public void testHandle_offline() { | |
| 82 | + // 调用:设备未连接 | |
| 83 | + downstreamHandler.handle(buildPropertySetMessage()); | |
| 84 | + // 断言:不序列化,也不发布 | |
| 85 | + verifyNoInteractions(deviceMessageService, endpoint); | |
| 86 | + } | |
| 87 | + | |
| 88 | + @Test | |
| 89 | + public void testHandle_serializeEmpty() { | |
| 90 | + // mock 方法:设备在线,但序列化结果为空 | |
| 91 | + registerConnection(); | |
| 92 | + IotDeviceMessage message = buildPropertySetMessage(); | |
| 93 | + when(deviceMessageService.serializeDeviceMessage(message, "pk", "dn")).thenReturn(new byte[0]); | |
| 94 | + | |
| 95 | + // 调用:异常只记录日志,不向外抛出 | |
| 96 | + assertDoesNotThrow(() -> downstreamHandler.handle(message)); | |
| 97 | + // 断言:不发布 | |
| 98 | + verify(endpoint, never()).publish(anyString(), any(Buffer.class), any(MqttQoS.class), anyBoolean(), anyBoolean()); | |
| 99 | + } | |
| 100 | + | |
| 101 | + @Test | |
| 102 | + public void testHandleRaw_success() { | |
| 103 | + // mock 方法:设备在线,publish 成功 | |
| 104 | + registerConnection(); | |
| 105 | + when(endpoint.publish(anyString(), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false))) | |
| 106 | + .thenReturn(Future.succeededFuture(1)); | |
| 107 | + | |
| 108 | + // 调用 | |
| 109 | + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); | |
| 110 | + // 断言:成功,并发布到 raw/push 主题,载荷不变 | |
| 111 | + assertTrue(future.succeeded()); | |
| 112 | + ArgumentCaptor<Buffer> payloadCaptor = ArgumentCaptor.forClass(Buffer.class); | |
| 113 | + verify(endpoint).publish(eq(RAW_PUSH_TOPIC), payloadCaptor.capture(), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false)); | |
| 114 | + assertArrayEquals(BYTES, payloadCaptor.getValue().getBytes()); | |
| 115 | + verifyNoInteractions(deviceMessageService); | |
| 116 | + } | |
| 117 | + | |
| 118 | + @Test | |
| 119 | + public void testHandleRaw_offline() { | |
| 120 | + // 调用:设备未连接 | |
| 121 | + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); | |
| 122 | + // 断言 | |
| 123 | + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 124 | + } | |
| 125 | + | |
| 126 | + @Test | |
| 127 | + public void testHandleRaw_writeFailed() { | |
| 128 | + // mock 方法:设备在线,publish 已提交,写出结果稍后才返回 | |
| 129 | + registerConnection(); | |
| 130 | + Promise<Integer> write = Promise.promise(); | |
| 131 | + when(endpoint.publish(anyString(), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false))) | |
| 132 | + .thenReturn(write.future()); | |
| 133 | + ListAppender<ILoggingEvent> appender = attachAppender(); | |
| 134 | + try { | |
| 135 | + // 调用 | |
| 136 | + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); | |
| 137 | + // 断言:不等待写出,提交即返回成功 | |
| 138 | + assertTrue(future.succeeded()); | |
| 139 | + assertTrue(getWarnMessages(appender).isEmpty()); | |
| 140 | + | |
| 141 | + // 调用:写出失败(例如写出前连接已关闭) | |
| 142 | + write.fail(new IllegalStateException("Connection is closed")); | |
| 143 | + // 断言:晚到的写出失败记 warn 日志(设备、主题、原因) | |
| 144 | + List<String> warnMessages = getWarnMessages(appender); | |
| 145 | + assertEquals(1, warnMessages.size()); | |
| 146 | + assertTrue(warnMessages.get(0).contains("发送消息失败")); | |
| 147 | + assertTrue(warnMessages.get(0).contains(String.valueOf(DEVICE_ID))); | |
| 148 | + assertTrue(warnMessages.get(0).contains(RAW_PUSH_TOPIC)); | |
| 149 | + assertTrue(warnMessages.get(0).contains("Connection is closed")); | |
| 150 | + } finally { | |
| 151 | + detachAppender(appender); | |
| 152 | + } | |
| 153 | + } | |
| 154 | + | |
| 155 | + @Test | |
| 156 | + public void testHandleRaw_publishThrows() { | |
| 157 | + // mock 方法:设备在线,但 publish 同步抛出异常 | |
| 158 | + registerConnection(); | |
| 159 | + when(endpoint.publish(anyString(), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false))) | |
| 160 | + .thenThrow(new IllegalStateException("Connection not accepted yet")); | |
| 161 | + | |
| 162 | + // 调用 | |
| 163 | + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); | |
| 164 | + // 断言 | |
| 165 | + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 166 | + } | |
| 167 | + | |
| 168 | + private void registerConnection() { | |
| 169 | + connectionManager.registerConnection(endpoint, new IotMqttConnectionManager.ConnectionInfo() | |
| 170 | + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn").setRemoteAddress("127.0.0.1:1883")); | |
| 171 | + } | |
| 172 | + | |
| 173 | + private static IotDeviceMessage buildMessage() { | |
| 174 | + return new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID) | |
| 175 | + .setMethod(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod()); | |
| 176 | + } | |
| 177 | + | |
| 178 | + private static IotDeviceMessage buildPropertySetMessage() { | |
| 179 | + return new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID) | |
| 180 | + .setMethod(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod()); | |
| 181 | + } | |
| 182 | + | |
| 183 | + private static ListAppender<ILoggingEvent> attachAppender() { | |
| 184 | + ListAppender<ILoggingEvent> appender = new ListAppender<>(); | |
| 185 | + appender.start(); | |
| 186 | + ((Logger) LoggerFactory.getLogger(IotMqttConnectionManager.class)).addAppender(appender); | |
| 187 | + return appender; | |
| 188 | + } | |
| 189 | + | |
| 190 | + private static void detachAppender(ListAppender<ILoggingEvent> appender) { | |
| 191 | + ((Logger) LoggerFactory.getLogger(IotMqttConnectionManager.class)).detachAppender(appender); | |
| 192 | + appender.stop(); | |
| 193 | + } | |
| 194 | + | |
| 195 | + private static List<String> getWarnMessages(ListAppender<ILoggingEvent> appender) { | |
| 196 | + return appender.list.stream().filter(event -> event.getLevel() == Level.WARN) | |
| 197 | + .map(ILoggingEvent::getFormattedMessage).collect(Collectors.toList()); | |
| 198 | + } | |
| 199 | + | |
| 200 | + private static void assertFailed(Future<Void> future, ErrorCode errorCode) { | |
| 201 | + assertTrue(future.failed()); | |
| 202 | + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause()); | |
| 203 | + assertEquals(errorCode.getCode(), exception.getCode()); | |
| 204 | + } | |
| 205 | + | |
| 206 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandlerTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.upstream; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 6 | +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; | |
| 7 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 9 | +import io.vertx.core.Context; | |
| 10 | +import io.vertx.core.Future; | |
| 11 | +import io.vertx.core.Vertx; | |
| 12 | +import io.vertx.mqtt.MqttEndpoint; | |
| 13 | +import org.junit.jupiter.api.AfterEach; | |
| 14 | +import org.junit.jupiter.api.BeforeEach; | |
| 15 | +import org.junit.jupiter.api.Test; | |
| 16 | +import org.mockito.ArgumentCaptor; | |
| 17 | +import org.mockito.Mock; | |
| 18 | + | |
| 19 | +import java.util.Arrays; | |
| 20 | +import java.util.Collections; | |
| 21 | +import java.util.List; | |
| 22 | +import java.util.concurrent.Callable; | |
| 23 | +import java.util.concurrent.CompletableFuture; | |
| 24 | +import java.util.concurrent.CopyOnWriteArrayList; | |
| 25 | +import java.util.concurrent.CountDownLatch; | |
| 26 | +import java.util.concurrent.RejectedExecutionException; | |
| 27 | +import java.util.concurrent.TimeUnit; | |
| 28 | + | |
| 29 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 30 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT; | |
| 31 | +import static org.junit.jupiter.api.Assertions.*; | |
| 32 | +import static org.mockito.AdditionalMatchers.aryEq; | |
| 33 | +import static org.mockito.ArgumentMatchers.any; | |
| 34 | +import static org.mockito.ArgumentMatchers.anyLong; | |
| 35 | +import static org.mockito.ArgumentMatchers.eq; | |
| 36 | +import static org.mockito.Mockito.*; | |
| 37 | + | |
| 38 | +/** | |
| 39 | + * {@link IotMqttUpstreamHandler} 的单元测试 | |
| 40 | + * | |
| 41 | + * @author 芋道源码 | |
| 42 | + */ | |
| 43 | +public class IotMqttUpstreamHandlerTest extends BaseMockitoUnitTest { | |
| 44 | + | |
| 45 | + private static final long TIMEOUT_SECONDS = 10; | |
| 46 | + private static final String PRODUCT_KEY = "pk"; | |
| 47 | + private static final String DEVICE_NAME = "dn"; | |
| 48 | + private static final String SERVER_ID = "mqtt-server"; | |
| 49 | + private static final String RAW_POST_TOPIC = "/sys/pk/dn/thing/raw/post"; | |
| 50 | + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, 0x03}; | |
| 51 | + | |
| 52 | + @Mock | |
| 53 | + private IotDeviceMessageService deviceMessageService; | |
| 54 | + @Mock | |
| 55 | + private MqttEndpoint endpoint; | |
| 56 | + | |
| 57 | + private final IotMqttConnectionManager connectionManager = new IotMqttConnectionManager(); | |
| 58 | + private IotMqttUpstreamHandler upstreamHandler; | |
| 59 | + private Vertx vertx; | |
| 60 | + private Context context; | |
| 61 | + | |
| 62 | + @BeforeEach | |
| 63 | + public void setUp() { | |
| 64 | + upstreamHandler = new IotMqttUpstreamHandler(connectionManager, deviceMessageService, SERVER_ID); | |
| 65 | + lenient().when(endpoint.clientIdentifier()).thenReturn("client-1"); | |
| 66 | + connectionManager.registerConnection(endpoint, new IotMqttConnectionManager.ConnectionInfo() | |
| 67 | + .setDeviceId(1L).setProductKey(PRODUCT_KEY).setDeviceName(DEVICE_NAME).setRemoteAddress("127.0.0.1:1883")); | |
| 68 | + vertx = Vertx.vertx(); | |
| 69 | + context = vertx.getOrCreateContext(); | |
| 70 | + } | |
| 71 | + | |
| 72 | + @AfterEach | |
| 73 | + public void tearDown() throws Exception { | |
| 74 | + vertx.close().toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 75 | + } | |
| 76 | + | |
| 77 | + @Test | |
| 78 | + public void testHandleBusinessRequest_rawPostOrderedAck() throws Exception { | |
| 79 | + // 准备参数 | |
| 80 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context); | |
| 81 | + CountDownLatch entered = new CountDownLatch(1); | |
| 82 | + CountDownLatch release = new CountDownLatch(1); | |
| 83 | + List<Boolean> onWorker = new CopyOnWriteArrayList<>(); | |
| 84 | + // mock 方法:第一条透传上报阻塞,第二条立即完成;记录执行线程 | |
| 85 | + when(deviceMessageService.sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID), | |
| 86 | + eq(IotProtocolTypeEnum.MQTT), anyLong())).thenAnswer(invocation -> { | |
| 87 | + onWorker.add(Context.isOnWorkerThread()); | |
| 88 | + entered.countDown(); | |
| 89 | + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { | |
| 90 | + throw new IllegalStateException("等待释放超时"); | |
| 91 | + } | |
| 92 | + return "M1"; | |
| 93 | + }).thenAnswer(invocation -> { | |
| 94 | + onWorker.add(Context.isOnWorkerThread()); | |
| 95 | + return "M2"; | |
| 96 | + }); | |
| 97 | + | |
| 98 | + // 调用:记录每条消息交给 handler 前后的时刻 | |
| 99 | + long firstBeginNanos = System.nanoTime(); | |
| 100 | + Future<Void> first = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD)); | |
| 101 | + long firstEndNanos = System.nanoTime(); | |
| 102 | + long secondBeginNanos = System.nanoTime(); | |
| 103 | + Future<Void> second = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD)); | |
| 104 | + long secondEndNanos = System.nanoTime(); | |
| 105 | + | |
| 106 | + try { | |
| 107 | + // 断言:处理完成之前不完成(不确认);第二条排在第一条之后 | |
| 108 | + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); | |
| 109 | + assertFalse(first.isComplete()); | |
| 110 | + assertFalse(second.isComplete()); | |
| 111 | + } finally { | |
| 112 | + release.countDown(); | |
| 113 | + } | |
| 114 | + // 断言:释放后按顺序完成;在 worker 上处理 | |
| 115 | + await(first); | |
| 116 | + await(second); | |
| 117 | + assertEquals(Arrays.asList(true, true), onWorker); | |
| 118 | + // 断言:收到的时刻在交给 handler 时(入队之前)取得:第二条在第一条阻塞期间排队,它的时刻仍在入队前后之间 | |
| 119 | + ArgumentCaptor<Long> receivedCaptor = ArgumentCaptor.forClass(Long.class); | |
| 120 | + verify(deviceMessageService, times(2)).sendRawMessage(any(), any(), any(), any(), any(), receivedCaptor.capture()); | |
| 121 | + assertBetween(firstBeginNanos, receivedCaptor.getAllValues().get(0), firstEndNanos); | |
| 122 | + assertBetween(secondBeginNanos, receivedCaptor.getAllValues().get(1), secondEndNanos); | |
| 123 | + verify(deviceMessageService, never()).deserializeDeviceMessage(any(), any(), any()); | |
| 124 | + } | |
| 125 | + | |
| 126 | + @Test | |
| 127 | + public void testHandleBusinessRequest_rawPostFailureStillAck() throws Exception { | |
| 128 | + // 准备参数 | |
| 129 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context); | |
| 130 | + // mock 方法:处理失败(脚本超时、未知异常) | |
| 131 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) | |
| 132 | + .thenThrow(exception(SCRIPT_TIMEOUT)) | |
| 133 | + .thenThrow(new IllegalStateException("boom")); | |
| 134 | + | |
| 135 | + // 调用 | |
| 136 | + Future<Void> first = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD)); | |
| 137 | + Future<Void> second = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD)); | |
| 138 | + | |
| 139 | + // 断言:只记日志,照常完成(照常确认),与原来一致 | |
| 140 | + await(first); | |
| 141 | + await(second); | |
| 142 | + assertTrue(first.succeeded()); | |
| 143 | + assertTrue(second.succeeded()); | |
| 144 | + } | |
| 145 | + | |
| 146 | + @Test | |
| 147 | + public void testHandleBusinessRequest_standard() throws Exception { | |
| 148 | + // 准备参数 | |
| 149 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context); | |
| 150 | + String topic = "/sys/pk/dn/thing/property/post"; | |
| 151 | + byte[] payload = "{\"method\":\"thing.property.post\"}".getBytes(); | |
| 152 | + IotDeviceMessage message = IotDeviceMessage.requestOf("thing.property.post", Collections.singletonMap("t", 1)); | |
| 153 | + // mock 方法 | |
| 154 | + when(deviceMessageService.deserializeDeviceMessage(payload, PRODUCT_KEY, DEVICE_NAME)).thenReturn(message); | |
| 155 | + | |
| 156 | + // 调用 | |
| 157 | + await(callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, topic, payload))); | |
| 158 | + | |
| 159 | + // 断言:反序列化后按标准路径发送,不走透传 | |
| 160 | + verify(deviceMessageService).sendDeviceMessage(same(message), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); | |
| 161 | + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); | |
| 162 | + } | |
| 163 | + | |
| 164 | + @Test | |
| 165 | + public void testHandleBusinessRequest_invalidRequest() throws Exception { | |
| 166 | + // 准备参数 | |
| 167 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context); | |
| 168 | + MqttEndpoint unknownEndpoint = mock(MqttEndpoint.class); | |
| 169 | + | |
| 170 | + // 调用:伪造其它设备的主题、未认证的连接、空载荷 | |
| 171 | + Future<Void> forged = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, | |
| 172 | + "/sys/pk/other/thing/raw/post", PAYLOAD)); | |
| 173 | + Future<Void> unknown = callOnContext(() -> upstreamHandler.handleBusinessRequest(unknownEndpoint, queue, | |
| 174 | + RAW_POST_TOPIC, PAYLOAD)); | |
| 175 | + Future<Void> empty = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, | |
| 176 | + RAW_POST_TOPIC, new byte[0])); | |
| 177 | + | |
| 178 | + // 断言:只记日志,照常完成(照常确认),不处理 | |
| 179 | + await(forged); | |
| 180 | + await(unknown); | |
| 181 | + await(empty); | |
| 182 | + assertTrue(forged.succeeded()); | |
| 183 | + assertTrue(unknown.succeeded()); | |
| 184 | + assertTrue(empty.succeeded()); | |
| 185 | + verifyNoInteractions(deviceMessageService); | |
| 186 | + } | |
| 187 | + | |
| 188 | + @Test | |
| 189 | + public void testHandleBusinessRequest_invalidRequestOrderedAck() throws Exception { | |
| 190 | + // 准备参数 | |
| 191 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context); | |
| 192 | + CountDownLatch entered = new CountDownLatch(1); | |
| 193 | + CountDownLatch release = new CountDownLatch(1); | |
| 194 | + // mock 方法:第一条透传上报阻塞 | |
| 195 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenAnswer(invocation -> { | |
| 196 | + entered.countDown(); | |
| 197 | + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { | |
| 198 | + throw new IllegalStateException("等待释放超时"); | |
| 199 | + } | |
| 200 | + return "M1"; | |
| 201 | + }); | |
| 202 | + | |
| 203 | + // 调用:第二条是空载荷,不处理 | |
| 204 | + Future<Void> first = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD)); | |
| 205 | + Future<Void> empty = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, | |
| 206 | + RAW_POST_TOPIC, new byte[0])); | |
| 207 | + | |
| 208 | + try { | |
| 209 | + // 断言:不处理的消息也排在前一条之后完成,确认(PUBACK)不越过前一条 | |
| 210 | + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); | |
| 211 | + assertFalse(first.isComplete()); | |
| 212 | + assertFalse(empty.isComplete()); | |
| 213 | + } finally { | |
| 214 | + release.countDown(); | |
| 215 | + } | |
| 216 | + await(first); | |
| 217 | + await(empty); | |
| 218 | + assertTrue(empty.succeeded()); | |
| 219 | + verify(deviceMessageService, times(1)).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); | |
| 220 | + } | |
| 221 | + | |
| 222 | + @Test | |
| 223 | + public void testHandleBusinessRequest_queueFull() throws Exception { | |
| 224 | + // 准备参数:队列最多 1 个待处理任务 | |
| 225 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context, true, 1); | |
| 226 | + CountDownLatch release = new CountDownLatch(1); | |
| 227 | + // mock 方法:第一条阻塞 | |
| 228 | + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenAnswer(invocation -> { | |
| 229 | + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { | |
| 230 | + throw new IllegalStateException("等待释放超时"); | |
| 231 | + } | |
| 232 | + return "M1"; | |
| 233 | + }); | |
| 234 | + | |
| 235 | + try { | |
| 236 | + // 调用 | |
| 237 | + Future<Void> first = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD)); | |
| 238 | + Future<Void> second = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD)); | |
| 239 | + | |
| 240 | + // 断言:超过上限立即失败(调用方断开连接,不确认) | |
| 241 | + assertTrue(second.failed()); | |
| 242 | + assertInstanceOf(RejectedExecutionException.class, second.cause()); | |
| 243 | + assertFalse(first.isComplete()); | |
| 244 | + release.countDown(); | |
| 245 | + await(first); | |
| 246 | + assertTrue(first.succeeded()); | |
| 247 | + // 断言:被拒绝的那条不处理 | |
| 248 | + verify(deviceMessageService, times(1)).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); | |
| 249 | + } finally { | |
| 250 | + release.countDown(); | |
| 251 | + } | |
| 252 | + } | |
| 253 | + | |
| 254 | + // ========== 辅助方法 ========== | |
| 255 | + | |
| 256 | + /** | |
| 257 | + * 在连接所在的 context 线程上执行(与 MQTT 的 publishHandler 一致),等待并返回结果 | |
| 258 | + */ | |
| 259 | + private <T> T callOnContext(Callable<T> action) throws Exception { | |
| 260 | + CompletableFuture<T> result = new CompletableFuture<>(); | |
| 261 | + context.runOnContext(v -> { | |
| 262 | + try { | |
| 263 | + result.complete(action.call()); | |
| 264 | + } catch (Throwable e) { | |
| 265 | + result.completeExceptionally(e); | |
| 266 | + } | |
| 267 | + }); | |
| 268 | + return result.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 269 | + } | |
| 270 | + | |
| 271 | + private static void await(Future<?> future) throws Exception { | |
| 272 | + CountDownLatch latch = new CountDownLatch(1); | |
| 273 | + future.onComplete(ar -> latch.countDown()); | |
| 274 | + assertTrue(latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); | |
| 275 | + } | |
| 276 | + | |
| 277 | + private static void assertBetween(long beginNanos, long actualNanos, long endNanos) { | |
| 278 | + assertTrue(actualNanos >= beginNanos && actualNanos <= endNanos, | |
| 279 | + String.format("%d 不在 [%d, %d] 之间", actualNanos, beginNanos, endNanos)); | |
| 280 | + } | |
| 281 | + | |
| 282 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandlerTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.handler.downstream; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 5 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodec; | |
| 9 | +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; | |
| 10 | +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; | |
| 11 | +import io.vertx.core.Future; | |
| 12 | +import io.vertx.core.buffer.Buffer; | |
| 13 | +import io.vertx.core.net.NetSocket; | |
| 14 | +import org.junit.jupiter.api.BeforeEach; | |
| 15 | +import org.junit.jupiter.api.Test; | |
| 16 | +import org.mockito.ArgumentCaptor; | |
| 17 | +import org.mockito.Mock; | |
| 18 | + | |
| 19 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 20 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.RAW_NOT_SUPPORTED; | |
| 21 | +import static org.junit.jupiter.api.Assertions.*; | |
| 22 | +import static org.mockito.ArgumentMatchers.any; | |
| 23 | +import static org.mockito.Mockito.*; | |
| 24 | + | |
| 25 | +/** | |
| 26 | + * {@link IotTcpDownstreamHandler} 的单元测试 | |
| 27 | + * | |
| 28 | + * @author 芋道源码 | |
| 29 | + */ | |
| 30 | +public class IotTcpDownstreamHandlerTest extends BaseMockitoUnitTest { | |
| 31 | + | |
| 32 | + private static final Long DEVICE_ID = 1024L; | |
| 33 | + private static final byte[] BYTES = new byte[]{0x01, 0x02, 0x03}; | |
| 34 | + /** | |
| 35 | + * 帧编码后的数据(模拟 codec 加了帧头) | |
| 36 | + */ | |
| 37 | + private static final byte[] FRAME = new byte[]{0x00, 0x03, 0x01, 0x02, 0x03}; | |
| 38 | + | |
| 39 | + @Mock | |
| 40 | + private IotTcpFrameCodec codec; | |
| 41 | + @Mock | |
| 42 | + private IotMessageSerializer serializer; | |
| 43 | + @Mock | |
| 44 | + private NetSocket socket; | |
| 45 | + @Mock | |
| 46 | + private NetSocket jsonSocket; | |
| 47 | + | |
| 48 | + private IotTcpConnectionManager connectionManager; | |
| 49 | + private IotTcpDownstreamHandler downstreamHandler; | |
| 50 | + | |
| 51 | + @BeforeEach | |
| 52 | + public void setUp() { | |
| 53 | + connectionManager = new IotTcpConnectionManager(10); | |
| 54 | + downstreamHandler = new IotTcpDownstreamHandler(connectionManager, codec, serializer); | |
| 55 | + } | |
| 56 | + | |
| 57 | + @Test | |
| 58 | + public void testHandle_propertySet() { | |
| 59 | + // mock 方法:普通连接,序列化 + 帧编码成功 | |
| 60 | + registerConnection(false); | |
| 61 | + IotDeviceMessage message = buildPropertySetMessage(); | |
| 62 | + when(serializer.serialize(message)).thenReturn(BYTES); | |
| 63 | + when(codec.encode(BYTES)).thenReturn(Buffer.buffer(FRAME)); | |
| 64 | + | |
| 65 | + // 调用 | |
| 66 | + downstreamHandler.handle(message); | |
| 67 | + // 断言:写出 codec 加帧后的数据 | |
| 68 | + ArgumentCaptor<Buffer> dataCaptor = ArgumentCaptor.forClass(Buffer.class); | |
| 69 | + verify(socket).write(dataCaptor.capture()); | |
| 70 | + assertArrayEquals(FRAME, dataCaptor.getValue().getBytes()); | |
| 71 | + } | |
| 72 | + | |
| 73 | + @Test | |
| 74 | + public void testHandle_notPropertySet() { | |
| 75 | + // mock 方法:普通连接 | |
| 76 | + registerConnection(false); | |
| 77 | + | |
| 78 | + // 调用:非属性设置消息(例如服务调用) | |
| 79 | + downstreamHandler.handle(new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID) | |
| 80 | + .setMethod(IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod())); | |
| 81 | + // 断言:忽略,不序列化也不写出 | |
| 82 | + verifyNoInteractions(serializer, codec); | |
| 83 | + verify(socket, never()).write(any(Buffer.class)); | |
| 84 | + } | |
| 85 | + | |
| 86 | + @Test | |
| 87 | + public void testHandle_offline() { | |
| 88 | + // 调用:设备未连接 | |
| 89 | + downstreamHandler.handle(buildPropertySetMessage()); | |
| 90 | + // 断言:不序列化也不写出 | |
| 91 | + verifyNoInteractions(serializer, codec, socket); | |
| 92 | + } | |
| 93 | + | |
| 94 | + @Test | |
| 95 | + public void testHandleRaw_success() { | |
| 96 | + // mock 方法:透传连接,write 成功 | |
| 97 | + registerConnection(true); | |
| 98 | + when(codec.encode(BYTES)).thenReturn(Buffer.buffer(FRAME)); | |
| 99 | + when(socket.write(any(Buffer.class))).thenReturn(Future.succeededFuture()); | |
| 100 | + | |
| 101 | + // 调用 | |
| 102 | + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); | |
| 103 | + // 断言:成功,并写出 codec 加帧后的数据 | |
| 104 | + assertTrue(future.succeeded()); | |
| 105 | + ArgumentCaptor<Buffer> dataCaptor = ArgumentCaptor.forClass(Buffer.class); | |
| 106 | + verify(socket).write(dataCaptor.capture()); | |
| 107 | + assertArrayEquals(FRAME, dataCaptor.getValue().getBytes()); | |
| 108 | + verifyNoInteractions(serializer); | |
| 109 | + } | |
| 110 | + | |
| 111 | + @Test | |
| 112 | + public void testHandleRaw_offline() { | |
| 113 | + // 调用:设备未连接 | |
| 114 | + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); | |
| 115 | + // 断言 | |
| 116 | + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 117 | + verifyNoInteractions(codec); | |
| 118 | + } | |
| 119 | + | |
| 120 | + @Test | |
| 121 | + public void testHandleRaw_notRawConnection() { | |
| 122 | + // mock 方法:普通(非透传)连接 | |
| 123 | + registerConnection(false); | |
| 124 | + | |
| 125 | + // 调用 | |
| 126 | + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); | |
| 127 | + // 断言:105,不写出 | |
| 128 | + assertFailed(future, RAW_NOT_SUPPORTED); | |
| 129 | + verifyNoInteractions(codec); | |
| 130 | + verify(socket, never()).write(any(Buffer.class)); | |
| 131 | + } | |
| 132 | + | |
| 133 | + @Test | |
| 134 | + public void testHandleRaw_writeFailed() { | |
| 135 | + // mock 方法:透传连接,但 write 异步失败(例如写出前对端已关闭) | |
| 136 | + registerConnection(true); | |
| 137 | + when(codec.encode(BYTES)).thenReturn(Buffer.buffer(FRAME)); | |
| 138 | + when(socket.write(any(Buffer.class))).thenReturn(Future.failedFuture(new IllegalStateException("Connection is closed"))); | |
| 139 | + | |
| 140 | + // 调用 | |
| 141 | + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); | |
| 142 | + // 断言:异步失败转成 106,而不是成功 | |
| 143 | + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 144 | + } | |
| 145 | + | |
| 146 | + @Test | |
| 147 | + public void testHandleRaw_encodeFailed() { | |
| 148 | + // mock 方法:透传连接,但帧编码失败(例如超过固定长度) | |
| 149 | + registerConnection(true); | |
| 150 | + when(codec.encode(BYTES)).thenThrow(new IllegalArgumentException("数据长度 3 超过固定长度 2")); | |
| 151 | + | |
| 152 | + // 调用 | |
| 153 | + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); | |
| 154 | + // 断言 | |
| 155 | + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 156 | + verify(socket, never()).write(any(Buffer.class)); | |
| 157 | + } | |
| 158 | + | |
| 159 | + @Test | |
| 160 | + public void testHandleRaw_connectionReplaced() { | |
| 161 | + // mock 方法:透传连接;检查之后、写出之前,设备换成了普通 JSON 连接 | |
| 162 | + registerConnection(true); | |
| 163 | + when(codec.encode(BYTES)).thenAnswer(invocation -> { | |
| 164 | + connectionManager.registerConnection(jsonSocket, DEVICE_ID, new IotTcpConnectionManager.ConnectionInfo() | |
| 165 | + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn").setRawMode(false)); | |
| 166 | + return Buffer.buffer(FRAME); | |
| 167 | + }); | |
| 168 | + | |
| 169 | + // 调用 | |
| 170 | + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); | |
| 171 | + // 断言:写出失败(106),不写到新的 JSON 连接上,也不重试 | |
| 172 | + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 173 | + verify(jsonSocket, never()).write(any(Buffer.class)); | |
| 174 | + verify(socket, never()).write(any(Buffer.class)); | |
| 175 | + assertSame(jsonSocket, connectionManager.getSocketByDeviceId(DEVICE_ID)); | |
| 176 | + } | |
| 177 | + | |
| 178 | + private void registerConnection(boolean rawMode) { | |
| 179 | + connectionManager.registerConnection(socket, DEVICE_ID, new IotTcpConnectionManager.ConnectionInfo() | |
| 180 | + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn").setRawMode(rawMode)); | |
| 181 | + } | |
| 182 | + | |
| 183 | + private static IotDeviceMessage buildMessage() { | |
| 184 | + return new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID) | |
| 185 | + .setMethod(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod()); | |
| 186 | + } | |
| 187 | + | |
| 188 | + private static IotDeviceMessage buildPropertySetMessage() { | |
| 189 | + return new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID) | |
| 190 | + .setMethod(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod()); | |
| 191 | + } | |
| 192 | + | |
| 193 | + private static void assertFailed(Future<Void> future, ErrorCode errorCode) { | |
| 194 | + assertTrue(future.failed()); | |
| 195 | + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause()); | |
| 196 | + assertEquals(errorCode.getCode(), exception.getCode()); | |
| 197 | + } | |
| 198 | + | |
| 199 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandlerTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.upstream; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.ArrayUtil; | |
| 4 | +import cn.hutool.core.util.StrUtil; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | + | |
| 8 | +import static org.junit.jupiter.api.Assertions.*; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * {@link IotUdpUpstreamHandler} 的单元测试 | |
| 12 | + * | |
| 13 | + * @author 芋道源码 | |
| 14 | + */ | |
| 15 | +public class IotUdpUpstreamHandlerTest { | |
| 16 | + | |
| 17 | + private static final String TOKEN = "eyJhbGciOiJIUzI1NiJ9.e30.token"; | |
| 18 | + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, 0x7E, 0x7B}; | |
| 19 | + | |
| 20 | + @Test | |
| 21 | + public void testIsRawPacket() { | |
| 22 | + assertTrue(IotUdpUpstreamHandler.isRawPacket(buildUpstreamPacket(TOKEN, PAYLOAD))); | |
| 23 | + // JSON、二进制序列化、长度不足、空 | |
| 24 | + assertFalse(IotUdpUpstreamHandler.isRawPacket(StrUtil.utf8Bytes("{\"method\":\"auth\"}"))); | |
| 25 | + assertFalse(IotUdpUpstreamHandler.isRawPacket(new byte[]{0x7E, 0x01, 0x02})); | |
| 26 | + assertFalse(IotUdpUpstreamHandler.isRawPacket(new byte[]{0x49})); | |
| 27 | + assertFalse(IotUdpUpstreamHandler.isRawPacket(new byte[0])); | |
| 28 | + assertFalse(IotUdpUpstreamHandler.isRawPacket(null)); | |
| 29 | + } | |
| 30 | + | |
| 31 | + @Test | |
| 32 | + public void testDecodeRawPacket_success() { | |
| 33 | + // 准备参数 | |
| 34 | + byte[] data = buildUpstreamPacket(TOKEN, PAYLOAD); | |
| 35 | + | |
| 36 | + // 调用 | |
| 37 | + IotUdpUpstreamHandler.RawPacket packet = IotUdpUpstreamHandler.decodeRawPacket(data); | |
| 38 | + // 断言 | |
| 39 | + assertEquals(TOKEN, packet.getToken()); | |
| 40 | + assertArrayEquals(PAYLOAD, packet.getPayload()); | |
| 41 | + } | |
| 42 | + | |
| 43 | + @Test | |
| 44 | + public void testDecodeRawPacket_maxPayload() { | |
| 45 | + // 准备参数:payload 正好 4096 字节 | |
| 46 | + byte[] payload = new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES]; | |
| 47 | + payload[payload.length - 1] = 0x55; | |
| 48 | + | |
| 49 | + // 调用 | |
| 50 | + IotUdpUpstreamHandler.RawPacket packet = IotUdpUpstreamHandler.decodeRawPacket(buildUpstreamPacket(TOKEN, payload)); | |
| 51 | + // 断言 | |
| 52 | + assertArrayEquals(payload, packet.getPayload()); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Test | |
| 56 | + public void testDecodeRawPacket_invalid() { | |
| 57 | + byte[] valid = buildUpstreamPacket(TOKEN, PAYLOAD); | |
| 58 | + // 魔术字不正确 | |
| 59 | + assertThrows(IllegalArgumentException.class, | |
| 60 | + () -> IotUdpUpstreamHandler.decodeRawPacket(StrUtil.utf8Bytes("{\"method\":\"auth\"}"))); | |
| 61 | + // 报文头不完整 | |
| 62 | + assertThrows(IllegalArgumentException.class, | |
| 63 | + () -> IotUdpUpstreamHandler.decodeRawPacket(new byte[]{0x49, 0x52, 0x01, 0x00})); | |
| 64 | + // 版本不支持 | |
| 65 | + byte[] wrongVersion = valid.clone(); | |
| 66 | + wrongVersion[2] = 0x02; | |
| 67 | + assertThrows(IllegalArgumentException.class, () -> IotUdpUpstreamHandler.decodeRawPacket(wrongVersion)); | |
| 68 | + // 缺少 token(tokenLen = 0,例如误把下行报文发上来) | |
| 69 | + assertThrows(IllegalArgumentException.class, | |
| 70 | + () -> IotUdpUpstreamHandler.decodeRawPacket(IotUdpUpstreamHandler.encodeRawDownstream(PAYLOAD))); | |
| 71 | + // tokenLen 超过报文长度 | |
| 72 | + byte[] tokenTooLong = valid.clone(); | |
| 73 | + tokenTooLong[3] = (byte) 0xFF; | |
| 74 | + assertThrows(IllegalArgumentException.class, () -> IotUdpUpstreamHandler.decodeRawPacket(tokenTooLong)); | |
| 75 | + // 缺少 payload | |
| 76 | + assertThrows(IllegalArgumentException.class, | |
| 77 | + () -> IotUdpUpstreamHandler.decodeRawPacket(buildUpstreamPacket(TOKEN, new byte[0]))); | |
| 78 | + // payload 超过 4096 字节 | |
| 79 | + assertThrows(IllegalArgumentException.class, () -> IotUdpUpstreamHandler.decodeRawPacket( | |
| 80 | + buildUpstreamPacket(TOKEN, new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES + 1]))); | |
| 81 | + } | |
| 82 | + | |
| 83 | + @Test | |
| 84 | + public void testEncodeRawDownstream() { | |
| 85 | + // 调用 | |
| 86 | + byte[] data = IotUdpUpstreamHandler.encodeRawDownstream(PAYLOAD); | |
| 87 | + // 断言:0x49 0x52 | 0x01 | 0x0000 | payload | |
| 88 | + assertArrayEquals(ArrayUtil.addAll(new byte[]{0x49, 0x52, 0x01, 0x00, 0x00}, PAYLOAD), data); | |
| 89 | + assertTrue(IotUdpUpstreamHandler.isRawPacket(data)); | |
| 90 | + } | |
| 91 | + | |
| 92 | + @Test | |
| 93 | + public void testEncodeDecode_roundTrip() { | |
| 94 | + // 准备参数:设备上行的报文 | |
| 95 | + byte[] upstream = buildUpstreamPacket(TOKEN, PAYLOAD); | |
| 96 | + | |
| 97 | + // 调用:解码上行,再把解出的 payload 编码为下行(例如 ack 回显) | |
| 98 | + IotUdpUpstreamHandler.RawPacket packet = IotUdpUpstreamHandler.decodeRawPacket(upstream); | |
| 99 | + byte[] downstream = IotUdpUpstreamHandler.encodeRawDownstream(packet.getPayload()); | |
| 100 | + // 断言:payload 原样保留,下行报文头 tokenLen = 0 | |
| 101 | + assertEquals(TOKEN, packet.getToken()); | |
| 102 | + assertArrayEquals(ArrayUtil.sub(upstream, upstream.length - PAYLOAD.length, upstream.length), | |
| 103 | + ArrayUtil.sub(downstream, 5, downstream.length)); | |
| 104 | + assertEquals(0, downstream[3]); | |
| 105 | + assertEquals(0, downstream[4]); | |
| 106 | + } | |
| 107 | + | |
| 108 | + /** | |
| 109 | + * 按协议构建上行报文:0x49 0x52 | 0x01 | tokenLen(u16 BE) | token(UTF-8) | payload | |
| 110 | + */ | |
| 111 | + private static byte[] buildUpstreamPacket(String token, byte[] payload) { | |
| 112 | + byte[] tokenBytes = StrUtil.utf8Bytes(token); | |
| 113 | + byte[] header = new byte[]{0x49, 0x52, 0x01, | |
| 114 | + (byte) ((tokenBytes.length >> 8) & 0xFF), (byte) (tokenBytes.length & 0xFF)}; | |
| 115 | + return ArrayUtil.addAll(header, tokenBytes, payload); | |
| 116 | + } | |
| 117 | + | |
| 118 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManagerTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 5 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 6 | +import io.vertx.core.Future; | |
| 7 | +import io.vertx.core.Promise; | |
| 8 | +import io.vertx.core.buffer.Buffer; | |
| 9 | +import io.vertx.core.datagram.DatagramSocket; | |
| 10 | +import org.junit.jupiter.api.BeforeEach; | |
| 11 | +import org.junit.jupiter.api.Test; | |
| 12 | +import org.mockito.ArgumentCaptor; | |
| 13 | +import org.mockito.Mock; | |
| 14 | + | |
| 15 | +import java.net.InetSocketAddress; | |
| 16 | + | |
| 17 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 18 | +import static org.junit.jupiter.api.Assertions.*; | |
| 19 | +import static org.mockito.ArgumentMatchers.any; | |
| 20 | +import static org.mockito.ArgumentMatchers.anyInt; | |
| 21 | +import static org.mockito.ArgumentMatchers.anyString; | |
| 22 | +import static org.mockito.ArgumentMatchers.eq; | |
| 23 | +import static org.mockito.Mockito.*; | |
| 24 | + | |
| 25 | +/** | |
| 26 | + * {@link IotUdpSessionManager} 的单元测试 | |
| 27 | + * | |
| 28 | + * @author 芋道源码 | |
| 29 | + */ | |
| 30 | +public class IotUdpSessionManagerTest extends BaseMockitoUnitTest { | |
| 31 | + | |
| 32 | + private static final Long DEVICE_ID = 1024L; | |
| 33 | + private static final byte[] DATA = new byte[]{0x49, 0x52, 0x01, 0x00, 0x00, 0x01}; | |
| 34 | + private static final InetSocketAddress ADDRESS = new InetSocketAddress("127.0.0.1", 18093); | |
| 35 | + | |
| 36 | + @Mock | |
| 37 | + private DatagramSocket socket; | |
| 38 | + | |
| 39 | + private IotUdpSessionManager sessionManager; | |
| 40 | + | |
| 41 | + @BeforeEach | |
| 42 | + public void setUp() { | |
| 43 | + sessionManager = new IotUdpSessionManager(10, 60000L); | |
| 44 | + } | |
| 45 | + | |
| 46 | + @Test | |
| 47 | + public void testSendToDeviceAsync_success() { | |
| 48 | + // mock 方法:会话存在,send 成功 | |
| 49 | + registerSession(); | |
| 50 | + when(socket.send(any(Buffer.class), anyInt(), anyString())).thenReturn(Future.succeededFuture()); | |
| 51 | + | |
| 52 | + // 调用 | |
| 53 | + Future<Void> future = sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, socket); | |
| 54 | + // 断言:发送到会话地址 | |
| 55 | + assertTrue(future.succeeded()); | |
| 56 | + ArgumentCaptor<Buffer> dataCaptor = ArgumentCaptor.forClass(Buffer.class); | |
| 57 | + verify(socket).send(dataCaptor.capture(), eq(ADDRESS.getPort()), eq(ADDRESS.getHostString())); | |
| 58 | + assertArrayEquals(DATA, dataCaptor.getValue().getBytes()); | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 62 | + public void testSendToDeviceAsync_notWait() { | |
| 63 | + // mock 方法:会话存在,send 一直未完成 | |
| 64 | + registerSession(); | |
| 65 | + Promise<Void> promise = Promise.promise(); | |
| 66 | + when(socket.send(any(Buffer.class), anyInt(), anyString())).thenReturn(promise.future()); | |
| 67 | + | |
| 68 | + // 调用:不等待发送结果,立即返回未完成的 Future(不走 sendToDevice 的 5 秒同步等待) | |
| 69 | + Future<Void> future = sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, socket); | |
| 70 | + assertFalse(future.isComplete()); | |
| 71 | + // 发送失败后,转成 106 | |
| 72 | + promise.fail(new IllegalStateException("Socket closed")); | |
| 73 | + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 74 | + } | |
| 75 | + | |
| 76 | + @Test | |
| 77 | + public void testSendToDeviceAsync_sessionNotExists() { | |
| 78 | + // 调用:会话不存在(或已过期) | |
| 79 | + Future<Void> future = sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, socket); | |
| 80 | + // 断言 | |
| 81 | + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 82 | + verifyNoInteractions(socket); | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Test | |
| 86 | + public void testSendToDeviceAsync_socketNotAvailable() { | |
| 87 | + // mock 方法:会话存在,但 UDP Socket 不可用 | |
| 88 | + registerSession(); | |
| 89 | + | |
| 90 | + // 调用,并断言 | |
| 91 | + assertFailed(sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, null), DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 92 | + } | |
| 93 | + | |
| 94 | + @Test | |
| 95 | + public void testSendToDeviceAsync_sendFailed() { | |
| 96 | + // mock 方法:会话存在,但 send 异步失败 | |
| 97 | + registerSession(); | |
| 98 | + when(socket.send(any(Buffer.class), anyInt(), anyString())) | |
| 99 | + .thenReturn(Future.failedFuture(new IllegalStateException("Socket closed"))); | |
| 100 | + | |
| 101 | + // 调用 | |
| 102 | + Future<Void> future = sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, socket); | |
| 103 | + // 断言:异步失败转成 106,而不是成功 | |
| 104 | + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 105 | + } | |
| 106 | + | |
| 107 | + private void registerSession() { | |
| 108 | + sessionManager.registerSession(DEVICE_ID, new IotUdpSessionManager.SessionInfo() | |
| 109 | + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn").setAddress(ADDRESS)); | |
| 110 | + } | |
| 111 | + | |
| 112 | + private static void assertFailed(Future<Void> future, ErrorCode errorCode) { | |
| 113 | + assertTrue(future.failed()); | |
| 114 | + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause()); | |
| 115 | + assertEquals(errorCode.getCode(), exception.getCode()); | |
| 116 | + } | |
| 117 | + | |
| 118 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManagerTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 5 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 6 | +import io.vertx.core.Future; | |
| 7 | +import io.vertx.core.buffer.Buffer; | |
| 8 | +import io.vertx.core.http.ServerWebSocket; | |
| 9 | +import org.junit.jupiter.api.BeforeEach; | |
| 10 | +import org.junit.jupiter.api.Test; | |
| 11 | +import org.mockito.ArgumentCaptor; | |
| 12 | +import org.mockito.Mock; | |
| 13 | + | |
| 14 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 15 | +import static org.junit.jupiter.api.Assertions.*; | |
| 16 | +import static org.mockito.ArgumentMatchers.any; | |
| 17 | +import static org.mockito.Mockito.*; | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * {@link IotWebSocketConnectionManager} 的单元测试 | |
| 21 | + * | |
| 22 | + * @author 芋道源码 | |
| 23 | + */ | |
| 24 | +public class IotWebSocketConnectionManagerTest extends BaseMockitoUnitTest { | |
| 25 | + | |
| 26 | + private static final Long DEVICE_ID = 1024L; | |
| 27 | + private static final byte[] BYTES = new byte[]{0x01, 0x02, 0x03}; | |
| 28 | + | |
| 29 | + @Mock | |
| 30 | + private ServerWebSocket socket; | |
| 31 | + | |
| 32 | + private IotWebSocketConnectionManager connectionManager; | |
| 33 | + | |
| 34 | + @BeforeEach | |
| 35 | + public void setUp() { | |
| 36 | + connectionManager = new IotWebSocketConnectionManager(); | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Test | |
| 40 | + public void testSendBinaryToDeviceAsync_success() { | |
| 41 | + // mock 方法:设备在线,writeBinaryMessage 成功 | |
| 42 | + registerConnection(); | |
| 43 | + when(socket.writeBinaryMessage(any(Buffer.class))).thenReturn(Future.succeededFuture()); | |
| 44 | + | |
| 45 | + // 调用 | |
| 46 | + Future<Void> future = connectionManager.sendBinaryToDeviceAsync(DEVICE_ID, BYTES); | |
| 47 | + // 断言:以 binary 帧写出原始字节,不写 text 帧 | |
| 48 | + assertTrue(future.succeeded()); | |
| 49 | + ArgumentCaptor<Buffer> dataCaptor = ArgumentCaptor.forClass(Buffer.class); | |
| 50 | + verify(socket).writeBinaryMessage(dataCaptor.capture()); | |
| 51 | + assertArrayEquals(BYTES, dataCaptor.getValue().getBytes()); | |
| 52 | + verify(socket, never()).writeTextMessage(anyString()); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Test | |
| 56 | + public void testSendBinaryToDeviceAsync_offline() { | |
| 57 | + // 调用:设备未连接 | |
| 58 | + Future<Void> future = connectionManager.sendBinaryToDeviceAsync(DEVICE_ID, BYTES); | |
| 59 | + // 断言 | |
| 60 | + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 61 | + verifyNoInteractions(socket); | |
| 62 | + } | |
| 63 | + | |
| 64 | + @Test | |
| 65 | + public void testSendBinaryToDeviceAsync_writeFailed() { | |
| 66 | + // mock 方法:设备在线,但 writeBinaryMessage 异步失败(例如写出前连接已关闭) | |
| 67 | + registerConnection(); | |
| 68 | + when(socket.writeBinaryMessage(any(Buffer.class))) | |
| 69 | + .thenReturn(Future.failedFuture(new IllegalStateException("WebSocket is closed"))); | |
| 70 | + | |
| 71 | + // 调用 | |
| 72 | + Future<Void> future = connectionManager.sendBinaryToDeviceAsync(DEVICE_ID, BYTES); | |
| 73 | + // 断言:异步失败转成 106,而不是成功 | |
| 74 | + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 75 | + } | |
| 76 | + | |
| 77 | + private void registerConnection() { | |
| 78 | + connectionManager.registerConnection(socket, DEVICE_ID, new IotWebSocketConnectionManager.ConnectionInfo() | |
| 79 | + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn")); | |
| 80 | + } | |
| 81 | + | |
| 82 | + private static void assertFailed(Future<Void> future, ErrorCode errorCode) { | |
| 83 | + assertTrue(future.failed()); | |
| 84 | + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause()); | |
| 85 | + assertEquals(errorCode.getCode(), exception.getCode()); | |
| 86 | + } | |
| 87 | + | |
| 88 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImplTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.service.device.message; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.ReflectUtil; | |
| 4 | +import cn.hutool.core.util.StrUtil; | |
| 5 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 6 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 7 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 8 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 9 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; | |
| 10 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 11 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 12 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 13 | +import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; | |
| 14 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptExecutor; | |
| 15 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 16 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 17 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 18 | +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager; | |
| 19 | +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; | |
| 20 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO; | |
| 21 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 22 | +import org.junit.jupiter.api.BeforeEach; | |
| 23 | +import org.junit.jupiter.api.Test; | |
| 24 | +import org.mockito.ArgumentCaptor; | |
| 25 | +import org.mockito.InjectMocks; | |
| 26 | +import org.mockito.Mock; | |
| 27 | + | |
| 28 | +import java.time.Duration; | |
| 29 | +import java.time.LocalDateTime; | |
| 30 | +import java.util.Arrays; | |
| 31 | +import java.util.Base64; | |
| 32 | +import java.util.Collections; | |
| 33 | +import java.util.List; | |
| 34 | +import java.util.concurrent.TimeUnit; | |
| 35 | + | |
| 36 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; | |
| 37 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.SUCCESS; | |
| 38 | +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; | |
| 39 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; | |
| 40 | +import static org.junit.jupiter.api.Assertions.*; | |
| 41 | +import static org.mockito.ArgumentMatchers.any; | |
| 42 | +import static org.mockito.ArgumentMatchers.anyLong; | |
| 43 | +import static org.mockito.ArgumentMatchers.anyString; | |
| 44 | +import static org.mockito.ArgumentMatchers.eq; | |
| 45 | +import static org.mockito.Mockito.*; | |
| 46 | + | |
| 47 | +/** | |
| 48 | + * {@link IotDeviceMessageServiceImpl} 的单元测试 | |
| 49 | + * | |
| 50 | + * @author 芋道源码 | |
| 51 | + */ | |
| 52 | +public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { | |
| 53 | + | |
| 54 | + private static final Long DEVICE_ID = 10L; | |
| 55 | + private static final Long TENANT_ID = 1L; | |
| 56 | + private static final Long PRODUCT_ID = 100L; | |
| 57 | + private static final String PRODUCT_KEY = "pk"; | |
| 58 | + private static final String DEVICE_NAME = "dn"; | |
| 59 | + private static final String SERVER_ID = "server-1"; | |
| 60 | + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, (byte) 0xFF}; | |
| 61 | + private static final String RAW = Base64.getEncoder().encodeToString(PAYLOAD); | |
| 62 | + | |
| 63 | + private static final String SCRIPT = "function decode(ctx) {}"; | |
| 64 | + | |
| 65 | + private static final String DECODE_PROPERTY_POST = "{\"method\":\"thing.property.post\",\"params\":{\"temperature\":25.5}}"; | |
| 66 | + | |
| 67 | + @InjectMocks | |
| 68 | + private IotDeviceMessageServiceImpl deviceMessageService; | |
| 69 | + | |
| 70 | + @Mock | |
| 71 | + private IotDeviceService deviceService; | |
| 72 | + @Mock | |
| 73 | + private IotDeviceMessageProducer deviceMessageProducer; | |
| 74 | + @Mock | |
| 75 | + private IotMessageSerializerManager messageSerializerManager; | |
| 76 | + @Mock | |
| 77 | + private IotScriptExecutor scriptExecutor; | |
| 78 | + | |
| 79 | + private final IotGatewayProperties gatewayProperties = new IotGatewayProperties(); | |
| 80 | + private final IotDeviceRespDTO device = new IotDeviceRespDTO().setId(DEVICE_ID).setTenantId(TENANT_ID) | |
| 81 | + .setProductId(PRODUCT_ID).setProductKey(PRODUCT_KEY).setDeviceName(DEVICE_NAME) | |
| 82 | + .setProtocolType(IotProtocolTypeEnum.MODBUS_TCP_CLIENT.getType()) | |
| 83 | + .setScriptEnabled(true).setScriptContent(SCRIPT); | |
| 84 | + private final IotScriptSource source = IotScriptSource.of(PRODUCT_ID, SCRIPT); | |
| 85 | + | |
| 86 | + @BeforeEach | |
| 87 | + public void setUp() { | |
| 88 | + ReflectUtil.setFieldValue(deviceMessageService, "gatewayProperties", gatewayProperties); | |
| 89 | + ReflectUtil.setFieldValue(deviceMessageService, "scriptExecutor", scriptExecutor); | |
| 90 | + } | |
| 91 | + | |
| 92 | + // ========== sendRawMessage ========== | |
| 93 | + | |
| 94 | + @Test | |
| 95 | + public void testSendRawMessage_decodeSuccess() { | |
| 96 | + // 准备参数:截止 = 收到的时刻 + gateway.script.deadline | |
| 97 | + gatewayProperties.getScript().setEnabled(true); | |
| 98 | + long receivedNanos = System.nanoTime(); | |
| 99 | + long deadlineNanos = receivedNanos + gatewayProperties.getScript().getDeadline().toNanos(); | |
| 100 | + // mock 方法 | |
| 101 | + mockDevice(); | |
| 102 | + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), eq(deadlineNanos))) | |
| 103 | + .thenReturn(IotScriptResult.success(DECODE_PROPERTY_POST, 1)); | |
| 104 | + | |
| 105 | + // 调用 | |
| 106 | + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 107 | + IotProtocolTypeEnum.TCP, receivedNanos); | |
| 108 | + | |
| 109 | + // 断言:只投递 1 条解析出的标准消息,raw 带上原始帧 | |
| 110 | + IotDeviceMessage message = captureSentMessage(); | |
| 111 | + assertEquals(messageId, message.getId()); | |
| 112 | + assertNotNull(message.getId()); | |
| 113 | + assertEquals(message.getId(), message.getRequestId()); // 没有 requestId 时默认 = id | |
| 114 | + assertNotNull(message.getReportTime()); | |
| 115 | + assertEquals("thing.property.post", message.getMethod()); | |
| 116 | + assertEquals(Collections.singletonMap("temperature", 25.5), message.getParams()); | |
| 117 | + assertEquals(RAW, message.getRaw()); | |
| 118 | + assertDeviceIdentity(message); | |
| 119 | + // 断言:decode 的入参 | |
| 120 | + ArgumentCaptor<String> ctxCaptor = ArgumentCaptor.forClass(String.class); | |
| 121 | + verify(scriptExecutor).execute(sameScript(), eq("decode"), ctxCaptor.capture(), eq(deadlineNanos)); | |
| 122 | + JsonNode ctx = JsonUtils.parseTree(ctxCaptor.getValue()); | |
| 123 | + assertEquals("decode", ctx.get("direction").asText()); | |
| 124 | + assertEquals(IotProtocolTypeEnum.TCP.getType(), ctx.get("protocol").asText()); | |
| 125 | + assertEquals(PRODUCT_KEY, ctx.get("productKey").asText()); | |
| 126 | + assertEquals(DEVICE_NAME, ctx.get("deviceName").asText()); | |
| 127 | + assertEquals("0102FF", ctx.get("hex").asText()); | |
| 128 | + assertEquals(255, ctx.get("bytes").get(2).asInt()); | |
| 129 | + assertNull(ctx.get("request")); | |
| 130 | + } | |
| 131 | + | |
| 132 | + @Test | |
| 133 | + public void testSendRawMessage_decodeKeepRequestId() { | |
| 134 | + // 准备参数 | |
| 135 | + gatewayProperties.getScript().setEnabled(true); | |
| 136 | + // mock 方法 | |
| 137 | + mockDevice(); | |
| 138 | + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong())) | |
| 139 | + .thenReturn(IotScriptResult.success("{\"method\":\"thing.event.post\",\"requestId\":\"dev-1\"," | |
| 140 | + + "\"params\":{\"identifier\":\"alarm\",\"value\":{\"level\":2}}}", 1)); | |
| 141 | + | |
| 142 | + // 调用 | |
| 143 | + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 144 | + IotProtocolTypeEnum.MQTT, System.nanoTime()); | |
| 145 | + | |
| 146 | + // 断言:脚本返回的 requestId 保留,id 由服务端生成 | |
| 147 | + IotDeviceMessage message = captureSentMessage(); | |
| 148 | + assertEquals(messageId, message.getId()); | |
| 149 | + assertEquals("dev-1", message.getRequestId()); | |
| 150 | + assertNotEquals("dev-1", message.getId()); | |
| 151 | + assertEquals("thing.event.post", message.getMethod()); | |
| 152 | + assertEquals(RAW, message.getRaw()); | |
| 153 | + assertDeviceIdentity(message); | |
| 154 | + } | |
| 155 | + | |
| 156 | + @Test | |
| 157 | + public void testSendRawMessage_decodeReturnNull() { | |
| 158 | + // 准备参数 | |
| 159 | + gatewayProperties.getScript().setEnabled(true); | |
| 160 | + // mock 方法 | |
| 161 | + mockDevice(); | |
| 162 | + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong())) | |
| 163 | + .thenReturn(IotScriptResult.success(null, 1)); | |
| 164 | + | |
| 165 | + // 调用 | |
| 166 | + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 167 | + IotProtocolTypeEnum.TCP, System.nanoTime()); | |
| 168 | + | |
| 169 | + // 断言:只投递 1 条 thing.raw.post | |
| 170 | + IotDeviceMessage message = captureSentMessage(); | |
| 171 | + assertEquals(messageId, message.getId()); | |
| 172 | + assertRawPost(message); | |
| 173 | + } | |
| 174 | + | |
| 175 | + @Test | |
| 176 | + public void testSendRawMessage_scriptError() { | |
| 177 | + // 准备参数 | |
| 178 | + gatewayProperties.getScript().setEnabled(true); | |
| 179 | + // mock 方法 | |
| 180 | + mockDevice(); | |
| 181 | + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong())) | |
| 182 | + .thenReturn(IotScriptResult.error("boom", 1)); | |
| 183 | + | |
| 184 | + // 调用,并断言异常 | |
| 185 | + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 186 | + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_EXECUTE_FAILED, "boom"); | |
| 187 | + // 断言:解析失败不投递任何消息(只记错误日志) | |
| 188 | + verifyNoInteractions(deviceMessageProducer); | |
| 189 | + } | |
| 190 | + | |
| 191 | + @Test | |
| 192 | + public void testSendRawMessage_scriptTimeout() { | |
| 193 | + // 准备参数 | |
| 194 | + gatewayProperties.getScript().setEnabled(true); | |
| 195 | + // mock 方法 | |
| 196 | + mockDevice(); | |
| 197 | + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong())) | |
| 198 | + .thenReturn(IotScriptResult.timeout("超过执行预算")); | |
| 199 | + | |
| 200 | + // 调用,并断言异常 | |
| 201 | + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 202 | + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_TIMEOUT); | |
| 203 | + // 断言:解析失败不投递任何消息(只记错误日志) | |
| 204 | + verifyNoInteractions(deviceMessageProducer); | |
| 205 | + } | |
| 206 | + | |
| 207 | + @Test | |
| 208 | + public void testSendRawMessage_scriptRejected() { | |
| 209 | + // 准备参数 | |
| 210 | + gatewayProperties.getScript().setEnabled(true); | |
| 211 | + // mock 方法 | |
| 212 | + mockDevice(); | |
| 213 | + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong())) | |
| 214 | + .thenReturn(IotScriptResult.rejected("已熔断")); | |
| 215 | + | |
| 216 | + // 调用,并断言异常 | |
| 217 | + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 218 | + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_REJECTED); | |
| 219 | + // 断言:解析失败不投递任何消息(只记错误日志) | |
| 220 | + verifyNoInteractions(deviceMessageProducer); | |
| 221 | + } | |
| 222 | + | |
| 223 | + @Test | |
| 224 | + public void testSendRawMessage_scriptResultOutputInvalid() { | |
| 225 | + // 准备参数 | |
| 226 | + gatewayProperties.getScript().setEnabled(true); | |
| 227 | + // mock 方法:执行器自己判定输出不合法(例如结果过大) | |
| 228 | + mockDevice(); | |
| 229 | + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong())) | |
| 230 | + .thenReturn(IotScriptResult.of(IotScriptResult.Status.OUTPUT_INVALID, "输出过大", -1, 1)); | |
| 231 | + | |
| 232 | + // 调用,并断言异常 | |
| 233 | + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 234 | + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_OUTPUT_INVALID, "输出过大"); | |
| 235 | + // 断言:解析失败不投递任何消息(只记错误日志) | |
| 236 | + verifyNoInteractions(deviceMessageProducer); | |
| 237 | + } | |
| 238 | + | |
| 239 | + @Test | |
| 240 | + public void testSendRawMessage_outputInvalid() { | |
| 241 | + // 准备参数:脚本试图伪造可信字段(raw、deviceId、id、tenantId、serverId、reportTime)、返回 messages 包装、ack、 | |
| 242 | + // 非对象、不支持的方法、缺少 params | |
| 243 | + gatewayProperties.getScript().setEnabled(true); | |
| 244 | + List<String> outputs = Arrays.asList( | |
| 245 | + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"raw\":\"AQID\"}", | |
| 246 | + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"deviceId\":999}", | |
| 247 | + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"id\":\"forged\"}", | |
| 248 | + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"tenantId\":999}", | |
| 249 | + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"serverId\":\"other\"}", | |
| 250 | + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"reportTime\":1}", | |
| 251 | + "{\"messages\":[{\"method\":\"thing.property.post\",\"params\":{\"t\":1}}]}", | |
| 252 | + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"ack\":[1]}", | |
| 253 | + "[{\"method\":\"thing.property.post\",\"params\":{\"t\":1}}]", | |
| 254 | + "{\"method\":\"thing.raw.post\",\"params\":{\"t\":1}}", | |
| 255 | + "{\"method\":\"thing.property.post\"}"); | |
| 256 | + // mock 方法 | |
| 257 | + mockDevice(); | |
| 258 | + for (String output : outputs) { | |
| 259 | + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong())) | |
| 260 | + .thenReturn(IotScriptResult.success(output, 1)); | |
| 261 | + | |
| 262 | + // 调用 | |
| 263 | + ServiceException exception = assertThrows(ServiceException.class, () -> deviceMessageService.sendRawMessage( | |
| 264 | + PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, IotProtocolTypeEnum.TCP, System.nanoTime()), output); | |
| 265 | + | |
| 266 | + // 断言:抛出 SCRIPT_OUTPUT_INVALID | |
| 267 | + assertEquals(SCRIPT_OUTPUT_INVALID.getCode(), exception.getCode(), output); | |
| 268 | + } | |
| 269 | + // 断言:解析失败不投递任何消息(伪造的可信字段也不会被投递出去) | |
| 270 | + verifyNoInteractions(deviceMessageProducer); | |
| 271 | + } | |
| 272 | + | |
| 273 | + @Test | |
| 274 | + public void testSendRawMessage_switchOff() { | |
| 275 | + // 准备参数:功能开关默认关闭 | |
| 276 | + // mock 方法 | |
| 277 | + mockDevice(); | |
| 278 | + | |
| 279 | + // 调用 | |
| 280 | + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 281 | + IotProtocolTypeEnum.TCP, System.nanoTime()); | |
| 282 | + | |
| 283 | + // 断言:只投递 1 条 thing.raw.post;不执行脚本 | |
| 284 | + IotDeviceMessage message = captureSentMessage(); | |
| 285 | + assertEquals(messageId, message.getId()); | |
| 286 | + assertRawPost(message); | |
| 287 | + verifyNoInteractions(scriptExecutor); | |
| 288 | + } | |
| 289 | + | |
| 290 | + @Test | |
| 291 | + public void testSendRawMessage_scriptDisabled() { | |
| 292 | + // 准备参数 | |
| 293 | + gatewayProperties.getScript().setEnabled(true); | |
| 294 | + device.setScriptEnabled(false); | |
| 295 | + // mock 方法 | |
| 296 | + mockDevice(); | |
| 297 | + | |
| 298 | + // 调用 | |
| 299 | + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 300 | + IotProtocolTypeEnum.TCP, System.nanoTime()); | |
| 301 | + | |
| 302 | + // 断言:只投递 1 条 thing.raw.post;不执行脚本 | |
| 303 | + IotDeviceMessage message = captureSentMessage(); | |
| 304 | + assertEquals(messageId, message.getId()); | |
| 305 | + assertRawPost(message); | |
| 306 | + verifyNoInteractions(scriptExecutor); | |
| 307 | + } | |
| 308 | + | |
| 309 | + @Test | |
| 310 | + public void testSendRawMessage_scriptNotConfigured() { | |
| 311 | + // 准备参数:产品从未配置脚本 | |
| 312 | + gatewayProperties.getScript().setEnabled(true); | |
| 313 | + device.setScriptEnabled(null).setScriptContent(null); | |
| 314 | + // mock 方法 | |
| 315 | + mockDevice(); | |
| 316 | + | |
| 317 | + // 调用 | |
| 318 | + deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 319 | + IotProtocolTypeEnum.TCP, System.nanoTime()); | |
| 320 | + | |
| 321 | + // 断言:与停用一致,只投递 1 条 thing.raw.post;不执行脚本 | |
| 322 | + assertRawPost(captureSentMessage()); | |
| 323 | + verifyNoInteractions(scriptExecutor); | |
| 324 | + } | |
| 325 | + | |
| 326 | + @Test | |
| 327 | + public void testSendRawMessage_scriptContentEmpty() { | |
| 328 | + // 准备参数:已启用,但脚本内容为空(配置不合法) | |
| 329 | + gatewayProperties.getScript().setEnabled(true); | |
| 330 | + // mock 方法 | |
| 331 | + mockDevice(); | |
| 332 | + for (String content : Arrays.asList(null, "", " ")) { | |
| 333 | + device.setScriptContent(content); | |
| 334 | + | |
| 335 | + // 调用,并断言异常:fail-closed,不当作停用 | |
| 336 | + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 337 | + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_EXECUTE_FAILED, "已启用但脚本内容为空"); | |
| 338 | + } | |
| 339 | + // 断言:不投递任何消息(只记错误日志),也不执行脚本 | |
| 340 | + verifyNoInteractions(deviceMessageProducer, scriptExecutor); | |
| 341 | + } | |
| 342 | + | |
| 343 | + @Test | |
| 344 | + public void testSendRawMessage_queuedPastDeadline() { | |
| 345 | + // 准备参数:开启功能,收到的时刻已早于截止时长之前(在 worker 队列里排队期间到期) | |
| 346 | + gatewayProperties.getScript().setEnabled(true); | |
| 347 | + long receivedNanos = System.nanoTime() - gatewayProperties.getScript().getDeadline().toNanos() - 1; | |
| 348 | + // mock 方法 | |
| 349 | + mockDevice(); | |
| 350 | + | |
| 351 | + // 调用,并断言异常 | |
| 352 | + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 353 | + IotProtocolTypeEnum.TCP, receivedNanos), SCRIPT_TIMEOUT); | |
| 354 | + // 断言:不投递任何消息(只记错误日志),也不执行脚本 | |
| 355 | + verifyNoInteractions(deviceMessageProducer, scriptExecutor); | |
| 356 | + } | |
| 357 | + | |
| 358 | + @Test | |
| 359 | + public void testSendRawMessage_scriptFinishedPastDeadline() { | |
| 360 | + // 准备参数:截止 500ms,脚本执行到截止之后才返回 | |
| 361 | + gatewayProperties.getScript().setEnabled(true).setDeadline(Duration.ofMillis(500)); | |
| 362 | + long receivedNanos = System.nanoTime(); | |
| 363 | + long deadlineNanos = receivedNanos + TimeUnit.MILLISECONDS.toNanos(500); | |
| 364 | + // mock 方法 | |
| 365 | + mockDevice(); | |
| 366 | + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), eq(deadlineNanos))).thenAnswer(invocation -> { | |
| 367 | + sleepUntil(deadlineNanos); | |
| 368 | + return IotScriptResult.success(DECODE_PROPERTY_POST, 300); | |
| 369 | + }); | |
| 370 | + | |
| 371 | + // 调用,并断言异常 | |
| 372 | + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 373 | + IotProtocolTypeEnum.TCP, receivedNanos), SCRIPT_TIMEOUT); | |
| 374 | + // 断言:迟到的业务消息不投递,也不投递原始消息(只记错误日志) | |
| 375 | + verifyNoInteractions(deviceMessageProducer); | |
| 376 | + } | |
| 377 | + | |
| 378 | + @Test | |
| 379 | + public void testSendRawMessage_scriptReturnNullPastDeadline() { | |
| 380 | + // 准备参数:截止 500ms,脚本执行到截止之后才返回 null | |
| 381 | + gatewayProperties.getScript().setEnabled(true).setDeadline(Duration.ofMillis(500)); | |
| 382 | + long receivedNanos = System.nanoTime(); | |
| 383 | + long deadlineNanos = receivedNanos + TimeUnit.MILLISECONDS.toNanos(500); | |
| 384 | + // mock 方法 | |
| 385 | + mockDevice(); | |
| 386 | + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), eq(deadlineNanos))).thenAnswer(invocation -> { | |
| 387 | + sleepUntil(deadlineNanos); | |
| 388 | + return IotScriptResult.success(null, 300); | |
| 389 | + }); | |
| 390 | + | |
| 391 | + // 调用,并断言异常 | |
| 392 | + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 393 | + IotProtocolTypeEnum.TCP, receivedNanos), SCRIPT_TIMEOUT); | |
| 394 | + // 断言:迟到的 null 结果不记为 SUCCESS,不投递任何消息(只记错误日志) | |
| 395 | + verifyNoInteractions(deviceMessageProducer); | |
| 396 | + } | |
| 397 | + | |
| 398 | + @Test | |
| 399 | + public void testSendRawMessage_payloadInvalid() { | |
| 400 | + // 调用,并断言:长度 0、4097、null 都直接拒绝,不查设备、不投递 | |
| 401 | + for (byte[] payload : Arrays.asList(new byte[0], new byte[4097], null)) { | |
| 402 | + ServiceException exception = assertThrows(ServiceException.class, () -> deviceMessageService.sendRawMessage( | |
| 403 | + payload, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, IotProtocolTypeEnum.TCP, System.nanoTime())); | |
| 404 | + assertEquals(BAD_REQUEST.getCode(), exception.getCode()); | |
| 405 | + } | |
| 406 | + verifyNoInteractions(deviceService, deviceMessageProducer, scriptExecutor); | |
| 407 | + } | |
| 408 | + | |
| 409 | + @Test | |
| 410 | + public void testSendRawMessage_maxPayload() { | |
| 411 | + // 准备参数:正好 4096 字节 | |
| 412 | + byte[] payload = new byte[4096]; | |
| 413 | + payload[4095] = 0x55; | |
| 414 | + // mock 方法 | |
| 415 | + mockDevice(); | |
| 416 | + | |
| 417 | + // 调用 | |
| 418 | + deviceMessageService.sendRawMessage(payload, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 419 | + IotProtocolTypeEnum.TCP, System.nanoTime()); | |
| 420 | + | |
| 421 | + // 断言:原始帧完整保留 | |
| 422 | + IotDeviceMessage message = captureSentMessage(); | |
| 423 | + assertArrayEquals(payload, Base64.getDecoder().decode(message.getRaw())); | |
| 424 | + } | |
| 425 | + | |
| 426 | + @Test | |
| 427 | + public void testSendRawMessage_deviceNotExists() { | |
| 428 | + // 调用,并断言异常:不投递 | |
| 429 | + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, | |
| 430 | + IotProtocolTypeEnum.TCP, System.nanoTime()), DEVICE_NOT_EXISTS, PRODUCT_KEY, DEVICE_NAME); | |
| 431 | + verifyNoInteractions(deviceMessageProducer, scriptExecutor); | |
| 432 | + } | |
| 433 | + | |
| 434 | + // ========== sendDeviceMessage ========== | |
| 435 | + | |
| 436 | + @Test | |
| 437 | + public void testSendDeviceMessage_clearForgedRaw() { | |
| 438 | + // 准备参数:设备经标准路径上报,试图伪造 raw、编号、时间、身份 | |
| 439 | + LocalDateTime forgedTime = LocalDateTime.of(2000, 1, 1, 0, 0); | |
| 440 | + IotDeviceMessage message = IotDeviceMessage.requestOf("thing.property.post", Collections.singletonMap("t", 1)) | |
| 441 | + .setId("forged-id").setReportTime(forgedTime).setRaw(RAW) | |
| 442 | + .setDeviceId(999L).setTenantId(999L).setServerId("forged-server"); | |
| 443 | + // mock 方法 | |
| 444 | + mockDevice(); | |
| 445 | + | |
| 446 | + // 调用 | |
| 447 | + deviceMessageService.sendDeviceMessage(message, PRODUCT_KEY, DEVICE_NAME, SERVER_ID); | |
| 448 | + | |
| 449 | + // 断言:raw 被清空;编号、时间、身份由服务端设置 | |
| 450 | + IotDeviceMessage sent = captureSentMessage(); | |
| 451 | + assertSame(message, sent); | |
| 452 | + assertNull(sent.getRaw()); | |
| 453 | + assertNotEquals("forged-id", sent.getId()); | |
| 454 | + assertNotEquals(forgedTime, sent.getReportTime()); | |
| 455 | + assertEquals(sent.getId(), sent.getRequestId()); | |
| 456 | + assertDeviceIdentity(sent); | |
| 457 | + } | |
| 458 | + | |
| 459 | + @Test | |
| 460 | + public void testSendDeviceMessage_keepRequestId() { | |
| 461 | + // 准备参数 | |
| 462 | + IotDeviceMessage message = IotDeviceMessage.requestOf("req-1", "thing.property.post", | |
| 463 | + Collections.singletonMap("t", 1)); | |
| 464 | + // mock 方法 | |
| 465 | + mockDevice(); | |
| 466 | + | |
| 467 | + // 调用 | |
| 468 | + deviceMessageService.sendDeviceMessage(message, PRODUCT_KEY, DEVICE_NAME, SERVER_ID); | |
| 469 | + | |
| 470 | + // 断言:设备的 requestId 保留 | |
| 471 | + IotDeviceMessage sent = captureSentMessage(); | |
| 472 | + assertEquals("req-1", sent.getRequestId()); | |
| 473 | + assertNull(sent.getRaw()); | |
| 474 | + } | |
| 475 | + | |
| 476 | + @Test | |
| 477 | + public void testSendDeviceMessage_rawMethodReserved() { | |
| 478 | + // 调用,并断言异常:标准路径不接受 thing.raw.post / thing.raw.push | |
| 479 | + for (IotDeviceMessageMethodEnum method : Arrays.asList(IotDeviceMessageMethodEnum.RAW_POST, | |
| 480 | + IotDeviceMessageMethodEnum.RAW_PUSH)) { | |
| 481 | + IotDeviceMessage message = IotDeviceMessage.requestOf(method.getMethod(), null).setRaw(RAW); | |
| 482 | + assertServiceException(() -> deviceMessageService.sendDeviceMessage(message, PRODUCT_KEY, DEVICE_NAME, | |
| 483 | + SERVER_ID), DEVICE_MESSAGE_RAW_METHOD_RESERVED); | |
| 484 | + } | |
| 485 | + // 断言:不查设备、不投递 | |
| 486 | + verifyNoInteractions(deviceService, deviceMessageProducer); | |
| 487 | + } | |
| 488 | + | |
| 489 | + @Test | |
| 490 | + public void testSendDeviceMessage_deviceNotExists() { | |
| 491 | + // 准备参数 | |
| 492 | + IotDeviceMessage message = IotDeviceMessage.requestOf("thing.property.post", Collections.singletonMap("t", 1)); | |
| 493 | + | |
| 494 | + // 调用,并断言异常 | |
| 495 | + assertServiceException(() -> deviceMessageService.sendDeviceMessage(message, PRODUCT_KEY, DEVICE_NAME, SERVER_ID), | |
| 496 | + DEVICE_NOT_EXISTS, PRODUCT_KEY, DEVICE_NAME); | |
| 497 | + verifyNoInteractions(deviceMessageProducer); | |
| 498 | + } | |
| 499 | + | |
| 500 | + // ========== encodeDownstreamCommand ========== | |
| 501 | + | |
| 502 | + @Test | |
| 503 | + public void testEncodeDownstreamCommand_deviceQueryFailed() { | |
| 504 | + // mock 方法 | |
| 505 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenThrow(new IllegalStateException("rpc 失败")); | |
| 506 | + | |
| 507 | + // 调用 | |
| 508 | + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(), | |
| 509 | + IotProtocolTypeEnum.TCP, System.nanoTime()); | |
| 510 | + | |
| 511 | + // 断言:按设备不存在处理,走标准路径,不丢弃指令 | |
| 512 | + assertTrue(result.isStandard()); | |
| 513 | + verifyNoInteractions(scriptExecutor, deviceMessageProducer); | |
| 514 | + } | |
| 515 | + | |
| 516 | + @Test | |
| 517 | + public void testEncodeDownstreamCommand_deviceNotExists() { | |
| 518 | + // 调用 | |
| 519 | + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(), | |
| 520 | + IotProtocolTypeEnum.TCP, System.nanoTime()); | |
| 521 | + | |
| 522 | + // 断言:与未启用脚本一致,走标准路径 | |
| 523 | + assertTrue(result.isStandard()); | |
| 524 | + verifyNoInteractions(scriptExecutor, deviceMessageProducer); | |
| 525 | + } | |
| 526 | + | |
| 527 | + @Test | |
| 528 | + public void testEncodeDownstreamCommand_scriptDisabledOrNotConfigured() { | |
| 529 | + for (Boolean scriptEnabled : Arrays.asList(false, null)) { | |
| 530 | + // mock 方法 | |
| 531 | + device.setScriptEnabled(scriptEnabled); | |
| 532 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 533 | + | |
| 534 | + // 调用 | |
| 535 | + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(), | |
| 536 | + IotProtocolTypeEnum.TCP, System.nanoTime()); | |
| 537 | + | |
| 538 | + // 断言:走标准路径,不执行脚本 | |
| 539 | + assertTrue(result.isStandard(), String.valueOf(scriptEnabled)); | |
| 540 | + } | |
| 541 | + verifyNoInteractions(scriptExecutor, deviceMessageProducer); | |
| 542 | + } | |
| 543 | + | |
| 544 | + @Test | |
| 545 | + public void testEncodeDownstreamCommand_scriptContentEmpty() { | |
| 546 | + // mock 方法:已启用,但脚本内容为空(配置不合法) | |
| 547 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 548 | + for (String content : Arrays.asList(null, "", " ")) { | |
| 549 | + device.setScriptContent(content); | |
| 550 | + | |
| 551 | + // 调用,并断言:不下发(不写设备,也不改发 JSON) | |
| 552 | + assertNull(deviceMessageService.encodeDownstreamCommand(buildPropertySet(), | |
| 553 | + IotProtocolTypeEnum.TCP, System.nanoTime()), String.valueOf(content)); | |
| 554 | + } | |
| 555 | + verifyNoInteractions(scriptExecutor, deviceMessageProducer); | |
| 556 | + } | |
| 557 | + | |
| 558 | + @Test | |
| 559 | + public void testEncodeDownstreamCommand_encodeBytes() { | |
| 560 | + // 准备参数:截止 = 收到的时刻 + gateway.script.deadline | |
| 561 | + IotDeviceMessage message = buildPropertySet(); | |
| 562 | + long receivedNanos = System.nanoTime(); | |
| 563 | + long deadlineNanos = receivedNanos + gatewayProperties.getScript().getDeadline().toNanos(); | |
| 564 | + // mock 方法 | |
| 565 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 566 | + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), eq(deadlineNanos))) | |
| 567 | + .thenReturn(IotScriptResult.success("{\"bytes\":[1,2,255]}", 1)); | |
| 568 | + | |
| 569 | + // 调用 | |
| 570 | + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(message, | |
| 571 | + IotProtocolTypeEnum.TCP, receivedNanos); | |
| 572 | + | |
| 573 | + // 断言:透传 encode 的字节,带上固定的脚本 | |
| 574 | + assertFalse(result.isStandard()); | |
| 575 | + assertArrayEquals(new byte[]{1, 2, (byte) 0xFF}, result.getBytes()); | |
| 576 | + assertEquals(source.getKey(), result.getSource().getKey()); | |
| 577 | + verifyNoInteractions(deviceMessageProducer); | |
| 578 | + // 断言:encode 的入参 | |
| 579 | + ArgumentCaptor<String> ctxCaptor = ArgumentCaptor.forClass(String.class); | |
| 580 | + verify(scriptExecutor).execute(sameScript(), eq("encode"), ctxCaptor.capture(), eq(deadlineNanos)); | |
| 581 | + JsonNode ctx = JsonUtils.parseTree(ctxCaptor.getValue()); | |
| 582 | + assertEquals("encode", ctx.get("direction").asText()); | |
| 583 | + assertEquals(IotProtocolTypeEnum.TCP.getType(), ctx.get("protocol").asText()); | |
| 584 | + assertEquals("thing.property.set", ctx.get("message").get("method").asText()); | |
| 585 | + assertEquals("req-1", ctx.get("message").get("requestId").asText()); | |
| 586 | + assertEquals(1, ctx.get("message").get("params").get("power").asInt()); | |
| 587 | + } | |
| 588 | + | |
| 589 | + @Test | |
| 590 | + public void testEncodeDownstreamCommand_encodeStandard() { | |
| 591 | + // mock 方法 | |
| 592 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 593 | + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), anyLong())) | |
| 594 | + .thenReturn(IotScriptResult.success("{\"standard\":true}", 1)); | |
| 595 | + | |
| 596 | + // 调用 | |
| 597 | + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(), | |
| 598 | + IotProtocolTypeEnum.TCP, System.nanoTime()); | |
| 599 | + | |
| 600 | + // 断言:走标准路径 | |
| 601 | + assertTrue(result.isStandard()); | |
| 602 | + assertNull(result.getBytes()); | |
| 603 | + } | |
| 604 | + | |
| 605 | + @Test | |
| 606 | + public void testEncodeDownstreamCommand_encodeNotWrite() { | |
| 607 | + // 准备参数:encode 返回 null、执行失败、超时、繁忙、输出不合法(未知键、bytes 为空、超过 255、同时有两种输出) | |
| 608 | + List<IotScriptResult> results = Arrays.asList(IotScriptResult.success(null, 1), | |
| 609 | + IotScriptResult.error("boom", 1), IotScriptResult.timeout("slow"), IotScriptResult.rejected("full"), | |
| 610 | + IotScriptResult.success("{\"other\":1}", 1), IotScriptResult.success("{\"bytes\":[]}", 1), | |
| 611 | + IotScriptResult.success("{\"bytes\":[256]}", 1), | |
| 612 | + IotScriptResult.success("{\"bytes\":[1],\"standard\":true}", 1)); | |
| 613 | + // mock 方法 | |
| 614 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 615 | + for (IotScriptResult result : results) { | |
| 616 | + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), anyLong())).thenReturn(result); | |
| 617 | + | |
| 618 | + // 调用,并断言:不下发(不写设备,也不改走标准路径) | |
| 619 | + assertNull(deviceMessageService.encodeDownstreamCommand(buildPropertySet(), | |
| 620 | + IotProtocolTypeEnum.TCP, System.nanoTime()), String.valueOf(result.getJson())); | |
| 621 | + } | |
| 622 | + // 断言:不给 biz 构造失败回复 | |
| 623 | + verifyNoInteractions(deviceMessageProducer); | |
| 624 | + } | |
| 625 | + | |
| 626 | + @Test | |
| 627 | + public void testEncodeDownstreamCommand_modbusPdu() { | |
| 628 | + // mock 方法 | |
| 629 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 630 | + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), anyLong())) | |
| 631 | + .thenReturn(IotScriptResult.success("{\"bytes\":[1,2]}", 1), | |
| 632 | + IotScriptResult.success("{\"bytes\":[6,0,16,0,1]}", 1)); | |
| 633 | + | |
| 634 | + // 调用,并断言:Modbus 协议,encode 输出不是合法的请求 PDU → 不下发 | |
| 635 | + assertNull(deviceMessageService.encodeDownstreamCommand(buildPropertySet(), | |
| 636 | + IotProtocolTypeEnum.MODBUS_TCP_CLIENT, System.nanoTime())); | |
| 637 | + // 调用,并断言:合法的请求 PDU → 透传 | |
| 638 | + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(), | |
| 639 | + IotProtocolTypeEnum.MODBUS_TCP_CLIENT, System.nanoTime()); | |
| 640 | + assertArrayEquals(new byte[]{6, 0, 16, 0, 1}, result.getBytes()); | |
| 641 | + } | |
| 642 | + | |
| 643 | + @Test | |
| 644 | + public void testEncodeDownstreamCommand_encodeFinishedPastDeadline() { | |
| 645 | + // 准备参数:截止 500ms,encode 执行到截止之后才返回 | |
| 646 | + gatewayProperties.getScript().setDeadline(Duration.ofMillis(500)); | |
| 647 | + long receivedNanos = System.nanoTime(); | |
| 648 | + long deadlineNanos = receivedNanos + TimeUnit.MILLISECONDS.toNanos(500); | |
| 649 | + // mock 方法 | |
| 650 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 651 | + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), eq(deadlineNanos))).thenAnswer(invocation -> { | |
| 652 | + sleepUntil(deadlineNanos); | |
| 653 | + return IotScriptResult.success("{\"bytes\":[1,2]}", 300); | |
| 654 | + }); | |
| 655 | + | |
| 656 | + // 调用 | |
| 657 | + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(), | |
| 658 | + IotProtocolTypeEnum.TCP, receivedNanos); | |
| 659 | + | |
| 660 | + // 断言:迟到的结果不下发(不写设备,也不改走标准路径) | |
| 661 | + assertNull(result); | |
| 662 | + verifyNoInteractions(deviceMessageProducer); | |
| 663 | + } | |
| 664 | + | |
| 665 | + @Test | |
| 666 | + public void testEncodeDownstreamCommand_queuedPastDeadline() { | |
| 667 | + // 准备参数:收到指令的时刻已早于截止时长之前(在设备的指令队列里排队期间到期) | |
| 668 | + long receivedNanos = System.nanoTime() - gatewayProperties.getScript().getDeadline().toNanos() - 1; | |
| 669 | + long deadlineNanos = receivedNanos + gatewayProperties.getScript().getDeadline().toNanos(); | |
| 670 | + // mock 方法:脚本执行器拿到已过的截止时间(真实的执行器不执行脚本,直接返回超时);即使这里返回了字节,也不下发 | |
| 671 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 672 | + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), eq(deadlineNanos))) | |
| 673 | + .thenReturn(IotScriptResult.success("{\"bytes\":[1,2]}", 1)); | |
| 674 | + | |
| 675 | + // 调用 | |
| 676 | + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(), | |
| 677 | + IotProtocolTypeEnum.TCP, receivedNanos); | |
| 678 | + | |
| 679 | + // 断言:不下发(不写设备,也不改走标准路径) | |
| 680 | + assertNull(result); | |
| 681 | + verifyNoInteractions(deviceMessageProducer); | |
| 682 | + } | |
| 683 | + | |
| 684 | + // ========== sendRawReply ========== | |
| 685 | + | |
| 686 | + @Test | |
| 687 | + public void testSendRawReply_rawPushResponse() { | |
| 688 | + // 准备参数:raw.push 的设备正常响应 | |
| 689 | + IotDeviceMessage request = buildRawPush(); | |
| 690 | + byte[] responsePdu = new byte[]{0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; | |
| 691 | + // mock 方法 | |
| 692 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 693 | + | |
| 694 | + // 调用 | |
| 695 | + deviceMessageService.sendRawReply(request, null, responsePdu, null, SERVER_ID); | |
| 696 | + | |
| 697 | + // 断言:回复 code = 0,raw = 响应 PDU;不执行脚本 | |
| 698 | + IotDeviceMessage reply = captureSentMessage(); | |
| 699 | + assertEquals("req-raw", reply.getRequestId()); | |
| 700 | + assertNotEquals("req-raw", reply.getId()); | |
| 701 | + assertEquals("thing.raw.push", reply.getMethod()); | |
| 702 | + assertEquals(SUCCESS.getCode(), reply.getCode()); | |
| 703 | + assertEquals(SUCCESS.getMsg(), reply.getMsg()); | |
| 704 | + assertNull(reply.getParams()); | |
| 705 | + assertNull(reply.getData()); | |
| 706 | + assertEquals(Base64.getEncoder().encodeToString(responsePdu), reply.getRaw()); | |
| 707 | + assertDeviceIdentity(reply); | |
| 708 | + verifyNoInteractions(scriptExecutor); | |
| 709 | + } | |
| 710 | + | |
| 711 | + @Test | |
| 712 | + public void testSendRawReply_rawPushDeviceError() { | |
| 713 | + // 准备参数:raw.push 的设备异常响应(83 02),结果码由协议判定 | |
| 714 | + IotDeviceMessage request = buildRawPush(); | |
| 715 | + byte[] responsePdu = new byte[]{(byte) 0x83, 0x02}; | |
| 716 | + ErrorCode deviceError = new ErrorCode(MODBUS_EXCEPTION.getCode(), StrUtil.format(MODBUS_EXCEPTION.getMsg(), 2)); | |
| 717 | + // mock 方法 | |
| 718 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 719 | + | |
| 720 | + // 调用 | |
| 721 | + deviceMessageService.sendRawReply(request, null, responsePdu, deviceError, SERVER_ID); | |
| 722 | + | |
| 723 | + // 断言:回复协议判定的结果码与文案;raw = 响应 PDU | |
| 724 | + IotDeviceMessage reply = captureSentMessage(); | |
| 725 | + assertEquals("req-raw", reply.getRequestId()); | |
| 726 | + assertEquals(deviceError.getCode(), reply.getCode()); | |
| 727 | + assertEquals(deviceError.getMsg(), reply.getMsg()); | |
| 728 | + assertEquals(Base64.getEncoder().encodeToString(responsePdu), reply.getRaw()); | |
| 729 | + verifyNoInteractions(scriptExecutor); | |
| 730 | + } | |
| 731 | + | |
| 732 | + @Test | |
| 733 | + public void testSendRawReply_encodeResponse() { | |
| 734 | + // 准备参数:encode 指令的设备响应;设备缓存里已是新版本脚本,必须用 encode 时固定的脚本 | |
| 735 | + gatewayProperties.getScript().setEnabled(true); | |
| 736 | + IotDeviceMessage request = buildPropertySet(); | |
| 737 | + byte[] responsePdu = new byte[]{0x06, 0x00, 0x10, 0x00, 0x01}; | |
| 738 | + device.setScriptContent("function decode(ctx) { return null; }"); | |
| 739 | + // mock 方法 | |
| 740 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 741 | + when(scriptExecutor.execute(same(source), eq("decode"), anyString(), anyLong())) | |
| 742 | + .thenReturn(IotScriptResult.success("{\"method\":\"thing.property.set\",\"requestId\":\"req-1\"," | |
| 743 | + + "\"code\":0,\"data\":{\"power\":1}}", 1)); | |
| 744 | + | |
| 745 | + // 调用 | |
| 746 | + long beginNanos = System.nanoTime(); | |
| 747 | + deviceMessageService.sendRawReply(request, source, responsePdu, null, SERVER_ID); | |
| 748 | + | |
| 749 | + // 断言:用固定的脚本 decode,入参带原请求、截止时间 = 现在 + deadline | |
| 750 | + ArgumentCaptor<String> ctxCaptor = ArgumentCaptor.forClass(String.class); | |
| 751 | + ArgumentCaptor<Long> deadlineCaptor = ArgumentCaptor.forClass(Long.class); | |
| 752 | + verify(scriptExecutor).execute(same(source), eq("decode"), ctxCaptor.capture(), deadlineCaptor.capture()); | |
| 753 | + verify(scriptExecutor, never()).execute(argThat(script -> script != source), any(), any(), anyLong()); | |
| 754 | + JsonNode ctx = JsonUtils.parseTree(ctxCaptor.getValue()); | |
| 755 | + assertEquals(IotProtocolTypeEnum.MODBUS_TCP_CLIENT.getType(), ctx.get("protocol").asText()); | |
| 756 | + assertEquals("0600100001", ctx.get("hex").asText()); | |
| 757 | + assertEquals("thing.property.set", ctx.get("request").get("method").asText()); | |
| 758 | + assertEquals("req-1", ctx.get("request").get("requestId").asText()); | |
| 759 | + assertTrue(deadlineCaptor.getValue() - beginNanos | |
| 760 | + >= gatewayProperties.getScript().getDeadline().toNanos()); | |
| 761 | + // 断言:回复带上 raw = 响应 PDU | |
| 762 | + IotDeviceMessage reply = captureSentMessage(); | |
| 763 | + assertEquals("req-1", reply.getRequestId()); | |
| 764 | + assertEquals("thing.property.set", reply.getMethod()); | |
| 765 | + assertEquals(SUCCESS.getCode(), reply.getCode()); | |
| 766 | + assertEquals(Collections.singletonMap("power", 1), reply.getData()); | |
| 767 | + assertEquals(Base64.getEncoder().encodeToString(responsePdu), reply.getRaw()); | |
| 768 | + assertDeviceIdentity(reply); | |
| 769 | + } | |
| 770 | + | |
| 771 | + @Test | |
| 772 | + public void testSendRawReply_scriptDisabledBeforeResponse() { | |
| 773 | + // 准备参数 | |
| 774 | + gatewayProperties.getScript().setEnabled(true); | |
| 775 | + for (Boolean scriptEnabled : Arrays.asList(false, null)) { | |
| 776 | + // mock 方法:等待响应期间,设备缓存里已看到脚本停用(或未配置) | |
| 777 | + device.setScriptEnabled(scriptEnabled); | |
| 778 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 779 | + | |
| 780 | + // 调用 | |
| 781 | + deviceMessageService.sendRawReply(buildPropertySet(), source, new byte[]{0x06, 0x00, 0x10, 0x00, 0x01}, | |
| 782 | + null, SERVER_ID); | |
| 783 | + } | |
| 784 | + | |
| 785 | + // 断言:不解析响应,不回复 | |
| 786 | + verifyNoInteractions(scriptExecutor, deviceMessageProducer); | |
| 787 | + } | |
| 788 | + | |
| 789 | + @Test | |
| 790 | + public void testSendRawReply_switchOffBeforeResponse() { | |
| 791 | + // 准备参数:等待响应期间功能开关被关闭;产品脚本仍是启用状态 | |
| 792 | + gatewayProperties.getScript().setEnabled(false); | |
| 793 | + assertTrue(device.getScriptEnabled()); | |
| 794 | + byte[] responsePdu = new byte[]{0x06, 0x00, 0x10, 0x00, 0x01}; | |
| 795 | + // mock 方法 | |
| 796 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 797 | + | |
| 798 | + // 调用:encode 指令的设备响应(固定的脚本) | |
| 799 | + deviceMessageService.sendRawReply(buildPropertySet(), source, responsePdu, null, SERVER_ID); | |
| 800 | + // 断言:不执行脚本,不回复 | |
| 801 | + verifyNoInteractions(scriptExecutor, deviceMessageProducer); | |
| 802 | + | |
| 803 | + // 调用:raw.push 的设备响应(没有脚本) | |
| 804 | + deviceMessageService.sendRawReply(buildRawPush(), null, responsePdu, null, SERVER_ID); | |
| 805 | + // 断言:照常直接回复设备的实际响应 | |
| 806 | + IotDeviceMessage reply = captureSentMessage(); | |
| 807 | + assertEquals("req-raw", reply.getRequestId()); | |
| 808 | + assertEquals(Base64.getEncoder().encodeToString(responsePdu), reply.getRaw()); | |
| 809 | + verifyNoInteractions(scriptExecutor); | |
| 810 | + } | |
| 811 | + | |
| 812 | + @Test | |
| 813 | + public void testSendRawReply_decodeRequestIdMismatch() { | |
| 814 | + // 准备参数 | |
| 815 | + gatewayProperties.getScript().setEnabled(true); | |
| 816 | + IotDeviceMessage request = buildPropertySet(); | |
| 817 | + // mock 方法:decode 返回的 requestId 与原请求不一致 | |
| 818 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 819 | + when(scriptExecutor.execute(same(source), eq("decode"), anyString(), anyLong())) | |
| 820 | + .thenReturn(IotScriptResult.success("{\"method\":\"thing.property.set\",\"requestId\":\"other\"," | |
| 821 | + + "\"code\":0}", 1)); | |
| 822 | + | |
| 823 | + // 调用 | |
| 824 | + deviceMessageService.sendRawReply(request, source, new byte[]{0x06, 0x00, 0x10, 0x00, 0x01}, null, SERVER_ID); | |
| 825 | + | |
| 826 | + // 断言:不回复 | |
| 827 | + verifyNoInteractions(deviceMessageProducer); | |
| 828 | + } | |
| 829 | + | |
| 830 | + @Test | |
| 831 | + public void testSendRawReply_decodeFailure() { | |
| 832 | + // 准备参数:decode 执行失败、返回 null(指令的响应必须有回执)、返回请求而不是回执 | |
| 833 | + gatewayProperties.getScript().setEnabled(true); | |
| 834 | + List<IotScriptResult> results = Arrays.asList(IotScriptResult.error("boom", 1), IotScriptResult.success(null, 1), | |
| 835 | + IotScriptResult.success("{\"method\":\"thing.property.post\",\"params\":{\"t\":1}}", 1)); | |
| 836 | + // mock 方法 | |
| 837 | + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); | |
| 838 | + for (IotScriptResult result : results) { | |
| 839 | + when(scriptExecutor.execute(same(source), eq("decode"), anyString(), anyLong())).thenReturn(result); | |
| 840 | + | |
| 841 | + // 调用 | |
| 842 | + deviceMessageService.sendRawReply(buildPropertySet(), source, new byte[]{0x06, 0x00, 0x10, 0x00, 0x01}, | |
| 843 | + null, SERVER_ID); | |
| 844 | + } | |
| 845 | + | |
| 846 | + // 断言:只记日志,不回复 | |
| 847 | + verifyNoInteractions(deviceMessageProducer); | |
| 848 | + } | |
| 849 | + | |
| 850 | + @Test | |
| 851 | + public void testSendRawReply_deviceNotExists() { | |
| 852 | + // 调用 | |
| 853 | + deviceMessageService.sendRawReply(buildRawPush(), null, new byte[]{0x03, 0x02, 0x00, 0x01}, null, SERVER_ID); | |
| 854 | + | |
| 855 | + // 断言:不回复 | |
| 856 | + verifyNoInteractions(deviceMessageProducer, scriptExecutor); | |
| 857 | + } | |
| 858 | + | |
| 859 | + // ========== 辅助方法 ========== | |
| 860 | + | |
| 861 | + private void mockDevice() { | |
| 862 | + when(deviceService.getDeviceFromCache(PRODUCT_KEY, DEVICE_NAME)).thenReturn(device); | |
| 863 | + } | |
| 864 | + | |
| 865 | + /** | |
| 866 | + * 匹配按设备信息构造的脚本:服务每次构造新的 {@link IotScriptSource},按 key(productId:hash)匹配 | |
| 867 | + */ | |
| 868 | + private IotScriptSource sameScript() { | |
| 869 | + return argThat(script -> script != null && source.getKey().equals(script.getKey())); | |
| 870 | + } | |
| 871 | + | |
| 872 | + private static IotDeviceMessage buildPropertySet() { | |
| 873 | + return IotDeviceMessage.requestOf("req-1", IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), | |
| 874 | + Collections.singletonMap("power", 1)).setDeviceId(DEVICE_ID); | |
| 875 | + } | |
| 876 | + | |
| 877 | + private static IotDeviceMessage buildRawPush() { | |
| 878 | + return IotDeviceMessage.requestOf("req-raw", IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(), null) | |
| 879 | + .setDeviceId(DEVICE_ID).setRaw(Base64.getEncoder().encodeToString(new byte[]{0x03, 0x00, 0x10, 0x00, 0x02})); | |
| 880 | + } | |
| 881 | + | |
| 882 | + /** | |
| 883 | + * 断言 producer 恰好发送了 1 条消息,并返回它 | |
| 884 | + */ | |
| 885 | + private IotDeviceMessage captureSentMessage() { | |
| 886 | + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class); | |
| 887 | + verify(deviceMessageProducer, times(1)).sendDeviceMessage(captor.capture()); | |
| 888 | + return captor.getValue(); | |
| 889 | + } | |
| 890 | + | |
| 891 | + private static void assertDeviceIdentity(IotDeviceMessage message) { | |
| 892 | + assertEquals(DEVICE_ID, message.getDeviceId()); | |
| 893 | + assertEquals(TENANT_ID, message.getTenantId()); | |
| 894 | + assertEquals(SERVER_ID, message.getServerId()); | |
| 895 | + } | |
| 896 | + | |
| 897 | + /** | |
| 898 | + * 断言是一条 thing.raw.post:params、data 为空,raw 为原始帧,requestId = id | |
| 899 | + */ | |
| 900 | + private static void assertRawPost(IotDeviceMessage message) { | |
| 901 | + assertEquals(IotDeviceMessageMethodEnum.RAW_POST.getMethod(), message.getMethod()); | |
| 902 | + assertNull(message.getParams()); | |
| 903 | + assertNull(message.getCode()); | |
| 904 | + assertEquals(RAW, message.getRaw()); | |
| 905 | + assertNotNull(message.getId()); | |
| 906 | + assertEquals(message.getId(), message.getRequestId()); | |
| 907 | + assertNotNull(message.getReportTime()); | |
| 908 | + assertDeviceIdentity(message); | |
| 909 | + assertNull(message.getData()); | |
| 910 | + } | |
| 911 | + | |
| 912 | + private static void sleepUntil(long deadlineNanos) throws InterruptedException { | |
| 913 | + long remaining; | |
| 914 | + while ((remaining = deadlineNanos - System.nanoTime()) >= 0) { | |
| 915 | + TimeUnit.NANOSECONDS.sleep(remaining + TimeUnit.MILLISECONDS.toNanos(1)); | |
| 916 | + } | |
| 917 | + } | |
| 918 | + | |
| 919 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtilsTest.java
| ... | ... | @@ -4,6 +4,8 @@ import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 4 | 4 | import org.junit.jupiter.api.Test; |
| 5 | 5 | |
| 6 | 6 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 7 | +import static org.junit.jupiter.api.Assertions.assertFalse; | |
| 8 | +import static org.junit.jupiter.api.Assertions.assertTrue; | |
| 7 | 9 | |
| 8 | 10 | /** |
| 9 | 11 | * {@link IotMqttTopicUtils} 的单元测试 |
| ... | ... | @@ -34,4 +36,57 @@ public class IotMqttTopicUtilsTest { |
| 34 | 36 | assertEquals("/sys/pk/dn/thing/service/invoke_reply", topic); |
| 35 | 37 | } |
| 36 | 38 | |
| 39 | + @Test | |
| 40 | + public void testBuildRawPushTopic() { | |
| 41 | + // 调用 | |
| 42 | + String topic = IotMqttTopicUtils.buildRawPushTopic("pk", "dn"); | |
| 43 | + // 断言 | |
| 44 | + assertEquals("/sys/pk/dn/thing/raw/push", topic); | |
| 45 | + } | |
| 46 | + | |
| 47 | + @Test | |
| 48 | + public void testIsRawPostTopic() { | |
| 49 | + // 正确格式 | |
| 50 | + assertTrue(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/raw/post")); | |
| 51 | + // 其它主题 | |
| 52 | + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/raw/push")); | |
| 53 | + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/property/post")); | |
| 54 | + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/raw/post_reply")); | |
| 55 | + // 格式不正确:缺少 / 多出段、productKey 或 deviceName 为空、前缀不对 | |
| 56 | + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/thing/raw/post")); | |
| 57 | + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/x/thing/raw/post")); | |
| 58 | + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys//dn/thing/raw/post")); | |
| 59 | + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk//thing/raw/post")); | |
| 60 | + assertFalse(IotMqttTopicUtils.isRawPostTopic("/abc/pk/dn/thing/raw/post")); | |
| 61 | + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/raw/post/")); | |
| 62 | + assertFalse(IotMqttTopicUtils.isRawPostTopic("")); | |
| 63 | + assertFalse(IotMqttTopicUtils.isRawPostTopic(null)); | |
| 64 | + } | |
| 65 | + | |
| 66 | + @Test | |
| 67 | + public void testIsRawPushTopic() { | |
| 68 | + assertTrue(IotMqttTopicUtils.isRawPushTopic("/sys/pk/dn/thing/raw/push")); | |
| 69 | + assertFalse(IotMqttTopicUtils.isRawPushTopic("/sys/pk/dn/thing/raw/post")); | |
| 70 | + assertFalse(IotMqttTopicUtils.isRawPushTopic("/sys/pk/thing/raw/push")); | |
| 71 | + assertFalse(IotMqttTopicUtils.isRawPushTopic(null)); | |
| 72 | + } | |
| 73 | + | |
| 74 | + @Test | |
| 75 | + public void testIsTopicPublishAllowed_rawPost() { | |
| 76 | + // 自己的 raw/post:允许 | |
| 77 | + assertTrue(IotMqttTopicUtils.isTopicPublishAllowed("/sys/pk/dn/thing/raw/post", "pk", "dn")); | |
| 78 | + // 别的设备的 raw/post:拒绝 | |
| 79 | + assertFalse(IotMqttTopicUtils.isTopicPublishAllowed("/sys/pk/other/thing/raw/post", "pk", "dn")); | |
| 80 | + assertFalse(IotMqttTopicUtils.isTopicPublishAllowed("/sys/other/dn/thing/raw/post", "pk", "dn")); | |
| 81 | + } | |
| 82 | + | |
| 83 | + @Test | |
| 84 | + public void testIsTopicSubscribeAllowed_rawPush() { | |
| 85 | + // 自己的 raw/push:允许 | |
| 86 | + assertTrue(IotMqttTopicUtils.isTopicSubscribeAllowed("/sys/pk/dn/thing/raw/push", "pk", "dn")); | |
| 87 | + // 别的设备的 raw/push:拒绝 | |
| 88 | + assertFalse(IotMqttTopicUtils.isTopicSubscribeAllowed("/sys/pk/other/thing/raw/push", "pk", "dn")); | |
| 89 | + assertFalse(IotMqttTopicUtils.isTopicSubscribeAllowed("/sys/+/+/thing/raw/push", "pk", "dn")); | |
| 90 | + } | |
| 91 | + | |
| 37 | 92 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/util/IotWorkerTaskQueueTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.util; | |
| 2 | + | |
| 3 | +import io.vertx.core.Context; | |
| 4 | +import io.vertx.core.Future; | |
| 5 | +import io.vertx.core.Vertx; | |
| 6 | +import org.junit.jupiter.api.AfterEach; | |
| 7 | +import org.junit.jupiter.api.BeforeEach; | |
| 8 | +import org.junit.jupiter.api.Test; | |
| 9 | + | |
| 10 | +import java.util.ArrayList; | |
| 11 | +import java.util.Arrays; | |
| 12 | +import java.util.List; | |
| 13 | +import java.util.concurrent.Callable; | |
| 14 | +import java.util.concurrent.CompletableFuture; | |
| 15 | +import java.util.concurrent.CopyOnWriteArrayList; | |
| 16 | +import java.util.concurrent.CountDownLatch; | |
| 17 | +import java.util.concurrent.RejectedExecutionException; | |
| 18 | +import java.util.concurrent.TimeUnit; | |
| 19 | +import java.util.concurrent.atomic.AtomicBoolean; | |
| 20 | +import java.util.concurrent.atomic.AtomicInteger; | |
| 21 | + | |
| 22 | +import static org.junit.jupiter.api.Assertions.*; | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * {@link IotWorkerTaskQueue} 的单元测试 | |
| 26 | + * | |
| 27 | + * @author 芋道源码 | |
| 28 | + */ | |
| 29 | +public class IotWorkerTaskQueueTest { | |
| 30 | + | |
| 31 | + private static final long TIMEOUT_SECONDS = 10; | |
| 32 | + | |
| 33 | + private Vertx vertx; | |
| 34 | + private Context context; | |
| 35 | + /** | |
| 36 | + * context 所在的 event loop 线程 | |
| 37 | + */ | |
| 38 | + private Thread contextThread; | |
| 39 | + | |
| 40 | + @BeforeEach | |
| 41 | + public void setUp() throws Exception { | |
| 42 | + vertx = Vertx.vertx(); | |
| 43 | + context = vertx.getOrCreateContext(); | |
| 44 | + contextThread = callOnContext(Thread::currentThread); | |
| 45 | + } | |
| 46 | + | |
| 47 | + @AfterEach | |
| 48 | + public void tearDown() throws Exception { | |
| 49 | + vertx.close().toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 50 | + } | |
| 51 | + | |
| 52 | + @Test | |
| 53 | + public void testSubmit_orderedSequential() throws Exception { | |
| 54 | + // 准备参数:越早提交的任务执行越久;第 3 个任务失败 | |
| 55 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context); | |
| 56 | + int count = 5; | |
| 57 | + List<Integer> started = new CopyOnWriteArrayList<>(); | |
| 58 | + List<Integer> completed = new CopyOnWriteArrayList<>(); | |
| 59 | + AtomicInteger running = new AtomicInteger(); | |
| 60 | + AtomicInteger maxRunning = new AtomicInteger(); | |
| 61 | + AtomicBoolean completedOnContext = new AtomicBoolean(true); | |
| 62 | + CountDownLatch done = new CountDownLatch(count); | |
| 63 | + | |
| 64 | + // 调用:在 context 上依次提交 | |
| 65 | + List<Future<Integer>> futures = callOnContext(() -> { | |
| 66 | + List<Future<Integer>> list = new ArrayList<>(); | |
| 67 | + for (int i = 0; i < count; i++) { | |
| 68 | + int index = i; | |
| 69 | + Future<Integer> future = queue.submit(() -> { | |
| 70 | + maxRunning.accumulateAndGet(running.incrementAndGet(), Math::max); | |
| 71 | + started.add(index); | |
| 72 | + try { | |
| 73 | + Thread.sleep((count - index) * 30L); | |
| 74 | + if (index == 2) { | |
| 75 | + throw new IllegalStateException("task-2 failed"); | |
| 76 | + } | |
| 77 | + return index; | |
| 78 | + } finally { | |
| 79 | + running.decrementAndGet(); | |
| 80 | + } | |
| 81 | + }); | |
| 82 | + future.onComplete(ar -> { | |
| 83 | + if (Thread.currentThread() != contextThread) { | |
| 84 | + completedOnContext.set(false); | |
| 85 | + } | |
| 86 | + completed.add(index); | |
| 87 | + done.countDown(); | |
| 88 | + }); | |
| 89 | + list.add(future); | |
| 90 | + } | |
| 91 | + return list; | |
| 92 | + }); | |
| 93 | + assertTrue(done.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); | |
| 94 | + | |
| 95 | + // 断言:按提交顺序逐个执行(同一时刻只有一个在执行)、逐个完成,结果回到 context 线程 | |
| 96 | + assertEquals(Arrays.asList(0, 1, 2, 3, 4), started); | |
| 97 | + assertEquals(Arrays.asList(0, 1, 2, 3, 4), completed); | |
| 98 | + assertEquals(1, maxRunning.get()); | |
| 99 | + assertTrue(completedOnContext.get()); | |
| 100 | + // 断言:第 3 个任务失败,之后的任务照常执行 | |
| 101 | + for (int i = 0; i < count; i++) { | |
| 102 | + Future<Integer> future = futures.get(i); | |
| 103 | + if (i == 2) { | |
| 104 | + assertTrue(future.failed()); | |
| 105 | + assertInstanceOf(IllegalStateException.class, future.cause()); | |
| 106 | + assertEquals("task-2 failed", future.cause().getMessage()); | |
| 107 | + } else { | |
| 108 | + assertTrue(future.succeeded()); | |
| 109 | + assertEquals(Integer.valueOf(i), future.result()); | |
| 110 | + } | |
| 111 | + } | |
| 112 | + } | |
| 113 | + | |
| 114 | + @Test | |
| 115 | + public void testSubmit_queuesParallel() throws Exception { | |
| 116 | + // 准备参数:同一个 context 上的两个队列(两个连接) | |
| 117 | + IotWorkerTaskQueue queueA = new IotWorkerTaskQueue(context); | |
| 118 | + IotWorkerTaskQueue queueB = new IotWorkerTaskQueue(context); | |
| 119 | + CountDownLatch releaseA = new CountDownLatch(1); | |
| 120 | + CountDownLatch startedA = new CountDownLatch(1); | |
| 121 | + | |
| 122 | + // 调用:A 的第一个任务阻塞,A 的第二个任务排在它之后 | |
| 123 | + List<Future<String>> futuresA = callOnContext(() -> Arrays.asList( | |
| 124 | + queueA.submit(() -> { | |
| 125 | + startedA.countDown(); | |
| 126 | + return block(releaseA, "A1"); | |
| 127 | + }), | |
| 128 | + queueA.submit(() -> "A2"))); | |
| 129 | + assertTrue(startedA.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); | |
| 130 | + // 调用:B 提交一个任务 | |
| 131 | + Future<String> futureB = callOnContext(() -> queueB.submit(() -> "B")); | |
| 132 | + | |
| 133 | + try { | |
| 134 | + // 断言:A 阻塞时 B 照常完成;A 的第二个任务仍在等待第一个 | |
| 135 | + assertEquals("B", await(futureB)); | |
| 136 | + assertFalse(futuresA.get(0).isComplete()); | |
| 137 | + assertFalse(futuresA.get(1).isComplete()); | |
| 138 | + } finally { | |
| 139 | + releaseA.countDown(); | |
| 140 | + } | |
| 141 | + // 断言:释放后 A 按顺序完成 | |
| 142 | + assertEquals("A1", await(futuresA.get(0))); | |
| 143 | + assertEquals("A2", await(futuresA.get(1))); | |
| 144 | + } | |
| 145 | + | |
| 146 | + @Test | |
| 147 | + public void testSubmit_maxPending() throws Exception { | |
| 148 | + // 准备参数:最多 2 个待处理任务 | |
| 149 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context, true, 2); | |
| 150 | + CountDownLatch release = new CountDownLatch(1); | |
| 151 | + AtomicBoolean rejectedImmediately = new AtomicBoolean(); | |
| 152 | + | |
| 153 | + // 调用:前两个任务阻塞,第三个超过上限 | |
| 154 | + List<Future<String>> futures = callOnContext(() -> { | |
| 155 | + List<Future<String>> list = Arrays.asList( | |
| 156 | + queue.submit(() -> block(release, "1")), | |
| 157 | + queue.submit(() -> block(release, "2")), | |
| 158 | + queue.submit(() -> "3")); | |
| 159 | + rejectedImmediately.set(list.get(2).failed()); | |
| 160 | + return list; | |
| 161 | + }); | |
| 162 | + | |
| 163 | + try { | |
| 164 | + // 断言:第三个立即失败为 RejectedExecutionException | |
| 165 | + assertTrue(rejectedImmediately.get()); | |
| 166 | + assertInstanceOf(RejectedExecutionException.class, futures.get(2).cause()); | |
| 167 | + assertFalse(futures.get(0).isComplete()); | |
| 168 | + assertFalse(futures.get(1).isComplete()); | |
| 169 | + } finally { | |
| 170 | + release.countDown(); | |
| 171 | + } | |
| 172 | + // 断言:前两个完成后名额释放,可以再次提交 | |
| 173 | + assertEquals("1", await(futures.get(0))); | |
| 174 | + assertEquals("2", await(futures.get(1))); | |
| 175 | + assertEquals("4", await(callOnContext(() -> queue.submit(() -> "4")))); | |
| 176 | + } | |
| 177 | + | |
| 178 | + @Test | |
| 179 | + public void testSubmitLast() throws Exception { | |
| 180 | + // 准备参数:最多 1 个待处理任务 | |
| 181 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context, true, 1); | |
| 182 | + CountDownLatch release = new CountDownLatch(1); | |
| 183 | + List<String> executed = new CopyOnWriteArrayList<>(); | |
| 184 | + AtomicBoolean lastRejectedImmediately = new AtomicBoolean(true); | |
| 185 | + | |
| 186 | + // 调用:第一个任务阻塞;队列已满时 submitLast 不受上限;之后 submit / submitLast 都失败 | |
| 187 | + List<Future<String>> futures = callOnContext(() -> { | |
| 188 | + List<Future<String>> list = Arrays.asList( | |
| 189 | + queue.submit(() -> { | |
| 190 | + block(release, "1"); | |
| 191 | + executed.add("1"); | |
| 192 | + return "1"; | |
| 193 | + }), | |
| 194 | + queue.submit(() -> "full"), | |
| 195 | + queue.submitLast(() -> { | |
| 196 | + executed.add("last"); | |
| 197 | + return "last"; | |
| 198 | + }), | |
| 199 | + queue.submit(() -> "closed"), | |
| 200 | + queue.submitLast(() -> "last-again")); | |
| 201 | + lastRejectedImmediately.set(list.get(2).failed()); | |
| 202 | + return list; | |
| 203 | + }); | |
| 204 | + | |
| 205 | + try { | |
| 206 | + // 断言:已满的 submit 被拒绝;submitLast 被接受,排在阻塞任务之后 | |
| 207 | + assertRejected(futures.get(1)); | |
| 208 | + assertFalse(lastRejectedImmediately.get()); | |
| 209 | + assertFalse(futures.get(2).isComplete()); | |
| 210 | + // 断言:关闭之后 submit、submitLast 都被拒绝 | |
| 211 | + assertRejected(futures.get(3)); | |
| 212 | + assertRejected(futures.get(4)); | |
| 213 | + } finally { | |
| 214 | + release.countDown(); | |
| 215 | + } | |
| 216 | + // 断言:最后一个任务在之前的任务之后执行 | |
| 217 | + assertEquals("1", await(futures.get(0))); | |
| 218 | + assertEquals("last", await(futures.get(2))); | |
| 219 | + assertEquals(Arrays.asList("1", "last"), executed); | |
| 220 | + // 断言:已关闭,之后的任务都被拒绝 | |
| 221 | + assertRejected(callOnContext(() -> queue.submit(() -> "after"))); | |
| 222 | + } | |
| 223 | + | |
| 224 | + @Test | |
| 225 | + public void testSubmit_unordered() throws Exception { | |
| 226 | + // 准备参数:无序,最多 2 个待处理任务 | |
| 227 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context, false, 2); | |
| 228 | + CountDownLatch release = new CountDownLatch(1); | |
| 229 | + | |
| 230 | + try { | |
| 231 | + // 调用:第一个任务阻塞 | |
| 232 | + Future<String> blocked = callOnContext(() -> queue.submit(() -> block(release, "A"))); | |
| 233 | + // 断言:之后的任务不排在它之后,直接完成 | |
| 234 | + assertEquals("B", await(callOnContext(() -> queue.submit(() -> "B")))); | |
| 235 | + assertFalse(blocked.isComplete()); | |
| 236 | + | |
| 237 | + // 调用:A 仍在执行,再提交 C 后达到上限,D 被拒绝 | |
| 238 | + List<Future<String>> futures = callOnContext(() -> Arrays.asList( | |
| 239 | + queue.submit(() -> block(release, "C")), | |
| 240 | + queue.submit(() -> "D"))); | |
| 241 | + // 断言:只限制数量 | |
| 242 | + assertRejected(futures.get(1)); | |
| 243 | + assertFalse(futures.get(0).isComplete()); | |
| 244 | + | |
| 245 | + // 调用:释放 | |
| 246 | + release.countDown(); | |
| 247 | + // 断言 | |
| 248 | + assertEquals("A", await(blocked)); | |
| 249 | + assertEquals("C", await(futures.get(0))); | |
| 250 | + } finally { | |
| 251 | + release.countDown(); | |
| 252 | + } | |
| 253 | + } | |
| 254 | + | |
| 255 | + // ========== 辅助方法 ========== | |
| 256 | + | |
| 257 | + /** | |
| 258 | + * 在 context 线程上执行(与 Vert.x 处理器一致),等待并返回结果 | |
| 259 | + */ | |
| 260 | + private <T> T callOnContext(Callable<T> action) throws Exception { | |
| 261 | + CompletableFuture<T> result = new CompletableFuture<>(); | |
| 262 | + context.runOnContext(v -> { | |
| 263 | + try { | |
| 264 | + result.complete(action.call()); | |
| 265 | + } catch (Throwable e) { | |
| 266 | + result.completeExceptionally(e); | |
| 267 | + } | |
| 268 | + }); | |
| 269 | + return result.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 270 | + } | |
| 271 | + | |
| 272 | + private static <T> T await(Future<T> future) throws Exception { | |
| 273 | + return future.toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 274 | + } | |
| 275 | + | |
| 276 | + /** | |
| 277 | + * worker 线程上阻塞,直到释放 | |
| 278 | + */ | |
| 279 | + private static String block(CountDownLatch release, String value) throws InterruptedException { | |
| 280 | + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { | |
| 281 | + throw new IllegalStateException("等待释放超时"); | |
| 282 | + } | |
| 283 | + return value; | |
| 284 | + } | |
| 285 | + | |
| 286 | + private static void assertRejected(Future<?> future) { | |
| 287 | + assertTrue(future.failed()); | |
| 288 | + assertInstanceOf(RejectedExecutionException.class, future.cause()); | |
| 289 | + } | |
| 290 | + | |
| 291 | +} | ... | ... |