ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#读取CSV与Excel实战:编码、分隔符、依赖选型与内存控制避坑指南

C#读取CSV与Excel实战:编码、分隔符、依赖选型与内存控制避坑指南

简介:这份资源面向具备一定C#基础的开发者,聚焦于使用C#读取CSV与Excel文件这一常见数据处理需求,帮助解决表格数据导入、解析与界面展示中的实际问题。资源包内含1个doc文档,压缩包大小约34KB,以文字讲解与代码示例为主,便于快速查阅与对照练习。文档围绕OleDbConnection与OleDbDataAdapter两类核心对象展开,涵盖CSV连接字符串的构造、HDR与FMT等扩展属性含义、SQL查询语句编写、DataTable数据填充,以及通过DataGridView自动生成列并绑定数据源的完整流程;同时对比了读取Excel文件时ACE OLEDB提供程序与Excel 8.0连接字符串的差异,并给出IMEX=1处理混合类型数据的思路。内容预览中附有可直接参考的窗体代码片段,展示了根据扩展名判断文件类型并分别处理CSV与XLS的分支逻辑。目前已有260人学习,适合需要快速掌握C#表格文件读取方法的开发者参考。

1. C#读取CSV与Excel:为什么你的第一版代码总在真实文件上翻车

很多同行第一次写 C# 读取 CSV 或 Excel,都是拿一个规规矩矩、逗号分隔、没有空行、没有合并单元格的样例文件跑通,然后直接丢进生产环境。结果第二天就收到反馈:中文乱码、日期变成一串数字、金额字段多了个货币符号、某个单元格里藏着换行符导致整行列错位。这不是代码写得差,而是 CSV 和 Excel 这两种格式本身就带着大量隐式约定,样例文件恰好把这些约定都藏起来了。

这篇笔记面向的是需要把 CSV/Excel 导入功能真正落地到项目里的 C# 开发者,不管你是做后台批处理、桌面工具还是接口服务,只要涉及读表格文件,这里的内容都能直接抄。我会把 CSV 和 Excel 分开讲,因为它们的坑完全不是一回事:CSV 是纯文本,问题集中在编码、分隔符和转义;Excel 是二进制或压缩包结构,问题集中在依赖库选型、格式兼容和内存占用。中间会给出可直接复现的代码,也会把参数怎么调、失败时看哪里讲清楚。

2. 先分清 CSV 和 Excel:选错解析路径后面全是返工

2.1 两种格式的本质差异决定了你该用什么工具

CSV 的全称是逗号分隔值,但它只是一个约定,并没有强制标准。RFC 4180 给了一个参考定义,可现实中你拿到的文件可能用分号分隔、用制表符分隔、字段里带逗号、字段里带换行、开头有 BOM、结尾有空行。这些变体让“按逗号 Split”这种写法几乎必然翻车。CSV 的本质是文本流,所以读取它的核心是正确处理编码和转义规则。

Excel 则完全不同。xls 是早期二进制复合文档格式,xlsx 本质是一个 ZIP 包,里面装着若干 XML 文件,描述工作表、共享字符串、样式和公式。你没法用文本方式去读 xlsx,必须借助能解析 Open XML 的库。很多人以为把 xlsx 后缀改成 csv 就能读,实际上文件内容根本没变,改后缀只会让程序报错或读出乱码。

选型上我的建议很直接:如果文件来源可控、格式规范,用 CsvHelper 这类成熟库读 CSV;如果文件是业务人员用 Excel 另存出来的,优先按 CSV 处理,因为 Excel 导出的 CSV 通常字段规整。只有当文件必须是 xlsx 且包含多工作表、公式或格式信息时,才引入 Excel 解析库。下面这张表是我在选型时会对照的维度。

维度CSVxlsxxls
本质纯文本ZIP+XML二进制复合文档
读取方式文本流+转义解析Open XML 解析专用库解析
内存占用低,可流式中高,视库而定高
多工作表不支持支持支持
公式结果无可读缓存值可读缓存值
常见坑编码、分隔符、转义依赖库、大文件内存库兼容性、格式老旧

2.2 用 CsvHelper 读 CSV 的最小可运行代码

先装包,在项目目录执行:

dotnet add package CsvHelper

