Skip to content

流式导出 - #62

Merged
chsword merged 4 commits into
mainfrom
feat/streaming-export
Sep 16, 2026
Merged

chsword merged 4 commits into
mainfrom
feat/streaming-export

Conversation

@chsword

@chsword chsword commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

做了什么

新增 ObjectToExcelStream:逐行取数据并写出,内存中只保留 StreamingRowWindow 指定的若干行(默认 100),底层走 NPOI 的 SXSSF。ExcelExporter 与 ExcelHelper 上各有 IEnumerable<TModel> 与 DataTable 两个重载。

using var file = File.Create("orders.xlsx");
ExcelHelper.ObjectToExcelStream(ReadOrders(), file, o => o.ExcelType = ExcelType.Xlsx);

本机实测(五列,含文本、金额与日期,net8.0):

行数 内存导出 流式导出
5 万 245 MB / 2.6 秒 89 MB / 2.2 秒
20 万 710 MB / 6.4 秒 89 MB / 3.7 秒

内存占用不随行数增长。需要说明的是,省下的是内存而非等待时间:写过的行落到临时文件,最终的 .xlsx 包要等数据取完才一次写入调用方的流。

为此改动的地方

原先的写入流程有三处要回看已经写过的内容,在流式写入下都行不通——先前的行已不在内存中:

  • 自动列宽此前先把数据遍历一遍量出宽度再从头写入,现改为随写随量,写完再设列宽。内存导出因此也少走一遍数据,AutoColumnWidth 不再要求数据源可遍历两次。
  • 按值合并此前在写完之后逐格读回工作表找连续相同的值,现改为写入时逐格记下各段。要合并的列名与公式列的拒绝也提前到写入开始之前校验,报错便不会留下写了一半的工作表。
  • 超长下拉列表写到隐藏表上时此前一张一张写,第二张要回头从首行再写一遍,那些行已被刷出内存,该列会悄然丢失。现改为收齐后逐行填满各列,一次写完。

另有两处是流式特有的:公式引用工作簿中已有的表时,SXSSF 只是套在外面的一层,随 SourceExcelBytes 带进来的表的行不在这一层里,表头读作空,公式会被判为引用了不存在的列——改为向底下的 XSSF 取表头;同理,流式追加时无从往已有的隐藏列表表里再添一列(NPOI 会报「该行已写入磁盘」),改为另起一张,原有的列表与定义名称原样保留。

SkiaSharp

SXSSF 每把一行刷出内存都要量一次默认字符宽度,用的是 SkiaSharp,而 NPOI 把该依赖标为不随包传递。本库不替使用方引入它——那会给每个运行时标识带来数十兆的本机库,只用内存导出的使用方本无须为此付出代价。缺失时 ObjectToExcelStream 在写出任何内容之前抛出 Excel2ObjectException,说明需要引用 SkiaSharp(Linux 上另需 SkiaSharp.NativeAssets.Linux.NoDependencies),而不是在写下若干行之后抛出一个与 Excel 无关的 FileNotFoundException。测试项目按使用方的方式自行引用这两个包。

限制

  • 仅 .xlsx 能够流式写入。.xls 必须先在内存中建好再写出,此时该方法只是把结果写进流;该格式至多 65,536 行,本也不适合大文件。
  • 文本不进共享字符串表,而是直接写在单元格里(SXSSF 的做法,否则各不相同的字符串会全数留在内存中)。读的一侧不受影响,有导回对象的用例覆盖。

测试

新增 17 个用例(StreamingExportTest),多数以窗口 5 行、数据 30~120 行运行,确保写入过程中确有大批行被刷出内存:与内存导出逐格比对(值、数字格式、底色、字体)、合并区域跨越已刷出的行、2000 行 400 个区域、冻结/筛选/下拉/条件格式/公式列/跨表公式、长下拉列表在流式追加时另起一表、自动列宽一致、数据只遍历一次、调用方的流不被关闭、临时文件写入与清理、导回对象、.xls 入口、窗口为 0 时报错。

全量 391 个用例在 net8.0 与 net10.0 上通过,库在 7 个目标框架上 0 警告 0 错误。

版本 2.10.0,文档见 docs/versions/v2.10.0.md。

🤖 Generated with Claude Code

ObjectToExcelStream 边取数据边写入调用方给出的流,内存中只保留 StreamingRowWindow 指定的若干行(默认 100),走 NPOI 的 SXSSF。实测二十万行五列:内存导出峰值 710 MB / 6.4 秒,流式导出 89 MB / 3.7 秒,且内存不随行数增长。

