简介:面向工业自动化与物联网领域、希望用 .NET 对接 PLC 与 OPC UA 服务器的开发者,这份通信示例包围绕客户端连接建立、安全参数配置、主动断开、节点读写、订阅推送以及心跳监听等典型环节组织,可用于快速理解 OPC UA 服务的调用流程与异常处理思路。压缩包共约 2000 个文件、87.75MB,以 1218 个 xml 配置与描述文件、252 个 dll 依赖库、112 个 txt 说明、28 个 nupkg 包及 10 个 cs 源码为主,另有证书签名、缓存和项目工程文件,便于还原完整运行环境。已有 678 人学习。读者可结合示例中的连接参数设置、客户端创建、订阅项添加、读写调用与心跳回调解析,梳理从基础连接到实时监视的实现路径,并作为实际项目联调与排错参考。
1. 一个 .NET OPC UA 客户端 Demo,能省掉多少对接功夫
产线上新到一台设备,随机文档只有一张端点截图和几个 NodeId,剩下的全靠自己接。这时候.NET OPC UA通信 Demo 的价值就显出来了:连接、断开、读写、订阅、心跳监听这五件事全部跑通,等于把客户端骨架立住了,后面换成哪家服务端都只是改配置。这份 Demo 面向的是要做设备数据采集的 .NET 开发——不追求协议栈全量实现,而是把 Session 生命周期、节点访问、订阅推送这三条主线走一遍。很多人卡住的地方不是 API 不会调,而是证书、类型、重连这些边角问题,先跑通一个能连能读能订阅的最小闭环,比抱着规范啃三天管用得多。
2. 工程结构与技术选型:Session、Subscription、NodeId 三件套怎么落进 .NET 项目
2.1 目录怎么切,才不至于三个月后自己都看不懂
Demo 规模不大,但我不建议全塞进一个Program.cs。OPC UA 客户端的复杂度集中在三处:会话生命周期、节点读写、订阅回调。这三块混在一起,后面加重连和缓冲的时候必然翻车。比较稳的切法是把它们拆成三个项目,客户端壳只负责启动和装配。
OpcUaDemo/ ├─ src/ │ ├─ OpcUaDemo.Client/ # 控制台入口,负责装配与演示调用 │ │ ├─ Program.cs # 连接 → 读 → 写 → 订阅 → 等待 → 断开 │ │ ├─ OpcSessionFactory.cs # 建 Session:证书、端点、身份 │ │ └─ appsettings.json # 端点、节点、超时全走配置 │ ├─ OpcUaDemo.Subscription/ # 订阅与心跳 │ │ ├─ SubscriptionManager.cs # 建订阅、加 MonitoredItem、重建 │ │ ├─ KeepAliveMonitor.cs # 接 Session.KeepAlive 事件 │ │ └─ SampleBuffer.cs # 回调到业务之间的有界队列 │ └─ OpcUaDemo.Core/ # 配置模型、日志、重试策略 │ ├─ OpcUaOptions.cs │ └─ RetryPolicy.cs └─ OpcUaDemo.slnClient项目不写业务逻辑,只演示调用顺序,这样新手照着Program.cs从上往下读就能跑起来。Subscription单独拆出来是因为订阅重连时最麻烦——会话断了,服务端上的 MonitoredItem 全没了,必须按原配置重建一遍,这段逻辑独立才好测。Core放配置和重试,属于迟早要被复用到底层服务里的部分。
2.2 SDK 选型:官方栈和封装库怎么挑
.NET 下做 OPC UA 客户端,主流就是两条路。第一条是 OPC Foundation 官方的 .NET Standard 栈(NuGet 上的OPCFoundation.NetStandard.Opc.Ua.*系列),Opc.Ua.Client里直接给你Session、Subscription、MonitoredItem这些类型,行为跟规范一一对应,出问题查规范能对上号。第二条是社区封装库,比如以Opc.UaFx风格提供更简洁 API 的那类包,写法舒服,但遇到证书链、端点发现这类底层问题时,抽象层会挡住排查路径。
这个 Demo 我选官方栈,理由很实际:连接、断开、读写、订阅、心跳这五件事里,心跳(KeepAlive)和会话重建在封装库里往往是半隐藏的,而这两块恰恰是产线采集最容易出事的地方,看得见比写得快重要。选型对照大致是这样:
| 关注点 | 官方Opc.Ua.Client | 社区封装库 |
|---|---|---|
| API 形态 | 贴近规范,类型多但语义明确 | 简洁,链式调用友好 |
| 证书与端点发现 | 完全暴露,可逐步调试 | 部分被封装,排查绕路 |
| KeepAlive 与重建 | Session.KeepAlive事件可直接接管 | 多为内部处理,回调粒度粗 |
| 适合场景 | 长期运行的采集服务、需要精细控制 | 快速验证、简单读写 |
新手如果只是想验证一台设备通不通,用哪条都行;但只要目标是做成常驻采集,官方栈的“笨”反而是优势。
2.3 配置先行:把端点和节点从代码里赶出去
Demo 最容易犯的错是把opc.tcp://...和 NodeId 硬编码在Program.cs里,换台设备就得改代码重编译。我一般一开始就把这些东西放进appsettings.json,绑定到强类型配置类。
{ "OpcUa": { "EndpointUrl": "opc.tcp://192.168.10.20:4840", "UseSecurity": false, "AutoAcceptCertificate": true, "ApplicationName": "OpcUaDemoClient", "SessionName": "demo-session", "SessionTimeoutMs": 60000, "OperationTimeoutMs": 10000, "CertStorePath": "./pki/own", "TrustStorePath": "./pki/trusted", "UserName": "", "Password": "", "MaxRetry": 10, "MonitoredNodes": [ { "DisplayName": "Line1.Temp", "NodeId": "ns=2;s=Line1.Temp", "SamplingInterval": 500, "QueueSize": 10 }, { "DisplayName": "Line1.Speed", "NodeId": "ns=2;s=Line1.Speed", "SamplingInterval": 1000, "QueueSize": 5 } ] } }几个参数值得单独说。UseSecurity为false时走None安全策略,只适合内网调试,一旦服务端要求签名就必须打开并配证书。SessionTimeoutMs是会话超时,服务端在这个时间内收不到任何请求就会话失效,通常给 60 秒;OperationTimeoutMs是单次请求超时,给 10 秒就够,设太大反而会在服务端卡住时拖住整个调用链。QueueSize决定 MonitoredItem 在客户端侧能缓存多少个采样值,设成 1 就意味着只保留最新值,中间突变会被覆盖。
配置类用IOptions<T>绑定,MonitoredNodes对应一个列表:
public sealed class OpcUaOptions { public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840"; public bool UseSecurity { get; set; } public bool AutoAcceptCertificate { get; set; } public string ApplicationName { get; set; } = "OpcUaDemoClient"; public string SessionName { get; set; } = "demo-session"; public int SessionTimeoutMs { get; set; } = 60_000; public int OperationTimeoutMs { get; set; } = 10_000; public string CertStorePath { get; set; } = "./pki/own"; public string TrustStorePath { get; set; } = "./pki/trusted"; public string UserName { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; public int MaxRetry { get; set; } = 10; public List<MonitoredNode> MonitoredNodes { get; set; } = new(); } public sealed class MonitoredNode { public string DisplayName { get; set; } = string.Empty; public string NodeId { get; set; } = string.Empty; public int SamplingInterval { get; set; } = 500; public int QueueSize { get; set; } = 10; }SamplingInterval是服务端侧的采样周期,-1表示跟随发布间隔,设得比发布间隔还小没有意义,只会白占服务端 CPU。QueueSize和发布间隔要一起看:采样 500ms、发布 2000ms、队列 2,就只能拿到最近两个值。这些参数在 Demo 阶段随便填也能跑,但搬到现场就会发现丢数据,所以配置模型一开始就该留出这些字段。
3. 连接、断开与心跳:Session 生命周期管理
3.1 建立 Session 的完整链路
连接这段是整个 Demo 的门面。OPC UA 不像 HTTP 那样一个请求一个连接,它要先做应用配置、发现端点、协商安全策略、创建会话,四步里任何一步出错都会以ServiceResultException的形式抛出来。我一般把这段封装成一个工厂方法,返回一个可用的Session。
public sealed class OpcSessionFactory { private readonly OpcUaOptions _options; public OpcSessionFactory(IOptions<OpcUaOptions> options) => _options = options.Value; public async Task<Session> CreateAsync(CancellationToken ct = default) { // 1) 应用配置:应用名、URI、证书存储位置 var config = new ApplicationConfiguration { ApplicationName = _options.ApplicationName, ApplicationUri = $"urn:{Dns.GetHostName()}:{_options.ApplicationName}", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { ApplicationCertificate = new CertificateIdentifier { StoreType = "Directory", StorePath = _options.CertStorePath, // 首次运行自动生成自签名证书 SubjectName = $"CN={_options.ApplicationName}" }, TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = _options.TrustStorePath }, TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = _options.TrustStorePath }, AutoAcceptUntrustedCertificates = _options.AutoAcceptCertificate // 仅内网调试期打开 }, TransportQuotas = new TransportQuotas { OperationTimeout = _options.OperationTimeoutMs }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = _options.SessionTimeoutMs } }; await config.Validate(ApplicationType.Client); // 配置不自洽会在这里就抛,别跳过 // 2) 端点发现:从服务端拿可用端点列表,按安全策略挑一个 var endpoint = CoreClientUtils.SelectEndpoint( config, _options.EndpointUrl, useSecurity: _options.UseSecurity); var endpointDescription = new ConfiguredEndpoint( null, endpoint, EndpointConfiguration.Create(config)); // 3) 创建会话:身份、超时、会话名 return await Session.Create( config, endpointDescription, updateBeforeConnect: false, // 已选好端点,不必再走一次发现 sessionName: _options.SessionName, sessionTimeout: (uint)_options.SessionTimeoutMs, identity: string.IsNullOrEmpty(_options.UserName) ? new UserIdentity(new AnonymousIdentityToken()) : new UserIdentity(_options.UserName, _options.Password), preferredLocales: null); } }逐段说清楚。ApplicationUri必须全局唯一,用主机名加应用名拼是最省事的做法,重复了服务端会认为两个客户端是同一个。ApplicationCertificate的StorePath指向一个本地目录,第一次运行 SDK 会自动生成自签名证书,生成的公钥证书要拷到服务端的信任目录里,否则连接阶段就会卡在BadSecurityChecksFailed——这是新手第一个玄学问题,后面避坑章还会提。
CoreClientUtils.SelectEndpoint会向服务端发一次GetEndpoints,返回的列表里按SecurityPolicy过滤。第三个参数useSecurity直接决定走None还是Basic256Sha256之类,它必须和服务端配置对上,对不上就是连接被拒。Session.Create的几个布尔参数里,updateBeforeConnect设false是因为我们刚拿到的端点信息已经是最新的,再取一次纯属浪费一次往返。
3.2 断开与资源释放:别只 Dispose
断开会话这件事,Demo 里写一行session.Dispose()也能跑,但跑几天就会撞上服务端会话数超限的告警。原因是Dispose只释放本地通道,不会给服务端发CloseSession,服务端要靠SessionTimeout慢慢回收那些“幽灵会话”。
public static async Task CloseAsync(Session? session, ILogger logger) { if (session is null) return; try { // 先删订阅,再关会话:避免服务端残留 MonitoredItem 占配额 foreach (var sub in session.Subscriptions.ToArray()) { await sub.DeleteAsync(false, CancellationToken.None); sub.Dispose(); } if (session.Connected) await session.CloseAsync(); // 给服务端发 CloseSession } catch (ServiceResultException ex) { // 连接已经断了的情况下 CloseAsync 也会失败,属于预期,记一笔就行 logger.LogWarning(ex, "关闭会话时出错,会话可能已失效"); } finally { session.Dispose(); // 释放通道与定时器 } }顺序不能反。订阅挂在会话下面,会话一关订阅自然也没了,但如果先关会话再删订阅,sub.DeleteAsync会因为会话失效抛异常。另外,DeleteAsync的第一个参数是“是否只删本地”——传true就跳过服务端通知,只在已知服务端已经没了的时候用,正常关闭必须传false。这个细节不写进代码注释,下一个人接手基本会踩。
3.3 KeepAlive 心跳监听与断线重连
心跳是这份 Demo 里最值钱的一段。OPC UA 的会话保活靠客户端按KeepAliveInterval周期性发Publish请求,服务端没数据可发时回一个空响应维持连接。SDK 会把这个过程抽象成Session.KeepAlive事件,状态好和坏都会回调,所以第一件事是判断Status。
private void OnKeepAlive(Session sender, KeepAliveEventArgs e) { if (e.Status is null || ServiceResult.IsGood(e.Status)) return; // 心跳正常,直接返回,别在这里写日志刷屏 // 心跳异常:只做记录和发信号,不要在回调线程里直接重连 _logger.LogWarning("心跳异常 {Status},会话 {Session}", e.Status, sender.SessionName); _lastKeepAliveFailAt = DateTime.UtcNow; _reconnectSignal.Set(); // 交给后台重连循环处理 }为什么不在回调里直接重连?KeepAlive是在 SDK 内部定时器线程上触发的,重连要建会话、要等网络超时,同步做会把 SDK 的定时器堵死,导致后续心跳判断全部错乱。正确做法是发一个信号,让独立的后台任务去处理。
private async Task ReconnectLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { await _reconnectSignal.WaitAsync(ct); // 由 KeepAlive 或请求失败触发 for (var attempt = 1; attempt <= _options.MaxRetry && !ct.IsCancellationRequested; attempt++) { try { await CloseAsync(_current, _logger); // 先清掉旧会话 var session = await _factory.CreateAsync(ct); // 重新建 Session session.KeepAlive += OnKeepAlive; await _subscriptionManager.RebuildAsync(session, ct); // 关键:订阅必须重建 _current = session; _logger.LogInformation("第 {Attempt} 次重连成功", attempt); break; } catch (Exception ex) { _logger.LogError(ex, "第 {Attempt} 次重连失败", attempt); var delay = TimeSpan.FromSeconds(Math.Min(30, attempt * 2)); // 2s 起步,封顶 30s await Task.Delay(delay, ct); } } } }重试间隔用指数退避但设上限,是为了避免服务端整体重启时几百个客户端同时猛冲,这个血泪经验在并发量大的现场特别明显。RebuildAsync这一步不能省:会话重建后服务端上原来的MonitoredItem全部失效,客户端对象虽然还在,但推送永远不会来,表现就是“重连成功了但数据不动了”。
提示:
MaxRetry用完后不要退出循环,直接进入下一轮WaitAsync,让会话在后续某个时刻还能被救回来,常驻服务不该有“彻底放弃”这个状态。
4. 读写与订阅:从 ReadValue 到 MonitoredItem 推送
4.1 读节点:NodeId、StatusCode 与类型转换
读单个节点是最简单的调用,但坑集中在返回值上。session.ReadValue返回的是DataValue,里面Value是装箱的object,StatusCode可能是Bad但不会抛异常,很多人只取Value就返回,结果上层拿到一堆 null。
public async Task<T> ReadAsync<T>(Session session, string nodeIdString) { var nodeId = NodeId.Parse(nodeIdString); // 形如 "ns=2;s=Line1.Temp" var response = await session.ReadAsync( null, 0, TimestampsToReturn.Both, new ReadValueIdCollection { new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value } }, CancellationToken.None); var result = response.Results[0]; if (StatusCode.IsBad(result.StatusCode)) throw new OpcUaReadException($"读取 {nodeIdString} 失败:{result.StatusCode}"); // 别静默返回 default if (result.Value is null) return default!; return (T)Convert.ChangeType(result.Value, typeof(T)); // 服务端 Int16 → 客户端 int,必须转 }NodeId.Parse支持ns=2;s=...(字符串标识)和ns=2;i=1001(数字标识)两种写法,从设备文档里抄过来就能用。TimestampsToReturn.Both表示同时要源时间戳和服务端时间戳,做趋势图时源时间戳更准。StatusCode.IsBad必须显式判断,节点不存在返回BadNodeIdUnknown,权限不够返回BadUserAccessDenied,这些都不会抛异常,只体现在StatusCode里。
Convert.ChangeType这一步是另一个高频翻车点。服务端的Int16到了客户端是short,你写(int)result.Value会抛InvalidCastException,所以要么用Convert.ChangeType,要么在配置里明确每个节点对应的 CLR 类型。批量读的时候把ReadValueIdCollection塞满再发一次请求,比循环调单点读快一个数量级,节点数超过十个就该走批量。
4.2 写节点:写之前先问服务端能不能写
写入看起来对称,其实多了一步校验。有些节点的AccessLevel是只读的,直接写会返回BadNotWritable,但此时你的业务可能已经认为写成功了。
public async Task WriteAsync(Session session, string nodeIdString, object value) { var nodeId = NodeId.Parse(nodeIdString); // 先查 DataType 和 AccessLevel,避免盲写 var attrs = await session.ReadAttributesAsync( nodeId, Attributes.DataType | Attributes.AccessLevel, CancellationToken.None); var access = attrs.Result.GetAttributeValue<byte>(Attributes.AccessLevel); if ((access & AccessLevels.CurrentWrite) == 0) throw new InvalidOperationException($"{nodeIdString} 不可写,AccessLevel={access}"); var dataTypeId = attrs.Result.GetAttributeValue<NodeId>(Attributes.DataType); var writeValue = new WriteValue { NodeId = nodeId, AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) }; var response = await session.WriteAsync( null, new WriteValueCollection { writeValue }, CancellationToken.None); if (StatusCode.IsBad(response.Results[0])) throw new OpcUaWriteException($"{nodeIdString} 写入失败:{response.Results[0]}"); }AccessLevel是个位标志,CurrentWrite位为 1 才表示当前可写,有些设备还会额外要求会话处于某种模式。Variant的构造类型必须和服务端DataType一致,服务端声明Int32你传double,返回的就是BadTypeMismatch。写入返回的StatusCodeCollection每个元素对应一个WriteValue,批量写的时候要逐条判断,不能只看第一个。
4.3 订阅:Subscription 与 MonitoredItem 的两层结构
订阅是这份 Demo 的核心。它分两层:Subscription管发布节奏,MonitoredItem管具体节点。发布间隔、保活计数、生命周期计数这三个参数共同决定服务端多久推一次数据、什么时候认为客户端失联。
public async Task<Subscription> SubscribeAsync( Session session, IEnumerable<MonitoredNode> nodes, Action<MonitoredItem, MonitoredItemNotificationEventArgs> handler) { var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 500, // 服务端每 500ms 尝试推一次 KeepAliveCount = 10, // 连续 10 个周期没数据,发一个空响应探活 LifetimeCount = 60, // 连续 60 个周期收不到 Publish,服务端判定客户端失联 MaxNotificationsPerPublish = 200, // 单次响应最多携带的通知条数 PublishingEnabled = true, Priority = 0 }; session.AddSubscription(subscription); await subscription.CreateAsync(); foreach (var node in nodes) { var item = new MonitoredItem(subscription.DefaultItem) { DisplayName = node.DisplayName, StartNodeId = NodeId.Parse(node.NodeId), AttributeId = Attributes.Value, SamplingInterval = node.SamplingInterval, // -1 表示跟随发布间隔 QueueSize = node.QueueSize, // >1 才谈得上不丢中间突变 DiscardOldest = true // 队列满时丢最旧的,保留最新 }; item.Notification += handler; subscription.AddItem(item); } await subscription.ApplyChangesAsync(); // 一次性下发,别在循环里逐条 Create return subscription; }几个参数的关系要理清。KeepAliveCount必须小于LifetimeCount,常见比例是 1:6 或者 1:3,前者是探活,后者是判死。PublishingInterval设成 100ms 而设备本身 1 秒才更新一次,只是让服务端空转,没有收益。SamplingInterval可以和发布间隔不同,采样比发布快,靠QueueSize缓冲,这就是“能不能拿到中间突变”的关键——队列只有 1,两次发布之间变了三次,你只能看到最后一次。
ApplyChangesAsync是必须的,AddItem只是改本地集合,不下发到服务端。有人写了AddItem却忘了这一句,然后奇怪为什么没有通知,这是最常见的订阅翻车现场。
4.4 通知回调里只做搬运,别做业务
Notification回调运行在 SDK 的 Publish 处理线程上,在这里做数据库写入或者同步 HTTP 调用,会直接堵死后续所有通知,表现就是“跑着跑着数据延迟越来越大”。
private readonly Channel<Sample> _buffer = Channel.CreateBounded<Sample>( new BoundedChannelOptions(10_000) { FullMode = BoundedChannelFullMode.DropOldest }); private void OnNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e) { foreach (var dv in item.DequeueValues()) // 必须取走队列,否则服务端侧会积压 { var sample = new Sample( item.DisplayName, dv.Value, dv.SourceTimestamp, dv.StatusCode); if (!_buffer.Writer.TryWrite(sample)) _logger.LogWarning("缓冲已满,丢弃 {Name} 的样本", item.DisplayName); } }DequeueValues()会把该MonitoredItem本地缓存的采样值全部取走并清空。不调用它,队列会一直涨,跑几天内存就上去了,这是典型的黑匣子现象——代码没报错,内存悄悄涨。取出来之后立刻丢进Channel就返回,真正的入库、计算、上报交给独立的消费者任务。
private async Task ConsumeAsync(CancellationToken ct) { await foreach (var batch in _buffer.Reader.ReadAllAsync(ct).Batch(50)) { await _repository.SaveBatchAsync(batch, ct); // 批量落库,单条写扛不住 } }批量落库比逐条写省的不只是 IO,还有事务开销。Channel用DropOldest是有意的:常驻采集服务在极端情况下宁可丢老数据也不能让内存无上限增长,丢了多少条记个计数器暴露出去就行。
5. 避坑与排查:五条踩过的坑
5.1 连接与证书类
现象:连接时报BadSecurityChecksFailed,日志里能看到对端证书不受信任。原因几乎都是客户端自签名证书没进服务端信任列表,或者服务端证书没进客户端TrustStorePath。这个问题双向的,两边都得互相认。解决办法是首次运行后去./pki/own/certs找到客户端的.der文件,导入服务端的信任目录;服务端的证书同样拷进客户端./pki/trusted/certs。AutoAcceptUntrustedCertificates打开能跳过这一步,但那等于关掉了安全模型,只适合内网调试,别带到现场。
现象:改用 IP 连接后突然连不上,之前用主机名好好的。原因是证书的 SAN 里只有主机名或者只有 IP,端点 URL 的主机部分对不上就校验失败。这是典型的玄学问题——配置一个字没改,只是换了地址。判断方法很简单:看证书主题里写的是什么,客户端EndpointUrl里的主机部分就得跟它一致。服务端证书重签的时候要把现场的访问地址都加进去,不然上线那天就是灾难现场。
现象:服务端报会话数超限,重启客户端才恢复。原因就是 3.2 说的,只Dispose没CloseAsync,服务端攒了一堆靠超时才回收的会话。检查一下每次异常退出的路径是不是都走了CloseAsync,using语句在这里不够,因为它只调Dispose。
5.2 读写与订阅类
现象:订阅建好了,Subscription状态也是Created,可一条通知都不来。排查顺序建议是:先看每个MonitoredItem的Status.Error,节点不存在会是BadNodeIdUnknown,这表示 NodeId 抄错了命名空间索引;再看SamplingInterval和QueueSize是不是合理;最后确认调没调ApplyChangesAsync。这四步能解决九成的“订阅没数据”,剩下的一成通常是服务端把PublishingEnabled关了或者节点值本身不变化(OPC UA 默认只在值变化时推,死值不会重复上报)。
现象:读回来的值类型转换抛InvalidCastException,或者值永远是 null。前者是 4.1 说的装箱类型不匹配,服务端Int16、客户端强转int就会炸,统一走Convert.ChangeType或者按配置声明类型。后者多半是读了错误的AttributeId,默认是Attributes.Value,但有人拿DisplayName之类的属性当值读,Value自然是空的。还有一种情况是节点StatusCode是BadWaitingForInitialData,服务端刚启动还没采到数据,等一个周期就有了。
现象:跑了一两天内存缓慢上涨,心跳也开始不稳。头号嫌疑是Notification回调里没调DequeueValues(),队列只进不出;其次是日志里把每次通知都打一条Information,日志框架的队列也跟着涨。第三种是重连逻辑里有事件没解绑,旧Session的KeepAlive还挂着委托,每次重连就多泄漏一点——CloseAsync之前记得session.KeepAlive -= OnKeepAlive。这三条都属于不报错但慢慢烂掉的问题,没有后悔药,只能在写的时候就养成习惯。
6. 二次开发:把 Demo 改成能上产线的采集服务
Demo 跑通之后,下一步通常是把它塞进一个常驻服务里。这一步最忌讳的是直接在Program.cs上原地膨胀,我见过太多“临时先用 Demo 顶一下”最后变成生产系统的主干。我的做法是先抽一层采集接口,让上层只依赖接口,不依赖Session。
public interface IOpcUaClient : IAsyncDisposable { bool IsConnected { get; } Task ConnectAsync(CancellationToken ct); Task<T> ReadAsync<T>(string nodeId, CancellationToken ct); Task WriteAsync(string nodeId, object value, CancellationToken ct); IAsyncEnumerable<Sample> WatchAsync(CancellationToken ct); // 订阅数据统一出口 }实现类里把前面几章的工厂、订阅管理、缓冲队列组装起来,WatchAsync从Channel.Reader读,上层用await foreach消费。这样一来业务代码完全不知道 OPC UA 的存在,以后要换成另一家协议的采集,只换实现类。改造前后的对照大致是这样:
| 关注点 | Demo 写法 | 产线写法 |
|---|---|---|
| 生命周期 | Program.cs里顺序调用 | 挂BackgroundService,随宿主启停 |
| 配置 | 硬编码或单文件 | 配置中心下发,支持热更新节点列表 |
| 数据出口 | 控制台打印 | Channel+ 批量落库或发 MQ |
| 错误处理 | 抛异常到顶层 | 分类重试,区分网络错误与配置错误 |
| 观测 | Console.WriteLine | 连接状态、通知速率、丢弃计数打点 |
热更新节点列表这件事值得单独做,现场调试时经常临时加几个点位看波形,如果每次都要重启服务,一晚上就过去了。实现上不难:配置变更时对比新旧MonitoredNodes,差异项走MonitoredItem的增删,再ApplyChangesAsync一次,不用重建整个Subscription。
验证改造是否到位,我一般跑三个动作。第一个是拔网线再插回去,看会话能不能在几个重试周期内自动恢复,订阅数据是不是也跟着回来了——只看连接状态恢复不算数,得看数据流真的续上。第二个是把服务端重启,客户端在这期间应该保持退避重试,服务端起来后自动连上,日志里不出现刷屏式报错。第三个是挂机 24 小时看内存曲线,曲线必须是平的,只要出现缓慢上升就回去查DequeueValues和事件解绑。
从那以后我每次接新的 OPC UA 设备,都强制先走一遍“连上、读一个点、写一个点、订阅一个点、拔网线”这五步,跑完再动业务代码。跳过哪一步,后面就会在哪一步被卡住,希望帮到你。
本文还有配套的精品资源,点击获取