简介:这是一份基于C#语言实现的OPC UA客户端示例工程,面向工业自动化、智慧工厂及设备联网等场景,适合希望深入学习OPC UA协议开发的初中级C#开发者,也可作为快速构建自定义客户端的起点。压缩包共收录197个文件,整体约6.93MB,以148个dll依赖库为主,另有9个cs源码、exe可执行程序、配置文件及图标资源,既可直接编译运行,也可按需替换库文件进行二次开发。目前已有1348人学习下载,资源体积适中、结构直观,特别适合作为OPC UA协议开发的入门样例和团队内部培训参考。示例项目ReferenceClient完整覆盖了OPC UA客户端的核心流程,包括创建会话、浏览服务器节点树、读取与写入变量、订阅实时数据变化、调用远程方法,以及针对网络异常的安全处理机制。源码采用类与接口分层组织,清晰分离了会话管理、节点操作、订阅回调和异常处理逻辑,便于阅读复用,可有效降低自研OPC UA客户端的开发门槛。
1. 为什么 ReferenceClient 是 OPC UA 客户端开发的第一份骨架代码
很多人在拿到ReferenceClient这个压缩包时,第一反应是“项目里怎么全是.cache文件”。我最初拆这个包时也是这个状态:DesignTimeResolveAssemblyReferences.cache、CoreCompileInputs.cache看起来像垃圾文件,但真正决定客户端能不能跑的,其实是App.config里的服务器地址、安全策略和证书路径。
Quickstarts.ReferenceClient 是 OPC Foundation 用 C# 写的一个完整示例,它把“连接 → 浏览 → 读写 → 订阅”这条链路从头到尾演示了一遍,而且依赖少、结构清晰,适合刚开始接触 OPC UA 协议开发的 C# 上位机工程师。如果你想在工控上位机或边缘网关里做 OPC UA 协议开发,又不想从零啃规范,这份源码是一个比文档更直接的起点。
2. OPC UA 会话与节点模型:在 C# 中建立连接前的必修课
开始写客户端之前,先要把 OPC UA 的地址空间模型放到熟悉的 C# 对象模型里。很多从 OPC DA 时代过来的开发者习惯把服务器想象成一张 tag 表,但在 OPC UA 里,服务器暴露给客户端的是一个由节点组成的图结构,也就是地址空间。客户端做的第一件事不是读数据,而是通过浏览找到目标节点,再对节点执行读写或订阅操作。
2.1 从 OPC DA 到 OPC UA:客户端为什么要面对地址空间
传统 OPC DA 的接口里,服务器暴露的是一组扁平的数据点,客户端靠标签名进行绑定;而 OPC UA 把一切可访问的对象都抽象成节点,这些节点通过引用关系组成图结构。这样做的好处是,服务器自带语义和结构描述,客户端拿到 NodeId 之后还能获取到数据类型、访问级别、读写权限等元数据。对于 C# 开发者来说,这意味着你处理的不再是裸的 tag 名,而是一个有类型、有关系的对象模型。
在实际项目中,很多报警只有变量值还不够,还需要知道变量的工程单位、设备状态、报警上下限。这些元数据在 OPC UA 中都是节点属性,客户端可以在同一个会话里读取,而不需要像以前那样专门写驱动去后台数据库里翻。这也是 OPC UA 能取代 OPC DA 成为工业互联互通主流协议的原因之一。
2.2 节点、NodeId 与 BrowseName:读地址空间必须分清的三个标识
OPC UA 地址空间中的每个节点都有一个NodeId,它是节点的唯一标识,格式通常是ns=2;s=Machine/PLC/Temperature,其中ns是命名空间索引,s是字符串标识。BrowseName则是节点在被浏览时显示的名称,它不一定是唯一的。在界面上展示节点树时,通常把BrowseName.Name和NodeId一起显示,方便调试。
下面是四种最常见的节点类型和它们的用途:
| 节点类型 | NodeClass 枚举 | 用途 | 典型示例 |
|---|---|---|---|
| 对象 | Object | 组织子节点和变量 | 设备对象 Machine |
| 变量 | Variable | 保存数据和属性 | 温度值 Temperature |
| 方法 | Method | 可调用的远程操作 | 复位报警 Reset |
| 视图 | View | 针对特定场景的节点子集 | 运行视图 RuntimeView |
2.3 用 Session 建立连接:App.config 里的配置项如何影响连接行为
ReferenceClient 的启动入口里,ApplicationConfiguration是整个客户端的配置中心。它从App.config读取的 OPC UA 配置段包含了EndpointUrl、SecurityMode、SecurityPolicyUri、UserIdentity等信息。下面是 Quickstarts.ReferenceClient 中典型的配置片段:
<opc:ApplicationConfiguration xmlns:opc="http://opcfoundation.org/UA/2008/02/Types.xsd"> <ApplicationName>Quickstarts.ReferenceClient</ApplicationName> <ApplicationUri>urn:Quickstarts:ReferenceClient</ApplicationUri> <SecurityConfiguration> <SecurityPolicyUri>http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256</SecurityPolicyUri> <SecurityMode>SignAndEncrypt</SecurityMode> </SecurityConfiguration> <TransportQuotas> <OperationTimeout>30000</OperationTimeout> </TransportQuotas> </opc:ApplicationConfiguration>这里SecurityPolicyUri指定加密算法套件,SecurityMode决定是否签名或加密,OperationTimeout控制单个请求的超时时间。本地联调推荐先用None模式绕过证书问题,跑通流程后再切回SignAndEncrypt。参考客户端里面ApplicationInstance.CheckApplicationInstanceCertificates(false)会跳过证书校验,部署到生产环境时要改成true,并配置好信任的证书列表。
创建会话的 C# 代码放在SessionManager类里,核心逻辑是加载配置、选择端点、创建会话三步:
private Session CreateSession() { var application = new ApplicationInstance { ApplicationName = "Quickstarts.ReferenceClient", ApplicationType = ApplicationType.Client, ApplicationUri = "urn:Quickstarts:ReferenceClient" }; var config = application.LoadApplicationConfiguration("App.config", false); application.CheckApplicationInstanceCertificates(false); var endpoint = CoreClientUtils.SelectEndpoint(config, config.ServerUrl(), useSecurity: true); var session = Session.Create( config, endpoint, new UserIdentity(new UserNameIdentityToken { UserName = "opcua", Password = "123456" }), sessionName: "ReferenceClient-Session", sessionTimeout: 60000).GetAwaiter().GetResult(); return session; }SelectEndpoint会根据配置里的安全策略和服务器返回的端点列表自动匹配一个可用端点;Session.Create的参数分别是要传的sessionName和sessionTimeout,后者表示服务器在多少毫秒内没有收到客户端请求就主动断开。实际项目里建议在会话创建成功后立刻调用session.KeepAlive += KeepAliveHandler,让 SDK 自动发送保活报文。
3. 用 OPC.UA.Client SDK 实现节点浏览与读写界面
3.1 NuGet 依赖引用与解决方案结构
ReferenceClient 示例里并没有把主逻辑全部塞进 MainWindow,而是拆成了 Session 管理、节点浏览、订阅管理几个类。自己写 C# 上位机时也可以沿用这个结构:界面层用 WinForms 或 WPF,数据层封装 OPC UA 客户端。先通过 NuGet 安装官方 SDK:
dotnet add package OPCFoundation.NetStandard.Opc.Ua.Client dotnet add package OPCFoundation.NetStandard.Opc.Ua.ConfigurationOPCFoundation.NetStandard.Opc.Ua.Client是客户端核心程序集,包含Session、Subscription、MonitoredItem等类型;Configuration程序集负责证书、地址和传输配置。如果你还在用 .NET Framework 4.7.2,也可以安装对应版本的包,API 基本一致,只是命名空间里少了NetStandard前缀。
提示:dotnet add package 要在项目的
csproj文件所在目录执行。如果公司网络环境访问 NuGet 受限,可以先把.nupkg下载到本地,再用nuget config set globalPackagesFolder指定包来源。
3.2 浏览服务器地址空间:用 Browse 递归构建节点树
在界面上展示 OPC UA 服务器里的节点,最直接的做法是从ObjectsFolder开始调用Browse。下面是一个 WinForms 环境下的递归浏览方法:
public void BrowseNode(Session session, NodeId sourceId, TreeNode treeNode) { var request = new BrowseRequest { Header = { Timestamp = DateTime.UtcNow }, NodesToBrowse = new BrowseDescriptionCollection { new BrowseDescription { NodeId = sourceId, BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, NodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable, ResultMask = (uint)BrowseResultMask.All } }, RequestedMaxReferencesPerNode = 100 }; var response = session.Browse(request); foreach (var refDesc in response.Results[0].References) { var child = new TreeNode { Text = $"{refDesc.BrowseName.Name} [{refDesc.NodeId}]" }; treeNode.Nodes.Add(child); BrowseNode(session, refDesc.NodeId, child); // 递归遍历子节点 } }BrowseDirection.Forward表示查找以该节点为源的所有正向引用;ReferenceTypeId.HierarchicalReferences把组织、归纳、层次引用全部包括进来,基本覆盖了设备模型里常见的父子关系。NodeClassMask只选择对象和变量,避免把数据类型、字符类型等内部结构塞进界面节点树。RequestedMaxReferencesPerNode控制单次最多返回的引用条数,当节点数量大时会出现 truncated 状态,需要根据ContinuationPoint继续请求后续批次。另外,节点之间如果存在循环引用,递归会死循环,建议在方法外维护一个HashSet<NodeId>,进入递归前先判断是否已经访问过。
3.3 读取与写入:NodeId 与数据类型的匹配
读取变量值需要拿到目标节点的NodeId,并指定读取属性。下面是封装读写操作的代码:
public DataValue ReadValue(Session session, NodeId nodeId) { var request = new ReadRequest { NodesToRead = new ReadValueIdCollection { new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value, IndexRange = null } } }; var response = session.Read(request); var value = response.Results[0]; if (StatusCode.IsBad(value.StatusCode)) throw new ServiceResultException(value.StatusCode); return value; } public void WriteValue(Session session, NodeId nodeId, object value) { var request = new WriteRequest { NodesToWrite = new WriteValueCollection { new WriteValue { NodeId = nodeId, AttributeId = Attributes.Value, Value = new DataValue { Value = value, StatusCode = StatusCodes.Good, SourceTimestamp = DateTime.UtcNow } } } }; session.Write(request); }读取的结果中StatusCode不是Good就表示节点不可读、不存在或无权限,需要根据具体错误码决定是继续还是记录到日志。写入时要注意:AttributeId = Attributes.Value表示写的是节点当前值,如果服务器把该节点设置成只读,写入操作会返回BadNotWritable。DataValue.SourceTimestamp建议显式带上,有些 PLC 网关在比较时间戳时会拒绝写入。对于从字符串拼接出来的 NodeId,例如ns=2;s=PLC/HMI/Start,直接用NodeId.Parse("ns=2;s=PLC/HMI/Start")更安全,节点不存在时可以快速抛出异常。
在界面上,不同数据点的可读属性和数据类型差别很大,可以先把节点属性缓存到表格里再接合读取。常用的读取属性见下表:
| 属性名 | AttributeId | 用途 |
|---|---|---|
| Value | Attributes.Value | 节点的当前值 |
| NodeClass | Attributes.NodeClass | 节点类型 |
| DataType | Attributes.DataType | 值的数据类型 |
| AccessLevel | Attributes.AccessLevel | 可否读/可否写 |
| DisplayName | Attributes.DisplayName | 界面显示名称 |
读取这些属性时,只需把AttributeId换成表中对应值,ReadValueId的其余字段保持不变。这样既可做节点树详情页,也方便在订阅前判断某个变量是否可读。
4. 订阅回调、UI 刷新与断线重连:客户端稳定性实战
4.1 用 Subscription 和 MonitoredItem 做数据变更通知
轮询在读点较多时会产生大量无效应答,而且延迟不稳定。OPC UA 订阅机制由服务器在数据变化时主动上报,适合工控现场的高频数据监控。创建订阅的流程是:先建Subscription,再往里加MonitoredItem,并为每个 Item 绑定数据变化回调,最后调用Subscription.Create()把订阅注册到服务器。
var subscription = new Subscription(session, 0) // 订阅 id 由服务器分配 { PublishingInterval = 200, KeepAliveCount = 10, LifetimeCount = 60, MaxNotificationsPerPublish = 1000 }; var item = new MonitoredItem { StartNodeId = new NodeId("ns=2;s=PLC/IO/Temperature", 2), AttributeId = Attributes.Value, SamplingInterval = 100, QueueSize = 10, DiscardOldest = true }; item.DataValueChanged += OnDataValueChanged; subscription.AddItem(item); session.AddSubscription(subscription); subscription.Create();PublishingInterval是服务器发送发布通知的周期,SamplingInterval是服务器内部对数据点采样的间隔,一般建议SamplingInterval比PublishingInterval小或相等,否则两次发布之间数据可能没有更新。KeepAliveCount表示连续多少个发布周期没有数据通知时发送一个保活消息,LifetimeCount是订阅在没有收到消息时最大存活周期,QueueSize和DiscardOldest决定队列满时的数据淘汰策略。对于需要断点续传的报表类应用,可以把DiscardOldest设为false,让新数据等待旧数据被处理后再进入队列。
| 参数名 | 推荐值 | 影响 |
|---|---|---|
| PublishingInterval | 100~500ms | 服务器推送周期 |
| SamplingInterval | 50~200ms | 实际采数周期 |
| QueueSize | 10~100 | 未处理通知的缓存深度 |
| KeepAliveCount | 5~20 | 保活频率 |
| LifetimeCount | 需大于 KeepAliveCount | 订阅存活周期 |
4.2 回调线程与 UI 刷新:避免出现“界面卡顿”
OPC UA 回调默认运行在 SDK 的 I/O 线程池里,直接在回调里更新 WinForms 的TextBox或 WPF 的Label会抛跨线程异常,也容易触发c# 循环数据采集和ui刷新卡顿这类问题。常见做法是把回调数据暂存到并发队列,由 UI 定时器统一取出并刷新界面:
private readonly ConcurrentQueue<(string nodeId, object value)> _dataQueue = new(); private void OnDataValueChanged(MonitoredItem item, MonitoredItemNotification notification) { // notification.Value 是 DataValue,WrappedValue 里才是原始值 _dataQueue.Enqueue((item.StartNodeId.ToString(), notification.Value.WrappedValue.Value)); } private void DispatchToUi() { while (_dataQueue.TryDequeue(out var item)) { textBoxValue.AppendText($"{item.nodeId}: {item.value}{Environment.NewLine}"); } } // UI 定时器每 50ms 调用一次 DispatchToUi timer.Interval = 50; timer.Tick += (s, e) => DispatchToUi();ConcurrentQueue保证多线程入队、UI 线程出队时不会互相踩内存。UI 刷新周期可以比订阅周期慢,但不能反过来,否则队列会持续堆积。对于图表控件,还可以在入队前判断数值变化量,只有超过阈值才入队,这样既能保证曲线平滑,又减少 UI 绘制次数。
4.3 断线重连:抓住 KeepAlive 失联的临界点
网络波动或服务器重启都会让Session进入BadConnectionClosed状态。ReferenceClient 里用了 SessionReconnectHandler 专门处理重连,简单场景下可以自己写保活循环。关键点在于不能收到一个 BadNoCommunication 就立刻重建会话,连续几次失联才触发重连会更稳定:
private class ReconnectGuard { private int _lostCount; public bool ShouldReconnect => _lostCount >= 3; public void OnKeepAlive(Session session, ServiceResult result) { if (result.Code == StatusCodes.BadNoCommunication) { _lostCount++; } else { _lostCount = 0; } } }在使用Session.KeepAlive时,事件参数里的ServiceResult.Code会先变成Good,再在断网时变成BadNoCommunication。只有连续 3 次收到失联信号才重建会话,可以避免 Wi-Fi 闪断时频繁重连。ServiceResult不为 Bad 但也不为 Good 的情况,比如节点暂时不可用,不应该被当成失联处理。
5. 用 Prosys OPC UA Browser 把客户端跑起来并验证订阅
5.1 本地模拟服务器:把 App.config 指向 Prosys
手头没有 PLC 时,Prosys OPC UA Browser 是常用的本地模拟服务器。启动后在 Server 面板里能看到端点地址,例如opc.tcp://localhost:53530/opcua/server。把 ReferenceClient 的App.config中ServerUrl改成这个地址,并把安全策略暂时改为None,就可以绕开证书问题。
注意:如果客户端和服务器不在同一台机器,
ServerUrl里的localhost要改成服务器 IP,否则即使连接报错也很难定位是地址问题还是安全配置问题。
5.2 验证三步:节点树、读写值、订阅回调
连接成功后,先检查界面上的节点树是否弹出。展开后找到Data分组里的模拟变量,读取时注意对比 Browser 中的值;从 Browser 修改某个模拟值,观察客户端订阅回调是否在 2 秒内刷新。这三步全部通过,说明浏览、读写、订阅三条链路都是通的,可以放心接真实设备。
5.3 采集数据向 MQTT 转发:一种轻量做法
OPC UA 客户端跑稳后,数据要发给上层系统时,常见做法是直接推到 MQTT。用 MQTTnet 实现很短:
var mqtt = new MqttFactory().CreateMqttClient(); await mqtt.ConnectAsync(new MqttClientOptionsBuilder() .WithTcpServer("127.0.0.1", 1883) .Build(), CancellationToken.None); await mqtt.PublishAsync($"opcua/{nodeId}", payload);nodeId如果包含/或;,会直接影响 MQTT topic 的分层,发布前先把nodeId里的非法字符替换成_。如果不想自己维护桥接程序,也可以直接让 Node-RED 订阅 OPC UA 再转发到 MQTT,但那样会多一条中间链路,对于只有一两个采集端的场景,放在客户端进程里更省事。
本文还有配套的精品资源,点击获取