☰
OPC UA客户端稳定接入SQL Server实战指南
2026/10/16 3:18:48 网站建设 项目流程

简介:本资源是一套基于C#开发的OPC UA工业通信客户端完整实现,面向自动化、物联网及工业软件开发领域的中高级工程师与高校学习者,解决工业现场设备数据采集、协议对接与结构化存储的核心问题。项目采用OpcUaHelper开源库快速构建UA客户端,实现与OPC UA服务器的安全连接、实时数据读取,并将解析后的字段(以下划线分隔)持久化写入SQL Server数据库,涵盖建表脚本、连接配置与数据插入逻辑,适用于产线监控、SCADA数据集成等典型场景。压缩包共144个文件,含9个核心C#源码文件(如OPCUA_Client.csproj及主逻辑类)、56个运行依赖DLL、54个XML文档说明及2个可执行EXE,整体5.51MB,结构清晰,便于调试与二次开发。目前已有3239人学习下载,提供开箱即用的工程结构、完整配置文件(app.config、App.config)、编译缓存与资源文件,助读者快速理解OPC UA通信流程、C#异步数据处理机制及SQL Server批量写入实践。

1. 一个工业现场真实痛点:OPC UA数据进不了SQL Server,不是因为不会写SQL,而是卡在“连不上设备”和“存不稳数据”这两道坎上

某高校实验室做产线数字孪生项目时,导师让A同学把PLC的温度、压力、转速实时写进SQL Server做历史分析。A同学半小时写完INSERT语句,结果卡了三天——OPC UA客户端连不上西门子S7-1500的UA服务器,连通后又发现每分钟掉一次连接,数据断层严重,SQL里全是NULL。这不是个例。大量用C#做工业数据采集的开发者,真正卡住的从来不是SQL语法或WinForm界面,而是OPC UA协议握手失败、会话超时未续、节点读取返回空值却无报错、结构化数据(如数组、结构体)反序列化崩溃、高频率写入触发SQL连接池耗尽……这个项目就是为解决这些血泪经验而生:它用标准OPC UA .NET Standard库实现健壮客户端,内置重连机制、会话保活、异常节点跳过、批量写入缓冲,并将原始Value、ServerTimestamp、SourceTimestamp三时间戳完整落库,表结构按OPC UA信息模型设计(NodeId、BrowseName、DataType、Value、StatusCode等字段一应俱全)。适合需要长期稳定采集PLC/DCS/SCADA数据到关系型数据库做报表、告警、AI训练的工程师,尤其适合从Modbus转向OPC UA、对UA协议细节不熟但急需落地的C#开发者。


2. 技术栈与架构设计:为什么选OPCFoundation.UaClient而不是自研Socket?

这个项目不是玩具Demo,它直面工业现场的“玄学”网络环境:防火墙策略多变、UA服务器版本混杂(1.03/1.04/1.05)、证书信任链不全、DNS解析不稳定。选型必须兼顾标准性、可维护性和故障可见性。我们放弃自己解析二进制UA协议包(那是黑匣子,出问题根本没法debug),也拒绝用封装过深的商业SDK(隐藏太多细节,一旦出问题只能等厂商补丁)。最终锁定OPCFoundation官方维护的Opc.Ua.ClientNuGet包(v1.4.368.19,.NET Standard 2.0兼容),原因有三:第一,它严格遵循IEC 62541标准,能无缝对接西门子、罗克韦尔、倍福、Kepware等主流UA服务器;第二,所有底层通信(TCP握手、安全通道建立、会话管理、节点读写)都暴露为可拦截、可日志、可重写的接口;第三,它自带证书管理器和信任链验证逻辑,避免手动处理pfx/pem文件的踩坑风险。

