EasyPro 驱动开发指南
写给第一次接触驱动开发的新同学 · 从「这是什么」到「我能做出一个」
这份指南是写给谁的?
如果你刚入职、第一次听到「驱动」这个词,打开代码库看到 DriverAccess、SPI、PlatformClient 一头雾水——
别慌,这份文档就是为你写的。
我们不会假设你已经懂任何东西,每一个术语第一次出现时都会解释清楚,每一个概念都会用生活里的例子帮你理解。
读完它,你将能够独立开发出一个能接入真实设备、能把数据上报到平台的驱动。
理解了设计,API 自然就记住了;不理解设计,API 永远背不完。
想象 EasyPro 是一个万能管控中心,要管理一整栋大楼里的所有设备:空调、灯具、传感器、PLC、网关……
问题是——这些设备「说的话」各不相同。空调说的是 Modbus 方言,灯具说的是 KNX 方言,传感器说的是 MQTT 方言,PLC 说的是 RCP 方言。管控中心不可能学会全世界所有的方言。
于是就有了驱动(Driver):它是平台和某种设备之间的「翻译官 + 代理人」。你每写一个驱动,就是给平台招了一名专门懂某门「方言」的员工,让平台能听懂这种设备、也能向这种设备下达指令。
驱动 = 平台与某类设备之间的「翻译官」。平台只和驱动对话,驱动负责和设备对话。
剩下要学的,只是「在这个平台里,怎么把这位翻译官/转接头/管家正式招聘进来、让它上岗干活」的具体规矩。而这些规矩,其实就那么几条。
第一遍顺着读下来建立全貌;之后把这份页面当「字典」用——开发时遇到具体问题,直接从左侧目录跳到对应章节即可。每个章节都尽量做到独立可读。
用一张图,看清驱动在整个系统里的位置
很多新人学驱动开发觉得难,不是因为代码多复杂,而是因为一开始没建立起全局画面——不知道自己写的这几行代码,最终在系统里扮演什么角色。这一节,我们先把这张「地图」摊开。
把整个系统想象成一家「智能楼宇服务公司」,那么:
- EasyPro 平台 = 公司总部,负责接单、记账、派活、发工资(统一管理)。
- 设备(空调/灯具/PLC)= 分布在各处的外包工人,他们各有各的工作习惯和「方言」。
- 驱动 = 公司派驻在每个外包工人身边的现场代表,负责翻译沟通、监督工作、定期汇报。
- DriverAccess = 现场代表的上岗合同,规定了必须会做的几件事(报到、报工号、开工、接单……)。
- PlatformClient = 公司发给现场代表的对讲机 + 工单系统,用它才能向总部汇报、查资料、领任务。
- 点位(Point) = 公司关注的每一项工作数据,比如「3 楼空调当前温度」「5 楼灯的开关状态」。
- 驱动实例(DriverInstance) = 现场代表负责的一个具体岗位(比如「负责 3 号楼所有 Modbus 空调」是一个实例)。
一种协议 → 一个驱动(modbus 驱动负责所有 Modbus 设备);一组同类设备 → 一个驱动实例(3 号楼的 Modbus 空调是一个实例,5 号楼的 Modbus 电表是另一个实例,但都用同一个 modbus 驱动)。
各自说不同的「方言」
(Modbus / KNX / MQTT …)
通过
PlatformClient 上报靠
DriverAccess 接入平台用户在界面看数据、控设备
报表 / 告警 / 联动
Point,再调 pushPointValue 上报。这就是驱动工作的全部核心。
举个例子:一台空调当前温度是 24.5℃,它用 Modbus 协议把这个数字「说」出来。你写的 modbus 驱动收到后,翻译成平台认识的 Point 对象,通过 PlatformClient.pushPointValue() 这个「对讲机」上报给平台。平台存下来,用户在界面上就看到了「24.5℃」——这一连串就是一次完整的数据旅程。
而你,就是中间那个翻译官。左右两边(设备和平台)都是现成的,你的工作就是把它们连起来。
新人常有疑问:「为什么驱动要实现这么多方法?为什么不能自己连数据库、自己开线程?非得受这些限制?」答案是为了「统一管理、隔离故障」。
想象公司有 7 个现场代表(7 种驱动),如果每个人都能随便动公司的账本和公章(数据库、线程池),一个人犯错全公司遭殃。所以平台定了规矩:
- 资源统一管:线程池由平台注入(
driverExecutor),数据库访问通过PlatformClient出口——这样平台能控制总量、监控使用、隔离故障。 - 接口标准化:所有驱动都实现
DriverAccess这一份「合同」(17 个方法),平台用同一套方式管理所有驱动,加新驱动不用改平台代码。 - 不使用 Spring:驱动不依赖 Spring 框架,靠 SPI 加载。这不仅是减少耦合,更是为了让 SDK 能独立成包——未来驱动可以打成 jar 上传平台加载、可以声明属性元数据让平台自动入库(详见第 3 章设计理念)。个别驱动的违规用法后续会修正。
这种「受约束的自由」看似啰嗦,实则是大规模系统稳定运行、也是驱动生态可独立交付的基础。理解了这一点,后面看到的各种规矩就都顺理成章了。
DriverAccessPlatformClientDriverInstancePoint / 点位SPIServiceLoader 启动时自动找到并加载你的驱动。HttpApiCallBack/driver/api/{驱动名}/{接口名} 路由到你的代码。ScheduleCallback{驱动名}_{任务名}),驱动只管写执行逻辑。Cron0/4 * * * * ? 表示每 4 秒。setStatusFlag(1))。上报数据时带上,让平台知道这个值能不能信。DriverApiResult做出一个能跑、能上报数据的「最小驱动」
学编程最好的方式永远是「先让它跑起来」。这一节我们不追求完整、不追求优雅,只做一件事:用最少的代码,让平台认识你的驱动、并看到它上报了一条数据。跑通这一遍,你会有「啊,原来如此」的顿悟感。
java -version 看到 21.x 版本号mvn -version这里我们用「内嵌模式」——把驱动代码直接放进平台主工程来开发。这是最适合新人的方式:能断点调试、改完立刻生效、不用操心打包。等熟练了,再看外置模式。
驱动 SDK(com.sctech.opti.cloud.drivers.sdk 包)就在 OptiCloud 主工程内部,驱动和平台是同一个 Maven 工程,所以不需要额外加 SDK 的 Maven 坐标——直接 import 即可。你只需确保 Lombok 可用(主工程已包含):
虽然驱动代码在主工程里(能「看到」Spring 的类),但驱动代码严禁 import 或使用任何 Spring 的东西(@Component/@Service/@Autowired/@Resource、org.springframework.* 等)。驱动靠 SPI 加载,资源通过 PlatformClient 获取。这是设计原则,违反会导致驱动无法被正确加载和管理。
// 直接 import SDK 中的接口,无需任何 Maven 坐标 import com.sctech.opti.cloud.drivers.sdk.DriverAccess; import com.sctech.opti.cloud.drivers.sdk.PlatformClient; import com.sctech.opti.cloud.drivers.sdk.HttpApiCallBack; import com.sctech.opti.cloud.drivers.sdk.ScheduleCallback; import com.sctech.opti.cloud.drivers.sdk.model.DriverInstance; import com.sctech.opti.cloud.drivers.sdk.model.Point;
新建一个类,实现 DriverAccess 接口。这是驱动的「身份证」——平台靠它认识你。注意:DriverAccess 接口一共有 17 个方法,下面先给出最核心的几个,其余校验/生命周期方法先空实现(IDE 一键生成即可),第 3 章会逐一讲:
@Slf4j public class HelloDriverAccess implements DriverAccess { // 第 3 步会用到的「对讲机」,先声明着 @Getter private static PlatformClient platformClient; @Override public boolean init() { log.info("🎉 我的第一个驱动,初始化成功!"); return true; // 返回 true 表示初始化成功 } @Override public String getDriverVersion() { return "V 1.0.0"; } @Override public String getDriverName() { return "hello"; } // 驱动名,要和数据库一致 @Override public DriverAccessMode getAccessMode() { return DriverAccessMode.EMBEDDED; } @Override public List<HttpApiCallBack> getHttpApiCallBackList() { return List.of(); } @Override public List<ScheduleCallback> getScheduleCallbackList() { return List.of(new HelloReportTask()); // 第 3 步创建 } @Override public void receivePlatformClient(PlatformClient client) { platformClient = client; // 平台把「对讲机」发给我们,存起来 log.info("📞 收到平台客户端"); } // —— 以下方法先空实现,IDE 可一键生成;真实驱动需补充逻辑(见第 6 章)—— @Override public void validateDriverInstanceAttr(List<DriverInstanceAttr> l, Long i, String p) {} @Override public void validateDriverPointAttr(List<PointAttr> l, Long i, Long p, String pr) {} @Override public void issuePointValueBatch(List<Point> l) {} @Override public boolean openDriverInstance(DriverInstance d) { return true; } @Override public boolean closeDriverInstance(DriverInstance d) { return true; } // ... 其余 newDriverInstanceHasSaved / driverInstanceHasUpdated / // driverInstanceHasDeleted / getUniqueCodeByPointFromDriver / 批量校验等 // 先空实现,详见第 3、6 章 }
不用现在全记住,这里先有个印象,第 3 章会逐一细讲。现在你只需知道:
init()= 报到,「我来了,准备好了」getDriverName()= 工号牌,平台按这个名字找你receivePlatformClient()= 领对讲机,存好以后用- 其他几个先填默认值即可
一个静止的驱动没意思。我们让它每 4 秒上报一条数据,这样你能在平台上亲眼看到数据在动:
@Slf4j public class HelloReportTask implements ScheduleCallback { @Override public String getTaskName() { return "helloReportTask"; } @Override public String getCronExpression() { return "0/4 * * * * ?"; // 每 4 秒执行一次 } @Override public String getTaskDescription() { return "hello 驱动测试上报"; } @Override public void execute() { log.info("📊 hello 驱动定时任务执行中..."); // 真实驱动会在这里读取设备数据,这里先模拟 // 用 platformClient.pushPointValue(...) 上报即可 } }
最后一步,也是最容易被忘的一步:在 META-INF/services/ 目录下创建一个文件,文件名是接口全限定名,内容是你的实现类全限定名。平台启动时会扫描这里:
src/main/resources/META-INF/services/
└── com.sctech.opti.cloud.drivers.sdk.DriverAccess # 文件名固定为这一长串com.sctech.opti.cloud.drivers.inline.hello.access.HelloDriverAccess # 你的实现类全路径,每行一个
如果启动后日志里看不到你的 🎉 我的第一个驱动,第一个就检查这个文件:文件名对不对、类路径对不对、有没有拼写错误。这一步没有报错提示,只会「静默地不加载」。
启动 EasyPro 平台,打开控制台日志,如果一切正常,你会看到:
[INFO] 🎉 我的第一个驱动,初始化成功! [INFO] 📞 收到平台客户端 [INFO] 📊 hello 驱动定时任务执行中... ← 每 4 秒出现一次 [INFO] 📊 hello 驱动定时任务执行中... [INFO] 📊 hello 驱动定时任务执行中...
看到这三行日志,恭喜——你的驱动已经被平台成功加载并运行起来了!虽然它还没接真实设备、还没上报真实点位,但「驱动开发」最关键的那道坎(让平台认识并运行你的代码)你已经跨过去了。
点击看答案
META-INF/services/com.sctech.opti.cloud.drivers.sdk.DriverAccess 这个 SPI 声明文件没创建、文件名写错、或里面的实现类全路径写错。SPI 加载失败是「静默」的,不会报错,只会让你的驱动不被加载。这是新人第一大坑。彻底搞懂 DriverAccess 这份「上岗合同」
上一章你照着抄了一份驱动代码跑起来了,但那些方法到底为什么要有、每个方法在什么时候被调用、返回值意味着什么,可能还一头雾水。这一章我们把 DriverAccess 掰开揉碎讲清楚——它是驱动开发的地基,地基打牢,后面盖楼就快了。
EasyPro 驱动框架有一条核心原则:驱动不使用 Spring,也不依赖任何平台端包,只关注业务实现。资源管理(线程池、数据库访问)通过 SDK 的 PlatformClient 统一出口,驱动专注业务实现,把「重活」托管给平台。
「驱动不使用 Spring」是必须遵守的设计原则。如果你在参考现有驱动代码时看到有人用了 Spring 注解,请将其视为待修正的违规写法,不要照抄——这并不代表原则发生了变化。新开发驱动时务必严格遵守:不引入 Spring 依赖。
驱动不依赖 Spring、只通过 SDK 接口与平台交互,这个设计不是单纯的洁癖,而是有明确的战略目的:
① SDK 能独立成包。因为驱动只依赖 SDK 这一层(不依赖整个平台),未来这套 SDK 就能抽成一个独立的开发包,提供给任何人在自己的工程里开发驱动,不需要拉整个 OptiCloud 源码。
② 支撑外置驱动 jar 加载。开发者用 SDK 包写好驱动、打包成 jar,上传到平台即可直接加载运行——不用改主工程、不用重新发布平台。这正是 外置模式的设计目标(目前内嵌先行,外置是下一阶段)。
③ SDK 声明元数据,平台自动建表。SDK 会提供声明方法,让驱动声明自己需要哪些实例属性、点位属性等元数据(比如「我这个驱动需要 IP、端口、波特率三个实例属性」「点位需要寄存器地址、功能码两个属性」)。驱动一加载,平台就会自动把这些元数据构建到数据库,前端据此生成配置界面——驱动开发者不用手写建表 SQL,不用手动配数据库。
所以,「轻量化」的真正含义是「可移植、可独立交付」。今天你按内嵌模式写的驱动代码,将来 SDK 独立成包、外置加载实现后,几乎可以原封不动地打成 jar 上传运行。理解了这个长远目标,就能明白现在那些「不用 Spring、走 PlatformClient」的规矩为什么必须从一开始就遵守。
DriverAccess 接口一共有 17 个方法,分四大类。新人一开始可能会被数量吓到,但其实大部分都有默认空实现,真正需要你认真写的就那么几个。我们按「现场代表上岗」的比喻分类讲:
① 身份类(5个):报姓名版本工种;② 校验类(4个):验证配置合法性;③ 生命周期类(5个):实例开关删改;④ 业务类(3个):下发点位、获取唯一码、领对讲机。
第一遍读,重点搞懂身份类(init/getDriverName/receivePlatformClient)+ openDriverInstance/closeDriverInstance + issuePointValueBatch 这几个,其余先空实现,遇到需求再回查。
① 身份类方法(必须实现,最基础)
- 返回
true= 「我准备好了,可以开工」 - 返回
false= 「出问题了,别给我派活」 - 这是你给平台留下的第一印象,建议在这里打印一条 INFO 日志方便排查
"V 1.2.0"。用于版本管理和兼容性检查。- 通常用小写字母,如
"modbus"、"rcp"、"hello" - 写错了 = 平台找不到你 = 驱动永远不会被加载
这个名字是字符串,拼错一个字母、大小写不对,都不会报错,只会「找不到」。如果驱动加载了但实例打不开,优先检查这里和数据库是否一致。
EMBEDDED(内嵌)= 代码在主工程里,当前唯一真正启用的方式EXTERNAL(外置)= 设计上预留,但目前未实现
- 不需要就返回
List.of()(空清单)
- 不需要就返回
List.of()
② 校验类方法(验证配置合法性,防止错误配置)
ImportResult。③ 生命周期方法(实例的开关删改,详见第 6 章)
④ 业务与交互方法
PlatformClient 注入进来——这是你与平台交互的唯一通道。务必存成静态变量,整个驱动生命周期都要用。null。DriverAccess 17 个方法看似很多,其实就四件事:「我是谁」(身份类)+ 「配置对不对」(校验类)+ 「实例开关删改」(生命周期)+ 「下发指令、领对讲机」(业务交互)。IDE 一键生成空实现,再按需填充即可,不必死记。
驱动代码放在主工程内部,通过 SPI 声明,平台用 ServiceLoader.load(DriverAccess.class) 自动发现加载。这是目前唯一真正启用的方式——所有 7 个在线驱动(modbus/knx/mqtt/rcp/secp/ycbox/bacnetip)都是内嵌的。
驱动代码直接在主工程 drivers/inline/<驱动名>/ 目录下,在 SPI 文件里声明实现类。平台启动时通过 ServiceLoader 自动加载。
com.sctech.opti.cloud.drivers.inline.ycbox.access.YcboxDriverAccess com.sctech.opti.cloud.drivers.inline.rcp.access.RcpDriverAccess com.sctech.opti.cloud.drivers.inline.modbus.access.ModbusAccess com.sctech.opti.cloud.drivers.inline.secp.access.SecpAccess com.sctech.opti.cloud.drivers.inline.knx.access.KnxAccess com.sctech.opti.cloud.drivers.inline.mqtt.access.MqttDriverAccess com.sctech.opti.cloud.drivers.inline.bacnetip.access.BacnetIpAccess
SPI 文件里每行一个实现类全路径,ServiceLoader 会加载所有非注释行。要临时禁用某个驱动,在行首加 # 注释掉即可;新增驱动就在文件末尾追加一行。当前启用的就是上面这 7 个。
SPI(Service Provider Interface)是 Java 自带的服务发现机制:在 META-INF/services/ 放一个「以接口名为文件名」的文本文件,里面每行写一个实现类的全路径,ServiceLoader 启动时自动扫描并加载。不用手动 new,不用改配置。
DriverAccessMode 枚举里定义了 EXTERNAL(外置模式),框架的 DriverAccessLoader 里也预留了 watch() 目录监听方法的骨架。但这部分目前标记为 //todo,尚未实现——所以现阶段所有驱动都走内嵌模式。
外置模式实现后的愿景(也就是设计理念里说的战略目标):
- SDK 抽成独立开发包,第三方拉这个包就能开发驱动,不用拉 OptiCloud 全量源码
- 驱动写好打包成 jar 上传平台,平台自动加载运行,不用改主工程、不用重新发布
- 驱动通过 SDK 声明属性元数据(实例属性、点位属性),平台自动构建到数据库、生成配置界面
这些是规划中的能力,当前版本不可用。开发时统一用内嵌模式。但现在写驱动就要按 SDK 规范来(不用 Spring、走 PlatformClient),这样将来外置模式上线,代码能直接复用。等外置模式真正实现后,本文档会同步更新。
因为驱动的代码是同一套。今天你按内嵌模式写的驱动(不依赖 Spring、只走 SDK 接口),将来外置模式上线后,几乎可以原封不动地打成 jar 上传运行。如果现在图方便用了 Spring、直接碰了平台内部类,那将来外置化时就得返工重写。
所以:内嵌是当下的运行方式,SDK 规范是面向未来的代码标准。两者不矛盾——用内嵌的方式开发,按外置的标准写代码。
掌握平台赋予驱动的三大能力
实现 DriverAccess 只是「上岗」,真正让驱动干活的是平台给你的三件工具:对外提供 HTTP 接口、跑定时任务、调用平台能力。搞懂这三样,你就能应付 90% 的开发需求。
PlatformClient)。我们一个个说。有时候驱动需要对外提供 HTTP 接口,让前端或第三方系统能调用。比如 Modbus 驱动要提供「导入点位配置文件」接口,BACnet 驱动要提供「触发设备发现」接口。
但你不用自己写 Controller、不用自己配路由——实现 HttpApiCallBack 接口,平台会自动把 URL 注册好,请求转发到你的代码里。
public interface HttpApiCallBack { /* * 返回 API 的映射路径 * 比如返回 "importDriverInstance" * 最终 URL 会是 /driver/{driverName}/importDriverInstance */ String getApiMapping(); /* * 执行 API 请求 * @param param 请求参数 Map(所有查询参数) * @param file 上传的文件(可能为 null) * @return DriverApiResult 封装执行结果 */ DriverApiResult execute(Map<String,String> param, File file); }
@Slf4j public class ImportDriverInstanceApi implements HttpApiCallBack { @Override public String getApiMapping() { return "importDriverInstance"; // URL 路径名 } @Override public DriverApiResult execute(Map<String,String> param, File file) { if (file == null) { throw new IllegalArgumentException("配置文件不能为空"); } Long driverId = Long.parseLong(param.get("driverId")); // ... 解析 file、导入配置的具体逻辑 DriverApiResult r = new DriverApiResult(); r.setData("导入成功"); return r; } }
然后在驱动的 getHttpApiCallBackList() 里把它注册上:
@Override public List<HttpApiCallBack> getHttpApiCallBackList() { return List.of(new ImportDriverInstanceApi()); }
这样前端就能通过 POST /driver/api/modbus/importDriverInstance 调用到你的代码了(注意路径里有 /api 段)。不用写 Controller,不用配路由,全自动化。
① 驱动实现接口返回路径名 → ② 平台启动时在 DriverAccessLoader 里收集,按 {driverName}/{apiMapping} 拼成 key 存入 apiCallBackMap → ③ 平台的 DriverApiController(类级 @RequestMapping("/driver/api"))用 @PostMapping("/{driverName}/{api}") 接收请求 → ④ 转发给 driverHelper.executeDriverApi() 找到你的 execute 方法 → ⑤ 结果包装成 Result 返回前端。完整 URL:/driver/api/{driverName}/{apiMapping}。
public interface ScheduleCallback { String getTaskName(); // 任务名(驱动内唯一,不能重复) String getCronExpression(); // Cron 表达式:多久执行一次 String getTaskDescription(); // 任务描述(给人看的) void execute(); // 真正要干的活 }
驱动返回 ScheduleCallback 列表后,平台在 DriverAccessLoader 里会调 scheduleHelper.createSysSchedule() 把任务注册进调度系统。注册时:任务全名 = {driverName}_{taskName}(比如 rcp 驱动的 aliveTask 全名是 rcp_aliveTask),bean 固定为 driverHelper,方法为 executeDriverScheduleTask,Cron 用你返回的表达式。所以任务名驱动内不能重复,否则会互相覆盖。
新人最怕 Cron,其实就背几个常用的就行:
| 需求 | 表达式 | 含义 |
|---|---|---|
| 每 4 秒 | 0/4 * * * * ? | 心跳检测常用 |
| 每 10 分钟 | 0 0/10 * * * ? | 数据同步常用 |
| 每小时整点 | 0 0 * * * ? | 周期巡检 |
| 每天中午 12 点 | 0 0 12 * * ? | 日报类任务 |
| 每月 1 号凌晨 2 点 | 0 0 2 1 * ? | 月度任务 |
字段允许值:秒/分 0-59,时 0-23,日 1-31,月 1-12,周 SUN-SAT。特殊字符 * , - / 通用,日还支持 ? L W,周支持 ? L #。
@Slf4j public class AliveTask implements ScheduleCallback { private static final PlatformClient platformClient = RcpDriverAccess.getPlatformClient(); @Override public String getTaskName() { return "aliveTask"; } @Override public String getCronExpression() { return "0/4 * * * * ?"; } // 每 4 秒 @Override public String getTaskDescription() { "rcp 驱动实例心跳检测"; } @Override public void execute() { log.debug("心跳检测开始"); RcpConnectorManager.checkDriverInstanceAlive(); log.debug("心跳检测完成"); } }
同样,在驱动的 getScheduleCallbackList() 里注册:return List.of(new AliveTask());
前面说过,驱动不能自己连数据库、自己开线程——所有和平台的交互,都必须通过 PlatformClient 这个统一出口。它就像公司发的对讲机+工单系统:
getDriverData()— 获取当前驱动的全部数据(实例、点位、属性、配置),心跳/巡检任务常用getDriverInstance(Long id)— 获取单个驱动实例createNewDriverInstance(...)/updateDriverInstance(...)/deleteDriverInstance(Long)— 创建 / 更新 / 删除实例pushInstanceKeepAlive(List<InstanceStatusToPush>)— 推送心跳(心跳任务必调)
getDeviceType(projectId, name)/saveDeviceType(...)/batchUpdateDeviceType(...)updateDeviceTypeDescription(...)/deleteDeviceTypesByKeys(List<Long>, Long)
getDevicesByDeviceTypeKey(Long)/getDevicesByTypeKeys(...)/getDevicesByDriverInstanceIdRelative(...)saveDeviceBatch(...)/batchUpdateDevice(...)deleteDeviceBath(...)/deleteDeviceByIds(...)— 批量删除(注意源码方法名是deleteDeviceBath)
getPointsByDriverInstanceId(Long)— 获取点位列表saveBatchPoint(List<Point>, boolean autoBinding)/updateBatchPoint(...)— 批量保存 / 更新点位pushPointValue(List<Point>)— 推送点位值(驱动最常调用的方法!)checkPointCanAdd(projectId, count, alarmSleepSeconds)— 检查点位是否可添加deletePoints(List<Long>)/deleteAllPointByDriverInstanceId(Long)— 删除点位
pushAlarm(DriversAlarm)— 推送告警uploadHistoryData(List<HistoryPointValue>)— 上报历史数据updateDriverExtraData(driverId, extraData)— 更新驱动扩展数据
getDriverThreadPoolExecutor()— 获取驱动专用线程池(平台注入的driverExecutor,不要自己 new Thread)
理解 PlatformClient 最快的方式,就是看一条点位数据是怎么「走」完整个链路的。下图是一次数据上报的完整旅程:
Point 对象,填好 curValue 和 statusFlagplatformClient.pushPointValue(pointList) —— 把数据交给平台第 1 步(设备)和第 4、5 步(平台/前端)都是现成的。你只需把设备数据翻译成 Point,再调 pushPointValue——这就是驱动上报数据的全部核心。下发指令(issuePointValueBatch)是反向的同一链路。
// 获取驱动实例 DriverInstance instance = platformClient.getDriverInstance(instanceId); // 推送点位值到平台(最核心的调用) platformClient.pushPointValue(pointList); // 用平台线程池跑异步任务(千万别自己 new Thread) platformClient.getDriverThreadPoolExecutor().execute(() -> { // 异步处理逻辑 });
平台给每个驱动分配了专用线程池,异步任务必须用 getDriverThreadPoolExecutor()。自己 new Thread().start() 是严重违规——会导致线程失控、平台无法管理、故障无法隔离。
❌ 错误写法
// 自己开线程,平台管不到 new Thread(() -> { doSomething(); }).start();
✅ 正确写法
// 用平台线程池,可管控可监控 platformClient .getDriverThreadPoolExecutor() .execute(() -> doSomething());
驱动开发会反复接触三个核心数据模型。它们都位于 com.sctech.opti.cloud.drivers.sdk.model 包下,用 Lombok @Data 简化代码,支持链式调用。理解了这三个,PlatformClient 的 API 就一目了然了。
把这三个模型想成「岗位(DriverInstance)— 工作项(Point)— 工作汇报(DriverApiResult)」的层级关系。
代表驱动负责的一组设备,比如「3 号楼所有 Modbus 空调」。
@Data public class DriverInstance { private Long instanceId; // 实例 ID(主键) private Long driverId; // 驱动 ID private String driverName; // 驱动名称(要和 getDriverName() 一致) private String instanceName; // 实例名称 private String instanceDesc; // 实例描述 private boolean switchOn; // 是否启用 private Integer instanceStatus; // 实例状态 private List<DriverInstanceAttr> driverInstanceAttrs; // 实例属性(配置项) private String projectId; // 项目 ID private Long defaultSubsystemId; // 默认子系统 ID }
平台关注的最小数据单元,比如「空调当前温度」「灯的开关」。这是驱动和平台之间数据交互的核心载体。字段较多,按用途分组记:
@Data public class Point { // —— 基础标识 —— private Long id; // 主键 ID private String pointName; // 点位名称 private String pointUniqueCode; // 点位唯一码 private String accessMode; // 点位地址(如 Modbus 寄存器地址) private String dataType; // 数据类型 // —— 归属关系 —— private Long driverId; private Long driverInstanceId; private String driverInstanceName; private String driverName; private String projectId; // —— 运行时数据(上报时填)—— private Object curValue; // 当前值(上报时填这个) private Integer statusFlag; // 质量码(正常数据设 1,见下方说明) // —— 物模型规格(spec)—— private String dataSpecUnit; // 单位标识 private String dataSpecUnitName; // 单位名称 private Double optionMin; // 取值下限 private Double optionMax; // 取值上限 private Double dataSpecStep; // 步长 private String dataSpecZero; // 关闭/0 值语义 private String dataSpecOne; // 开启/1 值语义 // —— 其他 —— private Integer sort; // 排序 private String pointDescription; // 描述 private List<PointAttr> pointAttrs; // 点位属性 }
调 pushPointValue() 前,一定要设好 curValue(当前值)和 statusFlag(质量码)。正常数据通常设 statusFlag = 1(参考各驱动 setStatusFlag(1) 的用法);异常时按驱动约定设置,让平台知道这个值能不能信。
做物模型类驱动(如 YCBox、MQTT 网关)时,optionMin/optionMax/dataSpecStep 定义取值范围和步长,dataSpecZero/dataSpecOne 定义开关语义,dataSpecUnit 定义单位——这些字段平台会用于前端控件生成和数据校验,按设备实际情况填写。
HTTP 接口必须返回这个类型,包含成功标志、错误信息和数据。
@Data @Accessors(chain = true) public class DriverApiResult { private Boolean success = true; // 是否成功 private String errorMsg; // 错误信息(success=false 时有效) private String msg; // 提示信息 private Object data; // 返回数据 }
每个驱动实例的配置(IP、端口、从站号等)都以 DriverInstanceAttr 列表形式挂载在 DriverInstance 上。第 6 章会讲怎么读取这些值。
@Data public class DriverInstanceAttr { private String attrName; // 属性名(如 "host"),对应SQL里的 attr_name private String attrValue; // 属性值(如 "192.168.1.100") private Long instanceId; // 所属实例 ID }
每个点位除了通用字段(名称、地址、值),还有协议特有的属性——比如 Modbus 的功能码、BACnet 的对象类型。这些以 PointAttr 列表挂在 Point 上。
@Data public class PointAttr { private String attrDisplayName; // 显示名称(给前端用) private String attrName; // 属性名(如 "functionCode") private String attrValue; // 属性值(如 "3" = 读保持寄存器) private Integer attrSort; // 排序 private String dataType; // 数据类型 private String optionElementType; // 前端控件类型(input/select) // ... pointId、driverId、createTime 等 }
DriverInstanceAttr 描述实例级配置(整个实例一份,如连接 IP);PointAttr 描述点位级配置(每个点位一份,如寄存器地址)。它们的元数据(有哪些属性、什么类型)都在数据库初始化 SQL 里定义。
前面几章是「零件」,这一章是「组装说明书」——把所有知识点串成一个完整的开发流程。日常工作中,每接一个新驱动,基本都走这套流程。
驱动代码统一放在主工程的 drivers/inline/<驱动名>/ 包下。一个规范的驱动,建议按职责分包(参考现有驱动如 rcp、modbus):
com.sctech.opti.cloud.drivers.inline.<driver-name>/ ├── access/ # 驱动接入层(实现 DriverAccess,第 3 章讲的) ├── base/ # 基础管理类(连接管理、资源管理,如 XxxConnectorManager) ├── constant/ # 常量定义 ├── entity/ # 实体类(驱动特定的数据结构) ├── task/ # 定时任务实现(第 4.2 节,如 AliveTask) ├── api/ # HTTP 接口实现(第 4.1 节,可选,如 ImportDriverInstanceApi) ├── util/ # 工具类 └── server/ # 服务端处理(连接、协议解析,如果是服务端模式)
前置条件:JDK 21 · Maven 3.6+ · 了解目标协议规范 · 准备测试设备或模拟器
驱动 SDK 是主工程内的包 com.sctech.opti.cloud.drivers.sdk,不需要在 pom.xml 里单独声明 SDK 依赖——你在主工程里写驱动代码,天然就能用所有 SDK 类。只有当你的驱动需要额外的协议库(比如 Netty、某个协议解析库)时,才往主工程 pom 加对应依赖。
<dependencies> <!-- 驱动 SDK 无需声明,已在主工程内 --> <!-- 协议相关依赖(按你的协议按需添加,例如 Netty) --> <dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.x</version> </dependency> <!-- Lombok(必需) --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <scope>provided</scope> </dependency> </dependencies>
这是 DriverAccess 里生命周期类的方法。平台会在实例「打开/关闭/删除/更新」时调用它们,这是你管理连接、资源的地方。先看一张生命周期全景图,理解每个回调在什么时候触发:
运行中用户改了实例配置(如换了 IP),平台会调 driverInstanceHasUpdated,你在这里决定要不要重连。它不在主线上,但配置类驱动常需要处理。
下面以 RCP 驱动的真实实现为参考:
@Override public boolean openDriverInstance(DriverInstance inst) { // 实例「开门营业」:建立连接、启动轮询 return RcpConnectorManager.handleDriverInstanceOpen(inst); } @Override public boolean closeDriverInstance(DriverInstance inst) { // 实例「打烊」:关闭连接、释放资源 return RcpConnectorManager.handleDriverInstanceClose(inst.getInstanceId()); } @Override public boolean driverInstanceHasDeleted(Long instanceId) { // 实例被删除:彻底清理连接等资源 return RcpConnectorManager.handleDriverInstanceHasDeleted(instanceId); } @Override public boolean newDriverInstanceHasSaved(DriverInstance inst) { // 新实例刚保存:可做初始化(不需要则 return false) return false; } @Override public boolean driverInstanceHasUpdated(DriverInstance inst) { // 实例配置被修改:可重新加载(不需要则 return false) return false; } @Override public void issuePointValueBatch(List<Point> pointList) { // 平台下发指令:把点位值写到设备 RcpConnectorManager.writeAllPointValue(pointList); }
open 建、close 关、delete 清、issue 写,再加 saved/updated 两个变更通知。把这六个方法和「开关门 + 清理 + 下指令 + 通知」对应起来,就不会乱。不需要的回调返回 false 或空实现即可。
这是新人写真实驱动时第一个会卡住的地方:用户在前端配的 IP、端口、从站号,驱动代码里到底怎么拿到?答案藏在 DriverInstance.driverInstanceAttrs 里——配置以「属性名→属性值」的键值对列表形式存在。
用户在前端填的每一项配置,对应一个 DriverInstanceAttr,有 attrName(属性名,如 "host")和 attrValue(属性值,如 "192.168.1.100")。属性名必须和数据库初始化 SQL 里 attr_name 字段一致——这是驱动代码和前端配置对上号的桥梁。
标准写法(参考 Modbus 驱动真实代码):
// 在 openDriverInstance 等方法里,从实例上读取配置 @Override public boolean openDriverInstance(DriverInstance inst) { List<DriverInstanceAttr> attrs = inst.getDriverInstanceAttrs(); // 方式一:逐个按 attrName 取值(最常用) String host = attrs.stream() .filter(a -> a.getAttrName().equals("host")) .findFirst().map(DriverInstanceAttr::getAttrValue).orElse(null); String port = attrs.stream() .filter(a -> a.getAttrName().equals("port")) .findFirst().map(DriverInstanceAttr::getAttrValue).orElse(null); // 方式二:一次性转成 Map,后续直接 get(配置项多时更清爽) Map<String, String> cfg = attrs.stream() .collect(Collectors.toMap( DriverInstanceAttr::getAttrName, DriverInstanceAttr::getAttrValue)); String slaveId = cfg.get("slaveId"); // 拿到配置后,建立连接等业务逻辑 return doConnect(host, Integer.parseInt(port), Integer.parseInt(slaveId)); }
配置项的 attrName(代码里取值用的 key)必须和数据库初始化 SQL 里定义的 attr_name 完全一致。比如 SQL 里写的是 'host',代码里却 equals("ip"),就读不到值——而且不会报错,只会得到 null。配置读不到时,第一时间核对这两处的命名。
用户配置驱动实例或点位时,平台会调校验方法让你校验合法性——在这里抛异常,配置就保存不上去,能把错误挡在源头。校验方法分单条和批量两种:
// 单条校验:保存单个实例/点位时调用 @Override public void validateDriverInstanceAttr(List<DriverInstanceAttr> attrList, Long instanceId, String projectId) throws Exception { // 校验实例属性,比如 IP 格式、端口范围等,不合法就抛异常 RcpConnectorManager.validateDriverInstanceAttr(attrList, instanceId, projectId); } @Override public void validateDriverPointAttr(List<PointAttr> pointAttrList, Long instanceId, Long pointId, String projectId) { // 校验点位属性,比如寄存器地址是否合法 } // 批量校验:导入工程文件(含多个实例/点位)时调用,结果填入 ImportResult @Override public void validateDriverInstanceAttrBatch(List<ImportInstanceResultVo> instanceList, ImportResult<Object> res) { RcpConnectorManager.validateDriverInstanceAttrBatch(instanceList, res); } @Override public void validateDriverPointAttrBatch(List<ImportPointResultVo.ImportPoint> pointList, ImportResult<Object> res, Long instanceId, String projectId) throws Exception { // 批量校验导入的点位 }
界面手动增删改调单条校验(validateDriverInstanceAttr / validateDriverPointAttr);导入整份工程文件时调批量校验(带 Batch 后缀的方法),需要把每条的错误信息填入 ImportResult,让用户看到哪些行有问题。
当前版本只有内嵌模式可用——驱动代码编译进主工程 jar,随平台一起启动。DriverAccessMode.EXTERNAL(外置模式,愿景是 jar 上传加载)在框架里有枚举和预留骨架,但 DriverAccessLoader.watch() 方法标注 //todo 尚未实现,所以这一章只讲内嵌模式的上线流程。
- 写代码:在
drivers/inline/<驱动名>/access/下实现DriverAccess - 登记 SPI:在
META-INF/services/com.sctech.opti.cloud.drivers.sdk.DriverAccess文件里加一行你的实现类全路径 - 数据库初始化:编写并执行初始化 SQL,向
iot_driver/iot_driver_attribute/iot_driver_point_attribute三张表插入驱动元数据(详见下方,这一步有门槛) - 编译启动:
mvn package打包,启动平台后日志会打印「加载到驱动: xxx」即表示成功
代码和 SPI 都对了,启动日志也显示驱动加载成功,但前端就是配不了实例、点位属性都是空的——99% 是忘了执行数据库初始化 SQL。驱动的属性元数据(实例有哪些配置项、点位有哪些协议参数)全部由 SQL 定义,不写 SQL 前端就没有配置界面。
内置驱动上线,必须编写一段初始化 SQL,把驱动的元数据注册到数据库。这段 SQL 要往三张表插入数据:
| 表 | 作用 | 举例 |
|---|---|---|
iot_driver | 驱动主记录 | 名称 bacnetip、版本、权限开关(允许谁建/改/删实例、设备、点位) |
iot_driver_attribute | 实例属性元数据 | 定义这个驱动有哪些实例配置项:host(IP)、port(端口)、slaveId(从站号)… |
iot_driver_point_attribute | 点位属性元数据 | 定义每个点位有哪些协议参数:寄存器地址、功能码、对象类型… |
你在 SQL 里定义的每个属性(attr_name、data_type、option_min/max、option_element_type 等),平台会据此自动生成前端的配置表单——是输入框还是下拉、必填还是选填、取值范围多少。所以 SQL 不仅是数据,更是 UI 的定义。这也正是未来声明式元数据要取代的东西(驱动用 SDK 声明,平台自动入库,不用手写 SQL)。
因为一个驱动要为每个项目都插入一套记录,所以 SQL 通常用存储过程 + 游标遍历所有项目的方式,并做幂等保护(已存在则跳过)。骨架如下:
-- 1. 遍历所有项目,为每个项目插入驱动记录 CREATE PROCEDURE proc_insert_xxx_driver() BEGIN DECLARE v_project_id, v_driver_id ...; DECLARE cur CURSOR FOR SELECT id FROM `ibms-system`.sys_project; OPEN cur; read_loop: LOOP FETCH cur INTO v_project_id; -- 2. 幂等保护:该项目下已有此驱动则跳过 IF NOT EXISTS (SELECT 1 FROM iot_driver WHERE driver_name='xxx' AND project_id=v_project_id) THEN -- 3. 插入 iot_driver(驱动主记录 + 权限开关) INSERT INTO iot_driver(driver_name, driver_version, ..., project_id) VALUES('xxx', 'v 1.0.0', ..., v_project_id); SET v_driver_id = LAST_INSERT_ID(); -- 拿到刚插入的driver_id -- 4. 插入 iot_driver_attribute(实例属性,如 host/port/slaveId) -- attr_name 要和代码里 getDriverInstanceAttrs 读取的 key 一致! INSERT INTO iot_driver_attribute (driver_id, attr_display_name, attr_name, data_type, option_check_type, option_required, option_max, option_min, ...) VALUES (v_driver_id, 'IP地址', 'host', 'string', 'ip', 1, NULL, NULL, ...), (v_driver_id, '端口', 'port', 'int', 'port', 1, 65535, 1, ...), (v_driver_id, '从站号', 'slaveId', 'int', 'int', 1, 247, 1, ...); -- 5. 插入 iot_driver_point_attribute(点位属性,如寄存器地址、功能码) INSERT INTO iot_driver_point_attribute (attr_display_name, attr_name, data_type, driver_id, ...) VALUES (...); END IF; END LOOP; CLOSE cur; END -- 执行后清理 CALL proc_insert_xxx_driver(); DROP PROCEDURE IF EXISTS proc_insert_xxx_driver;
不要从零写——参考驱动设计文档各驱动目录下的初始化 SQL(如 BACnet/BACnet IP驱动_初始化SQL.sql),复制一份改成你的驱动名和属性即可。重点改三处:① driver_name(和 getDriverName() 一致);② 实例属性列表(你的驱动需要哪些配置);③ 点位属性列表(你的协议需要哪些参数)。
这是整个上线流程里最容易出错、且出错最难查的环节。三处命名必须完全一致:
.attr_name = 'host'
取配置值
="IP地址"
SQL 里的 attr_name、代码里 equals(...) 的字符串、前端显示用的是 attr_display_name——前两者必须字字相同,不一致则配置读不到值(且不报错,只返回 null)。改驱动时三处要同步修改。
- 开发调试方便,IDE 里可直接断点
- 部署简单,驱动随主应用一起发布
- 性能好,驱动和平台同进程,无通信开销
- 故障排查直接,日志在一起
- 更新驱动需要重新打包、重启平台
- 驱动 bug 理论上可能影响主进程(靠规范和异常处理规避)
- 第三方驱动需并入主工程源码(外置模式实现后可解决)
DriverAccessMode 枚举定义了 EXTERNAL("external", "外置模式"),设计愿景是让 SDK 独立成包、驱动打成 jar 上传平台加载(详见第 3 章设计理念),支持热加载、隔离故障、声明式元数据自动入库。框架的 DriverAccessLoader 里预留了 watch() 方法骨架(监听驱动目录),但目前是 //todo 状态,未实现。
如果你在旧文档或网上看到「外置 jar 热插拔」「配置 driver.external.path」「打包 jar 放目录自动加载」这类描述,那些都是规划中的能力,当前版本不可用。现在统一用内嵌模式开发。外置模式实现后本文档会同步更新。
启动平台后看日志,DriverAccessLoader 会打印 加载到驱动: {name}, 版本: {version}, 接入方式:{内嵌模式}。如果看不到你的驱动,按FAQ Q2排查 SPI 文件。加载成功后,驱动的 init()、定时任务、HTTP 接口都会自动就绪。
这一节是踩坑总结。开发时未必每条都用得上,但出问题时回来翻,大概率能找到答案。
| 级别 | 什么时候用 | 新人提示 |
|---|---|---|
DEBUG | 详细调试信息:参数值、中间状态 | 排查问题时临时开,平时关 |
INFO | 重要运行信息:初始化完成、实例开关 | 关键节点都要打,方便定位 |
WARN | 不影响运行但需注意的情况 | 断线重试、配置异常等 |
ERROR | 影响功能正常使用的异常 | 必须带完整堆栈 |
① 不要在循环里打日志(会刷屏);② 异常日志要记录完整堆栈 log.error("xxx", e);③ 敏感信息(密码、密钥)绝不打印;④ 生产环境关闭 DEBUG。
- 捕获后必须处理,不能吞掉
- 向上抛的异常要有明确含义
- 记录异常要带上下文信息
- HTTP 接口异常返回友好提示
- 及时释放不用的资源(连接、句柄)
- 用 try-with-resources 自动关闭
- 在 closeDriverInstance 里清理
- 定期检查资源泄漏
- 共享资源必须加锁保护
- 避免死锁,按固定顺序获取锁
- 用线程安全集合(如
ConcurrentHashMap) - 合理使用平台线程池,避免任务堆积
// 连接管理器示例:用 ConcurrentHashMap 保证线程安全 // 注意:这是驱动的普通工具类,不要加 @Component 等 Spring 注解 // 驱动自己 new 出来用(如 static 实例),靠 SPI 加载而非 Spring 容器 public class ConnectionManager { private static final ConnectionManager INSTANCE = new ConnectionManager(); public static ConnectionManager getInstance() { return INSTANCE; } private final ConcurrentHashMap<Long, Connection> connections = new ConcurrentHashMap<>(); public Connection getConnection(Long instanceId) { return connections.get(instanceId); } public void removeConnection(Long instanceId) { Connection conn = connections.remove(instanceId); if (conn != null) conn.close(); } }
不矛盾。驱动的管理类、工具类可以正常写(普通 Java 类 + Lombok),只是不能加 Spring 注解、不能让 Spring 容器管理。上面的 ConnectionManager 用静态单例(驱动自己控制生命周期),这是驱动内部类的正确写法——它不依赖 Spring,符合设计原则。
- 驱动定期检查实例连接状态,通过
pushInstanceKeepAlive上报,平台据此判断实例是否存活 - 实例状态用
DriverInstanceStatus枚举:RUNNING(1)运行中、CLOSED(0)已关闭、OFFLINE(3)离线(FAULT(2)已废弃) - 心跳间隔建议 4-10 秒(RCP 驱动用
0/4 * * * * ?每 4 秒)
真实驱动(如 RCP)的心跳任务流程:先 getDriverData() 拿到所有实例 → 逐个检查连接 → 收集状态 → 批量推送:
@Override public void execute() { log.debug("心跳检测开始"); try { // 通过 PlatformClient 获取当前驱动的全部实例 List<DriverData> driverDataList = platformClient.getDriverData(); List<InstanceStatusToPush> statusList = new ArrayList<>(); for (DriverData data : driverDataList) { for (DriverInstance inst : data.getInstances()) { // 检查该实例连接是否健康(各驱动自己实现) DriverInstanceStatus status = checkConnectionAlive(inst); InstanceStatusToPush s = new InstanceStatusToPush(); s.setInstanceId(inst.getInstanceId()); s.setStatus(status); statusList.add(s); } } if (!statusList.isEmpty()) { platformClient.pushInstanceKeepAlive(statusList); } } catch (Exception e) { log.error("心跳检测失败", e); } }
不要自己维护一个「实例列表」——用 platformClient.getDriverData() 从平台获取,这样用户在前端新增/删除实例后,心跳任务能立刻感知到。各驱动只需实现 checkConnectionAlive 判断单个实例是否连得上。
这一节收集了新人最常问的问题。开发卡住时,先来这里找答案,能省下大量问人和排查的时间。
驱动名称必须与数据库中配置的名称完全一致(大小写敏感)。这是平台把「数据库配置」和「你的代码」对应起来的唯一线索。
命名规范:
- 只能包含小写字母、数字、下划线
- 不能用特殊字符和空格
- 建议用有意义的英文名,如
modbus、rcp - 长度 ≤ 50 字符
排查思路:驱动日志显示加载成功,但实例打不开 → 99% 是这个名称和数据库对不上。
这是新人第一大坑,几乎每个人都踩过。按这个顺序排查:
- SPI 声明文件:
META-INF/services/com.sctech.opti.cloud.drivers.sdk.DriverAccess存不存在?文件名对不对? - 文件内容:里面的实现类全路径对不对?有没有拼写错误?
- 类路径:实现类能不能被类加载器找到?
- 编译:代码有没有编译错误?target 目录里有没有生成对应的 class?
SPI 加载失败是静默的——不会报错,只是不加载。所以一定要逐项检查。
- 内嵌驱动:直接在 IDE 里打断点调试,最方便
- 外置驱动:把日志级别调成 DEBUG 排查,或用 IDE 远程调试
调试技巧:
- 在关键位置加断点(init、openInstance、读写数据)
- 用 IDE 远程调试外置驱动
- 开启 DEBUG 日志看执行流程
- 用 Wireshark 抓包分析网络协议
不用记,记住几个常用的就够了(格式:秒 分 时 日 月 周):
0/4 * * * * ?— 每 4 秒(心跳常用)0 0/10 * * * ?— 每 10 分钟0 0 * * * ?— 每小时整点0 0 12 * * ?— 每天 12 点
在 getCronExpression() 里返回对应字符串即可。crontab.guru 这类网站可以在线测试表达式。
用 platformClient.pushPointValue(List<Point>) 批量上报。
注意事项:
- 建议攒一批再上报,提高性能
- 上报前设好
curValue(当前值)和statusFlag(质量码) - 质量码
statusFlag:代码中正常数据通常设为1(参考各驱动setStatusFlag(1)的用法),异常时按驱动约定设置 - 历史数据用
uploadHistoryData(List<HistoryPointValue>),告警用pushAlarm(DriversAlarm) - 避免过于频繁上报,可设上报间隔(如 1 秒)
不建议用本地配置文件。驱动的配置应该通过驱动实例属性(DriverInstanceAttr)管理,由平台统一存储维护。
推荐做法:
- 不用本地配置文件
- 所有配置通过 DriverInstanceAttr 传递
- 在
validateDriverInstanceAttr里校验合法性 - 配置变更通过
driverInstanceHasUpdated通知驱动
采用「最大重试次数 + 重试间隔」的策略。关键:用循环而非递归(递归重试会导致调用栈堆积),且重试要在独立任务里做,不要阻塞心跳等调度线程:
public class ReconnectManager { private static final int MAX_RETRY = 10; private static final long RETRY_INTERVAL_MS = 5000; // 5秒 // 用循环重试,避免递归导致栈堆积 public boolean reconnect(Connection conn) { int retryCount = 0; while (retryCount < MAX_RETRY) { try { Thread.sleep(RETRY_INTERVAL_MS); conn.connect(); log.info("重连成功"); return true; } catch (Exception e) { retryCount++; log.warn("第 {} 次重连失败: {}", retryCount, e.getMessage()); } } log.error("达到最大重连次数 {},放弃", MAX_RETRY); return false; } }
① 别用递归重试:catch 里再调 reconnect() 会让调用栈不断堆积,重试多次可能栈溢出,用 while 循环。② 别在心跳任务里同步重连:心跳任务每 4 秒跑一次,如果在这里 Thread.sleep 重连会阻塞调度线程。正确做法是把重连作为一次性异步任务提交到 platformClient.getDriverThreadPoolExecutor(),让它在独立线程跑,心跳任务只负责检测和触发。
数据校验措施:
- 读取后做范围校验(最小值、最大值)
- 检查数据类型是否匹配
- 验证数据质量码(statusFlag)
- 对异常数据过滤或标记
- 支持滤波处理(去抖动、平均值等)