简介:面向工业自动化与物联网开发者的 .NET OPC UA 通信 Demo,围绕客户端与 PLC、OPC UA 服务器的实际交互展开,覆盖建立连接、安全断开、节点读写、数据订阅与心跳监听等核心环节,适合具备 C# 基础、希望快速理解 OPC UA 客户端开发流程的开发者作为练习样例和联调参考。压缩包共 2000 个文件,整体约 87.75MB,以 xml 配置与说明、dll 依赖库、txt 文档、nupkg 包及少量 cs 源码、config 配置和 exe 运行文件为主,较完整地保留了典型 .NET OPC UA 工程的依赖与目录结构。已有 678 人学习,表明该主题在工业通信学习中具有一定关注度。读者可从中梳理客户端初始化、证书与身份验证设置、订阅对象创建、监视项添加、读写调用及心跳回调的实现思路,并借助配置文件和依赖清单理解工程组织方式,为后续对接 PLC、排查连接与订阅异常提供可复用参考。
1. 从一条模拟产线说起:.NET OPC UA Demo 到底覆盖哪五个动作
设备侧已经开了 OPC UA 服务,上位机却还在用定时轮询数据库,这种架构在模拟产线里很常见。这个 .NET OPC UA Demo 把客户端最常遇到的五个动作拆开了:连接、断开、读写、订阅、监听心跳。它适合刚接触 .netOPCUA 的开发者,也适合手里已有服务端、需要快速验证点位和断线行为的熟手。代码不追求大而全,重点是把 Session、Subscription、MonitoredItem 和 KeepAlive 这几条线跑通,让人能对照自己的现场参数改。拿到 Demo 后别急着接真实设备,先把本机端点、匿名/用户名身份、安全策略跑一遍,再换成自己的 NodeId,坑会少很多。
2. 连接与断开:Session 超时、安全策略和重连骨架怎么落地
连接是整套 Demo 的第一道门槛。很多人把 Endpoint URL 填对就以为成功了,结果卡在证书、用户身份或 Session 超时上。OPC UA 客户端连接不是简单开一个 Socket,它要发现端点、协商安全策略、创建会话,再维持 KeepAlive。常见做法是先把连接参数集中到一个配置对象里,别散落在按钮事件中。下面按端点发现、Session 创建、关闭顺序、断线重连四块拆开。
2.1 先定连接参数:Endpoint、SecurityPolicy、UserIdentity
Endpoint 一般写成opc.tcp://127.0.0.1:4840,生产环境换成服务端实际地址。SecurityPolicy 在调试阶段常用 None,但只要跨机器或上生产,Basic256Sha256 这类策略就要把客户端证书加入服务端信任列表。UserIdentity 分匿名和用户名两种,匿名适合内网调试,用户名要确认服务端是否允许该账户读节点。SessionTimeout 我一般先给 60 秒,OperationTimeout 给 10 秒,KeepAliveInterval 给 5 秒,再根据网络抖动调整。
// 加载客户端配置并检查应用实例证书 var app = new ApplicationInstance { ApplicationName = "OpcUaDemoClient", ApplicationType = ApplicationType.Client, ConfigSectionName = "OpcUaDemoClient" }; var config = await app.LoadApplicationConfiguration("OpcUaDemoClient.Config.xml", false); await app.CheckApplicationInstanceCertificate(false, 2048); var endpointUrl = "opc.tcp://127.0.0.1:4840"; var useSecurity = true; // 发现服务端端点,15000 是发现超时,不是会话超时 var endpointDescription = CoreClientUtils.SelectEndpoint(config, endpointUrl, useSecurity, 15000); var endpointConfiguration = EndpointConfiguration.Create(config); var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration);这段代码先加载OpcUaDemoClient.Config.xml,再检查客户端证书。CheckApplicationInstanceCertificate(false, 2048)里的2048是证书密钥长度,不同维护分支的重载可能略有差异,按 IntelliSense 调整即可。CoreClientUtils.SelectEndpoint的第三个参数决定是否走安全通道,调试时设false能快速排除证书问题,但不要把这个开关带到生产。ConfiguredEndpoint把发现结果包装成后续Session.Create可用的对象。参数上最容易翻车的是把发现超时当成 Session 超时,结果服务端稍慢就误判连接失败。
2.2 建立 Session 与关闭 Session 的顺序
Session 创建时要把身份、会话名、超时一起传进去。会话名不要每次随机生成,否则服务端会话列表会很难排查。创建成功后立刻设置OperationTimeout和KeepAliveInterval,再挂 KeepAlive 事件。关闭时先移除订阅,再关会话,最后 Dispose,顺序反了容易出现回调还在跑、通道已经断的情况。
// 匿名身份;用户名身份改成 new UserIdentity("user", "password") IUserIdentity identity = new UserIdentity(); var session = await Session.Create( config, endpoint, false, "OpcUaDemoSession", 60_000, identity, null); session.OperationTimeout = 10_000; session.KeepAliveInterval = 5_000; session.KeepAlive += OnSessionKeepAlive; // 关闭顺序:先摘订阅,再关会话,最后释放 try { if (subscription != null) session.RemoveSubscription(subscription); if (session.Connected) session.Close(); } finally { session.Dispose(); }Session.Create的参数里,60_000是请求的会话超时,单位毫秒;false表示连接前不再做一次端点更新,按库版本重载可能不同。OperationTimeout控制单次读写等待时间,KeepAliveInterval控制客户端向服务端发 KeepAlive 的间隔。关闭阶段先RemoveSubscription是为了停止通知回调,再Close让服务端释放会话,最后Dispose释放通道。只把变量置 null 不会立刻释放会话,服务端会话数被占满时,下一次连接就会报错。
2.3 断线重连不是循环 new Session
断线后最忌讳在 KeepAlive 事件里直接new Session。旧订阅、旧 MonitoredItem、旧回调都还在,循环几次就会出现重复通知和句柄泄漏。正确做法是维护一个重连标志,失败后做退避,再调用会话自带的重连能力。重连期间界面状态应该从 Connected 切到 Reconnecting,而不是假装还连着。
private int _reconnecting; private DateTime _lastKeepAliveUtc = DateTime.UtcNow; private async Task OnKeepAliveAsync(Session session, KeepAliveEventArgs e) { if (!ServiceResult.IsBad(e.Status)) { _lastKeepAliveUtc = DateTime.UtcNow; return; } // 同一时间只允许一个重连流程 if (Interlocked.Exchange(ref _reconnecting, 1) == 1) return; try { await Task.Delay(TimeSpan.FromSeconds(2)); await session.ReconnectAsync(); _lastKeepAliveUtc = DateTime.UtcNow; } catch (Exception ex) { // 记录异常,下一轮定时器再试,不要在这里死循环 Console.WriteLine(ex.Message); } finally { Interlocked.Exchange(ref _reconnecting, 0); } }ServiceResult.IsBad(e.Status)表示协议层 KeepAlive 已经异常,此时先等 2 秒再重连,避免服务端刚重启就被客户端打满。Interlocked.Exchange是防止 KeepAlive 回调重入的简单办法。session.ReconnectAsync()在部分库里是Reconnect(),按实际引用调整。重连成功之后还要检查订阅是否恢复,不能只看session.Connected。我一般会在重连后主动读一次心跳节点,确认应用层也活了,再恢复界面状态。
3. 读写节点:NodeId、DataValue 与批量读写的类型边界
读写看起来只是两个方法,真正花时间的是 NodeId 和数据类型。服务端的 namespace index 可能随工程版本变化,写死ns=2很容易在某次升级后全部读空。另一个高频问题是写入类型不匹配,客户端写float,服务端节点是double,返回 BadTypeMismatch,但界面上只看到一个失败,没有细节。下面从 NodeId 确认、单点读写、批量读写三个层面落地。
3.1 NodeId 的三种写法与 Browse 确认
NodeId 常见有数字、字符串、GUID 三类。数字写法如i=2258,字符串写法如ns=2;s=Machine/Line1/Speed,GUID 多用于复杂设备。数字节点稳定,但可读性差;字符串节点可读性好,但 namespace index 要确认。动手前先用 Browse 或服务端工具看一眼节点树,确认ns和s部分,不要凭记忆写。
// 字符串节点:ns=2 要和服务端命名空间表一致 NodeId speedNode = NodeId.Parse("ns=2;s=Machine/Line1/Speed"); // 标准节点示例:ServerStatus 常见映射为 i=2256 NodeId serverStatusNode = new NodeId(2256); // 浏览 Objects 下的组织节点,确认实际 NodeId var browseDescription = new BrowseDescription { NodeId = ObjectIds.ObjectsFolder, BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.Organizes, IncludeSubtypes = true, NodeClassMask = (uint)NodeClass.Variable | (uint)NodeClass.Object, ResultMask = (uint)BrowseResultMask.All }; var browseResponse = session.Browse( null, null, 0, new[] { browseDescription });NodeId.Parse适合字符串节点,new NodeId(2256)适合标准数字节点。Browse 里的BrowseDirection.Forward表示往下层找,ReferenceTypeIds.Organizes是组织关系,IncludeSubtypes允许子类型。NodeClassMask只保留变量和对象,避免把方法节点也刷出来。浏览结果里的DisplayName和NodeId要一起看,别只抄显示名。服务端命名空间表变化时,最稳的办法是启动时 Browse 一次,把业务节点映射到配置里,而不是每次读写都重新猜。
3.2 ReadValue 与 WriteValue 的同步写法
单点读写的关键是看StatusCode,不要只看返回值是不是 null。DataValue里还有 SourceTimestamp 和 ServerTimestamp,做趋势和心跳时要用到。写值之前先读一次节点的 DataType 属性,确认 Variant 类型,尤其是浮点、整型和枚举。
var readIds = new ReadValueIdCollection { new ReadValueId { NodeId = speedNode, AttributeId = Attributes.Value } }; session.Read( null, 0, TimestampsToReturn.Both, readIds, out DataValueCollection readResults, out DiagnosticInfoCollection readDiagnostics); foreach (var dv in readResults) { if (StatusCode.IsBad(dv.StatusCode)) Console.WriteLine($"读失败: {dv.StatusCode}"); Console.WriteLine($"{dv.Value} {dv.SourceTimestamp}"); } var writeValues = new WriteValueCollection { new WriteValue { NodeId = speedNode, AttributeId = Attributes.Value, Value = new DataValue(new Variant(12.5)) } }; session.Write( null, writeValues, out StatusCodeCollection writeStatuses, out DiagnosticInfoCollection writeDiagnostics); foreach (var status in writeStatuses) Console.WriteLine(status);读方法里的TimestampsToReturn.Both表示同时要源时间戳和服务端时间戳,做数据追溯时有用。Attributes.Value只读值属性,如果要读数据类型,改成Attributes.DataType。写方法里的new Variant(12.5)生成 Double 类型,如果服务端节点是 Float,就会类型不匹配。writeStatuses要逐个检查,不要只判断集合数量。诊断信息writeDiagnostics在批量写失败时能定位到具体节点,调试阶段不要省略。
3.3 批量读写与类型不匹配的翻车点
批量读写能减少往返,但一次塞几千个节点会撞上服务端消息大小限制。我一般按 200 个一批,先小批量验证,再按现场调整。类型不匹配的排查顺序是:读Attributes.DataType、读Attributes.ValueRank、对照写入的 Variant 类型。数组节点还要注意维度,单值写数组节点会直接失败。
const int batchSize = 200; for (int offset = 0; offset < allReadIds.Count; offset += batchSize) { var batch = new ReadValueIdCollection( allReadIds.Skip(offset).Take(batchSize)); var response = session.Read( null, 0, TimestampsToReturn.Both, batch); foreach (var dv in response.Results) { if (StatusCode.IsBad(dv.StatusCode)) { // 记录 NodeId 和状态码,别只记“读失败” Console.WriteLine(dv.StatusCode); } } }batchSize设 200 是经验值,不是协议硬限制;服务端消息上限小的时候要往下调。Skip/Take只适合内存集合,节点特别多时改成游标分批。类型不匹配不要靠猜,读Attributes.DataType拿到的是类型 NodeId,再对照服务端文档或 Browse 结果。写入数组时new Variant(new double[] { ... })和单值new Variant(12.5)是两种类型,别混用。批量写失败时先看writeDiagnostics,再看单个节点的 StatusCode,能省很多时间。
4. 订阅与心跳:MonitoredItem 参数和看门狗的两条实现路径
订阅是 OPC UA 客户端最值得用的能力。轮询读在低频点位上看不出问题,一到几百个点位、几百毫秒刷新,网络请求就会堆起来。订阅由服务端按 PublishingInterval 推送,客户端只负责处理通知。心跳则要分两层看:协议层 KeepAlive 说明会话还活着,应用层 ServerStatus 或自定义心跳变量说明服务端业务线程还在刷新。两层都看,才不会把“会话没断但数据停了”误判成正常。
4.1 为什么轮询读不如订阅
轮询读的节奏由客户端控制,读 500 个点位就要发 500 个读请求,或者拼一个大请求等返回。订阅把采样和队列放在服务端,客户端只接收变化通知,PublishingInterval 控制推送节奏,SamplingInterval 控制采样节奏。对于温度、速度、状态字这种连续变化的点位,订阅的实时性更好,网络开销也更稳定。常见做法是把高频点位放进一个 Subscription,低频诊断点位放另一个,别把所有点位塞进同一个发布周期。
4.2 创建 Subscription 和 MonitoredItem 的参数
Subscription 的参数里,PublishingInterval 是发布间隔,KeepAliveCount 是连续多少个发布周期没有通知时服务端发一个空 KeepAlive,LifetimeCount 是订阅生命周期。MonitoredItem 的 SamplingInterval 是采样间隔,QueueSize 是队列长度,DiscardOldest 决定队列满时丢旧值还是丢新值。创建顺序是先把 Subscription 挂到 Session,再 Create,再 AddItem,最后 ApplyChanges。
var subscription = new Subscription(session.DefaultSubscription) { DisplayName = "Line1Subscription", PublishingInterval = 500, KeepAliveCount = 10, LifetimeCount = 30, MaxNotificationsPerPublish = 1000, PublishingEnabled = true, Priority = 0 }; session.AddSubscription(subscription); subscription.Create(); var item = new MonitoredItem(subscription.DefaultItem) { DisplayName = "Line1Speed", StartNodeId = speedNode, AttributeId = Attributes.Value, MonitoringMode = MonitoringMode.Reporting, SamplingInterval = 200, QueueSize = 10, DiscardOldest = true }; item.Notification += OnMonitoredItemNotification; subscription.AddItem(item); subscription.ApplyChanges();PublishingInterval = 500表示每 500 毫秒发布一次,SamplingInterval = 200表示服务端按 200 毫秒采样。KeepAliveCount = 10和LifetimeCount = 30的关系要满足服务端约束,LifetimeCount 通常要大于 KeepAliveCount 的若干倍。QueueSize = 10配合DiscardOldest = true适合只关心最新值的场景;如果要做趋势,队列要加大并保留旧值。MonitoringMode.Reporting才会产生通知,设成 Disabled 或 Sampling 时回调不会按预期来。ApplyChanges别漏,漏了会出现对象创建了但服务端没生效。
4.3 心跳监听:ServerStatus 与 KeepAlive 两条路
心跳监听我一般做两条路。第一条挂session.KeepAlive事件,判断协议层状态;第二条订阅 ServerStatus 节点或服务端自定义心跳变量,判断应用层是否还在刷新。只有 KeepAlive 正常、ServerStatus 长时间不更新时,才说明服务端可能卡在业务线程,而不是网络断了。
var heartbeatItem = new MonitoredItem(subscription.DefaultItem) { DisplayName = "ServerStatusHeartbeat", StartNodeId = new NodeId(2256), // ServerStatus 标准节点,常见映射 i=2256 AttributeId = Attributes.Value, SamplingInterval = 1000, QueueSize = 1, DiscardOldest = true }; heartbeatItem.Notification += (item, e) => { foreach (var dv in item.DequeueValues()) { if (StatusCode.IsGood(dv.StatusCode)) _lastHeartbeatUtc = DateTime.UtcNow; } }; subscription.AddItem(heartbeatItem); subscription.ApplyChanges(); // 看门狗定时器:超过阈值没刷新就标记可疑 var timer = new System.Threading.Timer(_ => { var silent = DateTime.UtcNow - _lastHeartbeatUtc; if (silent > TimeSpan.FromSeconds(6)) { // 触发告警或交给重连状态机,不要直接关闭会话 } }, null, 0, 1000);ServerStatus 节点i=2256是常见映射,不同服务端可能把自定义心跳放在ns=2;s=Heartbeat这类节点上,移植时以 Browse 结果为准。SamplingInterval = 1000表示每秒采一次,看门狗阈值 6 秒是示例,实际按3 × max(SamplingInterval, PublishingInterval)再加网络余量。回调里用DequeueValues把队列取空,避免积压。定时器只做判断,不要在定时器里直接new Session,重连交给第 2 章的状态机。订阅回调运行在库线程,更新界面时要切回 UI 线程,否则会撞跨线程异常。
5. 避坑与排查:五个现场高频现象从现象到解决
5.1 连接成功但读不到值
现象是 Session 显示 Connected,读点位返回 null 或 BadNodeIdUnknown。原因多半是 NodeId 的 namespace index 不对,或者读的属性不是 Value;也有服务端账户没有读权限的情况。解决时先 Browse 父节点确认实际 NodeId,再读Attributes.DisplayName和Attributes.DataType对照。如果状态码是 BadNotReadable,查账户权限;如果是 BadNodeIdUnknown,别改代码,先改 NodeId。把读失败的 NodeId 和 StatusCode 一起写日志,比只打“读取失败”有用得多。
5.2 订阅收不到通知
现象是subscription.Create()没报错,但回调一直不触发。常见原因是漏了ApplyChanges,或者MonitoringMode不是 Reporting,或者PublishingEnabled被设成 false。另一个原因是服务端队列满后丢弃了通知,客户端队列又没及时取。排查时先看subscription.Created和item.Status.Error,再用同步 Read 读同一个节点,确认值本身在变。解决顺序是:补 ApplyChanges、改 Reporting、把 QueueSize 调大并在回调里 DequeueValues 取空。不要用 Thread.Sleep 等通知,等不到还堵住线程。
5.3 断开后会话泄漏
现象是客户端界面已经断开,服务端会话数还在涨,重连几次后报会话数超限。原因通常是只把 session 置 null,没有 Close 和 Dispose,或者异常路径跳过了释放。解决时把关闭逻辑放进 try/finally,先 RemoveSubscription,再 Close,再 Dispose;重连前也检查旧会话是否已经释放。调试阶段可以在服务端看会话列表,确认每次断开后数量回落。别在 KeepAlive 回调里直接重建会话,旧回调没摘干净会重复触发。
5.4 心跳误判
现象是网络正常,客户端却频繁判定心跳丢失;或者服务端业务线程卡住,客户端还显示在线。原因是只看了 KeepAlive 事件,没有看应用层心跳;或者看门狗阈值小于 PublishingInterval,正常发布间隔被当成超时。解决时做双通道:KeepAlive 事件看协议层,ServerStatus 或自定义心跳变量看应用层;阈值按采样周期和发布周期取大者再乘 3,再加网络余量。日志里同时记录最后心跳时间、最后通知时间和重连次数,误判时能回溯。
5.5 安全证书不受信
现象是连接返回 BadSecurityChecksFailed 或 BadCertificateUntrusted,换成 None 就正常。原因是客户端证书没有被服务端信任,或者应用实例 URI 不一致,证书里绑定的地址对不上。解决时把客户端证书导出到服务端信任列表,服务端证书也要导入客户端信任列表;应用名和配置里的 ApplicationUri 保持一致。调试可以临时用 None 定位问题,但不要把 None 当成最终方案。证书换过之后,旧会话要清掉,否则还会拿旧通道重试。
6. 把 Demo 抽成组件:接口封装、状态机与上线前验收清单
Demo 跑通之后,下一步是把连接、读写、订阅收进一个可复用组件,别让界面按钮直接调 Session。我一般先定一个窄接口,把状态、连接、断开、读、写、订阅暴露出来,内部再维护重连状态机。状态至少分 Disconnected、Connecting、Connected、Reconnecting、Faulted 五个,界面只读状态,不直接碰 Session。订阅返回 IDisposable,调用方释放时自动 RemoveItem,避免订阅越加越多。
public interface IOpcUaClient : IAsyncDisposable { ConnectionState State { get; } Task ConnectAsync(CancellationToken ct = default); Task DisconnectAsync(); Task<DataValue> ReadAsync(NodeId nodeId, CancellationToken ct = default); Task WriteAsync(NodeId nodeId, Variant value, CancellationToken ct = default); IDisposable Subscribe(NodeId nodeId, Action<DataValue> onChanged); }验收时别只看“能连上”。下面这几项我每次上线前都会跑一遍,尤其是断网恢复和订阅恢复,翻车大多出在这里。
| 验收项 | 操作 | 通过标准 |
|---|---|---|
| 连接超时 | 填一个不可达地址 | 在设定超时内返回失败,界面回到 Disconnected |
| 断网重连 | 拔掉网络 10 秒再恢复 | 状态从 Reconnecting 回到 Connected,不重复建会话 |
| 订阅恢复 | 重连后观察通知 | 原 MonitoredItem 继续回调,没有重复通知 |
| 心跳阈值 | 暂停服务端业务线程 | 应用层心跳超时告警,协议层状态可区分 |
| 资源释放 | 反复连接断开 20 次 | 服务端会话数回落,客户端句柄不持续增长 |
接口里的ConnectionState用枚举实现即可,Subscribe内部把 MonitoredItem 和回调包成一个可释放对象,释放时先RemoveItem再ApplyChanges。重连状态机不要放在 UI 线程里跑,放到后台任务,界面只订阅状态变化。血泪经验是:Demo 阶段能读能写不代表能上线,拔网线跑三遍、反复开关会话跑二十遍,比加十个日志面板都管用。从那以后我每次交付 OPC UA 客户端,都强制走一遍这张验收表,再谈现场联调。希望帮到你。
本文还有配套的精品资源,点击获取