简介:这是一份面向.NET开发者与工业自动化工程师的OPC UA通信实战Demo,围绕连接、断开、读写、订阅与心跳监听五大核心操作展开,帮助读者快速掌握与PLC及自动化设备的数据交互方式。资源包共2000个文件,以1218个xml配置与数据文件、252个dll程序集、112个txt说明、28个nupkg依赖包及10个cs源码为主,另含sln解决方案与csproj工程文件,压缩包约87.75MB,结构完整便于直接编译调试。已有674人学习下载。通过该Demo可系统理解OPC UA客户端建立连接时的URL配置与证书验证、读写节点值的方法调用、订阅监视项的推送机制,以及心跳回调在连接活性检测中的应用,适合作为工业物联网项目对接OPC UA服务器的参考范例与排错起点。
1. 从一次产线联调说起:这个 .NET OPC UA Demo 到底能省多少事
上个月帮一个做设备集成的朋友看现场,他们那条产线要接三台不同品牌的 PLC,上位机用 C# 写,结果卡在 OPC UA 的会话保持上——连上五分钟就掉线,重连逻辑写得一团糟,订阅的数据还时不时丢包。这种场景其实特别典型:.NET 做工业通信不是不行,而是很多人第一次碰 OPC UA 的时候,把「连上」当成了终点,实际上连接、断开、读写、订阅、心跳监听这五件事,每一件都有独立的坑。这份 .NET OPC UA 通信 Demo 就是冲着这个痛点来的,它把五个核心动作拆成可运行的示例,覆盖了从建立会话到异常断线重连的完整链路。适合谁?做上位机开发的 .NET 工程师、需要对接 PLC 或 SCADA 的自动化从业者,以及正在选型 OPC UA 客户端库的技术负责人。你不需要先啃完几万字的规范文档,照着 Demo 跑一遍,再回头理解参数,效率会高很多。
2. 环境搭建与连接会话:从 NuGet 包到 Session 建立
2.1 选型理由:为什么是 OPCFoundation.NetStandard.Opc.Ua
.NET 生态里做 OPC UA 客户端,绕不开的就是官方基金会维护的OPCFoundation.NetStandard.Opc.Ua系列包。有人会问能不能用第三方封装,我的建议是:除非你有特殊需求,否则优先用官方栈。原因有三点。第一,协议栈的完整性,官方包覆盖了 UA-TCP、HTTPS、PubSub 等多种传输方式,第三方封装往往只做了最常用的 UA-TCP。第二,证书和安全策略的支持,OPC UA 的安全模型不是可选项,很多现场要求 SignAndEncrypt,第三方库在这块经常偷工减料。第三,社区和文档,出问题的时候你能搜到的答案大概率是基于官方包的。
Demo 里用到的核心包有这么几个:
# 在项目目录下执行,安装 OPC UA 客户端所需的核心包 dotnet add package OPCFoundation.NetStandard.Opc.Ua.Client dotnet add package OPCFoundation.NetStandard.Opc.Ua.Configuration dotnet add package OPCFoundation.NetStandard.Opc.Ua.Security.Certificates这三个包的分工要搞清楚。Opc.Ua.Client提供Session、Subscription、MonitoredItem这些核心类;Opc.Ua.Configuration负责应用配置和证书管理;Opc.Ua.Security.Certificates处理证书的生成和验证。少装一个,编译时就会报类型找不到。
2.2 建立连接的完整代码与参数说明
连接 OPC UA 服务端不是一句Connect()就完事,它涉及端点发现、安全策略选择、证书验证、会话创建四个阶段。Demo 里的连接逻辑大致是这样:
// 创建应用配置,ApplicationName 和 ApplicationUri 必须唯一 var appConfig = new ApplicationConfiguration() { ApplicationName = "MyOpcUaClient", ApplicationUri = $"urn:{Utils.GetHostName()}:MyOpcUaClient", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { // 自动接受不受信任的证书,仅限开发环境 AutoAcceptUntrustedCertificates = true, // 证书存储路径,生产环境要改成受控目录 ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, StorePath = "pki/own", SubjectName = "CN=MyOpcUaClient" } }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 } }; // 验证配置并创建证书 await appConfig.Validate(ApplicationType.Client); await appConfig.SecurityConfiguration.ApplicationCertificate.FindOrCreateCertificate(); // 发现端点,选择安全策略 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(), preferredLocales: null);这段代码里有几个参数值得单独拎出来说。DefaultSessionTimeout设的是 60000 毫秒,也就是 60 秒,这是会话在没有活动的情况下能保持的最长时间。现场如果网络抖动频繁,这个值可以适当放大到 120000,但不要超过服务端配置的上限,否则服务端会直接拒绝。SelectEndpoint的useSecurity参数在开发阶段设成false能省去证书配置的麻烦,但上生产必须改成true并配置好安全策略。Session.Create里的updateBeforeConnect如果设成true,每次连接前都会重新发现端点,适合服务端地址可能变化的场景,但会增加连接耗时。
连接建立之后,建议立刻做一件事:读取服务端的ServerStatus节点,确认服务端状态正常。这个动作花不了多少时间,但能帮你排除掉「连上了但服务端其实有问题」的情况。
3. 读写操作与订阅机制:从单点读取到批量监听
3.1 同步读写与异步读写的选择
OPC UA 的读写操作分同步和异步两套 API。Demo 里两种都给了示例,但实际项目里我强烈建议用异步版本。原因很直接:同步调用在服务端响应慢的时候会阻塞当前线程,如果你的上位机 UI 线程被阻塞,界面直接卡死。异步版本虽然写起来多几个await,但不会出现这个问题。
先看单点读取:
// 同步读取,不推荐在 UI 线程使用 var readValue = session.ReadValue("ns=2;s=Device1.Temperature"); Console.WriteLine($"温度: {readValue.Value}"); // 异步读取,推荐方式 var readValueAsync = await session.ReadValueAsync("ns=2;s=Device1.Temperature"); Console.WriteLine($"温度: {readValueAsync.Value}");ReadValue返回的是一个DataValue对象,里面除了Value还有StatusCode和SourceTimestamp。很多人只看Value,忽略了StatusCode,结果服务端返回了BadNodeIdUnknown还在那纳闷为什么值是 null。正确的做法是每次读取后先判断StatusCode是否为Good。
批量读取用Read方法,传入一个ReadValueIdCollection:
// 批量读取多个节点,减少网络往返 var nodesToRead = new ReadValueIdCollection { new ReadValueId { NodeId = "ns=2;s=Device1.Temperature", AttributeId = Attributes.Value }, new ReadValueId { NodeId = "ns=2;s=Device1.Pressure", AttributeId = Attributes.Value }, new ReadValueId { NodeId = "ns=2;s=Device1.Status", AttributeId = Attributes.Value } }; session.Read(null, 0, TimestampsToReturn.Both, nodesToRead, out var results, out var errors);批量读取的价值在于减少网络往返次数。三个节点分三次读,就是三次网络请求;批量读一次请求就搞定。在节点数量多、网络延迟高的场景下,这个差距非常明显。
写入操作相对简单,但要注意数据类型匹配:
// 写入单个节点,注意值的数据类型要和节点定义一致 var writeValue = new WriteValue { NodeId = "ns=2;s=Device1.SetPoint", AttributeId = Attributes.Value, Value = new DataValue(new Variant(25.5)) }; session.Write(null, new WriteValueCollection { writeValue }, out var writeResults, out var writeErrors);写入失败最常见的原因是数据类型不匹配。比如节点定义的是Int32,你写了个Double,服务端会返回BadTypeMismatch。解决办法是先读一次节点的DataType属性,确认类型后再写。
3.2 订阅与心跳监听:MonitoredItem 的参数怎么调
订阅是 OPC UA 最实用的功能之一,它让服务端在数据变化时主动推送,而不是客户端不停地轮询。Demo 里的订阅逻辑分三步:创建 Subscription、添加 MonitoredItem、处理通知事件。
// 创建订阅,PublishingInterval 是服务端推送的最小间隔 var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 1000, // 服务端每 1000ms 检查一次变化 KeepAliveCount = 10, // 10 个周期无变化则发送心跳 LifetimeCount = 30, // 30 个周期无响应则订阅失效 MaxNotificationsPerPublish = 1000, PublishingEnabled = true, Priority = 0 }; session.AddSubscription(subscription); subscription.Create(); // 添加监控项 var monitoredItem = new MonitoredItem(subscription.DefaultItem) { DisplayName = "Temperature", StartNodeId = "ns=2;s=Device1.Temperature", AttributeId = Attributes.Value, SamplingInterval = 500, // 客户端采样间隔 QueueSize = 10, // 队列大小,防止通知丢失 DiscardOldest = true // 队列满时丢弃最旧的数据 }; monitoredItem.Notification += OnNotification; // 绑定通知回调 subscription.AddItem(monitoredItem); subscription.ApplyChanges();这里面的参数关系要理清楚。PublishingInterval是服务端层面的推送周期,SamplingInterval是客户端层面的采样周期。如果SamplingInterval大于PublishingInterval,实际推送频率会受限于采样间隔。QueueSize设成 10 意味着最多缓存 10 条未处理的通知,超过之后根据DiscardOldest决定丢弃策略。现场调试时如果发现数据丢包,优先检查QueueSize是不是太小。
心跳监听是很多人忽略的一环。OPC UA 的KeepAliveCount机制本质上就是心跳:如果服务端在KeepAliveCount个PublishingInterval内没有数据变化,它会发送一个空的通知包,证明自己还活着。客户端可以通过Subscription的KeepAlive事件来监听这个心跳:
// 监听订阅的心跳事件,判断服务端是否存活 subscription.KeepAlive += (sender, args) => { if (args.Status.Error) { Console.WriteLine($"心跳异常: {args.Status.Code}"); // 触发重连逻辑 } };这个回调是判断连接健康度的关键。如果连续多个心跳周期都没有收到 KeepAlive,基本可以断定连接已经断了,需要走重连流程。
4. 断开重连与异常处理:那些让你半夜爬起来的问题
4.1 断开的正确姿势与重连策略
断开连接看起来简单,但做错了会留下僵尸会话。正确的断开流程是:先删除订阅,再关闭会话,最后释放通道。
// 正确的断开顺序 if (subscription != null) { subscription.Delete(true); // 删除订阅,true 表示删除监控项 session.RemoveSubscription(subscription); } session.Close(); // 关闭会话 session.Dispose(); // 释放资源如果直接调session.Dispose()而不先删订阅,服务端可能在一段时间内仍然认为会话活跃,导致资源泄漏。现场如果频繁重连,服务端的会话数会越积越多,最终拒绝新连接。
重连策略我一般用指数退避:第一次断线后等 1 秒重连,失败等 2 秒,再失败等 4 秒,上限 30 秒。这样既能快速恢复,又不会在服务端还没准备好的时候疯狂重试。
// 指数退避重连 private async Task ReconnectWithBackoff(Session session) { int delay = 1000; while (true) { try { await session.ReconnectAsync(); Console.WriteLine("重连成功"); break; } catch (Exception ex) { Console.WriteLine($"重连失败: {ex.Message},{delay}ms 后重试"); await Task.Delay(delay); delay = Math.Min(delay * 2, 30000); // 上限 30 秒 } } }4.2 常见异常与排查思路
OPC UA 的异常信息有时候比较隐晦,这里列几个我踩过的坑。BadSessionClosed通常意味着会话被服务端主动关闭了,原因可能是会话超时、服务端重启、或者并发会话数超限。BadSecureChannelClosed是安全通道断了,多半是网络问题或者证书过期。BadTimeout是请求超时,检查网络延迟和服务端负载。BadNotConnected是客户端根本没连上,检查端点地址和端口。
排查的时候,我习惯先开 OPC UA 的日志追踪,把日志级别调到Debug,能看到完整的请求响应过程。很多问题看日志比猜快得多。
5. 避坑指南:五条血泪经验
5.1 证书问题:AutoAcceptUntrustedCertificates 不是万能药
现象:开发环境连得好好的,部署到现场就连不上,报BadCertificateUntrusted。
原因:开发时开了AutoAcceptUntrustedCertificates = true,客户端自动接受了服务端的自签名证书。但生产环境的服务端证书是 CA 签发的,客户端没有把 CA 证书加入信任列表,验证就失败了。
解决:把服务端的 CA 证书导入客户端的信任存储,或者把服务端证书的指纹加入白名单。不要在生产环境开AutoAcceptUntrustedCertificates,这是个安全漏洞。
5.2 会话超时:DefaultSessionTimeout 设太大反而容易断
现象:把DefaultSessionTimeout设成 300000(5 分钟),结果会话反而更频繁地断。
原因:服务端有自己的MaxSessionTimeout限制,客户端设的值超过服务端上限时,服务端会用自己的上限值,但客户端不知道,双方对超时时间的认知不一致,导致会话被服务端提前关闭。
解决:先读服务端的ServerConfiguration节点,拿到MaxSessionTimeout的实际值,客户端设置不要超过它。
5.3 订阅丢数据:QueueSize 和 SamplingInterval 的配合
现象:订阅的数据偶尔丢几条,尤其是数据变化频繁的时候。
原因:QueueSize设得太小,数据变化速度超过消费速度时,队列溢出,旧数据被丢弃。
解决:把QueueSize调大,同时根据数据变化频率调整SamplingInterval。如果数据变化非常快,考虑用DiscardOldest = false,保证不丢数据但可能增加延迟。
5.4 重连后订阅失效:没有重新创建 MonitoredItem
现象:断线重连后,连接恢复了,但订阅的数据不再推送。
原因:重连后Session对象是新的,原来的Subscription和MonitoredItem已经失效,需要重新创建。
解决:在重连成功的回调里,重新执行订阅创建和监控项添加的逻辑。可以把这部分封装成一个SetupSubscriptions方法,连接和重连都调它。
5.5 心跳误判:KeepAlive 事件不等于连接正常
现象:KeepAlive事件正常触发,但实际读写操作全部超时。
原因:KeepAlive只代表订阅通道正常,不代表整个会话的读写通道正常。有时候安全通道出了问题,订阅还能收到心跳,但读写请求已经发不出去了。
解决:不要只依赖KeepAlive判断连接状态,定期做一次轻量级的读操作(比如读ServerStatus的CurrentTime节点),读写成功才算真正健康。
6. 进阶技巧:用批量读取和订阅组合做高效数据采集
跑通 Demo 之后,下一步就是把它用到实际项目里。我一般会做一个组合:对变化频率低的配置类数据用批量读取,对变化频率高的实时数据用订阅。这样既能保证实时性,又不会因为订阅太多节点导致服务端压力过大。
具体做法是,先批量读取所有节点的当前值做一次全量初始化,然后只对需要实时监控的节点创建订阅。订阅的PublishingInterval根据数据变化频率来定,温度这类慢变量设 2000ms 就够了,状态位这类快变量设 200ms。
验证连接健康度的时候,我习惯加一个定时器,每 30 秒读一次ServerStatus的CurrentTime,如果连续三次读取失败,就触发重连。这个逻辑比单纯依赖KeepAlive可靠得多。
还有一个细节:Session对象的ReconnectAsync方法在重连成功后会保留原来的订阅,但前提是服务端还保留着会话状态。如果服务端重启了,会话状态丢失,就必须重新创建订阅。所以重连逻辑里要判断一下,如果ReconnectAsync之后订阅的Created属性为false,就重新走一遍订阅创建流程。
从那以后我每次做 OPC UA 项目,都会先把连接、读写、订阅、心跳、重连这五件事的日志全部打开,跑够 24 小时再上现场。希望帮到你。
本文还有配套的精品资源,点击获取