为此把要回看数据的两处改为写入时算出:自动列宽随写随量,按值合并的各段随写随记,数据因而只遍历一遍(内存导出同样受益,AutoColumnWidth 不再走两遍)。多张长下拉列表改为一次写完隐藏表,否则第二列会落在已刷出内存的行上而丢失;跨表公式改为向底下的 XSSF 取表头,SXSSF 那一层读不到随源工作簿带进来的行。

流式写入要 SkiaSharp(SXSSF 刷行时用它量字符宽度),NPOI 把该依赖标为不随包传递,故由使用方自行引用,缺失时在写出任何内容之前报错说明。库本身不引入,以免只用内存导出的使用方为此背上数十兆的本机库。

版本 2.10.0。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Copilot AI lite review requested due to automatic review settings September 15, 2026 15:33
WriteSheet 拆成表头、行、列宽三段,写一张表时一路带着的列与样式收进 SheetLayout;SetCellValue 与 SetDateTimeCellValue 本就不用实例状态,改为静态。去掉 ObjectToExcelStream 里的显式判空:本仓库其余公开入口都不做这件事,且该参数在可空检查下本就是非空的。测试数据里的除法补上括号。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Several moderate issues remain unresolved in validation, coverage, merge/dropdown handling, and output behavior.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds SXSSF-based streaming export for large .xlsx files, with lazy row processing and updated support for merges, dropdowns, formulas, and column widths.

Changes:

  • Adds streaming export APIs and configurable row windows.
  • Refactors export internals for single-pass processing.
  • Updates documentation, version metadata, dependencies, and tests.
File summaries
File Summary
README.md Chinese feature and release documentation
README_EN.md English feature and release documentation
docs/versions/v2.10.0.md Version documentation and examples
docs/README.md Version index update
CLAUDE.md Streaming dependency guidance
Chsword.Excel2Object/Options/ExcelExporterOptions.cs Adds StreamingRowWindow
Chsword.Excel2Object/Models/SheetModel.cs Supports lazy row sequences
Chsword.Excel2Object/Internal/TypeConvert.cs Converts data lazily
Chsword.Excel2Object/Internal/StreamingSupport.cs Validates SkiaSharp availability
Chsword.Excel2Object/Internal/MergedRegions.cs Tracks merges during writing
Chsword.Excel2Object/Internal/DropdownValidation.cs Writes long dropdown lists in batches
Chsword.Excel2Object/ExcelHelper.cs Adds streaming helper overloads
Chsword.Excel2Object/ExcelExporter.cs Implements the streaming export pipeline
Chsword.Excel2Object/Chsword.Excel2Object.csproj Updates version to 2.10.0
Chsword.Excel2Object.Tests/TestTempDirectory.cs Isolates temporary test directories
Chsword.Excel2Object.Tests/StreamingExportTest.cs Adds streaming export coverage
Chsword.Excel2Object.Tests/Chsword.Excel2Object.Tests.csproj Adds streaming test dependencies
Review details

Suppressed comments (6)

Chsword.Excel2Object/ExcelExporter.cs:134

  • output 直到模型转换完成后才在这里校验。对字典输入而言,ConvertDictionaryToExcelModel 此前已经调用 MoveNext(),并把余下枚举器交给惰性 Rows;传入 null 流会消耗首项且在此处抛错时无法释放该枚举器。请在公开入口开始转换数据前校验输出流。
        // 字节数组那两个入口在源工作簿读不出来时返回 null,此处没有 null 可返回,故如实报错

