我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

C#读取与合并CSV文件:解析、对齐、流式处理与避坑

C#读取与合并CSV文件:解析、对齐、流式处理与避坑 简介面向需要掌握C#文件读写与批量数据整合的初、中级开发者这份“读取与合并CSV文件”示例聚焦逐行读取、逗号分列解析、记录集合聚合以及合并结果写出的完整流程并提示在表头不统一、列数不一致时借助CsvHelper等工具增强兼容性适合课程设计、工具脚本编写与日常表格数据整理等场景。压缩包共32个文件、约360KB包含6个C#源码文件、可执行程序、Visual Studio解决方案以及界面资源、调试符号和项目配置等保留较完整的工程目录便于直接打开对照运行。已有469人学习下载。读者可从中理解CSV解析、数据序列化与文件输入输出的基本思路也能借鉴辅助方法拆分读取、追加重组和统一写出的组织方式为处理多文件数据合并、字段清洗与格式转换提供可复用的参考并留意编码、分隔符与空行等细节。1. 十二份导出文件摆在面前先别急着 Split某开发者接手一个对账需求业务方陆陆续续给了十二份 CSV字段顺序各不相同有的带 UTF-8 BOM有的是 GB18030 编码还有几个单元格里塞了带逗号和换行的备注。他顺手写了File.ReadAllLines(path).Select(l l.Split(,))跑通前三份之后开始整列错位。这套 C# 读取与合并 CSV 文件的流程几乎每个做数据导入的人都要踩一遍。这里不解释 CSV 是什么只讲一条能直接抄的路解析层怎么选、纵向追加和横向关联怎么区分、大文件怎么不炸内存、哪几个细节会让整张表悄悄做废。适合要把多份导出表揉成一张交付表的后端和桌面端开发者也适合偶尔写个工具脚本、不想为一次合并引入半个生态的人。2. 解析层选型为什么 Split(,) 撑不到第三份文件2.1 CSV 不是「逗号分隔的文本」是一台状态机大部分人第一次写 CSV 读取脑子里想的是「按行切、按逗号切」。这个模型在 Excel 导出的干净小表上能跑但只要有一个字段被双引号包起来整套假设就崩了。RFC 4180 定义的规则其实很短但每一条都能让Split(,)翻车字段可以被双引号包裹被包裹的字段内部允许出现分隔符、回车和换行字段内的双引号用两个连续双引号转义行尾可能是 CRLF、LF 或裸 CR最后一行可能没有换行符文件开头可能带 BOM。举三个我实际见过的样本。第一条2024-05-01,华东, 上海,128.00第二列里有个逗号切完变成四列后面全线偏移。第二条备注客户要求\n分两批发货引号里带换行ReadAllLines直接把它拆成两行行数统计从此不可信。第三条Excel 另存的 UTF-8 CSV 带 BOM首列列名读出来是\uFEFF订单号你用row[订单号]取不到值还不报错只是全是空串。还有一个隐蔽的字段里出现不成对的双引号比如他说这样就行。手写解析器如果没处理好「引号内遇到引号」的分支会一直吞到文件末尾最后表现是「只读出一行」。这类问题不是靠加个if能补的得换模型——把解析写成逐字符状态机或者交给成熟库。2.2 三套解析方案的取舍C# 里能落地的就三条路自己写状态机、用 CsvHelper、用Microsoft.VisualBasic.TextFieldParser。三者不是「好与坏」而是适用面不同选错会白折腾。方案依赖流式读取容错能力字段内换行典型场景手写状态机无BCL 即可支持自己控制支持内网工具、单文件分发、列结构固定CsvHelperNuGet 一个包支持强可挂事件支持多来源异构表、需要类型映射和校验TextFieldParser引用 VisualBasic 程序集支持中等支持老项目、只做简单切分我一般的判断标准是如果这个工具要发给别人双击运行、又不想让对方装运行时依赖手写状态机最省事如果要处理十几个来源、列名写法不统一、还得做类型转换和错误行收集直接上 CsvHelper它把BadDataFound、MissingFieldFound、TrimOptions这些开关都留好了能省掉大量防御代码。TextFieldParser属于历史遗产新项目没必要为了它引入Microsoft.VisualBasic程序集但如果接手的老代码里已经在用就别动它。性能上有个常见的误解觉得手写一定比库快。实际在中低数据量下两者差距很小瓶颈通常在编码转换和字符串分配到了几百万行真正决定速度的是你有没有用IEnumerable流式处理而不是谁在 split。我做过一次粗糙对比同一份 120 万行文件手写状态机和 CsvHelper 的耗时差不到 15%但「一次性读进内存」和「流式读取」的差距是数倍且后者不会 OOM。所以优先保证流式再谈实现。2.3 手写状态机解析器四十行拿到完整字段不引依赖的场合我用的就是这个版本。核心是四个状态是否在引号内、当前字段缓冲、当前行缓冲、以及处理 CRLF 时的预读。using System.Text; public static class CsvReaderLite { /// summary逐行产出字段数组全程流式不会把整个文件读进内存/summary public static IEnumerablestring[] ReadRows( TextReader reader, char delimiter ,, char quote ) { var field new StringBuilder(64); var row new Liststring(16); var inQuotes false; int c; while ((c reader.Read()) ! -1) { var ch (char)c; if (inQuotes) { if (ch quote) { // 连续两个引号 一个字面量引号 if (reader.Peek() quote) { field.Append(quote); reader.Read(); } else inQuotes false; // 引号段结束回到普通状态 } else field.Append(ch); // 引号内的逗号和换行都属于字段内容 continue; } if (ch quote) { inQuotes true; } else if (ch delimiter) { row.Add(field.ToString()); field.Clear(); } else if (ch \r || ch \n) { if (ch \r reader.Peek() \n) reader.Read(); // CRLF 只算一次换行 row.Add(field.ToString()); field.Clear(); yield return row.ToArray(); row.Clear(); } else field.Append(ch); } // 文件末尾没有换行符时补最后一行的产出 if (field.Length 0 || row.Count 0) { row.Add(field.ToString()); yield return row.ToArray(); } } }逻辑上有三个点值得说。第一inQuotes为真时逗号和换行都被追加进field这就是字段内换行能被正确保留的原因只有引号段真正闭合后才恢复对分隔符的敏感。第二连续双引号的判断用了reader.Peek()加一次reader.Read()这是把还原成一个的唯一正确做法少写这一步转义字段就会多出两个引号。第三\r后面预读一个字符判断是不是\n避免 Windows 换行被当成两次行结束、产出一堆空行——这是手写解析里最常见的 bug。参数方面delimiter留成参数是因为不少导出系统用分号或制表符分隔尤其在区域设置把小数点写成逗号的环境里quote也留了口子虽然 RFC 规定是双引号但确实有系统用单引号包裹TextReader而不是路径是为了让调用方能自己决定编码和是否从流中间开始读。2.4 CsvHelper 的六个配置项改错一个就错位用 CsvHelper 时绝大多数「结果不对」都不是库的问题是配置没对齐。下面这段是我固定的开局模板。using System.Globalization; using CsvHelper; using CsvHelper.Configuration; var config new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter ,, // 分隔符分号文件改成 ; HasHeaderRecord true, // 首行是列名 TrimOptions TrimOptions.Trim, // 去掉字段两端空白 MissingFieldFound null, // 列缺失时不抛异常由业务层兜底 DetectDelimiter false, // 已知分隔符就别让库去猜猜错会读成单列 IgnoreBlankLines true, // 跳过纯空行 BadDataFound args { // 只记录不中断坏行交给人工复核 Console.Error.WriteLine($坏行 [{args.Context.Parser.Row}]: {args.Context.Parser.RawRecord}); } }; using var reader new StreamReader(path, encoding, detectEncodingFromByteOrderMarks: true); using var csv new CsvReader(reader, config); csv.Read(); csv.ReadHeader(); var headers csv.HeaderRecord ?? Array.Emptystring(); while (csv.Read()) { var record new Dictionarystring, string(StringComparer.OrdinalIgnoreCase); foreach (var h in headers) record[h] csv.GetField(h) ?? string.Empty; // 按列名取值不按列序号 // 交给后续合并逻辑 }几个参数的取舍值得展开。CultureInfo.InvariantCulture一定要显式传否则库会按当前线程的区域设置去解析数字和日期同一个文件在中文和英文机器上得到的decimal可能不一样。DetectDelimiter默认是关的有些教程建议打开但它在「表里同时存在逗号和分号」时会误判已知分隔符的场景我宁愿写死。MissingFieldFound null的作用是当某行字段数少于表头时不要抛异常——不同来源的表列数不一致是常态让它在读取阶段就炸等于把合并流程卡死在第一个文件上。BadDataFound这个事件是排错的抓手它把出问题的原始行内容通过Parser.RawRecord吐出来我通常把它写进一份.badrows.log跑完再看是不是引号不配对。最后注意GetField(h)是按列名取值列名在headers里不存在时会抛HeaderNotFound所以代码里先遍历headers而不用硬编码字符串等于把「列名写错」这个错误提前到了运行时的第一行。3. 合并语义纵向追加和按主键关联是两件事3.1 先把 append、union、join 分清楚「合并 CSV」这句话至少有三种完全不同的含义混着做是错位的主要来源。第一种是纵向追加多个文件列结构相同或相近把行摞起来SQL 里对应UNION ALL特点是行数相加、列不变。第二种是纵向去重追加还是把行摞起来但按某个业务主键去重对应UNION行数可能小于相加。第三种是横向关联两个文件都有「订单号」这一列但字段互补需要按主键把列拼在一起对应LEFT JOIN特点是列数相加、行数通常等于主表。判断方法很直接看两份文件的列名重复比例。重复超过一半多半是追加几乎没有重复但存在一列取值域重合多半是关联。我见过最典型的翻车是把关联做成了追加——两份表摞在一起同一订单号出现两行一半字段为空报表口径直接错位而且这种错在肉眼抽查前几行时看不出来。还有一个现实约束横向关联要求主键唯一但业务方导出的表经常一对多。这时不能简单用Dictionarystring, Row存右表得用ToLookup保留多值再在输出阶段展开或者聚合下面 3.4 的代码按这个思路写。3.2 列对齐按列名映射不按列序号十二份来源不同的文件字段顺序几乎不可能一致。有的把「金额」放第三列有的放第七列有的叫「订单号」有的叫「订单编号」还带空格。按列序号合并是自找麻烦正确做法是先建立列名并集再把每行的值映射到并集上。public sealed class CsvTable { public Liststring Columns { get; } new(); public ListDictionarystring, string Rows { get; } new(); } /// summary按列名忽略大小写和首尾空白把多张表纵向对齐/summary public static CsvTable MergeVertical(IEnumerableCsvTable tables) { var result new CsvTable(); var seen new HashSetstring(StringComparer.OrdinalIgnoreCase); foreach (var t in tables) foreach (var col in t.Columns) { var key col.Trim(); if (seen.Add(key)) result.Columns.Add(key); // 列名并集顺序以首次出现为准 } foreach (var t in tables) foreach (var row in t.Rows) { var aligned new Dictionarystring, string(StringComparer.OrdinalIgnoreCase); foreach (var col in result.Columns) aligned[col] row.TryGetValue(col, out var v) ? v : string.Empty; result.Rows.Add(aligned); } return result; }这段的关键全在HashSet的比较器上。StringComparer.OrdinalIgnoreCase让「订单号」和「ORDER_NO」这类大小写差异不会分裂成两列col.Trim()处理表头尾部的空白。首次出现决定列顺序也就是说输出文件的列序取决于你传进来的文件顺序这一点要在调用处显式排好别指望它自动按字母排。第二个关键点是row.TryGetValue(col, out v)失败时补空串而不是跳过这一行。追加场景下某些文件缺少某几列是常态缺列补空能让后面写出 CSV 时列数严格一致如果改成continue那张表的部分行就会短一截写出去就是错位的根源。提示如果业务方的列名差异不是大小写而是同义不同名「订单号」对「订单编号」不要试图在代码里堆if维护一张列名映射表把别名统一到规范名再走上面的并集逻辑。3.3 纵向追加与复合键去重追加做完之后去重是第二步。单键去重很简单DistinctBy一行搞定麻烦的是复合键——比如「订单号 行号」才算唯一单看订单号会误删同一订单的第二条明细。/// summary按复合键去重保留首次出现的行/summary public static ListDictionarystring, string Dedup( IEnumerableDictionarystring, string rows, params string[] keyColumns) { var seen new HashSetstring(StringComparer.Ordinal); var output new ListDictionarystring, string(); foreach (var row in rows) { // 用不可见分隔符拼接避免 A|B 与 AB 撞键 var key string.Join(\u0001, keyColumns.Select(c row.TryGetValue(c, out var v) ? v.Trim() : string.Empty)); if (seen.Add(key)) output.Add(row); } return output; }复合键的拼接有个坑直接用string.Join(-, ...)会撞键比如「AB-1」和「A-B1」拼出来不一样但值域重叠时边界模糊。用\u0001这种不会出现在业务数据里的字符做分隔符最稳。Trim()放在拼接之前是为了让「 1001」和「1001」被认成同一个键——导出表里带空格的数字非常常见。保留首次出现还是保留最后出现取决于数据时效性。业务方补发的文件通常放在列表末尾那就是「后出现覆盖先出现」这时别用HashSet.Add的写法改成Dictionarystring, Row赋值覆盖最后按插入顺序输出。这个差别不大但方向搞反了会留下一批旧数据。3.4 按主键左关联的链式写法横向合并的核心是保住主表所有行右表匹配不上就补空不能因为右表缺了某个键就把主表这行丢掉——这是LEFT JOIN和INNER JOIN的分水岭也是报表对不上总数时第一个要查的地方。/// summary以 left 为主表按 key 关联 right右表多行时取第一条并记录冲突数/summary public static CsvTable MergeByKey(CsvTable left, CsvTable right, string key, out int conflicts) { conflicts 0; var lookup right.Rows.ToLookup(r r.TryGetValue(key, out var v) ? v.Trim() : string.Empty); var result new CsvTable(); result.Columns.AddRange(left.Columns); result.Columns.AddRange(right.Columns.Where(c !left.Columns.Contains(c, StringComparer.OrdinalIgnoreCase))); foreach (var l in left.Rows) { var k l.TryGetValue(key, out var kv) ? kv.Trim() : string.Empty; var matches lookup[k].ToList(); var merged new Dictionarystring, string(StringComparer.OrdinalIgnoreCase); foreach (var c in result.Columns) merged[c] string.Empty; foreach (var kvp in l) merged[kvp.Key] kvp.Value; if (matches.Count 1) conflicts; // 一对多记下来别静默吞掉 if (matches.Count 0) foreach (var kvp in matches[0]) if (!merged.ContainsKey(kvp.Key) || string.IsNullOrEmpty(merged[kvp.Key])) merged[kvp.Key] kvp.Value; // 右表只补空位不覆盖左表已有值 result.Rows.Add(merged); } return result; }ToLookup而不是ToDictionary是因为右表主键不唯一时ToDictionary会直接抛「已添加相同键的项」而ToLookup把同键的多行收成一个集合。conflicts这个输出参数是我加的私货跑完打印一行「主键冲突 37 条」比事后发现金额翻倍要好查得多。第二个细节是覆盖策略右表只补空位不覆盖左表已有值。这个规则在多表链式关联时必须定死否则关联顺序不同会导致最终结果不同同一批数据跑两次对不上账。链式调用就写成MergeByKey(MergeByKey(a, b, 订单号, out _), c, 订单号, out _)主表始终在最左边。4. 大文件与编码把 3GB 的 CSV 压进 200MB 内存4.1 编码探测BOM、严格 UTF-8 试探、GB18030 兜底编码不对的表现有两种一种是\uFFFD乱码另一种更隐蔽中文没坏但首列列名多了个看不见的 BOM 字符导致row[订单号]永远取空。我用的判定顺序是先看 BOM再看能不能按严格 UTF-8 完整解码都不行才回落 GB18030。using System.Text; public static Encoding ResolveEncoding(string path) { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); // 让 GB18030 可用 using var fs File.OpenRead(path); Spanbyte head stackalloc byte[3]; int n fs.Read(head); if (n 3 head[0] 0xEF head[1] 0xBB head[2] 0xBF) return new UTF8Encoding(true); // UTF-8 with BOM if (n 2 head[0] 0xFF head[1] 0xFE) return Encoding.Unicode; if (n 2 head[0] 0xFE head[1] 0xFF) return Encoding.BigEndianUnicode; // 无 BOM用严格模式整篇试解码遇到非法字节序列会抛异常 fs.Position 0; var strict new UTF8Encoding(false, throwOnInvalidBytes: true); try { using var probe new StreamReader(fs, strict, detectEncodingFromByteOrderMarks: false, bufferSize: 64 * 1024, leaveOpen: true); var buf new char[8192]; while (probe.Read(buf, 0, buf.Length) 0) { } return new UTF8Encoding(false); // 合法 UTF-8无 BOM } catch (DecoderFallbackException) { return Encoding.GetEncoding(GB18030); // 常见的中文导出编码 } }throwOnInvalidBytes: true是这段的核心普通Encoding.UTF8遇到非法字节会静默替换成探不出问题。整篇试解码在大文件上确实要扫一遍但相比读错编码后再回头排查乱码这点开销值得如果文件特别大可以只试读前 1MB代价是极小概率误判。还有一个容易忽略的点Encoding.RegisterProvider(CodePagesEncodingProvider.Instance)必须在取GB18030之前调用.NET Core之后默认不带这些代码页漏了这一行会抛NotSupportedException错误信息看起来和编码毫无关系。4.2 流式管道一行一行进一行一行出内存爆炸的根源几乎都是File.ReadAllLines或者「先把所有行读进List再写」。改成流式之后内存占用与文件行数基本无关只和单行长度有关。public static void MergeStreaming( IEnumerablestring inputPaths, string outputPath, string[] outputColumns) { using var writer new StreamWriter(outputPath, append: false, new UTF8Encoding(true)); writer.WriteLine(string.Join(,, outputColumns.Select(Escape))); // 写表头 foreach (var path in inputPaths) { var enc ResolveEncoding(path); using var reader new StreamReader(path, enc, detectEncodingFromByteOrderMarks: true); var rows CsvReaderLite.ReadRows(reader); using var e rows.GetEnumerator(); if (!e.MoveNext()) continue; var header e.Current.Select(h h.Trim().TrimStart(\uFEFF)).ToArray(); // 去 BOM 残留 while (e.MoveNext()) { var cells e.Current; var map new Dictionarystring, string(StringComparer.OrdinalIgnoreCase); for (int i 0; i header.Length i cells.Length; i) map[header[i]] cells[i]; var line outputColumns.Select(c Escape(map.TryGetValue(c, out var v) ? v : string.Empty)); writer.WriteLine(string.Join(,, line)); } } } /// summary写 CSV 时按需加引号引号本身双写/summary static string Escape(string s) { if (string.IsNullOrEmpty(s)) return string.Empty; bool needQuote s.IndexOfAny(new[] { ,, , \r, \n }) 0; if (!needQuote) return s; return \ s.Replace(\, \\) \; }写出去这一步同样要讲规则。Escape只在字段含逗号、引号或换行时才加引号其他情况裸写——全字段加引号虽然也不会错但会让文件体积变大、还容易被下游系统误读成文本类型。s.Replace(\, \\)是写侧唯一的转义规则漏了它字段里带引号的那一行在下次读取时就会错位。StreamWriter用new UTF8Encoding(true)带 BOM。这不是审美问题Excel 双击打开无 BOM 的 UTF-8 CSV 会按本地代码页解释中文全是乱码。如果输出是给程序消费的反而建议不带 BOM。4.3 分块与哈希分组绕开内存天花板真正躲不掉内存的是「全局去重」和「全局排序」。前者要记住所有见过的键后者要拿到全部数据。我的做法是分块按哈希把行分到若干个临时文件每个文件内部单独去重最后再合并。// 伪代码级思路关键在分区函数同一主键必须落到同一个桶 int Bucket(string key, int bucketCount) (int)((uint)key.GetHashCode() % (uint)bucketCount);分区函数必须保证同一个键永远进同一个桶否则跨桶重复去不掉。string.GetHashCode()在 .NET Core 之后默认是随机化的同一进程内一致、跨进程不一致所以这份中间结果不能跨进程复用要稳定的话自己写个 FNV-1a 之类的哈希。分成 16 到 64 个桶通常就够每个桶处理完立即落盘峰值内存降到原来的十几分之一。如果没有全局去重需求、只是单纯追加那连分块都不用4.2 的流式管道直接过一遍即可几个 GB 的文件跑下来内存稳定在几十 MB。4.4 并行读取的边界别把 StreamReader 丢进 Parallel.For看到多个文件就想并行是本能但要分清并行的是什么。文件之间并行读取是安全的每个文件一个StreamReader、一个独立管道互不干扰同一个文件并行解析不安全StreamReader不是线程安全的还会因为预读缓冲导致位置错乱。我一般的写法是文件级并行用Parallel.ForEach每个分支内部完整走「探测编码 → 流式解析 → 按列映射」输出阶段要么用锁保护单个StreamWriter要么每个分支写各自的临时文件、最后按顺序拼接。后者更快也更稳因为拼接的顺序完全可控输出文件的行序不会因为调度顺序而每次不同——对账场景里行序不同会让 diff 工具全红排查成本陡增。还有一个反直觉的点文件数少于 CPU 核数时并行收益有限反而因为磁盘 IO 争抢变慢。机械盘上我宁愿串行固态盘上 4 到 8 个并发比较合适再多收益递减。这类参数没有通用值压测一遍拿数字说话比拍脑袋强。5. 避坑记录五个把合并结果做废的细节5.1 BOM 残留让第一列列名找不到现象代码里row[订单号]取到的永远是空串但打开文件肉眼看列名完全正常headers打印出来也不报错。原因Excel 另存的 UTF-8 CSV 带 BOMStreamReader在多数路径下会吃掉它但如果用File.ReadAllLines或者自己按字节切BOM 会以\uFEFF的形式挂在第一个字段前面。字典查找区分这两个字符串自然取不到。解决解析完表头后统一做一次清洗h.Trim().TrimStart(\uFEFF)并且在建立字典时用StringComparer.OrdinalIgnoreCase。更稳妥的做法是入口处就用new StreamReader(path, encoding, detectEncodingFromByteOrderMarks: true)别自己去读字节再转字符串。5.2 引号内换行让行数统计全错现象源文件 10 万行合并后输出 10.3 万行多出来的行内容看起来是残片且集中在某几个客户的数据上。原因某个字段里带了换行备注、地址而且是合法 CSV。用「按行读、按逗号切」的方式解析时这个换行被当成了行结束一条记录裂成两条后续所有字段整体偏移。解决换成状态机解析或者 CsvHelper让解析器自己处理引号内的换行。校验手段是比对源文件与输出的记录数——注意要比对「解析后的记录数」不是File.ReadAllLines().Length后者天然会把带换行的字段数错。发现差异时把BadDataFound的日志翻出来看原始行。5.3 长数字和前导零被 Excel 吃掉现象订单号0001234567890123456合并后变成1.23457E18或者前导零消失用程序读回来发现值本身没错但业务方打开看一眼就说数据坏了。原因CSV 本身没有类型Excel 打开时会自作主张把纯数字列按数值解析超过 15 位精度就丢。这是消费端的行为不是你的合并逻辑写错了。解决输出时给这类列强制加引号或者加个前置单引号、前置制表符。我更倾向在交付说明里写清楚「此文件请用导入功能打开不要直接双击」同时在Escape里对全数字且长度超过 15 的字段强制加引号。治标但省事比每次都被叫去解释强。5.4 区域格式decimal.Parse 用了当前区域现象同一份文件在某台机器上金额列解析正常换台机器全变 0 或者抛FormatException。原因decimal.Parse(s)不带IFormatProvider时用CultureInfo.CurrentCulture。在小数点用逗号的区域设置下1234.56会被解析成 123456 或者直接失败。更麻烦的是这个 bug 只在特定机器上复现本地怎么测都是对的。解决所有数值和日期解析统一传CultureInfo.InvariantCulture写成decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var v)。CsvHelper 的CsvConfiguration构造函数第一个参数就是CultureInfo别图省事留空。这个坑我踩过一次之后凡是跨越机器边界的解析都强制走一遍 Invariant。5.5 输出编码不带 BOMExcel 打开是乱码现象合并后的文件用记事本打开中文正常用 Excel 双击打开全是锟斤拷或者方块。原因输出用了无 BOM 的 UTF-8Excel 在没有 BOM 时按系统本地代码页简体中文环境是 GBK解释字节流UTF-8 的中文字节序列在 GBK 下就是乱码。解决交付给人工看的文件用new UTF8Encoding(true)带 BOM 写出给程序消费的接口文件保持无 BOM并在对接文档里写明编码。这两类输出最好走不同的方法别用同一个WriteCsv图省事否则总有一边不满意。6. 合并结果自检三道校验把错误挡在交付前合并逻辑写完只是开始真正省时间的是交付前跑一遍自检。我现在固定做三件事记录数守恒、主键唯一性、数值列合计比对。这三项能覆盖掉八成的静默错误尤其是「列对齐错了但程序不报错」这种最难查的。public static void Verify(string outputPath, string keyColumn, string amountColumn, long expectedRows, decimal expectedAmount, Encoding enc) { long rows 0; decimal amount 0m; var keys new HashSetstring(StringComparer.Ordinal); var dup 0; using var reader new StreamReader(outputPath, enc, detectEncodingFromByteOrderMarks: true); using var e CsvReaderLite.ReadRows(reader).GetEnumerator(); if (!e.MoveNext()) throw new InvalidOperationException(输出为空); var header e.Current.Select(h h.Trim().TrimStart(\uFEFF)).ToArray(); int ki Array.IndexOf(header, keyColumn); int ai Array.IndexOf(header, amountColumn); if (ki 0 || ai 0) throw new InvalidOperationException(关键列不存在列名可能未对齐); while (e.MoveNext()) { rows; if (!keys.Add(e.Current[ki])) dup; if (decimal.TryParse(e.Current[ai], NumberStyles.Any, CultureInfo.InvariantCulture, out var v)) amount v; // 解析失败的行不参与合计但会被下面的差值暴露出来 } Console.WriteLine($行数 {rows}期望 {expectedRows}差 {rows - expectedRows}); Console.WriteLine($主键重复 {dup} 条); Console.WriteLine($金额合计 {amount}期望 {expectedAmount}差 {amount - expectedAmount}); }三行输出各有各的用法。行数差为正通常是 5.2 那种引号内换行被拆开或者追加时没跳过表头行——没错把每个文件的表头都当数据写进输出是最高频的低级错误表现就是行数多了「文件数减一」条。行数差为负先查是不是某个文件读的时候编码判断失败、整份跳过。主键重复数不为零说明去重逻辑没生效或者主键选错了得回去看是不是复合键。金额合计的差值最有意思它与期望值对不上但行数正确时几乎可以断定是某几行的数值解析失败被静默跳过或者列对齐时金额串到了别的列。这几个期望值不能靠猜得从源文件独立算一遍。我习惯用另一条完全独立的路径算——比如简单地 grep 加 awk或者干脆让业务方给个汇总数——两边对上了才算通过。用同一个函数算期望值和实际值等于自己给自己判卷。注意decimal.TryParse失败时不累加也不报错这是故意的目的是让差值把问题顶出来但如果你把这段用在生产流水线上建议把失败行号一起打出来不然又成了新的黑匣子。从那以后我每次做 CSV 合并都强制走一遍这三道校验哪怕只是临时导个报表。血泪经验是肉眼抽查前二十行看着都对错误往往藏在第 3847 行某个带引号的备注里而校验器三秒钟就能告诉你差了多少。希望帮到你。本文还有配套的精品资源点击获取
返回列表