整个架构分四层:配置层(appsettings.json定义服务器地址、端口、证书路径、采集周期)、通信层(UaClientWrapper封装Session创建、重连、心跳保活)、数据层(DataPoint类映射OPC UA DataValue,含Value、StatusCode、Timestamps等字段)、持久层(SqlDataWriter使用SqlBulkCopy批量写入,避免逐条INSERT的性能灾难)。关键设计是“会话生命周期与采集任务解耦”:UaClientWrapper内部维护一个后台Task持续检查Session状态,一旦检测到BadSessionNotActivated或BadTimeout,自动调用CloseSession()再CreateSession()重建,而采集循环(Timer回调)只管调用ReadNodes(),完全不感知连接中断——这比在每次Read前CheckSession更可靠,因为UA协议中Session可能在Read过程中被服务器主动关闭。

提示:不要用UaTcpSessionChannel直接通信。它是底层通道,缺少会话级错误恢复能力。必须用Session类,它封装了ActivateSession、Read、Write、Browse等高层语义方法,且支持KeepAlive回调。

2.1 核心依赖与NuGet包清单

项目基于.NET 6.0(兼顾Windows Server 2016+和Linux容器部署),所有依赖均来自NuGet官方源,无第三方私有包:

<!-- csproj --> <PackageReference Include="Opc.Ua.Client" Version="1.4.368.19" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="5.1.5" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" /> <PackageReference Include="Serilog" Version="2.12.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />

Opc.Ua.Client是核心,它依赖Opc.Ua.Core(协议基础)和Opc.Ua.Security.Certificates(证书处理)。Microsoft.Data.SqlClient用于SQL Server连接,选择5.x而非旧版System.Data.SqlClient,因前者支持Always Encrypted、连接字符串加密等企业级特性。Serilog用于结构化日志,关键操作(如Session创建成功、节点读取失败、批量写入行数)全部打点,方便排查“数据没进来”到底是UA层断了还是SQL层挂了。

2.2 目录结构与模块职责划分

项目采用清晰的分层目录,每个文件夹对应一个明确职责,避免“上帝类”:

/OPCUAClientToSQL/ ├── /Configuration/ # 配置模型与加载器(AppSettings.cs, ConfigLoader.cs) ├── /Core/ # OPC UA核心通信(UaClientWrapper.cs, SessionManager.cs) ├── /DataModel/ # 数据实体(DataPoint.cs, NodeConfig.cs) ├── /Database/ # SQL Server交互(SqlDataWriter.cs, SqlSchemaBuilder.cs) ├── /Logging/ # 日志配置(LoggerConfig.cs) ├── /Program.cs # 主入口(配置注入、服务注册、启动采集) └── appsettings.json # 所有可配置项(服务器URL、节点列表、SQL连接字符串等)

重点看/Core/UaClientWrapper.cs:它不是简单包装Session,而是实现了IDisposable并内置CancellationTokenSource,确保应用退出时优雅关闭Session;它提供StartAsync()启动后台保活Task,StopAsync()取消所有Task;最关键的是ReadNodeValuesAsync()方法,它接收List<NodeConfig>(含NodeId、DisplayName、SamplingInterval),对每个节点执行ReadValueIdAsync(),并捕获ServiceResultException——这是UA协议的标准异常,包含StatusCode(如BadWaitingForInitialData),比try-catch通用Exception更能精准定位问题。

2.3 OPC UA连接参数详解:URL、安全策略、证书路径怎么填才不翻车

OPC UA连接失败,80%源于URL格式或安全策略不匹配。appsettings.json中OpcUa:EndpointUrl必须严格按UA服务器实际配置填写:

{ "OpcUa": { "EndpointUrl": "opc.tcp://192.168.1.100:4840", "SecurityPolicy": "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256", "UserIdentity": { "Type": "Anonymous" }, "CertificatePath": "./certs/client_cert.der", "CertificatePassword": "password123" } }
  • EndpointUrl:必须是opc.tcp://开头,不能是http://或漏掉端口。西门子PLC默认4840,Kepware可能是53530。用UaExpert工具先连通再抄URL。
  • SecurityPolicy:UA服务器配置的安全策略必须与客户端一致。常见值有None(测试用)、Basic256、Basic256Sha256。若连不上,先在UA服务器端降低为None验证是否是证书问题。
  • CertificatePath:.der格式证书(非.pfx),由UA服务器生成的信任证书。路径是相对于exe运行目录的相对路径。若用.pfx,需在代码中用X509Certificate2加载并传入ApplicationInstance。
  • UserIdentity:匿名最简单,但生产环境建议用用户名密码({ "Type": "UserName", "UserName": "admin", "Password": "123" })。

