【代码评审】IoT:产品脚本的逻辑

This commit is contained in:
YunaiV 2025-03-25 20:42:21 +08:00
parent 0ae893272b
commit eeb1dc4a07
20 changed files with 111 additions and 75 deletions

View File

@ -43,4 +43,5 @@ public enum IotProductScriptLanguageEnum implements ArrayValuable<String> {
.findFirst()
.orElse(null);
}
}

View File

@ -6,6 +6,7 @@ import lombok.Getter;
import java.util.Arrays;
// TODO @haohao要不复用 commonstatus
/**
* IoT 产品脚本状态枚举
*

View File

@ -8,6 +8,7 @@ import lombok.*;
import java.time.LocalDateTime;
// TODO @haohao类似阿里云的脚本貌似是一个这个可以简化么微信讨论哈类似阿里云貌似是加了个 topic
/**
* IoT 产品脚本信息 DO
*

View File

@ -26,6 +26,7 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU
import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.PRODUCT_NOT_EXISTS;
import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.PRODUCT_SCRIPT_NOT_EXISTS;
// TODO @芋艿后续再 review
/**
* IoT 产品脚本信息 Service 实现类
*

View File

@ -5,17 +5,15 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// TODO @芋艿是不是搞成 cn.iocoder.yudao.module.iot.plugin或者 commonscript 要自动配置
/**
* 独立运行入口
*/
@Slf4j
@SpringBootApplication(scanBasePackages = {
// common 的包
"cn.iocoder.yudao.module.iot.plugin.common",
// http 的包
"cn.iocoder.yudao.module.iot.plugin.http",
// script 的包
"cn.iocoder.yudao.module.iot.plugin.script"
"cn.iocoder.yudao.module.iot.plugin.common", // common 的包
"cn.iocoder.yudao.module.iot.plugin.http", // http 的包
"cn.iocoder.yudao.module.iot.plugin.script" // script 的包
})
public class IotHttpPluginApplication {

View File

@ -13,7 +13,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* HTTP协议脚本处理服务
* HTTP 协议脚本处理服务
* 用于管理和执行设备数据解析脚本
*
* @author haohao
@ -25,8 +25,10 @@ public class HttpScriptService {
private final ScriptService scriptService;
// TODO @haohao后续可以考虑放到 guava 缓存
// TODO @haohao可能要抽一个 script factory 之类的方便多个 emqxhttp 之类复用
/**
* 脚本缓存按产品Key缓存脚本内容
* 脚本缓存按产品 Key 缓存脚本内容
*/
private final Map<String, String> scriptCache = new ConcurrentHashMap<>();
@ -76,6 +78,7 @@ public class HttpScriptService {
productKey, deviceName, e);
}
// TODO @芋艿解析失败是不是不能返回空
// 解析失败返回空数据
return new HashMap<>();
}
@ -115,13 +118,14 @@ public class HttpScriptService {
productKey, deviceName, identifier, payload, result);
// 处理结果
// TODO @haohao处理结果可以复用么
if (result instanceof Map) {
return (Map<String, Object>) result;
} else if (result instanceof String) {
try {
return new JsonObject((String) result).getMap();
} catch (Exception e) {
log.warn("[parseEventData][脚本返回的字符串不是有效的JSON] result:{}", result);
log.warn("[parseEventData][脚本返回的字符串不是有效的 JSON] result:{}", result);
}
}
} catch (Exception e) {
@ -129,6 +133,7 @@ public class HttpScriptService {
productKey, deviceName, identifier, e);
}
// TODO @芋艿解析失败是不是不能返回空
// 解析失败返回空数据
return new HashMap<>();
}
@ -191,10 +196,11 @@ public class HttpScriptService {
/**
* 设置产品解析脚本
*
* @param productKey 产品Key
* @param productKey 产品 Key
* @param script 脚本内容
*/
public void setScript(String productKey, String script) {
// TODO @haohaoif return 会好点哈
if (StrUtil.isNotBlank(productKey) && StrUtil.isNotBlank(script)) {
// 验证脚本是否有效
if (scriptService.validateScript("js", script)) {
@ -209,13 +215,14 @@ public class HttpScriptService {
/**
* 清除产品解析脚本
*
* @param productKey 产品Key
* @param productKey 产品 Key
*/
public void clearScript(String productKey) {
if (StrUtil.isNotBlank(productKey)) {
scriptCache.remove(productKey);
log.info("[clearScript][清除产品:{}的解析脚本]", productKey);
if (StrUtil.isBlank(productKey)) {
return;
}
scriptCache.remove(productKey);
log.info("[clearScript][清除产品({})的解析脚本]", productKey);
}
/**
@ -225,4 +232,5 @@ public class HttpScriptService {
scriptCache.clear();
log.info("[clearAllScripts][清除所有脚本缓存]");
}
}

View File

@ -35,8 +35,7 @@ public class IotDeviceUpstreamServer {
router.route().handler(BodyHandler.create()); // 处理 Body
// 使用统一的 Handler 处理所有上行请求
IotDeviceUpstreamVertxHandler upstreamHandler = new IotDeviceUpstreamVertxHandler(deviceUpstreamApi,
applicationContext);
IotDeviceUpstreamVertxHandler upstreamHandler = new IotDeviceUpstreamVertxHandler(deviceUpstreamApi, applicationContext);
router.post(IotDeviceUpstreamVertxHandler.PROPERTY_PATH).handler(upstreamHandler);
router.post(IotDeviceUpstreamVertxHandler.EVENT_PATH).handler(upstreamHandler);

View File

@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.iot.plugin.http.upstream.router;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ObjUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
@ -150,10 +151,11 @@ public class IotDeviceUpstreamVertxHandler implements Handler<RoutingContext> {
Map<String, Object> properties = scriptService.parsePropertyData(productKey, deviceName, body);
// 如果脚本解析结果为空使用默认解析逻辑
if (properties.isEmpty()) {
// TODO @芋艿注释说明一下为什么要这么处理
if (CollUtil.isNotEmpty(properties)) {
properties = new HashMap<>();
Map<String, Object> params = body.getJsonObject("params") != null ? body.getJsonObject("params").getMap()
: null;
Map<String, Object> params = body.getJsonObject("params") != null ?
body.getJsonObject("params").getMap() : null;
if (params != null) {
// 将标准格式的 params 转换为平台需要的 properties 格式
for (Map.Entry<String, Object> entry : params.entrySet()) {
@ -193,7 +195,7 @@ public class IotDeviceUpstreamVertxHandler implements Handler<RoutingContext> {
Map<String, Object> params = scriptService.parseEventData(productKey, deviceName, identifier, body);
// 如果脚本解析结果为空使用默认解析逻辑
if (params.isEmpty()) {
if (CollUtil.isNotEmpty(params)) {
if (body.containsKey("params")) {
params = body.getJsonObject("params").getMap();
} else {

View File

@ -9,6 +9,7 @@ import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
// TODO @haohao写到单测类里
/**
* 脚本使用示例类
*/

View File

@ -6,6 +6,7 @@ import cn.iocoder.yudao.module.iot.plugin.script.service.ScriptServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
// TODO @haohao这个模块是不是融合到 plugin-common 里哈
/**
* 脚本模块配置类
*/
@ -31,7 +32,7 @@ public class ScriptConfiguration {
@Bean
public ScriptService scriptService(ScriptEngineFactory engineFactory) {
ScriptServiceImpl service = new ScriptServiceImpl();
// 如果有其他配置可以在这里设置
// TODO @haohao如果有其他配置可以在这里设置
return service;
}
}
}

View File

@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.iot.plugin.script.context;
import lombok.Getter;
import java.util.HashMap;
import java.util.Map;
@ -11,18 +13,22 @@ public class PluginScriptContext implements ScriptContext {
/**
* 上下文参数
*/
@Getter
private final Map<String, Object> parameters = new HashMap<>();
/**
* 上下文函数
*/
@Getter
private final Map<String, Object> functions = new HashMap<>();
/**
* 日志函数接口
*/
public interface LogFunction {
void log(String message);
}
/**
@ -46,16 +52,6 @@ public class PluginScriptContext implements ScriptContext {
}
}
@Override
public Map<String, Object> getParameters() {
return parameters;
}
@Override
public Map<String, Object> getFunctions() {
return functions;
}
@Override
public void setParameter(String key, Object value) {
parameters.put(key, value);
@ -71,6 +67,7 @@ public class PluginScriptContext implements ScriptContext {
functions.put(name, function);
}
// TODO @haohaosetParameters这样的话with 都是一些比较个性的参数
/**
* 批量设置参数
*
@ -87,11 +84,13 @@ public class PluginScriptContext implements ScriptContext {
/**
* 添加设备相关的上下文参数
*
* @param deviceId 设备ID
* @param deviceId 设备 ID
* @param deviceData 设备数据
* @return 当前上下文对象
*/
// TODO @haohao是不是加个 (String productKey, String deviceName, Map<String, Object> deviceData) {
public PluginScriptContext withDeviceContext(String deviceId, Map<String, Object> deviceData) {
// TODO @haohaodeviceId 一般是分开还是合并哈
parameters.put("deviceId", deviceId);
parameters.put("deviceData", deviceData);
return this;
@ -110,6 +109,7 @@ public class PluginScriptContext implements ScriptContext {
return this;
}
// TODO @haohaosetParameter 可以融合哈
/**
* 设置单个参数
*
@ -121,4 +121,5 @@ public class PluginScriptContext implements ScriptContext {
parameters.put(key, value);
return this;
}
}

View File

@ -37,6 +37,7 @@ public interface ScriptContext {
*/
Object getParameter(String key);
// TODO @haohao这个要不也是 setFunction
/**
* 注册函数
*
@ -44,4 +45,5 @@ public interface ScriptContext {
* @param function 函数对象
*/
void registerFunction(String name, Object function);
}
}

View File

@ -48,4 +48,4 @@ public abstract class AbstractScriptEngine {
public void setSandbox(ScriptSandbox sandbox) {
this.sandbox = sandbox;
}
}
}

View File

@ -12,8 +12,8 @@ import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
/**
* JavaScript脚本引擎实现
* 使用JSR-223 Nashorn脚本引擎
* JavaScript 脚本引擎实现
* 使用 JSR-223 Nashorn 脚本引擎
*/
@Slf4j
public class JsScriptEngine extends AbstractScriptEngine {
@ -24,7 +24,7 @@ public class JsScriptEngine extends AbstractScriptEngine {
private static final long DEFAULT_TIMEOUT_MS = 5000;
/**
* JavaScript引擎名称
* JavaScript 引擎名称
*/
private static final String JS_ENGINE_NAME = "nashorn";
@ -45,25 +45,24 @@ public class JsScriptEngine extends AbstractScriptEngine {
@Override
public void init() {
log.info("初始化JavaScript脚本引擎");
log.info("初始化 JavaScript 脚本引擎");
// 创建脚本引擎管理器
engineManager = new ScriptEngineManager();
// 获取JavaScript引擎
// 获取 JavaScript 引擎
engine = engineManager.getEngineByName(JS_ENGINE_NAME);
if (engine == null) {
log.error("无法创建JavaScript引擎尝试使用JavaScript名称获取");
log.error("无法创建JavaScript引擎尝试使用 JavaScript 名称获取");
engine = engineManager.getEngineByName("JavaScript");
}
if (engine == null) {
throw new IllegalStateException("无法创建JavaScript引擎请检查环境配置");
throw new IllegalStateException("无法创建 JavaScript 引擎,请检查环境配置");
}
log.info("成功创建JavaScript引擎: {}", engine.getClass().getName());
// 默认使用JS沙箱
// 默认使用 JS 沙箱
if (sandbox == null) {
setSandbox(new JsSandbox());
}
@ -99,7 +98,7 @@ public class JsScriptEngine extends AbstractScriptEngine {
// 执行脚本
return engine.eval(script, bindings);
} catch (ScriptException e) {
log.error("执行JavaScript脚本异常: {}", e.getMessage());
log.error("执行 JavaScript 脚本异常: {}", e.getMessage());
throw new RuntimeException("脚本执行异常: " + e.getMessage(), e);
}
};
@ -136,7 +135,7 @@ public class JsScriptEngine extends AbstractScriptEngine {
// 执行脚本
return engine.eval(script, bindings);
} catch (ScriptException e) {
log.error("执行JavaScript脚本异常: {}", e.getMessage());
log.error("执行 JavaScript 脚本异常: {}", e.getMessage());
throw new RuntimeException("脚本执行异常: " + e.getMessage(), e);
}
};
@ -152,9 +151,10 @@ public class JsScriptEngine extends AbstractScriptEngine {
@Override
public void destroy() {
log.info("销毁JavaScript脚本引擎");
log.info("销毁 JavaScript 脚本引擎");
cachedScripts.clear();
engine = null;
engineManager = null;
}
}

View File

@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.iot.plugin.script.engine;
import cn.hutool.core.lang.Assert;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@ -11,12 +12,12 @@ import org.springframework.stereotype.Component;
public class ScriptEngineFactory {
/**
* 创建JavaScript脚本引擎
* 创建 JavaScript 脚本引擎
*
* @return JavaScript脚本引擎
*/
public JsScriptEngine createJsEngine() {
log.debug("创建JavaScript脚本引擎");
log.debug("创建 JavaScript 脚本引擎");
return new JsScriptEngine();
}
@ -27,10 +28,7 @@ public class ScriptEngineFactory {
* @return 脚本引擎
*/
public AbstractScriptEngine createEngine(String scriptType) {
if (scriptType == null || scriptType.isEmpty()) {
throw new IllegalArgumentException("脚本类型不能为空");
}
Assert.notBlank(scriptType, "脚本类型不能为空");
switch (scriptType.toLowerCase()) {
case "js":
case "javascript":
@ -40,4 +38,5 @@ public class ScriptEngineFactory {
throw new IllegalArgumentException("不支持的脚本类型: " + scriptType);
}
}
}
}

View File

@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.iot.plugin.script.sandbox;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import javax.script.ScriptEngine;
@ -8,8 +9,9 @@ import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
// TODO @haohao这个是不是融合到 ScriptEngine
/**
* JavaScript脚本沙箱限制脚本的执行权限
* JavaScript 脚本沙箱限制脚本的执行权限
*/
@Slf4j
public class JsSandbox implements ScriptSandbox {
@ -34,6 +36,7 @@ public class JsSandbox implements ScriptSandbox {
"(?:\\bchild_process\\b)|" +
"(?:\\bwindow\\b)");
// TODO @haohao这个没用到哈
/**
* 脚本执行超时时间毫秒
*/
@ -44,30 +47,28 @@ public class JsSandbox implements ScriptSandbox {
if (!(engineContext instanceof ScriptEngine)) {
throw new IllegalArgumentException("引擎上下文类型不正确无法应用JavaScript沙箱");
}
ScriptEngine engine = (ScriptEngine) engineContext;
// Nashorn引擎中可以通过以下方式设置安全限制
// Nashorn 引擎中可以通过以下方式设置安全限制
try {
// 设置严格模式
String securityPrefix = "'use strict';\n";
// 禁用Java.type等访问系统资源的功能
// 禁用 Java.type 等访问系统资源的功能
engine.eval("var Java = undefined;");
engine.eval("var JavaImporter = undefined;");
engine.eval("var Packages = undefined;");
// 增强安全控制可以在这里添加
log.debug("已应用JavaScript安全沙箱限制");
log.debug("已应用 JavaScript 安全沙箱限制");
} catch (Exception e) {
log.warn("应用JavaScript沙箱限制失败: {}", e.getMessage());
log.warn("应用 JavaScript 沙箱限制失败: {}", e.getMessage());
}
}
@Override
public boolean validateScript(String script) {
if (script == null || script.isEmpty()) {
if (StrUtil.isNotEmpty(script)) {
return false;
}
@ -86,11 +87,12 @@ public class JsSandbox implements ScriptSandbox {
}
// 脚本长度限制
if (script.length() > 1024 * 100) { // 限制100KB
if (script.length() > 1024 * 100) { // 限制 100 KB
log.warn("脚本太大,超过了限制");
return false;
}
return true;
}
}

View File

@ -20,4 +20,5 @@ public interface ScriptSandbox {
* @return 是否安全
*/
boolean validateScript(String script);
}
}

View File

@ -12,7 +12,7 @@ public interface ScriptService {
/**
* 执行脚本
*
* @param scriptType 脚本类型jsgroovy
* @param scriptType 脚本类型 jsgroovy
* @param script 脚本内容
* @param context 脚本上下文
* @return 脚本执行结果
@ -22,7 +22,7 @@ public interface ScriptService {
/**
* 执行脚本
*
* @param scriptType 脚本类型jsgroovy
* @param scriptType 脚本类型 jsgroovy
* @param script 脚本内容
* @param params 脚本参数
* @return 脚本执行结果
@ -30,7 +30,7 @@ public interface ScriptService {
Object executeScript(String scriptType, String script, Map<String, Object> params);
/**
* 执行JavaScript脚本
* 执行 JavaScript 脚本
*
* @param script 脚本内容
* @param context 脚本上下文
@ -39,7 +39,7 @@ public interface ScriptService {
Object executeJavaScript(String script, ScriptContext context);
/**
* 执行JavaScript脚本
* 执行 JavaScript 脚本
*
* @param script 脚本内容
* @param params 脚本参数
@ -55,4 +55,5 @@ public interface ScriptService {
* @return 脚本是否安全
*/
boolean validateScript(String scriptType, String script);
}
}

View File

@ -38,6 +38,7 @@ public class ScriptServiceImpl implements ScriptService {
@PostConstruct
public void init() {
// 初始化常用的脚本引擎和沙箱
// TODO @haohaojs 是不是要枚举下哈
getEngine("js");
sandboxCache.put("js", new JsSandbox());
}
@ -49,6 +50,7 @@ public class ScriptServiceImpl implements ScriptService {
try {
engine.destroy();
} catch (Exception e) {
// TODO @haohaoengine 类名
log.error("销毁脚本引擎失败", e);
}
}
@ -58,6 +60,7 @@ public class ScriptServiceImpl implements ScriptService {
@Override
public Object executeScript(String scriptType, String script, ScriptContext context) {
// TODO @haohao可以使用 hutool assert
if (scriptType == null || script == null) {
throw new IllegalArgumentException("脚本类型和内容不能为空");
}
@ -74,6 +77,7 @@ public class ScriptServiceImpl implements ScriptService {
// 执行脚本
return engine.execute(script, context);
} catch (Exception e) {
// TODO @haohao最好把 e 堆栈出来哈然后engine 类名
log.error("执行脚本失败: {}", e.getMessage());
throw new RuntimeException("执行脚本失败: " + e.getMessage(), e);
}
@ -83,16 +87,19 @@ public class ScriptServiceImpl implements ScriptService {
public Object executeScript(String scriptType, String script, Map<String, Object> params) {
// 创建默认上下文
ScriptContext context = new PluginScriptContext(params);
// 执行脚本
return executeScript(scriptType, script, context);
}
@Override
public Object executeJavaScript(String script, ScriptContext context) {
// TODO @haohao枚举哈
return executeScript("js", script, context);
}
@Override
public Object executeJavaScript(String script, Map<String, Object> params) {
// TODO @haohao枚举哈
return executeScript("js", script, params);
}
@ -100,7 +107,8 @@ public class ScriptServiceImpl implements ScriptService {
public boolean validateScript(String scriptType, String script) {
ScriptSandbox sandbox = sandboxCache.get(scriptType.toLowerCase());
if (sandbox == null) {
log.warn("找不到脚本类型[{}]对应的沙箱使用默认JS沙箱", scriptType);
// TODO @haohao疑问为啥默认 JsSandbox
log.warn("[validateScript][找不到脚本类型[{}]对应的沙箱,使用默认 JS 沙箱]", scriptType);
sandbox = new JsSandbox();
sandboxCache.put(scriptType.toLowerCase(), sandbox);
}
@ -120,4 +128,4 @@ public class ScriptServiceImpl implements ScriptService {
return engine;
});
}
}
}

View File

@ -6,6 +6,9 @@ import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.concurrent.*;
// TODO @haohao重要 ScriptUtil.createGroovyEngine() 可以服用 hutool 的封装么
// TODO @haohao重要 js 引擎可能要看下 jdk8 的兼容性
// TODO @haohao重要我们要不 script 配置的时候支持 scriptType感觉会更通用一些groovypythonjs
/**
* 脚本工具类提供执行脚本的辅助方法
*/
@ -66,6 +69,7 @@ public class ScriptUtils {
* 关闭工具类的线程池
*/
public static void shutdown() {
// TODO @芋艿有没默认工具类可以 shutdown
SCRIPT_EXECUTOR.shutdown();
try {
if (!SCRIPT_EXECUTOR.awaitTermination(10, TimeUnit.SECONDS)) {
@ -77,8 +81,9 @@ public class ScriptUtils {
}
}
// TODO @芋艿要不要使用 JsonUtils
/**
* JSON字符串转换为Map
* JSON 字符串转换为 Map
*
* @param json JSON字符串
* @return Map对象转换失败则返回null
@ -86,19 +91,20 @@ public class ScriptUtils {
@SuppressWarnings("unchecked")
public static Map<String, Object> parseJson(String json) {
try {
// 使用hutool的JSONUtil工具类解析JSON
return JSONUtil.toBean(json, Map.class);
} catch (Exception e) {
log.error("解析JSON失败: {}", e.getMessage());
// TODO @haohaojsone 都打印出来哈
log.error("[parseJson][解析JSON失败: {}]", e.getMessage());
return null;
}
}
// TODO @芋艿要不要封装成 utils
/**
* 尝试将对象转换为整数
*
* @param obj 需要转换的对象
* @return 转换后的整数如果无法转换则返回null
* @return 转换后的整数如果无法转换则返回 null
*/
public static Integer toInteger(Object obj) {
if (obj == null) {
@ -122,6 +128,7 @@ public class ScriptUtils {
return null;
}
// TODO @芋艿要不要封装成 utils
/**
* 尝试将对象转换为双精度浮点数
*
@ -158,10 +165,12 @@ public class ScriptUtils {
* @return 如果两个数值相等则返回true否则返回false
*/
public static boolean numbersEqual(Number a, Number b) {
// TODO @haohaoNumberUtil.equals(1, 1D)
if (a == null || b == null) {
return a == b;
}
return Math.abs(a.doubleValue() - b.doubleValue()) < 0.0000001;
}
}