C# 调用 Historian API:工业历史数据库取数与 CSV 导出示例

发布时间:2026/11/2 2:13:03
C# 调用 Historian API:工业历史数据库取数与 CSV 导出示例

简介:这份示例项目面向具备一定C#基础、希望基于Proficy Historian开展二次开发的工程师与学习者,围绕工业历史数据的采集、写入与分析场景,演示如何通过客户端访问接口与Historian服务交互。压缩包共18个文件、约18KB,以C#源码和XAML界面文件为主,辅以资源文件、解决方案与项目配置,结构上兼顾接口调用示例和桌面端演示界面,便于在开发工具中打开后对照理解初始化、查询及数据写入的基本流程,也能帮助读者梳理服务连接参数与数据组织方式。已有561人学习,说明其在同类工业数据接口入门资料中具备一定参考价值。读者可从中获得可运行的示例骨架、接口库调用线索以及界面与后台逻辑的组织方式,用于快速搭建自己的Historian数据访问原型,并在此基础上扩展报警事件处理、数据导出或可视化展示等功能。

1. 一条曲线背后的取数难题

中控室的趋势画面只保留七天,工艺工程师想翻三个月前某个批次的温度曲线做复盘,只能靠截图加手抄。这个 C# 调用 Historian API 的 demo,解决的正是取数链路上的最后一公里:把工业历史数据库里的标签数据,按时间范围和采样方式成批拉出来,塞进 DataTable 或者直接落成 CSV。它面向的不是做架构评审的人,而是手上有一份 SDK、一个服务器地址、一份点位清单,明天就要交报表的 C# 开发者。整个 demo 没有花哨界面,控制台跑起来,改几行配置就能连上自己现场的历史库。真正值钱的地方,是它把连接、认证、采样、分页、异常串成了一条能复用的链路,而不是那种贴上去跑不通的示例代码。

2. 项目定位与技术选型:为什么用 C# 直连 SDK

2.1 demo 的边界:只做取数与回写,不做画面

先把预期说清楚,能省掉很多试错。这个 demo 的定位是一条数据通道,不是一套监控系统。它做三件事:连上历史库、按标签和时间范围取数、把结果落到本地文件;顺带演示怎么往库里写一个带质量码的采样点。它不做实时刷新、不做曲线渲染、不做权限管理,也不管标签组态。

这种取舍在现场很实用。工业现场的画面层通常已经有组态软件或上位机在跑,真正缺的是一个能挂到 Windows 服务里、按调度批量导出数据的后台程序。C# 在这块有天然优势:.NET 调 SDK 的互操作最顺,跟 Excel、CSV、SQL Server 打通也就是几行代码的事,现场工程师用 Visual Studio 装个 Community 版就能改。

需要提醒的是,demo 跑通不等于能上生产。它默认你已经有库的读权限、知道标签命名规则、清楚服务器地址和端口。这三样少一样,代码再对也连不上,后面第 5 章会专门讲这几类连不上的情况。

2.2 四种取数通道的取舍

同一个历史库,能取数的路子不止一条,选错了后面全是坑。整理成一张表更直观:

通道典型用法优点局限
原生 SDK(C#/COM)后台服务、批量导出、二次开发支持全部采样方式,能取质量码和元数据需要装客户端组件,位数必须匹配
OPC HDA跨厂商系统对接标准协议,松耦合采样方式受限,性能一般,调试成本高
ODBC / SQL 查询报表工具、BI 直连会用 SQL 就能查只适合读,写回和元数据支持弱
Web API / REST前端看板、跨平台无客户端依赖大批量拉取慢,通常有分页上限

demo 选的是第一条。原因很直接:批量导出场景动辄几十万点,ODBC 走一次宽表查询经常把库压得喘不过气,而 SDK 的采样接口能在服务端做降采样,返回的数据量小一个量级。我一般会在带宽和库负载之间做权衡——历史数据导出用 SDK,临时性的一次性查询用 ODBC 更省事。

提示:SDK 的位数(x86/x64)必须和宿主进程一致,这一点和后面要说的 BadImageFormatException 直接相关。

2.3 运行环境与依赖清单

demo 本质是一个 .NET 控制台项目,依赖可以拆成三层。最底下是历史库客户端组件,装完会在系统里注册若干程序集,项目里通过引用或 NuGet 包引入;中间是 .NET 运行时,建议 .NET 6 及以上,用Microsoft.Extensions.Configuration读配置文件;最上面是输出层,CsvHelper或者原生StreamWriter都行。

配置文件用appsettings.json,把服务器、认证方式、超时、批量大小这些可变项全部外置,别硬编码在Main里——这一点在 demo 阶段看不出差别,等你要在三个现场之间切换环境时就知道有多重要了。

{ "Historian": { "Server": "hist-node-01", "Port": 14000, "AuthMode": "Windows", "User": "", "Password": "", "ConnectTimeoutSeconds": 15, "QueryTimeoutSeconds": 120, "MaxSamplesPerQuery": 50000 }, "Export": { "OutputDir": "./out", "FileFormat": "csv", "TimeZone": "Asia/Shanghai" } }

参数里有两个值得单独说。QueryTimeoutSeconds默认给到 120 而不是 30,因为跨月查询在库负载高的时候很容易超时,超时后 SDK 抛的异常信息又很含糊,排查起来纯靠猜。MaxSamplesPerQuery是给分页用的阈值,不是库的硬限制,是为了控制单次返回的内存占用,具体怎么切下一章展开。

3. 目录结构与连接层:会话怎么建、怎么放

3.1 一套能直接抄的目录结构

demo 的目录刻意做得很薄,去掉 bin/obj 之后基本是这几个文件夹:

HistorianDemo/ ├── Config/ │ ├── appsettings.json │ └── HistorianOptions.cs # 配置强类型 ├── Core/ │ ├── HistorianConnectionFactory.cs │ ├── HistorianSession.cs # 会话封装,IDisposable │ ├── TagBrowser.cs # 标签浏览与元数据 │ ├── SampleQuery.cs # 查询参数对象 │ ├── HistorianReader.cs # 读 │ └── HistorianWriter.cs # 写 ├── Export/ │ └── CsvExporter.cs ├── Models/ │ ├── TagInfo.cs │ └── SampleRecord.cs └── Program.cs

Core下面每个类职责都很单一,这是刻意设计的。现场改代码最常见的情形是“只要读不要写”“只要某几个标签”,如果读和写揉在一个HistorianHelper里,改一处就得通读三百行。拆开之后,删掉HistorianWriter.cs整个项目照样编译,这就是结构带来的好处。

SampleQuery单独抽成对象而不是一堆方法参数,也是为了后面加字段方便——采样方式、死区、边界处理这些参数会越加越多,方法签名撑不住。

3.2 连接配置与认证的落地写法

配置类用sealed class加init访问器,避免运行时被随手改掉:

// Config/HistorianOptions.cs public sealed class HistorianOptions { public const string SectionName = "Historian"; // 服务器地址,可以是主机名,也可以是 "主机名:端口" public string Server { get; init; } = "localhost"; // 认证方式:Windows 集成认证 / 显式用户名密码 public AuthMode Mode { get; init; } = AuthMode.Windows; public string? User { get; init; } public string? Password { get; init; } // 建连超时,秒。网络抖动时默认值往往不够 public int ConnectTimeoutSeconds { get; init; } = 15; // 查询超时,秒。跨月查询务必放大 public int QueryTimeoutSeconds { get; init; } = 120; // 单次查询返回的采样点上限,用于分页切片 public int MaxSamplesPerQuery { get; init; } = 50_000; public static HistorianOptions FromConfiguration(IConfiguration cfg) => cfg.GetSection(SectionName).Get<HistorianOptions>() ?? throw new InvalidOperationException("缺少 Historian 配置节"); } public enum AuthMode { Windows, Explicit }

FromConfiguration里直接抛异常而不是返回默认值,是我踩过坑之后改的。早期版本配置读不到就默默用localhost,程序跑起来连的是本机,日志里一行报错都没有,查了半天才发现是配置文件没被复制到输出目录。

连接工厂负责把配置翻译成会话对象。这里有个细节:SDK 的入口对象创建开销不小,而且不是线程安全的,正确做法是一个进程一个会话,用连接池或者单例持有,而不是每次查询都 new 一个。

// Core/HistorianConnectionFactory.cs public static class HistorianConnectionFactory { private static readonly object SyncRoot = new(); private static HistorianSession? _shared; public static HistorianSession GetShared(HistorianOptions opt) { if (_shared is { IsOpen: true }) return _shared; lock (SyncRoot) { if (_shared is { IsOpen: true }) return _shared; _shared?.Dispose(); _shared = HistorianSession.Open(opt); return _shared; } } }

双重检查加锁在这里够用了,因为创建会话本身是幂等的。真正要小心的是_shared?.Dispose()那一行——如果旧会话已经断了,重新建连之前必须先释放,否则底层连接句柄会一直挂着,跑久了就是句柄泄漏。

3.3 会话生命周期与资源释放

会话类实现IDisposable,Dispose里做三件事:关闭会话、释放查询结果、清空缓存。很多人只做了第一件,结果跑一夜之后内存涨到几个 G,重启才恢复。

// Core/HistorianSession.cs(节选) public sealed class HistorianSession : IDisposable { private readonly IHistorian _inner; private bool _disposed; public bool IsOpen => !_disposed && _inner.IsConnected; private HistorianSession(IHistorian inner) => _inner = inner; public static HistorianSession Open(HistorianOptions opt) { // 认证方式在这里分叉,Windows 认证直接透传当前凭据 var inner = opt.Mode == AuthMode.Windows ? HistorianServer.Connect(opt.Server, opt.ConnectTimeoutSeconds) : HistorianServer.Connect(opt.Server, opt.User!, opt.Password!, opt.ConnectTimeoutSeconds); return new HistorianSession(inner); } public IHistorian Raw => _disposed ? throw new ObjectDisposedException(nameof(HistorianSession)) : _inner; public void Dispose() { if (_disposed) return; _disposed = true; try { _inner.Disconnect(); } catch { /* 断连失败不影响进程退出 */ } _inner.Dispose(); } }

Raw属性暴露底层对象给TagBrowser和HistorianReader用,同时做了 disposed 检查。这里没走接口抽象,是因为二次开发时经常需要调 SDK 的原生方法,包一层反而碍事。Dispose里的空 catch 看着不优雅,但断连失败时抛异常会把进程退出流程卡住,现场部署成 Windows 服务之后这类问题很难复现,索性吞掉。

调用方用using包住,进程退出时由工厂统一释放:

var opt = HistorianOptions.FromConfiguration(config); using var session = HistorianConnectionFactory.GetShared(opt); var reader = new HistorianReader(session, opt);

注意:注册成 Windows 服务时,Main返回并不等于资源已释放,务必在AppDomain.ProcessExit里补一次Dispose,否则库侧可能残留会话,第二天连不上。

4. 核心读写链路:从标签浏览到采样查询

4.1 标签浏览与元数据读取

取数之前先得知道有哪些标签。工业现场的标签名通常很长,带层级和分隔符,比如LINE1.REACTOR.TEMP,靠猜是不行的。TagBrowser用通配符做模糊匹配,一次拉回标签名、描述、工程单位、数据类型:

// Core/TagBrowser.cs public IReadOnlyList<TagInfo> Search(string pattern, int maxCount = 500) { // 通配符支持 * 和 ?,pattern 为空时返回全部(慎用) var filter = string.IsNullOrWhiteSpace(pattern) ? "*" : pattern; var result = new List<TagInfo>(maxCount); using var cursor = _session.Raw.BrowseTags(filter, maxCount); while (cursor.Next()) { result.Add(new TagInfo { Name = cursor.Name, Description = cursor.Description, EngUnits = cursor.EngUnits, DataType = cursor.DataType.ToString(), IsWritable = cursor.IsWritable }); } return result; }

maxCount一定要给默认值。现场的标签动辄几万个,不限量浏览会一次性把元数据全拉回客户端,内存和耗时都不可控。500 是个折中值,足够人在控制台里看一眼,需要全量的时候再显式调大。

IsWritable这个字段经常被忽略。历史库里的标签分两类:采集器自动写入的和用户手工写入的,后者才能被你的代码写回。写之前不判断这个标志,会得到一个语义很模糊的错误码,查文档才知道是权限问题。

4.2 四种采样方式与参数含义

这是整套 demo 最关键的部分。同一个时间范围,采样方式不同,返回结果可能差几十倍:

采样方式行为适用场景关键参数
Raw返回区间内所有原始采样点故障回溯、精确对时无
Interpolated按固定间隔插值画曲线、做趋势对比Interval
Calculated服务端聚合(平均/最大/最小)日报、班报统计Interval + Aggregate
CurrentValue只取当前值实时看板轮询无

查询参数对象把这些字段收在一起:

// Core/SampleQuery.cs public sealed class SampleQuery { public required IReadOnlyList<string> Tags { get; init; } // 时间一律用 UTC,避免本地时区把数据整体平移 public required DateTime StartUtc { get; init; } public required DateTime EndUtc { get; init; } public SamplingMode Mode { get; init; } = SamplingMode.Interpolated; // 仅 Interpolated / Calculated 有效 public TimeSpan Interval { get; init; } = TimeSpan.FromMinutes(1); // 计算型采样用的聚合函数 public Aggregate Aggregate { get; init; } = Aggregate.Average; // 死区,压缩存储用,0 表示不启用 public double Deadband { get; init; } public int MaxSamples => (int)Math.Ceiling( (EndUtc - StartUtc).TotalSeconds / Interval.TotalSeconds) + 1; }

StartUtc/EndUtc强制 UTC 是血泪经验。早期版本用DateTime.Now传进去,SDK 按本地时间解释,导出的数据整体偏移 8 小时,对着 SCADA 画面核对了一整天才发现问题。从那以后我在所有入口都加了一道ToUniversalTime()转换,宁可麻烦也不留这个口子。

MaxSamples是算出来的,用来判断这次查询会不会超出配置的上限,超了就自动切片。

4.3 查询执行与分批切片

真正执行查询的方法要做三件事:校验标签、按时间片切分、把结果摊平成统一模型。

// Core/HistorianReader.cs public IEnumerable<SampleRecord> Read(SampleQuery q) { if (q.Tags.Count == 0) throw new ArgumentException("标签列表为空"); if (q.EndUtc <= q.StartUtc) throw new ArgumentException("结束时间必须晚于开始时间"); var maxPoints = _options.MaxSamplesPerQuery; foreach (var tag in q.Tags) { // 按标签逐个查,而不是一次传整个列表 var cursor = q.StartUtc; while (cursor < q.EndUtc) { // 根据采样间隔反推这一片的时间跨度,保证片内点数不超限 var sliceEnd = q.Mode == SamplingMode.Raw ? cursor.AddHours(1) // 原始数据按小时切,保险 : cursor.AddTicks(q.Interval.Ticks * maxPoints); if (sliceEnd > q.EndUtc) sliceEnd = q.EndUtc; using var result = _session.Raw.ReadSamples( tag, cursor, sliceEnd, q.Mode, q.Interval, q.Aggregate); foreach (var s in result) { yield return new SampleRecord { Tag = tag, TimestampUtc = s.Timestamp.ToUniversalTime(), Value = s.Value, Quality = s.Quality.ToString() }; } cursor = sliceEnd; } } }

几个设计决定值得说明。按标签逐个查而不是整列表一起传,是因为批量查询里只要有一个标签名写错,整批返回空,错误信息里还看不出是哪个标签的锅。逐个查虽然多几次往返,但定位问题快得多,现场调试时这点更重要。原始数据按一小时切片,是按最坏情况估的——高频采集点一秒一个,一小时也有 3600 点,远低于五万的上限。插值查询则用Interval × maxPoints反推跨度,简单可控。

用yield return做流式输出,是为了让导出层边读边写,不用把整个结果集堆在内存里。这一点在处理原始数据时差别很大,同样是一个月的数据,先存List再导出,峰值内存能差十倍。

4.4 写回与质量码

写回的场景不多但绕不开,比如补录人工化验值、修正异常点。写的时候有三样东西必须给全:时间戳、值、质量码。

// Core/HistorianWriter.cs public WriteResult Write(SampleRecord record) { var tag = _browser.Search(record.Tag, 1).FirstOrDefault() ?? throw new InvalidOperationException($"标签不存在:{record.Tag}"); if (!tag.IsWritable) throw new InvalidOperationException($"标签不可写:{record.Tag}"); var sample = new DataSample { Timestamp = record.TimestampUtc, Value = record.Value, // 质量码不传的话,库里会按默认值存,后续查询可能被过滤掉 Quality = Quality.Good }; var rc = _session.Raw.WriteSample(tag.Name, sample); return new WriteResult(rc.Success, rc.Message); }

Quality.Good不能省。不同版本的 SDK 对默认质量码的处理不一致,有的给Uncertain,有的直接拒绝写入,而查询端如果带质量过滤条件,这些点就被静默丢掉了,表现出来是“写进去了但查不到”。写之前先判断IsWritable也是同样的道理,与其等 SDK 返回一个看不懂的错误码,不如自己抛一个明确的异常。

写操作还有一条隐含约束:同一标签同一时间戳重复写入,多数库是覆盖语义,但有些采集器配置下会保留多值。补录之前最好先查一遍目标时间段有没有已有数据,避免自己跟自己对不上。

5. 避坑与排查:五类现场常见问题

5.1 连接与环境类问题

现象:程序一启动就抛BadImageFormatException,提示“试图加载格式不正确的程序”。

原因:SDK 的程序集位数和宿主进程不匹配。历史库客户端组件通常是 32 位的,而 .NET 项目默认编译成AnyCPU,在 64 位系统上以 64 位进程运行,加载 32 位程序集必然失败。

解决:在 csproj 里固定平台,或者把目标改为x86:

<PropertyGroup> <PlatformTarget>x86</PlatformTarget> <Prefer32Bit>true</Prefer32Bit> </PropertyGroup>

改完记得清一次bin/obj,增量编译有时候不会重新生成宿主。

现象:本机能连,部署到另一台机器就报“未找到服务器”。

原因:客户端组件没装,或者防火墙挡了端口。历史库的默认端口经常被现场网络策略关掉,只开放了 Web 端口。

解决:先在目标机上用telnet 主机 端口确认连通性,再确认客户端组件的注册表项存在。装组件这件事没法靠代码绕过,纯靠部署清单里写清楚。

5.2 数据与时区类问题

现象:导出的数据和画面上的曲线整体差 8 小时,形状完全一致。

原因:DateTime的Kind属性是Local,SDK 按本地时间解释,而库里存的是 UTC。中间没人做转换,数据就整体平移了一个时区。

解决:所有进出 SDK 的时间统一走 UTC,模型层的字段名带Utc后缀,导出时再按配置里的TimeZone转成本地时间。命名上加后缀这个习惯很土,但确实能防住大部分手滑。

现象:查询一个月的数据,返回条数明显少于预期,也不报错。

原因:服务端有单次返回的采样点上限,超了直接截断,不抛异常。日志里只有一条Warning级别的提示,很容易被忽略。

解决:按 4.3 的做法自己切片,并且在校验阶段用MaxSamples预估点数,超限就分片。同时把返回条数和预估值做对比,差值超过 5% 就打日志,别让它悄悄发生。

现象:标签名明明是对的,查询返回空集合。

原因:标签名里的特殊字符没转义。历史库的标签名允许包含点号、斜杠这类字符,在通配符查询里它们是元字符,不处理就被当成模式匹配了。

解决:搜索时对用户输入做转义,精确查询时走“按名称直查”的接口而不是通配符接口。这两条路径在 SDK 里是两个方法,别混用。

现象:服务跑两三天后内存持续上涨,最终 OOM。

原因:查询结果集没有显式释放,会话对象也一直没重建。SDK 返回的游标对象持有非托管资源,靠 GC 回收不及时。

解决:所有游标和结果集用using包住,长时间运行的服务加一个定时重建会话的逻辑,比如每 12 小时主动断连重连一次。这个做法不优雅,但在现场比排查内存泄漏快得多。

6. 二次开发:把 demo 改成能干活的小工具

拿到 demo 之后,多数人会往两个方向改:做成定时导出的后台服务,或者做成带界面的查询工具。前者更常见,改造点也比较集中,我按优先级列一下。

改造点demo 现状建议做法验证方式
执行模型同步阻塞包成BackgroundService,用Channel排队连续跑 24 小时看线程数是否稳定
分页策略按标签逐条切标签分组 + 时间片并行同一批数据对比耗时和结果条数
输出格式CSVCSV + Parquet 双通道Parquet 用 DataFrame 读回来核对行数
断点续传无记录每个标签已导出的最大时间戳中途 kill 进程后重启看是否重头再来
错误处理抛异常中断单标签失败记账,不阻断整批故意写错一个标签名,看其余是否照常导出

异步化是最值得先做的一项。Read方法是同步的,包一层Task.Run意义不大,真正的瓶颈在 SDK 调用本身,所以要做的是把不同标签的查询并发起来,同时限制并发度。我一般用SemaphoreSlim卡在 4 到 8 之间,具体数字看库的负载能力,现场库如果还担着实时采集,并发开太高会把采集拖慢,这个后果比导出慢严重得多。

断点续传的实现比想象中简单:每导完一个标签,就往一个progress.json里写一条{tag, lastTimestampUtc}。重启时读这个文件,把起始时间改成记录值。要注意的是这个记录必须在数据落盘之后再写,顺序反了就会出现“标记已导出但文件里没有”的情况,我吃过这个亏,排查时对着两份对不上的数据看了半天。

验证这块有个土办法很管用:随机挑三个标签、三个时间段,用 demo 导出的 CSV 和组态软件里手工导出的数据做逐点比对。别只比点数,要比时间戳和值,尤其是时间范围的首尾两个点——边界处理是最容易出错的地方,左闭右开还是左闭右闭,不同采样方式的行为还不一样。

最后说个习惯。我现在每次改完查询相关的代码,都会先跑一遍“单标签、一小时、Raw 模式”的最小用例,确认返回条数和时间戳范围都对,再去跑全量。全量查询动辄几分钟,一旦参数写错,等结果出来才发现,一天就搭进去了。希望这个 demo 和这些踩过的坑,能帮你少走几段弯路。

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