C#开发OPC UA客户端:工业数据采集实战指南
1. OPC UA客户端开发基础与环境搭建
在工业自动化领域,OPC UA(开放平台通信统一架构)已经成为设备间数据交换的事实标准协议。作为.NET开发者,使用C#构建OPC UA客户端能够快速实现与各类工业设备的通信集成。这个项目展示了一个完整的OPC UA客户端实现,结合了Entity Framework 6和SQLite数据库,为工业数据采集场景提供了可靠的技术方案。
1.1 开发环境准备
要运行这个项目,你需要准备以下环境:
- Visual Studio 2019或更高版本(社区版即可)
- .NET Framework 4.7.2或.NET Core 3.1+
- OPC UA核心组件(通过NuGet安装)
- SQLite数据库引擎
首先在Visual Studio中创建新的Windows Forms或WPF应用程序项目。这个选择取决于你需要构建的客户端类型——WinForm适合快速开发工具类应用,而WPF则更适合需要复杂UI的数据可视化场景。
提示:虽然项目使用EF6,但建议新项目考虑EF Core,除非你有必须使用EF6的遗留系统集成需求。
1.2 核心NuGet包引用
通过NuGet包管理器安装以下关键依赖:
Install-Package OPCFoundation.NetStandard.Opc.Ua Install-Package OPCFoundation.NetStandard.Opc.Ua.Client Install-Package EntityFramework Install-Package System.Data.SQLite Install-Package System.Data.SQLite.EF6这些包将提供OPC UA通信的基础功能以及EF6与SQLite的集成支持。特别要注意System.Data.SQLite.EF6这个包,它包含了SQLite的EF6提供程序,是项目能正常运行的关键。
1.3 解决方案结构设计
良好的项目结构能显著提高代码可维护性。建议采用以下分层结构:
OPCUAClient/ ├── OPCUAClient.Core/ # 核心逻辑层 │ ├── Services/ # 服务接口与实现 │ ├── Models/ # 数据模型 │ └── Repositories/ # 数据访问层 ├── OPCUAClient.Forms/ # WinForm UI层 └── OPCUAClient.Tests/ # 单元测试这种结构将业务逻辑与UI分离,便于后续扩展和维护。核心的OPC UA通信逻辑应该放在Core项目的Services文件夹中。
2. OPC UA客户端核心实现
2.1 建立OPC UA连接
OPC UA通信的核心是ApplicationConfiguration和Session。首先需要配置客户端证书和安全策略:
public async Task<Session> ConnectAsync(string serverUrl) { var applicationConfiguration = new ApplicationConfiguration { ApplicationName = "OPCUA客户端", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { ApplicationCertificate = new CertificateIdentifier { StoreType = "Directory" }, TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory" }, RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory" }, AutoAcceptUntrustedCertificates = true // 生产环境应设为false }, TransportConfigurations = new TransportConfigurationCollection(), TransportQuotas = new TransportQuotas { OperationTimeout = 60000 }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 } }; await applicationConfiguration.Validate(ApplicationType.Client); var endpointDescription = CoreClientUtils.SelectEndpoint(serverUrl, false); var endpointConfiguration = EndpointConfiguration.Create(applicationConfiguration); var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration); var session = await Session.Create( applicationConfiguration, endpoint, false, false, applicationConfiguration.ApplicationName, 60000, new UserIdentity(), null); return session; }这段代码展示了如何创建一个安全的OPC UA会话。注意AutoAcceptUntrustedCertificates在生产环境中应该设置为false,并实现适当的证书验证逻辑。
2.2 节点浏览与订阅
连接建立后,通常需要浏览服务器地址空间并订阅感兴趣的节点:
public void BrowseNodes(Session session, NodeId startingNode, TreeView treeView) { var browser = new Browser(session) { BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, NodeClassMask = (int)(NodeClass.Object | NodeClass.Variable), ContinueUntilDone = true }; var references = browser.Browse(startingNode); foreach (var reference in references) { var node = new TreeNode(reference.DisplayName.Text) { Tag = reference.NodeId }; treeView.Nodes.Add(node); BrowseChildNodes(session, reference.NodeId, node); } } private void BrowseChildNodes(Session session, NodeId parentNodeId, TreeNode parentTreeNode) { // 递归浏览子节点 // 实现类似BrowseNodes的逻辑 }浏览功能通常以树形结构展示,方便用户导航复杂的地址空间。对于需要实时监控的节点,应该创建订阅:
public Subscription CreateSubscription(Session session, int publishingInterval = 1000) { var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = publishingInterval, Priority = 100, DisplayName = "Subscription1", PublishingEnabled = true }; session.AddSubscription(subscription); subscription.Create(); return subscription; } public MonitoredItem AddMonitoredItem(Subscription subscription, NodeId nodeId, string displayName) { var item = new MonitoredItem(subscription.DefaultItem) { StartNodeId = nodeId, AttributeId = Attributes.Value, DisplayName = displayName, SamplingInterval = 100, QueueSize = 0, DiscardOldest = true }; item.Notification += OnNotification; subscription.AddItem(item); subscription.ApplyChanges(); return item; } private void OnNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e) { foreach (var value in item.DequeueValues()) { Console.WriteLine($"{item.DisplayName}: {value.Value}, {value.SourceTimestamp}"); // 这里可以添加数据存储逻辑 } }3. 数据持久化与EF6集成
3.1 SQLite数据库配置
Entity Framework 6与SQLite的集成需要特别注意配置。首先创建DbContext:
public class OPCUADbContext : DbContext { public DbSet<OPCNodeValue> NodeValues { get; set; } public DbSet<OPCNode> Nodes { get; set; } public OPCUADbContext() : base("name=OPCUADatabase") { Database.SetInitializer(new CreateDatabaseIfNotExists<OPCUADbContext>()); } protected override void OnModelCreating(DbModelBuilder modelBuilder) { modelBuilder.Entity<OPCNodeValue>() .HasRequired(v => v.Node) .WithMany(n => n.Values) .HasForeignKey(v => v.NodeId); base.OnModelCreating(modelBuilder); } }然后在App.config或Web.config中添加连接字符串配置:
<connectionStrings> <add name="OPCUADatabase" providerName="System.Data.SQLite.EF6" connectionString="Data Source=|DataDirectory|\OPCUAData.sqlite;Version=3;"/> </connectionStrings>3.2 数据模型设计
对于OPC UA数据采集,通常需要两个主要实体:
public class OPCNode { public int Id { get; set; } public string NodeId { get; set; } public string DisplayName { get; set; } public string NodePath { get; set; } public DateTime CreatedAt { get; set; } = DateTime.Now; public virtual ICollection<OPCNodeValue> Values { get; set; } } public class OPCNodeValue { public int Id { get; set; } public int NodeId { get; set; } public string Value { get; set; } public DateTime Timestamp { get; set; } public byte StatusCode { get; set; } public virtual OPCNode Node { get; set; } }这种设计允许我们存储节点的元信息以及随时间变化的值记录。StatusCode字段存储OPC UA的状态码,便于后续数据分析。
3.3 数据访问层实现
创建Repository类来封装数据访问逻辑:
public class OPCNodeRepository { private readonly OPCUADbContext _context; public OPCNodeRepository(OPCUADbContext context) { _context = context; } public async Task<OPCNode> AddOrUpdateNodeAsync(string nodeId, string displayName, string nodePath) { var node = await _context.Nodes.FirstOrDefaultAsync(n => n.NodeId == nodeId); if (node == null) { node = new OPCNode { NodeId = nodeId, DisplayName = displayName, NodePath = nodePath }; _context.Nodes.Add(node); } else { node.DisplayName = displayName; node.NodePath = nodePath; } await _context.SaveChangesAsync(); return node; } public async Task AddValueAsync(int nodeId, string value, byte statusCode) { var nodeValue = new OPCNodeValue { NodeId = nodeId, Value = value, StatusCode = statusCode, Timestamp = DateTime.Now }; _context.NodeValues.Add(nodeValue); await _context.SaveChangesAsync(); } public async Task<List<OPCNodeValue>> GetValuesAsync(int nodeId, DateTime start, DateTime end) { return await _context.NodeValues .Where(v => v.NodeId == nodeId && v.Timestamp >= start && v.Timestamp <= end) .OrderBy(v => v.Timestamp) .ToListAsync(); } }4. 完整客户端实现与优化
4.1 将各部分集成到UI
在WinForm或WPF中,我们需要将OPC UA通信、数据存储和用户界面整合起来。以下是一个简单的WinForm实现示例:
public partial class MainForm : Form { private Session _session; private Subscription _subscription; private OPCUADbContext _dbContext; private OPCNodeRepository _nodeRepository; public MainForm() { InitializeComponent(); _dbContext = new OPCUADbContext(); _nodeRepository = new OPCNodeRepository(_dbContext); } private async void btnConnect_Click(object sender, EventArgs e) { var serverUrl = txtServerUrl.Text; try { var opcService = new OPCAccessService(); _session = await opcService.ConnectAsync(serverUrl); _subscription = opcService.CreateSubscription(_session); opcService.BrowseNodes(_session, ObjectIds.ObjectsFolder, treeNodes); lblStatus.Text = $"已连接到 {serverUrl}"; } catch (Exception ex) { MessageBox.Show($"连接失败: {ex.Message}"); } } private void treeNodes_AfterSelect(object sender, TreeViewEventArgs e) { var nodeId = e.Node.Tag as NodeId; if (nodeId == null) return; var opcService = new OPCAccessService(); opcService.AddMonitoredItem(_subscription, nodeId, e.Node.Text); // 在UI线程上更新数据网格 Task.Run(async () => { var node = await _nodeRepository.AddOrUpdateNodeAsync( nodeId.ToString(), e.Node.Text, GetNodePath(e.Node)); var values = await _nodeRepository.GetValuesAsync( node.Id, DateTime.Now.AddHours(-1), DateTime.Now); this.Invoke((MethodInvoker)delegate { dataGridView1.DataSource = values; }); }); } private string GetNodePath(TreeNode node) { var path = new List<string>(); while (node != null) { path.Insert(0, node.Text); node = node.Parent; } return string.Join("/", path); } }4.2 性能优化与错误处理
在实际工业环境中,性能和数据完整性至关重要。以下是几个关键优化点:
- 批量插入优化:
public async Task AddValuesBatchAsync(IEnumerable<(int nodeId, string value, byte statusCode)> values) { var entities = values.Select(v => new OPCNodeValue { NodeId = v.nodeId, Value = v.value, StatusCode = v.statusCode, Timestamp = DateTime.Now }).ToList(); _context.Configuration.AutoDetectChangesEnabled = false; _context.NodeValues.AddRange(entities); await _context.SaveChangesAsync(); _context.Configuration.AutoDetectChangesEnabled = true; }- 连接重试机制:
public async Task<Session> ConnectWithRetryAsync(string serverUrl, int maxRetries = 3) { int retryCount = 0; while (retryCount < maxRetries) { try { return await ConnectAsync(serverUrl); } catch (Exception ex) { retryCount++; if (retryCount >= maxRetries) throw; await Task.Delay(1000 * retryCount); } } return null; }- 订阅管理优化:
private readonly ConcurrentDictionary<string, MonitoredItem> _monitoredItems = new ConcurrentDictionary<string, MonitoredItem>(); public bool TryAddMonitoredItem(NodeId nodeId, string displayName, out MonitoredItem monitoredItem) { var key = nodeId.ToString(); if (_monitoredItems.ContainsKey(key)) { monitoredItem = null; return false; } monitoredItem = new MonitoredItem(_subscription.DefaultItem) { StartNodeId = nodeId, AttributeId = Attributes.Value, DisplayName = displayName, SamplingInterval = 100, QueueSize = 0, DiscardOldest = true }; monitoredItem.Notification += OnNotification; _subscription.AddItem(monitoredItem); _subscription.ApplyChanges(); return _monitoredItems.TryAdd(key, monitoredItem); }4.3 日志记录与诊断
完善的日志系统对生产环境至关重要:
public static class Logger { private static readonly string LogPath = Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "Logs", $"OPCUAClient_{DateTime.Now:yyyyMMdd}.log"); public static void Log(string message, LogLevel level = LogLevel.Info) { try { Directory.CreateDirectory(Path.GetDirectoryName(LogPath)); File.AppendAllText(LogPath, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [{level}] {message}{Environment.NewLine}"); } catch { /* 防止日志记录本身引发异常 */ } } public static void LogException(Exception ex, string context = null) { var message = new StringBuilder(); if (!string.IsNullOrEmpty(context)) message.AppendLine($"Context: {context}"); message.AppendLine($"Exception: {ex.GetType().Name}"); message.AppendLine($"Message: {ex.Message}"); message.AppendLine($"Stack Trace: {ex.StackTrace}"); if (ex.InnerException != null) { message.AppendLine("Inner Exception:"); message.AppendLine($" {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); } Log(message.ToString(), LogLevel.Error); } } public enum LogLevel { Debug, Info, Warning, Error }在OPC UA客户端的关键位置添加日志记录:
private void OnNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e) { try { foreach (var value in item.DequeueValues()) { Logger.Log($"收到数据: {item.DisplayName}={value.Value}"); // 存储到数据库 Task.Run(async () => { try { var node = await _nodeRepository.AddOrUpdateNodeAsync( item.StartNodeId.ToString(), item.DisplayName, item.ResolvedNodeId.ToString()); await _nodeRepository.AddValueAsync( node.Id, value.Value.ToString(), value.StatusCode.Code); } catch (Exception ex) { Logger.LogException(ex, "存储OPC UA数据时出错"); } }); } } catch (Exception ex) { Logger.LogException(ex, "处理OPC UA通知时出错"); } }5. 高级功能与扩展
5.1 历史数据读取
除了实时订阅,OPC UA还支持历史数据读取:
public async Task<List<DataValue>> ReadHistoryRawAsync( Session session, NodeId nodeId, DateTime start, DateTime end, uint numValues = 0, bool returnBounds = false) { var historyReadDetails = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = numValues, IsReadModified = false, ReturnBounds = returnBounds }; var nodesToRead = new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = nodeId, IndexRange = null, DataEncoding = null } }; var response = await session.HistoryReadAsync( null, new ExtensionObject(historyReadDetails), TimestampsToReturn.Both, false, nodesToRead); if (StatusCode.IsBad(response.Results[0].StatusCode)) throw new ServiceResultException(response.Results[0].StatusCode); var result = ExtensionObject.ToEncodeable( response.Results[0].HistoryData) as HistoryData; return result?.DataValues?.ToList() ?? new List<DataValue>(); }5.2 报警与事件处理
OPC UA的报警和事件功能对于监控系统状态非常重要:
public Subscription CreateEventSubscription(Session session) { var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 1000, Priority = 100, DisplayName = "EventSubscription", PublishingEnabled = true }; var monitoredItem = new MonitoredItem(subscription.DefaultItem) { StartNodeId = ObjectIds.Server, AttributeId = Attributes.EventNotifier, DisplayName = "ServerEvents", SamplingInterval = 0, QueueSize = 0, DiscardOldest = true, Filter = new EventFilter() }; // 配置事件过滤器 var filter = (EventFilter)monitoredItem.Filter; filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceName); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Time); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Severity); monitoredItem.Notification += OnEventNotification; subscription.AddItem(monitoredItem); session.AddSubscription(subscription); subscription.Create(); return subscription; } private void OnEventNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e) { foreach (var value in item.DequeueValues()) { if (value.Value is EventFieldList eventFields) { var eventType = eventFields.EventFields[1].Value as NodeId; var sourceName = eventFields.EventFields[3].Value as string; var message = eventFields.EventFields[5].Value as LocalizedText; var severity = (ushort)eventFields.EventFields[6].Value; Logger.Log($"收到事件: {sourceName} - {message} (严重性: {severity})", severity > 500 ? LogLevel.Error : LogLevel.Warning); // 更新UI或触发其他处理逻辑 } } }5.3 跨线程UI更新
在WinForm中,跨线程更新UI需要特别注意:
private void UpdateNodeValueInUI(string nodeName, object value, byte statusCode) { if (this.InvokeRequired) { this.Invoke(new Action<string, object, byte>(UpdateNodeValueInUI), nodeName, value, statusCode); return; } // 查找或创建ListViewItem ListViewItem item = lstValues.Items[nodeName]; if (item == null) { item = new ListViewItem(nodeName); item.SubItems.Add(value?.ToString() ?? "null"); item.SubItems.Add(statusCode.ToString()); item.SubItems.Add(DateTime.Now.ToString("HH:mm:ss.fff")); lstValues.Items.Add(item); } else { item.SubItems[1].Text = value?.ToString() ?? "null"; item.SubItems[2].Text = statusCode.ToString(); item.SubItems[3].Text = DateTime.Now.ToString("HH:mm:ss.fff"); } // 自动滚动到最后 item.EnsureVisible(); }5.4 配置持久化
使用Settings.settings或自定义配置文件保存应用设置:
public static class AppSettings { private static readonly string ConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OPCUAClient", "config.json"); public static ClientConfig Load() { try { if (File.Exists(ConfigPath)) { var json = File.ReadAllText(ConfigPath); return JsonConvert.DeserializeObject<ClientConfig>(json); } } catch (Exception ex) { Logger.LogException(ex, "加载配置时出错"); } return new ClientConfig(); } public static void Save(ClientConfig config) { try { Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)); var json = JsonConvert.SerializeObject(config, Formatting.Indented); File.WriteAllText(ConfigPath, json); } catch (Exception ex) { Logger.LogException(ex, "保存配置时出错"); } } } public class ClientConfig { public string LastServerUrl { get; set; } public List<MonitoredNodeConfig> MonitoredNodes { get; set; } = new List<MonitoredNodeConfig>(); public WindowSettings WindowSettings { get; set; } = new WindowSettings(); } public class MonitoredNodeConfig { public string NodeId { get; set; } public string DisplayName { get; set; } } public class WindowSettings { public int Width { get; set; } = 800; public int Height { get; set; } = 600; public int X { get; set; } public int Y { get; set; } public FormWindowState WindowState { get; set; } = FormWindowState.Normal; }6. 部署与维护
6.1 应用程序打包
使用ClickOnce或安装项目打包应用程序:
ClickOnce发布:
- 在Visual Studio中右键项目 → 发布
- 选择发布位置(文件系统、FTP等)
- 设置更新选项(应用程序启动时检查更新)
- 发布版本自动递增
安装项目:
- 添加新项目 → 安装程序项目
- 添加主项目输出
- 添加快捷方式和注册表项(如需要)
- 设置安装条件(如.NET Framework版本)
6.2 数据库迁移策略
随着应用演进,数据库结构可能需要变更。EF6支持迁移功能:
# 启用迁移(首次) Enable-Migrations -ContextTypeName OPCUADbContext # 创建新迁移 Add-Migration "AddNodeDescriptionField" # 更新数据库 Update-Database对于生产环境,考虑以下策略:
- 测试环境验证所有迁移
- 备份生产数据库后再应用迁移
- 考虑使用迁移脚本而非自动迁移,以便更精细控制
6.3 性能监控与调优
实现简单的性能计数器:
public class PerformanceMonitor { private readonly Stopwatch _stopwatch = new Stopwatch(); private long _totalMessages; private long _totalDatabaseWrites; public void MessageReceived() { Interlocked.Increment(ref _totalMessages); } public void DatabaseWriteCompleted(long milliseconds) { Interlocked.Increment(ref _totalDatabaseWrites); _stopwatch.Restart(); } public PerformanceStats GetStats() { return new PerformanceStats { TotalMessages = _totalMessages, TotalDatabaseWrites = _totalDatabaseWrites, Uptime = _stopwatch.Elapsed }; } } public class PerformanceStats { public long TotalMessages { get; set; } public long TotalDatabaseWrites { get; set; } public TimeSpan Uptime { get; set; } public double MessagesPerSecond => Uptime.TotalSeconds > 0 ? TotalMessages / Uptime.TotalSeconds : 0; public double WritesPerSecond => Uptime.TotalSeconds > 0 ? TotalDatabaseWrites / Uptime.TotalSeconds : 0; }在UI中定期更新性能显示:
private async void UpdatePerformanceStats() { while (!_cancellationTokenSource.IsCancellationRequested) { var stats = _performanceMonitor.GetStats(); this.Invoke((MethodInvoker)delegate { lblMessages.Text = $"消息: {stats.TotalMessages} ({stats.MessagesPerSecond:F2}/s)"; lblWrites.Text = $"写入: {stats.TotalDatabaseWrites} ({stats.WritesPerSecond:F2}/s)"; lblUptime.Text = $"运行时间: {stats.Uptime:hh\\:mm\\:ss}"; }); await Task.Delay(1000); } }6.4 异常处理策略
全局异常处理确保应用稳定性:
// 在Program.cs中 static class Program { [STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); Application.ThreadException += Application_ThreadException; AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; var mainForm = new MainForm(); Application.Run(mainForm); } private static void Application_ThreadException(object sender, ThreadExceptionEventArgs e) { HandleException(e.Exception); } private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) { HandleException(e.ExceptionObject as Exception); } private static void HandleException(Exception ex) { Logger.LogException(ex, "未处理异常"); MessageBox.Show($"发生未处理异常: {ex.Message}\n\n详细信息已记录到日志文件。", "应用程序错误", MessageBoxButtons.OK, MessageBoxIcon.Error); // 可以选择退出应用或尝试恢复 // Application.Exit(); } }7. 项目扩展与进阶方向
7.1 支持更多数据库后端
虽然项目使用SQLite,但可以扩展支持其他数据库:
- SQL Server支持:
public class OPCUASqlServerDbContext : DbContext { public OPCUASqlServerDbContext() : base("name=SqlServerConnection") { Database.SetInitializer(new MigrateDatabaseToLatestVersion<OPCUASqlServerDbContext, Configuration>()); } protected override void OnModelCreating(DbModelBuilder modelBuilder) { // 模型配置 } }- 配置连接字符串:
<connectionStrings> <add name="SqlServerConnection" providerName="System.Data.SqlClient" connectionString="Server=.;Database=OPCUAData;Integrated Security=True;"/> </connectionStrings>7.2 添加REST API接口
使用ASP.NET Core创建数据访问API:
[Route("api/[controller]")] [ApiController] public class OPCDataController : ControllerBase { private readonly OPCUADbContext _context; public OPCDataController(OPCUADbContext context) { _context = context; } [HttpGet("nodes")] public async Task<IActionResult> GetNodes() { var nodes = await _context.Nodes .Include(n => n.Values) .ToListAsync(); return Ok(nodes); } [HttpGet("values/{nodeId}")] public async Task<IActionResult> GetValues(int nodeId, [FromQuery] DateTime? start, [FromQuery] DateTime? end) { var query = _context.NodeValues .Where(v => v.NodeId == nodeId); if (start.HasValue) query = query.Where(v => v.Timestamp >= start.Value); if (end.HasValue) query = query.Where(v => v.Timestamp <= end.Value); var values = await query .OrderBy(v => v.Timestamp) .ToListAsync(); return Ok(values); } }7.3 实现数据可视化
使用第三方图表库如LiveCharts或OxyPlot:
public void SetupChart(List<OPCNodeValue> values) { var series = new LineSeries { Title = "值变化", Values = new ChartValues<double>(values.Select(v => double.Parse(v.Value))), PointGeometry = DefaultGeometries.Circle, PointGeometrySize = 5 }; var dateTimes = values.Select(v => v.Timestamp).ToList(); cartesianChart1.Series = new SeriesCollection { series }; cartesianChart1.AxisX.Add(new Axis { Title = "时间", Labels = dateTimes.Select(dt => dt.ToString("HH:mm:ss")).ToList(), Separator = new Separator { Step = Math.Max(1, dateTimes.Count / 10) } }); cartesianChart1.AxisY.Add(new Axis { Title = "值", LabelFormatter = value => value.ToString("N2") }); }7.4 添加用户认证与授权
扩展应用支持多用户:
- 用户模型:
public class User { public int Id { get; set; } public string Username { get; set; } public string PasswordHash { get; set; } public string Role { get; set; } public DateTime CreatedAt { get; set; } = DateTime.Now; }- 登录服务:
public class AuthService { private readonly OPCUADbContext _context; public AuthService(OPCUADbContext context) { _context = context; } public async Task<User> Authenticate(string username, string password) { var user = await _context.Users .FirstOrDefaultAsync(u => u.Username == username); if (user == null || !VerifyPassword(password, user.PasswordHash)) return null; return user; } private bool VerifyPassword(string password, string storedHash) { // 使用PBKDF2或其他安全算法验证密码 return BCrypt.Net.BCrypt.Verify(password, storedHash); } }- 保护OPC UA连接:
private async void btnConnect_Click(object sender, EventArgs e) { if (_currentUser == null || !_currentUser.Roles.Contains("OPCUser")) { MessageBox.Show("无权访问OPC UA功能"); return; } // 原有连接逻辑 }7.5 容器化部署
创建Dockerfile支持容器部署:
FROM mcr.microsoft.com/dotnet/framework/runtime:4.8-windowsservercore-ltsc2019 WORKDIR /app COPY ./publish . ENV OPCUA_SERVER_URL="opc.tcp://localhost:4840" ENV SQLITE_DB_PATH="/data/OPCUAData.sqlite" VOLUME /data ENTRYPOINT ["OPCUAClient.exe"]构建并运行:
docker build -t opcuaclient . docker run -v C:\OPCUAData:/data -e OPCUA_SERVER_URL="opc.tcp://opcserver:4840" opcuaclient8. 常见问题与解决方案
8.1 连接问题排查
问题:无法连接到OPC UA服务器
排查步骤:
- 验证服务器URL格式正确(opc.tcp://hostname:port)
- 检查网络连接(ping/telnet测试端口)
- 验证服务器证书是否被信任
- 检查服务器日志了解拒绝原因
- 尝试关闭安全策略(仅测试环境)
常见错误:
BadCertificateUntrusted:客户端证书不被服务器信任BadCertificateHostNameInvalid:证书主机名不匹配BadCertificateTimeInvalid:证书过期或时间不同步
8.2 数据订阅不稳定
问题:订阅数据时断时续
解决方案:
- 增加会话超时时间:
var session = await Session.Create( applicationConfiguration, endpoint, false, false, applicationConfiguration.ApplicationName, 120000, // 2分钟超时 new UserIdentity(), null);- 实现自动重连:
private async Task EnsureConnected() { if (_session == null || !_session.Connected) { try { _session = await _opcService.ConnectWithRetryAsync(_serverUrl); RestoreSubscriptions(); } catch (Exception ex) { Logger.LogException(ex, "重新连接失败"); throw; } } } private void RestoreSubscriptions() { foreach (var nodeConfig in _monitoredNodes) { var item = _opcService.AddMonitoredItem(_subscription, new NodeId(nodeConfig.NodeId), nodeConfig.DisplayName); item.Notification += OnNotification; } }8.3 SQLite性能优化
问题:大量数据写入时性能下降
优化方案:
- 使用事务批量写入:
public async Task AddValuesBatchAsync(IEnumerable<OPCNodeValue> values) { using (var transaction = _context.Database.BeginTransaction()) { try { _context.Configuration.AutoDetectChangesEnabled = false; _context.NodeValues.AddRange(values); await _context.SaveChangesAsync(); transaction.Commit(); } catch { transaction.Rollback(); throw; } finally { _context.Configuration.AutoDetectChangesEnabled = true; } } }- 定期执行
PRAGMA optimize:
public void OptimizeDatabase() { _context.Database.ExecuteSqlCommand("PRAGMA optimize"); }- 调整SQLite页面大小和缓存:
var