最近公司有个需求需要进行对接opcua,之前从来没有对接过这方面的,但是在网上找资源时,发现这方面网上资源很少,只能硬着头皮搞,下面给大家分享一下踩的坑

        连接的时候有很多种方式:
        1.用户名密码

        2.证书,用户名

        3.证书,密钥,用户名密码(前两种都是比较简单的,我是第三种情况,网上基本没资源)

首先是定义的一个config,主要是连接配置类:

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
import org.eclipse.milo.opcua.sdk.client.api.config.OpcUaClientConfig;
import org.eclipse.milo.opcua.sdk.client.api.identity.UsernameProvider;
import org.eclipse.milo.opcua.stack.client.DiscoveryClient;
import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy;
import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription;
import org.springblade.modules.wems.utils.CertificateLoader;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.KeyPair;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Optional;

@Slf4j
@Configuration
public class OpcUaConfig {

	@Value("${opcua.client.username}")
	private String username;

	@Value("${opcua.client.password}")
	private String password;

	@Value("${opcua.client.endpoint-url}")
	private String endpointUrl;

	@Value("${opcua.client.application-uri}")
	private String applicationUri;

	@Value("${opcua.client.cert-path}")
	private String certPath;

	@Value("${opcua.client.key-path}")
	private String keyPath;

	@Bean
	public OpcUaClient opcUaClient() throws Exception {

		// 使用 ClassLoader 获取输入流
		InputStream certInputStream = getClass().getClassLoader().getResourceAsStream(certPath);
		InputStream keyInputStream = getClass().getClassLoader().getResourceAsStream(keyPath);

		if (keyInputStream == null || certInputStream == null) {
			throw new IllegalArgumentException("Resource not found: client_key.pem or client_cert.der");
		}

		// 临时将输入流写入文件路径(CertificateLoader 需要文件路径作为输入)
		Path tempKeyPath = Files.createTempFile("client_key", ".pem");
		Files.copy(keyInputStream, tempKeyPath, StandardCopyOption.REPLACE_EXISTING);

		Path tempCertPath = Files.createTempFile("client_cert", ".der");
		Files.copy(certInputStream, tempCertPath, StandardCopyOption.REPLACE_EXISTING);

		// 调用 CertificateLoader 加载证书和私钥
		PrivateKey privateKey = CertificateLoader.loadPrivateKey(tempKeyPath.toString());
		X509Certificate clientCertificate = CertificateLoader.loadCertificate(tempCertPath.toString());

		// 清理临时文件
		Files.delete(tempKeyPath);
		Files.delete(tempCertPath);

//		PrivateKey privateKey = CertificateLoader.loadPrivateKey("src/main/resources/opcua/client_key.pem");
//		X509Certificate clientCertificate = CertificateLoader.loadCertificate("src/main/resources/opcua/client_cert.der");

		// 获取公钥
		PublicKey serverPublicKey = clientCertificate.getPublicKey();
		KeyPair clientKeyPair = new KeyPair(serverPublicKey, privateKey);

		// Set up X509 identity
		UsernameProvider usernameProvider = new UsernameProvider(username, password);

		// Discover endpoints and create client
		List<EndpointDescription> endpoints = DiscoveryClient.getEndpoints(endpointUrl).get();

		Optional<EndpointDescription> endpoint = endpoints.stream()
			.filter(e -> e.getSecurityPolicyUri().equals(SecurityPolicy.None.getUri())) // 尝试使用不加密的连接
			.findFirst();

		if (!endpoint.isPresent()) {
			throw new Exception("No suitable endpoint found");
		}

		OpcUaClientConfig config = OpcUaClientConfig.builder()
			.setEndpoint(endpoint.get())
			.setCertificate(clientCertificate)
			.setKeyPair(clientKeyPair)
			.setIdentityProvider(usernameProvider)
			.setApplicationUri(applicationUri)
			.build();
		OpcUaClient client = OpcUaClient.create(config);
		client.connect().get();	// 确保客户端已经成功连接到服务器,后续代码才能正常进行查询、订阅和写入等操作。如果没有这行代码,程序不会自动进行连接,导致无法与 OPC UA 服务器交互。
		return client;
	}

}

