☰
C#用NPOI把Excel转DataTable的完整实践指南
2026/9/26 13:46:37 网站建设 项目流程

1. 用C#把Excel转DataTable,这件事到底卡在哪

1.1 需求拆开看:一次转换由哪几件事组成

做C#开发,只要跟业务系统打交道,几乎都遇到过把Excel表格转换为DataTable这种需求。客户甩过来一张Excel参数表、一份经营台账、一个产品清单,你第一反应就是把它读进内存,去关联、去筛选、去批量落库。这个需求的本质,是把Excel这种带格式、多工作表、单元格类型混乱的“结构化文件”,映射成程序里统一好用的DataTable对象。

它不是一个简单的foreach读文件,而是包含文件定位、工作表选择、表头识别、行列遍历、单元格类型转换、空值处理、合并单元格处理的一整串动作。任何一步没做好,读出来的数据就可能是科学计数法、日期序列号、丢行缺列,甚至直接抛异常。

1.2 什么场景下最容易碰到这个需求

越是偏工业、偏上位机的项目,越逃不开这个需求。比如C#上位机里要读取PLC点位表,点位表是从西门子博途导出来再手工整理成Excel的,程序启动时需要把它变成DataTable,再转成点位配置集合加载到通讯模块。再比如ERP、MES系统做数据迁移时,Excel里是整理好的库存初始数据,要批量灌进数据库,也得先转DataTable,再配合SqlBulkCopy一次性写入。

这类场景还有个共同特点:数据来源不可控。你无法要求用户上传的Excel格式规整、列名唯一、没有合并单元格。程序要做的就是把这些“人类友好”但“机器不友好”的表格,稳妥地变成后面逻辑能直接消费的内存数据。

1.3 为什么偏偏是DataTable而不是List<实体类>

有人会问,我直接用List<实体类>不就行了?这个问题的答案取决于你是否提前知道列结构。Excel文件是在运行时才被用户选定打开的,表头可能有三行,可能有合并单元格,可能存在重复列名,这种情况下你没法在编译期定义好一个强类型实体类。

DataTable的优势在于:列是动态生成的,行按需添加,自带主键约束、唯一约束、行筛选和关系映射。最关键的是,很多底层接口直接吃DataTable。比如SqlBulkCopy的WriteToServer方法,参数就是DataTable;WinForms的DataGridView、WPF的DataGrid,拖一个DataSource就能显示。它就是.NET里那个“万能中转站”。

2. 四条主流路线:COM、NPOI、EPPlus、ADO.NET怎么选

2.1 COM组件:能用,但别让它出现在生产环境

最早做WinForm的时候,大家都喜欢引Microsoft.Office.Interop.Excel的COM引用。它的机制是程序通过RCW在运行时启动一个真实的EXCEL.EXE进程,用Office的对象模型去打开文件、遍历Cells、再关闭保存。

优点很突出:所见即所得,Excel能显示的它都能读到,还能触发公式重算。但缺点同样突出:服务器环境要装Office,许可和稳定性都让人头疼,Excel进程一旦没杀干净,任务管理器里就会躺着一堆EXCEL.EXE,文件也被占用。我曾经在服务器上接一个定时任务,跑了一年多内存暴涨,查下来就是COM对象没释放干净。在.NET Core和.NET 5+里,COM互操作的支持也更麻烦,所以这条路线我基本只用来给老项目修修补补。

2.2 NPOI:免费开源里的主力选择

NPOI是POI项目的.NET移植版,Apache 2.0协议,可以免费商用。它不依赖Office,直接在内存里解析Excel文件的二进制和XML结构。老式xls用HSSFWorkbook,新式xlsx用XSSFWorkbook,两者共同实现IWorkbook、ISheet、IRow、ICell这套统一模型。

优点是轻、可控、支持Linux容器,非常适合做Web上传解析和后台批量处理。缺点是不会执行Excel公式,只读取缓存结果,对复杂图表、透视表的支持也有限。但这些在做DataTable转换的场景里基本不影响,因为我们关心的是单元格的值,不是Excel的渲染效果。

2.3 EPPlus:功能更强,但有授权门槛

EPPlus的功能比NPOI更丰富,样式、公式、图表、数据透视表都有较好的支持。不过从5.0版本开始,EPPlus改变了许可证模型,商业环境中使用需要购买授权。

如果只是做Excel转DataTable这种解析任务,NPOI完全够用,没必要为库的使用权限多一份纠结。但如果你同时有生成复杂Excel报表的需求,项目预算允许,EPPlus也是值得考虑的,它写出来的报表在样式控制上确实精确很多。

