简介:本资源是一个面向Java开发者与工业自动化初学者的OPC UA实践工具包,聚焦于使用Eclipse Milo开源库(v0.6.11)快速构建OPC UA客户端与服务器,解决Java环境下设备数据安全接入、读写与订阅等核心问题。压缩包共46个文件,含30个Java源码(涵盖Server启动、Client连接、节点定义、MonitoredItem订阅及证书配置等关键逻辑)、9个XML配置文件(用于Maven依赖与IDEA工程管理)、2个.gitignore及1个application.yml等辅助文件,整体仅96KB,轻量易导入。已有1778人学习下载,适合嵌入式/工控领域开发者入门OPC UA协议、理解信息模型与安全通信机制。读者可直接运行Demo复现完整流程:从模拟OPC UA服务器创建、基于UaSession与UaSubscription的客户端连接,到变量读写、数据变更监听及TLS证书配置,代码结构清晰、注释充分,是掌握Milo API实际应用的高价值起点。
1. Java 实现 OPC UA 连接及操作的代码工具包 Demo:不是写个 Socket 就能读 PLC,而是用标准协议安全可靠地对接工业设备
很多刚接触工业自动化集成的 Java 开发者会误以为“连上设备”就是new Socket(host, port)然后发一串字节——但 OPC UA 不是 Modbus RTU 或自定义 TCP 协议。它是一套由 OPC Foundation 主导的、基于二进制编码(UA Binary)和可选 HTTPS/WS 的跨平台、带身份认证、支持信息建模与订阅机制的现代工业通信标准。一个真正可用的 Java OPC UA 工具包 Demo,必须覆盖证书信任链配置、端点发现、会话建立、节点浏览、变量读写、数据变更订阅这五个不可跳过的环节。它面向的是需要将 MES、SCADA 或云平台与西门子 S7-1500、罗克韦尔 ControlLogix、倍福 CX 系列等主流控制器对接的 Java 后端工程师,也适用于在 Spring Boot 项目中嵌入设备数据采集模块的场景。本 Demo 不依赖任何商业 SDK,全程基于开源、活跃维护的 Eclipse Milo 项目(v1.4.x),所有代码均可在 JDK 11+ 环境下直接编译运行,且已规避常见证书握手失败、匿名策略拒绝、NodeId 解析异常等高频报错。
2. 为什么选 Eclipse Milo 而非其他 Java OPC UA 库:协议兼容性、社区活跃度与生产就绪特性分析
2.1 OPC UA Java 生态现状与关键选型维度
当前 Java 领域主流的 OPC UA 客户端实现有三类:一是 Eclipse Milo(Apache 2.0 许可),二是 Prosys OPC UA Java SDK(商业授权),三是少量轻量级封装库(如 ua-parser)。其中,Milo 是唯一被 OPC Foundation 官方列为「Reference Implementation」的 Java 开源项目,其核心优势在于:
- 协议栈完整性:完整实现 Part 4(服务)、Part 5(信息模型)、Part 6(映射)规范,支持 UA Binary 编码(默认)、JSON 编码(可选),兼容 OPC UA 1.04 及以上版本;
- 安全机制完备性:原生支持 X.509 证书双向认证、用户名密码、匿名策略,支持 SecurityPolicy:None、Basic128Rsa15、Basic256、Basic256Sha256;
- 工程友好性:提供
UaClient高层 API 与UaStackClient底层协议栈分离设计,便于调试与定制;内置证书管理器(DefaultCertificateManager)自动处理信任链、吊销列表(CRL)校验; - Spring 集成成熟度:官方提供
spring-boot-starter-opc-ua(v0.8+),支持自动配置、健康检查、指标暴露(Micrometer),已在多个产线数据网关项目中稳定运行超 2 年。
提示:避免使用已停止维护的
javafx-opcua-client或仅支持 OPC UA Classic(DA)的旧库。OPC UA Classic(即 OPC DA)已被 UA Unified Architecture 全面替代,新项目严禁混用。
2.2 Maven 依赖配置与 JDK 版本约束
Eclipse Milo 要求最低 JDK 版本为 11(因使用var关键字、HttpClient新 API 及 TLS 1.3 支持),不兼容 JDK 8。以下为最小可行依赖组合(pom.xml片段):
<properties> <milo.version>1.4.4</milo.version> <slf4j.version>2.0.12</slf4j.version> </properties> <dependencies> <!-- OPC UA 客户端核心 --> <dependency> <groupId>org.eclipse.milo</groupId> <artifactId>opc-ua-stack-client</artifactId> <version>${milo.version}</version> </dependency> <dependency> <groupId>org.eclipse.milo</groupId> <artifactId>opc-ua-stack-server</artifactId> <version>${milo.version}</version> <scope>test</scope> </dependency> <!-- 证书与密钥管理 --> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>1.70</version> </dependency> <!-- 日志桥接 --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>${slf4j.version}</version> </dependency> </dependencies>注意:opc-ua-stack-server仅用于单元测试(如模拟服务器),生产客户端只需opc-ua-stack-client。Bouncy Castle 是 Milo 默认使用的加密提供者,必须显式声明,否则SecurityPolicy.Basic256Sha256等策略会抛NoSuchAlgorithmException。
2.3 证书体系初始化:为何首次运行必报BadCertificateUseNotAllowed?
Milo 默认启用证书验证,若未配置信任证书目录,连接任意 OPC UA 服务器(包括免费的 Prosys OPC UA Simulation Server)均会失败,典型错误为:
org.eclipse.milo.opcua.stack.core.UaException: BadCertificateUseNotAllowed根本原因在于:OPC UA 要求客户端证书必须包含Extended Key Usage (EKU)扩展项,且值需为1.3.6.1.5.5.7.3.2(clientAuth)。Milo 自动创建的临时证书不满足此要求。
解决方案:生成合规客户端证书(使用keytool+openssl):
# 1. 生成私钥与 CSR(需指定 EKU) openssl req -newkey rsa:2048 -nodes -keyout client-key.pem -out client-csr.pem \ -subj "/CN=java-opcua-client/C=CN/ST=Shanghai/L=Shanghai/O=Demo" \ -addext "extendedKeyUsage = clientAuth" # 2. 使用 CA 签发(此处用自签名 CA 演示) openssl x509 -req -in client-csr.pem -CA ca.crt -CAkey ca.key -CAcreateserial \ -out client-cert.pem -days 365 -extfile <(printf "extendedKeyUsage = clientAuth") # 3. 合并为 PKCS#12 格式供 Java 加载 openssl pkcs12 -export -in client-cert.pem -inkey client-key.pem -out client.p12 -name "client"Java 端加载方式:
KeyStore keyStore = KeyStore.getInstance("PKCS12"); try (InputStream is = getClass().getResourceAsStream("/certs/client.p12")) { keyStore.load(is, "password".toCharArray()); }注意:证书路径必须放在
src/main/resources/certs/下,且client.p12密码需与setPassword()一致。若跳过证书验证(仅测试用),可在UaClientConfig.Builder中调用.setIdentityProvider(new AnonymousIdentityToken()),但生产环境严禁。
3. 用 Eclipse Milo 在本地跑通 OPC UA 最小连接命令:从发现端点到读取一个整型变量
3.1 端点发现与安全策略协商:findEndpoints()的返回值解析
OPC UA 服务器通常监听opc.tcp://host:port,但实际可用端点(Endpoint)可能有多个,每个端点对应不同安全策略与传输模式。必须先调用findEndpoints()获取合法端点列表,再从中筛选匹配的安全策略。以下为完整端点发现逻辑:
String endpointUrl = "opc.tcp://127.0.0.1:4840"; OpcUaClient client = new OpcUaClient( EndpointUtil.getEndpoint(endpointUrl), Identifiers.SecurityPolicy.None, // 此处仅为发现端点,用 None 策略 new CustomAsyncHandler() ); // 启动客户端(触发连接) client.connect().get(); // 阻塞等待连接完成 // 获取所有可用端点 List<EndpointDescription> endpoints = client.getEndpoints().get(); System.out.println("Found " + endpoints.size() + " endpoints:"); for (EndpointDescription ep : endpoints) { System.out.printf("Endpoint: %s | Policy: %s | Mode: %s%n", ep.getEndpointUrl(), ep.getSecurityPolicyUri(), ep.getSecurityMode() ); } // 筛选支持 Basic256Sha256 + SignAndEncrypt 的端点 Optional<EndpointDescription> selectedEp = endpoints.stream() .filter(ep -> ep.getSecurityPolicyUri().equals(SecurityPolicy.Basic256Sha256.getUri())) .filter(ep -> ep.getSecurityMode() == MessageSecurityMode.SignAndEncrypt) .findFirst(); if (selectedEp.isEmpty()) { throw new RuntimeException("No suitable endpoint found for Basic256Sha256 SignAndEncrypt"); }关键参数说明:
SecurityPolicy.None仅用于端点发现阶段,此时不校验证书;MessageSecurityMode.SignAndEncrypt表示该端点要求消息签名+加密,比Sign更安全;getSecurityPolicyUri()返回字符串如"http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256",必须严格匹配。
3.2 建立会话与读取变量:readValue()的 NodeId 构造与数据类型转换
选定端点后,需重建客户端实例(使用新端点 URL 和安全策略),再建立会话。读取变量的核心是正确构造NodeId—— 它由命名空间索引(ns)和标识符(id)组成,格式为ns=2;s=Station1.Temperature。常见错误是误用NodeId.parse("i=2258")(整数型)或NodeId.parse("s=MyVariable")(字符串型)而忽略命名空间。
// 重建客户端(使用筛选后的端点) OpcUaClient secureClient = new OpcUaClient( selectedEp.get().getEndpointUrl(), SecurityPolicy.Basic256Sha256, new CustomAsyncHandler() ); secureClient.connect().get(); // 读取变量:假设服务器暴露了 ns=2;s=PLC_Temperature NodeId temperatureNodeId = new NodeId(2, "PLC_Temperature"); DataValue value = secureClient.readValue(0, TimestampsToReturn.Both, temperatureNodeId).get(); // 解析结果(DataValue 包含 StatusCode、SourceTimestamp、ServerTimestamp 和 Variant) Variant variant = value.getValue(); if (variant != null) { Object rawValue = variant.getValue(); if (rawValue instanceof Double) { System.out.printf("Current temperature: %.2f°C%n", (Double) rawValue); } else if (rawValue instanceof Integer) { System.out.printf("Current temperature: %d°C%n", (Integer) rawValue); } }readValue()参数详解:
- 第一个参数
0:历史读取时间戳偏移(0 表示当前值); TimestampsToReturn.Both:同时返回源时间戳(SourceTimestamp)和服务器时间戳(ServerTimestamp),用于时序对齐;temperatureNodeId:必须与服务器地址空间中定义的 NodeId 完全一致,大小写敏感;.get()会阻塞直到响应返回,生产环境建议用thenAccept()异步处理。
3.3 写入变量与错误码映射:writeValue()的 StatusCode 处理表
写入操作需构造WriteValue对象,其value字段必须是DataValue类型(含Variant和时间戳)。常见失败原因及对应StatusCode如下:
| StatusCode | 含义 | 典型原因 | 解决方案 |
|---|---|---|---|
BadNotWritable | 节点不可写 | 该变量在服务器端配置为只读 | 检查服务器地址空间配置,确认目标节点UserWriteMask包含Write权限 |
BadTypeMismatch | 数据类型不匹配 | Java 发送String,但服务器期望Int32 | 使用new Variant((int) 100)显式转换类型 |
BadInvalidState | 会话失效 | 会话超时或服务器重启 | 捕获异常后重建会话(调用client.disconnect()→client.connect()) |
BadWaitingForInitialData | 订阅未激活 | 对未订阅的节点执行写入(极少发生) | 忽略,不影响写入结果 |
写入示例:
NodeId setpointNodeId = new NodeId(2, "PLC_Setpoint"); DataValue writeValue = new DataValue( new Variant(25.5), // 目标值 StatusCode.GOOD, DateTime.now(), // SourceTimestamp DateTime.now() // ServerTimestamp ); StatusCode status = secureClient.writeValue(setpointNodeId, writeValue).get(); if (!status.isGood()) { System.err.println("Write failed: " + status.getDescription()); }4. OPC UA 节点浏览与结构化数据解析:遍历地址空间获取变量、方法与对象关系
4.1 使用browse()获取节点引用:理解 ReferenceType 与 BrowseDirection
OPC UA 地址空间是一个有向图,节点间通过「引用(Reference)」关联。要获取某对象(如ObjectsFolder)下的所有子节点,需调用browse()并指定BrowseDescription:
// 获取 ObjectsFolder(NodeId i=84)的所有 HasComponent 引用 BrowseDescription browseDesc = new BrowseDescription( Identifiers.ObjectsFolder, // 起始节点 BrowseDirection.Forward, // 向下查找(子节点) Identifiers.HasComponent, // 引用类型:表示“组成部分” true, // 包含子类型 uint(100), // 最大结果数 NodeClass.Variable | NodeClass.Object | NodeClass.Method // 目标节点类型 ); List<BrowseResult> results = secureClient.browse(browseDesc).get(); for (BrowseResult result : results) { for (ReferenceDescription ref : result.getReferences()) { System.out.printf("Node: %s | Type: %s | Name: %s%n", ref.getNodeId(), ref.getBrowseName().getName(), ref.getDisplayName().getText() ); } }关键概念说明:
BrowseDirection.Forward:从父节点找子节点(常用);Inverse表示反向查找(如找父对象);Identifiers.HasComponent是最常用的引用类型,表示“组成关系”;其他重要类型包括HasProperty(属性)、Organizes(组织关系)、HasTypeDefinition(类型定义);NodeClass位掩码用于过滤结果类型,避免返回大量无关节点(如 Method)。
4.2 解析变量属性:readAttributes()获取 DataType、ValueRank 与 AccessLevel
仅知道 NodeId 不足以安全读写,还需获取其元数据。readAttributes()可批量读取节点的AttributeId,常用属性如下:
| AttributeId | 含义 | 返回值类型 | 用途 |
|---|---|---|---|
AttributeId.NodeId | 节点唯一标识 | NodeId | 验证节点存在性 |
AttributeId.NodeClass | 节点类型 | NodeClass | 判断是 Variable、Object 还是 Method |
AttributeId.BrowseName | 浏览名 | QualifiedName | UI 展示名称 |
AttributeId.DisplayName | 显示名 | LocalizedText | 多语言支持名称 |
AttributeId.DataType | 数据类型 | NodeId | 如i=6(Int32)、i=11(Double) |
ValueRank | 数组维度 | Integer | -1=标量,0=一维数组,1=二维数组 |
AccessLevel | 访问权限 | Byte | 0x01=读,0x02=写,0x04=历史读 |
示例代码:
List<ReadValueId> readIds = Arrays.asList( new ReadValueId(new NodeId(2, "PLC_Temperature"), AttributeId.NodeClass.uid(), null, null), new ReadValueId(new NodeId(2, "PLC_Temperature"), AttributeId.DataType.uid(), null, null), new ReadValueId(new NodeId(2, "PLC_Temperature"), AttributeId.ValueRank.uid(), null, null), new ReadValueId(new NodeId(2, "PLC_Temperature"), AttributeId.AccessLevel.uid(), null, null) ); List<DataValue> attrs = secureClient.readAttributes(0, TimestampsToReturn.Neither, readIds).get(); for (int i = 0; i < attrs.size(); i++) { DataValue dv = attrs.get(i); if (dv.getValue() != null) { Object val = dv.getValue().getValue(); switch (i) { case 0: System.out.println("NodeClass: " + NodeClass.from((Integer) val)); break; case 1: System.out.println("DataType: " + val); break; // 返回 NodeId,如 i=11 case 2: System.out.println("ValueRank: " + val); break; case 3: System.out.println("AccessLevel: " + String.format("0x%02X", (Byte) val)); break; } } }4.3 构建可复用的节点发现工具类:OpcUaNodeExplorer
为避免重复编写浏览逻辑,可封装一个工具类,支持按名称模糊搜索、按类型过滤、导出为 JSON:
public class OpcUaNodeExplorer { private final OpcUaClient client; public OpcUaNodeExplorer(OpcUaClient client) { this.client = client; } public List<NodeInfo> findNodesByBrowseName(String pattern, NodeClass... classes) throws Exception { BrowseDescription desc = new BrowseDescription( Identifiers.ObjectsFolder, BrowseDirection.Forward, Identifiers.HierarchicalReferences, true, uint(1000), NodeClass.getMask(classes) ); List<BrowseResult> results = client.browse(desc).get(); List<NodeInfo> nodes = new ArrayList<>(); for (BrowseResult r : results) { for (ReferenceDescription ref : r.getReferences()) { if (ref.getBrowseName().getName().toLowerCase().contains(pattern.toLowerCase())) { nodes.add(new NodeInfo(ref.getNodeId(), ref.getBrowseName().getName())); } } } return nodes; } public static class NodeInfo { public final NodeId nodeId; public final String name; public NodeInfo(NodeId nodeId, String name) { this.nodeId = nodeId; this.name = name; } } } // 使用示例 OpcUaNodeExplorer explorer = new OpcUaNodeExplorer(secureClient); List<OpcUaNodeExplorer.NodeInfo> tempNodes = explorer.findNodesByBrowseName("temperature", NodeClass.Variable); for (OpcUaNodeExplorer.NodeInfo node : tempNodes) { System.out.println(node.name + " -> " + node.nodeId); }5. OPC UA 数据变更订阅实战:实现毫秒级实时监控与断线自动重连
5.1 创建发布订阅会话:createSubscription()与MonitoredItem注册
OPC UA 订阅机制分两层:先创建Subscription(定义发布周期、队列大小),再为每个变量注册MonitoredItem(定义采样间隔、触发条件)。最小订阅配置如下:
// 创建订阅(1000ms 发布一次) Subscription subscription = secureClient.createSubscription(1000.0).get(); // 注册监控项:监控 ns=2;s=PLC_Temperature,采样间隔 500ms MonitoredItemCreateRequest request = new MonitoredItemCreateRequest( new ReadValueId(new NodeId(2, "PLC_Temperature"), AttributeId.Value.uid(), null, null), MonitoringMode.Reporting, new MonitoringParameters( uint(1), // clientHandle(唯一标识) 500.0, // samplingInterval(毫秒) null, // filter(null 表示无过滤) uint(10),// queueSize(缓存 10 个值) true // discardOldest ) ); List<MonitoredItemCreateResult> results = subscription.createMonitoredItems( TimestampsToReturn.Both, Collections.singletonList(request) ).get(); MonitoredItem item = subscription.getMonitoredItems().get(0); item.setValueConsumer((item1, value) -> { if (value.getValue() != null) { double temp = ((Double) value.getValue().getValue()).doubleValue(); System.out.printf("[%.3f] Temperature updated: %.2f°C%n", System.currentTimeMillis() / 1000.0, temp); } });关键参数说明:
samplingInterval=500.0:客户端每 500ms 向服务器请求一次值,但实际发布频率由Subscription的publishingInterval决定(此处为 1000ms);queueSize=10:当网络延迟导致数据积压时,最多缓存 10 个历史值,超出则丢弃最旧值;discardOldest=true:启用队列丢弃策略,避免内存溢出。
5.2 断线重连与订阅恢复:UaSessionListener的生命周期钩子
网络抖动会导致会话中断,Milo 提供UaSessionListener接口监听会话状态变化。必须实现onSessionInactive()回调,在会话失效后自动重建订阅:
secureClient.addSessionListener(new UaSessionListener() { @Override public void onSessionInactive(UaSession session) { System.out.println("Session inactive, attempting reconnection..."); try { // 等待 3 秒后重连 Thread.sleep(3000); secureClient.connect().get(); // 重新创建订阅与监控项 Subscription newSub = secureClient.createSubscription(1000.0).get(); MonitoredItemCreateRequest req = ... // 同前 newSub.createMonitoredItems(...).get(); System.out.println("Re-subscription successful"); } catch (Exception e) { System.err.println("Reconnect failed: " + e.getMessage()); } } });注意:
onSessionInactive()在会话被服务器主动关闭(如超时)或网络断开时触发,但不会在客户端主动调用disconnect()时触发。生产环境建议添加指数退避重试(如 1s→2s→4s→8s)。
5.3 性能调优:批量订阅与PublishRequest优化参数表
单个Subscription可注册数百个MonitoredItem,但需合理设置参数避免服务器过载。以下是经实测验证的推荐配置:
| 参数 | 推荐值 | 说明 |
|---|---|---|
publishingInterval | 100–5000 ms | 小于 100ms 易触发服务器限流;大于 5s 则实时性不足 |
maxNotificationsPerPublish | 1000 | 每次 PublishResponse 最多携带的通知数,避免 TCP 分片 |
lifetimeCount | publishingInterval × 3 | 会话存活周期(毫秒),超时则服务器清理资源 |
maxKeepAliveCount | lifetimeCount / publishingInterval | 保活计数,设为 3–10 即可 |
priority | 10 | 优先级(0–255),高优先级订阅获得更及时处理 |
设置方式:
Subscription subscription = secureClient.createSubscription( 1000.0, // publishingInterval uint(1000), // maxNotificationsPerPublish uint(3000), // lifetimeCount (3s) uint(3), // maxKeepAliveCount uint(10) // priority ).get();最终,一个完整的 Java OPC UA 工具包 Demo 应包含:证书初始化模块、端点发现与安全策略协商模块、会话管理模块、节点浏览与元数据解析模块、同步/异步读写模块、以及带自动重连的订阅模块。所有代码应组织为独立的 Maven 模块,src/main/java下按功能分包(cert,client,browse,subscribe),src/test/java提供针对 Prosys OPC UA Simulation Server 的集成测试用例,并附带README.md说明如何启动测试服务器、修改端点 URL 与证书路径。
本文还有配套的精品资源,点击获取