ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Proficy Historian C# SDK集成实战:实时历史数据桥接与工业SCADA嵌入

Proficy Historian C# SDK集成实战:实时历史数据桥接与工业SCADA嵌入

简介:本资源是一个面向工业自动化领域C#开发者的Proficy Historian二次开发入门示例,专为希望快速掌握GE Digital历史数据平台API集成能力的工程师设计。项目完整封装了ClientAccessAPI核心库调用逻辑,涵盖历史数据查询、实时值写入、报警事件处理等关键功能,适用于制造业数据采集系统、能源监控仪表板及过程控制定制化开发等实际场景。压缩包共18个文件,以8个C#源码(含MainWindow.xaml.cs、App.xaml.cs等主程序逻辑)、2个XAML界面定义、2个RESX本地化资源及1个SLN解决方案文件为主体,辅以CSProj工程配置、APP.CONFIG运行参数和Readme说明文档,结构清晰、开箱即用,整体仅18KB,轻量易学。目前已有562人学习下载,读者可直接复用API调用模板、理解Historian数据模型与C#异步通信机制,并基于示例快速构建自有数据接入模块。

1. 这不是个“调API”的Demo,而是一套能嵌进产线SCADA系统的实时历史数据桥接骨架

某工厂的DCS系统每秒产生上万点位数据,但Historian里查不到最近3分钟的温度曲线——不是Historian坏了,是上层MES系统调用Historian API时用了同步阻塞式轮询,单次请求卡死2秒,重试逻辑又没做熔断,整个数据通道直接雪崩。这个proficy Historian API demo就是为这种场景写的:它不教你怎么连数据库,而是实打实跑通一条从C#进程发起、经Historian SDK封装、走OPC UA或SQL Server协议、最终拿到带时间戳原始采样值的端到端可部署链路。项目包含完整VS解决方案(.sln)、已预编译的Historian Client SDK引用、带超时控制和异常重试的异步查询封装、以及一个能直接拖进WinForms主窗体复用的数据绑定控件。适合正在做HMI升级、需要把Historian数据喂给自研报表模块、或要对接第三方分析平台的工业软件开发者——你不用再对着GE官方文档里那个只有5行代码的GetValues()示例抓耳挠腮,这里每一行都经过现场PLC点位真实压测。


2. 环境准备与依赖注入:Historian Client SDK不是NuGet包,得手动“种”进项目

2.1 必须安装的Historian运行时组件(非可选)

Historian API不是纯托管库,它底层强依赖GE Digital提供的原生客户端运行时。在目标机器上必须先安装Proficy Historian Client Runtime v2022.1+(注意:不是Server端,也不是旧版v7.x)。这个安装包不提供在线下载链接,需从Historian Server管理台导出,或向授权经销商索取。安装后会在C:\Program Files\GE Digital\Proficy Historian\Client下生成HistorianClient.dll和HistorianClient64.dll。关键点:32位应用必须引用前者,64位应用必须引用后者——VS里若设为AnyCPU且未勾选Prefer 32-bit,运行时会因DLL加载失败直接抛BadImageFormatException,错误日志里只显示“无法加载文件或程序集”,非常玄学。

2.2 Visual Studio项目配置三步法

  1. 引用路径硬编码:右键项目 → “添加引用” → “浏览” → 定位到上述安装目录下的HistorianClient.dll。不要用“COM组件”或“类型库”方式导入,Historian SDK是.NET Framework 4.0+兼容的托管封装,COM方式会丢失泛型方法签名。
  2. 复制本地设置:在引用属性中将Copy Local设为True,Specific Version设为False。Historian Client SDK版本号常随Server升级变动(如v2022.1→v2022.2),硬绑版本号会导致部署失败。
  3. 平台目标锁定:项目属性 → “生成” → “目标平台”明确选x64(推荐)或x86。Historian Server默认启用64位服务,若客户端用x86去连,即使Server开了32位兼容模式,部分高级查询(如GetSampledValues带插值参数)会静默返回空结果。
<!-- 在.csproj文件中确认以下配置存在 --> <PropertyGroup> <PlatformTarget>x64</PlatformTarget> <TargetFramework>net472</TargetFramework> </PropertyGroup> <ItemGroup> <Reference Include="HistorianClient"> <HintPath>C:\Program Files\GE Digital\Proficy Historian\Client\HistorianClient.dll</HintPath> <Private>true</Private> </Reference> </ItemGroup>

