文章目录
背景
基于生产过程控制系统(MES)与生产设备(PLC)通信的需求,PLC型号西门子S7-1500,设备数据采集使用KEPwareEX6.4 , 将kepware 作为服务端来开发一个Java服务,用于生产过程控制系统与设备数据交互,达到控制生产过程的目的。
一、配置kepware服务端
1.添加监控设备
注意:查看每个节点状态为良好,kepware服务端可正常读写PLC数据。
2. 配置远端访问路径:
注意:安全策略运行无证书模式。
3. 开启OPC UA 匿名访问模式
方便测试,简化配置流程。
服务端配置完成!得到数据访问路径:
opc.tcp://10.106.11.161:49300 支持匿名访问。
二、编写JAVA客户端
1. 引入jar包
基于Milo实现
<dependency>
<groupId>org.eclipse.milo</groupId>
<artifactId>sdk-client</artifactId>
<version>0.6.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.eclipse.milo</groupId>
<artifactId>dictionary-reader</artifactId>
<version>0.6.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.eclipse.milo</groupId>
<artifactId>sdk-server</artifactId>
<version>0.6.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.eclipse.milo</groupId>
<artifactId>dictionary-manager</artifactId>
<version>0.6.0-SNAPSHOT</version>
</dependency>
2. 创建证数工具类
class KeyStoreLoader {
private static final Pattern IP_ADDR_PATTERN = Pattern.compile(
"^(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])$");
private static final String CLIENT_ALIAS = "client-ai";
private static final char[] PASSWORD = "password".toCharArray();
private final Logger logger = LoggerFactory.getLogger(getClass());
private X509Certificate clientCertificate;
private KeyPair clientKeyPair;
KeyStoreLoader load(Path baseDir) throws Exception {
KeyStore keyStore = KeyStore.getInstance("PKCS12");
Path serverKeyStore = baseDir.resolve("example-client.pfx");
logger.info("Loading KeyStore at {}", serverKeyStore);
if (!Files.exists(serverKeyStore)) {
keyStore.load(null, PASSWORD);
KeyPair keyPair = SelfSignedCertificateGenerator.generateRsaKeyPair(2048);
SelfSignedCertificateBuilder builder = new SelfSignedCertificateBuilder(keyPair)
.setCommonName("Eclipse Milo Example Client")
.setOrganization("digitalpetri")
.setOrganizationalUnit("dev")
.setLocalityName("Folsom")
.setStateName("CA")
.setCountryCode("US")
.setApplicationUri("urn:eclipse:milo:examples:client")
.addDnsName("localhost")
.addIpAddress("127.0.0.1");
// Get as many hostnames and IP addresses as we can listed in the certificate.
for (String hostname : HostnameUtil.getHostnames("0.0.0.0")) {
if (IP_ADDR_PATTERN.matcher(hostname).matches()) {
builder.addIpAddress(hostname);
} else {
builder.addDnsName(hostname);
}
}
X509Certificate certificate = builder.build();
keyStore.setKeyEntry(CLIENT_ALIAS, keyPair.getPrivate(), PASSWORD, new X509Certificate[]{certificate});
try (OutputStream out = Files.newOutputStream(serverKeyStore)) {
keyStore.store(out, PASSWORD);
}
} else {
try (InputStream in = Files.newInputStream(serverKeyStore)) {
keyStore.load(in, PASSWORD);
}
}
Key serverPrivateKey = keyStore.getKey(CLIENT_ALIAS, PASSWORD);
if (serverPrivateKey instanceof PrivateKey) {
clientCertificate = (X509Certificate) keyStore.getCertificate(CLIENT_ALIAS);
PublicKey serverPublicKey = clientCertificate.getPublicKey();
clientKeyPair = new KeyPair(serverPublicKey, (PrivateKey) serverPrivateKey);
}
return this;
}
X509Certificate getClientCertificate() {
return clientCertificate;
}
KeyPair getClientKeyPair() {
return clientKeyPair;
}
}
3. 创建客户端工具类
@Component
public class ClientGen {
static {
Security.addProvider(new BouncyCastleProvider());
}
private final Logger log = LoggerFactory.getLogger(getClass());
private final CompletableFuture<OpcUaClient> future = new CompletableFuture<>();
public static OpcUaClient opcUaClient;
@Autowired
private OpcUaConfig opcUaConfig;
@PostConstruct
public void createClient() {
try {
Path securityTempDir = Paths.get(System.getProperty("java.io.tmpdir"), "security");
Files.createDirectories(securityTempDir);
if (!Files.exists(securityTempDir)) {
throw new Exception("没有创建安全目录: " + securityTempDir);
}
log.info("安全目录: {}", securityTempDir.toAbsolutePath());
//加载秘钥
KeyStoreLoader loader = new KeyStoreLoader().load(securityTempDir);
//安全策略 None、Basic256、Basic128Rsa15、Basic256Sha256
SecurityPolicy securityPolicy = SecurityPolicy.None;
List<EndpointDescription> endpoints;
try {
endpoints = DiscoveryClient.getEndpoints(opcUaConfig.getEndpointUrl()).get();
} catch (Throwable ex) {
// 发现服务
String discoveryUrl = opcUaConfig.getEndpointUrl();
if (!discoveryUrl.endsWith("/")) {
discoveryUrl += "/";
}
discoveryUrl += "discovery";
log.info("开始连接 URL: {}", discoveryUrl);
endpoints = DiscoveryClient.getEndpoints(discoveryUrl).get();
}
EndpointDescription endpoint = endpoints.stream()
.filter(e -> e.getSecurityPolicyUri().equals(securityPolicy.getUri()))
.filter(opcUaConfig.endpointFilter())
.findFirst()
.orElseThrow(() -> new Exception("没有连接上端点"));
log.info("使用端点: {} [{}/{}]", endpoint.getEndpointUrl(), securityPolicy, endpoint.getSecurityMode());
OpcUaClientConfig config = OpcUaClientConfig.builder()
.setApplicationName(LocalizedText.english("eclipse milo opc-ua client"))
.setApplicationUri("urn:eclipse:milo:examples:client")
.setCertificate(loader.getClientCertificate())
.setKeyPair(loader.getClientKeyPair())
.setEndpoint(endpoint)
//根据匿名验证和第三个用户名验证方式设置传入对象 AnonymousProvider(匿名方式)UsernameProvider(账户密码)
//new UsernameProvider("admin","123456")
.setIdentityProvider(new AnonymousProvider())
.setRequestTimeout(uint(5000))
.build();
opcUaClient = OpcUaClient.create(config);
} catch (Exception e) {
log.error("创建客户端失败" + e.getMessage());
}
}
}
4. 创建操作读写接口
注意:包含单个节点地址数据读写,订阅模式(推荐)。
@Component
public class OpcUaOperationSupport {
private final Logger logger = LoggerFactory.getLogger(getClass());
/**
* 浏览节点
*/
public void browseNode() {
OpcUaClient client = ClientGen.opcUaClient;
try {
client.connect().get();
browseNode("", client, Identifiers.RootFolder);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
/**
* 单个读取 PLC
*
* @param server
* @return
*/
public ResultBean readPLC(OpcModel server) {
ResultBean resultBean = new ResultBean(false, "");
String accessPath = server.getAccessPath();
String itemName = server.getItemName();
String item = accessPath + "." + itemName;
OpcUaClient client = ClientGen.opcUaClient;
try {
client.connect().get();
NodeId nodeId = new NodeId(2, item);
CompletableFuture<DataValue> readValue = client.readValue(0.0, TimestampsToReturn.Both, nodeId);
DataValue value = readValue.get();
String plcValue = value.getValue().getValue().toString();
String statusCode = value.getStatusCode().toString();
if (value.getStatusCode() != null) {
if (value.getStatusCode().isGood()) {
resultBean.setMsg("获取数据成功。");
resultBean.setResult(plcValue);
resultBean.setSuccess(true);
} else {
resultBean.setMsg("获取数据失败。");
resultBean.setResult(statusCode);
resultBean.setSuccess(false);
}
logger.info("======== > it means successfully read StatusCode = {}", statusCode);
} else {
resultBean.setMsg("获取数据出现异常。");
resultBean.setResult("出错了。。。。");
resultBean.setSuccess(false);
logger.error("=====》 读数据异常,未获取到数据。。。");
}
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
return resultBean;
}
/**
* 写入 PLC
* @param server
* @param editValue
* @return
*/
public ResultBean writePLC(OpcModel server, String editValue) {
ResultBean resultBean = new ResultBean(false, "");
String accessPath = server.getAccessPath();
String itemName = server.getItemName();
String item = accessPath + "." + itemName;
try {
// 创建连接
OpcUaClient client = ClientGen.opcUaClient;
client.connect().get();
// 创建节点
NodeId nodeId = new NodeId(2, item);
// 创建Variant对象和DataValue对象
Variant v;
boolean b = true;
switch (editValue) {
case "1":
v = new Variant(b);
break;
case "0":
b = false;
v = new Variant(b);
break;
default:
v = new Variant(editValue);
break;
}
DataValue dv = new DataValue(v, null, null);
StatusCode statusCode = client.writeValue(nodeId, dv).get();
if (statusCode.isGood()) {
resultBean.setMsg("写入数据成功。");
resultBean.setSuccess(true);
resultBean.setResult(editValue);
logger.info("========== > it means successfully Wrote '{}' to nodeId={}, statusCodes = {}", v, nodeId, statusCode.toString());
} else {
resultBean.setMsg("写入数据失败。item = " + item);
resultBean.setSuccess(false);
resultBean.setResult(statusCode.toString());
logger.error("statusCodes:" + statusCode.toString());
}
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
return resultBean;
}
/**
* 订阅变量
*
* @param server
* @return
*/
public ResultBean createSubscription(OpcModel server) {
ResultBean resultBean = new ResultBean(false, "");
String accessPath = server.getAccessPath();
String itemName = server.getItemName();
String item = accessPath + "." + itemName;
try {
OpcUaClient client = ClientGen.opcUaClient;
client.connect().get();
//创建发布间隔1000ms的订阅对象
UaSubscription subscription = client.getSubscriptionManager().createSubscription(1000.0).get();
//创建监控的参数
MonitoringParameters parameters = new MonitoringParameters(
uint(1),
// 发布间隔
1000.0,
// filter, 空表示用默认值
null,
// 队列大小
uint(10),
//放弃旧配置
true
);
//创建订阅的变量
NodeId nodeId = new NodeId(2, item);
ReadValueId readValueId = new ReadValueId(nodeId, AttributeId.Value.uid(), null, null);
//创建监控项请求
//该请求最后用于创建订阅。
MonitoredItemCreateRequest request = new MonitoredItemCreateRequest(readValueId, MonitoringMode.Reporting, parameters);
List<MonitoredItemCreateRequest> requests = new ArrayList<>();
requests.add(request);
//创建监控项,并且注册变量值改变时候的回调函数。
UaSubscription.ItemCreationCallback onItemCreated =
(subscriptionItem, id) -> subscriptionItem.setValueConsumer((UaMonitoredItem item1, DataValue value) -> {
logger.info("===== > here is callbacks ... 订阅的回调函数。 ");
resultBean.setMsg("订阅成功, item1 :" + item1.getReadValueId().getNodeId().toString());
resultBean.setResult(value.getValue().toString());
resultBean.setSuccess(true);
logger.info("subscription value received: item={}, value={}", item1.getReadValueId().getNodeId(), value.getValue());
}
);
List<UaMonitoredItem> monitoredItems = subscription.createMonitoredItems(
TimestampsToReturn.Both,
newArrayList(request),
onItemCreated
).get();
for (UaMonitoredItem monitoredItem : monitoredItems) {
if (monitoredItem.getStatusCode().isGood()) {
logger.info("item created for nodeId={}", monitoredItem.getReadValueId().getNodeId());
} else {
logger.warn(
"failed to create item for nodeId={} (status={})",
monitoredItem.getReadValueId().getNodeId(), monitoredItem.getStatusCode());
}
}
return resultBean;
} catch (Exception e) {
logger.error("订阅变量失败");
resultBean.setMsg(e.getMessage());
resultBean.setResult("订阅变量进入异常。");
resultBean.setSuccess(false);
}
return resultBean;
}
/**
* 查看历史变量记录
*
* @param server
* @param editValue
* @return
*/
public ResultBean historyRead(OpcModel server, String editValue) {
return null;
}
/**
* 浏览节点(抽取方法)
*
* @param indent
* @param client
* @param browseRoot
*/
private void browseNode(String indent, OpcUaClient client, NodeId browseRoot) {
BrowseDescription browse = new BrowseDescription(
browseRoot,
BrowseDirection.Forward,
Identifiers.References,
true,
uint(NodeClass.Object.getValue() | NodeClass.Variable.getValue()),
uint(BrowseResultMask.All.getValue())
);
try {
BrowseResult browseResult = client.browse(browse).get();
List<ReferenceDescription> references = toList(browseResult.getReferences());
for (ReferenceDescription rd : references) {
logger.info("{} Node={}", indent, rd.getBrowseName().getName());
// recursively browse to children
rd.getNodeId().toNodeId(client.getNamespaceTable())
.ifPresent(nodeId -> browseNode(indent + " ", client, nodeId));
}
} catch (InterruptedException | ExecutionException e) {
logger.error("Browsing nodeId={} failed: {}", browseRoot, e.getMessage(), e);
}
}
}
5. 调用层
@RestController
@AllArgsConstructor
public class OpcUAController {
@Autowired
private OpcUaOperationSupport opcUaOperationSupport;
@RequestMapping(value = "/", method = RequestMethod.GET)
public String test() {
return "hello ";
}
@RequestMapping(value = "/query")
public ResultBean readPLC(OpcModel server, HttpServletResponse response) throws Exception {
response = null;
ResultBean resultBean = opcUaOperationSupport.readPLC(server);
return resultBean;
}
@RequestMapping(value = "/subscription")
public ResultBean readSubscriptionPLC(OpcModel server, HttpServletResponse response) throws Exception {
response = null;
ResultBean resultBean = opcUaOperationSupport.createSubscription(server);
return resultBean;
}
@RequestMapping(value = "/edit")
public ResultBean writePLC(OpcModel server, String editValue, HttpServletResponse response) throws Exception {
response = null;
ResultBean resultBean = opcUaOperationSupport.writePLC(server, editValue);
return resultBean;
}
}
订阅模式测试结果如下:
三、OPCUA 与Jeasy opc性能测试对比
1. OPC UA 方式
条件:20线程,5秒,重复30次,一边读(订阅模式)一边写
订阅模式读:
普通模式写:
测试结果统计:1200个用例全部执行通过。
随机查看写的接口返回情况:
查看后台,运行正常,无报错:
注意:StatusCode{name=Bad_TypeMismatch, value=0x80740000, quality=bad} 控制台报错,是由于类型错误导致。以上用例仅兼容了Boolean 类型和String 类型,但是测试用例里面收入了Short 类型,所以会有部分类型错误,对类型进行转换即可。
2. Jeasy opc 方式
条件:20线程,5秒,重复30次,一边读一边写
并且把读和写分担在两个服务上面,端口8866:
端口 8877 :
测试结果统计:读和写均出现错误
报错信息如下:在正常执行过一段时间后,两个服务都出现宕机重启的情况
五、总结
综述所述,在追求效率的生产过程中,OPC UA 模式稳定性更强,承受并发效率更高,而Jeasy opc 在并发量高的情况下,直接挂机了,虽然采取补偿措施(读写分离+挂机自动重启),但是还是会影响正常使用,所以推荐使用OPC UA 方式。
补充:其实Jeasy opc 在重启后可以支撑一会儿并发,但是多执行几次就会出现宕机情况。而且并发越是频繁,宕机也越频繁,所以还是不推荐用于中大型工厂的过程控制系统。它更适用于对数据时效性不高的场景。
OPC UA源码参考:Milo