简介:面向工业自动化、物联网与企业级数据集成的开发者,这份C#实现的OPC UA客户端示例,围绕“从OPC UA服务器读取数据并存入SQL Server”这一完整链路展开。方案在Visual Studio环境中使用OpcUaHelper开源库完成连接管理与读写操作,并演示了数据接收、字符串格式化、SQL建表与插入、数据更新与解析等关键环节,便于迁移到实际工控项目中。压缩包共144个文件,其中包含56个dll依赖库、54个xml配置/文档文件,以及9个cs源码文件、exe可执行程序、项目工程和配置文件等,整体仅5.51MB,目录结构清晰,可直接用Visual Studio打开调试。代码中重点展示了如何将采集数据转为以下划线“_”分隔的字符串,再解析写入SQL Server,并为数据库表设计、字段适配、批量入库提供了可参考的处理思路。已有3238人学习下载,适合需要快速搭建OPC UA通信、实现工业数据持久化的C#开发者参考学习。 做上位机数据采集的,早晚都会碰到这么一件事:车间里十几台设备,PLC型号五花八门,有西门子的、有罗克韦尔的、有倍福的,上位机软件想把这些设备的数据统一读上来,再汇总到数据库里做报表和分析。前几年大家各显神通,用OPC DA的居多,但是OPC DA依赖COM/DCOM,配置麻烦不说,跨网段、跨防火墙的时候简直是噩梦。后来OPC UA出来以后,我终于可以只用一套协议就把所有设备搞定,配合C#写上位机,数据直接打到SQL Server里,整条链路干净又稳定。
这篇文章就围绕“C#实现OPC UA客户端并存入SQL Server”这条主线,把我实际做项目时踩过的坑、走过的弯路、验证过的写法完整地讲一遍。适合刚接触OPC UA的C#上位机开发人员,也适合已经在用但想优化数据入库逻辑的同行。文章不堆概念,核心是把一条可运行的链路讲透:从OPC UA客户端如何和设备建立连接、读取节点数据,到数据如何高效地写进SQL Server,每一步都有代码、有理由、有注意点。
1. 选型逻辑:为什么是OPC UA + C# + SQL Server这套组合
我看过不少项目,明明需求很简单,却把技术栈选得很热闹,最后交付的时候运维一脸懵。做工业数据采集,稳定性和可维护性永远是第一位的。
先说OPC UA。
OPC UA(Unified Architecture,统一架构)和老的OPC DA最大的区别在于它不再依赖Windows的COM/DCOM机制,而是基于TCP/IP或者HTTP,默认走4840端口,跨平台、跨防火墙都轻松很多。而且OPC UA自带信息模型,节点可以带类型、带属性、带单位,读上来的数据不是一堆裸的数值,而是有语义的对象。对做上位机的人来说,最有感知的是:设备地址表的结构清晰了,数据带时间戳和质量戳了,安全认证也内置了。像Prosys Simulation Server、Kepware这些模拟器和网关,对OPC UA支持得都非常成熟,开发阶段直接开一个模拟服务器就能干活。
再说C#。
C#做上位机几乎是工业圈的主流选项,因为Visual Studio的开发效率摆在那里,WinForms和WPF做界面都很快,类库生态里OPCFoundation官方提供的UA .NET Standard包非常好用,NuGet直接拉下来就能用,不需要自己啃协议栈源码。相比C++写OPC UA客户端要处理大量的回调、内存管理和异步逻辑,C#的语言特性和GC机制让开发周期缩短很多。
最后说SQL Server。
很多小项目偷懒用SQLite或者Access,但一旦数据量上来、查询变多了,关系型数据库的差距就非常明显。SQL Server和Windows Server、.NET平台的配合度是原生级别的,事务处理、并发写入、索引优化都做得很稳。而且大多数工厂IT环境里如果已经部署了数据库服务,大概率就是SQL Server。如果客户没有特别要求用Oracle或者MySQL,我基本都会用SQL Server,尤其是Express版免费、单库10GB上限对于中小型采集中转站足够用了。
这套组合的逻辑是:OPC UA解决“怎么从设备拿数据”的问题,C#解决“拿回来之后怎么处理”的问题,SQL Server解决“数据怎么存怎么查”的问题。每一层都有成熟的官方或者社区支持,出了问题找资料也方便,这才是选型最重要的考量。
2. 开发环境准备与OPC UA模拟服务器搭建
动手写代码之前,环境这块有几个细节值得先说清楚,不然做到一半才发现库引用不对、模拟环境连不上,非常耽误时间。
2.1 基础环境清单
我的常用环境是这样一套:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| Visual Studio | 2022 Community | 免费的社区版就够了,安装时勾选“.NET桌面开发”工作负载 |
| .NET | .NET 6.0 LTS(或更高LTS) | 工业项目选LTS是保险策略,别追新 |
| OPC UA库 | OPCFoundation.NetStandard.Opc.Ua 1.5.x | NuGet包名是这个,引用时注意选稳定版 |
| 模拟设备 | Prosys OPC UA Simulation Server | 免费,内置多组变量,支持读写和模拟变化 |
| SQL Server | 2019/2022 Express或Standard | 本机测试用Express完全够,部署按现场需求 |
有一个新手特别容易忽略的点:OPC UA客户端要访问服务端,服务端的证书必须被客户端信任。用模拟服务器测试时,Prosys第一次启动会自动生成证书,客户端目录里也会有证书交换的过程。我习惯先把模拟服务器的证书导到Windows受信任的根证书存储区,避免后面连不上排查半天。
2.2 为什么开发阶段要一台“假设备”
调试OPC UA客户端最怕的就是现场设备还没到位,或者设备在产线上不能随便折腾。模拟器在这里起的作用是:提供一个和真实OPC UA服务端行为一致的端点(Endpoint),里面有不断变化的变量,还有可以手动写入的节点。这样客户端代码可以在完全不影响生产的情况下跑通全流程。
Prosys Simulation Server启动后,默认会监听opc.tcp://localhost:53530/opcua-simulation-server,内置的Objects里有一条模拟数据分支,含随机数、正弦波、计数器之类的变量,拿来做订阅测试非常理想。
另外还有一个轻量级选择:OPC Foundation官方仓库里有一个UA Sample Server的示例工程,可以直接用Visual Studio编译跑起来,用命令行启动作为测试服务端。对于想要更纯粹环境、不想装额外软件的开发者,这条路也可以。
2.3 验证环境连通性的快速方法
在写代码之前,我建议先用现成的客户端工具验证一下模拟服务器的连通性。UA Expert是常用的免费客户端,下载安装后直接输入模拟服务器的URL就能连上。连上以后,你能在左侧树形列表里看到服务端的节点结构,右侧可以看到变量的实时值。这一步如果通了,说明模拟环境本身没问题,后面代码里连不上肯定是代码的事。
用UA Expert还有一个好处:可以查看节点的NodeId。OPC UA里定位一个节点靠的不是名字,而是NodeId,可能是数值型的(ns=2;i=1234),也可能是字符串型的(ns=2;s=SimulationData.Counter1)。写代码订阅的时候需要预先知道目标节点的NodeId,光靠浏览器看不一定够。这一步我基本都是用UA Expert把节点树点开,找到目标变量的属性面板里NodeId字段,然后复制出来用到代码里。
3. C#写一个可用的OPC UA客户端:从连接、浏览到订阅
环境通了之后,就可以开始写客户端了。这一节我按实际开发的顺序来讲,每一步都贴核心代码,然后解释为什么这么写。
3.1 创建会话并连接服务端
OPC UA客户端的第一步是和服务端建立会话。OPCFoundation库提供了一个简洁的模型:先创建一个ApplicationConfiguration,配置应用名和证书;然后用Session.Create方法建立会话。
using Opc.Ua; using Opc.Ua.Configuration; var application = new ApplicationInstance { ApplicationName = "MyUaClient", ApplicationType = ApplicationType.Client, ConfigSectionName = "Opc.Ua.Client" }; // 自动生成或加载应用证书(首次运行会生成) var config = await application.LoadApplicationConfiguration("Opc.Ua.Client.Config.xml", false); await application.CheckApplicationInstanceCertificates(false, 2048); var endpointDescription = CoreClientUtils.SelectEndpoint(config, "opc.tcp://localhost:53530/opcua-simulation-server", useSecurity: false); using var session = await Session.Create( config, endpointDescription, updateBeforeConnect: false, "MySession", 60000, new UserIdentity(new AnonymousIdentityToken()), null);这段代码里有几个小的设计点要解释一下。
useSecurity: false的意思是建立不加密的会话。生产环境如果是内网且设备网段隔离,很多人图省事就关掉安全,但如果是跨网段或者有安全审计要求,务必开启证书加密和签名。开发阶段关掉可以避开证书协商的麻烦,把精力集中在业务逻辑上。
UserIdentity支持匿名和用户名密码两种方式。大部分PLC或者网关的OPC UA服务端默认允许匿名访问,但某些站控软件要求必须配置用户。这个都看现场情况,代码里可以做成配置项。
3.2 浏览节点树,找到目标变量的地址
很多设备不像模拟器那样有现成的文档告诉你NodeId是多少。这时候就需要在代码里遍历服务端的节点树,把感兴趣的变量找出来。
var rootNodeId = ObjectIds.ObjectsFolder; var browseResult = await session.BrowseAsync( requestHeader: null, view: null, nodeToBrowse: rootNodeId, maxResultsToReturn: 0, browseDescriptionCollection: null, ct: CancellationToken.None);遍历的逻辑其实就是:从ObjectsFolder开始,递归调用Browse方法,读取每个节点的引用(References),过滤出类型为Variable的节点,打印出BrowseName和NodeId。
一个常见的问题是:某些节点被服务端标记为不可浏览(AccessRestriction),或者浏览权限受安全策略限制。遇到这种情况,代码里至少要能给出一个友好提示,而不是直接崩溃。
3.3 订阅数据变化
实时采集数据最常用的方式不是循环读(Polling),而是订阅(Subscription)。订阅的逻辑是:客户端告诉服务端“我关心哪些节点”,服务端在节点值变化时主动推给客户端。这样做的好处是省网络流量、时间戳更准确,响应也更快。
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 500, // 发布周期,单位毫秒 KeepAliveCount = 5, LifetimeCount = 20 }; session.AddSubscription(subscription); subscription.Create(); var collection = new MonitoredItemCollection(); collection.Add(new MonitoredItem { StartNodeId = new NodeId("SimulationData.Counter1", 2), SamplingInterval = 500, QueueSize = 1, DiscardOldest = true }); collection[0].Notification += OnDataChanged; subscription.AddItems(collection);回调函数里拿到的NotificationEventArgs里带了一个MonitoredItemNotification,其中Value就是最新值,包括Value字段(实际数值)和SourceTimestamp(设备侧的时间戳)。
private void OnDataChanged(MonitoredItem item, MonitoredItemNotificationEventArgs e) { var notification = e.NotificationValue as MonitoredItemNotification; if (notification == null) return; var newValue = notification.Value; Console.WriteLine($"节点 {item.StartNodeId} 值={newValue.Value} 时间戳={newValue.SourceTimestamp} 质量={newValue.StatusCode}"); }生产项目里,这个回调就是数据进入缓冲区、然后入库的入口点。
3.4 异步编程的一个关键警告
这里必须插一句话:如果你在回调里想做异步操作,比如调用async方法写数据库,不能直接用async void或者不加处理地await,因为OPC UA库的Notification事件触发线程不是UI线程,而且回调频率可能很高,每次都在回调里开异步任务会很容易把线程池拖垮。
我用的方案是:回调里只把数据塞进一个线程安全的缓冲区(比如Channel ),然后由一个后台写入任务统一消费这个缓冲区。这样订阅层和数据入库层完全解耦,不管是100个变量还是10000个变量,写入压力都是可控的。这个思路后面还会再提。
4. 数据入库:SQL Server批量写入的工程细节
数据从OPC UA客户端拿到手之后,最后一步是落到SQL Server。这一步看起来简单,实际坑也不少。最典型的问题就是性能——如果用逐条INSERT,采集几千个变量、一秒钟更新一次,数据库基本就废了。
4.1 建表设计
存储采集数据的表很简单,但设计上有几个关键点。我常用的结构是:
CREATE TABLE [dbo].[RealtimeData] ( [Id] BIGINT IDENTITY(1,1) PRIMARY KEY, [TagName] NVARCHAR(128) NOT NULL, [TagValue] FLOAT NOT NULL, [Quality] NVARCHAR(32) NULL, [SourceTimestamp] DATETIME2 NOT NULL, [ServerTimestamp] DATETIME2 NULL, [CreateTime] DATETIME2 NOT NULL CONSTRAINT DF_RealtimeData_CreateTime DEFAULT SYSUTCDATETIME() );几个设计理由:
- TagName用NVARCHAR:OPC UA的BrowseName可能是中文或者特殊字符,NVARCHAR避免转码问题。
- TagValue用FLOAT:PLC里的模拟量绝大多数是浮点,用FLOAT能覆盖绝大多数场景。如果还有整数离散量,可以考虑增加一列或者用SQL_VARIANT,但查询和索引时SQL_VARIANT不方便,我更倾向于单独建一张开关量表。
- SourceTimestamp和ServerTimestamp分开:SourceTimestamp是设备侧的时间,ServerTimestamp是OPC UA服务端接收时加的时间。现场排查数据延迟时这两个字段能帮上大忙。
- CreateTime用UTC:统一存UTC时间,后续做报表时按需转换时区,避免夏令时(如果有)或者服务器时区设置不一致带来的混乱。
4.2 批量写入:SqlBulkCopy的性能优势
逐条INSERT一度把我一个项目的CPU打到80%。采集1万个变量、每500毫秒刷新一次,这个量级下逐条插入完全不可行。后来我把数据缓冲到DataTable,定时批量用SqlBulkCopy灌进SQL Server,CPU占用直接掉到个位数。
using System.Data; using Microsoft.Data.SqlClient; var table = new DataTable(); table.Columns.Add("TagName", typeof(string)); table.Columns.Add("TagValue", typeof(double)); table.Columns.Add("Quality", typeof(string)); table.Columns.Add("SourceTimestamp", typeof(DateTime)); table.Columns.Add("ServerTimestamp", typeof(DateTime)); table.Columns.Add("CreateTime", typeof(DateTime)); // 从缓冲区取出累积的数据,填充table…… // 每2秒执行一次批量写入 var connectionString = "Server=localhost;Database=PlantData;Integrated Security=True;TrustServerCertificate=True;"; using var bulk = new SqlBulkCopy(connectionString) { DestinationTableName = "dbo.RealtimeData", BulkCopyTimeout = 30, BatchSize = 1000 }; bulk.ColumnMappings.Add("TagName", "TagName"); bulk.ColumnMappings.Add("TagValue", "TagValue"); bulk.ColumnMappings.Add("Quality", "Quality"); bulk.ColumnMappings.Add("SourceTimestamp", "SourceTimestamp"); bulk.ColumnMappings.Add("ServerTimestamp", "ServerTimestamp"); bulk.ColumnMappings.Add("CreateTime", "CreateTime"); await bulk.WriteToServerAsync(table);这里有两个细节:
- BatchSize=1000:每批1000行,不是一次性全灌。避免单次事务太大导致锁表时间过长。
- ColumnMappings必须要写:如果DataTable的列顺序和表结构不一致,不配置映射很容易死得很难看。显式映射虽然多写几行,但可维护性好很多。
为什么不用表值参数(Table-Valued Parameter)或者EF Core的批量插入?表值参数在小数据量下很好用,但配置自定义表类型在部署时多了一步;EF Core的批量插入性能在工程上不如直接SqlBulkCopy直接可靠。既然核心诉求是“稳定落库”,SqlBulkCopy是现有方案里最稳的。
4.3 连接字符串和事务边界
连接字符串里的TrustServerCertificate=True,在开发环境很有用,如果用的是SQL Server 2019以上默认强制加密的配置,不设置这个或者不安装证书会报证书链错误。生产环境如果DBA已经配好了证书,最好去掉Encrypt相关内容,让数据库服务器统一管理。
事务边界上,SqlBulkCopy默认是在一个事务内完成的,如果批量写入过程中发生错误,整个批次会回滚,不会出现半截数据。如果你的业务要求“即使写入失败也不能丢实时数据”,那就要在程序里做重试或者本地文件缓存兜底。工业项目我最担心的就是数据在内存里没落库就断电了,所以重要数据我往往会加一层“写入失败写本地文件,恢复后补写”的机制,这个看项目重要度自己取舍。
5. 实测中容易踩的坑:类型转换、时间戳、断线重连
写到这里,链路已经通了。但我必须把实际项目中高频踩坑的几个点单独拿出来聊,这些坑我在测试环境里基本都吃过一轮。
5.1 OPC UA返回值的类型陷阱
OPC UA的Variant类型非常灵活,底层的DataType可能是Int16、UInt64、Double、Float,甚至String、ByteString。直接Convert.ToDouble(notification.Value.Value)大多数时候没问题,但遇到UInt64或者BigInteger超过Double精度的情况,数据就会发生静默截断。一个典型的场景:电表读数经常会超过2^53,用Double存会丢精度。
我的做法是:在入库前先判断TypeInfo的类型,如果遇到整型大数,就用ToString()保留原始字符串,入库时对应列改成NVARCHAR;如果确认是浮点或小范围整数,再走FLOAT列。这个判断逻辑放在统一的转换函数里,后续扩展新设备时只要维护这一处。
5.2 时间戳到底该信谁的
前面提到SourceTimestamp和ServerTimestamp分开存,实际排查的时候就会发现,有的设备根本不提供SourceTimestamp,或者设备本身时钟不准,SourceTimestamp会突然跳变。这时候如果拿SourceTimestamp来做时间轴,报表上会出现数据断层。
针对这个问题,我建议:
- 如果设备时钟可校时,优先以SourceTimestamp为准。
- 如果设备不支持时钟同步,就用ServerTimestamp,放弃SourceTimestamp。
- 条件允许的话,在程序侧加一个高精度时钟源(比如NTP同步后的Windows时间),入库时以程序时间为CreateTime,保留两个服务端时间作为参考。
没有绝对正确的策略,关键是项目初期要跟客户确认清楚报表的时间基准是哪个字段,不然上线后再换字段会牵扯到历史数据的迁移。
5.3 断线重连不能只靠Session
OPC UA的连接断掉之后,最麻烦的是Subscription状态会丢失。我见过不少项目在重连之后收不到数据,就是因为只重新建立了Session,忘记重新Create Subscription。
做一个健壮重连机制,核心逻辑是:
private async Task KeepAliveLoop(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { await Task.Delay(TimeSpan.FromSeconds(5), ct); if (session != null && session.KeepAliveInterval > 0 && !session.Connected) { await ReconnectAsync(ct); } } catch (Exception ex) { // 记录日志,等待下一次循环 } } } private async Task ReconnectAsync(CancellationToken ct) { // 1. 重新选择端点并创建Session // 2. 重建Subscription及MonitoredItem // 3. 手动读取一次当前值,避免断线期间的数据空缺 }第三点很容易忽略:断线期间设备侧可能已经发生了很多变化,重连后如果只等下一次变化事件,中间的数据就永远丢了。所以我会在重连成功后立即做一次Read,把当前值先补采一次。
5.4 模拟器的订阅频率和服务端配置不匹配
还有一个坑是在用Prosys模拟器测试时发现的:我设置了100ms的采样间隔,但模拟器输出端的数据更新频率只有1秒。这种情况下订阅回调并不会按100ms触发,而是以服务端实际更新频率为准。当时我以为代码出了问题,查了大半天,最后看了模拟器文档才明白:OPC UA订阅的SamplingInterval只是“期望值”,服务端可以按自己的实际能力调整。
遇到这类问题,建议先查看服务端的RevisedPublishingInterval和RevisedSamplingInterval,这两个值会告诉你服务端实际采用的频率。如果期望频率和服务端实际频率相差太远,要么调整服务端配置,要么降低客户端的期望值,避免心理预期和实际不符。
6. 写在最后:从Demo到可交付系统的差距在哪里
很多时候,读者照着网上的Demo跑通了OPC UA读数和数据库写入,觉得项目就差不多了——其实差距还很大。真正要交付到工厂里的系统,至少还差这几块:日志记录要完整、配置要可修改(设备IP、订阅周期、数据库连接不要写死在代码里)、异常要能恢复(数据库重连、OPC UA重连)、数据要能补采(断电重启后把丢失时段的数据补回来)。这些内容展开讲又是另一篇文章的量,但核心思路就是四个字:防御性编程。所有可能出错的地方,都要在正常流程之外留一条后路。
我自己的习惯是,写OPC UA采集程序时,把“数据从设备到数据库”这条链路拆成独立模块,中间用队列解耦。这样无论前端设备怎么换、后端数据库怎么换,改动都是局部的,不会牵一发而动全身。用C#做这套东西,本身语言层面的开发效率就很高,只要架构不打结,后期维护会相当舒服。希望这篇分享能帮你少走一些弯路,顺利把项目跑通。
本文还有配套的精品资源,点击获取