简介:面向.NET开发者的NPOI 1.2.5动态链接库资源包已上线,用于在项目中读写Excel与Word文档,无需安装Office组件即可完成工作簿创建、单元格样式设置、公式写入及Word文本格式化,覆盖数据处理与文档生成等常见场景。资源共245个文件,压缩包约5.98MB,内含NPOI核心DLL、.NET工程源码(cs)、项目配置(csproj、config)、示例程序(exe)以及少量xls样例文件,便于开发者直接引用程序集或对照源码理解API调用方式。目前已有225人学习下载,适合希望升级至新版本NPOI、降低办公文档处理门槛的初中级开发者。通过这组文件,可快速搭建NPOI开发环境,结合示例掌握XLS/XLSX与DOC/DOCX的核心操作,尤其适合需要批量生成报表、完成数据导入导出或维护文档自动化流程的实战项目。
1. NPOI最新版1.25DLL:不装 Office 也能把 Excel 读写做成稳定服务的首选方案
第一次接触 NPOI最新版1.25DLL 的人,十个里有八个以为它就是那个单一的 NPOI.dll 文件。结果把库拷到生产环境,程序启动到一半报“未能加载文件或程序集”,打开 bin 目录才发现 NPOI.OOXML、OpenXml4Net 之类的依赖 DLL 一个都没带。这代版本是 NPOI 从 xls 走向 xlsx、从内存写入走向流式写入的稳定落脚点,背后的问题其实很集中:不装 Office、不依赖 Windows 桌面环境,就能在 .NET 进程里生成、读取、修改 Excel。
它能解决两类具体诉求。一是把 DataTable、List 对象批量导成格式可控的报表文件,几十万行不把内存撑爆;二是从业务方发来的 Excel 里提数入库,而不是让用户另存为 CSV 再手工清洗。适合的读者是做报表导出、数据导入、定时批处理的后端开发者,也包括正在给历史项目换库的维护者。
下面从 DLL 本身讲起,把引用方式、最小写入、大数据量性能,以及五条高频排查记录依次讲透。照着做,你不需要在程序里塞一个 Office COM 组件。
2. 先认清 1.25 这版 DLL:三层对象模型、版本边界与引用清单
导入导出方案里最怕的不是不会写代码,而是不知道手里的库到底对应哪种 Excel 文件。NPOI 的命名习惯和 Apache POI 一脉相承,HSSF 管老版 .xls,XSSF 管新版 .xlsx,SXSSF 是 XSSF 的流式版本。搞清楚三者差异,才能真正理解标题里的 DLL 不是一个文件,而是一组按用途拆分的程序集。
2.1 HSSF/XSSF/SXSSF:Excel 文件格式与三个入口的对应关系
Excel 文件分两个时代。.xls 是 OLE2 复合文档,本质是一个二进制容器;.xlsx 是 OpenXML 格式,本质上是一个 zip 压缩包,里面塞着一堆 xml 文件。NPOI 没有选择自己发明一套中间格式,而是让每个入口各自实现一套完整读写链。HSSFWorkbook 对应老格式 .xls,XSSFWorkbook 对应新格式 .xlsx,SXSSFWorkbook 则是对 XSSFWorkbook 的流式封装。
这意味着你在代码里写的 ISheet、IRow、ICell,在三种入口下行为基本一致,但底层序列化方式完全不同。读写 .xlsx 时如果错用了 HSSFWorkbook,程序不会立即报错,而是写出一个扩展名为 .xlsx 但内部结构是 OLE2 的文件,Excel 打开时就会提示文件损坏。这种“文件能生成但打不开”的故障最难排查,因为它不抛异常,只在下游用户双击文件时暴露。
我一般会用一个很土的方法快速验证:把生成的 xlsx 用压缩工具直接打开,能看到 sheet1.xml、sharedStrings.xml、styles.xml 这些内部文件。看不到就说明用的入口不对。NPOI 的对象模型对应关系很直观,xlsx 的 zip 包是外壳,sheet1.xml 里每一行是一个 row 节点,每个单元格是一个 c 节点,单元格里的字符串值则指向 sharedStrings.xml 的索引。把这层映射想明白,遇到“读出来是索引数字”或者“写进去中文变乱码”时,就能猜到问题出在共享字符串表上。
2.2 1.25 版本值得关注的几个变化:哪些项目该升,哪些先别动
版本号从 1.24 走到 1.25,单看 release notes 会觉得改动不算大,但实际影响到项目落地的有三个方面。第一是 xlsx 写入时的内存占用模型有调整,同样一个两万行的导出任务,旧版本里把所有单元格对象都挂在内存里等最后一次性写盘,1.25 这代对流式写入的容忍度高了很多,SXSSFWorkbook 在正常窗口参数下能稳定扛住更大数据量。第二是公式求值器的覆盖范围变宽了,常见函数在读取侧拿到的不再是一堆缓存值。第三是依赖关系更干净,手动引用 DLL 时不再动不动要求一堆系统组件。
但这不代表所有项目都该立刻升。如果现有工程用的是老接口且只处理 .xls 文件,升级带来的收益非常有限,反而要重新回归测试所有工作表、单元格样式、合并单元格逻辑。我的习惯是:新项目直接上 1.25 或更新版本,旧项目只在遇到“内存溢出”“大数据量导出卡死”这类硬问题时才动升级的念头。表格对比更直观:
| 对比项 | 老版本 | 1.25 这代 |
|---|---|---|
| .xls 支持 | 完整 | 完整 |
| .xlsx 写入 | 内存全量 | 流式窗口可控 |
| 大数据量导出 | 易 OOM | 配合 SXSSF 可跑几十万行 |
| 公式读取 | 部分函数返回缓存 | 求值器覆盖更广 |
| 依赖数量 | 较多 | 精简但仍有主次之分 |
选择逻辑很简单:如果你的瓶颈在导出性能上,优先测 SXSSFWorkbook;如果你的瓶颈在读取不可控的第三方 Excel 上,升级前后各跑一遍同一批文件对比解析结果。版本升级不是目的,解决产线问题是目的。
2.3 DLL 到底是哪几个:bin 目录里的依赖关系与手动引用的注意点
把 NPOI 装进工程后,去 bin 目录看一眼,你会发现 NPOI 相关的文件有好几个。常见的一组是 NPOI.dll、NPOI.OOXML.dll、NPOI.OpenXml4Net.dll,以及一个第三方压缩库 ICSharpCode.SharpZipLib.dll(具体文件以你实际安装的包输出为准)。不同版本可能合并或拆分部分程序集,但核心逻辑是一样的:NPOI.dll 提供公共对象模型和 HSSF 实现,NPOI.OOXML.dll 提供 xlsx 的 OpenXML 实现,OpenXml4Net 负责 zip 包相关的底层解析,SharpZipLib 负责压缩流。
手动引用时最常犯的错是只拷贝 NPOI.dll 到生产服务器。按需加载的机制下,编译期不直接引用 NPOI.OOXML.dll 也能通过,因为代码里用的是 ISheet、IWorkbook 这类接口,运行期真正 new XSSFWorkbook 时才触发程序集加载。这时候报错信息往往指向“未能加载文件或程序集 NPOI.OOXML”,排查方向却经常被带偏到配置文件上。实际解决很简单:把 NuGet 安装后在 bin 目录里生成的那一组 NPOI 开头的 DLL 连同第三方依赖一起部署,不要手工挑选。
另外注意目标框架差异。在 .NET Framework 和 .NET Core/.NET 5+ 项目里,同一版本的 NPOI 可能拉取不同的依赖链,直接拿一套 DLL 跨框架复制是翻车高发区。我一般装完包第一件事就是看 NuGet 依赖树,确认最终输出目录里的 DLL 列表是完整的,再手动引用时才不会少带依赖。
3. 把 1.25 接进工程:从 NuGet 锁定版本到第一条可复用写入代码
原理说清楚后,接下来要让代码跑起来。整个接入过程不需要写任何 COM 调用,也不需要服务器安装 Office,一个类库工程加几行代码就能生成 xlsx。这一章按“安装 → 最小导出 → 批量封装”的顺序,把第一步到生产可用的代码完整过一遍。
3.1 用 NuGet 引入并锁定 1.25:两条命令与安装后检查
Visual Studio 里可以用程序包管理器控制台,也可以用 .NET CLI。关键点是安装时必须带版本号,防止后续全局升级把行为悄悄改掉。命令如下:
# Visual Studio 程序包管理器控制台 Install-Package NPOI -Version 1.25.0 # .NET CLI dotnet add package NPOI --version 1.25.0带 Version 参数相当于锁死版本,后续其他人 restore 时用的都是同一套编译产物。安装完成后有两个检查点:一是打开 .csproj 文件确认出现 PackageReference,二是去 bin 目录确认 NPOI.dll 以及相关依赖文件已经跟着输出。如果是在类库工程里引用,还需要确认最终部署项目是否把 NPOI 的 DLL 一起带到了输出目录,常见做法是在启动项目里也安装同一版本包,或者设置引用项目的 Copy Local 属性。
3.2 最小导出:创建 xlsx 并写入表头、数据与日期
下面这段代码是完整的导出流程,从创建空白工作簿到落盘成文件,包含表头、普通数据、日期三种典型单元格:
using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; // 1. 创建 xlsx 工作簿,using 保证 Dispose 释放非托管资源 using var workbook = new XSSFWorkbook(); ISheet sheet = workbook.CreateSheet("订单"); // 2. 创建表头行,行索引从 0 开始 IRow header = sheet.CreateRow(0); header.CreateCell(0).SetCellValue("订单号"); header.CreateCell(1).SetCellValue("金额"); header.CreateCell(2).SetCellValue("下单时间"); // 3. 写入两行业务数据 IRow row1 = sheet.CreateRow(1); row1.CreateCell(0).SetCellValue("A1001"); row1.CreateCell(1).SetCellValue(88.5); row1.CreateCell(2).SetCellValue(DateTime.Now); IRow row2 = sheet.CreateRow(2); row2.CreateCell(0).SetCellValue("A1002"); row2.CreateCell(1).SetCellValue(120); row2.CreateCell(2).SetCellValue(DateTime.Now); // 4. 用 FileStream 落盘,注意 using 保证流释放 using FileStream fs = new FileStream("orders.xlsx", FileMode.Create, FileAccess.Write); workbook.Write(fs);逻辑说明:CreateSheet 的参数是工作表名,不能重复也不能包含非法字符;CreateRow 和 CreateCell 的索引都是从 0 开始,和 Excel 界面里的行号差 1;日期直接 SetCellValue 时默认不会自动带日期格式,后续章节会专门讲这个问题。FileMode.Create 表示每次都覆盖旧文件,适合导出场景;如果要追加写入,需要先读原文件再改。
参数说明:FileAccess.Write 告诉系统这个流只用于写,避免 FileShare 冲突把文件锁住。workbook.Write 执行的是完整序列化,数据量大时这一步耗时会明显,不要放在 UI 线程里跑,导出一律走后台任务。
3.3 从 DataTable 批量灌入 sheet:一次写完整张表的封装
实际业务里数据源大多是 DataTable 或 List。下面这个函数把 DataTable 直接写成一张工作表,并自动处理空值与类型,是最常用的封装:
public static void WriteDataTableToSheet(DataTable table, ISheet sheet) { if (table == null || table.Columns.Count == 0) return; // 表头:取列名作为标题 IRow headerRow = sheet.CreateRow(0); for (int c = 0; c < table.Columns.Count; c++) { headerRow.CreateCell(c).SetCellValue(table.Columns[c].ColumnName); } // 数据行:行索引从 1 开始,给表头让位 for (int r = 0; r < table.Rows.Count; r++) { IRow row = sheet.CreateRow(r + 1); for (int c = 0; c < table.Columns.Count; c++) { object val = table.Rows[r][c]; if (val == null || val == DBNull.Value) { row.CreateCell(c).SetCellValue(string.Empty); continue; } switch (val) { case int i: case long l: case double d: case decimal m: row.CreateCell(c).SetCellValue(Convert.ToDouble(val)); break; case DateTime dt: row.CreateCell(c).SetCellValue(dt); break; default: row.CreateCell(c).SetCellValue(Convert.ToString(val)); break; } } } }逻辑说明:单元格类型按值的运行时类型决定,而不是统一 ToString。数字保持数字,日期保持日期,这样导出的文件在 Excel 里可以直接求和、筛选、排序。如果全部转成字符串,Excel 里会出现“文本型数字”,单元格左上角带绿色三角,下游用户的透视表也会把金额当文本处理。decimal 转 double 在展示层可接受,但要精确保留小数位,建议在写入前设置单元格 DataFormat。
参数说明:r + 1 是表头让位;DBNull.Value 是 DataTable 里最常见的空值形态,不处理会走 default 分支变成空字符串,虽然不报错,但会把 null 和纯空字符串混淆。这个函数只负责写数据,不负责样式,样式在第四章单独展开。
4. 把性能逼出来:流式写入与样式复用,两个必调的设置
很多项目在导出五千行时一切正常,到五万行时内存从几百兆涨到几个 G,最后 OOM。问题通常不在 NPOI 本身,而在写入方式和样式对象的滥用上。这一章给出两个最影响产线的设置:SXSSFWorkbook 的窗口参数,以及 CellStyle 的复用策略。
4.1 SXSSFWorkbook 的 windowSize:内存里留多少行最合适
XSSFWorkbook 把工作簿里的所有行、所有单元格全部存在内存对象里,写十万行就是十万个 Row 对象和几百万个 Cell 对象,GC 根本来不及回收。SXSSFWorkbook 的做法是只有最近 N 行驻留在内存,更早的行直接序列化到临时文件,从而把内存占用从 O(总行数) 降到 O(窗口大小)。代码用法如下:
using NPOI.XSSF.UserModel; using NPOI.SS.UserModel; // 窗口 100 行,超出部分交给临时文件 using var workbook = new SXSSFWorkbook(100); ISheet sheet = workbook.CreateSheet("大表"); // 写入逻辑与 XSSFWorkbook 完全一致 for (int i = 0; i < 100000; i++) { IRow row = sheet.CreateRow(i); row.CreateCell(0).SetCellValue("订单" + i); row.CreateCell(1).SetCellValue(i * 0.5); } // Dispose 时清理临时文件 workbook.Dispose();逻辑说明:SXSSFWorkbook(100) 的 100 就是 windowSize,表示内存中保留最近 100 行。窗口设得越小,内存越省,但窗口之外的 Row 对象再通过 GetRow 去访问时会拿到 null,这点容易造成误判。窗口设得太大就失去了流式写入的意义。我一般建议 100 到 500 之间,具体看单行数据宽度和服务器内存配额。
参数说明:SXSSFWorkbook 写完后必须 Dispose,否则系统临时目录里会残留以 poi 开头的临时文件。另外,SXSSFWorkbook 创建后想拿底层 XSSFWorkbook 对象时,要用 workbook.XSSFWorkbook 属性而不是强转,这是这代版本里容易踩的 API 差异。大数据量下也尽量不要调用 AutoSizeColumn,它会对每一列遍历全部行来计算列宽,十万行数据执行一次就是几十万次字符串宽度测量,极其耗时。需要列宽自适应时,在写完数据后只对个别关键列处理,或者干脆在模板里预设列宽。
4.2 CellStyle 复用与 DataFormat:把样式数从过万压到个位数
这个坑很多刚上手的人意识不到。每次调用 workbook.CreateCellStyle() 都会在 styles.xml 里生成一个新的样式记录,一万个数字单元格如果各自建样式,文件里就会有一万条几乎一模一样的样式定义。生成的 xlsx 体积膨胀,Excel 打开变慢,NPOI 写入时也会因为序列化大量样式而卡顿。正确做法是全局复用样式对象:
using NPOI.SS.UserModel; using NPOI.HSSF.Util; // 创建一个带边框和灰底的表头样式 ICellStyle headerStyle = workbook.CreateCellStyle(); IFont headerFont = workbook.CreateFont(); headerFont.IsBold = true; headerStyle.SetFont(headerFont); headerStyle.FillForegroundColor = IndexedColors.Grey25Percent.Index; headerStyle.FillPattern = FillPattern.SolidForeground; // 金额样式:两位小数,全局只建一次 ICellStyle amountStyle = workbook.CreateCellStyle(); amountStyle.DataFormat = workbook.CreateDataFormat().GetFormat("0.00"); // 日期样式:全局只建一次 ICellStyle dateStyle = workbook.CreateCellStyle(); dateStyle.DataFormat = workbook.CreateDataFormat().GetFormat("yyyy-mm-dd hh:mm:ss");使用时不重复创建,只做引用赋值:
IRow headerRow = sheet.CreateRow(0); ICell headerCell = headerRow.CreateCell(0); headerCell.SetCellValue("金额"); headerCell.CellStyle = headerStyle; // 数据行只赋样式引用,不新建样式对象 IRow dataRow = sheet.CreateRow(1); ICell cell = dataRow.CreateCell(0); cell.SetCellValue(88.5); cell.CellStyle = amountStyle;逻辑说明:CellStyle 对象挂在 workbook 级别,Sheet 之间也可以共用。DataFormat 的字符串格式和 Excel 的单元格格式写法一致,yyyy-mm-dd hh:mm:ss 是常见日期格式;金额用 0.00 表示保留两位小数。FillPattern 必须配合 FillForegroundColor 一起使用,只设置背景色不设置填充模式,颜色不会生效。这一点排在样式坑的第一位。
参数说明:如果业务需要按条件分色(比如正数红色、负数绿色),创建两到三个样式对象分条件赋值即可,不要每一行都新建。对样式数量有极致要求的场景,可以在写完文件后检查 xlsx 内部的 styles.xml,看样式记录数是否异常膨胀,这是判断是否滥用样式的最直接方法。
5. NPOI 1.25 落地避坑:五条高频排查记录与对应解法
这一章全是在实际项目里反复出现的故障。每一条都按现象、原因、解决的顺序写,可以直接当排查手册用。
5.1 “文件正由另一进程使用”:流没关完,覆盖失败
现象:第一次导出成功,第二次在 FileStream 创建时报 IOException,提示文件正由另一进程使用,或者生成的文件被 Excel 打开时无法覆盖。
原因:最常见的是 FileStream 没有释放。workbook.Write(fs) 把数据写进流后,如果流没有 Close/Dispose,文件句柄一直被进程拽着;另一种情况是用户正在 Excel 里打开着同名文件,操作系统不允许覆盖。
解决:用 using 包住 FileStream,并确保 workbook 先于流释放。如果业务上允许被占用时跳过,可以在写入前先探测文件是否存在并可写;如果部署在 Windows 服务里,还要注意服务账号对目录的写权限。注意顺序问题:某些场景下先 Dispose workbook 再 Dispose 流更稳,所以在嵌套 using 时把 workbook 放内层。
5.2 十万行导出内存暴涨:不该用 XSSFWorkbook 硬扛
现象:导出任务从几千行加到十万行后,内存占用一路涨到两个 G,甚至出现 OutOfMemoryException。部分机器侥幸跑完,但导出耗时从几秒变成几分钟。
原因:XSSFWorkbook 把所有行和单元格对象全部驻留内存。每新增一行就是一组对象引用,十万行就是百万级对象,GC 压力极大。
解决:换 SXSSFWorkbook,窗口设置 100 到 500。另一个被忽略的因素是样式复用,确认是不是每行都在创建新 CellStyle,是的话先改掉再测。改完后的目标不是消灭内存占用,而是把内存从“随总行数线性增长”变成“稳定在一个窗口大小附近”。
5.3 日期变成一串小数:单元格格式没有设
现象:DateTime 写入后,Excel 里显示的是 45000.123456 这样的数字,而不是 2025-06-18 这样的日期。
原因:Excel 内部把日期存成 OLE 自动化日期序列号,整数部分是日期,小数部分是时间。NPOI 写入 DateTime 时写入的是序列号,没有附加日期格式,Excel 就按普通数字渲染了。
解决:给日期单元格设置带日期格式的 CellStyle,代码在 4.2 节已经给出。这里补充一个细节:先 SetCellValue 再赋 CellStyle 还是先赋样式再写值,顺序不敏感,但样式对象必须是 workbook.CreateCellStyle() 创建过的,不能跨工作簿共用。另外从 Excel 里读取日期时也会遇到反向问题,DataFormatter 在第六章展开。
5.4 公式读出来是旧值:求值器缺席
现象:程序写入公式后,再用 NPOI 读回来,拿到的不是计算结果而是公式字符串本身,或者拿到的是写入时的旧缓存值。用 Excel 打开则显示正常。
原因:xlsx 文件里公式单元格同时存公式文本和缓存值。NPOI 写入公式时如果没有显式计算,文件里可能没有缓存结果,复合公式在打开时由 Excel 引擎计算;纯读取场景下 NPOI 不会自动触发重算。
解决:读取端使用 FormulaEvaluator 主动求值。代码可以统一这样处理:
using NPOI.SS.UserModel; IFormulaEvaluator evaluator = workbook.GetCreationHelper().CreateFormulaEvaluator(); ICell cell = row.GetCell(0); if (cell.CellType == CellType.Formula) { CellValue evaluated = evaluator.Evaluate(cell); string result = evaluated.StringValue; // 或 Format(builtinFormat) 转成显示值 }逻辑说明:Evaluate 返回 CellValue 对象,根据求值结果类型取 StringValue 或 NumberValue。如果业务上要求导出文件自带计算结果,可以在写入公式后调用 evaluator.EvaluateAll(),让公式在文件里留下缓存值,下游用户用 WPS 或低版本 Excel 打开时也能看到数字。
5.5 “未能加载文件或程序集”:依赖 DLL 没带全
现象:开发环境编译通过、本地运行正常,部署到生产后启动报错,提示找不到 NPOI.OOXML 或某个第三方依赖程序集。有的还伴随版本号不匹配提示。
原因:手动发布时只拷贝了 NPOI.dll,或者拷贝的依赖 DLL 版本与开发环境不一致。程序运行到 new XSSFWorkbook 时才按需加载 NPOI.OOXML,所以错误往往在某个业务操作时才暴露,启动阶段看起来一切正常。
解决:把 NuGet 安装后 bin 目录里生成的所有 NPOI 开头的 DLL 以及 ICSharpCode.SharpZipLib 这类第三方依赖一起部署。不要手工挑选,输出目录就是最佳清单。如果用了发布工具,检查是否开启了“剪裁未使用程序集”之类的优化项,这选项可能把运行期才加载的依赖裁掉。版本不匹配时报错信息里通常会带明确的目标版本号,顺着提示把 NuGet 里的包版本对齐即可。
6. 读取时少踩一半坑的技巧:用 DataFormatter 拿“Excel 显示值”
读取 Excel 最麻烦的不是解析,而是把单元格值还原成用户眼里看到的样子。直接读 ICell 时,数字单元格拿的是 double,日期单元格拿的是 OLE 序列号,样式里设置的千分位、百分比、自定义格式全部要自己处理。NPOI 提供了一个专门干这事的类:DataFormatter。它能按照单元格自己的格式,把值渲染成 Excel 界面上显示的那个字符串。
using NPOI.SS.Util; using System.Globalization; // 显式指定区域,避免部署环境 culture 差异导致日期格式漂移 DataFormatter formatter = new DataFormatter(CultureInfo.GetCultureInfo("zh-CN")); for (int r = 0; r <= sheet.LastRowNum; r++) { IRow row = sheet.GetRow(r); if (row == null) continue; // 注意行可能为空 for (int c = 0; c < row.LastCellNum; c++) { ICell cell = row.GetCell(c); string displayValue = formatter.FormatCellValue(cell); // displayValue 就是用户在这个单元格里看到的文本 } }逻辑说明:Formatter 会读取单元格的 DataFormat,把 1.5 格式化成“1.50”,把日期序列号格式化成“2025-06-18 10:30:00”,把百分比格式化成“12.30%”。这样导入逻辑就不用手写一堆 switch 去猜类型,代码量和出错率都会明显下降。唯一要注意的是公式单元格,DataFormatter 默认拿到的是缓存值,如果缓存值不存在或已过期,需要先配合 FormulaEvaluator 求值后再格式化。
我第一次写报表读取时,就是直接 swich 判断 CellType,然后日期转成了 OLE 数字,金额格式也丢了,导到库里全变文本,那真是最折腾的一版代码。后来抽了这个公共方法,所有解析 Excel 的接口统一走 DataFormatter,列名和预设格式对不上时先格式化再比对,再也不用手工处理区域差异。这个技巧不是银弹,但能让导入侧的维护成本低一个量级。希望帮到你。
本文还有配套的精品资源,点击获取