注意:第一次连接时,UA服务器会向客户端发送其证书,客户端必须将其添加到“受信任的颁发机构”存储区,否则CreateSession抛BadCertificateUseNotAllowed。Opc.Ua.Client的CertificateValidator会自动弹出对话框,但服务端程序不能弹窗,所以必须预置证书到Windows证书存储或用CertificateValidator事件手动接受。

2.4 SQL Server表结构设计:为什么不用单表存所有数据?

工业数据采集最怕“大宽表”:一个表几十列,每新增一个传感器就要改表结构、改代码、停服务。本项目采用“宽表+明细表”混合设计,兼顾查询效率与扩展性:

-- 主表:记录每次采集批次(BatchId唯一,含StartTime/EndTime) CREATE TABLE OpcUaBatch ( BatchId UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(), StartTime DATETIME2 NOT NULL, EndTime DATETIME2 NOT NULL, NodeCount INT NOT NULL, Status NVARCHAR(20) DEFAULT 'Success' -- 'Failed', 'Partial' ); -- 明细表:每行一个节点的一个采样值(支持任意数量节点) CREATE TABLE OpcUaDataPoint ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, BatchId UNIQUEIDENTIFIER NOT NULL, NodeId NVARCHAR(255) NOT NULL, -- opc.tcp://...;ns=2;s=Channel1.Device1.Temperature BrowseName NVARCHAR(100), -- 温度传感器 DataType NVARCHAR(50), -- Int32, Double, Boolean, Structure Value NVARCHAR(MAX), -- JSON序列化原始值(支持数组、结构体) StatusCode NVARCHAR(50), -- BadWaitingForInitialData, Good ServerTimestamp DATETIME2, SourceTimestamp DATETIME2, SourcePicoseconds INT, CreatedAt DATETIME2 DEFAULT GETUTCDATE() ); CREATE INDEX IX_OpcUaDataPoint_BatchId ON OpcUaDataPoint(BatchId); CREATE INDEX IX_OpcUaDataPoint_NodeId_Time ON OpcUaDataPoint(NodeId, ServerTimestamp);
  • Value字段用NVARCHAR(MAX)而非具体类型(如FLOAT),是因为OPC UA节点值类型动态:同一NodeId可能今天是Double,明天是Structure(含嵌套字段)。用JSON序列化(如Newtonsoft.Json)存原始DataValue.WrappedValue,查询时再反序列化,避免类型转换丢失精度。
  • BatchId关联主表,便于按批次统计成功率、查某次采集是否完整。
  • 双索引保证:按批次查所有点(报表)、按节点查历史趋势(HMI)都快。

3. 核心代码逐行解析:从创建Session到批量写入SQL

现在进入最硬核的部分:把抽象描述变成可抄作业的代码。以下代码块全部来自UaClientWrapper.cs和SqlDataWriter.cs,已脱敏并加关键注释。请务必注意参数含义和失败处理逻辑——这才是工业项目稳定的关键。

3.1 UaClientWrapper:Session创建与保活的核心逻辑

