简介:围绕 OpenXml 读写 Excel 的实际开发需求,这份资源提供了一套可直接参考的 C# 实例代码。代码基于 DocumentFormat.OpenXml 实现,核心包含 Import 与 Export 两条主线:读取时遍历工作表与行列单元格,将数据填充为 DataTable,并处理共享字符串、数字和布尔值;写入时接收 DataTable 列表,生成工作簿、工作表及基础样式表。示例中封装了 GetCellValue 辅助方法,针对共享字符串和普通单元格分别取值,避免类型转换出错;导出部分的样式表也给出字体、填充、边框等基础配置,便于在此基础上扩展模板。整个过程无需安装 Microsoft Office,适合服务端报表生成、批量导入导出和数据迁移等场景。资源包总共只有 1 个文件,为 PDF 文档,体积约 50KB,携带方便,适合快速查阅代码思路。目前已有 565 人学习浏览,说明在 OpenXml 读写需求中有一定代表性。通过这份实例,可以避开单元格取值、共享字符串解析等常见问题,快速掌握 OpenXml 操作 Excel 的骨架。
1. 从一份“有年代感”的示例代码开始:OpenXml 读写 Excel 其实没你想的那么玄
接手老系统报表模块的朋友,大概都被这一行报错支配过:CLSID 对应的 COM 组件无法实例化。那是网页导出 Excel 的老路——服务器装 Office,程序去调 COM 接口,换一台机器、换一个 Office 版本就翻车。新版本 xlsx 不是二进制 BIFF,它本质是重新包装过的 XML,OpenXml 读写 Excel 就是在跟这组 XML 打交道。这份示例代码把 DataTable 写入 xlsx、再读回 DataTable 的链路摊开了,连列字母的 26 进制换算都自己写了一份。适合服务端报表导出、Excel 批量导入,也适合被 COM 折磨过的 .NET 开发。
2. 先拆开 xlsx 再看代码:Zip 包里的 XML 与 OpenXml SDK 的选型逻辑
在动 OpenXml SDK 之前,我建议先手动拆一次 xlsx。这个动作能帮你省掉大量“为什么我的单元格读不出来”的调试时间。xlsx 文件表面上看是个单文件,实际上是个 Zip 压缩包,里面按固定目录摆着若干 XML。理解了这几张表,后面读代码会顺畅很多。
2.1 解压一个 xlsx:核心部件就这几样
随便拿一个 xlsx,复制一份改后缀成 .zip,然后解压。命令行三行就够:
mkdir xlsx_extract cp test.xlsx xlsx_extract/test.zip cd xlsx_extract && unzip test.zip -d extracted之后你会在 extracted 目录看到一串文件和文件夹。我一般只关注这四个:
| 文件/目录 | 对应 OpenXml 部件 | 负责什么 |
|---|---|---|
| [Content_Types].xml | ContentType 声明 | 声明包里每个部件的 MIME 类型 |
| xl/workbook.xml | WorkbookPart | 工作簿,记录有哪些 Sheet、每个 Sheet 的 rId |
| xl/worksheets/sheet1.xml | WorksheetPart | 单个工作表的核心内容,行、列、单元格都在这里 |
| xl/sharedStrings.xml | SharedStringTablePart | 共享字符串表,这就是后面 GetValue 里查的那个表 |
解压后可以打开 sheet1.xml 看一眼,真实的单元格结构长这样:
<row r="1"> <c r="A1" t="s"><v>0</v></c> <c r="B1"><v>100</v></c> </row>先记住一个结论:单元格本身不一定会“完整地”存储文本。凡是文本内容,Excel 默认把它塞进 sharedStrings.xml,工作表的单元格里只留一个整数下标,比如上面 A1 单元格里的t="s"表示这是共享字符串,<v>0</v>里的 0 是下标而不是值。这就是后面很多 bug 的源头。
提示:不同工具生成的 xlsx 结构略有差异,有些导入工具会把字符串直接内联写进单元格而不走共享表,这就引出后面 4.4 节要处理的数据类型问题。
2.2 选型理由:为什么是 OpenXml SDK,而不是 NPOI 或 COM
网上搜“OpenXml Excel”会有一堆老帖,三套方案被反复比较。如果你在 Python 生态,大概率会直接用 pandas 或 openpyxl 读写 Excel;但在 .NET 服务端,微软官方的 OpenXml SDK 才是正路。这里直接把结论摆出来:
| 方案 | 是否依赖 Office | 适合场景 | 主要风险 |
|---|---|---|---|
| COM 组件 | 依赖 | 客户端小工具 | 服务器权限、版本兼容、单线程问题多,容易翻车 |
| NPOI | 不依赖 | 兼容老 .xls、需要读写 HSSF 格式 | 社区维护,样式复杂时 API 比较繁琐 |
| OpenXml SDK | 不依赖 | 服务端批量生成/解析 .xlsx | 学习曲线集中在这几个部件类上,本身非常稳定 |
OpenXml SDK 是微软官方的东西,API 是强类型的,WorkbookPart、WorksheetPart、SharedStringTablePart 都对应真实的包结构。相比用 NPOI 去“模拟”Excel 行为,OpenXml 更接近事实本身。项目里那套 SpreadsheetReader、SpreadsheetWriter 的封装,就是在这层 SDK 之上把重复操作收口的。
注意:这份代码是按 OpenXml SDK 2.x 的 API 写的,DocumentFormat.OpenXml.Extensions 这个命名空间在新版 SDK 3.x 里被拆了出去,需要额外引入 OpenXmlPowerTools 才有 SpreadsheetReader 这些工具类。如果你是拿新版 SDK 直接编译,第一个报错就会在这里。
判断标准也很简单:数据量小、只求快速出文件,可以直接用 SDK 在内存里建文档;但要做多 Sheet、样式统一、反复读写,就必须把 SpreadsheetReader 这套封装留下。
2.3 SharedStringTable:Excel 的“文字仓库”与单元格索引机制
SharedStringTable 值得单独讲。一个单元格里如果存的是“张三”,sheet1.xml 里可能只是<c r="A1" t="s"><v>0</v></c>,真正的“张三”在 sharedStrings.xml 的<si>节点里。那个 0 是下标,不是值。
读的时候要反向操作:先看 DataType 是不是 SharedString,是的话把 CellValue.InnerText 当作整数下标,去 SharedStringTable 里取真正的字符串。这里最容易踩的坑是:许多新手拿到 v 节点里的数字就直接当成值返回,结果读出来全是“0”“12”“35”,完全对不上。
导出端的处理恰好相反,PasteText 会把字符串交给 SharedStringTable 去排重存储,同一个词在一张表里出现一万次,sharedStrings.xml 里也只存在一份。这也是 xlsx 在文本密集型表格里往往比 csv 更省空间的原因之一。理解了这套机制,导出和导入两头的代码就都好懂了:导出时 SDK 自动维护共享表;导入时就得自己动手查表。
3. 把 DataTable 写进 xlsx:导出链路的每一步都要讲得清
导出这段代码是典型的“模板操作”:拿一个空的 xlsx 模板,把默认 Sheet 删掉,按 DataTable 逐个建 Sheet,再逐格填值。下面按步骤拆。
3.1 从内存流开始:SpreadsheetReader.Create() 与 SpreadsheetDocument.Open
示例代码开头是这样的:
using (MemoryStream stream = SpreadsheetReader.Create()) { using (SpreadsheetDocument doc = SpreadsheetDocument.Open(stream, true)) { SpreadsheetWriter.RemoveWorksheet(doc, "Sheet1"); SpreadsheetWriter.RemoveWorksheet(doc, "Sheet2"); SpreadsheetWriter.RemoveWorksheet(doc, "Sheet3"); // 后续在这里逐个插入工作表 } }SpreadsheetReader.Create()会构建一个带默认模板的内存流,这个模板里有 Sheet1、Sheet2、Sheet3 三张空表。SpreadsheetDocument.Open(stream, true)第二个参数true表示以可写方式打开。接着立刻删掉三张默认表——如果不删,最后生成的文件会带着三张空 Sheet,表格取名就从 Sheet4 开始,观感很差。
不走磁盘临时文件、全程用 MemoryStream,好处是服务器上不会有残留的 .xlsx 临时文件,也不会有文件被占用的问题。缺点后面避坑章节会说到:流位置管理不好,保存出来的文件就是坏的。
3.2 用 WorksheetWriter.PasteText 逐格写入
删除默认表之后,每个 DataTable 对应一张新表:
WorksheetPart sheet = SpreadsheetWriter.InsertWorksheet(doc, table.TableName); WorksheetWriter writer = new WorksheetWriter(doc, sheet); SpreadsheetStyle style = SpreadsheetStyle.GetDefault(doc); foreach (DataRow row in table.Rows) { for (int i = 0; i < table.Columns.Count; i++) { string columnName = SpreadsheetReader.GetColumnName("A", i); string location = columnName + (table.Rows.IndexOf(row) + 1); writer.PasteText(location, row[i].ToString(), style); } } writer.Save();逐个参数解释:table.TableName是工作表的名称,会出现在 Excel 底部标签上;SpreadsheetReader.GetColumnName("A", i)从 A 列开始向右偏移 i 位,得到 B、C、D 这样的列字母;table.Rows.IndexOf(row) + 1算出 Excel 行号,从 1 开始;writer.PasteText把字符串写进指定单元格,style 参数控制默认字体和边框。
这里有两个容易忽视的点。其一,代码没有做列头特殊处理,DataTable 的第一行数据会被写到第一行,表头文字要自己提前放在 DataTable 里。其二,IndexOf(row)是按引用查找,数据量大时效率不高,示例代码图的是可读性,真要优化就换成 for 循环按行号取。
3.3 列字母与列号的 26 进制换算
这套代码里最值得拿出来说的一块,是 Num_to_letter 和 Letter_to_num 这两个函数。Excel 的列名是 A、B、…、Z、AA、AB… 的字母序列,本质上是个没有数字 0 的 26 进制:
private static string Num_to_letter(int value) { int remainder = value % 26; int front = (value - remainder) / 26; if (front < 26) { return Level[front - 1] + Level[remainder]; } else { return Num_to_letter(front) + Level[remainder]; } } private static int Letter_to_num(string str) { char[] letter = str.ToCharArray(); int reNum = 0; int power = 1; int times = 1; int num = letter.Length; reNum += Char_num(letter[num - 1]); if (num >= 2) { for (int i = num - 1; i > 0; i--) { power = 1; for (int j = 0; j < i; j++) power *= 26; reNum += power * (Char_num(letter[num - i - 1]) + times); times = 0; } } return reNum; }我简单说下这段的思路:Char_num 把 A 到 Z 映射成 0 到 25,字母串按 26 进制累加。注意最高位有个+1的补偿操作,因为 A 代表的是第 1 列而不是第 0 列。这套算法摆在这份代码里其实有个尴尬点:SDK 自带了SpreadsheetReader.GetColumnName,大多数时候用不到它;但当你手上只有字母列名、需要反算列号时,Letter_to_num 就是现成的工具。
提示:这两个函数的入参校验都被省略了,value 为 0 或负数时会算错甚至越界。生产环境如果要复用,先在外面过滤边界值,最好直接用 ASCII 字符运算代替手写字母表数组。至于原因,第 5.2 节会讲。
3.4 保存到文件:StreamToFile 的职责与流位置
所有 Sheet 写完,最后一步是落盘:
SpreadsheetWriter.StreamToFile(path, stream);StreamToFile 会把内存流的内容写到一个新文件里。这里要特别注意:如果你的代码在写入中间对 stream 做过 Seek、Read 之类的操作,保存前最好先把流位置归零,否则文件会从错误的偏移量开始写,生成一个只有半截的 xlsx。
为了避免这个问题,我习惯把这行放在最靠后的位置,并且让整个过程都在一个 using 块里结束,让流的生命周期和文档的生命周期完全一致。
4. 反向解析:把 xlsx 读回 DataTable 的 Import 实现与单元格寻址细节
导入比导出麻烦一些,因为要处理列名提取、共享字符串、空行空列、以及不同数据类型的边界。示例里 Import 的流程是:打开文档,遍历所有 Sheet,第一遍收集列名,第二遍逐行取值,最后过滤空白表。
4.1 用正则从 CellReference 提取列字母:为什么不能依赖列索引
先看列名收集这一段:
string columnName = Regex.Match(cell.CellReference.Value, "[a-zA-Z]+").Value; if (!columnsNames.Contains(columnName)) { columnsNames.Add(columnName); }cell.CellReference.Value是单元格的地址,比如 “C5”,正则会抽出字母部分 “C”。为什么不直接用循环里的 index 来当列号?因为在 XML 里,空单元格根本不会出现<c>节点,单元格的物理位置和它在 Descendants 里出现的顺序不一定一致。用 CellReference 解析才能保证列名对应真实坐标,而不是“第几个出现的单元格”。
这里有一个容易被忽略的语义点:这个 DataTable 的列名用的是 Excel 的字母列名(A、B、C),不是表头文本。如果需要用第一行的文本当列名,就要单独解析第一行,把字母替换成表头值。
4.2 GetValue:SharedString 与普通值的分叉点
核心取值方法,完整代码在这里:
public static String GetValue(Cell cell, SharedStringTablePart stringTablePart) { if (cell.ChildElements.Count == 0) return null; String value = cell.CellValue.InnerText; if ((cell.DataType != null) && (cell.DataType == CellValues.SharedString)) value = stringTablePart.SharedStringTable .ChildElements[Int32.Parse(value)] .InnerText; return value; }cell.ChildElements.Count == 0对应的是空单元格,直接返回 null;接着读cell.CellValue.InnerText拿到原始值;最关键是最后一个分支:当 DataType 是 SharedString 时,把原始值转成整数下标,再去 SharedStringTable 里拿真正的字符串。普通数字单元格没有 DataType,或 DataType 是别的值,就直接返回原值。
这段代码的短板是它假设 CellValue 节点一定存在。我在实际项目中见过一种脏数据:单元格只有样式没有值,<c>节点里既没有<v>也没有<is>。这种情况需要先判空再取 InnerText,否则直接 NullReferenceException。
4.3 列排序与空 Sheet 过滤
收集完列名之后不是直接建表,而是先排序:
columnsNames.Sort(CompareColumn);排序用的不是默认字典序,因为字典序会把 “B” 排在 “AA” 后面、把 “AA” 排在 “AB” 前面。CompareColumn 内部把列字母转成数字(A 是 1、AA 是 27),再比较数字大小,排序结果才符合 Excel 的列顺序。
遍历完所有行之后还有两道过滤:
if (table.Rows.Count <= 0) continue; if (table.Columns.Count <= 0) continue;这俩判断是用来跳过空表和无列表的。如果某张 Sheet 只有单元格边框没有内容,Rows 会是 0;如果 XML 里连一个有 CellReference 的单元格都没有,Columns 会是 0。如果下游是要把这份 DataTable 导入数据库,空表会让同步任务白跑一趟,这两道过滤经常能救你一命。
4.4 数字、日期、布尔与 InlineString:原示例没覆盖的数据类型
示例代码只处理了 SharedString 和普通字符串两种形态。实际 Excel 导出时,数字、日期、布尔、公式结果都会以不同形态存在 XML 里。日期在内部是个小数序列号,布尔值在 XML 里写的是 0 或 1。常见的扩展是这样:
if (cell.DataType != null && cell.DataType.Value == CellValues.InlineString) return cell.InlineString.InnerText; if (cell.DataType != null && cell.DataType.Value == CellValues.Boolean) return cell.CellValue.InnerText == "1" ? "TRUE" : "FALSE";这个补充很有必要:当你解析别的系统导出的 xlsx 时,不会有人保证所有字符串都走 SharedString。新版本的写库工具越来越倾向于直接写 InlineString,不开共享表。所以 GetValue 里加这两个分支,能让导入函数在真实业务文件面前站稳。
5. 避坑与常见问题:OpenXml 读写 Excel 的六个翻车现场
到这里,导出和导入的主流程都讲完了。下面记录我自己在这套代码上遇到过的、以及从这份示例代码里看出来的六个典型坑,每一条都属于“不现场撞一次根本想不到”的类型。
5.1 导出的文件 Excel 打不开:“文件格式或扩展名无效”
现象:程序运行没报错,文件也生成了,双击用 Excel 或 WPS 打开,弹“无法打开文件,因为文件格式或文件扩展名无效”。
原因:大部分情况是内存流被提前动了。MemoryStream 的位置指针没有归零,或者 StreamToFile 在文档还没 Flush 时就被调用,写完的文件其实是半个 Zip 包。还有一个常见手误:SpreadsheetDocument.Open(stream, true)之后,又在另一个线程去读同一个 stream。
解决:保存必须是在 using 块内、且文档还在 open 状态下进行;不要跨线程碰 stream;如果你对 stream 做过 Seek,保存前强制stream.Position = 0。
5.2 Level 数组里缺了一个“J”:列名错位最难发现
现象:导出文件能打开,但从第 10 列开始数据就错了,而且肉眼很难看出规律。
原因:看示例代码里 Level 数组的原始写法:{"A","B","C","D","E","F","G","H","I","G","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"}。第 10 个元素写的不是 “J”,而是 “G”。一旦列数超过 9,所有列名整体错位,数据写进错误的列里,前面的列看着又“都是对的”。
解决:第一版拿到代码时,先检查这个数组是不是完整的 26 个大写字母。更省心的做法是重写一个基于 ASCII 的转换:
private static string ColumnName(int index) // index 从 1 开始 { string result = ""; while (index > 0) { int m = (index - 1) % 26; result = (char)('A' + m) + result; index = (index - 1) / 26; } return result; }从那以后我每次看到手写的字母表数组,都会下意识数一遍长度。
5.3 字符串读回来变成“0”或索引值:SharedString 没查表
现象:导入时,明明是中文文本的列,读出来后是 “0”“12”“35” 这种数字。
原因:单元格的值在 XML 里存的是 SharedString 的下标,不是文本本身。有人直接把CellValue.InnerText当结果返回,跳过了查表那一步。这段示例代码里 GetValue 的 SharedString 分支不是摆设,每个分支都有存在的必要。
解决:数据类型标记为 SharedString 时,必须用下标去 SharedStringTable 取真实文本。这是 OpenXml 读 Excel 的经典考点,也是面试里最常见的问题。
5.4 读回多出大量空白行和列:Descendants 只返回有内容的节点
现象:导出的表 10 行 5 列,读回来变成 20 行 8 列,里面全是空串或 null。
原因:worksheet.Worksheet.Descendants<Row>()只遍历 XML 里真实存在的<row>节点。Excel 在保存时会把某些被格式过但没数据的行列写进 XML,这些行和列在读取代码里都会被当成有效数据。空白行不是一条条空记录,而是 XML 里根本没有内容,代码却为它们建了 DataRow。
解决:在数据填充后加“空值裁剪”逻辑。比较省事的做法是遍历完所有行后,把全为 null 或空字符串的行从 DataTable 里删掉。另外,单元格的列名收集用的是 CellReference 而不是物理 index,这一步也能避免把中间空列当成有值列。
5.5 后缀名判断不靠谱:一个 EndsWith 把合法文件拦掉了
现象:传一个 .xlsx 文件给 Import 函数,返回空列表;但同一个文件改个名又能读出来。
原因:注意示例代码开头那行if (path.EndsWith(ExcelHelper.POSTFIX_SVN)) return tables;。这个 POSTFIX_SVN 常量在示例的 ExcelHelper 类里并没有定义,大概率是项目里其他位置的常量或历史残留。如果它被赋了个模糊的串,EndsWith 会把所有以它结尾的路径全部拦下,包括正常的 .xlsx。后缀判断用Path.GetExtension(path)才严谨。
解决:把这类黑名单判断直接删掉,或者改成全小写再比较扩展名Path.GetExtension(path).ToLower() == ".xls"。我用的是后者。
5.6 大数据量导出内存飙升:OpenXml 不是流式处理器
现象:数据量到 10 万行时,导出过程内存暴涨,服务器差点被拖垮。
原因:示例全程用 MemoryStream 攒数据,WorksheetWriter 逐格创建对象,所有 Sheet 都在内存里,最后一起写盘。这套写法在 1 万行内非常舒服,但量级上来就是灾难。
解决:真正的大文件导出,常见做法是改用 SAX 风格的流式写入,或者按 Sheet 分批导出,又或者直接操作 sheet XML 用文本拼接。这个代码的价值在于把链路讲清楚,上线前还是要评估数据量。数据量大时我会把导出的 xlsx 按每 5 万行拆一张 Sheet,内存压力会小很多。
6. 验证与进阶:导出后不要只盯着“能打开”
很多人验证 Excel 文件的方式是双击打开看一眼,这远远不够。Excel 打开成功只代表文件结构基本合法,不代表数据位置、类型、共享字符串都对。我现在导出完会强制走三层检查。
6.1 第一层验证:把 xlsx 当 Zip 解压检查
生成的文件不急着打开,先复制一份改个后缀解压:
cp result.xlsx check.zip && unzip -o check.zip -d check_xlsx然后重点看两个文件:xl/sharedStrings.xml里<si>节点的数量对不对,xl/workbook.xml里的 sheet 名称和数量对不对、再抽查sheet1.xml里某几个关键单元格的<c>节点值。这一步能在一分钟内发现问题,比反复用 Excel 打开快得多。
6.2 第二层验证:用 Import 读回来做数据一致性比对
这是我最喜欢的一步:用它自己的 Import 把刚导出的文件读回来,和原始 DataTable 做逐行比对。因为导入和导出用的是同一套代码,这个验证主要防的是列名、行号、共享字符串这三处出问题。
DataTable sourceTable = BuildSourceTable(); // 写入前的原始表 List<DataTable> readBackList = OpenXmlSDKExporter.Import("result.xlsx"); DataTable readBackTable = readBackList[0]; if (readBackTable.Rows.Count != sourceTable.Rows.Count) throw new Exception($"行数不一致: {sourceTable.Rows.Count} != {readBackTable.Rows.Count}"); for (int r = 0; r < sourceTable.Rows.Count; r++) { for (int c = 0; c < sourceTable.Columns.Count; c++) { string src = sourceTable.Rows[r][c].ToString(); string dst = readBackTable.Rows[r][c]?.ToString(); if (src != dst) throw new Exception($"第{r}行第{c}列不一致: {src} != {dst}"); } }如果这一段不打点异常,那这次导出大概率是通的。
6.3 可扩展的边界:样式、合并单元格与流式写入
原示例用的SpreadsheetStyle.GetDefault(doc)是一套默认样式。想控制背景色、字体和边框,要自己在默认样式上扩展或新建样式对象传给 PasteText。合并单元格需要额外操作 MergeCells 集合,PasteText 本身不处理。
OpenXml 读写 Excel 的上限比这份示例高得多。这套代码适合做 1 万行以内的报表生成和普通文件解析;再往上走,就要考虑流式 API、图片嵌入、数据验证下拉列表这些更重的功能。建议按这条线去扩展,而不是在这份示例代码里硬塞。
从那以后,我每次给 .NET 项目加 Excel 导入导出,都会先把这份代码的流程走一遍:建内存流、删默认表、逐格写入、正则取列名、SharedString 查表。文件生成后必须解压检查一遍再做数据回读比对,两个都过了才敢说“导出没问题”。这套流程虽然多花几分钟,但省掉的是无数个“文件打不开”的售后电话。希望帮到你。
本文还有配套的精品资源,点击获取