第一个坑,这个有一个配置:applicationUri,因为我们的硬件团队没有提供这个,但是连接的时候如果不配置就会报错,这个时候可以使用证书来获取,下面这个方法可以直接拿到对应的数据

/**
	 * 根据der类型的证书获取证书中的ApplicationUri属性
	 * @param filePath
	 * @return 证书中的ApplicationUri属性
	 */
	public String getApplicationUri(String filePath){
		String uri = null;
		try {
			// 加载证书文件
			FileInputStream fis = new FileInputStream(filePath);
			CertificateFactory cf = CertificateFactory.getInstance("X.509");
			X509Certificate cert = (X509Certificate) cf.generateCertificate(fis);

			// 获取 SAN 扩展字段
			Collection<List<?>> sanEntries = cert.getSubjectAlternativeNames();
			if (sanEntries != null) {
				for (List<?> sanEntry : sanEntries) {
					Integer type = (Integer) sanEntry.get(0);
					if (type == 6) { // type 6 corresponds to URI in SAN
						uri = (String) sanEntry.get(1);
						log.info("ApplicationUri: " + uri);
						break;
					}
				}
			} else {
				log.info("No SAN entries found in the certificate.");
			}
			return uri;
		} catch (Exception e) {
			e.printStackTrace();
		}
		return null;
	}

二:主要是一些工具类,包含了创建Node节点,读取简单节点,读取方法类型的节点,读取Node的上级节点,设置节点,监听变化节点等方式:
 

import lombok.extern.slf4j.Slf4j;
import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
import org.eclipse.milo.opcua.stack.core.AttributeId;
import org.eclipse.milo.opcua.stack.core.Identifiers;
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.*;
import org.eclipse.milo.opcua.stack.core.types.structured.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.io.FileInputStream;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

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

@Slf4j
@Component
public class OpcUaUtils {

	@Autowired
	private OpcUaClient client;

	public NodeId createNodeId(Integer nameSpaceIndex, Object identifier) {
		NodeId nodeId;
		if (identifier instanceof Integer) {
			nodeId = new NodeId(nameSpaceIndex, (Integer) identifier);
		} else if (identifier instanceof String) {
			nodeId = new NodeId(nameSpaceIndex, (String) identifier);
		} else if (identifier instanceof UUID) {
			nodeId = new NodeId(nameSpaceIndex, identifier.toString());
		} else if (identifier instanceof ByteString) {
			nodeId = new NodeId(nameSpaceIndex, (ByteString) identifier);
		} else {
			throw new IllegalArgumentException("Unsupported identifier type: " + identifier.getClass().getName());
		}
		return nodeId;
	}

	public DataValue readNode(NodeId nodeId) {
		try {
			CompletableFuture<DataValue> future = client.readValue(0.0, TimestampsToReturn.Neither, nodeId);
			return future.get();
		} catch (InterruptedException | ExecutionException e) {
			throw new RuntimeException("Failed to read node", e);
		}
	}

	public Object readMethod(NodeId nodeId,List<String> args) throws Exception {
		try {
			// 动态设置输入参数
			Variant[] inputArguments = args.stream()
				.map(Variant::new) // 将每个参数封装为 Variant
				.toArray(Variant[]::new); // 转换为 Variant 数组

			NodeId objectId = readObjectNodeId(nodeId);
			// 构造方法调用请求
			CallMethodRequest callMethodRequest = new CallMethodRequest(
				objectId,  // 方法所属对象的 NodeId
				nodeId,  // 方法的 NodeId
				inputArguments // 输入参数
			);

			// 调用方法
			List<CallMethodRequest> requests = Arrays.asList(callMethodRequest);
			CallMethodResult[] results = client.call(requests).get().getResults();

			// 解析返回值
			CallMethodResult result = results[0]; // 假设只有一个请求
			if (result.getStatusCode().isGood()) {
				log.info("Method called successfully!");

				// 输出返回值
				Variant[] outputArguments = result.getOutputArguments();
				Object objectResult = null;
				for (Variant output : outputArguments) {
					log.info("Output: " + output.getValue());
					objectResult = output.getValue();
				}
				return objectResult;
			} else {
				log.info("Method call failed: " + result.getStatusCode());
				return null;
			}
		} catch (InterruptedException | ExecutionException e) {
			throw new RuntimeException("Failed to read node", e);
		}
	}