2.4 ADO.NET驱动:最快,但脾气最差

还有一条常被忽略的路:用OleDb或ACE驱动把Excel当数据库来查。连接字符串写Provider=Microsoft.ACE.OLEDB.12.0,Data Source指向文件,Extended Properties指定Excel版本和HDR,然后直接SELECT * FROM [Sheet1$],再用OleDbDataAdapter.Fill填充DataTable。

它的速度比NPOI快很多,内存占用也低,几十万行都能吃得消。但最烦人的是列类型推断不可控:同一列出现“数字加文本”的混搭时,ACE经常把整列读成Null,或者把长数字变成科学计数,IMEX=1也只能缓解不能根治。数据规整的时候它是神器,数据一混乱就是灾难。

2.5 四条路线横向对比

方案依赖Office读取速度类型控制授权适用场景
COM组件是慢强Office许可老WinForm、需要公式重算的少量数据
NPOI否中强Apache 2.0主流首选,Web、服务端、桌面都行
EPPlus否中强5.0后商用需授权同时要写Excel、生成报表的场景
OleDb/ACE否(需驱动)快弱引擎随系统大文件、列类型规整、只读数据

3. 用NPOI实现转换:几个最关键的细节

3.1 环境准备和命名空间

在VS里通过NuGet安装NPOI,然后引入这几个命名空间:NPOI.SS.UserModel、NPOI.HSSF.UserModel、NPOI.XSSF.UserModel、NPOI.SS.Util。

前面两个对应老式xls,XSSF对应新式xlsx,SS.Util里面有CellRangeAddress和DateUtil这些工具类。这里有个设计上的好处:HSSFWorkbook和XSSFWorkbook都实现了IWorkbook,所以只要在打开文件时区分一下格式,后续所有代码都可以统一走ISheet、IRow、ICell接口,不用为两种格式写两套逻辑。

3.2 为什么程序入口必须先区分xls和xlsx

xls是OLE复合文档格式,xlsx是ZIP压缩包,里面装着一堆XML,包括sharedStrings.xml、sheet1.xml、styles.xml。解析方式完全不同,所以代码入口要根据扩展名分别new HSSFWorkbook和XSSFWorkbook。

对于.xlsm(启用宏的xlsx),也可以用XSSFWorkbook读取。这里要注意,如果你只是读取数据,问题不大;如果读出来之后还要回写,要小心不要破坏原有的宏结构。我们的场景是转DataTable,只读不改,所以直接用XSSFWorkbook就行。

3.3 表头怎么转成DataTable的列

表头行并不总是第0行。有人会在第1行写大标题,第2行才是字段名,所以headerRowIndex必须作为参数传进来,由调用方决定。

确定表头行之后,先遍历一遍所有行求出最大列数。因为Excel的列不保证是规则矩形,有的行只有两列,有的行有十列,不先求出最大值,后面给DataRow赋值时列数不够就会丢失数据。

表头内容建议用DataFormatter去取,它能按单元格格式返回显示字符串,避免数字格式的表头变成“4.1”这种奇怪样子。列名要Trim,空值用Column1兜底,重复名加_1、_2后缀。列类型统一用typeof(object),原因在下面单元格映射部分详细说。

3.4 单元格类型映射规则

这是整个转换的核心。NPOI里每个单元格有一个CellType,取值有String、Boolean、Numeric、Formula、Blank、Error等。映射规则用表格表示比较清楚:

NPOI类型对应处理说明
String返回cell.StringCellValue纯文本
Boolean返回cell.BooleanCellValue布尔值
Numeric用IsCellDateFormatted判断,是日期格式则返回DateTime,否则返回double数字和日期都走这里
Formula按CachedFormulaResultType取缓存结果NPOI不重算公式
Blank返回DBNull.Value空值
Error返回错误标记极少见

列类型用object的好处是:Excel一列里可能混着数字、文本、日期,如果你提前把列声明成double或string,转换时遇到类型不符就会抛异常。用object承接,虽然后续用起来要转型,但至少数据不会丢。

这里有个很关键的坑:判断数字单元格是不是日期,要用DateUtil.IsCellDateFormatted(cell),千万别用DateUtil.IsValidDate。IsValidDate只判断数值是否落在Excel日期序列号范围内,普通数字100000也会被误判成日期;而IsCellDateFormatted会去看单元格的数字格式,正确率高得多。

