简介:这是一份面向工业自动化、物联网及企业级数据集成方向开发者的C#实战项目源码,核心演示如何基于OpcUaHelper开源库构建OPC UA客户端,连接服务器完成数据读写,并将采集结果以字符串形式存入SQL Server关系型数据库,适合具备一定C#基础、希望打通设备通信与数据落库链路的进阶学习者参考。资源包共144个文件,约5.51MB,以56个dll动态库、54个xml配置文档、9个cs源码文件为主,另含config、resx、exe及csproj工程文件,完整保留了可编译运行的Visual Studio项目结构。目前已有3240人学习下载。通过该实例,读者可掌握OPC UA连接建立、节点数据读取、下划线分隔字符串解析、数据库表结构设计及INSERT写入等关键环节,并借鉴其工程组织方式与依赖管理思路,快速迁移到自有的工业数据采集场景中。
1. 从一台老机床到数据库:这个 C# OPC UA 客户端到底解决什么问题
车间里那台跑了十年的数控机床,数据只能在本地面板上看,想存进数据库做报表就得靠人拿 U 盘拷——这是我第一次接触 OPC UA 采集需求时的真实场景。这个项目要干的事很直接:用 C# 写一个 OPC UA 客户端,连上服务端,把节点数据读出来,再写进 SQL Server。它适合两类人:一是做工业数据采集上位机的开发者,二是需要把 PLC、传感器数据接进业务系统的后端同学。技术栈上,通信层用 OPC UA 官方 .NET 库,数据落地用 ADO.NET 或轻量 ORM,整体是一个可独立运行的控制台或服务程序。读完你能拿到一套能跑通的采集链路,也能照着改节点、改表结构、改采集频率。
2. 架构拆解:从会话建立到数据落库的完整链路
2.1 整体分层与数据流向
这个项目我一般会拆成四层,每层职责单一,方便后面替换。最上面是配置层,管服务端地址、节点 ID、数据库连接串;第二层是 OPC UA 通信层,负责建会话、读节点、订阅变化;第三层是数据处理层,做类型转换、时间戳补全、异常值过滤;最下面是持久化层,把整理好的数据写进 SQL Server。
数据流向是单向的:配置加载 → 建立会话 → 读取或订阅 → 转换 → 入库。单向的好处是排查问题时能快速定位是哪一层断了。比如读不到值,先看会话是否建立;值读到了但库里没有,就看转换和入库。常见做法是把通信层和持久化层用接口隔开,后面想换成 MQTT 或 MySQL 只改实现类。
// 分层接口定义,方便替换实现 public interface IOpcUaService { Task ConnectAsync(string endpointUrl); Task<Dictionary<string, object>> ReadNodesAsync(IEnumerable<string> nodeIds); Task SubscribeAsync(IEnumerable<string> nodeIds, Action<string, object> onDataChanged); Task DisconnectAsync(); } public interface IDataRepository { Task SaveAsync(IEnumerable<OpcRecord> records); }这段接口定义是整个架构的骨架。ConnectAsync负责建立会话,ReadNodesAsync做批量读取,SubscribeAsync用于订阅模式,DisconnectAsync保证资源释放。IDataRepository只暴露一个SaveAsync,把入库细节藏起来。参数上,nodeIds用集合而不是单个字符串,是因为实际项目里一次采集几十上百个节点很常见,批量操作能减少往返开销。
2.2 OPC UA 会话建立与节点读取
会话建立是整条链路最容易翻车的地方。OPC UA 服务端通常要求安全策略和证书,很多新手直接抄一段匿名连接的代码,结果连不上就开始怀疑网络。我一般会先确认三件事:服务端是否允许匿名、安全策略是什么、端点地址有没有写错。
var endpointDescription = CoreClientUtils.SelectEndpoint(endpointUrl, useSecurity: false); var endpointConfiguration = EndpointConfiguration.Create(); endpointConfiguration.OperationTimeout = 15000; var session = await Session.Create( configuration: endpointConfiguration, endpointDescription: endpointDescription, sessionName: "CSharpOpcUaClient", sessionTimeout: 60000, identity: new UserIdentity(), preferredLocales: null );SelectEndpoint会自动发现服务端支持的端点,useSecurity: false表示先用无安全策略跑通,生产环境再换成证书。OperationTimeout设成 15 秒,是因为有些老设备响应慢,默认值容易超时。sessionTimeout是会话存活时间,设 60 秒,配合后面的保活逻辑。UserIdentity空构造表示匿名,如果服务端要求用户名密码,这里换成new UserIdentity("user", "pwd")。
读取节点时,我习惯用批量读而不是循环单读:
var readValueIds = nodeIds.Select(id => new ReadValueId { NodeId = new NodeId(id), AttributeId = Attributes.Value }).ToArray(); var response = await session.ReadAsync( null, 0, TimestampsToReturn.Both, readValueIds); var results = new Dictionary<string, object>(); for (int i = 0; i < response.Results.Length; i++) { var value = response.Results[i].Value?.Value; results[nodeIds.ElementAt(i)] = value ?? DBNull.Value; }TimestampsToReturn.Both表示同时返回源时间戳和服务端时间戳,入库时用源时间戳更准。response.Results的顺序和请求的readValueIds一一对应,所以用索引回填字典。Value?.Value做空判断,因为节点可能被删除或权限不足,直接取会抛异常。
2.3 数据转换与 SQL Server 入库
读出来的值类型五花八门,布尔、整数、浮点、字符串都有,直接塞进数据库容易类型不匹配。我一般会在转换层统一处理,把值、时间戳、节点 ID 打包成一个记录对象。
public class OpcRecord { public string NodeId { get; set; } public object Value { get; set; } public DateTime SourceTimestamp { get; set; } public DateTime ServerTimestamp { get; set; } public DateTime CollectTime { get; set; } = DateTime.Now; }入库用参数化 SQL,避免拼接字符串带来的注入和类型问题:
using var conn = new SqlConnection(connectionString); await conn.OpenAsync(); using var cmd = new SqlCommand( "INSERT INTO OpcData (NodeId, Value, SourceTimestamp, CollectTime) " + "VALUES (@NodeId, @Value, @SourceTimestamp, @CollectTime)", conn); cmd.Parameters.Add("@NodeId", SqlDbType.NVarChar, 200); cmd.Parameters.Add("@Value", SqlDbType.NVarChar, 500); cmd.Parameters.Add("@SourceTimestamp", SqlDbType.DateTime2); cmd.Parameters.Add("@CollectTime", SqlDbType.DateTime2); foreach (var record in records) { cmd.Parameters["@NodeId"].Value = record.NodeId; cmd.Parameters["@Value"].Value = record.Value?.ToString() ?? ""; cmd.Parameters["@SourceTimestamp"].Value = record.SourceTimestamp; cmd.Parameters["@CollectTime"].Value = record.CollectTime; await cmd.ExecuteNonQueryAsync(); }参数类型显式声明,NVarChar长度给足,避免截断。DateTime2比DateTime精度高,适合工业时间戳。循环里复用同一个SqlCommand,只改参数值,比每次新建命令快很多。如果数据量大,可以换成SqlBulkCopy,但要注意它不走参数化,得自己保证数据干净。
3. 跑起来:环境准备、配置与首次采集验证
3.1 环境依赖与项目结构
跑这个项目需要 .NET 6 或以上,OPC UA 库用官方 NuGet 包,数据库驱动用Microsoft.Data.SqlClient。项目结构我一般这样组织:
OpcUaCollector/ ├── Config/ │ └── appsettings.json ├── Services/ │ ├── OpcUaService.cs │ └── SqlRepository.cs ├── Models/ │ └── OpcRecord.cs ├── Program.cs └── OpcUaCollector.csproj配置文件里放三块内容:OPC UA 端点、节点列表、数据库连接串。节点列表用数组,方便增删。
{ "OpcUa": { "EndpointUrl": "opc.tcp://127.0.0.1:4840", "NodeIds": [ "ns=2;s=Machine1.Temperature", "ns=2;s=Machine1.Pressure" ], "UseSecurity": false }, "Database": { "ConnectionString": "Server=localhost;Database=FactoryData;Trusted_Connection=True;TrustServerCertificate=True;" } }EndpointUrl换成实际服务端地址,NodeIds里的命名空间和标识符按服务端实际节点改。TrustServerCertificate=True是本地开发常用,生产环境建议配好证书。
3.2 数据库表结构与初始化
表结构要能装下节点 ID、值、时间戳。我一般建一张主表加一个索引:
CREATE TABLE OpcData ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, NodeId NVARCHAR(200) NOT NULL, Value NVARCHAR(500) NULL, SourceTimestamp DATETIME2 NOT NULL, CollectTime DATETIME2 NOT NULL DEFAULT GETDATE() ); CREATE INDEX IX_OpcData_NodeId_Time ON OpcData (NodeId, SourceTimestamp DESC);索引建在NodeId和SourceTimestamp上,因为查询通常是「某个节点最近一段时间的数据」。Value用NVARCHAR存字符串,兼容各种类型,如果确定只有数值,可以换成FLOAT省空间。
3.3 首次运行与采集验证
启动程序后,先看控制台输出。正常流程会打印「会话已建立」「读取到 N 个节点」「入库 N 条」。如果卡在会话建立,检查端点地址和服务端是否启动。如果读取返回空值,检查节点 ID 是否正确。
验证数据是否真的进去了,直接查库:
SELECT TOP 10 NodeId, Value, SourceTimestamp FROM OpcData ORDER BY CollectTime DESC;如果表里有数据且时间戳合理,说明链路通了。我一般还会故意改错一个节点 ID,看程序是否报错并继续处理其他节点——这能验证异常处理是否到位。
提示:首次跑通建议先用无安全策略和匿名连接,确认链路没问题后再加证书和账号,这样排查范围小。
4. 避坑与排查:连接、类型、性能的常见翻车点
4.1 会话建立失败:证书与安全策略不匹配
现象是Session.Create抛异常,提示安全策略不支持或证书验证失败。原因通常是服务端要求签名加密,而客户端用了无安全策略,或者客户端证书没被服务端信任。解决方法是先用SelectEndpoint打印服务端支持的所有端点,看SecurityPolicyUri和SecurityMode,然后按需配置。如果服务端允许匿名无安全,就先用useSecurity: false跑通。生产环境要配证书,把客户端证书导入服务端信任列表。
4.2 读取返回 BadNodeIdUnknown
现象是response.Results[i].StatusCode不是 Good,值是空。原因是节点 ID 写错了,命名空间索引或标识符不对。解决方法是先用 UaExpert 之类的工具连上服务端,浏览节点树,把正确的 NodeId 复制出来。注意命名空间索引在不同服务端可能不一样,不能照抄示例。
4.3 入库报类型转换错误
现象是SqlException提示类型不匹配或截断。原因是Value字段长度不够,或者参数类型和列类型不一致。解决方法是把Value列改成NVARCHAR(MAX)或加大长度,参数用NVarChar并给足 Size。如果值里有特殊字符,确保用参数化而不是拼接。
4.4 长时间运行内存增长
现象是程序跑几天后内存占用越来越高。原因是订阅模式没释放旧句柄,或者读取结果没及时清理。解决方法是订阅时保存Subscription和MonitoredItem引用,退出时逐个删除;读取模式用using或及时置空大对象。我一般会加一个定时器,每小时打印一次内存和会话状态,方便观察。
4.5 采集频率过高导致服务端拒绝
现象是服务端返回BadTooManyRequests或连接被断开。原因是读取间隔太短,服务端处理不过来。解决方法是把采集间隔调到服务端能承受的范围,或者改用订阅模式让服务端主动推送。订阅模式下可以设PublishingInterval,比如 1000 毫秒,比轮询更省资源。
5. 二次开发:换节点、换库、加告警的改造思路
5.1 节点配置化与动态加载
最直接的改造是把节点列表从配置文件挪到数据库或接口。我一般会建一张NodeConfig表,存节点 ID、别名、是否启用、采集间隔。程序启动时加载,运行中定时刷新。这样加节点不用重启程序。
public async Task<List<NodeConfig>> LoadNodeConfigsAsync() { var configs = new List<NodeConfig>(); using var conn = new SqlConnection(connectionString); await conn.OpenAsync(); using var cmd = new SqlCommand( "SELECT NodeId, Alias, Enabled, IntervalMs FROM NodeConfig WHERE Enabled = 1", conn); using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { configs.Add(new NodeConfig { NodeId = reader.GetString(0), Alias = reader.GetString(1), Enabled = reader.GetBoolean(2), IntervalMs = reader.GetInt32(3) }); } return configs; }Enabled字段控制是否采集,IntervalMs支持不同节点不同频率。改造后,采集循环按节点分组,每组用自己的间隔。
5.2 换数据库:从 SQL Server 到其他存储
持久化层已经用接口隔开,换库只需实现新的IDataRepository。比如换成 MySQL,把SqlConnection换成MySqlConnection,SQL 语法微调。如果换成时序数据库,SaveAsync里改成批量写入接口。关键是把OpcRecord作为统一数据契约,不让上层感知底层存储。
5.3 加告警:阈值判断与通知
告警逻辑放在数据处理层,读完值后判断是否超阈值。我一般会加一个AlarmRule表,存节点、上限、下限、通知方式。触发后写告警表并调用通知接口。
public bool CheckAlarm(OpcRecord record, AlarmRule rule) { if (record.Value == null) return false; if (!double.TryParse(record.Value.ToString(), out var value)) return false; return value > rule.UpperLimit || value < rule.LowerLimit; }double.TryParse做安全转换,避免非数值节点误判。触发后可以写库、发邮件或调 Webhook。注意告警要去重,同一节点短时间内重复触发只记一次,否则告警表会爆。
5.4 验证改造是否成功
每次改造后,我习惯用三步验证:先看控制台日志确认会话和读取正常,再查库确认数据写入,最后模拟异常(比如断开服务端)看程序是否优雅处理。特别是换库和加告警后,一定要跑一遍完整链路,别只测单个模块。
从那以后我每次改采集逻辑,都强制先跑一遍「断线重连 + 空值 + 类型异常」三个用例,确认不会因为一个节点的问题拖垮整个程序。希望帮到你。
本文还有配套的精品资源,点击获取