	public NodeId readObjectNodeId(NodeId methodNodeId) throws Exception {
		// 浏览该节点的反向引用
		BrowseDescription browseDescription = new BrowseDescription(
			methodNodeId,                // 要浏览的起始节点
			BrowseDirection.Inverse,     // 浏览方向:反向
			Identifiers.HasComponent,    // 关系类型:HasComponent
			true,                        // 是否包含子类型
			uint(NodeClass.Object.getValue()), // 父节点类型必须是 Object
			uint(BrowseResultMask.All.getValue()) // 包含所有可用信息
		);
		ReferenceDescription[] references1 = client.browse(browseDescription).get().getReferences();
		List<ReferenceDescription> references = Arrays.asList(references1);
		// 查找第一个符合条件的父节点
		if (!references.isEmpty()) {
			ReferenceDescription parentRef = references.get(0);

			// 将引用的 NodeId 转换为完整的 NodeId
			NodeId parentNodeId = parentRef.getNodeId().toNodeId(client.getNamespaceTable()).orElse(null);

			if (parentNodeId != null) {
				log.info("Parent NodeId: " + parentNodeId);
				return parentNodeId;
			} else {
				log.error("Failed to resolve parent NodeId.");
			}
		} else {
			log.error("No parent node found for the method.");
		}
		return null;
	}

	public void writeNode(NodeId nodeId, Object value) {
		try {
			DataValue dataValue = new DataValue(new Variant(value));
			client.writeValue(nodeId, dataValue).get();
			log.info("Value written successfully to node: " + nodeId);
		} catch (InterruptedException | ExecutionException e) {
			throw new RuntimeException("Failed to write node", e);
		}
	}

	/**
	 * 订阅某个节点
	 * @param nodeId
	 * @throws Exception
	 */
	public void subscribe(NodeId nodeId) throws Exception {
		//创建发布间隔1000ms的订阅对象
		client.getSubscriptionManager()
			.createSubscription(1000.0)
			.thenAccept(t -> {
				//节点ns=2;s=test.device2.test2
				ReadValueId readValueId = new ReadValueId(nodeId, AttributeId.Value.uid(), null, null);
				//创建监控的参数
				MonitoringParameters parameters = new MonitoringParameters(UInteger.valueOf(1), 1000.0, null, UInteger.valueOf(10), true);
				//创建监控项请求
				//该请求最后用于创建订阅。
				MonitoredItemCreateRequest request = new MonitoredItemCreateRequest(readValueId, MonitoringMode.Reporting, parameters);
				List<MonitoredItemCreateRequest> requests = new ArrayList<>();
				requests.add(request);
				//创建监控项,并且注册变量值改变时候的回调函数。
				t.createMonitoredItems(
					TimestampsToReturn.Both,
					requests,
					(item, id) -> item.setValueConsumer((it, val) -> {
						// TODO 改变值的时候的回调函数 可以根据	it.getReadValueId().getNodeId()里面的getNamespaceIndex和getIdentifier两个属性进行判断从而进行逻辑处理
						log.info("=====订阅nodeid====== :" + it.getReadValueId().getNodeId());
						log.info("=====订阅value===== :" + val.getValue().getValue());
					})
				);
			}).get();

		//持续订阅
		Thread.sleep(Long.MAX_VALUE);
	}