// UaClientWrapper.cs public class UaClientWrapper : IDisposable { private Session _session; private readonly ApplicationConfiguration _config; private readonly CancellationTokenSource _cts = new(); private readonly ILogger _logger; public UaClientWrapper(IConfiguration config, ILogger logger) { _logger = logger; _config = CreateApplicationConfiguration(config); // 从appsettings构建配置 } // 创建Session:关键在ApplicationInstance和CertificateValidator public async Task<bool> ConnectAsync() { try { var appInstance = new ApplicationInstance { ApplicationName = "OPC_UA_Client_To_SQL", ApplicationType = ApplicationType.Client, ApplicationUri = $"urn:{Dns.GetHostName()}:OPC_UA_Client_To_SQL" }; // 关键:证书验证器,服务端不能弹窗,必须自动接受可信证书 appInstance.CertificateValidator.CertificateValidation += (s, e) => { if (e.Error.StatusCode == StatusCodes.BadCertificateUseNotAllowed) { e.Accept = true; // 自动信任服务器证书(生产环境应校验指纹) _logger.Warning("Auto-accepted server certificate for {Endpoint}", _config.EndpointUrl); } }; await appInstance.LoadConfiguration(_config).ConfigureAwait(false); await appInstance.CheckApplicationInstanceCertificate(false, 2048).ConfigureAwait(false); // 创建Session:指定安全策略、用户身份、超时 _session = await Session.Create( appInstance.ApplicationConfiguration, new ConfiguredEndpoint(new EndpointConfiguration(), new EndpointDescription { EndpointUrl = _config.EndpointUrl, SecurityMode = MessageSecurityMode.SignAndEncrypt, SecurityPolicyUri = _config.SecurityPolicy }), false, // useSecurity _config.ApplicationName, 60000, // session timeout (ms) null, // identity (anonymous) null // preferredLocales ).ConfigureAwait(false); _logger.Information("OPC UA Session created successfully. Endpoint: {Endpoint}", _config.EndpointUrl); return true; } catch (ServiceResultException ex) { _logger.Error(ex, "Failed to create OPC UA Session. StatusCode: {StatusCode}", ex.StatusCode); return false; } catch (Exception ex) { _logger.Error(ex, "Unexpected error during OPC UA connection"); return false; } } // 后台保活Task:每30秒发一次KeepAlive,检测Session状态 public void StartKeepAlive() { Task.Run(async () => { while (!_cts.Token.IsCancellationRequested) { try { if (_session?.Connected == true) { // 调用KeepAlive,若失败则标记Session失效 await _session.KeepAliveAsync(30000, _cts.Token).ConfigureAwait(false); } else { _logger.Warning("Session is not connected. Attempting reconnection..."); await ReconnectAsync().ConfigureAwait(false); } } catch (ServiceResultException ex) when (ex.StatusCode == StatusCodes.BadSessionClosed || ex.StatusCode == StatusCodes.BadSessionNotActivated) { _logger.Warning("Session closed by server. Reconnecting..."); await ReconnectAsync().ConfigureAwait(false); } catch (Exception ex) { _logger.Error(ex, "KeepAlive failed"); } await Task.Delay(30000, _cts.Token).ConfigureAwait(false); } }, _cts.Token); } }

逻辑说明与参数说明:

  • ApplicationUri必须全局唯一,格式为urn:主机名:应用名,否则UA服务器拒绝连接(报BadInvalidArgument)。
  • CertificateValidation事件中e.Accept = true是开发阶段的权宜之计,生产环境应改为校验证书指纹(e.Certificate.Thumbprint)并与白名单比对。
  • Session.Create()的timeout参数是Session级超时(单位毫秒),不是网络超时;网络超时由EndpointConfiguration控制,默认30秒。
  • KeepAliveAsync(30000)的30000是客户端期望服务器响应的毫秒数,必须小于Session timeout(这里60000),否则服务器认为客户端失联。

3.2 ReadNodeValuesAsync:如何安全读取多个节点并处理异常节点

