资讯详情

资讯详情

NPOI多Sheet合并与SharpZipLib打包:LmyExamExport导出工具实战

简介LmyExamExport.rar 是一套面向教育工作者与 C# 开发者的蓝墨云试题导出工具源码针对平台仅支持导入、无法直接导出试题数据的痛点借助 NPOI 库解析并重组 Excel 试题文件生成完整试题库并支持是否显示答案的可选设置兼顾教师评估与学生自测场景。资源包共 85 个文件约 17.87MB包含 10 个 cs 源码、22 个 dll 依赖、8 个 xml 配置、4 个 config 与 3 个 exe 程序等另有 xls/xlsx 示例数据、resx 资源文件及 sln、csproj、vdproj 工程文件覆盖从源码到部署的完整结构。目前已有 1167 人学习下载。读者可从中掌握 NPOI 读写 Excel、C# 面向对象封装试题属性、WinForm 界面交互与打包部署等实用技能并直接复用工具完成试题导出是学习开源技术在教育信息化中落地的参考实例。1. 从 LmyExamExport.rar 说起一个被低估的导出工具到底解决了什么如果你手头有一份 LmyExamExport.rar解压后看到 Program.cs、App.config、packages.config 这几个文件大概率会先愣一下——这不是一个标准的 .NET 控制台项目吗没错它就是一个用 C# 写的命令行导出工具核心依赖是 NPOI 和 SharpZipLib 两个库。NPOI 负责读写 Excel 文件SharpZipLib 负责压缩打包Program.cs 是入口逻辑App.config 管连接字符串和路径配置packages.config 锁定 NuGet 包版本。这套组合在考试系统、教务管理、题库导出场景里非常常见把数据库里的题目、成绩、考生信息按规则导出成 Excel再打包成 zip 交付。热搜里有人问“npoi 多个 excel 合并到一个 excel 多个 sheet”这恰好是这类工具最典型的诉求之一。LmyExamExport 这个名字本身就暗示了“考试导出”的定位它要解决的不是简单的单表导出而是多维度、多 sheet、带格式、可压缩的批量交付问题。适合谁看如果你正在维护一个老旧的 .NET Framework 导出模块或者需要从零搭一个类似的导出工具又或者你拿到了这个 rar 但不知道从哪下手改那接下来的内容就是为你准备的。我会按“先跑通、再改参数、最后避坑”的顺序把 NPOI 多 sheet 合并、SharpZipLib 打包、配置文件管理这几件事讲透。2. 拆开 LmyExamExport.rarNPOI 与 SharpZipLib 的分工与选型理由2.1 为什么是 NPOI 而不是 EPPlus 或 ClosedXML在 .NET 生态里做 Excel 导出常见选项有 NPOI、EPPlus、ClosedXML、Aspose.Cells。LmyExamExport 选了 NPOI原因很实际NPOI 同时支持 .xlsHSSF和 .xlsxXSSF不依赖 Office COM 组件能在服务器端无 Office 环境运行而且开源免费。EPPlus 从 5.0 开始商业授权收紧ClosedXML 对 .xls 支持弱Aspose 要钱。考试导出场景经常要兼容老系统留下的 .xls 模板NPOI 是少数能同时处理两种格式的库。NPOI 的核心对象模型分三层Workbook工作簿、Sheet工作表、Row/Cell行/单元格。写一个多 sheet 导出的最小骨架如下// 引入 NPOI 命名空间 using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; // .xlsx 用 XSSFWorkbook.xls 用 HSSFWorkbook // 创建工作簿 IWorkbook workbook new XSSFWorkbook(); // 创建第一个 sheet ISheet sheet1 workbook.CreateSheet(考生信息); IRow header1 sheet1.CreateRow(0); header1.CreateCell(0).SetCellValue(准考证号); header1.CreateCell(1).SetCellValue(姓名); // 创建第二个 sheet ISheet sheet2 workbook.CreateSheet(成绩明细); IRow header2 sheet2.CreateRow(0); header2.CreateCell(0).SetCellValue(科目); header2.CreateCell(1).SetCellValue(分数); // 写入文件流 using (FileStream fs new FileStream(output.xlsx, FileMode.Create, FileAccess.Write)) { workbook.Write(fs); }逻辑说明CreateSheet 每调用一次就新增一个 sheetsheet 名不能重复且不能超过 31 个字符。参数说明XSSFWorkbook 对应 .xlsxHSSFWorkbook 对应 .xls选错会导致文件打不开。实际项目中sheet 名往往来自数据库里的分类字段比如“语文”“数学”“英语”需要做去重和截断处理。2.2 SharpZipLib 在导出链路里的位置SharpZipLib 负责把生成好的 Excel 文件压缩成 zip。为什么不用 System.IO.Compression因为 SharpZipLib 对 .NET Framework 2.0/3.5 兼容更好而且支持设置压缩级别、密码、分卷。LmyExamExport 这类工具经常要交付给第三方打包成 zip 能减少传输体积也方便一次性下载多个文件。最小打包代码using ICSharpCode.SharpZipLib.Zip; // 创建 zip 输出流 using (FileStream fsOut File.Create(export.zip)) using (ZipOutputStream zipStream new ZipOutputStream(fsOut)) { zipStream.SetLevel(6); // 压缩级别 0-96 是速度和体积的平衡点 string[] files Directory.GetFiles(output_dir, *.xlsx); foreach (string file in files) { ZipEntry entry new ZipEntry(Path.GetFileName(file)); entry.DateTime DateTime.Now; zipStream.PutNextEntry(entry); byte[] buffer File.ReadAllBytes(file); zipStream.Write(buffer, 0, buffer.Length); zipStream.CloseEntry(); } }逻辑说明SetLevel(6) 是常用值0 不压缩、9 最慢最小。参数说明ZipEntry 的 DateTime 不设会默认 1970 年某些解压工具会报错。PutNextEntry 和 CloseEntry 必须成对出现否则 zip 结构损坏。2.3 packages.config 与 App.config 的版本锁定策略packages.config 是 NuGet 在旧版 .NET Framework 项目里的包管理文件它记录了每个包的 id、version、targetFramework。LmyExamExport 里通常长这样?xml version1.0 encodingutf-8? packages package idNPOI version2.5.6 targetFrameworknet461 / package idSharpZipLib version1.3.3 targetFrameworknet461 / /packages版本锁定很重要NPOI 2.5.x 和 2.6.x 在单元格样式 API 上有差异SharpZipLib 1.3.x 修复了 1.2.x 的 zip 路径穿越漏洞。App.config 则管连接字符串和导出路径configuration appSettings add keyExportPath valueD:\Export\ / add keySheetNamePrefix valueExam_ / /appSettings connectionStrings add nameExamDb connectionStringServer.;DatabaseExam;Integrated Securitytrue; providerNameSystem.Data.SqlClient / /connectionStrings /configuration逻辑说明appSettings 读路径和前缀connectionStrings 读数据库。参数说明ExportPath 末尾必须带反斜杠否则拼接文件名时会出错。SheetNamePrefix 用于给 sheet 名加统一前缀避免和 Excel 保留名冲突。3. 用 NPOI 把多个 Excel 合并到一个 Excel 的多个 sheet3.1 合并场景的两种数据来源内存 DataTable 与磁盘文件热搜里“npoi 多个 excel 合并到一个 excel 多个 sheet”通常有两种情况一是数据已经在内存里DataTable/List直接写多个 sheet二是磁盘上已有多个独立 Excel 文件需要读进来再合并。LmyExamExport 更偏向第一种因为考试数据来自数据库查询。但第二种也常见比如把各科老师分别交上来的成绩表合并。内存合并的核心是复用 IWorkbook 对象每类数据创建一个 sheetpublic void ExportMultiSheet(DataSet ds, string outputPath) { IWorkbook workbook new XSSFWorkbook(); foreach (DataTable table in ds.Tables) { string sheetName table.TableName; // sheet 名去重和截断 if (sheetName.Length 31) sheetName sheetName.Substring(0, 31); if (workbook.GetSheet(sheetName) ! null) sheetName sheetName _ Guid.NewGuid().ToString(N).Substring(0, 4); ISheet sheet workbook.CreateSheet(sheetName); // 写表头 IRow headerRow sheet.CreateRow(0); for (int i 0; i table.Columns.Count; i) { headerRow.CreateCell(i).SetCellValue(table.Columns[i].ColumnName); } // 写数据 for (int r 0; r table.Rows.Count; r) { IRow row sheet.CreateRow(r 1); for (int c 0; c table.Columns.Count; c) { row.CreateCell(c).SetCellValue(table.Rows[r][c].ToString()); } } } using (FileStream fs new FileStream(outputPath, FileMode.Create, FileAccess.Write)) { workbook.Write(fs); } }逻辑说明DataSet 里每个 DataTable 对应一个 sheet表名做去重和长度截断。参数说明GetSheet 返回 null 表示不存在Guid 截取 4 位做后缀足够避免重复。注意 CreateRow 的索引从 0 开始表头占 0数据从 1 开始。磁盘文件合并则需要先读再写public void MergeExcelFiles(string[] filePaths, string outputPath) { IWorkbook outWorkbook new XSSFWorkbook(); foreach (string path in filePaths) { using (FileStream fs new FileStream(path, FileMode.Open, FileAccess.Read)) { IWorkbook inWorkbook WorkbookFactory.Create(fs); for (int i 0; i inWorkbook.NumberOfSheets; i) { ISheet inSheet inWorkbook.GetSheetAt(i); string newName Path.GetFileNameWithoutExtension(path) _ inSheet.SheetName; if (newName.Length 31) newName newName.Substring(0, 31); ISheet outSheet outWorkbook.CreateSheet(newName); // 复制行和单元格 for (int r 0; r inSheet.LastRowNum; r) { IRow inRow inSheet.GetRow(r); if (inRow null) continue; IRow outRow outSheet.CreateRow(r); for (int c 0; c inRow.LastCellNum; c) { ICell inCell inRow.GetCell(c); if (inCell null) continue; ICell outCell outRow.CreateCell(c); outCell.SetCellValue(inCell.ToString()); } } } } } using (FileStream fsOut new FileStream(outputPath, FileMode.Create, FileAccess.Write)) { outWorkbook.Write(fsOut); } }逻辑说明WorkbookFactory.Create 能自动识别 .xls 和 .xlsx。参数说明LastRowNum 和 LastCellNum 是 inclusive 和 exclusive 的区别LastRowNum 包含最后一行LastCellNum 不包含最后一列。复制时只复制了值没复制样式如果需要样式要额外处理。3.2 sheet 命名冲突与 Excel 保留字的处理Excel 对 sheet 名有硬性限制不能超过 31 字符不能包含 : \ / ? * [ ]不能为空不能叫 History。实际项目中数据库里的分类名经常带这些字符。我一般会写一个清洗函数public string SanitizeSheetName(string raw) { if (string.IsNullOrWhiteSpace(raw)) return Sheet; string name raw.Trim(); char[] invalid { :, \\, /, ?, *, [, ] }; foreach (char c in invalid) { name name.Replace(c, _); } if (name.Length 31) name name.Substring(0, 31); if (name.Equals(History, StringComparison.OrdinalIgnoreCase)) name History_1; return name; }逻辑说明先替换非法字符再截断最后处理保留字。参数说明Trim 去空格因为 Excel 不允许 sheet 名首尾有空格。这个函数在每次 CreateSheet 前调用能避免 90% 的命名异常。3.3 大数据量下的内存控制SXSSFWorkbook 的引入时机NPOI 的 XSSFWorkbook 把所有数据放内存导出几万行没问题但上百万行就会 OOM。LmyExamExport 如果面对的是大型考试考生几十万就需要换成 SXSSFWorkbook。它基于流式写入只保留最近 N 行在内存// 保留 100 行在内存其余刷到磁盘临时文件 IWorkbook workbook new SXSSFWorkbook(100); // 使用方式和 XSSFWorkbook 一致 ISheet sheet workbook.CreateSheet(大数据); // ... 写入逻辑 ... // 最后必须调用 Dispose 清理临时文件 ((SXSSFWorkbook)workbook).Dispose();逻辑说明SXSSFWorkbook 构造参数是内存中保留的行数越小内存占用越低但磁盘 IO 越多。参数说明Dispose 必须调用否则临时文件不会删除。注意 SXSSFWorkbook 不支持读取只能写。如果既要读又要写还是得用 XSSFWorkbook。4. 避坑与排查LmyExamExport 类工具最常见的 5 个翻车点4.1 现象导出的 Excel 打开提示“文件格式与扩展名不符”原因用 HSSFWorkbook 生成了 .xlsx 文件或者用 XSSFWorkbook 生成了 .xls 文件。NPOI 不会自动根据扩展名切换实现必须手动匹配。解决写一个工厂方法根据输出路径后缀返回对应的 IWorkbookpublic IWorkbook CreateWorkbook(string filePath) { if (filePath.EndsWith(.xls, StringComparison.OrdinalIgnoreCase)) return new HSSFWorkbook(); else return new XSSFWorkbook(); }4.2 现象SharpZipLib 打包后的 zip 在 Windows 资源管理器里能打开但用 7-Zip 报“头部错误”原因ZipOutputStream 没有正确关闭或者 PutNextEntry 后没有 CloseEntry。解决确保 using 块包裹 ZipOutputStream并且每个 entry 都成对调用。另外SetLevel 不要设 0某些解压工具对存储模式兼容不好。4.3 现象App.config 里的连接字符串在开发机正常部署到服务器后读不到原因App.config 在编译后变成 YourApp.exe.config如果服务器上只拷贝了 exe 没拷贝 config或者 config 里的 name 和代码里 ConfigurationManager.ConnectionStrings[ExamDb] 不一致。解决部署时确保 exe.config 同目录并且用 ConfigurationManager.AppSettings 和 ConnectionStrings 的键名做一次启动自检读不到就写日志。4.4 现象NPOI 写入日期格式变成数字比如 44562原因SetCellValue 传了 DateTime 但没设 CellStyle 的 DataFormat。解决创建日期样式并应用到单元格ICellStyle dateStyle workbook.CreateCellStyle(); dateStyle.DataFormat workbook.CreateDataFormat().GetFormat(yyyy-MM-dd); cell.CellStyle dateStyle; cell.SetCellValue(DateTime.Now);4.5 现象packages.config 里 NPOI 版本和实际引用不一致编译报“找不到方法”原因NuGet 还原时用了缓存里的旧版本或者手动改了 packages.config 但没更新引用。解决删除 packages 文件夹和 bin/obj重新 NuGet restore。如果项目用 PackageReference 格式packages.config 就不该存在两者混用会冲突。5. 进阶用配置文件驱动导出模板让 LmyExamExport 不再硬编码5.1 把 sheet 结构抽到 App.config 的 section 里硬编码 sheet 名和列名是这类工具最大的维护痛点。改一个字段要重新编译。我一般会把 sheet 定义放到自定义配置节configSections section nameexamExport typeSystem.Configuration.NameValueSectionHandler / /configSections examExport add keySheet_考生信息 value准考证号,姓名,身份证号 / add keySheet_成绩明细 value科目,分数,考试时间 / /examExport读取时用 ConfigurationManager.GetSection(examExport) 拿到 NameValueCollectionkey 是 sheet 名value 是逗号分隔的列名。这样新增一个 sheet 只需要改 config不用动代码。5.2 用反射把 DataTable 列名映射到配置列如果 DataTable 的列名和配置不一致可以加一层映射public void ExportByConfig(DataTable table, string sheetName, string[] columns) { ISheet sheet workbook.CreateSheet(SanitizeSheetName(sheetName)); IRow header sheet.CreateRow(0); for (int i 0; i columns.Length; i) { header.CreateCell(i).SetCellValue(columns[i]); } for (int r 0; r table.Rows.Count; r) { IRow row sheet.CreateRow(r 1); for (int c 0; c columns.Length; c) { string colName columns[c]; if (table.Columns.Contains(colName)) row.CreateCell(c).SetCellValue(table.Rows[r][colName].ToString()); else row.CreateCell(c).SetCellValue(); } } }逻辑说明配置里的列名去 DataTable 里找找不到就填空避免抛异常。参数说明table.Columns.Contains 是大小写敏感的配置里要和数据库查询的别名一致。5.3 验证导出结果用 NPOI 读回来做自检导出完成后我习惯写一个自检方法把生成的 Excel 读回来检查 sheet 数量、每个 sheet 的行数、表头是否匹配public bool VerifyExport(string filePath, int expectedSheets, int[] expectedRows) { using (FileStream fs new FileStream(filePath, FileMode.Open, FileAccess.Read)) { IWorkbook wb WorkbookFactory.Create(fs); if (wb.NumberOfSheets ! expectedSheets) return false; for (int i 0; i expectedSheets; i) { ISheet sheet wb.GetSheetAt(i); if (sheet.LastRowNum 1 ! expectedRows[i]) return false; } } return true; }逻辑说明LastRowNum 从 0 开始所以行数要加 1。参数说明expectedRows 数组要和 sheet 顺序一致。这个自检放在导出后、打包前能拦住大部分数据错位问题。5.4 一个我踩过的坑SXSSFWorkbook 和 SharpZipLib 同时用时的临时文件冲突有一次导出百万行数据用 SXSSFWorkbook 写 Excel再用 SharpZipLib 打包。结果打包时偶尔报“文件被占用”。原因是 SXSSFWorkbook 的临时文件还没释放Dispose 调用时机不对。后来改成先 Dispose 再打包并且把临时文件目录设到独立路径// 设置临时文件目录 System.IO.File.SetAttributes(temp, FileAttributes.Temporary); SXSSFWorkbook wb new SXSSFWorkbook(100); // ... 写入 ... wb.Dispose(); // 必须先释放 // 然后再用 SharpZipLib 打包这个坑让我养成了一个习惯任何流式写入的库打包前先确认它已经彻底释放资源。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →