Repository navigation
流式导出 - #62
Merged
Merged
流式导出#62
Conversation
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>
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>
There was a problem hiding this comment.
🟡 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
_runsretains oneCellRangeAddressfor every closed merge run until all data has been written. A high-cardinality or alternatingMergeRepeatedColumnsinput 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 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); |
合并区域在流式工作表上落回了 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
做了什么
新增
ObjectToExcelStream:逐行取数据并写出,内存中只保留StreamingRowWindow指定的若干行(默认 100),底层走 NPOI 的 SXSSF。ExcelExporter与ExcelHelper上各有IEnumerable<TModel>与DataTable两个重载。本机实测(五列,含文本、金额与日期,net8.0):
内存占用不随行数增长。需要说明的是,省下的是内存而非等待时间:写过的行落到临时文件,最终的
.xlsx包要等数据取完才一次写入调用方的流。为此改动的地方
原先的写入流程有三处要回看已经写过的内容,在流式写入下都行不通——先前的行已不在内存中:
AutoColumnWidth不再要求数据源可遍历两次。另有两处是流式特有的:公式引用工作簿中已有的表时,SXSSF 只是套在外面的一层,随
SourceExcelBytes带进来的表的行不在这一层里,表头读作空,公式会被判为引用了不存在的列——改为向底下的 XSSF 取表头;同理,流式追加时无从往已有的隐藏列表表里再添一列(NPOI 会报「该行已写入磁盘」),改为另起一张,原有的列表与定义名称原样保留。SkiaSharp
SXSSF 每把一行刷出内存都要量一次默认字符宽度,用的是 SkiaSharp,而 NPOI 把该依赖标为不随包传递。本库不替使用方引入它——那会给每个运行时标识带来数十兆的本机库,只用内存导出的使用方本无须为此付出代价。缺失时
ObjectToExcelStream在写出任何内容之前抛出Excel2ObjectException,说明需要引用SkiaSharp(Linux 上另需SkiaSharp.NativeAssets.Linux.NoDependencies),而不是在写下若干行之后抛出一个与 Excel 无关的FileNotFoundException。测试项目按使用方的方式自行引用这两个包。限制
.xlsx能够流式写入。.xls必须先在内存中建好再写出,此时该方法只是把结果写进流;该格式至多 65,536 行,本也不适合大文件。测试
新增 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