本文档基于自写的 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;
主要功能区域
- 应用全局配置 (#region 1-应用全局配置)
- 会话、节点读写订阅相关 (#region 会话,节点读写订阅相关)
- 全局方法 (#region 全局方法)
方法详解
一、应用全局配置方法
1.1 BuildConfig()
作用: 创建并配置 OPC UA 客户端应用实例
原理:
- 创建 ApplicationInstance 对象,设置为客户端类型
- 创建 ApplicationConfiguration 对象进行详细配置
- 设置证书验证器,绑定证书验证事件处理函数
- 为后续的连接操作准备基础配置
使用场景: 程序启动时或重新连接前调用
代码解析:
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()
作用: 创建自签名证书并存储到系统证书存储区
原理:
- 获取本机的 IP 地址和 DNS 名称
- 使用 CertificateFactory 创建证书构建器
- 设置证书的有效期、加密算法、密钥大小等参数
- 生成 RSA 密钥对并创建证书
- 将证书添加到 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 地址和主机名
原理:
- 通过 Dns.GetHostEntry 获取主机信息
- 遍历所有网络接口
- 筛选出 IPv4 地址(AddressFamily.InterNetwork)
- 添加主机名到列表
返回值: 包含 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()
作用: 创建详细的客户端配置
原理:
- 配置应用基本信息(名称、类型、URI)
- 配置安全设置(证书、信任的证书存储)
- 检查并创建应用证书
- 配置传输配额(防止 DOS 攻击)
- 配置客户端特定设置
- 验证配置的一致性
配置项详解:
- 应用标识: 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()
作用: 证书验证事件处理函数
原理:
- 当需要验证服务器证书时触发
- 检查本地证书存储是否已有该证书
- 如果没有,弹出证书详情窗体让用户确认
- 使用 certStep 计数器处理重复弹窗问题
- 设置 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 服务器
原理:
- 创建 ApplicationConfiguration 和 ClientConfiguration
- 使用 EndpointDescription 创建端点
- 调用 Session.Create 创建匿名会话
- 使用空的 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 服务器
原理:
- 创建 UserIdentity 对象,设置用户名和密码
- 选择安全性最高的端点
- 创建 ConfiguredEndpoint
- 调用 Session.Create 创建认证会话
参数说明:
endpoint: 服务器端点 URLusername: 用户名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 服务器的节点树结构
原理:
- 调用 session.Browse() 方法发起浏览请求
- 根据 nodeId 参数决定浏览根节点、父节点或子节点
- 使用 continuationPoint 处理大量数据的分页问题
- 返回 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()
作用: 同步读取单个节点的值
原理:
- 创建 ReadValueIdCollection 集合
- 设置要读取的节点 ID 和属性(Attributes.Value)
- 调用 session.Read() 发送同步读请求
- 处理返回的 DataValueCollection
- 判断值是否为数组,进行相应格式化
- 转换时间戳为本地时间
- 创建 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()
作用: 异步读取单个节点的值
原理:
- 与同步读取类似,但使用 session.ReadAsync()
- 使用 await 等待异步操作完成
- 使用 CancellationToken 取消令牌
- 返回 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()
作用: 同步写入单个节点的值
原理:
- 将字符串类型转换为对应的 TypeCode
- 创建 DataValue 和 WriteValue 对象
- 设置节点 ID、属性 ID 和值
- 处理数组和普通值的区别
- 调用 session.Write() 发送同步写请求
- 检查返回的 StatusCode 确认写入是否成功
参数说明:
session: 已建立的会话对象nodeId: 要写入的节点 IDvalue: 要写入的值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()
作用: 异步写入单个节点的值
原理:
- 与同步写入类似,但使用 session.WriteAsync()
- 使用 .Result 等待异步操作完成
- 返回 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()
作用: 创建或取消节点的订阅
原理:
- 获取会话的默认订阅(DefaultSubscription)
- 如果 subOpen 为 true,创建 MonitoredItem 并订阅
- 绑定 Notification 事件处理函数
- 将监控项添加到订阅并创建订阅
- 如果 subOpen 为 false,取消订阅并删除监控项
参数说明:
session: 已建立的会话对象nodeId: 要订阅的节点 IDsubOpen: 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()
作用: 订阅通知事件处理函数
原理:
- 当订阅的节点值变化时触发
- 获取变化后的值和元数据
- 判断是否为数组并格式化
- 转换时间戳为本地时间
- 创建 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()
作用: 批量读取多个节点的值
原理:
- 接收 NodeId 列表作为参数
- 调用 session.ReadValues() 批量读取
- 处理返回的 DataValueCollection 和错误列表
- 对每个节点的结果进行格式化
- 返回 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()
作用: 批量写入多个节点的值
原理:
- 接收节点 ID、值、类型、是否数组的数组作为参数
- 为每个节点创建 WriteValue 对象
- 逐个调用 session.Write() 写入
- 收集所有写入的状态码
参数说明:
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()
作用: 添加批量订阅
原理:
- 创建新的 Subscription 对象
- 配置订阅参数(发布间隔、优先级等)
- 为每个标签创建 MonitoredItem
- 绑定回调函数到通知事件
- 使用字典管理多个订阅
参数说明:
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()
作用: 移除所有订阅
原理:
- 遍历所有订阅
- 依次删除每个订阅
- 清空订阅字典
- 关闭订阅标志
参数说明:
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()
作用: 选择安全性最高的端点
原理:
- 创建 DiscoveryClient 连接到服务器
- 获取所有可用的端点
- 根据安全策略和安全等级筛选
- 选择最安全的端点返回
参数说明:
discoveryUrl: 发现 URLuseSecurity: 是否使用安全策略
返回值: 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: 证书错误通常是因为服务器证书不受信任。解决方案:
- 在 BuildConfig() 中设置自动接受未信任证书:
csharp
configuration.SecurityConfiguration.AutoAcceptUntrustedCertificates = true;
- 手动处理证书验证事件,在 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: 检查以下几点:
- 确认订阅已创建且 Enable:
csharp
subscription.PublishingEnabled = true;
- 确认会话连接正常:
csharp
if (session.Connected)
{
// 创建订阅
}
- 检查采样间隔设置是否合理:
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:
- 使用批量读取:
csharp
ListViewItem[] items = UAClient.SessionReadMultiple(session, nodeIds);
- 使用订阅:
csharp
client.AddSubscription(session, "Group1", tags, callback);
- 调整采样间隔:
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 | 响应超时 |
相关资源
- OPC UA 规范: https://opcfoundation.org/developer-tools/specifications-unified-architecture
- OPC UA .NET SDK: https://github.com/OPCFoundation/UA-.NETStandard
- OPC 基金会: https://opcfoundation.org/