Bootstrap

Java使用Milo实现OPC UA客户端及服务端,操作uaexpert工具测试(史上最详细讲解)

 一、Milo库

本文使用Milo库实现OPC UA客户端,以达到通过java读、写、订阅变量的目的。,在使用客户端实现时我们使用官方的服务端代码。如下链接,下载即可。

1.官网:Milo Github链接

2.打开服务端代码加载依赖,此时可能会找不到依赖。因为官方库版本太高,我们这里更改为0.6.13版本即可。

    <parent>
        <groupId>org.eclipse.milo</groupId>
        <artifactId>milo-examples</artifactId>
        <version>0.6.13</version>
    </parent>
        <dependency>
            <groupId>org.eclipse.milo</groupId>
            <artifactId>sdk-server</artifactId>
            <version>0.6.13</version>
        </dependency>

        <dependency>
            <groupId>org.eclipse.milo</groupId>
            <artifactId>dictionary-manager</artifactId>
            <version>0.6.13</version>
        </dependency>

3.在ExampleServer启动

二、使用uaexpert工具启动一个客户端(uaexpert使用教程)

1.下载路径

        这里我给大家提供一个资源包:uaexpert工具,大家也可在官网下载。安装过程就是无脑下一步,这里就不赘述了。

2.这个界面可以随意填写

3.按照下图的步骤进行操作,填写OPC服务的ip和端口,连接OPC服务端,注意使用英文的冒号。

4.填写完成后我们可以看见服务端支持的连接方式,这里可以看见我们启动的服务端支持匿名无证书登录,以及Basic256Sha256加密认证登录。我们这里给大家演示使用Basic256Sha256证书登录的方式。

5.使用加密连接,点击连接出现提示框,选择下方的信任服务证书,此时完成了客户端对服务端的认证,但是并没有完成服务端对客户端的认证

6.在服务端对客户端进行认证。

此时会产生一个安全证书路径,在服务的目录下rejected文件夹中会生成一个安全证书。我们需要将这个安全证书放到trusted目录下,再次进行客户端连接,会自动加载安全证书。

7.连接成功

三、java实现OPC客户端(使用证书版)

3.1添加依赖

        <dependency>
            <groupId>org.eclipse.milo</groupId>
            <artifactId>sdk-client</artifactId>
            <version>0.6.13</version>
        </dependency>
        <dependency>
            <groupId>org.eclipse.milo</groupId>
            <artifactId>dictionary-reader</artifactId>
            <version>0.6.13</version>
        </dependency>
        <dependency>
            <groupId>org.eclipse.milo</groupId>
            <artifactId>server-examples</artifactId>
            <version>0.6.13</version>
        </dependency>

3.2创建opc ua客户端 

package org.example.opc;

import lombok.extern.slf4j.Slf4j;
import org.eclipse.milo.examples.server.ExampleServer;
import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
import org.eclipse.milo.opcua.sdk.client.api.identity.AnonymousProvider;
import org.eclipse.milo.opcua.sdk.client.api.subscriptions.UaSubscription;
import org.eclipse.milo.opcua.sdk.client.api.subscriptions.UaSubscriptionManager;
import org.eclipse.milo.opcua.sdk.client.nodes.UaNode;
import org.eclipse.milo.opcua.sdk.client.subscriptions.ManagedDataItem;
import org.eclipse.milo.opcua.sdk.client.subscriptions.ManagedSubscription;
import org.eclipse.milo.opcua.stack.client.security.DefaultClientCertificateValidator;
import org.eclipse.milo.opcua.stack.core.Identifiers;
import org.eclipse.milo.opcua.stack.core.UaException;
import org.eclipse.milo.opcua.stack.core.security.DefaultTrustListManager;
import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy;
import org.eclipse.milo.opcua.stack.core.types.builtin.*;
import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;

@Slf4j
@Service
public class OpcClient {
    private static DefaultTrustListManager trustListManager;