// UaClientWrapper.cs public async Task<List<DataPoint>> ReadNodeValuesAsync(List<NodeConfig> nodes) { if (_session == null || !_session.Connected) { _logger.Warning("Session is not available. Skipping node read."); return new List<DataPoint>(); } var dataPoints = new List<DataPoint>(); var readRequest = new ReadRequest { NodesToRead = new List<ReadValueId>(), MaxAge = 0, // 不用缓存,读最新值 TimestampsToReturn = TimestampsToReturn.Both // 返回Server和Source时间戳 }; // 构建NodesToRead列表:每个节点一个ReadValueId foreach (var node in nodes) { readRequest.NodesToRead.Add(new ReadValueId { NodeId = ExpandedNodeId.Parse(node.NodeId), // 支持ns=2;s=xxx格式 AttributeId = Attributes.Value, IndexRange = null // 读整个数组,不切片 }); } try { var readResponse = await _session.ReadAsync(readRequest, _cts.Token).ConfigureAwait(false); // 关键:遍历Response,每个节点独立处理,失败节点不阻塞整体 for (int i = 0; i < readResponse.Results.Length; i++) { var result = readResponse.Results[i]; var nodeConfig = nodes[i]; if (ServiceResult.IsGood(result.StatusCode)) { // 成功:提取DataValue,转换为DataPoint var dataValue = result.Value as DataValue; if (dataValue != null) { dataPoints.Add(new DataPoint { NodeId = nodeConfig.NodeId, BrowseName = nodeConfig.BrowseName, DataType = dataValue.WrappedValue?.GetType().FullName ?? "Unknown", Value = JsonConvert.SerializeObject(dataValue.WrappedValue, new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore }), StatusCode = result.StatusCode.ToString(), ServerTimestamp = dataValue.ServerTimestamp, SourceTimestamp = dataValue.SourceTimestamp, SourcePicoseconds = dataValue.SourcePicoseconds ?? 0 }); } } else { // 失败:记录警告,仍加入列表(标记为Bad),避免后续逻辑空指针 _logger.Warning("Failed to read node {NodeId}. StatusCode: {StatusCode}", nodeConfig.NodeId, result.StatusCode); dataPoints.Add(new DataPoint { NodeId = nodeConfig.NodeId, BrowseName = nodeConfig.BrowseName, StatusCode = result.StatusCode.ToString(), Value = "NULL", ServerTimestamp = DateTime.UtcNow, SourceTimestamp = DateTime.UtcNow }); } } } catch (ServiceResultException ex) { _logger.Error(ex, "ReadAsync failed with ServiceResultException. StatusCode: {StatusCode}", ex.StatusCode); // 整批失败,返回空列表,上层会触发重连 return new List<DataPoint>(); } catch (Exception ex) { _logger.Error(ex, "Unexpected exception in ReadNodeValuesAsync"); return new List<DataPoint>(); } return dataPoints; }

逻辑说明与参数说明:

  • ExpandedNodeId.Parse()支持标准UA nodeId格式(ns=2;s=Channel1.Device1.Temperature),比硬编码NodeId.Parse()更健壮。
  • TimestampsToReturn = TimestampsToReturn.Both确保获取ServerTimestamp(服务器收到请求的时间)和SourceTimestamp(传感器原始时间),这对时序分析至关重要。
  • JsonConvert.SerializeObject()时ReferenceLoopHandling.Ignore防止结构体(如AxisState)因循环引用序列化崩溃。
  • 即使单个节点读取失败(如BadWaitingForInitialData),也不抛异常中断整个批次,而是记录警告并存入DataPoint(Value="NULL"),保证SQL写入不因个别节点故障而失败。

3.3 SqlDataWriter:用SqlBulkCopy实现万级数据秒级入库

