.NET OPC UA 通信 Demo:会话管理、节点读写、订阅与断线重连

简介:面向.NET开发者与工业自动化方向的OPC UA通信示例包,围绕客户端连接、断开、读写节点、创建订阅与监听心跳等核心环节,给出可直接参考的代码组织与配置思路,适合已具备一定C#基础、需要与PLC等设备做数据交互的中级开发者对照实践。压缩包共2000个文件、约87.75MB,以xml配置与说明、dll依赖库、txt记录、nupkg包及少量cs源码、sln/csproj工程文件为主,完整保留了项目运行所需的依赖与工程结构。目前已有678人学习。通过其中的代码示例可了解安全连接参数设置、节点读写调用、监视项订阅推送以及心跳回调的落地方式,也可作为排查连接失败、订阅无响应等问题的参照,便于快速搭起可运行的通信验证环境。

1. 拿到一个 .NET OPC UA 通信 Demo,先弄清它能替你把哪几件事跑通

某开发者接了个小采集任务,手上只有一台支持 OPC UA 的网关和一个端点地址,要求是跑在 Windows 服务里别老掉线:连接、断开要可控,读写要能对上点位,订阅要能持续推数,还得能判断对面是不是已经没了。这份 .NET OPC UA 通信 Demo 就是照这个需求攒出来的,基于官方 .NET 标准栈,把会话管理、节点读写、订阅推送、心跳监听四条链路串成一套能直接跑的代码。适合两类人:一是第一次接触 OPC UA、想先看到数据在屏幕上动起来的新手;二是已经写过几版客户端、但每次都在重连和订阅失效上翻车的熟手。下面按「结构 → 连上 → 取数 → 保活 → 排错 → 改造」的顺序拆。

2. 工程结构与会话生命周期:连接和断开不是一对 Begin/End

OPC UA 跟 HTTP 最大的差别在于它是有状态的:客户端和服务端之间先要协商出一个 Session,之后所有读写订阅都挂在这个 Session 上。Session 断了,挂在它下面的 Subscription 全部作废,MonitoredItem 一个都活不下来。所以这份 Demo 的骨架不是围绕「请求」搭的,是围绕「会话」搭的。

2.1 目录结构:把 OPC UA 的脏活收在一个 Core 类库里

先看目录,这是整个项目能不能二次开发的基础。控制台宿主只负责读配置、起服务、打日志,所有跟协议相关的代码都压在 Core 里:

OpcUaDemo/ ├─ OpcUaDemo.Core/ │ ├─ OpcUaOptions.cs 连接参数(端点、安全策略、超时、重连间隔) │ ├─ OpcUaClient.cs 会话建立/关闭、读写、订阅的统一入口 │ ├─ HeartbeatMonitor.cs 应用层心跳 + 掉线判定 │ └─ SubscriptionFactory.cs 订阅与监控项的创建与重建 ├─ OpcUaDemo.Console/ │ ├─ Program.cs 宿主,注册配置与后台服务 │ └─ appsettings.json 端点地址、节点清单、心跳周期 └─ OpcUaDemo.Server/ 可选,本地起一个测试服务端用来对点

这么分的好处很直接:OpcUaClient不依赖控制台,可以原样搬进 ASP.NET Core 的 hosted service,也可以搬进 WinForm 的后台线程。OpcUaOptions单独成型,意味着换一台设备只改 json,不改代码。我在别的项目里见过把 Session 直接 new 在按钮点击事件里的写法,结果一个窗口开了五个会话,服务端连接数直接打满,后来拆了三天才理干净——分层这件事在 OPC UA 上不是洁癖,是止损。

OpcUaDemo.Server这一层容易被忽略,但没有它调试会很痛苦。没有真机的时候,本地起一个服务端,自己造几个节点、自己改值,读写和订阅才有对照物。常见的做法是用官方栈里的服务端示例改一个最小实现,挂两个变量节点加一个定时自增的计数器,用来验证订阅的推送节奏。

