简介:这是一份面向.NET开发者与工业自动化工程师的OPC UA通信实战Demo,围绕连接、断开、读写、订阅与心跳监听五大核心操作展开,帮助读者快速掌握在.NET环境中与PLC及自动化设备进行数据交互的方法。资源包共2000个文件,以1218个xml配置与元数据、252个dll程序集、112个txt说明、28个nupkg依赖包及10个cs源码文件为主,另含sln解决方案、csproj工程与少量exe、pdb调试文件,压缩包约87.75MB,结构完整便于直接编译运行。目前已有674人学习下载。通过该Demo,读者可参考客户端实例创建、服务器URL与安全证书配置、节点读写调用、订阅与监视项推送、心跳回调处理等关键代码片段,理解OPC UA协议从基础连接到高级订阅的完整链路,为工业物联网项目落地提供可复用的排错思路与工程模板。
1. 从一次产线联调翻车说起:这个 .NET OPC UA Demo 到底能干什么
去年帮一个做自动化集成的朋友排查问题,他们产线上位机用 .NET 写了个客户端去连 PLC 的 OPC UA 服务端,结果每次跑几个小时就断连,重连逻辑写得稀碎,订阅的数据还时不时丢包。翻他们的代码一看,连接、读写、订阅、心跳全是散在各处的裸调用,没有统一的状态管理,也没有断线重连的兜底。后来我干脆自己整理了一套 .NET OPC UA 通信的 Demo,把连接、断开、读写、订阅、监听心跳这几个核心动作全部串起来,形成一个可以直接跑、可以直接抄的最小闭环。
这份 Demo 解决的就是「.NET 环境下怎么跟 OPC UA 服务端稳定对话」这件事。它适合两类人:一类是刚接触工业通信、需要一份能跑通的参考代码来理解 OPC UA 客户端基本流程的开发者;另一类是有一定经验、但断线重连和订阅管理总是踩坑、想看看别人怎么组织代码结构的熟手。技术栈就是 .NET + OPC UA 客户端库,不依赖任何商业组件,核心逻辑全部可读可改。下面我按「先跑起来 → 再搞懂参数 → 最后避开坑」的顺序拆一遍。
2. 环境搭建与连接建立:从零把客户端跑起来
2.1 选型理由:为什么用这个库而不是自己撸协议
OPC UA 的协议栈相当厚,二进制编码、安全通道、会话管理、订阅发布,每一层都有大量细节。自己从 Socket 开始撸不是不行,但投入产出比极低,而且安全策略那块很容易写出漏洞。常见做法是直接用成熟的 .NET OPC UA 客户端库,把精力放在业务逻辑上。
我一般会选官方维护的那个客户端库,原因有三:第一,它同时支持 Core 和 Framework,老项目新项目都能用;第二,它的 API 设计比较贴近 OPC UA 规范本身,Session、Subscription、MonitoredItem 这些概念跟协议文档能对上,排查问题时不会迷失;第三,社区资料多,遇到玄学问题至少能搜到别人踩过的坑。
安装方式就是标准的 NuGet 包引用,在项目文件里加一行:
<ItemGroup> <PackageReference Include="Opc.Ua.Client" Version="1.5.374.158" /> <PackageReference Include="Opc.Ua.Configuration" Version="1.5.374.158" /> </ItemGroup>版本号按你实际能拉到的稳定版填,不用跟我这个完全一致。注意客户端库和配置库要版本对齐,混用不同大版本会出现类型不匹配的编译错误,这个坑我后面还会提。
2.2 建立连接:Endpoint、SecurityPolicy 和 Session 三个关键点
连接 OPC UA 服务端,核心就三样东西:Endpoint URL、安全策略、会话参数。Endpoint URL 格式一般是opc.tcp://host:port/path,端口默认 4840。安全策略决定了通信是否加密、是否签名,测试环境常用None,生产环境至少要用SignAndEncrypt。
下面是一段可以直接抄的连接代码:
// 创建应用配置,证书用于安全通道 var appConfig = new ApplicationConfiguration() { ApplicationName = "MyOpcClient", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { // 证书存放目录,首次运行会自动生成自签名证书 ApplicationCertificate = new CertificateIdentifier { StoreType = "Directory", StorePath = "%LocalApplicationData%/MyOpcClient/pki/own", SubjectName = "CN=MyOpcClient" }, // 信任列表,服务端证书需要加进来 TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = "%LocalApplicationData%/MyOpcClient/pki/trusted" }, AutoAcceptUntrustedCertificates = true // 仅测试环境使用 }, TransportConfigurations = new TransportConfigurationCollection(), TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 } }; await appConfig.Validate(ApplicationType.Client); // 选择端点,这里按安全策略筛选 var endpointDescription = CoreClientUtils.SelectEndpoint( "opc.tcp://192.168.1.100:4840", useSecurity: false, discoverTimeout: 15000); var endpointConfiguration = EndpointConfiguration.Create(appConfig); var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration); // 创建会话 var session = await Session.Create( appConfig, endpoint, updateBeforeConnect: false, sessionName: "MySession", sessionTimeout: 60000, identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null);逻辑说明:先构建ApplicationConfiguration,它承载了证书、超时、配额等全局设置;然后通过SelectEndpoint去服务端拉取可用端点列表并按条件筛选;最后用Session.Create建立会话。参数方面,OperationTimeout控制单次操作超时,DefaultSessionTimeout控制会话保活周期,这两个值要根据网络质量调整。AutoAcceptUntrustedCertificates在测试时省事,生产环境必须关掉并手动管理信任列表,否则等于没有安全防护。
连接建立后,建议立刻做一次session.KeepAlive或者读一个已知节点来验证通道真的通了,不要等到业务读写时才发现连了个寂寞。
3. 读写与订阅:把数据流打通的核心操作
3.1 节点读写:ReadValue 和 WriteValue 的正确姿势
OPC UA 的数据模型是节点化的,每个变量、对象、方法都是一个 NodeId。读写操作本质上就是拿着 NodeId 去服务端取或存值。读操作用ReadValue,写操作用WriteValue,但要注意数据类型必须匹配,否则服务端会返回BadTypeMismatch。
// 读单个节点 var readNode = new NodeId("ns=2;s=Device1.Temperature"); DataValue value = session.ReadValue(readNode); Console.WriteLine($"值: {value.Value}, 状态: {value.StatusCode}, 时间戳: {value.SourceTimestamp}"); // 写单个节点,注意值类型要和节点定义一致 var writeNode = new NodeId("ns=2;s=Device1.SetPoint"); var writeValue = new WriteValue { NodeId = writeNode, AttributeId = Attributes.Value, Value = new DataValue(new Variant(25.5)) }; var writeCollection = new WriteValueCollection { writeValue }; session.Write(null, writeCollection, out StatusCodeCollection results, out DiagnosticInfoCollection diagnostics);逻辑说明:ReadValue是同步阻塞调用,返回的DataValue里除了值本身,还带状态码和时间戳,排查数据质量时这两个字段比值本身还重要。写操作走的是WriteValueCollection,可以批量写,results里会逐个返回每个节点的写入状态。参数上,Attributes.Value表示写的是值属性,如果要写别的属性(比如描述)就换对应的枚举。批量写的时候注意集合顺序和结果顺序是一一对应的,别搞混了。
3.2 订阅与监听:MonitoredItem 和 Notification 事件
订阅是 OPC UA 最实用的功能之一,不用轮询,服务端数据一变就推给你。核心流程是:创建 Subscription → 添加 MonitoredItem → 绑定 Notification 事件 → 处理数据。
// 创建订阅,发布间隔 1000ms var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 1000, KeepAliveCount = 10, LifetimeCount = 30, MaxNotificationsPerPublish = 1000, PublishingEnabled = true }; session.AddSubscription(subscription); subscription.Create(); // 添加监控项 var monitoredItem = new MonitoredItem(subscription.DefaultItem) { DisplayName = "Temperature", StartNodeId = new NodeId("ns=2;s=Device1.Temperature"), AttributeId = Attributes.Value, SamplingInterval = 500, QueueSize = 10, DiscardOldest = true }; monitoredItem.Notification += OnNotification; subscription.AddItem(monitoredItem); subscription.ApplyChanges(); // 通知回调 void OnNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e) { foreach (var value in item.DequeueValues()) { Console.WriteLine($"{item.DisplayName}: {value.Value} @ {value.SourceTimestamp}"); } }逻辑说明:PublishingInterval是服务端向客户端推送的周期,SamplingInterval是服务端采集数据的周期,两者可以不同。QueueSize决定客户端来不及处理时缓存多少条,DiscardOldest为 true 表示队列满了丢最旧的。KeepAliveCount和LifetimeCount配合决定订阅的存活判定,如果服务端连续这么多个周期没收到客户端确认,就会认为订阅失效。回调里用DequeueValues把队列里的值全部取出来,避免积压。
3.3 心跳监听:KeepAlive 与 Session 状态监控
心跳这块很多人忽略,但它恰恰是断线重连的触发依据。OPC UA 的会话本身有 KeepAlive 机制,客户端库会定期发 KeepAlive 请求,如果连续失败就会触发Session.KeepAlive事件。
// 注册会话状态变化和 KeepAlive 事件 session.KeepAlive += (s, e) => { if (ServiceResult.IsBad(e.Status)) { Console.WriteLine($"心跳异常: {e.Status}"); // 触发重连逻辑 Reconnect(); } else { Console.WriteLine($"心跳正常: {e.CurrentTime}"); } }; // 也可以主动查询会话状态 var sessionStatus = session.SessionStatus; Console.WriteLine($"会话状态: {sessionStatus}");逻辑说明:KeepAlive事件里拿到的Status如果是 Bad,说明通道已经不可用,这时候要果断走重连流程,不要等业务操作报错才反应。CurrentTime是服务端时间,可以用来做时间同步校验。主动查询SessionStatus适合在定时任务里做健康检查。重连逻辑要注意先清理旧的订阅和会话,再重新创建,否则会残留僵尸订阅。
4. 避坑与排查:那些让我熬夜的常见问题
4.1 连接超时或直接失败
现象:Session.Create抛异常,提示BadTimeout或BadCommunicationError。
原因:最常见的是 Endpoint URL 写错、端口不通、服务端没启动,或者防火墙拦了 4840 端口。另一个容易被忽略的是安全策略不匹配,客户端选None但服务端只允许SignAndEncrypt。
解决:先用 telnet 或 Test-NetConnection 确认端口通不通,再检查服务端的端点列表里有没有你选的那个策略。如果服务端要求加密,客户端必须配置对应证书并加入信任列表。
4.2 证书报错导致连接被拒
现象:连接时提示BadCertificateUntrusted或BadCertificateChainIncomplete。
原因:客户端和服务端互相不信任对方的证书。测试环境经常用自签名证书,双方都没有把对方加进信任列表。
解决:测试环境可以临时开AutoAcceptUntrustedCertificates,但生产环境必须把服务端证书导出后放到客户端的 trusted 目录,反之亦然。证书的 SubjectName 和域名/IP 要匹配,否则校验也会失败。
4.3 订阅数据丢失或延迟严重
现象:订阅回调里数据断断续续,或者延迟好几秒才到。
原因:PublishingInterval和SamplingInterval设置不合理,或者QueueSize太小导致丢包。网络抖动也会造成推送延迟。
解决:先确认服务端的采集能力,SamplingInterval不要小于服务端的最小采样周期。QueueSize适当放大,DiscardOldest根据业务决定是丢旧还是丢新。如果延迟敏感,把PublishingInterval调小,但要注意服务端负载。
4.4 断线后重连失败或订阅失效
现象:网络恢复后重连成功,但订阅不再推送数据。
原因:重连时只重建了 Session,没有重建 Subscription 和 MonitoredItem。旧的订阅在服务端已经失效,客户端却还在等推送。
解决:重连逻辑里必须把订阅创建流程完整走一遍,先RemoveSubscription清理旧的,再重新AddSubscription和ApplyChanges。建议把连接、订阅、心跳封装成一个状态机,任何一步失败都回到初始状态重来。
4.5 批量读写时结果对不上号
现象:批量写 10 个节点,返回的结果里有的成功有的失败,但分不清哪个对应哪个。
原因:WriteValueCollection和StatusCodeCollection是按顺序对应的,但代码里没有按索引去匹配。
解决:遍历结果时用索引去取原始请求的 NodeId,不要靠 DisplayName 去猜。读操作同理,DataValueCollection的顺序和ReadValueIdCollection严格一致。
5. 进阶技巧:把 Demo 改造成能上产线的客户端
Demo 跑通只是第一步,真要上产线,还得在几个地方加固。第一个是重连策略,我一般会做一个带退避的重连循环,第一次失败等 1 秒,第二次等 2 秒,指数退避到 30 秒封顶,避免疯狂重试把服务端打挂。第二个是订阅恢复,重连后不仅要重建订阅,还要把之前监控的节点列表存下来,逐个重新添加,不能靠硬编码。
第三个是心跳超时的判定阈值。KeepAlive事件本身有延迟,如果网络只是抖了一下,立刻重连反而会造成不必要的断开。我的做法是连续两次 KeepAlive 失败才触发重连,单次失败只记日志。这个阈值可以根据实际网络质量调,产线环境通常比较稳定,两次足够。
第四个是资源释放。Session、Subscription、MonitoredItem都实现了IDisposable,断开时要按顺序释放,先删监控项,再删订阅,最后关会话。顺序反了会报BadSubscriptionIdInvalid。
验证方法上,我习惯用两个手段:一是用服务端模拟器手动改值,看订阅回调是否及时触发;二是直接拔网线,观察重连日志和订阅恢复情况。这两个场景覆盖了大部分线上故障。
// 带退避的重连封装示例 async Task ReconnectWithBackoff() { int delay = 1000; while (true) { try { await ConnectAsync(); // 重建会话 await RestoreSubscriptionsAsync(); // 恢复订阅 Console.WriteLine("重连成功"); break; } catch (Exception ex) { Console.WriteLine($"重连失败: {ex.Message},{delay}ms 后重试"); await Task.Delay(delay); delay = Math.Min(delay * 2, 30000); // 指数退避,封顶 30 秒 } } }这段代码的关键在于RestoreSubscriptionsAsync必须把之前的监控项配置持久化下来,不能依赖内存里的旧对象。我一般会把 NodeId、SamplingInterval、QueueSize 这些参数存到一个配置列表里,重连时遍历重建。
从那以后我每次写 OPC UA 客户端,都强制把连接、订阅、心跳、重连这四块拆成独立的状态机,任何一块出问题都能单独定位,不会再出现「连上了但没数据」这种黑匣子情况。希望帮到你。
本文还有配套的精品资源,点击获取