3.5 合并单元格、空行、公式这些特殊形态怎么处理

合并单元格是常见的坑。Excel里合并区域只有左上角那个单元格有值,其余都是空白。遍历数据时,要同时遍历sheet.MergedRegions集合,用CellRangeAddress.IsInRange判断当前行列是否落在某个合并区域内,如果是,就去取左上角单元格的值,填充给区域内所有单元格。

空行也烦。有些导出工具会在数据缝隙里生成空白行,不跳过的话DataTable里会塞一堆全Null的脏数据。判断标准是:一行里所有单元格都是Blank或null,就跳过。

公式单元格要注意:NPOI不会执行公式,cell.CellType是Formula,要读CachedFormulaResultType才能拿到Excel计算后缓存的结果。直接对公式单元格读StringCellValue或NumericCellValue,很可能拿到空值或抛异常,处理方式就是按缓存结果类型再走一次分支。

4. 一份可以直接抄的完整实现

4.1 核心工具类代码

下面这个工具类,是我实际项目里精简出来的版本,支持指定工作表、指定表头行、合并单元格取值、空行过滤、重复列名处理。代码直接复制可用。

using System; using System.Data; using System.IO; using NPOI.HSSF.UserModel; using NPOI.SS.UserModel; using NPOI.SS.Util; using NPOI.XSSF.UserModel; public static class ExcelHelper { public static DataTable ReadExcelToDataTable(string filePath, string sheetName = null, int headerRowIndex = 0, bool useHeaderRow = true) { if (!File.Exists(filePath)) throw new FileNotFoundException("Excel文件不存在", filePath); DataTable dataTable = new DataTable(); string ext = Path.GetExtension(filePath).ToLowerInvariant(); using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { IWorkbook workbook = null; if (ext == ".xlsx" || ext == ".xlsm") workbook = new XSSFWorkbook(fs); else if (ext == ".xls") workbook = new HSSFWorkbook(fs); else throw new NotSupportedException($"不支持的文件格式:{ext}"); try { ISheet sheet = null; if (!string.IsNullOrEmpty(sheetName)) sheet = workbook.GetSheet(sheetName); if (sheet == null) sheet = workbook.GetSheetAt(0); if (sheet == null) throw new InvalidOperationException("Excel文件中没有工作表"); // 先遍历一遍求最大列数,避免行尾缺列导致丢数据 int maxColCount = 0; for (int i = 0; i <= sheet.LastRowNum; i++) { IRow row = sheet.GetRow(i); if (row != null) maxColCount = Math.Max(maxColCount, row.LastCellNum); } // 表头处理 if (useHeaderRow) { IRow headerRow = sheet.GetRow(headerRowIndex); if (headerRow == null) throw new InvalidOperationException("表头行不存在"); DataFormatter formatter = new DataFormatter(); for (int col = 0; col < maxColCount; col++) { ICell cell = headerRow.GetCell(col); string colName = formatter.FormatCellValue(cell)?.Trim(); if (string.IsNullOrEmpty(colName)) colName = $"Column{col + 1}"; colName = GetUniqueColumnName(dataTable, colName); dataTable.Columns.Add(colName, typeof(object)); } } else { for (int col = 0; col < maxColCount; col++) dataTable.Columns.Add($"Column{col + 1}", typeof(object)); } // 数据行遍历 int startRow = useHeaderRow ? headerRowIndex + 1 : 0; for (int r = startRow; r <= sheet.LastRowNum; r++) { IRow row = sheet.GetRow(r); if (IsRowEmpty(row, maxColCount)) continue; DataRow dr = dataTable.NewRow(); for (int c = 0; c < maxColCount; c++) { dr[c] = GetMergedCellValue(sheet, row, r, c); } dataTable.Rows.Add(dr); } } finally { if (workbook != null) workbook.Close(); } } return dataTable; } private static string GetUniqueColumnName(DataTable table, string name) { if (string.IsNullOrEmpty(name)) name = "Column"; if (!table.Columns.Contains(name)) return name; int i = 1; while (table.Columns.Contains($"{name}_{i}")) i++; return $"{name}_{i}"; } private static bool IsRowEmpty(IRow row, int maxColCount) { if (row == null) return true; for (int c = 0; c < maxColCount; c++) { ICell cell = row.GetCell(c); if (cell != null && cell.CellType != CellType.Blank) return false; } return true; } private static object GetMergedCellValue(ISheet sheet, IRow row, int rowIndex, int colIndex) { foreach (CellRangeAddress range in sheet.MergedRegions) { if (range.IsInRange(rowIndex, colIndex)) { ICell firstCell = sheet.GetRow(range.FirstRow)?.GetCell(range.FirstColumn); return ConvertCellValue(firstCell); } } ICell cell = row?.GetCell(colIndex); return ConvertCellValue(cell); } private static object ConvertCellValue(ICell cell) { if (cell == null || cell.CellType == CellType.Blank) return DBNull.Value; switch (cell.CellType) { case CellType.String: return cell.StringCellValue; case CellType.Boolean: return cell.BooleanCellValue; case CellType.Numeric: if (DateUtil.IsCellDateFormatted(cell)) return cell.DateCellValue; return cell.NumericCellValue; case CellType.Formula: switch (cell.CachedFormulaResultType) { case CellType.String: return cell.StringCellValue; case CellType.Boolean: return cell.BooleanCellValue; case CellType.Numeric: if (DateUtil.IsCellDateFormatted(cell)) return cell.DateCellValue; return cell.NumericCellValue; default: return DBNull.Value; } case CellType.Error: return $"#ERR:{cell.ErrorCellValue}"; default: return cell.ToString(); } } }

4.2 代码里几个容易被忽略的用意

文件流用了FileShare.ReadWrite,这个不是随便写的。Excel文件正被用户打开时,如果直接用File.OpenRead,会报“文件正在由另一进程使用”,加上FileShare.ReadWrite之后,只要对方没用独占锁,我们就能共享读取。

最大列数为什么要先遍历一遍?因为Excel的列不保证是规则矩形,有的行只有三列,有的行有十列,不先求出最大值,后面DataRow赋值时列数不够,多出来的数据就静默丢失了。

ConvertCellValue里判断日期用的是IsCellDateFormatted而不是IsValidDate,前面说过原因。补充一点:对公式单元格里的日期,IsCellDateFormatted的判断依赖缓存样式,多数情况是准的,如果遇到个别不准的情况,可以先用FormulaEvaluator求值后再走类型分支。不过绝大多数业务场景用不到这一步。

4.3 调用方式示例

这个工具类的调用非常简单,几种典型用法如下:

// 读第一个工作表,第0行做表头 DataTable dt1 = ExcelHelper.ReadExcelToDataTable(@"D:\data\台账.xlsx"); // 指定工作表名,第2行做表头 DataTable dt2 = ExcelHelper.ReadExcelToDataTable(@"D:\data\点位表.xls", "点位表", 1, true); // 文件本身没有表头,列名自动生成Column1、Column2... DataTable dt3 = ExcelHelper.ReadExcelToDataTable(@"D:\data\台账.xlsx", useHeaderRow: false);

拿到的DataTable可以直接当成数据源用:

dataGridView1.DataSource = dt1;

4.4 大数据量下的性能思路

NPOI的XSSFWorkbook在解析xlsx时,会把整个XML树加载进内存,一个50MB的xlsx可能吃掉几百MB内存。几万行没问题,几十万行就要换思路了。

我的建议是分情况:如果只是导入数据库且列类型规整,直接用OleDb/ACE配合DataAdapter,几百MB都能扛;如果必须走NPOI,可以考虑换个轻量方案——先将xlsx转成CSV,再用流式方式逐行读取构建DataTable。后面这条路代码量会大一些,但内存占用确实能降下来。

5. 高频问题排查实录

5.1 长数字变科学计数法

这是被问得最多的一个:Excel里身份证号、物料编码明明是完整字符串,用NPOI读出来变成4.101234567E+18。根因是Excel把这些单元格当作数字存储,NumericCellValue返回的是double,ToString就成了科学计数。

解决办法分三个层次:第一,在源文件里把该列设置成文本格式再导出,这是最干净的做法;第二,读取时用DataFormatter按单元格显示格式取字符串,能拿到用户看到的原样文本;第三,如果已经拿到double且确认是整数编码,用string.Format("{0:F0}", value)去掉小数点。但要注意,double对18位整数本来就有精度损失,所以最好的方案永远是源头拦截,不要等到读出来再补救。

5.2 日期读出来是数字序列号

Excel内部把日期存成数字序列号,1900年1月1日是1,2024年1月1日大约45292。如果直接读NumericCellValue,你拿到的是double而不是日期。正确做法是先用DateUtil.IsCellDateFormatted(cell)判断单元格格式,再用DateCellValue转成DateTime。

还要注意字符串伪日期这种情况:单元格里显示“2024/01/01”,但它是文本格式,读出来是string。遇到接数据库的场景,可能还要按照业务约定再转一次类型。

5.3 列名重复导致DataTable抛异常

DataTable的列名不能重复,否则Columns.Add时会抛“列已经属于该DataTable”。Excel表头非常容易出现两个“备注”、两个“数量”,所以必须做去重加后缀的处理,工具类里的GetUniqueColumnName就是干这个的。

5.4 文件被占用、读取失败

Excel文件正被用户打开时,直接File.OpenRead会报文件占用。FileShare.ReadWrite能解决共享读取,但有一个隐藏坑:Excel未保存的新数据不在磁盘上,你共享读到的仍是旧版本。正规做法是把Excel文件上传到程序后先复制一份到临时目录,再从副本解析,这样既能避开占用,又能保证文件版本是你提交那一刻的快照。

5.5 空Sheet、受保护Sheet、格式不支持

GetSheetAt(0)可能返回null,这时候要明确抛异常而不是继续往下走。受保护Sheet只是不能编辑,读取一般没问题。传进一个CSV文件时,扩展名判断会抛NotSupportedException,这种情况我一般单独走文本解析逻辑,不让它跟Excel混在一起。

5.6 常见问题速查表

问题根因解决方案
身份证号变科学计数法double精度加默认格式化源文件设文本、DataFormatter或F0格式化
日期读成数字Excel序列号机制IsCellDateFormatted加DateCellValue配合使用
列名重复抛异常DataTable列约束自动加_1、_2后缀
文件被占用读不了Excel进程锁文件FileShare.ReadWrite加副本策略
合并单元格丢数据非左上角单元格为空MergedRegions加IsInRange补值
空行灌进一堆Null导出工具生成空行整行非空判断后再AddRow
公式单元格读出来为空NPOI不重算公式CachedFormulaResultType读缓存

6. DataTable拿到手之后还能怎么用

6.1 直接绑定界面

WinForms里最省事,DataGridView的DataSource直接赋值给DataTable,表头、行数据都自动映射好。WPF里用DataGrid也是一样的逻辑,把ItemsSource指向dt.DefaultView就行。要注意的是,如果DataTable列很多,界面可能显示得比较挤,这个属于展示层优化的问题,跟转换本身无关。

6.2 整表批量写入数据库

这是最常用的出口。用SqlBulkCopy可以一次性把DataTable写入SQL Server:

using (SqlBulkCopy bulkCopy = new SqlBulkCopy(connectionString)) { bulkCopy.DestinationTableName = "Products"; bulkCopy.WriteToServer(dt); }

前提是DataTable的列名和数据库表的列名要匹配。列名对不上的时候,可以先给DataTable的Columns做重命名,也可以给SqlBulkCopy配置ColumnMappings,两种方式都行。

6.3 内存筛选和统计

DataTable自带很多数据处理能力。可以用AsEnumerable加Field方式筛选行:

var rows = dt.AsEnumerable() .Where(r => r.Field<string>("状态") == "有效") .Select(r => new { Name = r.Field<string>("名称") });

还可以直接调用Compute做聚合统计:

object sum = dt.Compute("SUM(数量)", "状态='有效'");

6.4 顺手扫个盲:DataTable和jQuery DataTables不是一回事

搜索引擎里经常看到“datatable 使用$.extend封装”、“jquery datatable 单元格内容过长”这类词条,很多人会误以为跟C#的DataTable有关系。其实C#的DataTable是服务端的内存数据容器,jQuery DataTables是前端展示表格的插件,只是英文同名。搜资料的时候看清楚上下文,避免越查越乱。

另外行业里还有“markdown表格转换excel”这种衍生需求,本质上也是先解析成内存结构再导出,思路跟Excel转DataTable完全一致,只是输入来源变成了Markdown文本。

如果让我给你一个最简单的起步建议:装好NPOI,把上面这个工具类复制过去,先跑通xlsx和xls两条链路,再回来处理那些奇奇怪怪的格式问题。我自己做这个需求做了不下二十次,最后固定下来的经验其实就三条:文件先复制再解析、列类型一律用object、日期判断用IsCellDateFormatted。除这三条之外,基本都是遇到一个问题补一个处理分支。把这个流程吃透之后,不管是读取Excel做上位机点位表,还是把Excel倒进数据库,对你来说都只是DataTable到手之后换个出口的问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询