目录
1. 工程概述
本项目是一个基于 OPC Foundation .NET Standard SDK 的 OPC UA 客户端 WinForms 程序。支持以下功能:
- 连接/断开 OPC UA 服务器(匿名 + 用户名密码认证)
- 浏览服务器地址空间(TreeView 树形展示,支持递归展开全部节点)
- 单节点同步/异步读取
- 单节点同步/异步写入
- 批量读取
- 单节点订阅(数据变化自动推送)
- 批量订阅
- 服务器证书验证弹窗
目标框架 : .NET Framework 4.8
SDK 版本: OPCFoundation.NetStandard.Opc.Ua v1.5.376.244
2. 环境依赖
2.1 NuGet 包
powershell
Install-Package OPCFoundation.NetStandard.Opc.Ua.Client -Version 1.5.376.244
Install-Package OPCFoundation.NetStandard.Opc.Ua.Configuration -Version 1.5.376.244
Install-Package OPCFoundation.NetStandard.Opc.Ua.Security.Certificates -Version 1.5.376.244
Client 包会自动拉取 Core、BouncyCastle、Newtonsoft.Json 等依赖。
2.2 引用命名空间
csharp
using Opc.Ua; // OPC UA 基础类型(NodeId, DataValue, StatusCode 等)
using Opc.Ua.Client; // 客户端 API(Session, Subscription, MonitoredItem)
using Opc.Ua.Configuration; // 应用配置(ApplicationInstance, ApplicationConfiguration)
using System.Security.Cryptography.X509Certificates; // Windows 证书存储操作
3. 项目文件结构
OPC_UA_Claude/
├── Program.cs // 程序入口
├── OPC_UA_Client.cs // ★ 主窗体(所有 OPC UA 逻辑)★
├── OPC_UA_Client.Designer.cs // 窗体控件声明(VS 生成)
├── FormCertClient.cs // 服务器证书验证弹窗
└── FormCertClient.Designer.cs // 证书弹窗控件声明
只需要关注两个 .cs 文件:OPC_UA_Client.cs(业务逻辑)和 FormCertClient.cs(证书弹窗)。
4. UI 界面布局
┌──────────────────────────────────────────────────────────────┐
│ 连接设置 │
│ URL: [opc.tcp://127.0.0.1:4841] [连接] [断开] ● 已连接/未连接│
│ [✓ 匿名登录] 用户名: [___] 密码: [___] [显示密码] │
├────────────────────┬─────────────────────────────────────────┤
│ 地址空间浏览 │ [单节点操作] [批量操作] │
│ │ ┌─ 读取节点 ─────────────────────────┐ │
│ TreeView │ │ NodeId: [________] [读取] │ │
│ 带类型前缀: │ │ [异步读取] [订阅数据变化] │ │
│ [V] 变量 │ │ 值: [___] 时间: [___] │ │
│ [O] 对象/文件夹 │ │ 状态: [___] │ │
│ [M] 方法 │ ├──────────────────────────────────┤ │
│ │ │ 写入节点 │ │
│ │ │ NodeId: [________] [写入] │ │
│ │ │ 值: [___] 类型: [Int16 ▼] │ │
│ │ │ [异步写入] │ │
│ │ │ 状态: [___] │ │
│ │ └──────────────────────────────────┘ │
│ NodeId: [___] │ │
│ [刷新][展开][折叠] │ │
├────────────────────┴───────────────────────────────────────┤
│ 结果列表 │
│ ┌─ NodeId ─┬─ Value ─┬─ DataType ─┬─ Status ─┬─ Timestamp┐│
│ │ │ │ │ │ ││
│ └──────────┴─────────┴────────────┴──────────┴───────────┘│
└────────────────────────────────────────────────────────────┘
5. 核心概念
5.1 OPC UA 连接分层模型
┌─────────────────────────────────────┐
│ Session(会话层) │ 用户身份认证、会话管理
│ ├── KeepAlive(心跳) │ 检测连接状态
│ ├── Read / Write / Browse │ 数据访问
│ └── Subscription(订阅) │ 数据变化推送
├─────────────────────────────────────┤
│ SecureChannel(安全通道层) │ 加密、签名、证书交换
├─────────────────────────────────────┤
│ TCP 连接(传输层) │ opc.tcp://host:port
└─────────────────────────────────────┘
5.2 关键对象说明
| 对象 | 类型 | 说明 |
|---|---|---|
m_session |
Session |
OPC UA 会话,所有操作(读/写/浏览/订阅)都通过它进行 |
m_configuration |
ApplicationConfiguration |
应用配置,包含证书、安全策略、传输限制 |
NodeId |
NodeId |
节点的唯一标识,如 ns=2;s=Temperature |
DataValue |
DataValue |
节点的值,包含 Value、StatusCode、Timestamp |
Subscription |
Subscription |
订阅容器,管理一组 MonitoredItem |
MonitoredItem |
MonitoredItem |
监控项,指定要监控的节点和属性 |
5.3 NodeId 格式
ns=命名空间索引;标识符类型=标识符值
示例:
ns=0;i=85 → 命名空间0,数字型NodeId 85(Objects 文件夹)
ns=2;s=Temperature → 命名空间2,字符串型NodeId "Temperature"
ns=4;s=GVL_Camera.ChipCamera_Trig → 命名空间4,字符串型NodeId
6. 初始化流程
6.1 入口 Program.cs
csharp
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new OPC_UA_Client()); // 启动主窗体
}
标准 WinForms 入口,无需修改。
6.2 窗体构造器
csharp
public OPC_UA_Client()
{
InitializeComponent();
WriteType_cB.SelectedIndex = 0; // 写入类型下拉框默认选 Boolean
}
7. 连接建立
连接建立是整个客户端的核心流程,分为 4 个步骤:
用户点击 [连接]
│
├── 步骤1: BuildConfiguration()
│ ├── 创建 ApplicationConfiguration(应用名、URI、安全策略)
│ ├── 查找或创建 X.509 自签名证书(标识客户端身份)
│ ├── 注册证书验证事件(连接时服务端返回证书触发)
│ └── 验证配置合法性
│
├── 步骤2: 发现端点
│ ├── 匿名登录 → 直接用 URL 创建 EndpointDescription
│ └── 用户名密码 → DiscoveryClient.GetEndpoints() → 选最安全的
│
├── 步骤3: 创建 ConfiguredEndpoint
│ └── 将端点描述 + 配置封装为 ConfiguredEndpoint
│
├── 步骤4: 创建用户身份
│ ├── 匿名 → AnonymousIdentityToken
│ └── 用户名密码 → UserIdentity(name, password)
│
└── 步骤5: Session.Create()
├── TCP 连接
├── OpenSecureChannel(交换证书建立加密通道)
├── CreateSession(创建会话)
└── ActivateSession(激活会话,身份认证)
7.1 BuildConfiguration() --- 构建应用配置
这是连接前必须执行的方法。它在后台线程运行以避免阻塞 UI。
csharp
private void BuildConfiguration()
{
string appName = "OPC_UA_Claude_Client"; // 应用名称(可自定义)
// 1. 创建 ApplicationConfiguration 对象(纯代码构建,不需要 XML 配置文件)
m_configuration = new ApplicationConfiguration
{
ApplicationName = appName,
ApplicationUri = "urn:MyOPCUAClient", // 唯一标识(可自定义)
ApplicationType = ApplicationType.Client, // 标记为客户端
ProductUri = "OPC_UA_Claude_Client_1.0",
// 2. 安全配置:证书存放路径
SecurityConfiguration = new SecurityConfiguration
{
AutoAcceptUntrustedCertificates = false, // 不自动接受证书
RejectSHA1SignedCertificates = false,
// 客户端自己的证书:存在 Windows 证书管理器的"个人"存储
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.X509Store,
StorePath = "CurrentUser\\My", // Windows 证书管理器
SubjectName = appName
},
// 受信任的服务端证书存放位置
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.X509Store,
StorePath = "CurrentUser\\Root" // 受信任的根证书
}
},
// 3. 传输配额:限制消息大小和超时
TransportQuotas = new TransportQuotas
{
OperationTimeout = 360000, // 操作超时(毫秒)
SecurityTokenLifetime = 86400000, // 安全令牌有效期(毫秒)
MaxStringLength = 67108864, // 最大字符串长度
MaxByteStringLength = 16777216 // 最大字节串长度
},
// 4. 客户端行为配置
ClientConfiguration = new ClientConfiguration
{
DefaultSessionTimeout = 360000 // 默认会话超时(毫秒)
}
};
// 5. 查找或创建客户端证书
var certFindTask = m_configuration.SecurityConfiguration
.ApplicationCertificate.Find(true);
if (certFindTask.Result == null)
{
// 没有就创建新的自签名 RSA 2048 位证书
CreateCertificateAndAddToStore(
m_configuration.ApplicationUri, appName,
"X509Store", "CurrentUser\\My");
}
// 6. 注册证书验证事件:连接服务器时会触发
var certValidator = new CertificateValidator();
certValidator.CertificateValidation += CertificateValidator_CertificateValidation;
m_configuration.CertificateValidator = certValidator;
// 7. 验证配置
m_configuration.Validate(ApplicationType.Client);
}
7.2 创建自签名证书
OPC UA 使用 X.509 证书标识每个应用的唯一身份。客户端需要自己的证书即使使用匿名登录。
csharp
private void CreateCertificateAndAddToStore(
string applicationUri, string applicationName,
string storeType, string storePath)
{
// 收集本机 IP 和主机名(作为证书的 DNS 条目)
var host = Dns.GetHostEntry(Dns.GetHostName());
var localIps = new List<string>();
foreach (var ip in host.AddressList)
{
if (ip.AddressFamily == AddressFamily.InterNetwork)
localIps.Add(ip.ToString());
}
localIps.Add(Dns.GetHostName());
// 创建自签名证书:RSA 2048 位,SHA256,有效期 24 个月
ushort keySize = 2048;
ushort lifeTimeMonths = 24;
ushort hashSizeBits = 256;
var builder = CertificateFactory.CreateCertificate(
applicationUri, applicationName, null, localIps);
X509Certificate2 cert = builder
.SetNotBefore(DateTime.Now)
.SetNotAfter(DateTime.Now.AddMonths(lifeTimeMonths))
.SetHashAlgorithm(X509Utils.GetRSAHashAlgorithmName(hashSizeBits))
.SetRSAKeySize(keySize)
.CreateForRSA();
cert.FriendlyName = applicationName;
cert.AddToStore(storeType, storePath, null);
}
迁移要点:
SubjectName必须唯一,不同应用不能重名- 第一次运行需要管理员权限(写入 Windows 证书存储需要)
- 证书有效期为 24 个月,过期需重新生成
7.3 端点发现
当使用用户名密码连接时,需要先发现服务器支持哪些端点:
csharp
private EndpointDescription SelectEndpoint(Uri discoveryUrl, bool useSecurity)
{
var discConfig = EndpointConfiguration.Create();
discConfig.OperationTimeout = 5000; // 发现超时 5 秒
EndpointDescription bestEndpoint = null;
// 创建 DiscoveryClient 连接服务器获取端点列表
using (var discoveryClient = DiscoveryClient.Create(discoveryUrl, discConfig))
{
var endpoints = discoveryClient.GetEndpoints(null);
foreach (var ep in endpoints)
{
// 遍历选择安全等级最高的端点
if (bestEndpoint == null)
bestEndpoint = ep;
else if (ep.SecurityMode > bestEndpoint.SecurityMode ||
(ep.SecurityMode == bestEndpoint.SecurityMode &&
ep.SecurityLevel > bestEndpoint.SecurityLevel))
{
bestEndpoint = ep;
}
}
}
return bestEndpoint;
}
7.4 创建会话
csharp
m_session = await Session.Create(
configuration: m_configuration, // 应用配置(含证书)
endpoint: configuredEndpoint, // 端点信息
updateBeforeConnect: true, // 连接前更新端点
checkDomain: false, // 不检查证书域名
sessionName: m_configuration.ApplicationName,
sessionTimeout: 300000, // 会话超时 5 分钟(300秒)
identity: userIdentity, // 用户身份
preferredLocales: new string[] { "zh-CN" });
// 设置心跳间隔(10 秒发一次 Ping)
m_session.KeepAliveInterval = 10000;
m_session.KeepAlive += Session_KeepAlive;
关键参数说明:
sessionTimeout: 服务端在此时间内未收到任何请求会关闭会话。心跳会自动发送请求防止超时KeepAliveInterval: 心跳间隔。值太小增加网络负担,太大会导致断线检测延迟updateBeforeConnect: true 表示连接前重新获取服务器端点信息
8. 证书验证
连接时服务端返回其证书。SDK 触发 CertificateValidation 事件让客户端决定是否信任。
csharp
private void CertificateValidator_CertificateValidation(
CertificateValidator validator, CertificateValidationEventArgs e)
{
// SDK 会连续触发两次验证事件,用计数器区分
if (m_certStep == 0)
{
// 步骤1: 先查 Windows 受信任存储,已有则直接通过
X509Store store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly);
var found = store.Certificates.Find(
X509FindType.FindByThumbprint, e.Certificate.Thumbprint, true);
store.Close();
if (found.Count > 0)
{
e.Accept = true; // 已信任,直接通过
return;
}
// 步骤2: 未知证书 → 弹出证书详情窗口让用户确认
using (var certForm = new FormCertClient())
{
certForm.LoadCertificate(e.Certificate); // 加载证书信息
DialogResult result = certForm.ShowDialog(this);
if (result == DialogResult.OK)
{
e.Accept = true;
if (certForm.AlwaysTrust)
{
// 用户勾选"始终信任"→ 存入 Windows 受信任根存储
using (var trustStore = new X509Store(
StoreName.Root, StoreLocation.CurrentUser))
{
trustStore.Open(OpenFlags.ReadWrite);
trustStore.Add(e.Certificate);
}
}
}
else
e.Accept = false;
}
m_certStep++;
}
else
{
// 第二次验证:SDK 内部重复触发,直接自动接受
e.Accept = true;
m_certStep = 0;
}
}
迁移要点:
- 必须添加
certStep计数器机制(SDK 会重复触发验证事件) - 弹窗操作必须通过
InvokeRequired检查确保在 UI 线程执行 - 信任证书存入
CurrentUser\Root后,下次连接不会再弹窗
9. 地址空间浏览
浏览 OPC UA 地址空间类似于浏览文件夹树:
Root
├── Views
├── Objects
│ ├── Server
│ └── My Variables
│ ├── Temperature
│ ├── Pressure
│ └── Status
└── Types
9.1 浏览原理
session.Browse(nodeId, direction, referenceType, includeSubtypes, nodeClassMask)
↓
返回 ReferenceDescriptionCollection(子节点/父节点的引用描述)
↓
如果 continuationPoint ≠ null → 继续调用 session.BrowseNext() 获取后续分页
关键参数:
| 参数 | 值 | 含义 |
|---|---|---|
nodeId |
ObjectIds.RootFolder |
从根目录开始 |
direction |
BrowseDirection.Forward |
向下找子节点 |
referenceTypeId |
ReferenceTypeIds.HierarchicalReferences |
只遍历层级引用 |
includeSubtypes |
true |
包含子类型(Organizes, HasComponent 等) |
nodeClassMask |
`Variable | Object |
9.2 BrowseAllRecursive --- 递归加载全部节点
展开按钮点击后的流程:
[展开按钮]
├── 阶段1(后台线程): BrowseAllRecursive()
│ 递归浏览所有节点 → 存入 Dictionary<父NodeId, 子节点列表>
│ 只递归 Object 节点(Folder/对象),跳过 Variable 和 Method
│ 上限 10 层
│
└── 阶段2(UI线程): BuildChildTree()
从 Dictionary 构建 TreeNode → 添加到 TreeView
关键技术点:
- 分两阶段避免跨线程操作 UI 控件(后台浏览 + UI 构建)
- 用
Dictionary<string, ReferenceDescriptionCollection>存储中间数据 - key 为空字符串
""时表示根目录
9.3 CreateTreeNode --- 创建树节点
csharp
private TreeNode CreateTreeNode(ReferenceDescription refDesc)
{
// ExpandedNodeId → NodeId → 字符串(如 "ns=2;s=Temperature")
string id = ExpandedNodeId.ToNodeId(
refDesc.NodeId, m_session.NamespaceUris).ToString();
string name = refDesc.DisplayName?.Text ?? id;
// 不同类型用不同前缀标记
string prefix = refDesc.NodeClass == NodeClass.Variable ? "[V] " :
refDesc.NodeClass == NodeClass.Method ? "[M] " : "[O] ";
var node = new TreeNode(prefix + name);
node.Tag = id; // ★ 核心:把 NodeId 字符串存在 Tag 里,后续读写时取出
return node;
}
迁移要点:
Tag属性是关键设计:点击树节点时从Tag取 NodeId 自动填入读/写输入框- 类型前缀
[V][O][M]帮助用户区分节点类型
10. 单节点读写
10.1 同步读取
csharp
private (string value, string type, string status, string timestamp)
ReadNodeSync(NodeId nodeId)
{
// 1. 构造读取请求:指定 NodeId 和要读的属性(Value)
var readValueId = new ReadValueId
{
NodeId = nodeId,
AttributeId = Attributes.Value
};
var readValues = new ReadValueIdCollection { readValueId };
// 2. 发送同步读取请求
m_session.Read(
null, // requestHeader
0, // 最大年龄(0=拿最新值)
TimestampsToReturn.Both, // 同时返回源时间戳和服务器时间戳
readValues, // 读取列表
out DataValueCollection results, // 输出:结果集合
out DiagnosticInfoCollection _); // 输出:诊断信息
// 3. 解析结果
return ParseDataValue(results[0]);
}
10.2 异步读取
csharp
private async Task<(...)> ReadNodeAsync(NodeId nodeId)
{
// 1. 构造相同请求
var readValues = new ReadValueIdCollection {
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }
};
// 2. 发送异步读取请求
var response = await m_session.ReadAsync(
null, 0, TimestampsToReturn.Both,
readValues, CancellationToken.None);
// 3. 解析响应
return ParseDataValue(response.Results[0]);
}
10.3 结果解析
csharp
private (string value, string type, string status, string timestamp)
ParseDataValue(DataValue dataValue)
{
// 值 → 字符串(数组特殊处理)
string strValue;
if (dataValue.Value is Array arr)
strValue = ArrayToString(arr); // [1, 2, 3] → "1, 2, 3"
else if (dataValue.Value == null)
strValue = "null";
else
strValue = dataValue.Value.ToString();
// 数据类型
string strType = dataValue.WrappedValue.TypeInfo?
.BuiltInType.ToString() ?? "Unknown";
// 状态码(Good / BadXxx)
string strStatus = StatusCode.LookupSymbolicId(
dataValue.StatusCode.Code);
// 时间戳(UTC → 本地时间)
DateTime localTime = dataValue.ServerTimestamp.ToLocalTime();
string strTime = localTime.ToString("yyyy-MM-dd HH:mm:ss");
return (strValue, strType, strStatus, strTime);
}
10.4 单节点写入
csharp
// 1. 类型转换
TypeCode typeCode = MapTypeCode(typeStr); // "Int32" → TypeCode.Int32
// 布尔值特殊处理
object convertedValue;
if (typeCode == TypeCode.Boolean)
convertedValue = valueStr == "1" ||
valueStr.Equals("true", StringComparison.OrdinalIgnoreCase);
else
convertedValue = Convert.ChangeType(valueStr, typeCode);
// 2. 构造写入请求
DataValue dataValue = new DataValue { Value = convertedValue };
WriteValue writeValue = new WriteValue
{
NodeId = nodeId,
AttributeId = Attributes.Value,
Value = dataValue
};
var writeValues = new WriteValueCollection { writeValue };
// 3. 同步写入(异步类似)
StatusCodeCollection statusCodes = await Task.Run(() =>
{
m_session.Write(null, writeValues,
out StatusCodeCollection sc, out DiagnosticInfoCollection _);
return sc;
});
// 4. 判断结果
uint code = statusCodes[0].Code;
// code == 0 → Good → 写入成功
// code != 0 → 失败 → LookupSymbolicId(code) 查状态名
11. 批量读取
一次请求读取多个节点,使用 session.ReadValues() 方法:
csharp
// 1. 从多行文本框解析 NodeId 列表
string[] lines = BatchReadNodes_tB.Lines
.Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
var nodeIds = new NodeIdCollection();
foreach (string line in lines)
nodeIds.Add(new NodeId(line));
// 2. 批量读取(后台线程)
var results = await Task.Run(() =>
{
m_session.ReadValues(
nodeIds,
out DataValueCollection values,
out IList<ServiceResult> _);
return values;
});
// 3. 逐条解析并添加到结果列表
for (int i = 0; i < results.Count; i++)
{
if (StatusCode.IsGood(results[i].StatusCode))
{
var parsed = ParseDataValue(results[i]);
AddResultToList(lines[i], parsed.value, ...);
}
}
12. 数据订阅
12.1 订阅原理
Subscription(订阅容器)
├── PublishingInterval = 500ms // 检查间隔
├── PublishingEnabled = true // 启用发布
│
└── MonitoredItem(监控项) × N
├── StartNodeId // 要监控的节点
├── AttributeId = Value // 监控值属性
├── SamplingInterval = 100ms // 采样间隔
└── Notification 事件 // 值变化时触发
12.2 单节点订阅完整流程
csharp
// ===== 启动订阅 =====
private void StartSubscription(string nodeIdStr)
{
NodeId nodeId = new NodeId(nodeIdStr);
// 1. 基于默认模板创建订阅
m_singleSub = new Subscription(m_session.DefaultSubscription)
{
PublishingInterval = 500, // 每 500ms 检查一次变化
PublishingEnabled = true
};
// 2. 创建监控项
m_singleMonitoredItem = new MonitoredItem
{
StartNodeId = nodeId,
AttributeId = Attributes.Value,
SamplingInterval = 100, // 每 100ms 采样一次
DisplayName = nodeIdStr
};
// 3. 绑定通知回调 + 注册到订阅 + 向服务器注册
m_singleMonitoredItem.Notification += OnSingleMonitoredItemChanged;
m_singleSub.AddItem(m_singleMonitoredItem);
m_session.AddSubscription(m_singleSub);
m_singleSub.Create(); // ← 向服务器发送注册请求
}
// ===== 停止订阅 =====
private void StopSubscription()
{
m_singleMonitoredItem.Notification -= OnSingleMonitoredItemChanged;
m_singleSub.Delete(true); // ← 通知服务器删除订阅
m_session.RemoveSubscription(m_singleSub);
m_singleSub.Dispose();
m_singleSub = null;
}
// ===== 回调处理(后台线程 → BeginInvoke 封送到 UI 线程)=====
private void OnSingleMonitoredItemChanged(MonitoredItem item,
MonitoredItemNotificationEventArgs e)
{
var notification = e.NotificationValue as MonitoredItemNotification;
if (notification == null) return;
var parsed = ParseDataValue(notification.Value);
// ★ 关键:后台线程不能直接操作 UI 控件,必须用 BeginInvoke
if (this.InvokeRequired)
this.BeginInvoke(new Action(() => UpdateSubscriptionUI(parsed)));
else
UpdateSubscriptionUI(parsed);
}
迁移要点:
- 回调在后台线程 触发,更新 UI 必须用
Control.BeginInvoke() SamplingInterval控制采样频率,PublishingInterval控制推送频率- 断开连接前必须先停止订阅,否则可能抛出异常
12.3 批量订阅
与单节点订阅的区别:一个 Subscription 包含多个 MonitoredItem,共享 500ms 发布间隔。
回调通过 item.DisplayName 区分是哪个节点的数据变化:
csharp
private void OnBatchMonitoredItemChanged(MonitoredItem item,
MonitoredItemNotificationEventArgs e)
{
string nodeId = item.DisplayName; // ← 通过 DisplayName 识别节点
// ... 解析并更新 UI
}
13. 断线检测与断开
13.1 心跳检测
csharp
private void Session_KeepAlive(object sender, KeepAliveEventArgs e)
{
// 心跳返回 Bad 状态 → 连接已断开
if (e.Status != null && ServiceResult.IsBad(e.Status))
{
this.BeginInvoke(new Action(() =>
{
StatusPic_pB.BackColor = Color.Red;
Status_lb.Text = $"已断开: {StatusCode.LookupSymbolicId(e.Status.StatusCode.Code)}";
SetConnectedState(false);
}));
}
}
13.2 断开流程
csharp
private async Task DisconnectSession()
{
if (m_session != null && !m_session.Disposed)
{
// 1. 先停止所有订阅
StopSubscription();
StopBatchSubscription();
// 2. 取消心跳
m_session.KeepAlive -= Session_KeepAlive;
// 3. 关闭并释放会话
await Task.Run(() =>
{
m_session.Close(); // 向服务器发送关闭请求
m_session.Dispose(); // 释放资源
});
m_session = null;
}
}
14. 完整代码清单
FormCertClient.cs --- 证书验证弹窗
csharp
public partial class FormCertClient : Form
{
// 用户是否勾选了"始终信任此证书"
public bool AlwaysTrust => StoreCert_cB.Checked;
// 解析证书并展示到 DataGridView
public void LoadCertificate(X509Certificate2 cert)
{
Cert_dGV.Rows.Clear();
Cert_dGV.Rows.Add("主题", cert.Subject);
Cert_dGV.Rows.Add("颁发者", cert.Issuer);
Cert_dGV.Rows.Add("有效期", $"{cert.NotBefore:yyyy-MM-dd} ~ {cert.NotAfter:yyyy-MM-dd}");
Cert_dGV.Rows.Add("指纹", cert.Thumbprint);
Cert_dGV.Rows.Add("签名算法", cert.SignatureAlgorithm?.FriendlyName);
}
}