2.2 建立会话:Endpoint、ApplicationConfiguration 与证书三件套

连接这一步,代码不长,但参数一个都不能少。核心是先构造ApplicationConfiguration,再发现端点,最后创建 Session:

// 1) 应用配置:ApplicationUri 必须是 urn 形式,否则部分服务端直接拒绝 var config = new ApplicationConfiguration { ApplicationName = "OpcUaDemoClient", ApplicationUri = "urn:localhost:OpcUaDemo:Client", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { // 内网调试可以自动信任;生产环境务必换成受控证书存储 AutoAcceptUntrustedCertificates = true, ApplicationCertificate = new CertificateIdentifier { StoreType = "Directory", StorePath = "%LocalApplicationData%/OpcUaDemo/pki/own", SubjectName = "CN=OpcUaDemoClient" } }, TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 } }; await config.Validate(ApplicationType.Client); // 2) 端点发现:useSecurity=false 走 None 策略,联调阶段最省事 var endpointDescription = CoreClientUtils.SelectEndpoint( config, "opc.tcp://127.0.0.1:4840", useSecurity: false); var endpointConfiguration = EndpointConfiguration.Create(config); var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration); // 3) 建会话:sessionName 会出现在服务端审计日志里,起个能认出来的名字 var session = await Session.Create( config, endpoint, updateBeforeConnect: false, sessionName: "OpcUaDemo-Collector", sessionTimeout: 60000, identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null);

几个参数值得单独说。OperationTimeout是单次服务调用的上限,不是会话超时,很多人把它和DefaultSessionTimeout搞混,结果把 60 秒的会话超时当成请求超时,调大调小都没效果。updateBeforeConnect控制建会话前是否重新拉一次端点描述,如果你连的服务端可能换证书,把它设成 true 更稳。Session.Create在不同大版本之间签名有调整,如果你引用的包编译报参数不匹配,先去看这个方法在你那个版本里是不是多了一个 telemetry 相关入参,别急着改业务代码。

证书这块是新手最容易卡住的地方。None 策略下服务端不校验,一切都好说;一旦切到 Sign & Encrypt,客户端首次运行会在自己的证书存储里生成一张自签证书,这张证书必须被服务端信任,同时服务端证书也要被客户端信任,否则连接会以BadCertificateUntrusted之类的状态码失败。AutoAcceptUntrustedCertificates = true是联调期的后悔药,不是生产环境的方案,上线前记得关掉并改成显式的信任列表。

2.3 断开与释放:Close 之后还得 Dispose,顺序还很重要

断开这件事看起来就一行session.Close(),实际会翻车。正确的顺序是:先停订阅、再关会话、最后释放配置资源。订阅不停就关会话,服务端那边会留一段时间的孤儿订阅,等你重连回来发现监控项数量对不上:

