ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零搭建灌装监控系统(十一):CSV流式导出,大数据量也不拖垮界面

从零搭建灌装监控系统(十一):CSV流式导出,大数据量也不拖垮界面 CSV 流式导出这是「从零搭建灌装监控系统」系列第11篇。生产记录已经落到 SQLite但现场人员还需要按日期导出数据。这篇实现 CSV 导出重点不只是“能写文件”还要处理大数据量、中文编码、分隔符、文件占用和取消操作。导出按钮把软件卡死了有一次测试数据只有几百条点击导出很快就结束了。于是有人把“查询全部记录再一次性写 CSV”的代码提交了。上线后数据量涨到几万条操作员点下按钮界面先停住过了很久才恢复。更麻烦的是导出期间如果用户误点了第二次程序会同时打开两个文件写入如果目标文件正被 Excel 占用异常又只显示成一句“导出失败”。导出功能看似简单实际上同时碰到数据库查询、内存占用、文件编码、UI 线程和异常提示。正确的做法是让数据库分批给数据文件流逐批接收UI 只显示进度和结果。先确定 CSV 的契约CSV 不是“把属性用逗号拼起来”。在实现之前要先确定第一行是否写表头字段顺序是否固定分隔符用逗号还是分号中文软件用什么编码字段中包含逗号、换行或引号时如何转义导出时间范围是闭区间还是半开区间。这里采用UTF-8 BOM方便旧版 Excel 正确识别中文分号作为分隔符避免某些地区设置下逗号与小数格式冲突第一行写表头由 CsvHelper 负责引号和换行转义查询范围使用[start, end)。这些不是“唯一正确答案”但必须稳定。一旦用户用导出的 CSV 做了统计下一次字段顺序和编码不能随意变化。不要一次性 ToList最直观的实现是varrecordsawaitDbProvider.Fsql.SelectProductionRecord().Where(xx.Timestartx.Timeend).ToListAsync();awaitcsv.WriteRecordsAsync(records);它在数据少时没问题数据多时会把所有记录先放进内存。监控软件本来还要同时运行轮询、报警和 UI导出不应该把内存和数据库连接都占满。分批查询的思路是固定批次大小每次只取一批constintbatchSize500;varpage0;while(true){varbatchawaitDbProvider.Fsql.SelectProductionRecord().Where(xx.Timestartx.Timeend).OrderByDescending(xx.Time).Skip(page*batchSize).Take(batchSize).ToListAsync();if(batch.Count0)break;awaitcsv.WriteRecordsAsync(batch);awaitcsv.FlushAsync();page;}一次只持有 500 条记录内存压力稳定很多。FlushAsync让已经写出的内容尽快落到文件里程序异常时至少不会所有内容都滞留在缓冲区。对于非常大的表反复Skip可能随着页数增加而变慢。此时可以改成基于时间和主键的游标分页记住上一批最后一条记录再查询更早的数据。文章先用页码分页因为它更容易理解也足以应对普通单机监控数据量。是否是否点击导出固定导出结束时间CountAsync 统计总数写表头查询一批 500 条本批为空?关闭文件并返回总数写入 CSV 并 Flush已取消?中断导出page 加一用 CsvHelper 处理转义不要手动拼字符串writer.WriteLine(${record.BatchNo};{record.Operator};{record.Message});只要操作员姓名或备注中出现分号、引号或换行列就会错位。CsvHelper 能处理这些边界情况varconfignewCsvConfiguration(CultureInfo.InvariantCulture){Delimiter;,HasHeaderRecordtrue,EncodingnewUTF8Encoding(encoderShouldEmitUTF8Identifier:true)};awaitusingvarstreamnewFileStream(filePath,FileMode.Create,FileAccess.Write,FileShare.Read);awaitusingvarwriternewStreamWriter(stream,config.Encoding);awaitusingvarcsvnewCsvWriter(writer,config);csv.WriteHeaderProductionRecord();awaitcsv.NextRecordAsync();如果版本的 CsvHelper 不接受Encoding配置就把编码交给StreamWriter并在配置中只设置分隔符。关键不是 API 形式而是要明确写入 BOM。表头建议使用稳定的导出模型不要直接把数据库实体当成永久格式publicsealedclassProductionExportRow{publicstringBatchNo{get;init;};publicDateTimeTime{get;init;}publicdecimalVolume{get;init;}publicdecimalTemperature{get;init;}publicstringOperator{get;init;};}数据库实体以后可能新增内部字段但用户导出的列应该由产品需求控制。两者耦合在一起数据库一次加字段就可能改变客户收到的文件格式。完整的流式导出服务下面是一个适合放在DataService的教学版实现publicstaticasyncTask(inttotal,stringfilePath)ExportToCsvAsync(DateTimestart,DateTimeend,stringfilePath,CancellationTokentokendefault){varqueryDbProvider.Fsql.SelectProductionRecord().Where(xx.Timestartx.Timeend);vartotal(int)awaitquery.CountAsync();varpage0;constintbatchSize500;varencodingnewUTF8Encoding(true);awaitusingvarstreamnewFileStream(filePath,FileMode.Create,FileAccess.Write,FileShare.Read);awaitusingvarwriternewStreamWriter(stream,encoding);awaitusingvarcsvnewCsvWriter(writer,CultureInfo.InvariantCulture);csv.WriteHeaderProductionExportRow();awaitcsv.NextRecordAsync();while(true){token.ThrowIfCancellationRequested();varbatchawaitDbProvider.Fsql.SelectProductionRecord().Where(xx.Timestartx.Timeend).OrderByDescending(xx.Time).Skip(page*batchSize).Take(batchSize).ToListAsync(token);if(batch.Count0)break;awaitcsv.WriteRecordsAsync(batch.Select(Map),token);awaitcsv.FlushAsync();page;}return(total,filePath);}不同 FreeSql 或 CsvHelper 版本的异步方法签名可能略有差异文章中的结构比某个版本的参数细节更重要先统计、写表头、分批查询、逐批写入、支持取消。实际项目还需要处理目标目录不存在、文件正在被 Excel 占用和用户取消。错误要记录到日志同时给 UI 一个不带堆栈的提示堆栈留在日志里操作员不需要看一屏异常文本。UI 层只负责选择条件和显示进度ViewModel 不应该自己拼 SQL。它拿到日期范围后调用服务[RelayCommand]privateasyncTaskExportAsync(){if(IsBusy)return;IsBusytrue;try{varpathawaitSelectExportPathAsync();if(pathnull)return;varresultawaitDataService.ExportToCsvAsync(StartDate,EndDate.AddDays(1),path,_exportCts.Token);StatusText$已导出{result.total}条记录;}catch(OperationCanceledException){StatusText导出已取消;}catch(IOException){StatusText文件正在被其他程序使用请关闭后重试;}catch(Exceptionex){LogService.Error(导出生产记录失败,ex);StatusText导出失败请查看日志;}finally{IsBusyfalse;}}IsBusy是防重复点击的第一道保护。按钮禁用、取消令牌和服务层异常处理要配合使用不能只在界面上把按钮变灰因为其他调用方仍可能触发导出。导出顺序和分页一致性导出过程中如果数据库仍在写入分页查询可能遇到数据变化。按时间倒序查询时新插入的记录会出现在前面极端情况下可能影响页码偏移。有几种处理方式导出开始时记录一个结束时间只导出[start, exportEnd)的数据使用数据库事务或快照读取使用主键游标分页不依赖Skip对单机小数据量接受导出期间的轻微延迟。这里推荐第一种。导出开始时固定exportEnd DateTime.Now用户看到的就是“点击导出瞬间之前”的数据不会因为导出持续一分钟而把后一分钟的记录悄悄混进来。踩坑记录用系统默认编码写中文在一台电脑上正常换到另一台电脑打开全是乱码。UTF-8 BOM 是兼容 Excel 的简单办法别指望用户自己去选择编码。手工拼 CSV字段里一旦出现分隔符或换行导出的列会错位。把转义交给成熟库自己只负责定义导出模型。页码从 1 还是 0 开始FreeSql 的Page通常从 1 开始手写Skip则习惯从 0 计算。不要把两个概念混在一起最好在方法内部统一。导出路径和数据库路径混用数据库文件属于应用数据CSV 是用户主动选择的输出。两者目录职责不同不能为了方便把导出文件全部丢进数据库目录。本篇小结问题做法大数据量导出500 条一批逐批写入中文乱码UTF-8 BOM特殊字符错列CsvHelper 负责转义重复点击IsBusy 取消令牌查询边界固定导出结束时间使用半开区间数据库和导出解耦使用独立的导出模型导出做好以后用户可以把数据带走但配置仍然是另一个危险点程序正在运行时写配置如果刚好断电JSON 可能只剩半截。下期预告第12篇配置系统原子写入与容错下一篇处理配置保存中的半文件、并发写入和损坏恢复让设备参数不会因为一次意外断电就全部回到默认值。
返回列表