最近一个项目中需要用到OPC client,从OPC Server中获取数据。主要的编程语言使用Java实现。实际开发中遇到了各种坑,其实也和自己没有这方面的经验有关,现在写一篇文章分享下整个项目中遇到的一些问题。
准备知识
开发OPC Client之前需要一些准备知识,需要一些知识储备,否则根本搞不清楚里面的门道。现在对一些预先准备的知识点做一概述。OPC是什么就不说了。
OPC Server端的协议
OPC Server端目前常见的有以下几种协议:
- OPC DA: Data Access协议,是最基本的OPC协议。OPC DA服务器本身不存储数据,只负责显示数据收集点的当前值。客户端可以设置一个refresh interval,定期刷新这个值。目前常见的协议版本号为2.0和3.0,两个协议不完全兼容。也就是用OPC DA 2.0协议的客户端连不上OPC DA 3.0的Server
- OPC HDA: Historical Data Access协议。前面说过DA只显示当前状态值,不存储数据。而HDA协议是由数据库提供,提供了历史数据访问的能力。比如价格昂贵的Historian数据库,就是提供HDA协议接口访问OPC的历史数据。HDA的Java客户端目前我没找到免费的。
- OPC UA: Unified Architecture统一架构协议。诞生于2008年,摒弃了前面老的OPC协议繁杂,互不兼容等劣势,并且不再需要
COM
口访问,大大简化了编程的难度。基于OPC UA的开源客户端非常多。不过由于诞生时间较晚,目前在国内工业上未大规模应用,并且这个协议本身就跟旧的DA协议不兼容,客户端没法通用。
我们的目标环境绝大多数是OPC DA 2.0的Server,极个别可能有OPC DA 3.0。当时找到的很多类库实现的都是OPC UA的。
第一坑: 基于JAVA开发的OPC Client非常少,大部分是商业的,售价不菲。现场环境又是OPC DA的Server,开源client只有两个可选,找工具和评估就花了不少时间。
OPC存储格式
OPC存储和传统的关系型数据库存储格式有很大的不同,不同于关系型数据库的表存储,OPC存储格式是树形结构,Server端的存储格式如下:
host
`-- OPC Server Name
`-- tag1: value, type, timestamp, ...,
`-- tag2: value, type, timestamp, ...,
`-- tag3: ...
...
每个主机上可能存在多个OPC Server,每个Server下面有若干个tag
,就是各个数据收集点当前的值,会定期更新。每个tag
包含的内容大致有当前值,值类型,时间戳等等数据。是一种树形结构。所以客户端连接的时候需要指明服务器的ip或主机名,需要连接的OPC服务名,以及监听哪些tag
的数据。
Client端存储的格式如下:
Group1
`-- tag1
`-- tag2
`-- tag3
Group2
`-- tag4
`-- tag5
...
这个就比较有意思了,Client是可以自己维护一个存储层级Group
。也就是服务端存储的都是一个个tag
,客户端可以自己维护一个个Group
,分类存放这些tag
。所以OPC的Client就和传统的关系型数据库有很大的不同。客户端除了指明上述Server端的信息之外,还需要创建一个个Group
,将Server端的tag
一个个放到这些Group
中,然后对应的tag
才能持续的获得数据。
第二坑: 这种存储格式在其他数据库十分罕见,当时这里就迷茫了好一阵子,通过了解协议的人讲解,才明白原来客户端还可以维护一套存储结构。当时没理清楚Group和tag的关系,从服务端看不到Group,客户端却要填一个Group,不知道这个Group从哪来。后来才搞清楚。
COM
Component Object Model对象组件模型,是微软定义的一套软件的二进制接口,可以实现跨编程语言的进程间通信,进而实现复用。
DCOM
Microsoft Distributed Component Object Model,坑最多的一个玩意。字面意思看起来是分布式的COM,简单理解就是可以利用网络传输数据的COM协议,客户端也可以通过互联网分布在各个角落,不再限制在同一台主机上了。
上面描述来看这玩意好像挺美好是吧?实际操作开发中才发现,这玩意简直是坑王之王,对于不熟悉的人来说充满了坑,十分折腾。配置过程可以参考一些文章
- DCOM是windows上的服务,使用前需要启用
- DCOM是远程连接的协议,需要配置相关的权限,以及防火墙规则放行
- 特别注意这一点,前两项配置在网上都能找到,这一条是我在经历无数次痛之后才意识到的。DCOM远程连接和http不同,是通过本地用户认证的,需要以本地用户身份登录服务器,拿到相应的权限,才能使用DCOM。有点绕是吧?你可以类比Windows的远程桌面登录,需要拿到服务器的用户名密码才能登录并操作系统,权限受到登录用户的权限所限制。而DCOM就是用的这种方式。关于各种错误网上能找出一大堆解决方案,可能还没一个能解决你的问题的。甚至可能
progID
无论无何也通不了,始终报错,不得不改用CLSID
这种方法,十分坑。
神坑: DCOM。从配置开始就充满了陷阱和坑。不但配置繁琐复杂,还会受到各种权限以及防火墙规则的影响。最恶心的是这玩意随时可能报各种奇葩的错误,由于缺乏足够的错误信息,很难解决,基本凭借经验解决DCOM的故障。
开发过程
收集到足够的准备知识后,就可以开工了。OPC Server是DA 2.0的,因此找到了以下两个开源类库。
JEasyOPC Client
- 底层依赖JNI,只能跑在windows环境,不能跨平台
- 整个类库比较古老,使用的dll是32位的,整个项目只能使用32位的JRE运行
- 同时支持DA 2.0与3.0协议,算是亮点
Utgard
- OpenSCADA项目底下的子项目
- 纯Java编写,具有跨平台特性
- 全部基于
DCOM
实现(划重点) - 目前只支持DA 2.0协议,3.0协议的支持还在开发中
这两个类库都试过,JEasyOPC底层用了JNI,调用代码量倒不是很大,使用也足够简单,坑也遇到了点,就是64位的JRE运行会报错,说dll是ia32架构的,不能运行于AMD64平台下,换了32位版本的JRE之后运行起来了,但是一直报错Unknown Error,从JNI报出来的,不明所以,实在无力解决,只能放弃。
只剩下Utgard一种选择了,也庆幸目标Server是DA 2.0的,用这个类库完全够用。这个类库全部使用DCOM协议连接OPC Server,所以对于本地连接OPC Server,理论上不需要COM口,但是这个类库全部使用DCOM协议连接,所以依旧需要配置主机名,以及登录的用户名密码。使用之前必须先配置DCOM,其中痛苦不足为外人道也,在上面准备知识部分已经写道了。
经过一番折腾,总算将项目跑起来了,最终参考的工程代码如下(项目实用Gradle构建,代码使用Utgard官方的tutorial范例):
build.gradle:
apply plugin: 'java'
apply plugin: 'application'
repositories {
maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' }
jcenter()
maven { url 'http://neutronium.openscada.org/maven/' }
}
dependencies {
compile 'org.openscada.utgard:org.openscada.opc.lib:1.3.0-SNAPSHOT'
compile 'org.openscada.utgard:org.openscada.opc.dcom:1.2.0-SNAPSHOT'
compile 'org.jinterop:j-interop:2.0.4'
compile 'ch.qos.logback:logback-core:1.2.3'
compile 'org.slf4j:slf4j-api:1.7.25'
}
mainClassName = 'UtgardTutorial1'
src/main/java/UtgardTutorial1.java:
import org.jinterop.dcom.common.JIException;
import org.openscada.opc.lib.common.ConnectionInformation;
import org.openscada.opc.lib.da.AccessBase;
import org.openscada.opc.lib.da.Server;
import org.openscada.opc.lib.da.SyncAccess;
import java.util.concurrent.Executors;
public class UtgardTutorial1 {
public static void main(String[] args) throws Exception {
// create connection information
final ConnectionInformation ci = new ConnectionInformation();
ci.setHost("localhost");
ci.setUser("Administrator");
ci.setPassword("mypassword");
ci.setProgId("TLSvrRDK.OPCTOOLKIT.DEMO");
// ci.setClsid("08a3cc25-5953-47c1-9f81-efe3046f2d8c"); // if ProgId is not working, try it using the Clsid instead
final String itemId = "tag1";
// create a new server
final Server server = new Server(ci, Executors.newSingleThreadScheduledExecutor());
try {
// connect to server
server.connect();
// add sync access, poll every 500 ms
final AccessBase access = new SyncAccess(server, 500);
access.addItem(itemId, (item, state) ->
System.out.println("Resut: " + state.toString()));
// start reading
access.bind();
// wait a little bit
Thread.sleep(10 * 1000);
// stop reading
access.unbind();
} catch (final JIException e) {
System.out.println(String.format("%08X: %s", e.getErrorCode(), server.getErrorMessage(e.getErrorCode())));
e.printStackTrace();
}
}
}
最终项目运行输出如下:
Recieved RESPONSE
Resut: Value: [[]]], Timestamp: 星期三 七月 05 00:32:29 CST 2017, Quality: 192, ErrorCode: 00000000
七月 05, 2017 12:32:27 上午 rpc.DefaultConnection processOutgoing
信息:
Sending REQUEST
七月 05, 2017 12:32:27 上午 rpc.DefaultConnection processIncoming
信息:
Recieved RESPONSE
Resut: Value: [[]]], Timestamp: 星期三 七月 05 00:32:29 CST 2017, Quality: 192, ErrorCode: 00000000
七月 05, 2017 12:32:28 上午 rpc.DefaultConnection processOutgoing
信息:
Sending REQUEST
七月 05, 2017 12:32:28 上午 rpc.DefaultConnection processIncoming
信息:
Recieved RESPONSE
Resut: Value: [[U]], Timestamp: 星期三 七月 05 00:32:30 CST 2017, Quality: 192, ErrorCode: 00000000
七月 05, 2017 12:32:28 上午 rpc.DefaultConnection processOutgoing
信息:
Sending REQUEST
七月 05, 2017 12:32:28 上午 rpc.DefaultConnection processIncoming
信息:
Recieved RESPONSE
Resut: Value: [[U]], Timestamp: 星期三 七月 05 00:32:30 CST 2017, Quality: 192, ErrorCode: 00000000
七月 05, 2017 12:32:29 上午 rpc.DefaultConnection processOutgoing
信息:
总算跑起来了。