ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#解析CSV全攻略:方法对比、编码陷阱与选型建议

C#解析CSV全攻略:方法对比、编码陷阱与选型建议 CSV这东西看着简单但你越用它越觉得水深。我最早接触C#解析CSV文件时以为不就是按逗号切开吗后来被带引号的字段、嵌入的换行、Excel的编码问题轮番教育了一通才算是真正把CSV的脾气摸清了。这篇文章我不打算只丢几个库给你而是把C#里常见到的解析CSV文件的几种方式、各自的适用场景、以及我实测下来的坑一次性讲明白。如果你是刚入门C#、正准备处理上级扔过来的csv报表或者你已经在写数据处理和上位机程序但被错误编码、并发写文件折磨过再或者你只是好奇为什么一个看起来这么简单的格式会有那么多解析工具——这篇文章都适合你。1. 先认清CSV文件的真实结构别把“逗号分隔”想得太简单CSV的全称是Comma-Separated Values字面意思是逗号分隔的值。但如果你真的只用逗号去切用不了多久就会撞上这样一行数据张三,北京,朝阳区程序员,1800000这里“朝阳区程序员”本身是一个字段可里面包含了中文并没有逗号。真正麻烦的是下面这种张三,北京,朝阳区,他说今天加班,18000第二个字段北京,朝阳区里面带逗号所以必须用双引号包起来。第三个字段里用了两个连续的双引号来表示原文中的双引号。如果你用line.Split(,)会把它拆成一堆碎片数据当场烂掉。1.1 CSV不仅限于逗号分隔除了逗号制表符\t分隔的TSV文件在实际工作中也极其常见尤其是从某些数据库或统计软件导出的报表。很多语言把CSV当作通用“二维表格文本格式”来用所以解析时不能写死Delimiter ,要允许外部传入。另外一个常见的误解是CSV文件一定是一行一条记录。严谨的RFC 4180规范确实默认一行是一条记录但字段值内部可以包含换行只要这个字段被双引号包围姓名,备注 张三,第一行 第二行这种字段里的换行在Excel里是能正常显示的但用File.ReadAllLines再逐行处理时你会把一条逻辑记录拆成两条后面的字段全部对不上。1.2 字段转义的基本规则现在把CSV的语法规则明确一下解析器靠的就是这几条用分隔符通常是逗号或制表符区分字段。字段如果包含分隔符、双引号或者换行需要用双引号包起来。双引号内部的双引号要写成两个连续的双引号转义。整行可以带\r\n或\n换行解析器要正确识别回车换行和记录内部换行的区别。文件开头可能带BOMByte Order Mark也会被解析器当成字段内容。所以解析CSV本质上是一个带状态的字符扫描过程需要在“普通字段”“引号内部”“引号刚刚结束”几种状态之间切换。这也是我后文讲状态机的原因。2. C#里开箱就能写的几种解析方式C#本身没有像Python的csv模块那样内置一个官方CSV库。很多人会拿string.Split硬上也有人知道TextFieldParser的存在但实际体验下来各有各的问题。2.1 string.Split解析最直接的方案也是翻车最快的方案先看最原始的做法var lines File.ReadAllLines(data.csv, Encoding.UTF8); foreach (var line in lines) { var fields line.Split(,); Console.WriteLine(fields[0]); }这段代码在应对“规规矩矩”的CSV时没什么大问题比如你手上是一个接口自动导出的纯英文单层数据只有简单的逗号分隔字段内没有引号也没有换行。这种情况Split的确够用代码零依赖性能还非常不错。但它几乎无法应对实际业务数据。最常见的问题有三个字段内包含逗号比如地址、备注、金额等。字段内包含双引号需要先把最外层引号去掉再把内部还原成。字段跨行一条记录被拆成了多行解析。如果你手上文件是别人用Excel随手存的或者是从银行、运营商、政府系统导出的我建议你默认它“不值得信任”不要用Split。2.2 TextFieldParser微软官方半成品能救急但不能苛求TextFieldParser来自Microsoft.VisualBasic.FileIO命名空间在.NET Framework时代一直是C#项目里解析CSV的“备胎之王”。用法很直接using Microsoft.VisualBasic.FileIO; using var parser new TextFieldParser(data.csv, Encoding.UTF8) { TextFieldType FieldType.Delimited, Delimiters new[] { , }, HasFieldsEnclosedInQuotes true }; while (!parser.EndOfData) { string[]? fields parser.ReadFields(); if (fields null) continue; Console.WriteLine(${fields[0]} | {fields[1]}); }它能正确处理带引号、带转义、带换行的字段这是它比string.Split强的地方。但我在实际使用中发现几个问题在.NET Framework下用起来很顺手但到了.NET Core/.NET 5环境你需要额外引入Microsoft.VisualBasic包跨平台部署时有过行为不一致的情况。API是同步的没有异步读取版本处理大文件时很容易把UI线程卡死。每行读取完返回的是string[]没有类型映射能力后续还是要自己转换。我曾经用它读取一批带日期和金额的银行流水每列都要手工DateTime.ParseExact和decimal.Parse代码写得很冗余。结论是TextFieldParser适合少量文件、快速处理性能不是它的优势。2.3 自己写状态机解析器理解原理才是核心竞争力网上的很多CSV解析库核心算法都是一个状态机。我建议你至少自己手写一个迷你版本不是为了造轮子而是为了理解解析过程中各种边界情况的由来。核心思路是逐字符扫描并维护一个当前状态enum CsvState { FieldStart, InField, InQuotedField, QuoteInQuotedField, AfterQuote }大致的逻辑是如果当前状态是InField遇到分隔符就切到下一个字段如果遇到双引号就进入InQuotedField状态在引号字段里如果又遇到双引号先假设是转义再看下一个字符是不是双引号或分隔符。我写过一个约120行的解析函数用来处理单行带引号内容最终性能跟CsvHelper相差不大。但问题在于自己手写要考虑的边界条件很多文件最后的记录有没有换行符空行是跳过还是保留字段内容有首尾空格是保留还是修剪这些都是业务级别的决策。自己写可以但要明确它只是项目私有方案维护成本很高。3. 上生产环境我更推荐CsvHelper的组合拳如果你问我C#生态里解析CSV最省心的方案我首选CsvHelper。这个库几乎成了.NET里CSV处理的事实标准很多框架和工具内部都在用它。3.1 基础读取几行代码就搞定复杂转义用NuGet安装CsvHelper后读取文件里所有记录并且映射到强类型对象核心代码是这样using CsvHelper; using CsvHelper.Configuration; using System.Globalization; var config new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord true, Delimiter ,, Encoding Encoding.UTF8 }; using var reader new StreamReader(data.csv, Encoding.UTF8); using var csv new CsvReader(reader, config); var records csv.GetRecordsPerson().ToList(); public class Person { public string Name { get; set; } public string City { get; set; } public string Remark { get; set; } public decimal Salary { get; set; } }你不需要自己处理双引号转义、逗号分隔、换行换行。CsvHelper底层会通过状态机逐字符解析对带引号字段的支持非常完整。我自己实测过包含跨行备注和字段内引号的CSV文件它都能正确解析回原值。3.2 字段映射应对列名和顺序不固定的文件现实中的数据文件经常列名变化比如这个月导出的是中文列名下个月可能变成英文字段名甚至列顺序都不一样。CsvHelper的类映射可以解决这类问题public sealed class PersonMap : ClassMapPerson { public PersonMap() { Map(m m.Name).Name(姓名).Index(0); Map(m m.City).Name(所在城市); Map(m m.Remark).Name(备注); Map(m m.Salary).Name(月薪).NumberStyles(NumberStyles.Number | NumberStyles.AllowThousands); } }注册映射后读取csv.Context.RegisterClassMapPersonMap(); var records csv.GetRecordsPerson().ToList();你在写映射时能指定列名、列索引、日期格式、数字格式甚至可以把多个源列拼接成一个目标属性。这个能力对“文档结构化解析”这类需求特别有用能省掉大量手工操作。3.3 大文件流式处理不要ToList很多初学者最容易犯的错误是GetRecordsT().ToList()。如果文件有两三百万行这操作会瞬间吃掉几百兆内存。正确的做法是流式一条一条消费using var reader new StreamReader(big.csv, Encoding.UTF8); using var csv new CsvReader(reader, CultureInfo.InvariantCulture); await foreach (var record in csv.GetRecordsAsyncPerson()) { // 每读取一条就立即处理不要堆积 await ProcessOneAsync(record); }凡是能用流式处理的地方就不要把整个文件装进内存。数字数据量一大内存就是从几百MB和几十MB的巨大差距。3.4 写文件时的转义问题CsvHelper同样可以写CSV而且写入时它会自动处理转义不会写出非法的CSV内容using var writer new StreamWriter(output.csv, false, Encoding.UTF8); using var csv new CsvWriter(writer, CultureInfo.InvariantCulture); var rows new ListPerson { ... }; csv.WriteRecords(rows);这里有个很关键的细节如果你因为担心安全问题想在写入端自己加密CSV文件不要直接在整个文件写完后再用旧的内存方式加密这样大文件会内存爆炸。我推荐边写边用一个CryptoStream包在StreamWriter外面流式加密输出这样即使文件整体加密内存占用也稳定。CsvHelper也能做到同时只有一个实例写入不与其他写入者冲突这点我在后文并发写入部分会详细讲。4. 编码、BOM与Excel兼容性这些问题比解析本身更烦人很多项目里CSV解析代码本身没问题但用户反馈“打开乱码”。这通常不是解析器的问题而是编码和BOM的锅。4.1 Excel和UTF-8的恩怨情仇老版本的Excel在打开UTF-8编码且不带BOM的CSV文件时默认会按本地编码去解析。在中文Windows环境里本地编码经常是GBK所以文件里的中文字符会全部变成问号或乱码。带BOM的UTF-8文件则不同文件头的EF BB BF三个字节相当于告诉Excel“我是UTF-8”。所以如果你用C#生成一个需要交给用户用Excel打开的CSV强烈建议带上BOM写入var utf8WithBom new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); using var writer new StreamWriter(output.csv, false, utf8WithBom);反过来如果程序要读取外部传入的CSV我更推荐先读取文件前三个字节判断是否有BOM再决定用什么Encoding去解码。4.2 如何判断文件编码下面这个写法是我在项目里常用的专门用来判断是不是带BOM的UTF-8byte[] bytes File.ReadAllBytes(data.csv); Encoding encoding; if (bytes.Length 3 bytes[0] 0xEF bytes[1] 0xBB bytes[2] 0xBF) { encoding Encoding.UTF8; } else if (bytes.Length 2 bytes[0] 0xFF bytes[1] 0xFE) { encoding Encoding.Unicode; } else if (bytes.Length 2 bytes[0] 0xFE bytes[1] 0xFF) { encoding Encoding.BigEndianUnicode; } else { // 没有BOM按业务常用编码解析 encoding Encoding.GetEncoding(GB2312); }但要注意判断“不带BOM的UTF-8和GBK”本身就是个难题靠几个字节无法保证。稳妥的做法是给程序一个可配置的编码参数让用户自己选择或者对读取到的内容做一次合法性校验比如用正则检查是否符合预期的中文字符范围。4.3 各系统导出的CSV差异开发中我接触最多的是这几类CSV来源来源常见编码分隔符坑点ExcelGBK/带BOM UTF-8逗号列名带空格、数值字段可能有千分位逗号pandas导出UTF-8不带BOM逗号中文列名、科学计数法MySQL导出UTF-8制表符或逗号字段可能包含\N表示NULLStata导出取决于系统区域逗号文本字段可能自带引号和换行所以我处理这些文件时会先把文件的编码检测出来再根据来源决定分隔符。有些情况下一行CSV里不仅有逗号还有管道符|这时候CsvConfiguration.Delimiter就要设成对应字符。5. 并发读写入同一个CSV文件的实操方案你可能搜到过“C# csv可同时写入与读取”之类的问题。很多初学者想用一个共享的CSV文件同时记录日志、供另一个线程读取分析这就会碰到文件锁和写冲突。5.1 为什么并发写会崩CSV本质是文本文件没有数据库的事务和行级锁。两个进程同时向同一个文件追加内容时可能发生文件被另一个进程独占打开当前进程报“文件正由另一进程使用”。两个进程同时读取文件末尾并分别写入一段内容导致互相覆盖或内容交错。进程A写入半行时进程B来读取读到了不完整的行。最简单的方案是加一个进程级锁但跨进程需要Mutex或者文件锁。5.2 一个可复用的并发安全写入器在单进程多线程场景下可以直接用一个静态锁加File.AppendAllLinespublic static class SafeCsvWriter { private static readonly object Gate new(); public static void AppendLine(string filePath, string line) { lock (Gate) { File.AppendAllText(filePath, line Environment.NewLine, Encoding.UTF8); } } }但这样做仍然有问题每次追加都是先打开文件再关闭频繁操作时性能不好。而且lock只能保证当前进程内的线程安全无法约束别的进程。5.3 用Channel配合单写线程如果程序里有多个生产者要写CSV而你又希望顺序可控我推荐用System.Threading.Channels做一个单写者模型var channel Channel.CreateUnboundedstring(); // 消费者单独一个后台线程负责写文件 var writerTask Task.Run(async () { await using var stream new StreamWriter(log.csv, append: true, Encoding.UTF8); await foreach (var line in channel.Reader.ReadAllAsync()) { await stream.WriteLineAsync(line); } }); // 生产者多个线程往channel丢数据 await channel.Writer.WriteAsync(张三,北京,18000);这样文件始终只有一个线程在写天然避免互相覆盖。读取方如果再配合FileShare.ReadWrite打开文件可以在写的同时读取using var fs new FileStream(log.csv, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);我发现很多项目把“直接改成数据库”作为这种需求的最终归宿。这也对CSV并不适合做高并发随机读写。如果并发请求量上来了趁早换SQLite或数据库别在文本文件上硬扛。6. 多种方案性能对比以及最终选型建议光说哪个“好”不够我们直接看数据。我在一台普通办公电脑上用一个包含100万行、约50MB的CSV文件做了简单对比结果如下数据量级仅供横向参考不代表绝对准确方案100万行耗时内存占用转义字段支持类型映射适用场景string.Split约0.3秒高不支持无简单内部格式、可直接规范化TextFieldParser约2.8秒中支持无小文件快速处理自写状态机约1.2秒中可控制无学习/特殊规则定制CsvHelper流式读取约1.8秒低完善强生产环境、复杂业务string.Split看着快但它只在“文件已经被标准清洗过”这个前提下才有意义真实业务里一旦遇到转义字段就会产生错误后续补数据的人会非常痛苦。有时我也会刻意切一段字节流自己解析然后按行做近似分割换取性能。性能之外的选型我建议用下面这个表文件是系统干净导出字段简单 - string.Split 临时处理小文件、偶尔遇到引号字段 - TextFieldParser 生产系统需要强类型映射和稳定解析 - CsvHelper 需要把写入、并发控制、加密一起做 - CsvHelper Channel 自定义写入器我自己维护的老项目里现在是混合状态。历史代码用了TextFieldParser还在平稳跑着我不会为了换而换但新写的模块一律用CsvHelper因为它的配置项和社区文档最成熟后续接入新需求时改动最小。如果你正在做一个长期使用的系统我的建议只有一条CSV的解析一定要做成可配置的模块把编码、分隔符、是否首行为表头、是否跳过空行、引号转义规则这些参数都暴露出来。不要写死在业务代码里。我接手过太多用Split(,)写出来的接口每次别人发来一份特殊格式的CSV就要去业务代码里找三四处地方改逻辑改完还会引入新问题。另外认真处理CSV之后你会发现它本质上是一个“约等于格式化文本但又不够严格”的数据交换格式。指望随便一拼、随意一拆就能保证数据正确是不可能的。把解析这层平台做厚一点后面不管是对接Excel、数据库还是统计软件都会省很多事。最后分享一个小技巧调试CSV解析问题时先手动用记事本或十六进制查看器看文件原始内容不要直接用Excel打开后凭眼睛猜。很多解析问题根本不是代码逻辑的问题而是文件里藏了一个肉眼看不见的\r或者\uFEFF。记住了这一点能帮你少走很多弯路。
返回列表