public async Task DisconnectAsync() { // 1) 先退订事件,避免关闭过程中回调还在往业务层推数据 foreach (var item in _monitoredItems) item.Notification -= OnItemNotification; // 2) 删订阅,true 表示同时删掉它下面的所有监控项 foreach (var sub in _subscriptions) { try { await sub.DeleteAsync(true); } catch { /* 关闭阶段忽略异常 */ } _session?.RemoveSubscription(sub); } _subscriptions.Clear(); _monitoredItems.Clear(); // 3) 关会话并释放 if (_session != null) { try { _session.Close(); } catch { } _session.Dispose(); _session = null; } // 4) 停心跳定时器 _heartbeatTimer?.Dispose(); _heartbeatTimer = null; }

注意每个try/catch都是空的,这不是偷懒。关闭阶段网络可能已经断了,任何一步抛异常都会让后面的清理不执行,最后变成句柄泄漏。断开流程的原则是「尽力而为、全部走完」,异常在日志里记一笔就行,不能让流程中断。

另外Session实现了IDisposable,但它内部还握着ApplicationConfiguration的引用和定时器。如果宿主是长期运行的服务,建议把整个客户端包成IAsyncDisposable,在DisposeAsync里按上面的顺序走一遍,进程退出时才不会出现「关闭窗口后后台还有线程在跑」的玄学现象。

3. 节点读写与订阅:三种取数方式的取舍和代码骨架

会话连上只是通了路,真正干活的是节点操作。OPC UA 取数有三种方式:单点读、批量读、订阅推送。三者不是替代关系,选错了轻则浪费带宽,重则数据延迟看不出来。这一章把三种方式的代码骨架和参数含义拆开讲。

3.1 同步读与批量读:ReadValue 和 Read 的区别

最简单的读是session.ReadValue(nodeId),一行拿到DataValue。它适合做点位探测和心跳,不适合做批量采集,因为你循环调 100 次就是 100 个请求往返:

// 单点读:适合校验某个节点是否存在、值是否可读 DataValue single = session.ReadValue(new NodeId("ns=2;s=Device1.Temperature")); if (StatusCode.IsBad(single.StatusCode)) { // 节点不存在、权限不足、服务端拒绝,都会走到这里 _logger.LogWarning("读节点失败: {0}", single.StatusCode); } // 批量读:一次请求拿多个点,采集场景优先用这个 var nodesToRead = new ReadValueIdCollection { new ReadValueId { NodeId = new NodeId("ns=2;s=Device1.Temperature"), AttributeId = Attributes.Value }, new ReadValueId { NodeId = new NodeId("ns=2;s=Device1.Pressure"), AttributeId = Attributes.Value }, new ReadValueId { NodeId = new NodeId("ns=2;s=Device1.RunState"), AttributeId = Attributes.Value } }; session.Read( null, // requestHeader,传 null 用默认 0, // maxAge:0 表示必须取服务端最新值,不走缓存 TimestampsToReturn.Both, // 同时返回源时间戳和服务端时间戳 nodesToRead, out DataValueCollection results, out DiagnosticInfoCollection diagnostics);

maxAge这个参数值得展开。传 0 是强制要最新值,传大于 0 的毫秒数表示「这个时间内的缓存值可以接受」。有些服务端在内部有采样缓存,传 0 会让它每次都刷新采样,频率太高反而拖慢服务端;采集周期在秒级的场景,传一个略小于采集周期的值更合理。TimestampsToReturn.Both会同时给SourceTimestamp和ServerTimestamp,前者是设备侧采集时间,后者是服务端处理时间,做数据质量分析时这两个值的差值就是链路延迟,别只留一个。

批量读的结果必须逐个检查StatusCode。一次批量读里可能三个点两个好一个坏,results数组长度和请求数组一一对应,但服务端返回的diagnostics可能是空的,这属于正常行为,不要拿diagnostics.Count去索引。

3.2 写值:Variant 里的类型必须和服务端节点对上

写值翻车的概率比读高得多,因为类型不匹配时有些服务端不报错,只是默默不生效。写之前最好先读一次该节点的DataType属性,或者至少在配置里把类型写死:

var writeValues = new WriteValueCollection { new WriteValue { NodeId = new NodeId("ns=2;s=Device1.SetPoint"), AttributeId = Attributes.Value, Value = new DataValue(new Variant(36.5)) // double,必须和节点 DataType 一致 }, new WriteValue { NodeId = new NodeId("ns=2;s=Device1.Enable"), AttributeId = Attributes.Value, Value = new DataValue(new Variant(true)) // bool,别传 "true" 字符串 } }; session.Write( null, writeValues, out StatusCodeCollection writeResults, out DiagnosticInfoCollection writeDiagnostics); for (int i = 0; i < writeResults.Count; i++) { // 服务端对单个点的拒绝只体现在这里,不抛异常 if (StatusCode.IsBad(writeResults[i])) _logger.LogError("写第 {Index} 个点失败: {Code}", i, writeResults[i]); }

两个细节。第一,Variant的构造类型必须精确,节点是Int16你传int,部分服务端会做隐式转换,部分会返回BadTypeMismatch,行为不统一,所以宁可先读 DataType 再构造。第二,Write方法本身不抛异常,成败全在writeResults里,只判断方法有没有抛错是不够的——这是我见过最常见的「日志显示成功、现场数值没变」的原因。

如果节点是只读的(AccessLevel里没有CurrentWrite),写入会返回BadNotWritable。这个属性可以在连接后统一读一遍,把点位分成可写和只读两组,界面上该禁用的按钮就禁用掉,比让用户点了再报错体面。

3.3 订阅:Subscription 和 MonitoredItem 是两层结构

订阅是这套 Demo 里最有价值的部分,也是最容易被参数坑的地方。结构上分两层:Subscription管发布节奏,MonitoredItem管具体节点和采样节奏。

// 第一层:订阅,决定多久往客户端推一次 var subscription = new Subscription(session.DefaultSubscription) { DisplayName = "Device1-Sub", PublishingInterval = 1000, // 服务端每 1 秒发一次 NotificationMessage KeepAliveCount = 10, // 连续 10 个周期没有数据变化,发一个空保活包 LifetimeCount = 30, // 连续 30 个周期客户端无响应,服务端删掉这个订阅 PublishingEnabled = true, Priority = 0 }; session.AddSubscription(subscription); await subscription.CreateAsync(); // 第二层:监控项,决定这个节点多久采一次 var item = new MonitoredItem(subscription.DefaultItem) { DisplayName = "Temperature", StartNodeId = new NodeId("ns=2;s=Device1.Temperature"), AttributeId = Attributes.Value, SamplingInterval = 500, // 服务端每 0.5 秒采一次 QueueSize = 10, // 客户端来不及消费时最多缓存 10 个 DiscardOldest = true // 队列满了丢最旧的 }; item.Notification += OnItemNotification; subscription.AddItem(item); await subscription.ApplyChangesAsync();

回调长这样,注意它是后台线程触发的,业务处理要么快、要么丢队列,别在里面做数据库写入:

private void OnItemNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e) { foreach (var value in item.DequeueValues()) { // value.Value 是 Variant,value.SourceTimestamp 是设备侧时间 _queue.Enqueue(new Sample(item.DisplayName, value.Value.Value, value.SourceTimestamp)); } }

参数之间的关系必须理清:SamplingInterval是服务端采样频率,PublishingInterval是服务端打包推送频率。采样比发布快,一个发布包里会带多个值,靠QueueSize兜着;采样比发布慢,就会出现空发布包。KeepAliveCount和LifetimeCount的关系是后者必须大于前者,官方栈里如果配反了会直接抛异常。LifetimeCount的物理含义是「服务端等你多久」,客户端卡死超过这个时间,订阅就被服务端回收了,重连回来必须重新建订阅,这一点在第 5 章还会再说一次。

另外别忘了ApplyChangesAsync。不少同学习惯性地AddItem之后就开始等数据,结果一条都不来——AddItem只是加到本地集合,真正下发给服务端的是ApplyChanges。

4. 心跳监听与断线重连:怎么判断对面那台设备还活着

连接成功不代表一直在线。网线被拔、服务端重启、防火墙超时回收,这几种情况下 TCP 层可能还认为连接是通的,但应用层早就拿不到数据了。这一章讲两种心跳和一套重连策略。

4.1 Session.KeepAlive:协议层自带的保活信号

官方栈的Session自带KeepAlive事件,服务端在KeepAliveCount个发布周期内没有数据变化时会发一个空包,客户端收到就触发这个事件。它是最廉价的心跳,不用你额外发请求:

session.KeepAlive += (s, e) => { if (ServiceResult.IsBad(e.Status)) { // 注意:这里只是「这一次保活失败」,不等于会话已经死了 _logger.LogWarning("KeepAlive 异常: {0}, 当前状态 {1}", e.Status, e.CurrentState); Interlocked.Increment(ref _keepAliveFailCount); } else { Interlocked.Exchange(ref _keepAliveFailCount, 0); _lastKeepAliveAt = DateTime.UtcNow; } };

关键在于别把单次失败当断线。网络抖一下就会来一次 Bad,直接触发重连会把正常的会话反复拆掉,反而更不稳定。常见的做法是连续失败 N 次(比如 3 次)或者距上次成功超过 M 秒才判定为断线。e.CurrentState能告诉你当前会话处于 Connected、Connecting 还是 Closed,比只看 StatusCode 信息量大。

还有一点:KeepAlive只在有订阅存在时才稳定触发。如果这个客户端只做读写、一个订阅都没建,服务端不会主动发保活包,这个事件可能长时间不触发,这时得靠应用层心跳。

4.2 应用层心跳:定时读服务端时间节点

应用层心跳的思路是定时读一个一定会变的节点,看看读不读得到、返回的状态码好不好。标准地址空间里ServerStatus下的CurrentTime就是干这个用的,它的值每次读都不一样,天然适合做活性探测:

用途节点说明
服务端当前时间i=2258每次读都变,用来判断服务端是否在正常处理请求
服务端状态i=2259Running / Failed 等枚举,判断服务是否处于运行态
服务端启动时间i=2257值变了说明服务端重启过,可用来触发全量重连
自定义心跳变量ns=2;s=Heartbeat设备侧自己维护的计数器,比标准节点更贴近业务

代码上就是定时任务加一次读:

private async Task HeartbeatLoopAsync(CancellationToken ct) { var nodeId = new NodeId(2258); // ServerStatus.CurrentTime while (!ct.IsCancellationRequested) { try { var value = _session.ReadValue(nodeId); if (StatusCode.IsBad(value.StatusCode)) throw new ServiceResultException(value.StatusCode); var serverTime = (DateTime)value.Value; var drift = (DateTime.UtcNow - serverTime).TotalSeconds; _logger.LogDebug("心跳正常,与服务端时间偏差 {Drift:F1}s", drift); _lastHeartbeatOkAt = DateTime.UtcNow; } catch (Exception ex) { _logger.LogWarning(ex, "应用层心跳失败"); } await Task.Delay(_options.HeartbeatIntervalMs, ct); // 常见取值 3000~10000ms } }

ReadValue是同步阻塞调用,放在Task.Delay循环里会占一个线程。采集点位多的项目建议换成异步读,或者干脆放到独立的采集线程上。心跳周期不要设得太短,1 秒一次读在几十个客户端同时跑的时候,服务端光处理这些读请求就要占不少资源,3 到 10 秒是常见的折中值。

CurrentTime的返回值还能顺带做时间同步检查。客户端和服务端时间偏差过大时,签名校验和证书有效期判断都可能出问题,日志里带一句偏差值,排查时会省很多事。

4.3 重连:先用官方重连处理器,兜不住再自己建

重连有两条路。第一条是用官方栈里的SessionReconnectHandler,它会自己尝试重建会话并转移订阅,代码量最小:

private SessionReconnectHandler _reconnectHandler; private void OnKeepAlive(object sender, KeepAliveEventArgs e) { if (!ServiceResult.IsBad(e.Status)) return; if (Interlocked.Increment(ref _keepAliveFailCount) < 3) return; lock (_reconnectLock) { if (_reconnectHandler != null) return; // 已经在重连了,别重复起 _logger.LogWarning("连续保活失败,开始重连"); _reconnectHandler = new SessionReconnectHandler(); _reconnectHandler.BeginReconnect(_session, 10000, (s, ev) => { if (ev.Status == null || ServiceResult.IsGood(ev.Status)) { _logger.LogInformation("重连成功"); _reconnectHandler?.Dispose(); _reconnectHandler = null; Interlocked.Exchange(ref _keepAliveFailCount, 0); } }); } }

BeginReconnect的第二个参数是重试间隔毫秒数,内部会按固定节奏一直试。它的局限在于:服务端换了证书、换了端点地址、或者订阅已经被服务端彻底删掉的情况下,它转不回来,这时候必须整会话重建。

第二条路是自己控制:把「建会话 + 建订阅」抽成一个ConnectAsync()方法,判定断线后先DisconnectAsync()清理干净,再按指数退避反复调ConnectAsync()。

重试次数间隔说明
1~3 次2s处理网络抖动、服务端瞬时重启
4~10 次5s / 10s / 20s 递增处理服务端升级、网关重启
10 次以上固定 60s长时间断网,进入低频探测,别把日志刷爆

指数退避的价值在于不会在服务端刚起来的一瞬间被几十个客户端同时打满,也不会在长时间断网时每秒写一条错误日志把磁盘写满。退避上限我一般卡在 60 秒,超过这个值现场运维会怀疑程序死了。

无论是哪条路,重连成功后必须做一件事:核对订阅和监控项是否还在。SessionReconnectHandler会尽量转移,但不保证 100%;自己重建的话,直接在ConnectAsync()里重建一遍最保险。这一条是血泪经验,第 5 章会展开。

5. 避坑与排查:五个把 Demo 拖成线上事故的细节

Demo 能跑通和能上生产之间隔着一堆细节。下面五条是我在类似项目里真实遇到过或者被人问过最多的,每条按现象、原因、解决三段写。

5.1 现象:连接成功,第一次 Read 就超时

现象:日志里Session.Create返回成功,紧接着读任意节点都是BadTimeout,重试几次偶尔能成。

原因:OperationTimeout配得太小,或者端点发现阶段用的是localhost而服务端只监听了具体网卡地址。还有一种情况是服务端在 None 策略下允许匿名,但配置里传了用户名身份,服务端要额外走一次认证,首次请求被拖慢。

解决:先把TransportQuotas.OperationTimeout调到 15000 以上再试;端点地址统一写 IP,不要写主机名,避免 DNS 解析和服务端绑定不一致;身份先用匿名跑通,再切用户名密码。排查时打开官方栈的追踪日志,能看到每次请求的详细耗时,比猜快得多。

5.2 现象:订阅建好了,曲线一动不动

现象:CreateAsync和ApplyChangesAsync都返回成功,MonitoredItem数量也对,但回调一次都不触发。

原因:三个高频原因。一是PublishingInterval设得比SamplingInterval大很多,而节点值又没变化,服务端只在KeepAliveCount个周期后才发空包,看起来像没数据;二是节点 ID 写错但服务端不报错,只是返回BadNodeIdUnknown埋在StatusCodes里没人看;三是忘了把subscription加到session上就调了CreateAsync。

解决:先检查MonitoredItem.Status.Error,创建监控项后它的状态里会带错误码,这一步九成的人会跳过。再把PublishingInterval和SamplingInterval调成一样,用本地测试服务端的自增计数器验证链路。最后确认session.AddSubscription(subscription)在CreateAsync之前调用。

5.3 现象:跑几个小时内存涨到几个 G

现象:进程启动时几十兆,跑一天涨到几个 G,抓 dump 看全是DataValue和MonitoredItemNotification。

原因:订阅回调里只处理了值,没清空队列。MonitoredItem内部有个QueueSize大小的缓冲区,每次回调如果不用DequeueValues()把值取走,或者自己又往一个无界队列里塞,内存就一路涨。另一种是每次重连都新建Subscription却没删旧的,对象越堆越多。

解决:回调里统一用item.DequeueValues()取完;往下游传递用有界队列(Channel带容量上限),满了丢最旧的并计数;重连前把旧订阅从session里RemoveSubscription掉。上线前用dotnet-counters盯着GC Heap Size跑二十四小时,曲线应该是锯齿状,不是单调上升。

5.4 现象:断网恢复后客户端「假在线」

现象:网线拔了十分钟再插回去,客户端日志还在打「心跳正常」,但数据一直不更新,界面上显示在线。

原因:KeepAlive事件在没有任何订阅或者订阅已被服务端回收的情况下不会触发,客户端就永远等不到失败信号。另一种是用了SessionReconnectHandler,它重建了会话,但订阅被服务端按LifetimeCount回收掉了,新会话上光秃秃的,读还能读,订阅一条都没有。

解决:应用层心跳必须独立于订阅存在,定时读CurrentTime,连续失败超过阈值就主动断开重连。重连成功后遍历订阅列表,逐个检查subscription.Created和监控项数量,缺了就重建。判断在线状态别只看 TCP,要同时满足「应用层心跳成功」和「订阅数量符合预期」两个条件。

5.5 现象:写值返回成功,服务端数值没变

现象:session.Write没抛异常,日志打了「写入成功」,但服务端那边读出来的还是老值。

原因:这个坑我在不同项目里遇到过三次。第一次是没检查writeResults,里面是BadNotWritable;第二次是Variant类型不匹配,服务端做了截断但没报错;第三次是写到了错误的命名空间,ns=2写成了ns=3,节点恰好也存在,写进去的是另一个变量。

解决:写完立刻回读一次做校验,这一步不能省;把writeResults逐个判StatusCode.IsGood;节点 ID 从配置里读,不要硬编码,配置里带上命名空间索引。对关键设定值,写完回读不一致就重试一次并告警,比事后查日志强得多。

6. 二次开发:把这个 Demo 改成能进生产的采集服务

Demo 和生产的差距主要在四件事:配置化、并发、可观测、优雅退出。下面这张表是我改造时的对照清单,照着填基本不会漏:

维度Demo 写法生产写法
连接参数代码里写死端点appsettings.json绑定OpcUaOptions,支持热重载
会话管理单例,手动创建注册为IHostedService,随宿主启停
读写同步阻塞异步 + 批量 + 超时取消
心跳定时读CurrentTime双通道(KeepAlive+ 应用层),带失败计数
断线重建会话指数退避 + 重建订阅 + 指标上报
日志Console.WriteLine结构化日志 + 关键指标(心跳延迟、订阅数、队列深度)

落地时我一般把客户端注册成单例后台服务,宿主负责启动和停止:

// Program.cs:客户端作为托管服务随宿主启停,退出时自动走清理流程 builder.Services.Configure<OpcUaOptions>(builder.Configuration.GetSection("OpcUa")); builder.Services.AddSingleton<OpcUaClient>(); builder.Services.AddHostedService(sp => sp.GetRequiredService<OpcUaClient>());
public class OpcUaClient : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { await ConnectAsync(stoppingToken); // 建会话 + 建订阅 await RunHeartbeatAsync(stoppingToken); // 心跳跑到断开为止 } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogError(ex, "会话异常,{Delay}s 后重连", _nextDelay); await Task.Delay(TimeSpan.FromSeconds(_nextDelay), stoppingToken); _nextDelay = Math.Min(_nextDelay * 2, 60); // 指数退避,上限 60 秒 } } } }

改的时候有两个验证方法特别管用。第一,把本地测试服务端做成可以手动杀掉再拉起的,反复开关二十次,看客户端能不能每次都自动恢复并且订阅数量正确——这一步能抓出大部分重连问题。第二,把读取的点位清单做成可配置的,换个配置文件就能连另一台设备,不改一行代码,这是二次开发里最省事的一笔投资。

还有一个容易忽略的点:采集和业务处理要解耦。订阅回调和心跳循环都跑在后台线程上,直接在里面写数据库、调 HTTP 接口,一旦下游变慢就会反压到 OPC UA 的接收队列,最后表现为「服务端在推,客户端收不到」。中间加一层有界队列,消费端单独跑,队列深度打到监控指标里,问题一眼就能看出来。

从那以后我每次接 OPC UA 的活,都强制先跑一遍「拔网线 → 等五分钟 → 插回去」这个动作,再跑一遍「杀服务端 → 重启 → 看订阅数量」,两关都过了才敢往现场发版本。前面瘫在会议室里排查总比瘫在客户现场好。希望帮到你。

本文还有配套的精品资源,点击获取