ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#导出Excel六种方案对比:CSV、OpenXML、NPOI、COM组件与Blob

C#导出Excel六种方案对比:CSV、OpenXML、NPOI、COM组件与Blob

后台管理系统里,导出Excel这个需求应该没人能避开。我最早接触C#导出Excel相关任务时,第一反应就是直接拼文本文件,后来陆续接触过库存报表、对账单、数据接口这几类场景,才把C#导出Excel文件的常见路线都摸了一遍。这篇就把我用过的6种办法整理出来:文本逗号分隔值、OpenXML、NPOI、COM组件、模板填充、前端Blob下载。它们之间没有绝对的好坏,分别对应不同环境约束、数据量级和文件格式要求。如果你正在写导出功能,或者准备重构项目里的导出模块,这6种办法和它们踩过的坑,应该能帮你省不少时间。

1. 六种方案全景与选型思路

1.1 六个方案的基础对比

在开始写代码之前,先把这6种办法的定位看清楚。很多需求方嘴上说“导出Excel”,实际拿到什么文件都能接受。但也有些场景就是要真正的.xlsx,还要带格式、公式、图表,这时候选错方案就会从头返工。

方案依赖生成格式能否服务端使用样式支持大数据量表现典型场景
逗号分隔值文本无.csv可以无很好,写入快日志、数据预览、简单报表
OpenXML官方NuGet包.xlsx可以,跨平台中等,代码繁琐好,可流式写入服务端标准Excel文件
NPOI社区NuGet包.xls/.xlsx可以,跨平台较强中等,内存偏高中小数据量读写
COM组件本机安装Office.xls/.xlsx不建议最强差Windows桌面端工具
模板填充NPOI或OpenXML.xlsx可以完整保留模板样式受模板设计限制固定格式报表、对账单
后端字节流+前端Blob无由后端方案决定可以由后端方案决定由后端方案决定Web系统前端触发下载

1.2 决定选型的三个关键维度

第一个维度是文件格式。如果用户只是要一份能打开、能筛选、能交给别人继续处理的数据,文本逗号分隔值完全够用。它生成速度最快,体积最小,普通Excel直接打开也无压力。但如果文件要作为正式交付物,比如报送给外部系统、要体现企业Logo、要打印出来走审批流程,那就必须生成真正的.xlsx,并且大概率需要样式和固定模板。

第二个维度是运行环境。开发机、测试机、生产服务器这三类环境差别很大。服务器如果是Windows且装过Office,也不意味着可以随心所欲用COM组件——服务端调用Office接口会带来权限、进程残留、并发稳定性等一系列问题。更常见的生产环境可能是Linux容器,这时候凡是依赖本机Office的方案全部不可用,只能走文本逗号分隔值、OpenXML、NPOI这些纯托管代码方案。

第三个维度是数据量和并发。一万行和五十万行不是同一个量级,单用户手动点击导出和多个用户同时并发导出也不是同一个量级。文本逗号分隔值和OpenXML在流式写入时可以处理几十万行,NPOI在十几万行就会开始明显吃内存。选择方案前先预估数据量,可以避免做完成后再返工去改性能问题。

2. 六个导出方案从零手写

2.1 逗号分隔值文本——最轻量的“伪Excel”

逗号分隔值本质是纯文本文件,不是二进制Excel格式,但Excel可以自动识别并打开。它的原理非常简单:把一行行数据用逗号拼接,特殊字符做转义,然后写进文本文件。我最早做导出时就用的这个方案,因为不需要引入任何第三方库,代码量最少,出问题时也最好排查。

var sb = new StringBuilder(); sb.AppendLine("姓名,年龄,城市"); foreach (var item in data) { sb.AppendLine($"{EscapeCsv(item.Name)},{EscapeCsv(item.Age)},{EscapeCsv(item.City)}"); } File.WriteAllText(filePath, sb.ToString(), new UTF8Encoding(true));

转义逻辑是逗号分隔值格式里最容易忽略的地方。如果某个字段本身包含逗号、双引号或换行,直接拼进去就会导致列错位。正确做法是:字段中如果包含这几种字符,就用双引号把整个字段包起来,同时把内部的双引号替换成两个双引号。

static string EscapeCsv(string value) { if (string.IsNullOrEmpty(value)) return string.Empty; if (value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r")) { return "\"" + value.Replace("\"", "\"\"") + "\""; } return value; }

写文件的时候有一个必须注意的细节:File.WriteAllText的编码参数要传new UTF8Encoding(true)。true表示写入BOM头。Excel打开不带BOM的UTF-8文件时,中文内容会出现乱码。这个问题很多人第一次都会踩到,我在实际项目中至少见过三次因为漏掉BOM导致导出的逗号分隔值文件在用户机器上一片乱码。

大数据量导出时,不要用字符串直接相加,更不要一次性把全部内容拼成一个大字符串再写入。正确做法是配合StreamWriter逐行写,这样内存占用会非常平稳。

using var writer = new StreamWriter(filePath, false, new UTF8Encoding(true)); writer.WriteLine("姓名,年龄,城市"); foreach (var item in data) { writer.WriteLine($"{EscapeCsv(item.Name)},{EscapeCsv(item.Age)},{EscapeCsv(item.City)}"); }

逗号分隔值方案虽然简单,但也有明显边界:它不支持单元格样式、不支持公式、不支持多个工作表。如果需求里明确说了要带格式,那就得换下面的方案。

2.2 OpenXML——官方标准,服务端xlsx首选

.xlsx文件本质上是一个ZIP压缩包,里面是多个XML文件,分别描述工作簿、工作表、单元格、样式。OpenXML是官方SDK,它做的事情就是让开发者用面向对象的方式操作这些XML,生成标准、干净的.xlsx文件。这个方案完全不需要在服务器上安装Office,跨平台能力非常好。

using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; using System.Globalization; public static void ExportByOpenXml(IEnumerable<UserRow> data, string filePath) { using var doc = SpreadsheetDocument.Create(filePath, SpreadsheetDocumentType.Workbook); var workbookPart = doc.AddWorkbookPart(); workbookPart.Workbook = new Workbook(); var worksheetPart = workbookPart.AddNewPart<WorksheetPart>(); worksheetPart.Worksheet = new Worksheet(new SheetData()); var sheets = workbookPart.Workbook.AppendChild(new Sheets()); sheets.AppendChild(new Sheet { Id = workbookPart.GetIdOfPart(worksheetPart), SheetId = 1, Name = "Sheet1" }); var sheetData = worksheetPart.Worksheet.GetFirstChild<SheetData>(); uint rowIndex = 0; foreach (var item in data) { var row = new Row { RowIndex = ++rowIndex }; row.AppendChild(CreateTextCell(item.Name)); row.AppendChild(CreateNumberCell(item.Age)); row.AppendChild(CreateTextCell(item.City)); sheetData.AppendChild(row); } workbookPart.Workbook.Save(); } static Cell CreateTextCell(string value) { return new Cell { DataType = CellValues.String, CellValue = new CellValue(value ?? string.Empty) }; } static Cell CreateNumberCell(double value) { return new Cell { DataType = CellValues.Number, CellValue = new CellValue(value.ToString(CultureInfo.InvariantCulture)) }; }

这段代码里最值得注意的地方是数字单元格。OpenXML中CellValue接收的是字符串,数字也照样传字符串,但DataType必须标为CellValues.Number,否则Excel会把数字当文本存储。另外数字转字符串时最好用CultureInfo.InvariantCulture,避免在部分地区用户机器上小数点被格式化成逗号,导致文件内容不符合预期。

OpenXML的优势是官方、稳定、可控性强,尤其适合需要流式写入大型文件的场景。缺点是代码量确实不小,写单元格样式时要维护很多XML级别的对象,开发效率比NPOI低不少。如果项目里只是偶尔导出简单报表,用OpenXML会有点杀鸡用牛刀的感觉。

2.3 NPOI——普及度最高的社区库

NPOI是Apache POI的.NET移植版本,是目前社区里用得最广的Excel操作库。它支持读写.xls和.xlsx,不需要装Office,语法也比较贴近Excel操作习惯。做导出时,NPOI的代码可读性比OpenXML好很多,样式控制也方便。