提示:HistorianClient.dll本身不依赖.NET Core,但若你的主程序是.NET 6+,需额外安装.NET Framework 4.7.2运行时。Windows Server 2019默认不带,必须手动启用“NET Framework 4.7 Features”。

2.3 连接字符串构造:不是标准ADO.NET格式,而是Historian专属语法

Historian连接字符串不是Server=xxx;Database=xxx,而是形如:
Data Source=HISTORIAN-SERVER;Initial Catalog=MyHistorianDB;Integrated Security=True;Connect Timeout=15;
其中:

  • Data Source:Historian Server主机名或IP,必须能被客户端DNS解析(不能填localhost,除非Server装在同一台机器)
  • Initial Catalog:Historian中定义的数据源名称(DataSource Name),不是SQL Server数据库名。该名称在Historian Administrator工具的“Data Sources”节点下配置,常见值如DefaultDataSource或ProductionDB
  • Integrated Security=True:强制使用Windows域账户认证。Historian不支持SQL Server账号密码直连,若客户端不在域内,需提前在Server端配置允许的本地账户映射
// 正确写法:用HistorianConnection类而非SqlConnection string connectionString = "Data Source=HISTORIAN-SERVER;Initial Catalog=DefaultDataSource;Integrated Security=True;Connect Timeout=15;"; using (var conn = new HistorianConnection(connectionString)) { conn.Open(); // 此处会触发对Historian Server的TCP握手(默认端口5151) Console.WriteLine($"Connected to Historian: {conn.ServerVersion}"); }

注意:HistorianConnection类位于GE.Historian.Client命名空间,不是System.Data.SqlClient。若引用了错误的dll,编译能过,但运行时conn.Open()会抛TypeLoadException,提示找不到类型GE.Historian.Client.HistorianConnection。


3. 核心模块拆解:一个查询从发起、执行到绑定UI的完整链路

3.1 历史数据查询器:HistorianQueryBuilder封装了最易踩坑的参数组合

Historian原生API中,GetValues()方法有12个重载,但生产环境真正可用的只有带DateTimeRange和TagList的异步版本。本Demo将其封装为HistorianQueryBuilder类,核心逻辑如下:

public class HistorianQueryBuilder { private readonly string _connectionString; public HistorianQueryBuilder(string connectionString) => _connectionString = connectionString; // 关键:TimeRange必须用Historian专用的DateTimeRange结构,不能传DateTime[] public async Task<List<HistorianValue>> QueryAsync( List<string> tagNames, DateTimeRange timeRange, int maxPointsPerTag = 1000) { using var conn = new HistorianConnection(_connectionString); await conn.OpenAsync(); // 异步打开连接,避免UI线程阻塞 var query = new HistorianQuery(conn); // 设置超时:Historian Server可能因负载高延迟响应,必须设硬超时 query.CommandTimeout = TimeSpan.FromSeconds(30); // 构造查询条件:Historian要求所有tag必须存在于同一DataSource下 var criteria = new HistorianCriteria { TagNames = tagNames.ToArray(), // 转为string[],Historian SDK不接受List<string> TimeRange = timeRange, MaxPointsPerTag = maxPointsPerTag, // 关键避坑:必须显式指定采样模式,否则默认返回"Raw",但很多点位没存Raw数据! SampleMode = SampleMode.Interpolated, InterpolationInterval = TimeSpan.FromMinutes(1) // 插值间隔,单位是TimeSpan }; // 执行查询:返回HistorianValue数组,每个元素含TagName、Timestamp、Value、Quality var results = await query.GetValuesAsync(criteria); return results.ToList(); } }

参数说明:

  • DateTimeRange:必须用new DateTimeRange(startDate, endDate)构造,不能用new TimeSpan()或DateTime.Now.AddHours(-1)直接拼——Historian Server内部会校验时间范围是否合法(endDate > startDate,且跨度不超过Historian配置的最大查询窗口,默认7天)
  • SampleMode.Interpolated:生产环境首选。Historian存储的是压缩后的“事件变化点”,直接取Raw会漏掉大量中间值;插值模式由Server端计算,保证曲线连续
  • InterpolationInterval:插值粒度,设为1 minute表示每分钟一个点。若设为1 second,Server可能因计算量过大直接拒绝请求(HTTP 500或超时)

3.2 数据绑定控件:HistorianDataGrid直接消费HistorianValue列表

Demo提供HistorianDataGrid继承自DataGridView,其LoadDataAsync()方法自动完成:

  • 将List<HistorianValue>按TagName分组
  • 为每个TagName创建一列(列标题=TagName)
  • 按Timestamp升序排列所有点位,填充行数据
  • 对Quality字段做颜色标记(Good=绿色,Bad=红色,Uncertain=黄色)
public partial class HistorianDataGrid : DataGridView { public async Task LoadDataAsync(List<HistorianValue> values) { // 清空现有列和行 this.Columns.Clear(); this.Rows.Clear(); // 按TagName分组,确保同名点位数据合并到同一列 var grouped = values.GroupBy(v => v.TagName).ToList(); // 为每个TagName添加列 foreach (var group in grouped) { var col = new DataGridViewTextBoxColumn { Name = group.Key, HeaderText = group.Key, Width = 120, SortMode = DataGridViewColumnSortMode.NotSortable }; this.Columns.Add(col); } // 获取所有唯一Timestamp(作为行索引) var timestamps = values.Select(v => v.Timestamp).OrderBy(t => t).Distinct().ToList(); // 填充数据:遍历每个时间点,查找各TagName在此刻的值 foreach (var ts in timestamps) { var row = new string[grouped.Count]; for (int i = 0; i < grouped.Count; i++) { var value = grouped[i].FirstOrDefault(v => v.Timestamp == ts); row[i] = value?.Value?.ToString() ?? "N/A"; // 关键:设置单元格样式反映数据质量 if (value?.Quality == Quality.Good) this[0, this.Rows.Count - 1].Style.BackColor = Color.LightGreen; else if (value?.Quality == Quality.Bad) this[0, this.Rows.Count - 1].Style.BackColor = Color.Salmon; } this.Rows.Add(row); } } }

提示:HistorianValue.Value是object类型,实际可能是double、int、string或bool。Demo中统一转ToString(),但二次开发时建议用Convert.ToDouble(value.Value)并捕获InvalidCastException——某些点位存的是状态码字符串(如"RUNNING"),强转double会崩溃。

3.3 实时订阅模块:HistorianRealTimeSubscriber实现毫秒级数据推送

Historian原生支持“Change Subscription”,但SDK文档极少提及。Demo中HistorianRealTimeSubscriber类通过HistorianSubscription对象监听点位变化:

public class HistorianRealTimeSubscriber { private readonly HistorianConnection _conn; private HistorianSubscription _subscription; public HistorianRealTimeSubscriber(string connectionString) { _conn = new HistorianConnection(connectionString); } public void StartListening(List<string> tagNames, Action<HistorianValue> onValueChanged) { _subscription = new HistorianSubscription(_conn); _subscription.ValueChanged += (sender, e) => { // e.Values是HistorianValue[],每个元素代表一个变化的点位 foreach (var value in e.Values) { onValueChanged(value); // 通知UI更新 } }; // 启动订阅:Historian Server会为每个tag建立长连接 _subscription.Start(tagNames.ToArray()); } public void Stop() { _subscription?.Stop(); _subscription?.Dispose(); } }

关键约束:

  • 订阅的tag必须已在Historian中配置为“Enable Real-time Publishing”(在Historian Administrator的Tag Properties里勾选)
  • 单次订阅tag数不宜超过50个,否则Server端心跳包积压导致延迟飙升(实测>100个tag时,平均延迟从50ms升至800ms)
  • ValueChanged事件在Historian SDK线程中触发,不能直接操作WinForms控件。必须用this.Invoke()切回UI线程:
// 在WinForm中正确使用 private void OnHistorianValueChanged(HistorianValue value) { this.Invoke((MethodInvoker)delegate { // 更新label或chart lblCurrentValue.Text = $"{value.Value} ({value.Quality})"; }); }

4. 避坑指南:Historian API开发中血泪换来的5条铁律

4.1 现象:GetValuesAsync()永远不返回,Task一直处于Running状态

原因:Historian Server防火墙未开放端口5151(TCP),或客户端网络策略禁止出站连接。Historian SDK不会抛出SocketException,而是无限等待Server响应。
解决:用telnet HISTORIAN-SERVER 5151测试端口连通性;若不通,在Server所在机器的Windows防火墙中添加入站规则,开放TCP 5151端口。

4.2 现象:查询返回空列表,但Historian Administrator里能查到数据

原因:DateTimeRange的startDate和endDate精度问题。Historian Server内部用DateTimeKind.Utc存储时间,若客户端传入DateTime.Now(DateTimeKind.Local),Server会按本地时区转换,导致时间范围偏移。
解决:强制用UTC时间构造范围:

var range = new DateTimeRange(DateTime.UtcNow.AddHours(-1), DateTime.UtcNow);

4.3 现象:HistorianConnection.Open()抛UnauthorizedAccessException

原因:客户端进程未以“具有Historian访问权限的Windows账户”运行。Historian Server默认只允许域用户或明确授权的本地管理员连接。
解决:在Historian Administrator工具中,进入“Security” → “User Management”,添加客户端机器的计算机账户(格式:DOMAIN\MACHINE$)或具体用户名,并赋予Read Data权限。

4.4 现象:HistorianRealTimeSubscriber启动后,ValueChanged事件偶尔不触发

原因:Historian Server的“Real-time Buffer Size”配置过小(默认1000条),当点位变化频率过高时,缓冲区溢出导致事件丢失。
解决:在Historian Administrator中,进入“Configuration” → “System Configuration”,将RealTimeBufferSize调大至5000,并重启Historian Service。

4.5 现象:部署到客户现场后,HistorianClient.dll加载失败,报System.DllNotFoundException

原因:Historian Client Runtime安装包未包含VC++ 2015-2019运行时(vcruntime140.dll等),而HistorianClient.dll依赖这些原生库。
解决:在目标机器安装 Microsoft Visual C++ 2015-2019 Redistributable (x64版),或把vcruntime140.dll等文件随程序一起部署到exe同目录。


5. 二次开发实战:如何把Demo改造成可嵌入HMI的轻量级数据服务

5.1 改造目标:剥离WinForms UI,暴露REST API供Vue/React前端调用

Historian数据查询本质是IO密集型任务,不应阻塞Web服务器线程。Demo中HistorianQueryService类已预留扩展点:

// 新增HistorianQueryService.cs public class HistorianQueryService { private readonly string _connectionString; private readonly ILogger<HistorianQueryService> _logger; public HistorianQueryService(string connectionString, ILogger<HistorianQueryService> logger) { _connectionString = connectionString; _logger = logger; } // 关键:用IHostedService生命周期管理连接池,避免频繁Open/Close public async Task<HistorianQueryResult> QueryAsync(HistorianQueryRequest request) { try { var builder = new HistorianQueryBuilder(_connectionString); var values = await builder.QueryAsync( request.TagNames, new DateTimeRange(request.StartTime, request.EndTime), request.MaxPointsPerTag); return new HistorianQueryResult { Success = true, Data = values.GroupBy(v => v.TagName) .ToDictionary(g => g.Key, g => g.Select(v => new { v.Timestamp, v.Value, v.Quality }).ToArray()) }; } catch (Exception ex) { _logger.LogError(ex, "Historian query failed for tags {@Tags}", request.TagNames); return new HistorianQueryResult { Success = false, ErrorMessage = ex.Message }; } } } // 请求模型(供ASP.NET Core Controller接收) public class HistorianQueryRequest { public List<string> TagNames { get; set; } = new(); public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } public int MaxPointsPerTag { get; set; } = 1000; } // 响应模型 public class HistorianQueryResult { public bool Success { get; set; } public string ErrorMessage { get; set; } public Dictionary<string, object[]> Data { get; set; } }

5.2 ASP.NET Core集成:用Minimal API快速暴露端点

在Program.cs中注册服务并添加API:

var builder = WebApplication.CreateBuilder(args); // 注册Historian服务(单例,复用连接) builder.Services.AddSingleton<HistorianQueryService>(sp => new HistorianQueryService( builder.Configuration.GetConnectionString("Historian"), sp.GetRequiredService<ILogger<HistorianQueryService>>())); var app = builder.Build(); // POST /api/historian/query app.MapPost("/api/historian/query", async (HttpContext context, HistorianQueryRequest request, HistorianQueryService service) => { var result = await service.QueryAsync(request); return Results.Ok(result); }); app.Run();

配置文件appsettings.json:

{ "ConnectionStrings": { "Historian": "Data Source=HISTORIAN-SERVER;Initial Catalog=DefaultDataSource;Integrated Security=True;Connect Timeout=30;" } }

提示:Historian Server对并发连接数有限制(默认50个)。若HMI页面同时发起10个查询请求,需在HistorianQueryService中加SemaphoreSlim限流,避免连接耗尽。

5.3 生产级加固:添加查询缓存与降级策略

Historian查询慢是常态,但相同时间范围、相同tag的请求高频重复(如HMI每5秒刷一次趋势图)。Demo中CachedHistorianQueryService用内存缓存:

public class CachedHistorianQueryService { private readonly IMemoryCache _cache; private readonly HistorianQueryService _innerService; public CachedHistorianQueryService(IMemoryCache cache, HistorianQueryService innerService) { _cache = cache; _innerService = innerService; } public async Task<HistorianQueryResult> QueryAsync(HistorianQueryRequest request) { // 缓存Key:用tag列表+时间范围哈希生成 var cacheKey = $"hist:{string.Join(",", request.TagNames.OrderBy(x => x))}:{request.StartTime:yyyyMMddHHmm}:{request.EndTime:yyyyMMddHHmm}"; if (_cache.TryGetValue(cacheKey, out HistorianQueryResult cached)) return cached; var result = await _innerService.QueryAsync(request); if (result.Success) { // 缓存10秒:足够覆盖HMI刷新周期,又不至于数据过期太久 _cache.Set(cacheKey, result, TimeSpan.FromSeconds(10)); } return result; } }

注册方式(替换原服务):

builder.Services.AddMemoryCache(); // 启用内存缓存 builder.Services.AddSingleton<HistorianQueryService>(); builder.Services.AddScoped<CachedHistorianQueryService>(); // 作用域内单例 // Controller中注入CachedHistorianQueryService即可

5.4 最后一道防线:Historian不可用时的静态数据兜底

当Historian Server宕机或网络中断,HMI不能白屏。Demo提供FallbackHistorianService,从本地JSON文件读取模拟数据:

public class FallbackHistorianService { private readonly string _fallbackFilePath = "historian-fallback.json"; public HistorianQueryResult GetFallbackData(List<string> tagNames, DateTime startTime, DateTime endTime) { if (!File.Exists(_fallbackFilePath)) return new HistorianQueryResult { Success = false, ErrorMessage = "Fallback data not found" }; var fallbackData = JsonSerializer.Deserialize<FallbackDataModel>(File.ReadAllText(_fallbackFilePath)); // 模拟生成最近1小时的随机数据(正态分布,均值=50,标准差=5) var now = DateTime.UtcNow; var data = tagNames.ToDictionary( tag => tag, tag => Enumerable.Range(0, 60) // 每分钟一个点 .Select(i => new { Timestamp = now.AddMinutes(-i), Value = Math.Round(50 + Random.Shared.NextDouble() * 10 - 5, 2), Quality = Quality.Good }) .ToArray()); return new HistorianQueryResult { Success = true, Data = data }; } } // 在Controller中组合使用: [HttpPost("/api/historian/query")] public async Task<IActionResult> Query([FromBody] HistorianQueryRequest request) { var result = await _queryService.QueryAsync(request); // 若Historian查询失败,自动降级到fallback if (!result.Success && _fallbackService != null) { _logger.LogWarning("Historian query failed, using fallback data"); result = _fallbackService.GetFallbackData(request.TagNames, request.StartTime, request.EndTime); } return Ok(result); }

从那以后我每次接手Historian集成项目,都会在第一天就搭好这三层防护:连接池保底、内存缓存减负、静态fallback兜底。不是信不过Historian Server,而是信不过现场网络和运维同学的重启速度——毕竟产线停一分钟,损失的不是代码,是真金白银。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进