    /**
     * 创建OPC UA客户端
     * @return
     * @throws Exception
     */
    private static OpcUaClient createClient() throws Exception {
        //opc ua服务端地址
        String endPointUrl = "opc.tcp://127.0.0.1:12686/milo";
        Path securityTempDir = Paths.get(System.getProperty("java.io.tmpdir"), "security");
        Files.createDirectories(securityTempDir);
        if (!Files.exists(securityTempDir)) {
            throw new Exception("unable to create security dir: " + securityTempDir);
        }
        KeyStoreLoader loader = new KeyStoreLoader().load(securityTempDir);
        File pkiDir = securityTempDir.resolve("pki").toFile();

        trustListManager = new DefaultTrustListManager(pkiDir);

        DefaultClientCertificateValidator certificateValidator =
                new DefaultClientCertificateValidator(trustListManager);

        return OpcUaClient.create(endPointUrl,
                endpoints ->
                        endpoints.stream()
                                .filter(e -> e.getSecurityPolicyUri().equals(SecurityPolicy.Basic256Sha256.getUri()))
                                .findFirst(),
                configBuilder ->
                        configBuilder
                                .setApplicationName(LocalizedText.english("eclipse milo opc-ua client"))
                                .setApplicationUri("urn:eclipse:milo:examples:client")
                                .setKeyPair(loader.getClientKeyPair())
                                .setCertificate(loader.getClientCertificate())
                                .setCertificateChain(loader.getClientCertificateChain())
                                .setCertificateValidator(certificateValidator)
                                //访问方式
                                .setIdentityProvider(new AnonymousProvider())
                                .setRequestTimeout(UInteger.valueOf(5000))
                                .build()
        );
    }

}

注意:

new AnonymousProvider()表示使用匿名方式访问,也可以通过new UsernameProvider(userName, password)方式访问。

 3.3 我们这里使用证书认证,所以有一个工具类

/*
 * Copyright (c) 2021 the Eclipse Milo Authors
 *
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 */

package org.example.opc;

import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Key;
import java.security.KeyPair;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.regex.Pattern;

import org.eclipse.milo.opcua.sdk.server.util.HostnameUtil;
import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateBuilder;
import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public 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[] clientCertificateChain;
    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 clientPrivateKey = keyStore.getKey(CLIENT_ALIAS, PASSWORD);
        if (clientPrivateKey instanceof PrivateKey) {
            clientCertificate = (X509Certificate) keyStore.getCertificate(CLIENT_ALIAS);

            clientCertificateChain = Arrays.stream(keyStore.getCertificateChain(CLIENT_ALIAS))
                .map(X509Certificate.class::cast)
                .toArray(X509Certificate[]::new);

            PublicKey serverPublicKey = clientCertificate.getPublicKey();
            clientKeyPair = new KeyPair(serverPublicKey, (PrivateKey) clientPrivateKey);
        }

        return this;
    }

    X509Certificate getClientCertificate() {
        return clientCertificate;
    }

    public X509Certificate[] getClientCertificateChain() {
        return clientCertificateChain;
    }

    KeyPair getClientKeyPair() {
        return clientKeyPair;
    }

}

3.4遍历树形节点

/**
  * 遍历树形节点
  *
  * @param client OPC UA客户端
  * @param uaNode 节点
  * @throws Exception
  */
private static void browseNode(OpcUaClient client, UaNode uaNode) throws Exception {
  List<? extends UaNode> nodes;
  if (uaNode == null) {
    nodes = client.getAddressSpace().browseNodes(Identifiers.ObjectsFolder);
  } else {
    nodes = client.getAddressSpace().browseNodes(uaNode);
  }
  for (UaNode nd : nodes) {
    //排除系统行性节点,这些系统性节点名称一般都是以"_"开头
    if (Objects.requireNonNull(nd.getBrowseName().getName()).contains("_")) {
      continue;
    }
    System.out.println("Node= " + nd.getBrowseName().getName());
    browseNode(client, nd);
  }
}
  1. namespaceIndex可以通过UaExpert客户端去查询,一般来说这个值是2。
  2. identifier也可以通过UaExpert客户端去查询,这个值=通道名称.设备名称.标记名称

3.5 写入节点数据

/**
 * 写入节点数据
 *
 * @param client
 * @throws Exception
 */
private static void writeNodeValue(OpcUaClient client) throws Exception {
    //节点
    NodeId nodeId = new NodeId(2, "TD-01.SB-01.AG-01");
    short i = 3;
    //创建数据对象,此处的数据对象一定要定义类型,不然会出现类型错误,导致无法写入
    DataValue nowValue = new DataValue(new Variant(i), null, null);
    //写入节点数据
    StatusCode statusCode = client.writeValue(nodeId, nowValue).join();
    System.out.println("结果:" + statusCode.isGood());
}

3.6 批量订阅

/**
  * 批量订阅
  *
  * @param client
  * @throws Exception
  */
private static void managedSubscriptionEvent(OpcUaClient client) throws Exception {
  final CountDownLatch eventLatch = new CountDownLatch(1);
	
  //处理订阅业务
  handlerNode(client);

  //持续监听,main方法测试需要加上,不然主线程直接结束了就没办法持续监听了。如果是springboot中调用就不需要了。
  eventLatch.await();
}

/**
  * 处理订阅业务
  *
  * @param client OPC UA客户端
  */
private static void handlerNode(OpcUaClient client) {
  try {
    //创建订阅
    ManagedSubscription subscription = ManagedSubscription.create(client);

    //你所需要订阅的key
    List<String> key = new ArrayList<>();
    key.add("TD-01.SB-01.AG-01");
    key.add("TD-01.SB-01.AG-02");

    List<NodeId> nodeIdList = new ArrayList<>();
    for (String s : key) {
      nodeIdList.add(new NodeId(2, s));
    }

    //监听
    List<ManagedDataItem> dataItemList = subscription.createDataItems(nodeIdList);
    for (ManagedDataItem managedDataItem : dataItemList) {
      managedDataItem.addDataValueListener((t) -> {
        System.out.println(managedDataItem.getNodeId().getIdentifier().toString() + ":" + t.getValue().getValue().toString());
      });
    }
  } catch (Exception e) {
    e.printStackTrace();
  }
}

注意:正常情况下通过OpcUaClient去订阅,没问题,但是当服务端断开之后,milo会抛出异常,当服务端重新启动成功后,OpcUaClient可以自动断线恢复,但是恢复之后会发现之前订阅的数据没法访问了。要解决这个问题,只需要断线重连后重新订阅即可

3.7 处理断线重连后的订阅问题

3.7.1 自定义实现SubscriptionListener
/**
  * 自定义订阅监听
  */
private static class CustomSubscriptionListener implements UaSubscriptionManager.SubscriptionListener {

  private OpcUaClient client;

  CustomSubscriptionListener(OpcUaClient client) {
    this.client = client;
  }

  public void onKeepAlive(UaSubscription subscription, DateTime publishTime) {
    logger.debug("onKeepAlive");
  }

  public void onStatusChanged(UaSubscription subscription, StatusCode status) {
    logger.debug("onStatusChanged");
  }

  public void onPublishFailure(UaException exception) {
    logger.debug("onPublishFailure");
  }

  public void onNotificationDataLost(UaSubscription subscription) {
    logger.debug("onNotificationDataLost");
  }

  /**
    * 重连时 尝试恢复之前的订阅失败时 会调用此方法
    * @param uaSubscription 订阅
    * @param statusCode 状态
    */
  public void onSubscriptionTransferFailed(UaSubscription uaSubscription, StatusCode statusCode) {
    logger.debug("恢复订阅失败 需要重新订阅");
    //在回调方法中重新订阅
    handlerNode(client);
  }
}
3.7.2 添加 SubscriptionListener
/**
  * 批量订阅
  *
  * @param client
  * @throws Exception
  */
private static void managedSubscriptionEvent(OpcUaClient client) throws Exception {
  final CountDownLatch eventLatch = new CountDownLatch(1);

  //添加订阅监听器,用于处理断线重连后的订阅问题
  client.getSubscriptionManager().addSubscriptionListener(new CustomSubscriptionListener(client));

  //处理订阅业务
  handlerNode(client);

  //持续监听
  eventLatch.await();
}

3.8.测试

public static void main(String[] args) throws Exception {
        //创建OPC UA客户端

        OpcUaClient opcUaClient = OpcClient.createClient();
        //开启连接
        opcUaClient.connect().get();

        //遍历节点
        browseNode(opcUaClient, null);

        //读
        readNode(opcUaClient);

        //写
        writeNodeValue(opcUaClient);


        //批量订阅
        managedSubscriptionEvent(opcUaClient);

        //关闭连接
        opcUaClient.disconnect().get();
    }

3.9 在我们首次启动客户端程序时,需要对双方证书进行认证,即服务端证书认证,客户端证书认证。

        跟uaexpert客户端不同的情况是,我们需要进行服务端对客户端证书认证,再进行客户端对服务端认证。接下来给大家演示一下。

启动客户端,报错提示证书未认证。

此时服务端文件夹下生成证书,我们将证书放到trusted的certs目录下

再次启动客户端,提示客户端未对服务端认证

此时在我们的客户端rejected目录下会生成一个证书,我们把他放在trusted的certs目录下。

此时双方都进行了认证,再次启动客户端程序,连接成功。读取到节点信息。

;