OPC UA 通讯开发笔记 - 基于Opc.Ua.Client

本文档基于自写的 OPCUA_Client_Test项目编写,讲解 OPC UA 通讯的原理、及每个方法的实现细节。

本文只用于对该项目的技术分析。项目为自用项目,源码不对外开源。

目录


项目概述

项目简介

本项目是一个基于 C# WinForms 的 OPC UA(OPC Unified Architecture)客户端应用程序,用于连接和与 OPC UA 服务器进行通讯。

技术栈

  • 开发语言: C#
  • UI框架: Windows Forms
  • OPC UA SDK: Opc.Ua.Client
  • 安全框架: BouncyCastle (用于证书处理)
  • .NET Framework: .NET Framework 4.7.2

主要功能

  • OPC UA 服务器连接(支持匿名和用户名密码认证)
  • 节点浏览(树形结构)
  • 节点值读取(同步/异步)
  • 节点值写入(同步/异步)
  • 数据订阅(实时监控)
  • 批量操作支持
  • 证书管理

OPC UA 通讯原理

什么是 OPC UA

OPC UA(OPC Unified Architecture)是一种跨平台、面向服务的工业自动化通讯协议。它是 OPC(OLE for Process Control)的下一代标准,解决了传统 OPC 的安全性和跨平台问题。

OPC UA 架构

1. 通讯模型

OPC UA 采用客户端-服务器架构:

  • 客户端: 发起连接请求,浏览节点,读写数据
  • 服务器: 提供数据服务,响应客户端请求
2. 地址空间

OPC UA 使用节点(Node)来表示信息模型中的对象:

  • Object: 对象节点,代表实体
  • Variable: 变量节点,存储数据
  • Method: 方法节点,可调用的功能
  • View: 视图节点,定义浏览视图
3. 通讯模式

OPC UA 支持多种数据访问模式:

  • 读取: 同步读取 (Read) / 异步读取 (ReadAsync)
  • 写入: 同步写入 (Write) / 异步写入 (WriteAsync)
  • 订阅: 实时数据变化通知
  • 浏览: 遍历地址空间结构
4. 安全机制
  • 证书验证: X.509 证书双向认证
  • 加密: 支持多种加密策略(None, Basic128Rsa15, Basic256, Basic256Sha256)
  • 签名: 消息完整性保护
  • 用户认证: 匿名、用户名密码、证书等多种认证方式

通讯流程

复制代码
1. 应用配置 (BuildConfig)
   ↓
2. 证书创建与验证 (CreateCertificateAndAddToStore)
   ↓
3. 获取服务器端点 (SelectEndpoint)
   ↓
4. 创建会话连接 (Session.Create)
   ↓
5. 浏览节点 (BrowserNode)
   ↓
6. 读写数据 (SessionSyncRead/Write)
   ↓
7. 订阅监控 (SessionSubsrciption)

复制代码
客户端                          服务器
  |                              |
  |------- 1. 发现 Endpoints ----->|
  |<----- 2. 返回可用端点 ---------|
  |                              |
  |------- 3. 创建会话 ----------->|
  |<----- 4. 证书验证 -------------|
  |------- 5. 验证通过 ----------->|
  |                              |
  |------- 6. 浏览/读写/订阅 ----->|
  |<----- 7. 返回数据 -------------|
## 项目结构

OPCUA Client/

├── OPCUAClient.cs # OPC UA 客户端核心类

├── Form1.cs # 主窗体

├── FormCertClient.cs # 证书管理窗体

├── HelpForm.cs # 帮助窗体

├── MyListView.cs # 自定义ListView控件

├── Program.cs # 程序入口

└── Properties/

├── AssemblyInfo.cs # 程序集信息

├── Resources.Designer.cs

└── Settings.Designer.cs

复制代码
## 核心类详解

### 1. UAClient 类

**命名空间**: `OPCUA_Client`

**功能**: OPC UA 客户端的核心功能实现类

#### 字段说明