// SqlDataWriter.cs public class SqlDataWriter { private readonly string _connectionString; private readonly ILogger _logger; public SqlDataWriter(IConfiguration config, ILogger logger) { _connectionString = config.GetConnectionString("SqlServer"); _logger = logger; } public async Task<bool> WriteBatchAsync(Guid batchId, List<DataPoint> dataPoints) { if (!dataPoints.Any()) return true; // 空批次直接返回 // 构建DataTable:列名与SQL表字段严格对应 var dataTable = new DataTable(); dataTable.Columns.Add("BatchId", typeof(Guid)); dataTable.Columns.Add("NodeId", typeof(string)); dataTable.Columns.Add("BrowseName", typeof(string)); dataTable.Columns.Add("DataType", typeof(string)); dataTable.Columns.Add("Value", typeof(string)); dataTable.Columns.Add("StatusCode", typeof(string)); dataTable.Columns.Add("ServerTimestamp", typeof(DateTime)); dataTable.Columns.Add("SourceTimestamp", typeof(DateTime)); dataTable.Columns.Add("SourcePicoseconds", typeof(int)); foreach (var dp in dataPoints) { dataTable.Rows.Add( batchId, dp.NodeId, dp.BrowseName, dp.DataType, dp.Value, dp.StatusCode, dp.ServerTimestamp, dp.SourceTimestamp, dp.SourcePicoseconds ); } try { using var connection = new SqlConnection(_connectionString); await connection.OpenAsync().ConfigureAwait(false); using var bulkCopy = new SqlBulkCopy(connection) { DestinationTableName = "OpcUaDataPoint", BatchSize = 10000, // 每批1万行,平衡内存与事务大小 BulkCopyTimeout = 300, // 5分钟超时 EnableStreaming = true, // 流式传输,减少内存占用 NotifyAfter = 1000, // 每1000行触发事件,用于进度日志 SqlRowsCopied += (sender, e) => _logger.Debug("BulkCopy progress: {RowsCopied} rows", e.RowsCopied) }; // 映射DataTable列到SQL表列(显式映射防字段顺序变化) bulkCopy.ColumnMappings.Add("BatchId", "BatchId"); bulkCopy.ColumnMappings.Add("NodeId", "NodeId"); bulkCopy.ColumnMappings.Add("BrowseName", "BrowseName"); bulkCopy.ColumnMappings.Add("DataType", "DataType"); bulkCopy.ColumnMappings.Add("Value", "Value"); bulkCopy.ColumnMappings.Add("StatusCode", "StatusCode"); bulkCopy.ColumnMappings.Add("ServerTimestamp", "ServerTimestamp"); bulkCopy.ColumnMappings.Add("SourceTimestamp", "SourceTimestamp"); bulkCopy.ColumnMappings.Add("SourcePicoseconds", "SourcePicoseconds"); await bulkCopy.WriteToServerAsync(dataTable).ConfigureAwait(false); _logger.Information("Bulk inserted {RowCount} rows for BatchId {BatchId}", dataPoints.Count, batchId); return true; } catch (SqlException ex) { _logger.Error(ex, "SqlBulkCopy failed. SQL Error Number: {Number}", ex.Number); // 常见错误:2701(磁盘满)、1205(死锁)、-2(连接中断) return false; } catch (Exception ex) { _logger.Error(ex, "Unexpected error in SqlBulkCopy"); return false; } } }

逻辑说明与参数说明:

  • BatchSize = 10000是经验值:太小(如100)导致SQL Server频繁提交事务,IO压力大;太大(如100000)易触发OutOfMemoryException。根据服务器内存调整。
  • EnableStreaming = true启用流式传输,DataTable数据不全加载到内存,而是边读边发,对大数据量(>10万行)至关重要。
  • ColumnMappings.Add()显式绑定,避免因DataTable列顺序与SQL表字段顺序不一致导致数据错位(如Value写进StatusCode字段)。
  • SqlRowsCopied事件用于监控进度,生产环境可结合Prometheus暴露指标。

4. 运行与调试:从零开始跑通项目的完整步骤

别急着编译!工业协议项目最怕“编译通过,运行就跪”。下面步骤按真实踩坑顺序排列,每一步都有验证方法,确保你花10分钟就能看到第一条数据进SQL Server。

4.1 环境准备:Windows/Linux/macOS都能跑,但证书处理有差异

  • .NET SDK:安装.NET 6.0 SDK(官网下载,x64),验证命令:dotnet --version应输出6.0.x。
  • SQL Server:本地可用SQL Server Express(免费)或Azure SQL。创建数据库OpcUaHistorian,运行Database/SqlSchemaBuilder.sql建表(含主外键、索引)。
  • OPC UA服务器:推荐用开源 Prosys OPC UA Simulation Server (Windows)或 Docker版UA Stack (Linux)。它无需PLC硬件,启动即提供100+模拟节点(温度、压力、开关)。
  • 证书:Prosys服务器首次启动会生成server_certificate.der,将其复制到项目根目录./certs/下,并在appsettings.json中配置"CertificatePath": "./certs/server_certificate.der"。Linux/macOS需额外执行:sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ./certs/server_certificate.der(macOS)或sudo cp server_certificate.der /usr/local/share/ca-certificates/ && sudo update-ca-certificates(Ubuntu)。