然后写一个能处理常见变体的读取示例。这里我特意把编码检测、分隔符配置和字段映射都显式写出来,因为默认配置在真实文件上大概率不够用。

using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; using CsvHelper; using CsvHelper.Configuration; // 定义一行记录对应的模型 public class OrderRecord { public string OrderId { get; set; } public string CustomerName { get; set; } public DateTime OrderDate { get; set; } public decimal Amount { get; set; } } public static class CsvReaderDemo { public static List<OrderRecord> Read(string filePath) { // 关键点1:用 StreamReader 指定编码,并让 .NET 自动检测 BOM // detectEncodingFromByteOrderMarks: true 能正确处理带 BOM 的 UTF-8 using var reader = new StreamReader(filePath, Encoding.UTF8, true); // 关键点2:配置分隔符、是否含表头、空行处理 var config = new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = ",", // 如果文件用分号,这里改成 ";" HasHeaderRecord = true, // 第一行是表头 IgnoreBlankLines = true, // 跳过空行,避免解析出空记录 TrimOptions = TrimOptions.Trim, // 去掉字段首尾空格 BadDataFound = null // 遇到坏数据不抛异常,交给后续校验 }; using var csv = new CsvReader(reader, config); // 关键点3:日期和金额的解析规则要显式指定,否则受当前区域影响 csv.Context.TypeConverterOptionsCache.GetOptions<DateTime>() .Formats = new[] { "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMMdd" }; var records = new List<OrderRecord>(); foreach (var record in csv.GetRecords<OrderRecord>()) { records.Add(record); } return records; } }

这段代码的逻辑说明:StreamReader负责把字节流转成字符流,detectEncodingFromByteOrderMarks设为 true 后,带 BOM 的 UTF-8 文件不会出现开头多一个不可见字符的问题。CsvConfiguration里Delimiter必须和实际文件一致,很多“读出来只有一列”的问题就是分隔符没对上。TrimOptions.Trim能解决字段前后空格导致的匹配失败。BadDataFound = null是让解析器遇到引号不配对这类问题时不要直接抛异常,而是把原始内容交出来,方便你记录日志后人工排查。

参数怎么改:如果文件是 GBK 编码,把Encoding.UTF8换成Encoding.GetEncoding("GBK"),注意 .NET Core 需要先注册CodePagesEncodingProvider。如果文件没有表头,把HasHeaderRecord设为 false,然后用索引映射或手动读取。如果字段里包含换行符,CsvHelper 默认能处理被引号包裹的换行,前提是文件本身符合转义规则。

2.3 读 xlsx 时用 ClosedXML 还是 EPPlus

xlsx 的解析库选择主要看授权和功能。ClosedXML 基于 MIT 协议,API 友好,适合中小规模文件;EPPlus 从 5.0 起改为商业授权,功能更全但商用要注意合规。如果只是读数据,ClosedXML 足够。装包:

dotnet add package ClosedXML

读取示例:

using System; using System.Collections.Generic; using System.IO; using ClosedXML.Excel; public static class ExcelReaderDemo { public static List<OrderRecord> Read(string filePath, string sheetName) { var records = new List<OrderRecord>(); // 关键点1:用 using 确保工作簿被释放,否则文件会被占用 using var workbook = new XLWorkbook(filePath); // 关键点2:按名称取工作表,取不到时给出明确异常 if (!workbook.Worksheets.TryGetWorksheet(sheetName, out var sheet)) { throw new InvalidOperationException($"工作表 {sheetName} 不存在"); } // 关键点3:用 UsedRange 而不是整个工作表,避免遍历百万空行 var usedRange = sheet.RangeUsed(); if (usedRange == null) return records; // 第一行当表头,从第二行开始读数据 var rows = usedRange.RowsUsed().Skip(1); foreach (var row in rows) { var record = new OrderRecord { OrderId = row.Cell(1).GetString().Trim(), CustomerName = row.Cell(2).GetString().Trim(), // 日期单元格可能是文本也可能是日期格式,这里做兼容 OrderDate = row.Cell(3).TryGetValue<DateTime>(out var dt) ? dt : DateTime.Parse(row.Cell(3).GetString()), // 金额单元格可能带货币符号,先取文本再清洗 Amount = decimal.Parse( row.Cell(4).GetString().Replace("¥", "").Replace(",", "").Trim()) }; records.Add(record); } return records; } }

逻辑说明:XLWorkbook构造时会加载整个文件到内存,所以大文件要谨慎。RangeUsed()只返回有内容的区域,比遍历Rows()快很多。TryGetValue<DateTime>能处理单元格本身是日期类型的情况,如果单元格是文本,就回退到DateTime.Parse。金额字段的清洗是血泪经验,业务人员经常在 Excel 里把金额列设成货币格式,读出来带符号和千分位。

参数说明:sheetName建议从配置读取,不要硬编码。如果工作表名不确定,可以先遍历workbook.Worksheets打印名称。对于超大 xlsx,ClosedXML 没有流式读取能力,这时要考虑用 ExcelDataReader 这类只读流式库,或者先用脚本把 xlsx 转成 CSV 再走 CSV 解析路径。

3. 编码、分隔符与转义:CSV 读取的三大翻车点

3.1 中文乱码的根因和一次性解决思路

乱码的本质是“写入时用的编码”和“读取时用的编码”不一致。中文 Windows 上 Excel 另存 CSV 默认用 GBK,而 .NET Core 默认只带 UTF-8 和 UTF-16,直接读 GBK 文件会得到问号或方块。解决办法有两个:一是注册编码提供程序后显式用 GBK 读,二是让业务方统一另存为 UTF-8。前者更现实。

using System.Text; // 在程序启动时注册一次,之后才能用 Encoding.GetEncoding("GBK") Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); // 读取时先探测 BOM,没有 BOM 再按 GBK 处理 public static Encoding DetectEncoding(string filePath) { using var fs = File.OpenRead(filePath); var buffer = new byte[3]; var read = fs.Read(buffer, 0, 3); if (read == 3 && buffer[0] == 0xEF && buffer[1] == 0xBB && buffer[2] == 0xBF) return Encoding.UTF8; return Encoding.GetEncoding("GBK"); }

这段代码先看文件开头三个字节是不是 UTF-8 BOM,是就用 UTF-8,否则按 GBK 处理。这个策略覆盖了绝大多数国内业务场景。注意CodePagesEncodingProvider需要System.Text.Encoding.CodePages包,.NET Framework 下不需要。

3.2 分隔符不是逗号时怎么让解析器自动适配

有些系统导出的 CSV 用分号或制表符,原因是字段内容里本身有逗号。硬编码分隔符迟早出事,我一般会先读第一行做探测。

public static char DetectDelimiter(string firstLine) { // 按出现次数最多的候选分隔符判定 var candidates = new[] { ',', ';', '\t', '|' }; var counts = candidates.Select(c => new { c, n = firstLine.Count(ch => ch == c) }); return counts.OrderByDescending(x => x.n).First().c; }

拿到分隔符后再传给CsvConfiguration。这个探测只对第一行有效,如果表头里恰好有多个逗号而实际分隔符是分号,可能误判,所以更稳妥的做法是把候选分隔符都试一遍,看哪种切出来的列数在后续行里保持一致。

3.3 字段内换行和引号转义的正确处理方式

CSV 里如果字段内容包含逗号、换行或引号,规范做法是用双引号把整个字段包起来,字段内的双引号写成两个双引号。手写解析器最容易在这里翻车。CsvHelper 默认遵循这个规则,但前提是文件本身写对了。如果文件是用程序拼出来的、没有正确转义,那任何解析器都救不了,只能先做数据清洗。

// 演示一个字段内包含换行和引号的情况 // 原始 CSV 内容: // Id,Remark // 1,"第一行 // 第二行" // 2,"他说""你好"""

用 CsvHelper 读上面内容时,第一条记录的 Remark 会是“第一行\n第二行”,第二条是“他说"你好"”。如果你自己用Split(','),第一条会被拆成两行,第二条的引号会残留。所以我的习惯是:只要字段可能包含特殊字符,一律用成熟库,不自己造轮子。

4. Excel 读取的依赖选型与内存控制:别让一个文件拖垮服务

4.1 三种主流库的适用边界对比

除了 ClosedXML 和 EPPlus,还有 ExcelDataReader 和 NPOI。NPOI 同时支持 xls 和 xlsx,适合需要兼容老格式的场景;ExcelDataReader 是只读流式,内存占用低,适合大文件导入;ClosedXML 和 EPPlus 功能全但会把工作簿加载进内存。选型时先问自己三个问题:文件是 xls 还是 xlsx?文件最大多少行?需不需要写回?答案基本能锁定库。

库支持格式读取方式内存特点典型场景
NPOIxls/xlsx全量或事件中高兼容老格式
ExcelDataReaderxls/xlsx流式只读低大文件导入
ClosedXMLxlsx全量中高中小文件、需写回
EPPlusxlsx全量中高功能全、注意授权

4.2 用 ExcelDataReader 流式读取大文件

当文件超过几万行,ClosedXML 的全量加载会让内存飙升。ExcelDataReader 配合AsDataSet或逐行读取能显著降低峰值。下面是一个逐行读取的示例:

using System; using System.IO; using ExcelDataReader; // 注意:.NET Core 下需要注册编码提供程序,否则读 xls 会报错 System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); public static void ReadLargeExcel(string filePath) { using var stream = File.Open(filePath, FileMode.Open, FileAccess.Read); // useHeaderRow: true 表示第一行作为列名 using var reader = ExcelReaderFactory.CreateReader(stream); do { // 每个 do-while 对应一个工作表 while (reader.Read()) { // reader.FieldCount 是列数,reader.GetValue(i) 取第 i 列 var id = reader.GetValue(0)?.ToString(); var name = reader.GetValue(1)?.ToString(); // 这里做业务处理,不要在这里堆积大对象 } } while (reader.NextResult()); }

逻辑说明:CreateReader不会一次性加载整个文件,Read()逐行推进,NextResult()切换到下一个工作表。这种方式内存占用和行数基本无关,适合后台批处理。参数上,CreateReader有重载可以指定是否读公式、是否读格式,默认只读值,够用。

4.3 日期和数字格式的读取陷阱

Excel 内部把日期存成序列号,1900 年 1 月 1 日是 1。如果单元格格式是日期,库通常能自动转换;但如果格式是常规,读出来就是数字。金额同理,可能读成 double 导致精度丢失。我的做法是:读之前先看单元格的NumberFormat,或者统一按字符串读出来再自己解析。用 ExcelDataReader 时,GetValue返回的是 object,日期会返回 DateTime,数字返回 double,所以金额字段要转成 decimal 时用Convert.ToDecimal并注意四舍五入。

5. 避坑与排查:这五类问题我几乎每次都能遇到

5.1 文件被占用导致读取失败

现象:调用读取方法时抛IOException,提示文件正被另一进程使用。原因:文件还在 Excel 里打开着,或者上一次读取的流没有释放。解决:确保所有StreamReader、XLWorkbook、FileStream都用using包裹;如果文件确实被 Excel 占用,可以先把文件复制到临时目录再读,复制时用FileShare.ReadWrite打开源文件。

5.2 表头名称带空格或大小写不一致导致映射失败

现象:CsvHelper 映射后字段全是默认值。原因:模型属性名和表头不完全一致,或者表头有前后空格。解决:在CsvConfiguration里开启TrimOptions.Trim,或者用ClassMap显式指定列名映射,不依赖自动匹配。

5.3 空行和末尾空列被当成有效数据

现象:导入结果里多出若干条空记录。原因:CSV 末尾有空行,或者 Excel 的 UsedRange 把格式化的空单元格也算进去。解决:CSV 侧开启IgnoreBlankLines;Excel 侧在循环里判断关键字段是否为空,为空则跳过。

5.4 大文件导致内存溢出

现象:导入几十兆的 xlsx 时进程内存暴涨甚至崩溃。原因:用了全量加载的库。解决:换 ExcelDataReader 流式读取,或者先用脚本把 xlsx 转成 CSV,再用 CsvHelper 流式处理。转换脚本可以用 Python 的 pandas 或 LibreOffice 命令行,但要注意转换后的编码。

5.5 不同区域设置下日期解析结果不同

现象:同一份文件在开发机正常,在服务器上日期解析错乱。原因:DateTime.Parse依赖当前线程的区域设置,服务器可能是 en-US。解决:所有日期解析都显式传入CultureInfo.InvariantCulture和格式数组,不要用无参的Parse。

6. 把读取逻辑封装成可复用组件的一个实用技巧

走到这里,你已经能把 CSV 和 Excel 读出来了。但真实项目里,读取只是第一步,后面还有校验、去重、入库。我一般会把读取部分封装成一个泛型方法,让调用方只关心模型和文件路径,编码、分隔符、日期格式这些细节都在组件内部处理掉。下面这个技巧是我踩了多次坑之后固定下来的写法:用委托把“行到对象的映射”抽出去,读取组件只负责把每一行变成字典。

using System; using System.Collections.Generic; using System.IO; using System.Text; using CsvHelper; using CsvHelper.Configuration; public static class TabularReader { // 统一入口:根据扩展名分派到 CSV 或 Excel 读取 public static IEnumerable<Dictionary<string, string>> ReadRows(string filePath) { var ext = Path.GetExtension(filePath).ToLowerInvariant(); return ext switch { ".csv" => ReadCsvRows(filePath), ".xlsx" => ReadExcelRows(filePath), _ => throw new NotSupportedException($"不支持的文件类型:{ext}") }; } private static IEnumerable<Dictionary<string, string>> ReadCsvRows(string filePath) { var encoding = DetectEncoding(filePath); using var reader = new StreamReader(filePath, encoding, true); var config = new CsvConfiguration(System.Globalization.CultureInfo.InvariantCulture) { Delimiter = DetectDelimiter(reader.ReadLine() ?? ""), IgnoreBlankLines = true, TrimOptions = TrimOptions.Trim, BadDataFound = null }; // 注意:这里需要重新打开流,因为上面 ReadLine 已经消耗了一行 using var reader2 = new StreamReader(filePath, encoding, true); using var csv = new CsvReader(reader2, config); if (!csv.Read()) yield break; csv.ReadHeader(); var headers = csv.HeaderRecord; while (csv.Read()) { var row = new Dictionary<string, string>(); foreach (var h in headers) row[h] = csv.GetField(h) ?? ""; yield return row; } } private static IEnumerable<Dictionary<string, string>> ReadExcelRows(string filePath) { // 这里用 ExcelDataReader 的流式读取,返回字典 using var stream = File.Open(filePath, FileMode.Open, FileAccess.Read); using var reader = ExcelReaderFactory.CreateReader(stream); do { if (!reader.Read()) continue; var headers = new string[reader.FieldCount]; for (int i = 0; i < reader.FieldCount; i++) headers[i] = reader.GetValue(i)?.ToString() ?? $"Col{i}"; while (reader.Read()) { var row = new Dictionary<string, string>(); for (int i = 0; i < reader.FieldCount; i++) row[headers[i]] = reader.GetValue(i)?.ToString() ?? ""; yield return row; } } while (reader.NextResult()); } private static Encoding DetectEncoding(string filePath) { using var fs = File.OpenRead(filePath); var buffer = new byte[3]; var read = fs.Read(buffer, 0, 3); if (read == 3 && buffer[0] == 0xEF && buffer[1] == 0xBB && buffer[2] == 0xBF) return Encoding.UTF8; return Encoding.GetEncoding("GBK"); } private static char DetectDelimiter(string firstLine) { var candidates = new[] { ',', ';', '\t', '|' }; return candidates.OrderByDescending(c => firstLine.Count(ch => ch == c)).First(); } }

这个组件的核心思路是:读取层只输出Dictionary<string,string>,把类型转换和业务校验留给上层。这样 CSV 和 Excel 可以共用同一套后续处理逻辑,换文件格式时不用改业务代码。参数上,DetectEncoding和DetectDelimiter可以根据你的实际文件来源调整候选列表。注意 CSV 分支里我打开了两次流,因为探测分隔符需要先读一行,而 CsvReader 需要从头开始读,这个细节如果忽略,表头就会丢。

最后说一个我自己的习惯:每次写完读取逻辑,我都会准备三个测试文件——一个 UTF-8 带 BOM 的标准 CSV、一个 GBK 编码且字段含换行的 CSV、一个包含日期和货币格式的 xlsx。这三个文件能跑通,上线基本不会出大问题。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进