ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C# OPC UA客户端开发实战:连接、订阅与SQL Server数据存储

C# OPC UA客户端开发实战:连接、订阅与SQL Server数据存储 简介这是一份面向工业自动化、物联网及企业级数据集成方向开发者的C#实战项目源码核心是用C#构建OPC UA客户端连接OPC UA服务器完成数据读写并将采集数据存入SQL Server数据库。项目借助OpcUaHelper开源库简化协议实现数据以字符串形式经下划线分隔后写入关系型数据库涵盖建表、插入与查询等典型环节适合具备一定C#基础、希望打通设备通信与数据落库链路的读者参考。资源包共144个文件约5.51MB以56个dll依赖库、54个xml配置文档、9个cs源码文件为主另含config、resx、exe及csproj工程文件构成可直接运行的完整客户端工程。目前已有3239人学习下载读者可从中获取OPC UA连接管理、数据解析与SQL Server存储的完整实现思路并借鉴其工程目录组织方式快速搭建自己的采集入库程序。1. 从一台老冲压机说起为什么我最终选了 C# 写 OPC UA 客户端去年帮一家汽配厂做设备联网车间里一台 2008 年的冲压机还在跑PLC 是西门子 S7-300数据出不来老板又不想换设备。我试过 Modbus TCP 轮询寄存器地址对不上试过厂商私有协议文档早丢了。最后翻到 PLC 支持 OPC UA Server才把这条路走通。用 C# 写客户端连上去读节点再把数据落到 SQL Server整套跑下来不到 300 行核心代码。如果你也在做 C# 上位机、设备数据采集、或者要把 OPC UA 数据接进关系库这篇就是我从零搭到能跑之后把参数、坑和验证方法都摊开的一份记录。OPC UA 不是新东西但 C# 生态里能落地的示例不多很多文章停在连上就结束存库和断线重连才是真正花时间的地方。2. OPC UA 客户端选型与连接从 NuGet 包到会话建立2.1 为什么用 OPCFoundation.NetStandard.Opc.UaC# 做 OPC UA 客户端绕不开官方栈。市面上能搜到的库大概三类一是 OPC Foundation 官方维护的OPCFoundation.NetStandard.Opc.Ua二是商业库如 Unified Automation 的 SDK三是自己拿 socket 拼二进制协议。第三类我劝你别碰OPC UA 的握手、加密、证书链、节点浏览手写一遍至少两周而且后面维护是无底洞。商业库稳定但授权费不低小项目不划算。官方库是 Apache 2.0NuGet 直接拉社区案例多出问题能搜到。我一般会装这几个包dotnet add package OPCFoundation.NetStandard.Opc.Ua dotnet add package OPCFoundation.NetStandard.Opc.Ua.Client dotnet add package OPCFoundation.NetStandard.Opc.Ua.Configuration第一个是核心协议栈第二个是客户端封装第三个管应用配置和证书。版本上2.x 和 3.x 差异不小3.x 把很多同步 API 改成了异步如果你抄的是老博客里的Session.Create在 3.x 上会编译不过。我建议直接用当前稳定版别锁死老版本否则后面想用Subscription的异步回调会很别扭。2.2 建立会话的完整代码与参数说明连接分四步配置应用、选端点、建会话、拿节点。下面是我实际项目里精简后的代码能直接跑。using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; public async TaskSession ConnectAsync(string endpointUrl) { // 1. 应用配置应用名、应用类型、证书存储路径 var appConfig new ApplicationConfiguration() { ApplicationName PlcDataCollector, ApplicationType ApplicationType.Client, SecurityConfiguration new SecurityConfiguration { ApplicationCertificate new CertificateIdentifier { StoreType X509Store, StorePath CurrentUser\\My, SubjectName PlcDataCollector }, AutoAcceptUntrustedCertificates true, // 测试环境用生产要关 RejectSHA1SignedCertificates false }, ClientConfiguration new ClientConfiguration { DefaultSessionTimeout 60000 } }; await appConfig.Validate(ApplicationType.Client); // 2. 选端点这里用无安全策略生产建议 SignAndEncrypt var endpointDescription CoreClientUtils.SelectEndpoint( appConfig, endpointUrl, useSecurity: false); var endpointConfiguration EndpointConfiguration.Create(appConfig); var endpoint new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration); // 3. 建会话 var session await Session.Create( appConfig, endpoint, false, PlcDataCollectorSession, 60000, new UserIdentity(new AnonymousIdentityToken()), null); return session; }逻辑上ApplicationConfiguration是 OPC UA 客户端的身份证书是它的身份证。AutoAcceptUntrustedCertificates true只在调试时开生产环境必须走证书信任流程否则等于裸奔。SelectEndpoint会自动去问服务器支持哪些安全策略useSecurity: false表示选无加密端点适合内网隔离环境。Session.Create的第四个参数是会话名服务器端日志里能看到方便排查。超时 60000 毫秒是经验值车间网络抖动大设太短会频繁断。2.3 读节点从 NodeId 到值的映射连上之后读数据靠NodeId。每个 PLC 变量在 OPC UA Server 里都有唯一标识格式像ns2;sMachine1.Temperature。ns是命名空间索引s表示字符串标识。我一般先浏览一遍节点树把要用的 NodeId 记下来。public async TaskDataValue ReadNodeAsync(Session session, string nodeIdString) { var nodeId NodeId.Parse(nodeIdString); var readValueId new ReadValueId { NodeId nodeId, AttributeId Attributes.Value }; var response await session.ReadAsync( null, 0, TimestampsToReturn.Both, new ReadValueIdCollection { readValueId }); return response.Results[0]; }TimestampsToReturn.Both会同时返回源时间戳和服务器时间戳存库时我一般用源时间戳因为那是 PLC 采集时刻服务器时间戳是 OPC UA Server 转发时刻两者可能差几十毫秒。AttributeId Attributes.Value表示读值属性如果要读节点描述就换成Attributes.Description。批量读的话把多个ReadValueId塞进集合一次请求拿回来比循环单读快一个数量级。3. 数据落 SQL Server表结构、批量写入与时间戳处理3.1 表结构设计别用一张大宽表我见过有人把所有设备所有变量塞进一张表列名是Tag1到Tag200。这种表三个月后就没人看得懂。我的做法是两张表一张设备变量字典一张时序数据。CREATE TABLE DeviceTag ( TagId INT IDENTITY(1,1) PRIMARY KEY, DeviceName NVARCHAR(64) NOT NULL, TagName NVARCHAR(128) NOT NULL, NodeId NVARCHAR(256) NOT NULL, DataType NVARCHAR(32) NOT NULL, Unit NVARCHAR(16) NULL, IsActive BIT NOT NULL DEFAULT 1 ); CREATE TABLE TagHistory ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, TagId INT NOT NULL, Value NVARCHAR(64) NULL, Quality NVARCHAR(16) NOT NULL, SourceTime DATETIME2(3) NOT NULL, ServerTime DATETIME2(3) NOT NULL, InsertTime DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME() ); CREATE INDEX IX_TagHistory_TagId_SourceTime ON TagHistory (TagId, SourceTime DESC);Value用NVARCHAR(64)而不是FLOAT是因为 OPC UA 的值可能是布尔、整数、浮点、字符串统一转字符串存查询时再按DataType转。Quality存 OPC UA 的质量码Good、Bad、Uncertain这个字段在排查数据异常时救过我很多次。索引建在TagId SourceTime上按设备查最近数据时走索引不然几百万行扫起来很慢。3.2 批量写入SqlBulkCopy 比逐条 Insert 快在哪逐条INSERT在每秒几百个点的时候还能撑上千就顶不住。我用SqlBulkCopy攒一批 500 到 1000 条写一次。public void BulkInsert(string connStr, ListTagRecord records) { var table new DataTable(); table.Columns.Add(TagId, typeof(int)); table.Columns.Add(Value, typeof(string)); table.Columns.Add(Quality, typeof(string)); table.Columns.Add(SourceTime, typeof(DateTime)); table.Columns.Add(ServerTime, typeof(DateTime)); foreach (var r in records) table.Rows.Add(r.TagId, r.Value, r.Quality, r.SourceTime, r.ServerTime); using var bulk new SqlBulkCopy(connStr) { DestinationTableName TagHistory, BatchSize 1000, BulkCopyTimeout 30 }; bulk.ColumnMappings.Add(TagId, TagId); bulk.ColumnMappings.Add(Value, Value); bulk.ColumnMappings.Add(Quality, Quality); bulk.ColumnMappings.Add(SourceTime, SourceTime); bulk.ColumnMappings.Add(ServerTime, ServerTime); bulk.WriteToServer(table); }BatchSize 1000表示每 1000 行提交一次太大内存涨太小网络往返多。BulkCopyTimeout 30秒是给锁等待留的余量车间数据库偶尔有报表查询占锁。ColumnMappings必须显式写顺序不对会串列这个坑我踩过数据全错位查了半天才发现是映射顺序问题。3.3 时间戳源时间、服务器时间、入库时间怎么选OPC UA 的DataValue带两个时间戳。SourceTimestamp是 PLC 采集时刻ServerTimestamp是 OPC UA Server 发出时刻。我两个都存查询时默认用SourceTime。入库时间InsertTime用SYSUTCDATETIME()自动填用来算端到端延迟。有一次现场说数据延迟大我对比InsertTime - SourceTime发现平均 800 毫秒最后定位到是订阅发布间隔设成了 500 毫秒改小就降下来了。没有这个字段只能靠猜。4. 订阅模式与断线重连让采集能连续跑一周4.1 Subscription 与 MonitoredItem 的参数怎么设轮询读适合低频、少量点。设备多、频率高的时候要用订阅。OPC UA 的订阅是服务器主动推客户端只管收。var subscription new Subscription(session.DefaultSubscription) { PublishingInterval 200, // 服务器推送间隔毫秒 KeepAliveCount 10, // 10 个周期无数据发心跳 LifetimeCount 30, // 30 个周期无响应认为订阅失效 MaxNotificationsPerPublish 1000, PublishingEnabled true }; session.AddSubscription(subscription); await subscription.CreateAsync(); var item new MonitoredItem(subscription.DefaultItem) { StartNodeId NodeId.Parse(ns2;sMachine1.Temperature), AttributeId Attributes.Value, SamplingInterval 100, // 服务器采样间隔 QueueSize 10, // 客户端队列长度 DiscardOldest true }; item.Notification OnDataReceived; subscription.AddItem(item); await subscription.ApplyChangesAsync();PublishingInterval 200是服务器每 200 毫秒推一次SamplingInterval 100是服务器每 100 毫秒采一次采两次推一次。QueueSize 10是客户端来不及处理时缓存多少条DiscardOldest true表示满了丢最旧的保证拿到最新值。KeepAliveCount和LifetimeCount是心跳机制网络断的时候靠它触发重连。4.2 断线重连Session 的 KeepAlive 与重建逻辑车间网络断是常态交换机重启、网线被叉车压断、PLC 断电都会导致会话断。官方库的Session有KeepAlive事件但光靠它不够我一般加一个独立的重连循环。private async Task ReconnectLoopAsync() { while (!_cts.IsCancellationRequested) { try { if (_session null || _session.SessionState ! SessionState.Activated) { _session?.Close(); _session await ConnectAsync(_endpointUrl); await RebuildSubscriptionsAsync(_session); } } catch (Exception ex) { _logger.LogWarning(ex, 重连失败5 秒后重试); } await Task.Delay(5000, _cts.Token); } }SessionState.Activated是正常状态其他状态都触发重建。重建时订阅要重新加MonitoredItem不能复用必须新建。重试间隔 5 秒是折中太短会在服务器还没起来时疯狂重试太长数据空洞大。我一般还会在重连成功后补读一次当前值避免断线期间的数据完全丢失。4.3 数据缓冲断线时内存队列怎么用断线期间数据不能丢我在内存里放一个ConcurrentQueue订阅回调先入队写库线程从队列取。队列设上限比如 10 万条超了丢最旧的并记日志。private readonly ConcurrentQueueTagRecord _buffer new(); private const int MaxBufferSize 100000; private void OnDataReceived(MonitoredItem item, MonitoredItemNotificationEventArgs e) { foreach (var value in item.DequeueValues()) { if (_buffer.Count MaxBufferSize) { _buffer.TryDequeue(out _); _logger.LogWarning(缓冲区满丢弃最旧数据); } _buffer.Enqueue(ConvertToRecord(item, value)); } }ConcurrentQueue是无锁的多线程读写安全。MaxBufferSize按内存算一条记录大概 200 字节10 万条约 20MB可接受。写库线程每 500 毫秒或队列到 1000 条就批量写一次两个条件谁先满足都触发。5. 避坑与排查证书、命名空间、时区、批量写入的五个血泪教训5.1 现象连接报 BadCertificateUntrusted原因客户端证书没被服务器信任或者服务器证书没被客户端信任。OPC UA 双向验证两边都要互信。解决测试环境把AutoAcceptUntrustedCertificates设 true生产环境把双方证书导入TrustedPeers存储。具体路径在CertificateIdentifier里配Windows 下是CurrentUser\My和CurrentUser\TrustedPeople。导入用certmgr.msc手动做一次或者代码里调CertificateValidator。5.2 现象NodeId 读回来是 BadNodeIdUnknown原因命名空间索引对不上。不同 OPC UA Server 的ns2可能指向不同命名空间换一台设备就变了。解决别硬编码ns2先调session.NamespaceUris拿到实际索引再拼 NodeId。我一般把变量名和命名空间 URI 一起存字典表启动时解析成实际索引。5.3 现象存库时间比实际晚 8 小时原因SourceTimestamp是 UTC直接存进DATETIME2后查询时按本地时间显示差了时区。解决统一存 UTC查询时用AT TIME ZONE转或者前端转。我吃过这个亏报表出来所有数据都偏 8 小时被现场追着问了一下午。从那以后所有时间字段一律 UTC显示层再转。5.4 现象SqlBulkCopy 报主键冲突或数据错位原因ColumnMappings顺序和DataTable列顺序不一致或者目标表有自增主键但映射里包含了它。解决显式写ColumnMappings自增列不映射。DataTable列顺序和映射顺序保持一致别依赖默认顺序。5.5 现象订阅回调不触发但会话是活的原因PublishingEnabled没设 true或者ApplyChangesAsync没调。解决Subscription创建后必须ApplyChangesAsyncMonitoredItem加完后也要再调一次。这个在官方示例里不明显我翻了两小时源码才定位到。6. 进阶用配置表驱动采集不改代码加设备写到后面你会发现每加一台设备就改代码、重编译运维成本太高。我的做法是把设备、变量、NodeId、采样周期全放配置表程序启动时读表动态建订阅。public class TagConfig { public string DeviceName { get; set; } public string NodeId { get; set; } public int SamplingInterval { get; set; } public int PublishingInterval { get; set; } public bool IsActive { get; set; } } // 从 SQL Server 读配置 var configs LoadTagConfigs(connStr); foreach (var group in configs.GroupBy(c c.PublishingInterval)) { var sub new Subscription(session.DefaultSubscription) { PublishingInterval group.Key, PublishingEnabled true }; session.AddSubscription(sub); await sub.CreateAsync(); foreach (var cfg in group) { var item new MonitoredItem(sub.DefaultItem) { StartNodeId NodeId.Parse(cfg.NodeId), SamplingInterval cfg.SamplingInterval, QueueSize 10, DiscardOldest true }; item.Notification OnDataReceived; sub.AddItem(item); } await sub.ApplyChangesAsync(); }按PublishingInterval分组是因为一个订阅只能有一个发布间隔不同频率的变量要分到不同订阅。配置表里加个IsActive字段停用变量不用删行改标志位就行。这套跑起来之后现场加一台设备我只需要在DeviceTag表里插几行重启服务五分钟搞定不用碰代码。验证方法我一般走三步先用 UaExpert 连上去确认节点能读到值再用客户端读一次对比最后看TagHistory表里InsertTime - SourceTime的延迟分布。延迟稳定在 200 毫秒以内算正常超过 1 秒就要查网络或服务器负载。从那以后我每次上线新设备都强制走一遍这三步再也没出现过数据对不上的情况。希望帮到你。本文还有配套的精品资源点击获取
返回列表