using NPOI.XSSF.UserModel; using NPOI.SS.UserModel; public static byte[] ExportByNpoi(List<UserRow> data) { var workbook = new XSSFWorkbook(); var sheet = workbook.CreateSheet("用户报表"); var header = sheet.CreateRow(0); header.CreateCell(0).SetCellValue("姓名"); header.CreateCell(1).SetCellValue("年龄"); header.CreateCell(2).SetCellValue("城市"); for (var i = 0; i < data.Count; i++) { var row = sheet.CreateRow(i + 1); row.CreateCell(0).SetCellValue(data[i].Name); row.CreateCell(1).SetCellValue(data[i].Age); row.CreateCell(2).SetCellValue(data[i].City); } using var ms = new MemoryStream(); workbook.Write(ms); return ms.ToArray(); }

写到.xls需要把XSSFWorkbook换成HSSFWorkbook,并对应引入NPOI.HSSF.UserModel命名空间。.xls是老格式,最多只能支持65536行,超出会报错,现在新项目基本都用.xlsx。

NPOI中单元格的值类型很讲究。比如SetCellValue传入字符串,单元格就会被当作文本;传入double或者int,才会被当作数字。如果用户用Excel筛选排序,文本型数字会很烦躁。正确做法是先Convert.ToDouble再写入。日期时间类型更是如此,不要把DateTime直接ToString成字符串,否则Excel里无法进行日期运算。通常需要手动创建单元格样式并设置数据格式,或者先转成Excel能识别的日期序数。

NPOI的大数据量表现需要提前评估。十万行往上的场景,NPOI在内存里构建整个工作簿,内存和GC压力会明显变大。我正在处理超大文件时,会更倾向OpenXML的流式写入,或者用文本逗号分隔值来做纯数据导出。不过如果项目已经引入NPOI,中小数据量下用起来确实顺手、省心。

2.4 COM组件——桌面端专用,服务器勿碰

COM组件方案是通过调用本机安装的Excel程序来生成文件,代码里操作的是真实的Excel进程。这个方案能实现几乎所有Excel界面能完成的动作,比如复杂格式化、图表、透视表、公式重算,非常强大。但它有一个致命约束:必须依赖安装了Office的Windows环境,而且服务端使用极不稳定。

using Excel = Microsoft.Office.Interop.Excel; using System.Runtime.InteropServices; var app = new Excel.Application(); app.Visible = false; app.DisplayAlerts = false; var workbook = app.Workbooks.Add(); var worksheet = (Excel.Worksheet)workbook.Worksheets[1]; worksheet.Cells[1, 1] = "姓名"; worksheet.Cells[1, 2] = "年龄"; worksheet.Cells[2, 1] = "张三"; worksheet.Cells[2, 2] = 25; workbook.SaveAs(filePath); workbook.Close(); app.Quit(); Marshal.FinalReleaseComObject(worksheet); Marshal.FinalReleaseComObject(workbook); Marshal.FinalReleaseComObject(app);

这段代码在开发机上通常能正常跑起来,因为开发机装过完整的Office。一旦部署到生产服务器,就会遇到各种奇怪问题。服务器上Excel的交互桌面环境通常是被禁用的,创建进程时往往出现权限错误,或者Excel进程虽然在后台运行但窗口不显示。更麻烦的是进程残留,OpenXML和NPOI操作完文件程序就退出了,COM方案如果某个对象没有及时释放,服务器上会堆起一个又一个僵死的Excel进程,最后把服务器的内存和句柄吃光。

如果你确实在Windows桌面端工具里使用COM方案,注意两点:一是所有COM对象用完都要释放,包括循环里产生的Range对象,不能只释放workbook和app;二是释放顺序要正确,先关闭工作簿再退出应用,最后调用Marshal.FinalReleaseComObject。即便如此,COM方案也只适合单机工具类程序,不适合Web服务。

2.5 模板填充——保留复杂样式的最优解

前面几个方案生成的文件都是“从零创建”,复杂样式需要代码一点点写。而实际业务里有大量固定格式的报表,比如对账单、工资条、审批汇总表,这些报表的Logo、边框、合并单元格、打印设置都是固定的。如果每个导出都用代码重新绘制一遍,既慢又容易出错。模板填充方案的思路是:提前用Excel手工做一份模板文件,里面预留好数据位置,C#打开这份模板,往对应单元格填数据,另存为结果文件。

using NPOI.XSSF.UserModel; using NPOI.SS.UserModel; using var templateStream = new FileStream(templatePath, FileMode.Open, FileAccess.Read); var workbook = new XSSFWorkbook(templateStream); var sheet = workbook.GetSheetAt(0); sheet.GetRow(2).GetCell(1).SetCellValue("张三"); sheet.GetRow(2).GetCell(5).SetCellValue(DateTime.Now.ToString("yyyy-MM-dd")); using var outStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); workbook.Write(outStream);