```csharp
// 应用实例,对应一个配置对象
private static ApplicationInstance myAppInstance = null;

// 证书验证器
private CertificateValidator certificateValidator = null;

// 证书认证步骤计数器
private static int certStep = 0;

// 存储discoveryClient获取的endpoints
private static EndpointDescriptionCollection myEndpoints = null;

// 用户身份证明(UserName, Password等)
public static UserIdentity userIdentity { get; set; } = null;

// 订阅相关的静态变量
public static ListViewItem subListViewItem = null;
public static NodeId subNodeId = null;
public static MonitoredItem mi = null;
private static Dictionary<string, Subscription> dic_subscriptions;
public static bool subOpen;
主要功能区域
  1. 应用全局配置 (#region 1-应用全局配置)
  2. 会话、节点读写订阅相关 (#region 会话,节点读写订阅相关)
  3. 全局方法 (#region 全局方法)

方法详解

一、应用全局配置方法

1.1 BuildConfig()

作用: 创建并配置 OPC UA 客户端应用实例

原理:

  1. 创建 ApplicationInstance 对象,设置为客户端类型
  2. 创建 ApplicationConfiguration 对象进行详细配置
  3. 设置证书验证器,绑定证书验证事件处理函数
  4. 为后续的连接操作准备基础配置

使用场景: 程序启动时或重新连接前调用

代码解析:

csharp 复制代码
public void BuildConfig()
{
    string clientName = "myAppClient";

    // 创建应用实例,指定为客户端类型
    myAppInstance = new ApplicationInstance()
    {
        ApplicationType = ApplicationType.Client,
        ApplicationName = clientName,
    };

    // 创建应用配置对象
    myAppInstance.ApplicationConfiguration = new Opc.Ua.ApplicationConfiguration();

    // 进行应用配置
    CreateClientConfiguration();

    // 配置证书验证器并绑定事件
    certificateValidator = new CertificateValidator();
    myAppInstance.ApplicationConfiguration.CertificateValidator = certificateValidator;
    certificateValidator.CertificateValidation += certClient;
}

新手理解 :

就像你需要一张身份证才能进入某些场所一样,OPC UA 客户端也需要配置"身份信息"才能连接到服务器。BuildConfig() 就是负责制作这张"身份证"的方法。


1.2 CreateCertificateAndAddToStore()

作用: 创建自签名证书并存储到系统证书存储区

原理:

  1. 获取本机的 IP 地址和 DNS 名称
  2. 使用 CertificateFactory 创建证书构建器
  3. 设置证书的有效期、加密算法、密钥大小等参数
  4. 生成 RSA 密钥对并创建证书
  5. 将证书添加到 Windows 证书存储区

参数说明:

  • applicationUri: 应用的唯一标识符
  • applicationName: 应用名称
  • storeType: 证书存储类型(如 X509Store)
  • storePath: 证书存储路径(如 "CurrentUser\My")

使用场景: 首次运行时或证书不存在时自动调用

代码解析:

csharp 复制代码
private void CreateCertificateAndAddToStore(
    string applicationUri,
    string applicationName,
    string storeType,
    string storePath)
{
    // 获取本地 IP 地址
    List<string> localIps = GetLocalIpAddressAndDns();

    // 证书参数设置
    ushort keySize = 2048;          // 密钥大小:2048位
    ushort lifeTimeInMonths = 24;  // 证书有效期:24个月
    ushort hashSizeInBits = 256;    // 哈希算法:SHA-256
    var startTime = System.DateTime.Now;

    // 创建证书构建器
    var certificateBuilder = CertificateFactory.CreateCertificate(
        applicationUri, applicationName, null, localIps);

    // 配置并创建证书
    X509Certificate2 clientCertificate2 = certificateBuilder
        .SetNotBefore(startTime)
        .SetNotAfter(startTime.AddMonths(lifeTimeInMonths))
        .SetHashAlgorithm(X509Utils.GetRSAHashAlgorithmName(hashSizeInBits))
        .SetRSAKeySize(keySize)
        .CreateForRSA();

    // 设置友好名称并存储到证书存储区
    clientCertificate2.FriendlyName = myAppInstance.ApplicationName;
    clientCertificate2.AddToStore(storeType, storePath, null);
}

新手理解 :

这就像办理正式身份证的过程。系统会收集你的基本信息(应用名称、IP地址),生成一个带有加密签名的证书,然后把这张证书保存在安全的地方(证书存储区)备用。


1.3 GetLocalIpAddressAndDns()

作用: 获取本机的所有 IPv4 地址和主机名

原理:

  1. 通过 Dns.GetHostEntry 获取主机信息
  2. 遍历所有网络接口
  3. 筛选出 IPv4 地址(AddressFamily.InterNetwork)
  4. 添加主机名到列表

返回值: 包含 IP 地址和主机名的字符串列表

使用场景: 创建证书时需要将本机地址信息写入证书

代码解析:

csharp 复制代码
private List<string> GetLocalIpAddressAndDns()
{
    List<string> localIps = new List<string>();
    var host = Dns.GetHostEntry(Dns.GetHostName());

    // 遍历所有地址
    foreach (var ip in host.AddressList)
    {
        // 只保留 IPv4 地址
        if (ip.AddressFamily == AddressFamily.InterNetwork)
        {
            localIps.Add(ip.ToString());
        }
    }

    // 如果没找到 IP 地址,抛出异常
    if (localIps.Count == 0)
    {
        throw new Exception("Local IP Address Not Found!");
    }

    // 添加主机名
    localIps.Add(Dns.GetHostName());
    return localIps;
}

新手理解 :

就像你要在名片上印上联系方式一样,证书也需要记录你的"联系方式"------IP 地址和计算机名称,这样服务器才能识别你。


1.4 CreateClientConfiguration()

作用: 创建详细的客户端配置

原理:

  1. 配置应用基本信息(名称、类型、URI)
  2. 配置安全设置(证书、信任的证书存储)
  3. 检查并创建应用证书
  4. 配置传输配额(防止 DOS 攻击)
  5. 配置客户端特定设置
  6. 验证配置的一致性

配置项详解:

  • 应用标识: ApplicationUri、ProductUri、ApplicationName
  • 安全配置: 证书存储位置、自动接受未信任证书设置
  • 传输配额: 消息大小限制、操作超时、安全令牌生命周期
  • 客户端配置: 默认会话超时时间

代码解析:

csharp 复制代码
private void CreateClientConfiguration()
{
    Opc.Ua.ApplicationConfiguration configuration = myAppInstance.ApplicationConfiguration;

    // Step 1 - 指定客户端标识
    configuration.ApplicationName = myAppInstance.ApplicationName;
    configuration.ApplicationType = myAppInstance.ApplicationType;
    configuration.ApplicationUri = "urn:MyClient";
    configuration.ProductUri = "myAppClient1.0";

    // Step 2 - 进行安全配置
    configuration.SecurityConfiguration.AutoAcceptUntrustedCertificates = true;
    configuration.SecurityConfiguration.RejectSHA1SignedCertificates = false;

    // 配置应用证书
    configuration.SecurityConfiguration = new SecurityConfiguration();
    configuration.SecurityConfiguration.ApplicationCertificate = new CertificateIdentifier();
    configuration.SecurityConfiguration.ApplicationCertificate.StoreType = CertificateStoreType.X509Store;
    configuration.SecurityConfiguration.ApplicationCertificate.StorePath = "CurrentUser\\My";
    configuration.SecurityConfiguration.ApplicationCertificate.SubjectName = configuration.ApplicationName;

    // 配置受信任的证书存储
    configuration.SecurityConfiguration.TrustedIssuerCertificates.StoreType = CertificateStoreType.X509Store;
    configuration.SecurityConfiguration.TrustedIssuerCertificates.StorePath = "CurrentUser\\Root";
    configuration.SecurityConfiguration.TrustedPeerCertificates.StoreType = CertificateStoreType.X509Store;
    configuration.SecurityConfiguration.TrustedPeerCertificates.StorePath = "CurrentUser\\Root";

    // 查找或创建证书
    Task<X509Certificate2> clientCertificate = configuration.SecurityConfiguration.ApplicationCertificate.Find(true);
    if (clientCertificate.Result == null)
    {
        CreateCertificateAndAddToStore(
            configuration.ApplicationUri,
            configuration.ApplicationName,
            configuration.SecurityConfiguration.ApplicationCertificate.StoreType,
            configuration.SecurityConfiguration.ApplicationCertificate.StorePath);
    }

    // Step 3 - 指定支持的传输配额
    configuration.TransportQuotas = new TransportQuotas();
    configuration.TransportQuotas.OperationTimeout = 360000;
    configuration.TransportQuotas.SecurityTokenLifetime = 86400000;
    configuration.TransportQuotas.MaxStringLength = 67108864;
    configuration.TransportQuotas.MaxByteStringLength = 16777216;

    // Step 4 - 指定客户端特定的配置
    configuration.ClientConfiguration = new ClientConfiguration();
    configuration.ClientConfiguration.DefaultSessionTimeout = 360000;

    // Step 5 - 验证配置
    _ = configuration.Validate(ApplicationType.Client);
}

新手理解 :

这就像详细填写个人信息表格。你需要提供:

  • 基本信息是谁
  • 安全设置(钥匙存放位置、是否接受陌生人的钥匙)
  • 通讯限制(最多发送多长的消息、超时时间)
  • 这些设置决定了你和其他服务器"交往"的规则

1.5 certClient()

作用: 证书验证事件处理函数

原理:

  1. 当需要验证服务器证书时触发
  2. 检查本地证书存储是否已有该证书
  3. 如果没有,弹出证书详情窗体让用户确认
  4. 使用 certStep 计数器处理重复弹窗问题
  5. 设置 e.Accept 决定是否接受证书

事件参数:

  • sender: 事件发送者(CertificateValidator)
  • e: CertificateValidationEventArgs,包含证书信息和 Accept 标志

代码解析:

csharp 复制代码
private void certClient(object sender, CertificateValidationEventArgs e)
{
    if (certStep == 0)
    {
        // 打开证书存储,查找是否已有该证书
        X509Store store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
        store.Open(OpenFlags.ReadOnly);
        X509CertificateCollection certCol = store.Certificates.Find(
            X509FindType.FindByThumbprint, e.Certificate.Thumbprint, true);
        store.Close();

        if (certCol.Capacity > 0)
        {
            // 证书已存在,直接接受
            e.Accept = true;
        }
        else
        {
            // 证书不存在,弹出确认窗体
            FormCertClient formCertClient = new FormCertClient(e);
            formCertClient.ShowDialog();
        }

        if (e.Accept == true)
        {
            certStep++;
        }
    }
    else
    {
        // 第二次弹窗直接接受
        e.Accept = true;
        certStep = 0;
    }
}

新手理解 :

就像你要和陌生人见面时,需要确认他的身份。如果你们之前见过(证书已存储),就直接信任;如果没见过,就展示他的"身份证"(证书详情)让你决定是否接受。


二、会话、节点读写订阅相关方法

2.1 AnonmousConnect()

作用: 匿名连接到 OPC UA 服务器

原理:

  1. 创建 ApplicationConfiguration 和 ClientConfiguration
  2. 使用 EndpointDescription 创建端点
  3. 调用 Session.Create 创建匿名会话
  4. 使用空的 UserIdentity 表示匿名登录

参数说明:

  • endpoint: 服务器端点 URL(如 "opc.tcp://localhost:4840")

返回值: Task - 异步返回会话对象

使用场景: 连接到不需要用户名密码的服务器

代码解析:

csharp 复制代码
public static Task<Session> AnonmousConnect(string endpoint)
{
    return Session.Create(
        new ApplicationConfiguration()
        {
            ClientConfiguration = new ClientConfiguration()
        },
        new ConfiguredEndpoint(null, new EndpointDescription(endpoint)),
        true,
        sessionName: "GKXLJ",
        sessionTimeout: 60000,
        new UserIdentity(),  // 空的 UserIdentity 表示匿名
        new List<string>() { }
    );
}

新手理解 :

这就像用"访客"身份进入某个地方,不需要出示身份证,但可能只能访问有限的内容。


2.2 UserNameConnect()

作用: 使用用户名和密码连接到 OPC UA 服务器

原理:

  1. 创建 UserIdentity 对象,设置用户名和密码
  2. 选择安全性最高的端点
  3. 创建 ConfiguredEndpoint
  4. 调用 Session.Create 创建认证会话

参数说明:

  • endpoint: 服务器端点 URL
  • username: 用户名
  • password: 密码

返回值: Task - 异步返回会话对象

使用场景: 连接到需要身份验证的服务器

代码解析:

csharp 复制代码
public static async Task<Session> UserNameConnect(
    string endpoint,
    string username,
    string password)
{
    // 创建用户身份
    userIdentity = new Opc.Ua.UserIdentity(username, password);

    // 选择安全性最高的端点
    var endpointsDescription = SelectEndpoint(new Uri(endpoint), false);

    // 创建配置的端点
    ConfiguredEndpoint cfEndpoint = new ConfiguredEndpoint(
        collection: null,
        description: endpointsDescription,
        configuration: Opc.Ua.EndpointConfiguration.Create(
            applicationConfiguration: myAppInstance.ApplicationConfiguration));

    // 创建会话
    return await Session.Create(
        configuration: myAppInstance.ApplicationConfiguration,
        cfEndpoint,
        updateBeforeConnect: false,
        checkDomain: false,
        sessionName: "GKXLJ",
        sessionTimeout: 60000,
        identity: userIdentity,
        preferredLocales: new string[] { "zh-CN" }
    );
}

新手理解 :

这就像用"会员"身份进入某个地方,需要出示会员卡(用户名和密码),可以访问更多内容。


2.3 BrowserNode()

作用: 浏览 OPC UA 服务器的节点树结构

原理:

  1. 调用 session.Browse() 方法发起浏览请求
  2. 根据 nodeId 参数决定浏览根节点、父节点或子节点
  3. 使用 continuationPoint 处理大量数据的分页问题
  4. 返回 ReferenceDescriptionCollection 包含所有子节点信息

参数说明:

  • session: 已建立的会话对象
  • nodeId: 要浏览的节点 ID(空字符串表示根节点)
  • inverse: 是否反向浏览(true 查父节点,false 查子节点)

返回值: ReferenceDescriptionCollection - 节点引用描述集合

使用场景: 构建节点树形视图、查找特定节点

代码解析:

csharp 复制代码
public static ReferenceDescriptionCollection BrowserNode(
    Session session,
    string nodeId,
    bool inverse)
{
    dic_subscriptions = new Dictionary<string, Subscription>();
    ReferenceDescriptionCollection referenceDescriptionCollection;
    byte[] continuationPoint;

    if (nodeId == "")
    {
        // 浏览根节点
        session.Browse(
            null,  // requestHeader
            null,  // ViewDescription view
            ObjectIds.RootFolder,  // NodeId - 根目录
            0u,  // maxResultToReturn
            BrowseDirection.Forward,  // 正向遍历
            ReferenceTypeIds.HierarchicalReferences,
            true,  // includeSubtypes
            (uint)NodeClass.Variable | (uint)NodeClass.Object | (uint)NodeClass.Method,
            out continuationPoint,
            out referenceDescriptionCollection
        );
    }
    else if (inverse == true)
    {
        // 反向查询父节点
        session.Browse(null, null, nodeId, 1,
            BrowseDirection.Inverse,
            ReferenceTypeIds.HierarchicalReferences, true, 1,
            out continuationPoint, out referenceDescriptionCollection);
    }
    else
    {
        // 正向查询子节点
        ReferenceDescriptionCollection nextreferenceDescriptionCollection;
        byte[] revisedContinuationPoint;

        session.Browse(null, null, nodeId, 0u, BrowseDirection.Forward,
            ReferenceTypeIds.HierarchicalReferences, true, 0,
            out continuationPoint, out referenceDescriptionCollection);

        // 循环获取所有子节点
        while (continuationPoint != null)
        {
            session.BrowseNext(null, false, continuationPoint,
                out revisedContinuationPoint, out nextreferenceDescriptionCollection);
            referenceDescriptionCollection.AddRange(nextreferenceDescriptionCollection);
            continuationPoint = revisedContinuationPoint;
        }
    }
    return referenceDescriptionCollection;
}

新手理解 :

这就像在图书馆找书,你可以:

  • 从入口开始(根节点)
  • 找到一本书后看它的分类目录(子节点)
  • 或查看这本书属于哪个大类(父节点)
  • 如果目录太长,会分页显示(continuationPoint)

2.4 SessionSyncRead()

作用: 同步读取单个节点的值

原理:

  1. 创建 ReadValueIdCollection 集合
  2. 设置要读取的节点 ID 和属性(Attributes.Value)
  3. 调用 session.Read() 发送同步读请求
  4. 处理返回的 DataValueCollection
  5. 判断值是否为数组,进行相应格式化
  6. 转换时间戳为本地时间
  7. 创建 ListViewItem 用于显示

参数说明:

  • session: 已建立的会话对象
  • nodeId: 要读取的节点 ID

返回值: ListViewItem - 包含节点信息的列表项

使用场景: 需要立即获取节点当前值

代码解析:

csharp 复制代码
public static ListViewItem SessionSyncRead(Session session, NodeId nodeId)
{
    ReadValueIdCollection readValues = new ReadValueIdCollection();
    ReadValueId readValueId = new ReadValueId();
    readValueId.NodeId = nodeId;           // 节点名称
    readValueId.AttributeId = Attributes.Value;  // 读取节点值属性
    readValues.Add(readValueId);

    string strValue = "";
    session.Read(
        new RequestHeader(),
        0,
        TimestampsToReturn.Both,
        readValues,
        out DataValueCollection results,
        out DiagnosticInfoCollection diagnosticInfos
    );

    for (int i = 0; i < results.Count; i++)
    {
        // 判断是否为数组
        if (results[i].Value is Array array_V)
            strValue = ArrayToString(array_V);
        else if (results[i].Value is null)
            strValue = "null";
        else
            strValue = results[i].Value.ToString();

        // 获取数据类型
        string strTypeInfo = results[i].WrappedValue.TypeInfo.ToString();

        // 转换为本地时间
        TimeZoneInfo localTimeZone = TimeZoneInfo.Local;
        DateTime dateTime = Convert.ToDateTime(results[i].ServerTimestamp);
        DateTime localTime = TimeZoneInfo.ConvertTimeFromUtc(dateTime, localTimeZone);

        // 创建列表项
        subListViewItem = new ListViewItem(nodeId.ToString());
        subListViewItem.SubItems.Add(strValue);         // 当前值
        subListViewItem.SubItems.Add(strTypeInfo);      // 数据类型
        subListViewItem.SubItems.Add(results[i].StatusCode.ToString());  // 状态码
        subListViewItem.SubItems.Add(localTime.ToString());  // 时间戳
    }
    return subListViewItem;
}

新手理解 :

这就像你问朋友"现在几点了?",朋友会立即告诉你答案。同步读取就是这样的"问-答"过程。


2.5 SessionAsyncRead()

作用: 异步读取单个节点的值

原理:

  1. 与同步读取类似,但使用 session.ReadAsync()
  2. 使用 await 等待异步操作完成
  3. 使用 CancellationToken 取消令牌
  4. 返回 ReadResponse 对象包含结果

参数说明:

  • session: 已建立的会话对象
  • nodeId: 要读取的节点 ID

返回值: Task - 异步返回节点信息

使用场景: 需要读取多个节点且不阻塞界面线程

代码解析:

csharp 复制代码
public static async Task<ListViewItem> SessionAsyncRead(
    Session session,
    NodeId nodeId)
{
    ReadValueIdCollection readValues = new ReadValueIdCollection();
    ReadValueId readValueId = new ReadValueId();
    readValueId.NodeId = nodeId;
    readValueId.AttributeId = Attributes.Value;
    readValues.Add(readValueId);

    string strValue = "";
    CancellationToken ct = new CancellationToken();

    // 异步读取
    ReadResponse response = await session.ReadAsync(
        new RequestHeader(),
        0,
        TimestampsToReturn.Both,
        readValues,
        ct
    );

    for (int i = 0; i < response.Results.Count; i++)
    {
        if (response.Results[i].Value is Array array_V)
            strValue = ArrayToString(array_V);
        else if (response.Results[i].Value is null)
            strValue = "null";
        else
            strValue = response.Results[i].Value.ToString();

        string strTypeInfo = response.Results[i].WrappedValue.TypeInfo.ToString();

        TimeZoneInfo localTimeZone = TimeZoneInfo.Local;
        DateTime dateTime = Convert.ToDateTime(response.Results[i].ServerTimestamp);
        DateTime localTime = TimeZoneInfo.ConvertTimeFromUtc(dateTime, localTimeZone);

        subListViewItem = new ListViewItem(nodeId.ToString());
        subListViewItem.SubItems.Add(strValue);
        subListViewItem.SubItems.Add(strTypeInfo);
        subListViewItem.SubItems.Add(response.Results[i].StatusCode.ToString());
        subListViewItem.SubItems.Add(localTime.ToString());
    }
    return subListViewItem;
}

新手理解 :

这就像你给朋友发短信问"现在几点了?",然后你可以继续做别的事,等朋友回复时再处理。异步读取不会让你的界面"卡住"。


2.6 SessionSyncWrite()

作用: 同步写入单个节点的值

原理:

  1. 将字符串类型转换为对应的 TypeCode
  2. 创建 DataValue 和 WriteValue 对象
  3. 设置节点 ID、属性 ID 和值
  4. 处理数组和普通值的区别
  5. 调用 session.Write() 发送同步写请求
  6. 检查返回的 StatusCode 确认写入是否成功

参数说明:

  • session: 已建立的会话对象
  • nodeId: 要写入的节点 ID
  • value: 要写入的值
  • valueType: 值的数据类型(如 "Int32", "String")
  • isArray: 是否为数组类型

返回值: uint - 状态码(0 表示成功)

使用场景: 需要立即设置节点值

代码解析:

csharp 复制代码
public static uint SessionSyncWrite(
    Session session,
    NodeId nodeId,
    object value,
    string valueType,
    bool isArray)
{
    TypeCode typeCode = ChangeValueType(valueType);
    DataValue dataValue = new DataValue();
    WriteValueCollection writeValueCollection = new WriteValueCollection();
    WriteValue writeValue = new WriteValue();

    writeValue.NodeId = nodeId;
    writeValue.AttributeId = Attributes.Value;

    if (isArray)
    {
        // 处理数组类型
        dataValue.Value = ChangeValueTypeToArray(value, valueType);
        writeValue.Value = (DataValue)dataValue.Value;
    }
    else
    {
        // 处理普通类型
        dataValue.Value = Convert.ChangeType(value, typeCode);
        writeValue.Value = dataValue;
    }

    writeValueCollection.Add(writeValue);

    // 执行写入
    session.Write(new RequestHeader(),
        writeValueCollection,
        out StatusCodeCollection statusCodes,
        out DiagnosticInfoCollection diagnosticInfos
    );

    uint statusResult = 001;
    foreach (var item in statusCodes)
    {
        statusResult = item.Code;  // 0 表示成功
    }
    return statusResult;
}

新手理解 :

这就像你告诉朋友"把闹钟设为7点",朋友会立即帮你设置。同步写入就是这样的"指令-执行"过程。


2.7 SessionAsyncWrite()

作用: 异步写入单个节点的值

原理:

  1. 与同步写入类似,但使用 session.WriteAsync()
  2. 使用 .Result 等待异步操作完成
  3. 返回 WriteResponse 对象包含结果

参数说明:

  • 与 SessionSyncWrite 相同

返回值: uint - 状态码(0 表示成功)

使用场景: 需要写入多个节点且不阻塞界面线程

代码解析:

csharp 复制代码
public static uint SessionAsyncWrite(
    Session session,
    NodeId nodeId,
    object value,
    string valueType,
    bool isArray)
{
    TypeCode typeCode = ChangeValueType(valueType);
    DataValue dataValue = new DataValue();
    WriteValueCollection writeValueCollection = new WriteValueCollection();
    WriteValue writeValue = new WriteValue();

    writeValue.NodeId = nodeId;
    writeValue.AttributeId = Attributes.Value;

    if (isArray)
    {
        dataValue.Value = ChangeValueTypeToArray(value, valueType);
        writeValue.Value = (DataValue)dataValue.Value;
    }
    else
    {
        dataValue.Value = Convert.ChangeType(value, typeCode);
        writeValue.Value = dataValue;
    }

    writeValueCollection.Add(writeValue);

    CancellationToken cancellationToken = new CancellationToken();
    WriteResponse response = session.WriteAsync(
        new RequestHeader(),
        writeValueCollection,
        cancellationToken
    ).Result;

    uint statusResult = 001;
    foreach (var item in response.Results)
    {
        statusResult = item.Code;
    }
    return statusResult;
}

2.8 SessionSubsrciption()

作用: 创建或取消节点的订阅

原理:

  1. 获取会话的默认订阅(DefaultSubscription)
  2. 如果 subOpen 为 true,创建 MonitoredItem 并订阅
  3. 绑定 Notification 事件处理函数
  4. 将监控项添加到订阅并创建订阅
  5. 如果 subOpen 为 false,取消订阅并删除监控项

参数说明:

  • session: 已建立的会话对象
  • nodeId: 要订阅的节点 ID
  • subOpen: true 表示订阅,false 表示取消订阅

使用场景: 实时监控节点值的变化

代码解析:

csharp 复制代码
public static void SessionSubsrciption(
    Session session,
    NodeId nodeId,
    bool subOpen)
{
    var sub = session.DefaultSubscription;
    bool miNfOpen = false;

    if (subOpen)
    {
        // 创建监控项
        mi = new MonitoredItem();
        mi.StartNodeId = nodeId;
        subNodeId = nodeId;
        mi.Notification += Mi_Notification;  // 绑定通知事件
        sub.AddItem(mi);
        session.AddSubscription(sub);
        sub.Create();
        miNfOpen = true;
    }

    if (!subOpen && miNfOpen && session.Connected)
    {
        // 取消订阅
        miNfOpen = false;
        mi.Notification -= Mi_Notification;  // 解绑事件
        sub.RemoveItem(mi);
        sub.Delete(true);
    }
}

新手理解 :

这就像你订阅了某个频道的更新通知。每当频道有新内容时,你会立即收到通知。订阅就是这样的"自动通知"机制。


2.9 Mi_Notification()

作用: 订阅通知事件处理函数

原理:

  1. 当订阅的节点值变化时触发
  2. 获取变化后的值和元数据
  3. 判断是否为数组并格式化
  4. 转换时间戳为本地时间
  5. 创建 ListViewItem 用于更新界面

参数说明:

  • monitoredItem: 触发通知的监控项
  • e: 通知事件参数,包含新值等信息

使用场景: 更新界面显示实时数据

代码解析:

csharp 复制代码
private static void Mi_Notification(
    MonitoredItem monitoredItem,
    MonitoredItemNotificationEventArgs e)
{
    string strValue = "";
    var item = e.NotificationValue as MonitoredItemNotification;

    // 判断是否为数组
    if (item.Value.Value is Array array_V)
        strValue = ArrayToString(array_V);
    else if (item.Value.Value is null)
        strValue = "null";
    else
        strValue = item.Value.Value.ToString();

    string strTypeInfo = item.Value.WrappedValue.TypeInfo.ToString();

    // 转换为本地时间
    TimeZoneInfo localTimeZone = TimeZoneInfo.Local;
    DateTime dateTime = Convert.ToDateTime(item.Value.ServerTimestamp);
    DateTime localTime = TimeZoneInfo.ConvertTimeFromUtc(dateTime, localTimeZone);

    // 创建列表项
    subListViewItem = new ListViewItem(subNodeId.ToString());
    subListViewItem.SubItems.Add(strValue);
    subListViewItem.SubItems.Add(strTypeInfo);
    subListViewItem.SubItems.Add(item.Value.StatusCode.ToString());
    subListViewItem.SubItems.Add(localTime.ToString());
}

新手理解 :

这就像你订阅的新闻推送来了,你的手机会弹出通知显示新闻内容。Mi_Notification 就是处理这些通知的函数。


2.10 SessionReadMultiple()

作用: 批量读取多个节点的值

原理:

  1. 接收 NodeId 列表作为参数
  2. 调用 session.ReadValues() 批量读取
  3. 处理返回的 DataValueCollection 和错误列表
  4. 对每个节点的结果进行格式化
  5. 返回 ListViewItem 数组

参数说明:

  • session: 已建立的会话对象
  • nodeIds: 要读取的节点 ID 列表

返回值: ListViewItem[] - 包含所有节点信息的数组

使用场景: 需要读取大量节点时提高效率

代码解析:

csharp 复制代码
public static ListViewItem[] SessionReadMultiple(
    Session session,
    IList<NodeId> nodeIds)
{
    ListViewItem[] listViewItems = new ListViewItem[nodeIds.Count];

    session.ReadValues(
        nodeIds,
        out DataValueCollection results,
        out IList<ServiceResult> errors
    );

    string strValue = "";
    string strTypeInfo = "";
    DateTime localTime = DateTime.Now;

    for (int i = 0; i < results.Count; i++)
    {
        uint statusCode = results[i].StatusCode.Code;
        if (statusCode == 0)
        {
            // 读取成功,处理值
            if (results[i].Value is Array array_V)
                strValue = ArrayToString(array_V);
            else if (results[i].Value is null)
                strValue = "null";
            else
                strValue = results[i].Value.ToString();

            strTypeInfo = results[i].WrappedValue.TypeInfo.ToString();

            TimeZoneInfo localTimeZone = TimeZoneInfo.Local;
            DateTime dateTime = Convert.ToDateTime(results[i].ServerTimestamp);
            localTime = TimeZoneInfo.ConvertTimeFromUtc(dateTime, localTimeZone);
        }
        else
        {
            strValue = "";
            strTypeInfo = "";
        }

        listViewItems[i] = new ListViewItem(nodeIds[i].ToString());
        listViewItems[i].SubItems.Add(strValue);
        listViewItems[i].SubItems.Add(strTypeInfo);
        listViewItems[i].SubItems.Add(results[i].StatusCode.ToString());
        listViewItems[i].SubItems.Add(localTime.ToString());
    }
    return listViewItems;
}

新手理解 :

这就像你一次性问朋友几个问题,朋友一次性回答所有问题,比一个一个问效率高很多。


2.11 SessionWriteMultiple()

作用: 批量写入多个节点的值

原理:

  1. 接收节点 ID、值、类型、是否数组的数组作为参数
  2. 为每个节点创建 WriteValue 对象
  3. 逐个调用 session.Write() 写入
  4. 收集所有写入的状态码

参数说明:

  • session: 已建立的会话对象
  • nodeIds: 节点 ID 数组
  • value: 值数组
  • valueType: 数据类型数组
  • isArray: 是否数组数组

返回值: uint[] - 每个节点的状态码数组

使用场景: 需要写入大量节点时提高效率

代码解析:

csharp 复制代码
public static uint[] SessionWriteMultiple(
    Session session,
    NodeId[] nodeIds,
    object[] value,
    string[] valueType,
    bool[] isArray)
{
    TypeCode[] typeCode = new TypeCode[valueType.Length];
    for (int i = 0; i < valueType.Length; i++)
    {
        typeCode[i] = ChangeValueType(valueType[i]);
    }

    DataValue[] dataValue = new DataValue[nodeIds.Length];
    WriteValueCollection[] writeValueCollection = new WriteValueCollection[nodeIds.Length];
    WriteValue[] writeValue = new WriteValue[nodeIds.Length];
    uint[] statusResult = new uint[nodeIds.Length];

    for (int i = 0; i < nodeIds.Length; i++)
    {
        dataValue[i] = new DataValue();
        writeValueCollection[i] = new WriteValueCollection();
        writeValue[i] = new WriteValue();
        writeValue[i].NodeId = nodeIds[i];
        writeValue[i].AttributeId = Attributes.Value;

        if (isArray[i])
        {
            dataValue[i].Value = ChangeValueTypeToArray(value[i], valueType[i]);
            writeValue[i].Value = (DataValue)dataValue[i].Value;
        }
        else
        {
            dataValue[i].Value = Convert.ChangeType(value[i], typeCode[i]);
            writeValue[i].Value = dataValue[i];
        }

        writeValueCollection[i].Add(writeValue[i]);
        session.Write(new RequestHeader(),
            writeValueCollection[i],
            out StatusCodeCollection statusCodes,
            out DiagnosticInfoCollection diagnosticInfos
        );

        for (int j = 0; j < statusCodes.Count; j++)
        {
            statusResult[j] = (uint)statusCodes[j];
        }
    }
    return statusResult;
}

2.12 AddSubscription()

作用: 添加批量订阅

原理:

  1. 创建新的 Subscription 对象
  2. 配置订阅参数(发布间隔、优先级等)
  3. 为每个标签创建 MonitoredItem
  4. 绑定回调函数到通知事件
  5. 使用字典管理多个订阅

参数说明:

  • session: 会话对象
  • key: 订阅键名
  • tags: 节点标签数组
  • callback: 回调函数

返回值: bool - 是否成功

使用场景: 批量监控多个节点的变化

代码解析:

csharp 复制代码
public bool AddSubscription(
    Session session,
    string key,
    string[] tags,
    Action<string, MonitoredItem, MonitoredItemNotificationEventArgs> callback)
{
    Subscription subscription = new Subscription(session.DefaultSubscription);
    subscription.PublishingEnabled = true;
    subscription.PublishingInterval = 0;
    subscription.KeepAliveCount = uint.MaxValue;
    subscription.LifetimeCount = uint.MaxValue;
    subscription.MaxNotificationsPerPublish = uint.MaxValue;
    subscription.Priority = 100;
    subscription.DisplayName = key;

    for (int i = 0; i < tags.Length; i++)
    {
        MonitoredItem monitoredItem2 = new MonitoredItem
        {
            StartNodeId = new NodeId(tags[i]),
            AttributeId = 13u,
            DisplayName = tags[i],
            SamplingInterval = 100
        };
        monitoredItem2.Notification += delegate (MonitoredItem monitoredItem, MonitoredItemNotificationEventArgs args)
        {
            callback?.Invoke(key, monitoredItem, args);
        };
        subscription.AddItem(monitoredItem2);
    }

    session.AddSubscription(subscription);
    subscription.Create();
    subOpen = true;

    lock (dic_subscriptions)
    {
        if (dic_subscriptions.ContainsKey(key))
        {
            dic_subscriptions[key].Delete(silent: true);
            session.RemoveSubscription(dic_subscriptions[key]);
            dic_subscriptions[key].Dispose();
            dic_subscriptions[key] = subscription;
        }
        else
        {
            dic_subscriptions.Add(key, subscription);
        }
    }
    return subOpen;
}

2.13 RemoveAllSubscription()

作用: 移除所有订阅

原理:

  1. 遍历所有订阅
  2. 依次删除每个订阅
  3. 清空订阅字典
  4. 关闭订阅标志

参数说明:

  • session: 会话对象

返回值: bool - 是否成功

使用场景: 断开连接或重置监控时

代码解析:

csharp 复制代码
public bool RemoveAllSubscription(Session session)
{
    lock (dic_subscriptions)
    {
        foreach (KeyValuePair<string, Subscription> dic_subscription in dic_subscriptions)
        {
            dic_subscription.Value.Delete(silent: true);
            session.RemoveSubscription(dic_subscription.Value);
            dic_subscription.Value.Dispose();
        }

        dic_subscriptions.Clear();
        subOpen = false;
    }
    return subOpen;
}

三、全局方法

3.1 SelectEndpoint()

作用: 选择安全性最高的端点

原理:

  1. 创建 DiscoveryClient 连接到服务器
  2. 获取所有可用的端点
  3. 根据安全策略和安全等级筛选
  4. 选择最安全的端点返回

参数说明:

  • discoveryUrl: 发现 URL
  • useSecurity: 是否使用安全策略

返回值: EndpointDescription - 选择的端点描述

代码解析:

csharp 复制代码
private static EndpointDescription SelectEndpoint(Uri discoveryUrl, bool useSecurity)
{
    var configuration = Opc.Ua.EndpointConfiguration.Create();
    configuration.OperationTimeout = 5000;
    EndpointDescription endpointDescriptionMain = null;

    try
    {
        using (var discoveryClient = DiscoveryClient.Create(discoveryUrl, configuration))
        {
            myEndpoints = discoveryClient.GetEndpoints(null);

            foreach (var endpointDescriptionAlternate in myEndpoints
                .Where(endpointDescriptionAlternate =>
                    endpointDescriptionAlternate.EndpointUrl.StartsWith(discoveryUrl.Scheme)))
            {
                // 检查安全模式
                if (useSecurity)
                {
                    // 使用安全的端点
                }
                else if (endpointDescriptionAlternate.SecurityMode != MessageSecurityMode.None)
                    continue;

                // 选择更安全的端点
                if (endpointDescriptionMain == null)
                {
                    endpointDescriptionMain = endpointDescriptionAlternate;
                }
                else if (endpointDescriptionAlternate.SecurityMode > endpointDescriptionMain.SecurityMode ||
                    (endpointDescriptionAlternate.SecurityMode == endpointDescriptionMain.SecurityMode &&
                     endpointDescriptionAlternate.SecurityLevel > endpointDescriptionMain.SecurityLevel))
                {
                    endpointDescriptionMain = endpointDescriptionAlternate;
                }
            }

            if (endpointDescriptionMain == null && myEndpoints.Count > 0)
            {
                endpointDescriptionMain = myEndpoints[0];
            }
        }
    }
    catch (Exception ex)
    {
        MessageBox.Show("获取接入点时出现错误:\r\n" + ex.ToString());
        return null;
    }

    return endpointDescriptionMain;
}

常见问题

Q1: 连接时提示证书错误怎么办?

A: 证书错误通常是因为服务器证书不受信任。解决方案:

  1. 在 BuildConfig() 中设置自动接受未信任证书:
csharp 复制代码
configuration.SecurityConfiguration.AutoAcceptUntrustedCertificates = true;
  1. 手动处理证书验证事件,在 certClient() 中添加接受逻辑:
csharp 复制代码
private void certClient(object sender, CertificateValidationEventArgs e)
{
    e.Accept = true;  // 接受所有证书
}

Q2: 如何处理数组类型的数据?

A: 使用以下方法:

读取数组:

csharp 复制代码
var item = UAClient.SessionSyncRead(session, nodeId);
// 数组会自动转换为逗号分隔的字符串

写入数组:

csharp 复制代码
string arrayValue = "1,2,3,4,5";
uint status = UAClient.SessionSyncWrite(session, nodeId, arrayValue, "Int32", true);

Q3: 订阅通知没有触发怎么办?

A: 检查以下几点:

  1. 确认订阅已创建且 Enable:
csharp 复制代码
subscription.PublishingEnabled = true;
  1. 确认会话连接正常:
csharp 复制代码
if (session.Connected)
{
    // 创建订阅
}
  1. 检查采样间隔设置是否合理:
csharp 复制代码
monitoredItem.SamplingInterval = 100;  // 100ms

Q4: 批量操作比单次操作快吗?

A: 是的,批量操作通常更快:

  • 批量读取: 一次请求读取多个节点,减少网络往返
  • 批量写入: 一次请求写入多个节点
  • 批量订阅: 管理多个监控项,统一处理通知

Q5: 如何判断读写操作是否成功?

A: 检查状态码:

写入操作:

csharp 复制代码
uint status = UAClient.SessionSyncWrite(session, nodeId, value, type, false);
if (status == 0)
{
    Console.WriteLine("成功");
}
else
{
    Console.WriteLine($"失败,状态码: {status}");
}

读取操作:

csharp 复制代码
var item = UAClient.SessionSyncRead(session, nodeId);
string statusCode = item.SubItems[3].Text;  // 状态码在第四列
if (statusCode == "Good")
{
    Console.WriteLine("读取成功");
}

Q6: 如何处理 OPC UA 的时间戳?

A: OPC UA 使用 UTC 时间,需要转换为本地时间:

csharp 复制代码
// UAClient 类中已实现转换
TimeZoneInfo localTimeZone = TimeZoneInfo.Local;
DateTime dateTime = Convert.ToDateTime(results[i].ServerTimestamp);
DateTime localTime = TimeZoneInfo.ConvertTimeFromUtc(dateTime, localTimeZone);

Q7: 异步操作和同步操作有什么区别?

A:

特性 同步操作 异步操作
执行方式 阻塞等待 非阻塞,可并行
适用场景 单次操作 大量或耗时操作
用户体验 可能卡顿 流畅
代码复杂度 简单 需要处理异步

推荐: UI 相关操作使用异步,后台处理可使用同步。

Q8: 如何优化大量节点的读取性能?

A:

  1. 使用批量读取:
csharp 复制代码
ListViewItem[] items = UAClient.SessionReadMultiple(session, nodeIds);
  1. 使用订阅:
csharp 复制代码
client.AddSubscription(session, "Group1", tags, callback);
  1. 调整采样间隔:
csharp 复制代码
subscription.PublishingInterval = 1000;  // 1秒

Q9: 证书存储在什么地方?

A: 默认存储在 Windows 证书存储中:

  • 位置: CurrentUser\My
  • 查看方式 : 运行 certmgr.msc
  • 安全: 私钥受操作系统保护

Q10: 如何支持多种 OPC UA 安全策略?

A: 在 SelectEndpoint() 中配置:

csharp 复制代码
// 支持 None(无加密)
if (endpoint.SecurityMode == MessageSecurityMode.None)
    // 使用此端点

// 支持 Basic256Sha256(加密+签名)
if (endpoint.SecurityPolicyUri.EndsWith("Basic256Sha256"))
    // 使用此端点

附录

OPC UA 数据类型对照表

OPC UA 类型 C# 类型 TypeCode
Boolean bool TypeCode.Boolean
SByte sbyte TypeCode.SByte
Byte byte TypeCode.Byte
Int16 short TypeCode.Int16
UInt16 ushort TypeCode.UInt16
Int32 int TypeCode.Int32
UInt32 uint TypeCode.UInt32
Int64 long TypeCode.Int64
UInt64 ulong TypeCode.UInt64
Float float TypeCode.Single
Double double TypeCode.Double
String string TypeCode.String
DateTime DateTime TypeCode.DateTime
ByteString byte[] TypeCode.Byte

OPC UA 状态码参考

状态码 含义 说明
0x00000000 Good 操作成功
0x80000000 Bad 一般错误
0x80010000 BadUnexpectedError 意外错误
0x80020000 BadInternalError 内部错误
0x80030000 BadOutOfMemory 内存不足
0x80040000 BadResourceUnavailable 资源不可用
0x80050000 BadCommunicationError 通讯错误
0x80060000 BadEncodingError 编码错误
0x80070000 BadDecodingError 解码错误
0x80080000 BadEncodingLimitsExceeded 编码限制超出
0x80090000 BadRequestTimeout 请求超时
0x800A0000 BadResponseTimeout 响应超时

相关资源


相关推荐
迷路爸爸1802 小时前
Docker 入门学习笔记 06:用一个可复现的 Python 项目真正理解 Dockerfile
笔记·学习·docker
Engineer邓祥浩2 小时前
JVM学习笔记(6) 第二部分 自动内存管理 第5章节 调优案例分析与实战
jvm·笔记·学习
ysa0510302 小时前
斐波那契上斐波那契【矩阵快速幂】
数据结构·c++·笔记·算法
派大星~课堂3 小时前
【力扣-94.二叉树的中序遍历】Python笔记
笔记·python·leetcode
我是唐青枫3 小时前
C#.NET TPL Dataflow 深入解析:数据流管道、背压控制与实战取舍
c#·.net
ZhiqianXia3 小时前
PyTorch 学习笔记(10) : PyTorch torch.library
pytorch·笔记·学习
小陈phd3 小时前
多模态大模型学习笔记(三十一)—— 基于CCT(Compact Convolutional Transformers)实现中文车牌数据集微调
笔记·学习
zzh0813 小时前
MySQL故障排查与优化笔记
数据库·笔记·mysql
&&Citrus3 小时前
【CPN 学习笔记(三)】—— Chap3 CPN ML 编程语言 上半部分 3.1 ~ 3.3
笔记·python·学习·cpn·petri网