做过业务系统的都懂:Excel
导入才是真正的”需求黑洞”。客户给的模板从来不是干净的二维表——标题行占了前两行、表头合并单元格、大类下面挂子列、一个文件里放好几个
Sheet、有的 Sheet 是”新增”有的是”删除”。如果用最朴素的
Cells[0,0] 逐格读取,代码会迅速膨胀成一堆
if (row == 3) continue; 的补丁。
这篇文章拆解一个在生产环境跑了很久的 Excel
封装:ExcelWorkbook / ExcelWorksheet / 静态
ExcelHelper 三层结构。它不是”又一个 Excel
帮助类”,而是把表头定位、合并单元格语义、列名校验、多 Sheet
归并这四类脏活收敛成几个参数。读完之后,你面对再离谱的模板也能用一行调用搞定。
底层依赖是商业库 Aspose.Cells(需要授权),但本文讨论的分层思路和参数设计,换成 NPOI、MiniExcel 也同样适用。
一、为什么不能直接读成 DataTable
先说三个真实场景:
- 双层表头:第一行是”PCC套餐状态1 /
PCC套餐状态2”这种大类,第二行才是具体列名。直接按首行转列名,会得到一堆空列名或
Column1。 - 合并单元格:Aspose 的
ExportDataTableAsString导出时,合并区域只有左上角有值,其余是空。下游按列取值时全是DBNull。 - 多 Sheet 语义不同:同一个文件里,
新增页签和删除页签结构一样但语义相反,需要加一列”来源类型”来区分。
所以封装的核心不是”能读”,而是把模板的视觉结构翻译成程序能理解的结构。
flowchart TD
A[Excel 文件] --> B[ExcelWorkbook 打开工作簿]
B --> C{选择 Sheet}
C --> D[ExcelWorksheet 定位数据区]
D --> E[拆分合并单元格 + 填充值]
E --> F[多层表头合成列名]
F --> G[必填列校验]
G -->|缺列| H[抛出中文错误]
G -->|通过| I[导出 DataTable]
I --> J{多 Sheet?}
J -->|是| K[裁剪列 + 加来源列 + Merge]
J -->|否| L[交给业务层]
K --> L
二、三层结构:工作簿、工作表、静态入口
ExcelWorkbook 持有 Aspose 的
Workbook,只负责”有哪些 Sheet”和”按名字/索引取
Sheet”;ExcelWorksheet
负责单个表的数据区计算;ExcelHelper
是一组静态重载,把常见调用组合成一行。
public sealed class ExcelWorkbook
{
private readonly Workbook workbook;
public ExcelWorkbook(string fileName)
{
workbook = new Workbook(fileName);
}
public string[] SheetNames
{
get
{
sheetNames = new string[workbook.Worksheets.Count];
for (int i = 0; i < workbook.Worksheets.Count; i++)
{
sheetNames[i] = workbook.Worksheets[i].Name;
}
return sheetNames;
}
}
public ExcelWorksheet GetAt(string sheetName)
=> new ExcelWorksheet(workbook, sheetName);
}
这样做有个实际好处:同一个文件只解析一次。很多实现里
Load(fileName, sheet1)、Load(fileName, sheet2)
各开一次文件,10MB 的 xlsx 就要解析好几遍。持有
ExcelWorkbook 后,多个 Sheet 共用一次解析结果:
ExcelWorkbook workbook = new ExcelWorkbook(fileName);
SourceTable = ExcelHelper.Load(workbook, "基本信息", requiredColumns: new[] { "工单号", "文件号" });
SourceTable = ExcelHelper.Load(workbook, "TAC4GConfigData", requiredColumns: columns);
三、数据区定位:MinRow 与 MaxDataRow 的区别
Aspose 有 MaxRow 和 MaxDataRow
两个属性,差别很关键:
MaxRow:包含带格式的空行的最大行号。模板里给空白行刷了边框,MaxRow就会虚高几百行。MaxDataRow:真正有数据的最大行号。
如果拿 MaxRow 当边界,DataTable
里会塞进一堆空行,后面落库时要额外清洗。正确写法是:
public DataTable Load(bool includeColumnName)
{
Cells cells = worksheet.Cells;
if (cells.Count < 1 || cells.MaxDataRow < 0)
{
return DataCommon.BuildDataTable(1);
}
return Load(
cells.MinRow,
cells.MinColumn,
cells.MaxDataRow - cells.MinRow + 1,
cells.MaxDataColumn - cells.MinColumn + 1,
includeColumnName);
}
注意 MinRow / MinColumn 也不能写死成
0——模板左上角经常留白(比如从 B3 开始才是数据),用
MinRow/MinColumn
自动贴合真实起点,能省掉一堆偏移量参数。
空表统一返回
BuildDataTable(1)(一个只有一列的空表),是为了让下游
dataTable.Rows.Count == 0 的判断不抛
NullReferenceException,避免到处写空判断。
四、核心:多层表头怎么合成唯一列名
这是整个封装最有价值的部分。签名如下:
public DataTable Load(
int columnNameRowIndex, // 主表头所在行
int subColumnNameRowIndex, // 子表头所在行(-1 表示没有)
IReadOnlyList<string> prefixColumnNames, // 需要拼接前缀的大类名
bool joinPrefixColumnName, // 是否自动把所有上级表头都拼进来
bool splitMergedCells) // 是否先拆合并单元格
处理顺序是:先拆合并单元格 → 再合成列名 → 最后按字符串导出。
4.1 拆合并单元格:复制值而不是 UnMerge
很多人第一反应是调用 UnMerge(),但 Aspose
解除合并后只保留左上角的值,其余格子还是空的。正确做法是把左上角的值铺满整个区域:
public void SplitMergedCells()
{
Cells cells = worksheet.Cells;
foreach (CellArea area in cells.MergedCells)
{
for (int i = area.StartRow; i <= area.EndRow; i++)
{
for (int j = area.StartColumn; j <= area.EndColumn; j++)
{
cells[i, j].Value = cells[area.StartRow, area.StartColumn].Value;
}
}
}
}
还有一个”只拆表头区域”的重载
SplitMergedCells(int startRow, int endRow):数据区如果有纵向合并(比如同一个工单号跨多行),铺满反而会破坏业务语义,所以只处理表头那几行就够了。
4.2 两种前缀拼接策略
同一份模板里,大类名往往要拼进子列名,否则
"状态1"、"状态2" 这种子列名会重复。封装提供了两种模式:
模式一:指定前缀列(推荐,最可控)
if (columnName.Length > 0 && subColumnName.Length > 0
&& prefixColumnNames.Contains(columnName))
{
cells[subColumnNameRowIndex, j].Value = columnName + "-" + subColumnName;
}
调用方明确告诉封装”哪几个大类要拼”,产出
PCC套餐状态1-限速值
这样稳定的列名。业务代码里就能直接写常量:
SourceTable = ExcelHelper.Load(
excelWorkbook, 0,
columnNameRowIndex: 0,
subColumnNameRowIndex: 1,
requiredColumns: columns,
prefixColumnNames: new[] { "PCC套餐状态1", "PCC套餐状态2" });
模式二:自动拼接所有上级表头
不知道模板有几个层级时,把主表头行到子表头行之间的非空值全部收集起来拼成
A-B:
var prefixColumnNameList = new List<string>();
for (int j = cells.MinColumn; j <= cells.MaxDataColumn; j++)
{
for (int k = columnNameRowIndex; k <= subColumnNameRowIndex; k++)
{
string name = cells[k, j].Value?.ToString()?.Trim() ?? string.Empty;
if (name.Length > 0 && !prefixColumnNameList.Contains(name))
{
prefixColumnNameList.Add(name);
}
}
if (prefixColumnNameList.Count > 1)
{
cells[subColumnNameRowIndex, j].Value = string.Join("-", prefixColumnNameList);
}
prefixColumnNameList.Clear();
}
去重用的是 List.Contains 而不是
HashSet——因为表头列数通常只有几十列,保持列内顺序比
O(1) 查找更重要,HashSet 会打乱 A-B
的拼接顺序。
4.3 必须按字符串导出
导出统一走 ExportDataTableAsString,不是
ExportDataTable:
DataTable dataTable = cells.ExportDataTableAsString(
startRow, cells.MinColumn, rowsToRead, columns, false);
原因很实际:模板里手机号、身份证、长编号一旦被识别成数字,就会变成
1.3812345678E+10
或者丢掉前导零。按字符串导出后,类型转换交给业务层显式处理,反而更安全。
导出后立刻 Trim(true, true):把 null
转成空串、去掉首尾空格,再 FirtRowToColumnName()
把首行提升为列名。这样列名不会带尾部空格导致
Columns.Contains("工单号") 判断失败。
五、必填列校验:把错误提前到导入瞬间
最烦的 bug 是”导入了 8000 行,跑到第 5000
行才因为列名对不上炸掉”。校验放在 Load
之后、业务处理之前:
if (!requiredColumns.IsNullOrEmpty())
{
var missingColumns = requiredColumns
.Where(name => dataTable.Columns.Contains(name) == false);
if (!missingColumns.IsNullOrEmpty())
{
throw new Exception(string.Format(
"[{0}]表单缺少[{1}]字段。",
excelWorksheet.Name,
string.Join(",", missingColumns)));
}
}
错误信息里带上 Sheet 名 + 缺失列名,运营同学把 Excel 改好再传即可,不用来问你”到底少了哪一列”。这一个小细节能省掉大量来回沟通。
六、多 Sheet 合并:裁剪列 + 来源标记 + 去空行
业务上常见”新增/修改/删除”三个页签,列结构相同但要标记来源。核心逻辑分三步:
- 取第一个存在的 Sheet 作为基准,用
CopyData(requiredColumns)只保留需要的列(避免模板里那些说明性列污染结果集); - 给基准表加一列”更新类型”,用
FillColumn整列填充; - 其余 Sheet 逐行裁剪列后追加,最后
RemoveEmptyRows()清掉模板尾部空行。
resultTable.Columns.Add(updateTypeColumnName);
resultTable.FillColumn(updateTypeColumnName, updateTypes[i]);
调用侧非常短,updateTypes 与 sheetNames
一一对应:
SourceTable = ExcelHelper.Load(
fileName,
sheetNames, // 如 { "新增", "修改", "删除" }
UpdateTypeColumn.SourceName, // 结果表里新增的标识列名
updateTypes, // 如 { "新增", "修改", "删除" }
requiredColumns);
如果所有页签都不存在,通过 throwException
参数决定是抛异常还是返回
null——有些业务场景”文件里没有这个页签就是不需要更新”,返回
null 让调用方跳过即可。
还有更简单的一种:全量汇总表所有 Sheet 结构完全一致,直接逐页 Merge:
public static DataTable LoadJtSummaryTable(string fileName)
{
ExcelWorkbook excelWorkbook = new ExcelWorkbook(fileName);
DataTable dataTable = null;
foreach (var sheetName in excelWorkbook.SheetNames)
{
if (dataTable == null)
{
dataTable = ExcelHelper.Load(excelWorkbook, sheetName);
}
else
{
dataTable.Merge(ExcelHelper.Load(excelWorkbook, sheetName));
}
}
return dataTable;
}
DataTable.Merge
会自动按列名对齐,同名同类型列合并,多出的列会补进结果表。所以前提是各
Sheet 表头必须一致,不然会得到一堆稀疏列。
七、写出:三种数据源 + 列索引映射
导出侧同样用重载消除差异,支持
DataTable、DataView、IDataReader
三种来源。内部统一转成列索引再写单元格,因为列名查找只做一次,比循环里反复
Columns["xxx"] 快很多:
public static int Save(Stream sourceStream, string destFileName, int sheetIndex,
int firstRow, int firstColumn, FileFormatType type,
DataTable dataTable, IReadOnlyList<int> columnIndices, bool includeColumnName)
{
Workbook book = sourceStream != null ? new Workbook(sourceStream) : new Workbook();
Cells cells = book.Worksheets[sheetIndex].Cells;
int j = 0;
if (includeColumnName)
{
foreach (int index in columnIndices)
{
cells[firstRow, firstColumn + j].PutValue(dataTable.Columns[index].ColumnName);
j++;
}
firstRow++;
}
DataRowCollection rows = dataTable.Rows;
for (int i = 0; i < rows.Count; i++)
{
j = 0;
foreach (int index in columnIndices)
{
cells[firstRow + i, firstColumn + j].PutValue(rows[i][index].ToString());
j++;
}
}
book.Save(destFileName, GetSaveFormat(type));
return rows.Count;
}
两个设计点值得注意:
sourceStream允许传模板流。导出时用已有模板打开,只往指定位置写数据,样式、冻结窗格、列宽都保留,不用在代码里重新画一遍格式。PutValue(row.ToString())统一按字符串写。日期、金额的最终显示格式交给 Excel 单元格格式控制,避免DateTime写进去后被本地化设置改成奇怪的格式。
多 Sheet 导出则把 DataSet 的每个
DataTable.TableName 直接作为 Sheet 名:
public static void Save(string fileName, DataSet dataSet, bool includeColumnName)
{
Workbook book = new Workbook();
for (int index = 0; index < dataSet.Tables.Count; index++)
{
var dataTable = dataSet.Tables[index];
while (book.Worksheets.Count <= index)
{
book.Worksheets.Add();
}
book.Worksheets[index].Name = dataTable.TableName;
Cells cells = book.Worksheets[index].Cells;
int firstRow = 0, firstColumn = 0;
if (includeColumnName)
{
for (int j = 0; j < dataTable.Columns.Count; j++)
{
cells[firstRow, firstColumn + j].PutValue(dataTable.Columns[j].ColumnName);
}
firstRow++;
}
DataRowCollection rows = dataTable.Rows;
for (int i = 0; i < rows.Count; i++)
{
for (int j = 0; j < dataTable.Columns.Count; j++)
{
cells[firstRow + i, firstColumn + j].PutValue(rows[i][j].ToString());
}
}
}
book.Save(fileName, SaveFormat.Xlsx);
}
顺带一句:DataTable.TableName
是有长度和字符限制的(Excel Sheet 名不超过 31 字符,且不能含
: \ / ? * [ ])。如果 TableName
来自用户输入,记得先做一次净化,否则 book.Save
会抛异常。
八、踩过的坑与经验
MaxRow会虚高:模板刷了格式的空行会让它偏大,永远用MaxDataRow定数据边界。- 合并单元格必须铺值:
UnMerge只清格式不复制值,铺满区域才能被ExportDataTableAsString读到。 - 表头去重要保序:拼接前缀时用
List+Contains,HashSet会打乱大类-子列的顺序,导致列名不稳定。 - 列名统一 Trim:
"工单号 "和"工单号"是两个列名,Columns.Contains会返回false,校验必须在Trim之后做。 - 空表返回结构完整的空 DataTable:不要返回
null,让下游能安全访问Rows.Count。 - 删除行要倒序遍历:清理空行时从
Rows.Count - 1往前删,正序删除会漏行;同时要跳过DataRowState.Deleted的行,访问它的Current数据会抛异常。 - 大文件用分页读取:封装里额外提供了
LoadWithOffset(offset, limit, includeColumnName),按偏移量分批导出,避免一次把几十万行塞进内存。 - 一个文件只解析一次:多个 Sheet 共用同一个
ExcelWorkbook实例,别在每次Load里重复new Workbook(fileName)。
九、小结
这个封装的本质是把”模板长什么样”这件事参数化:表头在第几行、有没有子表头、哪些大类要拼前缀、需要哪些必填列、多个 Sheet 各自什么语义,全部通过方法参数表达,业务层只保留一行调用。
具体到可复用的经验,有三条:
- 结构先于逻辑:先用
MinRow/MinColumn/MaxDataRow/MaxDataColumn把数据区框出来,再谈取值; - 校验前置:列名缺失在
Load阶段就报错,别让它流到第 5000 行; - 类型后置:Excel 一律按字符串读,类型转换在业务层显式做,能避开科学计数法、前导零、日期本地化这三类经典坑。
同样的分层思路换成 NPOI 也能落地——Sheet.GetRow / GetCell
代替 Cells[i, j],判断逻辑完全一致。Excel
导入从来不是技术难题,难的是把”客户模板的随意”收敛成”代码约束的确定”,而这正是封装的价值所在。