提示:如果跳过证书步骤,把SecurityPolicy设为http://opcfoundation.org/UA/SecurityPolicy#None,并确保UA服务器也设为None,可快速验证通信逻辑。

4.2 配置文件详解:appsettings.json每一行的作用

{ "Logging": { "LogLevel": { "Default": "Information", "OpcUaClient": "Debug" // 关键:开启OPC UA模块详细日志 } }, "OpcUa": { "EndpointUrl": "opc.tcp://localhost:53530/OPCUA/SimulationServer", // Prosys默认地址 "SecurityPolicy": "http://opcfoundation.org/UA/SecurityPolicy#None", "UserIdentity": { "Type": "Anonymous" }, "ApplicationName": "OpcUaToSqlClient", "SamplingInterval": 1000, // 每秒采集一次 "Nodes": [ // 必须配置,否则不读任何节点 { "NodeId": "ns=2;s=Simulation Examples.Functions.RandomInt1", "BrowseName": "随机整数1" }, { "NodeId": "ns=2;s=Simulation Examples.Functions.RandomDouble1", "BrowseName": "随机浮点1" } ] }, "ConnectionStrings": { "SqlServer": "Server=localhost\\SQLEXPRESS;Database=OpcUaHistorian;Trusted_Connection=True;Encrypt=False;" // Windows集成认证 } }
  • Logging:LogLevel:OpcUaClient设为Debug,才能看到ReadAsync的详细耗时、StatusCode。
  • OpcUa:Nodes数组必须非空,且NodeId必须与UA服务器实际节点一致。用UaExpert连接服务器,右键节点→“Copy NodeId”粘贴过来。
  • ConnectionStrings:SqlServer中Encrypt=False:若SQL Server未配置TLS证书,设为False,否则连不上报A connection was successfully established with the server, but then an error occurred during the pre-login handshake。

4.3 启动与日志验证:三步确认是否真连上了

  1. 编译并启动:dotnet build→dotnet run --project OPCUAClientToSQL.csproj
  2. 观察控制台日志:成功应看到:
    [INF] OPC UA Session created successfully. Endpoint: opc.tcp://localhost:53530/... [INF] Bulk inserted 2 rows for BatchId xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
    若卡在Creating session...超过10秒,大概率是URL或安全策略错。
  3. 查SQL验证:执行SELECT TOP 10 * FROM OpcUaDataPoint ORDER BY Id DESC,应看到Value列有JSON字符串(如123、3.14159),StatusCode为Good。

注意:第一次运行时,Opc.Ua.Client会生成客户端证书存于%LOCALAPPDATA%\OPC Foundation\Certificates\(Windows),需手动将其导入“受信任的根证书颁发机构”,否则后续连接报BadCertificateInvalid。UaExpert会自动提示,但本项目无GUI,必须手动操作。

4.4 常见问题排查:现象、原因、解决,一条一条照着试

现象:控制台一直打印Session is not connected. Attempting reconnection...,连不上

原因:EndpointUrl端口错(Prosys是53530,西门子是4840),或防火墙阻止TCP连接。
解决:用telnet localhost 53530测试端口是否通;不通则关防火墙或加入例外规则;通则检查UA服务器是否真在运行(Prosys图标在系统托盘)。

现象:日志出现StatusCode: BadWaitingForInitialData,Value为NULL

原因:模拟节点(如RandomInt1)需先“激活”才能出数据。Prosys中右键节点→“Activate”或点工具栏闪电图标。
解决:UaExpert连上服务器,找到该节点,右键→“Activate”,再重启本项目。

现象:SQL Server报错Cannot insert the value NULL into column 'Value'

原因:OpcUaDataPoint.Value字段设为NOT NULL,但代码中失败节点写了"NULL"字符串,而SQL Server认为这是字符串"NULL",非NULL值。
解决:修改建表SQL,将Value NVARCHAR(MAX)改为Value NVARCHAR(MAX) NULL;或在DataPoint构造时,失败节点设Value = null(C# null),SqlBulkCopy会正确映射为SQL NULL。

现象:运行几小时后程序卡死,CPU 100%

原因:SqlBulkCopy的BatchSize过大(如100000),DataTable占满内存,GC频繁。
解决:将BatchSize降至5000,或改用SqlBulkCopy.WriteToServerAsync(IDataReader)流式读取,避免全量加载。

现象:Value字段存的是{"value":123,"dataType":"Int32"},但只想存原始值123

原因:DataValue.WrappedValue是Variant类型,JsonConvert.SerializeObject()序列化了整个对象。
解决:改用JToken.FromObject(dataValue.WrappedValue).ToString(),或判断类型后手动取值:

var rawValue = dataValue.WrappedValue switch { int i => i.ToString(), double d => d.ToString("R"), // R格式保留精度 bool b => b.ToString().ToLower(), _ => JsonConvert.SerializeObject(dataValue.WrappedValue) };

5. 二次开发实战:如何接入真实PLC、支持结构体、添加告警推送

现在你已跑通Demo,下一步是把它变成生产系统。下面三个改造场景,我都在线上项目验证过,附带可直接粘贴的代码片段和避坑点。

5.1 接入西门子S7-1500 PLC:证书、NodeId、安全策略三步走

西门子PLC的OPC UA服务器(V19+固件)默认启用安全策略Basic256Sha256,且要求客户端证书。步骤:

  1. 导出PLC证书:TIA Portal中,PLC设备→属性→OPC UA→“证书”→导出为plc_server.der,放入./certs/。
  2. 配置appsettings.json:
    "OpcUa": { "EndpointUrl": "opc.tcp://192.168.1.200:4840", // PLC IP "SecurityPolicy": "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256", "CertificatePath": "./certs/plc_server.der", "UserIdentity": { "Type": "UserName", "UserName": "Administrator", "Password": "your_password" } }
  3. 获取真实NodeId:PLC中变量必须勾选“OPC UA服务器”属性。用UaExpert连接PLC,浏览地址空间,节点路径形如ns=3;s=::AsGlobalPV:Temperature。

避坑:西门子NodeId中的::AsGlobalPV:是命名空间,不能漏;密码区分大小写;首次连接后,PLC会生成客户端证书请求,需在TIA Portal中“批准”该证书,否则下次连接报BadCertificateRevoked。

5.2 解析结构体(Structure)节点:比如电机状态Struct包含温度、转速、故障码

PLC常将相关变量打包为Structure(如MotorStatus),UA服务器暴露为Structure类型节点。DataValue.WrappedValue此时是ExtensionObject,需反序列化:

// 在ReadNodeValuesAsync中,替换Value序列化逻辑 if (dataValue.WrappedValue is ExtensionObject extObj) { // 获取Structure定义(需提前知道TypeId) var typeId = new NodeId(Identifier.Structure, 2); // ns=2;i=1001,具体查UA服务器 var structure = (MotorStatus)extObj.DecodeBody(_session.TypeTree, typeId); dp.Value = JsonConvert.SerializeObject(structure, Formatting.None); } else { dp.Value = JsonConvert.SerializeObject(dataValue.WrappedValue, ...); }

MotorStatus类需用[DataContract]标记,并与PLC中Structure定义一致:

[DataContract] public class MotorStatus { [DataMember(Order = 0)] public double Temperature { get; set; } [DataMember(Order = 1)] public int SpeedRpm { get; set; } [DataMember(Order = 2)] public uint FaultCode { get; set; } }

避坑:ExtensionObject.DecodeBody()需传入_session.TypeTree(类型树),它在Session创建后自动加载;若报BadTypeNotFound,说明PLC未发布该Structure类型,需在TIA Portal中勾选“发布类型”。

5.3 添加微信/邮件告警:当温度

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询