这里的关键是模板设计。模板里的固定文本、图片、样式都不需要代码处理,只需要在指定位置留空单元格或者在某个单元格里写下划线占位符。第一次做模板时要在Excel里数清楚行列号,用GetRow(2).GetCell(1)这类坐标方式定位。建议在模板里加一个填充说明工作表,用批注标清楚“这一格填姓名、这一格填日期”,否则时间一久自己都会忘。

模板填充最容易踩的坑是并发。如果多个请求同时用同一个模板文件读写,就会出现文件被占用或数据互相覆盖的问题。我的习惯是后端先把模板复制到一个临时目录,再操作副本,最后输出结果文件,这样模板原件始终是干净的。文件命名也尽量不要固定,用Guid.NewGuid()生成临时文件名,导出完成后及时清理临时文件。

模板方案的限制在于动态数据。如果模板里只需要填几个占位单元格,它是完美方案。但如果要填充几万行明细,模板设计时必须预留一个“明细起始行”,C#找到这一行后动态向下复制样式并插入行,这个逻辑会让NPOI代码复杂不少。建议根据实际数据量提前评估模板方案的适用性。

2.6 后端字节流 + 前端Blob——Web项目最常用的完整链路

严格来说,这个方案不是纯C#导出,而是前后端协作。但在现代Web项目里,大部分“导出Excel”最终都是这个链路:后端生成字节数组返回给前端,前端收到二进制流后创建一个Blob对象并触发浏览器下载。把这个方案单独列出来,是因为很多开发者在后端把文件生成好了,却在前端下载这步出了乱码、空白页、文件名不对的问题。

后端C#代码非常简单,只要把前面任意一种方案生成的二进制内容返回给前端即可。

byte[] excelBytes = ExportByNpoi(data); return File(excelBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "用户报表.xlsx");

File方法三个参数分别是文件字节数组、MIME类型、下载文件名。MIME类型必须写对,.xlsx的标准MIME是application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,如果写成普通二进制流或者漏了MIME,前端下载下来的文件可能无法被Excel正确打开。

前端通过fetch拿到响应后,需要先判断请求是否成功,再用Blob触发下载。

async function downloadExcel() { const response = await fetch('/api/export', { method: 'POST' }); if (!response.ok) { const errorText = await response.text(); throw new Error(errorText || '导出失败'); } const blob = await response.blob(); const downloadUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = '用户报表.xlsx'; document.body.appendChild(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000); }

有人会问:为什么不在前端直接生成Excel?前端生成Excel需要引入SheetJS这类库,几十万行数据时页面会卡死,而且有些Excel高级功能支持不全。后端生成的最大优势是可以复用现有数据访问层逻辑,权限校验、数据过滤都在服务端完成,前端只是一个下载动作。另外Blob URL要在触发点击之后及时调用URL.revokeObjectURL释放,不然频繁导出会在浏览器里积累大量内存占用。

这6种方案并不是割裂的,实际项目里经常组合使用。比如模板填充方案同样可以用Blob链路返回给前端,OpenXML生成的字节流也完全可以套用同一个前端下载逻辑。方案本身是工具,关键还是按场景组合。

3. 选型逻辑、性能实测与排查技巧

3.1 不同数据量级怎么选

做选型时,我习惯先把数据量级估出来,再决定用哪个方案。一万行和五十万行的内存差异不是线性增长,而是可能直接从几百MB飙到几个GB。

数据量推荐方案搭配建议
1万行以内逗号分隔值 / NPOI逗号分隔值最简单,NPOI适合要格式
1万到10万行NPOI / OpenXMLNPOI代码简单,OpenXML更稳
10万到50万行OpenXML流式 / 逗号分隔值流式避免完整工作表驻留内存
固定格式模板模板填充先复制模板再操作,注意并发
Web前端触发后端字节流 + Blob搭配任意后端生成方案

数据量级判断有一个很实用的方法:取目标表中的最大行数,乘以预估每行字段长度,看看生成文件大概多大。假如一个Excel文件预计超过50MB,前端下载和用户打开都会变慢,这时候要先排查业务上是否真的需要一次性导出这么多数据,而不是只想着换一个更快的库。

