资讯详情

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

📅 2026/11/2 2:13:12 | 华诺云谱 👁 阅读
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# 直连 SDK2.1 demo 的边界只做取数与回写不做画面先把预期说清楚能省掉很多试错。这个 demo 的定位是一条数据通道不是一套监控系统。它做三件事连上历史库、按标签和时间范围取数、把结果落到本地文件顺带演示怎么往库里写一个带质量码的采样点。它不做实时刷新、不做曲线渲染、不做权限管理也不管标签组态。这种取舍在现场很实用。工业现场的画面层通常已经有组态软件或上位机在跑真正缺的是一个能挂到 Windows 服务里、按调度批量导出数据的后台程序。C# 在这块有天然优势.NET 调 SDK 的互操作最顺跟 Excel、CSV、SQL Server 打通也就是几行代码的事现场工程师用 Visual Studio 装个 Community 版就能改。需要提醒的是demo 跑通不等于能上生产。它默认你已经有库的读权限、知道标签命名规则、清楚服务器地址和端口。这三样少一样代码再对也连不上后面第 5 章会专门讲这几类连不上的情况。2.2 四种取数通道的取舍同一个历史库能取数的路子不止一条选错了后面全是坑。整理成一张表更直观通道典型用法优点局限原生 SDKC#/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.csCore下面每个类职责都很单一这是刻意设计的。现场改代码最常见的情形是“只要读不要写”“只要某几个标签”如果读和写揉在一个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).GetHistorianOptions() ?? 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 会话生命周期与资源释放会话类实现IDisposableDispose里做三件事关闭会话、释放查询结果、清空缓存。很多人只做了第一件结果跑一夜之后内存涨到几个 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 IReadOnlyListTagInfo Search(string pattern, int maxCount 500) { // 通配符支持 * 和 ?pattern 为空时返回全部慎用 var filter string.IsNullOrWhiteSpace(pattern) ? * : pattern; var result new ListTagInfo(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按固定间隔插值画曲线、做趋势对比IntervalCalculated服务端聚合平均/最大/最小日报、班报统计Interval AggregateCurrentValue只取当前值实时看板轮询无查询参数对象把这些字段收在一起// Core/SampleQuery.cs public sealed class SampleQuery { public required IReadOnlyListstring 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 IEnumerableSampleRecord 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 里固定平台或者把目标改为x86PropertyGroup PlatformTargetx86/PlatformTarget Prefer32Bittrue/Prefer32Bit /PropertyGroup改完记得清一次bin/obj增量编译有时候不会重新生成宿主。现象本机能连部署到另一台机器就报“未找到服务器”。原因客户端组件没装或者防火墙挡了端口。历史库的默认端口经常被现场网络策略关掉只开放了 Web 端口。解决先在目标机上用telnet 主机 端口确认连通性再确认客户端组件的注册表项存在。装组件这件事没法靠代码绕过纯靠部署清单里写清楚。5.2 数据与时区类问题现象导出的数据和画面上的曲线整体差 8 小时形状完全一致。原因DateTime的Kind属性是LocalSDK 按本地时间解释而库里存的是 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 和这些踩过的坑能帮你少走几段弯路。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