	/**
	 * 订阅多个节点
	 * @param nodeIds
	 * @throws Exception
	 */
	public void subscribe(List<NodeId> nodeIds) throws Exception {
		// 创建发布间隔为 1000ms 的订阅对象
		client.getSubscriptionManager()
			.createSubscription(1000.0)
			.thenAccept(subscription -> {
				List<MonitoredItemCreateRequest> requests = new ArrayList<>();

				// 遍历 NodeId 列表并创建监控项请求
				for (NodeId nodeId : nodeIds) {
					ReadValueId readValueId = new ReadValueId(nodeId, AttributeId.Value.uid(), null, null);
					MonitoringParameters parameters = new MonitoringParameters(
						UInteger.valueOf(1), 1000.0, null, UInteger.valueOf(10), true
					);
					MonitoredItemCreateRequest request = new MonitoredItemCreateRequest(
						readValueId, MonitoringMode.Reporting, parameters
					);
					requests.add(request);
				}

				// 创建监控项,并注册变量值改变时的回调函数
				subscription.createMonitoredItems(
					TimestampsToReturn.Both,
					requests,
					(item, id) -> item.setValueConsumer((it, val) -> {
						// TODO 改变值的时候的回调函数 可以根据	it.getReadValueId().getNodeId()里面的getNamespaceIndex和getIdentifier两个属性进行判断从而进行逻辑处理
						log.info("===== 订阅的 NodeId =====: " + it.getReadValueId().getNodeId());
						log.info("===== 订阅的值 =====: " + val.getValue().getValue());
					})
				);
			}).get();

		// 持续订阅
		Thread.sleep(Long.MAX_VALUE);
	}

	/**
	 * 根据der类型的证书获取证书中的ApplicationUri属性
	 * @param filePath
	 * @return 证书中的ApplicationUri属性
	 */
	public String getApplicationUri(String filePath){
		String uri = null;
		try {
			// 加载证书文件
//			FileInputStream fis = new FileInputStream("src/main/resources/opcua/client_cert.der");
			FileInputStream fis = new FileInputStream(filePath);
			CertificateFactory cf = CertificateFactory.getInstance("X.509");
			X509Certificate cert = (X509Certificate) cf.generateCertificate(fis);

			// 获取 SAN 扩展字段
			Collection<List<?>> sanEntries = cert.getSubjectAlternativeNames();
			if (sanEntries != null) {
				for (List<?> sanEntry : sanEntries) {
					Integer type = (Integer) sanEntry.get(0);
					if (type == 6) { // type 6 corresponds to URI in SAN
						uri = (String) sanEntry.get(1);
						log.info("ApplicationUri: " + uri);
						break;
					}
				}
			} else {
				log.info("No SAN entries found in the certificate.");
			}
			return uri;
		} catch (Exception e) {
			e.printStackTrace();
		}
		return null;
	}


}

这个方法里面的基本够用了:
之前碰到的坑是,node节点有很多种类型,最简单的就类似于java中的String类型的格式,但是有一个比较复杂的是类似于一个函数的类型,但是函数类型读取的时候,还必须要有一个上级的ObjectNodeId,所以就多写了一个方法是查询反向的节点

import java.io.FileInputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.spec.PKCS8EncodedKeySpec;

public class CertificateLoader {

    public static X509Certificate loadCertificate(String filePath) throws Exception {
        CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
        try (FileInputStream fis = new FileInputStream(filePath)) {
            Certificate cert = certificateFactory.generateCertificate(fis);
            return (X509Certificate) cert;
        }
    }

    public static PrivateKey loadPrivateKey(String filePath) throws Exception {
        // 读取 PEM 文件的字节数据
        byte[] keyBytes = Files.readAllBytes(Paths.get(filePath));

        // 使用 PKCS8EncodedKeySpec 解析私钥
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        return keyFactory.generatePrivate(keySpec);
    }

}

至于OPCUA是干嘛用的,这个就需要各位小伙伴自己去查了,这次分享的主要是JAVA怎么去处理OPCUA的数据,如果本地测试可以下载一个Prosys OPCUA这个软件,在本地测试

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