做压力测试时,不要只在开发机上拉几百行数据点一下。我习惯准备5000行、5万行、20万行三档测试数据,分别测生成耗时、内存峰值、文件大小。三档数据测完,哪个方案在什么量级下开始退化就一目了然了。测试时还要顺手观察GC表现,NPOI在大量创建Cell对象时会产生很多临时对象,内存峰值往往比预估高很多。

3.2 导出接口的内存和并发坑

Web接口导出的性能问题,往往不是“哪个库快”,而是“有没有把整个文件塞进内存”。很多代码喜欢用MemoryStream把整个Excel文件放在内存里再返回,数据量大时会触发两次内存占用:一次是构建工作簿的对象内存,一次是最终输出的字节数组内存。对20万行数据来说,这个峰值很容易超过500MB。

我的处理方式是尽量用文件流。如果业务允许导出到服务器临时目录,就直接用FileStream写入文件,写完后通过File方法以“物理文件”方式返回,避免把文件字节再复制一份到内存。如果是纯CSV,用StreamWriter逐行写入同样能控制内存。

并发导出是另一个容易忽视的点。多个用户同时点击导出,每个请求都会创建独立的工作簿对象,内存开销直接翻倍。这时候要限制导出接口的并发数,可以在应用层加一个信号量,或者把大导出任务改成异步任务,前端轮询等待生成完成。COM组件绝对不要放进并发场景,Excel进程互相抢占,轻则文件损坏,重则服务器假死。OpenXML和NPOI虽然资源占用比COM好,但也不是完全无状态,建议通过线程安全的临时文件方案把并发请求隔离开。

3.3 典型问题速查表

把常见的导出异常按问题现象整理成一张速查表,排查时可以对照定位。

现象原因解决方式
Excel打开中文乱码逗号分隔值文件没有BOM写文件时用new UTF8Encoding(true)
文件名下载后乱码前端缺少下载文件名,或响应头未处理用后端File方法传文件名,前端设置link.download
文件提示已损坏文件被占用或返回时机不对检查using释放时机,不要在文件流没有Flush前输出
服务端Excel进程残留使用了COM组件换成OpenXML或NPOI,彻底移除COM依赖
大量数据导出内存溢出MemoryStream + 完整对象驻留内存改用FileStream流式写入,后端以物理文件返回
数字列显示为文本NPOI/OpenXML里传入了字符串数字写入时用数值类型并正确设置CellValues.Number
并发导出同名文件文件名固定或模板被多请求同时读写使用Guid临时文件名,模板先复制再操作
前端下载到的是JSONfetch收到异常但被当作成功前端先检查response.ok,再处理Blob

这张表里的问题,基本覆盖了我这几年遇到的绝大多数导出故障。很多问题的根因不是代码写得少,而是生成文件与返回文件这两个环节没有分开处理,尤其是文件流生命周期没有管理清楚。写完代码后养成一个检查习惯:文件在哪个方法里被创建,在哪个方法里被关闭,在哪个方法里被读取并返回,三步之间不能有交叉。

4. 最后的实操心得

导出Excel的难点,从来不是“把数据写成文件”这一步,而是搞清楚文件到底给谁用、在什么环境生成、数据量有多大。先回答这三个问题,方案选择基本就定了。文件要是给人填完再导入回来的,优先考虑NPOI和模板填充;文件要是几十万行的数据交换,直接上流式逗号分隔值或OpenXML;环境如果是Linux容器,记住只有不依赖Office的纯托管代码方案能跑。

我在实际项目里的组合习惯是:固定格式报表用模板填充,临时查询结果用NPOI生成.xlsx,超大数据量用OpenXML流式写入,只要对格式没要求就毫不犹豫选逗号分隔值。另外强烈建议把导出逻辑抽成一个服务类,数据源传IEnumerable<T>,列映射做成配置,文件名和路径做成参数。这样不同业务模块各自传不同的列配置即可,不用为每个导出需求复制粘贴一套代码。

最后一个提醒:导出功能也需要做清理。文件生成到临时目录,不管成功还是失败,都要在finally里删除临时文件。线上环境跑久了会发现,很多磁盘告警其实都是导出功能留下的临时文件没有清干净。写代码时顺手加个清理逻辑,能省掉后续很多运维问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进