Chsword.Excel2Object/ExcelExporter.cs:128

  • 这里新增的 DataTable 流式入口,以及上方的字典分支,分别走了新的惰性转换路径,但 StreamingExportTest 只覆盖 IEnumerable<Model>。已有内存导出测试不足以验证它们在 SXSSF 窗口刷行后的输出;请至少为这两种入口各增加一个流式导回或格式断言。
    public void ObjectToExcelStream(DataTable dt, Stream output,
        Action<ExcelExporterOptions>? optionsAction = null)
    {
        var options = new ExcelExporterOptions();
        optionsAction?.Invoke(options);

Chsword.Excel2Object/ExcelExporter.cs:128

  • 这个新增的公开重载走的是独立的 ToRows(DataTable, DataColumn[]) 路径,而新增流式测试全部调用 IEnumerable<Model> 的重载;因此 DataTable 的 DBNull/日期转换、行遍历和流式写出行为没有回归覆盖。请至少增加一个 DataTable 流式导出用例并校验生成文件(最好也覆盖空值)。
    public void ObjectToExcelStream(DataTable dt, Stream output,
        Action<ExcelExporterOptions>? optionsAction = null)
    {
        var options = new ExcelExporterOptions();
        optionsAction?.Invoke(options);

Chsword.Excel2Object/Internal/MergedRegions.cs:23

  • _runs retains one CellRangeAddress for every closed merge run until all data has been written. A high-cardinality or alternating MergeRepeatedColumns input therefore grows this structure linearly with row count, contradicting the advertised row-count-independent memory usage and adding another copy of the merged-region metadata. Document this limitation or use an incremental/compact range-tracking strategy.
    private readonly Dictionary<int, List<CellRangeAddress>> _runs = new();

Chsword.Excel2Object/Internal/TypeConvert.cs:37

  • 这里直接访问 first.Keys,取消了旧实现 FirstOrDefault 后的空首行处理。调用方仍可在运行时传入包含 null 的 IEnumerable<Dictionary<string, object>>;以前会返回空工作簿,现在会抛出 NullReferenceException。请在读取 Keys 前保留空值分支并释放枚举器。
        var first = rows.Current;
        var columns = first.Keys.Select((c, i) => new ExcelColumn(c, typeof(string)) {Order = i}).ToList();

docs/versions/v2.10.0.md:86

  • 这句话把无效值描述为所有 ObjectToExcelStream 都会抛错,但 .xls 路径不会调用 Window(options),因此 StreamingRowWindow = 0 会被静默忽略;OpenWorkbook 只有创建 SXSSFWorkbook 时才校验。请将限制限定为 .xlsx 流式路径,或在入口统一校验。
该值并非越小越好:行一旦刷出内存即写入临时文件,此后不可再读回,它同时决定了写入过程中可回看的范围。取值须为正数,否则抛出 `Excel2ObjectException`。
  • Files reviewed: 17/17 changed files
  • Comments generated: 5
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

WriteSheet(workbook, excelSheet, options, styleFactory);

// 调用方给的流由调用方关闭
workbook.Write(output, true);
Comment on lines +105 to +108
// 该表向右增长,一列一张列表,先前写下的列表因而保有其区域
var first = 0;
var header = sheet.GetRow(0);
if (header != null) column = header.LastCellNum < 0 ? 0 : header.LastCellNum;
if (header != null) first = header.LastCellNum < 0 ? 0 : header.LastCellNum;
Comment on lines +76 to 79
foreach (var range in _runs.Values.SelectMany(list => list).Concat(declared))
// 重叠已在此处校验过,故绕开 NPOI 自带的两两比对:它对每个新区域都要扫描已有全部区域,
// 十万行的分组表会因此退化为平方级
AddUnsafe(sheet, range);
Comment on lines +18 to +22
var rows = data.GetEnumerator();
bool any;
try
{
any = rows.MoveNext();
Comment thread Chsword.Excel2Object/ExcelExporter.cs Outdated
Comment on lines +232 to +235
var headerRow = sheet.CreateRow(ExcelConstants.DefaultHeaderRowIndex);
for (var i = 0; i < columns.Length; i++)
{
var title = options.MappingColumnAction!(columns[i].Title, columns[i].Type);
chsword and others added 2 commits September 15, 2026 15:44
合并区域在流式工作表上落回了 NPOI 自带的 AddMergedRegion:SXSSFSheet 既非 XSSFSheet 也非 HSSFSheet,两两比对因而照旧,绕开它的用意落空。补上该分支,并加一个 2000 行、400 个区域的用例。

下拉的隐藏列表表,流式追加时会撞上 NPOI 的「该行已写入磁盘」:随源工作簿带进来的表,其行不在流式这一层里,既读不回也无从从首行重写。改为此时另起一张列表表,原有的列表与指向它的定义名称原样保留。

字典入口为取列名要先读一行,枚举器因而在写入开始之前即已打开;写入若未能开始(选项不合法、缺少 SkiaSharp 等),它不会被释放。改由模型带着,导出结束时一并释放。

另外,列名映射不再以 ! 压制可空警告,也不再改写调用方的选项对象;措辞改正:SXSSF 省下的是内存而非等待时间,最终的包要等数据取完才一次写入调用方的流,文档此前说成了「边取边写入流」。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
建立工作簿这一步本身也会失败——选项不合法、源工作簿读不出来、缺少 SkiaSharp——而它原先在 try 之外,字典入口预先取走一行所打开的枚举器因而不会被释放,数据库游标之类的东西就这么悬着。挪进 try 之内。

行窗口原先只在 SXSSF 那条路上校验,.xls 上的非法取值无声通过,与选项文档所说不符。改为流式导出一律校验。

删掉 ToBytes:ObjectToExcelBytes 改走 Write 之后它已无人调用,留着还少了 SXSSF 的释放。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
@chsword
chsword merged commit 722b03c into main Sep 16, 2026
9 checks passed
@chsword
chsword deleted the feat/streaming-export branch September 16, 2026 01:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants