简介: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.rar:NPOI 与 SharpZipLib 的分工与选型理由
2.1 为什么是 NPOI 而不是 EPPlus 或 ClosedXML
在 .NET 生态里做 Excel 导出,常见选项有 NPOI、EPPlus、ClosedXML、Aspose.Cells。LmyExamExport 选了 NPOI,原因很实际:NPOI 同时支持 .xls(HSSF)和 .xlsx(XSSF),不依赖 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 每调用一次就新增一个 sheet,sheet 名不能重复且不能超过 31 个字符。参数说明:XSSFWorkbook 对应 .xlsx,HSSFWorkbook 对应 .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-9,6 是速度和体积的平衡点 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 version="1.0" encoding="utf-8"?> <packages> <package id="NPOI" version="2.5.6" targetFramework="net461" /> <package id="SharpZipLib" version="1.3.3" targetFramework="net461" /> </packages>版本锁定很重要:NPOI 2.5.x 和 2.6.x 在单元格样式 API 上有差异,SharpZipLib 1.3.x 修复了 1.2.x 的 zip 路径穿越漏洞。App.config 则管连接字符串和导出路径:
<configuration> <appSettings> <add key="ExportPath" value="D:\Export\" /> <add key="SheetNamePrefix" value="Exam_" /> </appSettings> <connectionStrings> <add name="ExamDb" connectionString="Server=.;Database=Exam;Integrated Security=true;" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>逻辑说明:appSettings 读路径和前缀,connectionStrings 读数据库。参数说明:ExportPath 末尾必须带反斜杠,否则拼接文件名时会出错。SheetNamePrefix 用于给 sheet 名加统一前缀,避免和 Excel 保留名冲突。
3. 用 NPOI 把多个 Excel 合并到一个 Excel 的多个 sheet
3.1 合并场景的两种数据来源:内存 DataTable 与磁盘文件
热搜里“npoi 多个 excel 合并到一个 excel 多个 sheet”通常有两种情况:一是数据已经在内存里(DataTable/List),直接写多个 sheet;二是磁盘上已有多个独立 Excel 文件,需要读进来再合并。LmyExamExport 更偏向第一种,因为考试数据来自数据库查询。但第二种也常见,比如把各科老师分别交上来的成绩表合并。
内存合并的核心是复用 IWorkbook 对象,每类数据创建一个 sheet:
public 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 不会自动根据扩展名切换实现,必须手动匹配。解决:写一个工厂方法,根据输出路径后缀返回对应的 IWorkbook:
public 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 name="examExport" type="System.Configuration.NameValueSectionHandler" /> </configSections> <examExport> <add key="Sheet_考生信息" value="准考证号,姓名,身份证号" /> <add key="Sheet_成绩明细" value="科目,分数,考试时间" /> </examExport>读取时用 ConfigurationManager.GetSection("examExport") 拿到 NameValueCollection,key 是 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 打包这个坑让我养成了一个习惯:任何流式写入的库,打包前先确认它已经彻底释放资源。希望帮到你。
本文还有配套的精品资源,点击获取