简介面向Java工业自动化开发者的OPC通信示例包围绕JOPC与JeasyOPC连接OPC Server、读取写入及订阅数据变化展开适合需要把Java程序接入工业控制系统的初中级开发者。压缩包约1.99MB共860个文件包含java源码、class编译文件、jar依赖、dll组件以及xml、properties、cfg等配置与SVN版本管理文件另有pas、dcu等辅助资料便于对照工程结构和运行依赖。已有1645人学习下载。内容中可见多个连接示例与内存泄漏排查示例线索可帮助读者理解连接建立、项订阅、事件回调与资源释放等关键环节并参考配置和依赖组织方式排查环境问题快速搭建可调试的OPC客户端样例。1. Java 连 OPC Server 这件事难在 DCOM 而不在代码前阵子帮一个做产线数采的项目救火现场情况很典型一台 Windows 工控机上跑着 OPC Server采集设备数据业务侧的 Java 服务部署在另一台 Linux 机器上。甲方要求 Java 直接连 OPC Server 获取数据理由是不想再加一台网关机也不想改动已经跑了几年的 Server 配置。标题里那套 JOPC、JEasyOpc 的组合就是在这种“只能用 Java、只能走 OPC DA”的夹缝里被翻出来的。JEasyOpc 后来并入 OpenSCADA 的 Utgard 项目本质是一套纯 Java 实现的 OPC DA 客户端库底下靠 J-Interop 这个 DCOM 协议栈去跟 Windows 端的 COM 对象握手JOPC 一类项目则是在它之上再包一层的轻量封装。它能解决的核心问题是让跑在 Linux 上的 JVM 进程不加任何中间件、不在本地注册任何 COM 组件就把IOPCServer里的点位值取回来。适合谁手上有一套现成的 OPC DA Server、又没有条件升级到 OPC UA、还想把采集逻辑收进 Java 服务里的团队。不适合谁Server 端已经是 OPC UA 的直接换协议栈更省事。2. 选型与环境前置JEasyOpc、JOPC 与 J-Interop 到底谁在干活很多资料把“Java 连 OPC”讲得像调个 SDK 一样实际拆开看是三层最下面一层是 DCOM 协议本身RPC over TCP135 端口做端点映射然后协商出动态端口中间一层是 J-Interop 把 Java 调用翻译成 DCOM 的 ORPC 请求最上面一层才是 JEasyOpc/Utgard 提供的Server、Group、Item这些面向对象的门面。理解这个分层很关键因为后面 90% 的故障不是 Java 代码写错了而是中间那层协议握手没通过报出来的异常往往只有一句 “Catastrophic failure”跟黑匣子一样。2.1 三条接入路线的边界对比Java 侧要访问 COM现实中就三条路选错了后面全是返工。第一条是 JNI 桥接典型是 JACOB 这类库让 Java 通过 JNI 调IDispatch。优点是协议层完全交给 Windows稳定缺点是 Java 进程必须跑在 Windows 上还得本机注册对应的 COM 组件跨平台这个前提直接没了。第二条是协议网关找一台 Windows 机器跑个网关软件对外暴露 HTTP 或 OPC UAJava 只跟网关说标准协议。这条路最省心代价是多一个进程、多一份授权、多一跳延迟而且现场往往不让再加机器。第三条就是 JEasyOpc/Utgard 这条路纯 Java 实现 DCOM 客户端Java 进程放哪都行只要网络能通到 Server 那台机器的 135 端口和动态 RPC 端口。代价是 DCOM 的认证、模拟级别、权限模型全都得自己在 Windows 那边配好而这些东西在 Java 代码里是看不见的。路线代表实现部署位置主要代价适用场景JNI 桥接JACOB 一类必须 Windows需本机注册 COM采集进程本来就跑在 Server 机上协议网关商用网关软件任意授权成本 多一跳点位多、要求长期稳定纯 Java DCOMJ-Interop JEasyOpc/Utgard任意Windows 侧 DCOM 配置复杂不想加机器、只要 DA 数据至于 JOPC 和 JEasyOpc 怎么选JEasyOpcUtgard的封装更完整Group、AccessBase、SyncAccess这一套把订阅和回调都包好了社区里能搜到的示例也基本围绕它JOPC 这类项目更多是直接把IOPCServer、IOPCItemMgt的代理暴露出来适合你想自己控制调用节奏、或者需要访问 Utgard 没包的接口时用。我一般的做法是主线业务用 Utgard遇到它没覆盖的接口再拿 J-Interop 的代理对象直接调两者用的是同一套底层能混着跑。2.2 Windows 侧必须先配好的四项这部分是全文最容易被跳过、也最容易翻车的地方。代码写完之后连不上八成要回到这里改。第一项是 Server 所在机器的 DCOM 权限。打开组件服务dcomcnfg进“组件服务 → 计算机 → 我的电脑 → 属性”在“默认属性”里看默认身份验证级别和默认模拟级别在“默认安全”里给采集用的那个 Windows 账号加上“访问权限”和“启动与激活权限”。光在 COM 安全里加还不够还得找到 OPC Server 那个具体的 DCOM 应用项单独给它配一遍“身份”和“安全”因为应用级配置会覆盖机器级配置。第二项是账号。这个账号必须是 Server 机器上真实存在的本地账号或域账号密码不能为空。老系统上很常见的一种情况是运维图省事建了个空密码账号本地登录没问题远程 DCOM 一律拒绝报出来的就是0x80070005。另外这个账号要加进Distributed COM Users组有些 Server 还要求它在Performance Monitor Users里。第三项是网络端口。DCOM 用 135 端口找端点映射器之后协商出一个动态端口传数据。防火墙只开 135 是不够的必须在 Server 机器上把动态端口范围限死再把这段范围放行# 在 OPC Server 所在 Windows 机器上用管理员权限执行 # 把动态 RPC 端口限制在 5000-5020共 21 个端口 netsh int ipv4 set dynamicport tcp start5000 num21 # 查看当前设置是否生效 netsh int ipv4 show dynamicport tcp端口范围别设太小一个 Java 客户端连上来占用的端口数量跟建立的 DCOM 引用数相关点位多、分组多的时候 5 个端口肯定不够。改完要重启一下相关服务或者干脆重启机器注册表里的Ports和PortsInternetAvailable才会真正生效。第四项是名称解析。DCOM 对主机名的正反向解析很敏感连接信息里填 IP 有时反而比填主机名稳因为省掉了一次反向查询。我一般会在 Java 客户端的/etc/hosts里把 Server 那台机器的 IP 和主机名硬绑一条避免 DNS 抽风导致的间歇性失败。提示这四项配置改完之后先在 Server 机器上用另一个 Windows 账号跑一下 OPC 客户端确认能连再去调 Java。用 Java 去验证 DCOM 配置是否正确的成本太高了。3. 最小可跑链路从 Maven 坐标到第一个 ItemState前置条件配好之后Java 侧其实很快。这一章先跑通单点读取把整条链路验证一遍再谈批量。3.1 依赖坐标与传递依赖JEasyOpc/Utgard 在 Maven 中央仓库的坐标是org.openscada下的org.openscada.opc.lib。不同版本的传递依赖结构不太一样早一些的版本直接依赖org.jinterop:j-interop后期的版本把 J-Interop fork 到了自己的 groupId 下。所以引入的时候不要只盯着主依赖把依赖树打出来看一眼确认 J-Interop 和 jcifs 这两个包都在。dependency groupIdorg.openscada/groupId artifactIdorg.openscada.opc.lib/artifactId version1.4.0/version /dependency !-- 若依赖树里没有 J-Interop需要显式补上版本与主依赖对齐 -- dependency groupIdorg.openscada.jinterop/groupId artifactIdorg.openscada.jinterop.core/artifactId version1.4.0/version /dependency# 确认传递依赖是否齐全重点看 jinterop 和 jcifs mvn dependency:tree -Dincludesorg.openscada*:*,org.jinterop:*,jcifs:*参数上只有一个坑要提前说主依赖的版本号和 J-Interop 的版本号必须匹配混用不同代次的包会在运行时抛NoSuchMethodError而这种错误往往在第一次connect()的时候才炸出来编译期完全看不出来。所以版本号统一用一个变量管住别一个写 1.3.0 一个写 1.4.0。JDK 版本我一般锁在 8 或者 11再往上的版本因为模块化把一些反射路径封了J-Interop 在某些路径上需要加--add-opens才能跑通。如果项目必须用高版本 JDK先把最小可跑例子跑通再迁业务代码别一上来就在整个工程里调。3.2 ConnectionInformation 的六个字段连接信息全在ConnectionInformation这个类里它决定了 DCOM 握手时用哪个身份、去连哪个对象。字段不多但每一个填错都会得到一个语焉不详的异常。import org.openscada.opc.lib.common.ConnectionInformation; import org.openscada.opc.lib.da.*; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; public class OpcProbe { public static void main(String[] args) throws Exception { ConnectionInformation ci new ConnectionInformation(); ci.setHost(10.20.30.41); // Server 机器 IP优先填 IP 而不是主机名 ci.setDomain(); // 本地账号留空域账号填域名 ci.setUser(opc_reader); // 必须有密码的 Windows 账号 ci.setPassword(ChangeMe#2024); ci.setClsid(A1B2C3D4-0000-0000-0000-000000000000); // 首选直接给 CLSID ci.setProgId(Vendor.DA.Server.1); // 兜底CLSID 拿不到时用 ProgId ScheduledExecutorService scheduler Executors.newScheduledThreadPool(2); Server server new Server(ci, scheduler); try { server.connect(); Group group server.addGroup(probe); Item item group.addItem(Channel1.Device1.Temperature); ItemState state item.read(true); // true 表示绕过缓存强制读设备 System.out.printf(value%s quality%d ts%s%n, state.getValue().getObject(), state.getQuality(), state.getTimestamp().getTime()); } finally { server.disconnect(); scheduler.shutdown(); } } }逐项说明。host填 IP 是为了避开反向解析domain对本地账号留空字符串不要填机器名填了反而会被当成域账号去认证user和password就是 2.2 里配的那个账号clsid和progId二选一优先级是 CLSID 高。为什么优先 CLSID因为 ProgId 的解析需要访问 Server 机器上的远程注册表这意味着Remote Registry服务必须是启动状态而且账号还得有远程读注册表的权限。现场经常是服务被安全策略关掉了用 ProgId 连死活解析不到报REGDB_E_CLASSNOTREG。CLSID 可以从 Server 机器的注册表HKEY_CLASSES_ROOT\ProgId\CLSID下面直接抄出来抄一次就一劳永逸。Server的构造函数第二个参数是一个调度器Utgard 内部用它来跑超时检测和订阅刷新newSingleThreadScheduledExecutor()够用但如果点位多、还开了SyncAccess给两个线程更稳。这个调度器一定要在finally里shutdown否则 JVM 不会退出这是新手最常遇到的一种“程序跑完了但不结束”。3.3 ItemState 里的三个值怎么读ItemState就三个东西值、质量、时间戳。值是一个JIVariant包装getObject()拿到的 Java 类型跟 OPC 的 VARIANT 类型一一对应VT_I2 拿到ShortVT_I4 拿到IntegerVT_R4 拿到FloatVT_BSTR 拿到StringVT_BOOL 在 J-Interop 里可能被表示成Boolean也可能是Short取决于 Server 端的实现。质量码是整数1920xC0是 GOOD640x40是 UNCERTAIN0是 BAD。业务代码里千万别只判值不判质量设备断线的时候很多 Server 返回的是上一次的缓存值配一个 BAD 质量直接入库就是脏数据。时间戳返回的是Calendar注意它是 Server 端的时间。如果 Server 机器的时区和 Java 客户端不一致getTime()出来的Date在展示时会有偏差。稳妥的做法是取getTimeInMillis()之后统一按 UTC 处理展示层再做时区转换。read(true)里那个布尔参数是“是否从设备读”。传false走 Server 的缓存快但可能拿到旧值传true强制下到设备慢一点但准。做调试和首次握手验证时用true批量采集时按业务对实时性的要求来定。4. 从单点读到批量采Group、SyncAccess 与写入回写单点跑通只是验证了链路真正上线是几十上百个点位持续刷新。这一章讲怎么组织分组、怎么用回调接数据、怎么写回去。4.1 SyncAccess 的定时刷新与生命周期Utgard 里最省心的批量方案是SyncAccess它本质是一个定时轮询的封装给定一个周期内部按周期把所有注册的点位读一遍然后回调给你。好处是不用自己管线程池和超时坏处是它不给“变化才推送”的能力点位多的时候网络往返会明显。import org.openscada.opc.lib.da.*; // 周期 1000ms内部按这个节奏轮询所有已注册点位 AccessBase access new SyncAccess(server, 1000); access.addItem(Channel1.Device1.Temperature, new AccessCallback() { Override public void accessItem(String itemId, ItemState state) { int q state.getQuality(); if (q ! 192) { // 非 GOOD 直接跳过不要入库 return; } Object raw state.getValue().getObject(); // 业务处理注意这里跑在 Utgard 的调度线程里别做耗时 IO System.out.println(itemId - raw); } }); access.bind(); // 开始轮询 // ... 业务运行 ... access.unbind(); // 停止轮询addItem的第二个参数是回调回调里跑在调度线程上所以里面绝对不能有阻塞操作——写数据库、发 HTTP 请求这类事情一律丢到自己的业务线程池里。这条不是建议是硬约束回调阻塞会直接把轮询周期拖长表现出来就是“数据越来越不实时”但日志里看不出任何错误。bind()和unbind()的生命周期要跟应用的启动和关闭绑起来。容器环境下要挂在PostConstruct/PreDestroy上或者用SmartLifecycle。见过不止一次因为忘了unbind应用重启时旧线程还在两个实例同时往一个 Server 读Server 的 DCOM 引用数越堆越高最后拒绝新连接。4.2 分组策略与轮询周期的取舍SyncAccess只有一个周期所有点位共用。但现场的点位重要程度差别很大温度、压力这种关键工艺参数希望 500ms 一次而罐存量、批次号这种一分钟一变没必要高频刷。我一般按周期拆成多个AccessBase实例一个周期一个实例各自绑自己的点位。三五个实例对 JVM 完全没压力反而比一个大实例塞几百个点位稳得多因为单次读取的请求体积小了超时概率也低。点位类型建议周期说明关键工艺量温度、压力500~1000ms超过这个频率对业务没意义反而压 Server状态位、报警位1000ms状态变化本身有滞后1s 足够统计量、批次信息5000~30000ms变化慢降频能显著降低 DCOM 往返写入回读确认写入后单次读不要塞进轮询里周期不是越小越好。SyncAccess的周期指的是“上一次轮询结束后等这么久再发起下一次”实际间隔 周期 单次轮询耗时。点位多的时候单次耗时可能就几百毫秒你把周期设成 100ms实际跑出来是 400ms还会把 Server 压得很难受。4.3 写入与类型匹配写点位用的是Item.write参数是一个JIVariant。这里最大的坑是类型必须跟 Server 端点位的 VARIANT 类型严格匹配写错类型不会报错而是悄悄写进去一个错误的值或者干脆被 Server 忽略。Item item group.addItem(Channel1.Device1.SetPoint); // 先读一次确认当前值的实际类型再按同类型写回 ItemState before item.read(true); System.out.println(current type before.getValue().getObject().getClass()); // 常见做法按读到的类型构造 JIVariant而不是凭感觉写 double JIVariant newValue new JIVariant(23.5f); // 对应 VT_R4写 double 会变成 VT_R8 item.write(newValue); // 写完立刻回读确认不要假设写成功了 ItemState after item.read(true); System.out.println(written after.getValue().getObject());write没有返回值也不抛异常表示业务失败只要 DCOM 调用没挂它就返回了。所以生产代码里写操作一定要跟一次回读校验这是唯一的确认手段。写操作还有个权限问题。opc_reader这种只读账号写会得到E_ACCESSDENIED而且这个错误有时候会在下一次读取时才冒出来因为 DCOM 的错误返回被延后处理了。所以读写账号最好分开读用一个、写用另一个出问题的时候能快速定位是哪一侧的权限没配。5. 避坑DCOM 权限、错误码与数据类型的三类翻车这一章的五条都是现场真踩过的按现象、原因、解决的顺序写遇到对应症状可以直接对号入座。5.1 连不上时的错误码对照先给一张表Java 抛出来的异常信息里往往带一个HRESULT对着查比翻日志快得多。HRESULT常见含义优先排查方向0x80070005拒绝访问账号密码、DCOM 访问/启动权限、账号是否在 Distributed COM Users 组0x800706BARPC 服务器不可用135 端口通不通、防火墙动态端口范围、Server 服务是否在跑0x800706BERPC 调用失败身份验证级别不匹配、跨网段、J-Interop 版本与 Server 不兼容0x80040154类未注册CLSID/ProgId 抄错、Remote Registry 被关、Server 已卸载0x8000FFFF灾难性故障认证协商失败、账号无桌面交互权限、Server 端崩溃现象一任何账号都报0x80070005但用 Windows 客户端能连。原因基本是 DCOM 的应用级配置没配。机器级的“默认安全”只对没单独配置的应用生效OPC Server 装完之后通常会在组件服务里注册一个自己的应用项它的权限是独立的一份。解决方式是进组件服务找到那个应用项在“安全”页里把采集账号加进“启动与激活权限”和“访问权限”的自定义列表注意是自定义而不是“使用默认值”。现象二第一次能连上跑几个小时后开始报0x800706BA。这是动态端口泄露的典型症状。DCOM 每次建立引用都会占用一个动态端口如果SyncAccess的unbind没调或者代码里反复new Server而没disconnect端口会被逐渐耗尽最后新的 RPC 连接建不起来。解决办法一是把 2.2 里的端口范围放宽二是严格管住Server的生命周期一个应用进程只持有一个Server实例全生命周期复用。现象三报0x800706BEServer 端事件日志里能看到认证失败。这是身份验证级别不匹配。Windows 的 DCOM 默认验证级别是“连接时验证”某些老版本的 Server 只支持“无验证”两边谈不拢就报这个。做法是在 Server 的应用项里把身份验证级别改成“无”同时把模拟级别设成“标识”或“模拟”。这两项改完必须重启 Server 进程才生效只重启 Java 端没用。现象四读取返回null质量码是0。这不是协议问题是点位 ID 写错了。OPC 的 ItemID 是区分大小写的而且带完整路径Channel1.Device1.Tag这种三段式很常见少一段或者大小写不对Server 会返回一个 BAD 质量的空值而不是抛异常。解决方式是先用server.getFlatBrowser().browse()把全部点位列出来从里面复制 ItemID别手敲。现象五数值读回来是Short类型的 1234业务代码按Double强转直接ClassCastException。这是 VARIANT 类型跟 Java 类型的映射问题。工业设备里 VT_I216 位整数用得极多而 J-Interop 会老老实实映射成Short。做法是写一个统一的数值转换工具把Number、Boolean、String三种来源都收口到一个toDouble方法里所有点位走同一个入口。public static Double toDouble(Object raw) { if (raw null) return null; if (raw instanceof Number) return ((Number) raw).doubleValue(); // Short/Integer/Float 全覆盖 if (raw instanceof Boolean) return ((Boolean) raw) ? 1.0 : 0.0; // VT_BOOL 的两种表示 try { return Double.parseDouble(String.valueOf(raw).trim()); // VT_BSTR 兜底 } catch (NumberFormatException e) { return null; // 转换不了就返回空别抛 } }这个工具方法看着简单但它是防止脏数据入库的最后一道闸。我在项目里会把转换失败的比例做成一个指标打出去某段时间失败率突然涨上去通常意味着有人在 Server 端改了点位类型。注意排查顺序永远是 Windows 侧配置 → 网络端口 → 账号权限 → Java 代码倒过来查会浪费大量时间因为 Java 层的异常信息从来不会告诉你真实原因。6. 上线前的连通性验证与断线重连把代码跑通只是及格线能在一个月无人值守的情况下不掉数据才算过关。我一般会在业务代码之外单独留一个探针程序就做三件事连、读一个已知点位、断开。部署前在目标环境里跑一遍能把 DCOM 配置、端口、账号这三类问题一次性筛掉。探针的判断逻辑很简单——连上之后读一个必定存在的系统点位很多 Server 都有_System._Time这类内置点位能读出 GOOD 质量就说明整条链路是通的跟业务点位无关出问题的时候能立刻区分是“链路挂了”还是“某个点位配错了”。断线重连这块Server.connect()失败会直接抛异常重连逻辑得自己写别指望库有内置的自动恢复。我一般的做法是一个带指数退避的重连循环// 断线重连间隔从 2s 起翻倍到 60s 封顶成功后重置 long backoffMs 2000L; while (!Thread.currentThread().isInterrupted()) { try { server.connect(); access.bind(); // 重连成功后必须重新 bind旧的订阅已经失效 backoffMs 2000L; // 成功后重置退避 break; } catch (Exception e) { Thread.sleep(backoffMs); backoffMs Math.min(backoffMs * 2, 60_000L); } }这段代码里有两处血泪经验。第一处是重连之后必须重新bind()Server断开时内部会把所有 Group 和订阅清掉你之前注册的回调全都失效了不重新绑就是“连上了但没数据”。第二处是退避上限见过有人写成无限翻倍结果一次网络抖动之后程序等了三小时才重连中间数据全丢了。还有一个容易被忽略的点是点位健康度。SyncAccess的回调里我能拿到每个点位的质量码和时间戳把这两样东西按点位维度聚合成一个健康度指标判断逻辑就三条质量码连续 N 次非 GOOD 就报警、时间戳超过 M 秒没更新就判定为假活、回调连续 K 个周期没触发就判定订阅已死。这三条能覆盖绝大多数“看着在跑其实已经瞎了”的情况。从那以后我每次交付这类采集程序上线前都强制走一遍“探针 拔网线 重启 Server 进程”这三步验证拔网线看重连、重启 Server 看订阅恢复两步都过了才敢让它自己跑。这个习惯是从一次半夜被告警叫起来、发现程序连着一个已经重启过的 Server 假活了四个小时之后养成的。希望帮到你。本文还有配套的精品资源点击获取