CSV 不是「纯文本」──C# 业务应用中 CSV 实务(字符编码・Excel 兼容性・注入攻击防范)
· Go Komura · CSharp, .NET, CSV, Excel, 字符编码, 文件集成, Windows 开发, 技术咨询
在业务应用领域,基于 CSV 的数据集成至今仍是主角。而正因为它看起来只是「用逗号把文本连起来」,所以也是事故频发的重灾区──自制解析器被字段内的逗号搞坏、用 Excel 打开后乱码、电话号码的前导零消失,诸如此类的问题反复出现。
本文以用 C# 读写 CSV 的业务应用为前提,按照实务中容易犹豫不决的顺序,梳理 CSV 的规范(RFC 4180)与现实中的各种方言、字符编码的确定方法、与 Excel 的兼容性问题、CSV 注入攻击的防范,以及运维中的各种陷阱。
1. 先说结论(判断表)
CSV 的设计判断应当从「谁(什么系统)会打开这个文件」反推而来。
| 用途 | 字符编码的首选 | 设计要点 |
|---|---|---|
| 人用 Excel 双击打开 | UTF-8(带 BOM) | 没有 BOM 时,某些环境下 Excel 会误判字符编码导致乱码。前导零丢失、被误判为日期的问题无法靠字符编码解决(第 5 章) |
| 新建的系统间集成 | UTF-8(不带 BOM) | 在规格书中明确写清字符编码・引号・换行符・是否有表头。也要写明是否带 BOM |
| 与旧系统的集成 | 按对方规范(多为 Shift_JIS) | 在 .NET(Core) 中使用 Shift_JIS 需要注册 CodePagesEncodingProvider(第 4 章)。要提前就机型依赖字符的处理方式达成一致 |
| 大数据量的批处理集成 | 按规范执行 | 不要把全部数据一次性载入内存,应逐行流式处理(第 7 章)。互斥控制与写入中文件的处理也需要一并设计 |
| 主要目的是供人用 Excel 查看・打印 | ── | 应从一开始就考虑输出 xlsx 而非 CSV(参见 FAQ) |
先给出结论。
- 用
String.Split(',')解析 CSV,即便看起来能跑通,从规范角度看也是有问题的。 因为它无法处理字段内的逗号、换行、引号。如果只用 .NET 标准库,请使用TextFieldParser(第 3 章)。 - 字符编码方面的事故大多是由「Excel 如何解读」引起的。 目前比较稳妥的做法是:供人用 Excel 打开的 CSV 采用带 BOM 的 UTF-8。另外有一个陷阱:在 .NET 中是否附带 BOM,取决于
Encoding的指定方式(第 4 章)。 - 在 .NET(Core) 中,Shift_JIS 若不额外添加包是无法使用的。 只有添加
System.Text.Encoding.CodePages包并注册CodePagesEncodingProvider,Encoding.GetEncoding("shift_jis")才能成功执行。这是从 .NET Framework 迁移过来的 CSV 处理逻辑,在生产环境中第一次抛出异常的典型场景。 - 前导零丢失、被误判为日期,这些问题在 CSV 这一侧是无法防止的。 一个重要的事实是:即便用双引号括起来也无法防止这一点(第 5 章)。
- 如果应用会把用户输入内容写入 CSV,就需要防范 CSV 注入攻击。 以
=或+开头的字段会被 Excel 解读为公式(第 6 章)。
2. CSV 的「规范」──RFC 4180 与现实中的方言
CSV 事实上的标准是 RFC 41801。实务中需要掌握的规则只有以下 4 条。
- 记录之间用换行(CRLF)分隔,字段之间用逗号分隔。
- 如果字段中包含逗号、换行、双引号,需要用双引号将整个字段括起来。
- 字段内的双引号需要用
""两个连续双引号来转义。 - 不需要括起来的字段,括不括都可以。
也就是说,下面这一行是包含 3 个字段的记录。
1001,"株式会社サンプル 営业部","地址是""东京都○○区1-2-3""这样输入的备注"
而 RFC 4180 只是「事实上的标准」,现实中的 CSV 存在大量方言──换行只用 LF、不做引号处理、分隔符是制表符或分号、有的有表头有的没有……正因如此,在集成规格书中写明「符合 RFC 4180」这一句话,并明确字符编码・BOM・换行符・是否有表头,是防止后续麻烦性价比最高的工作。
3. 不要用 Split(‘,’) 自行实现──读取的实务
结合上一章的规则,String.Split(',') 会出问题的原因就很明显了:字段内的逗号会导致多分出字段,引号内的换行会导致行被从中间截断。「现在收到的文件里没有带逗号的数据所以能跑」──这只是巧合,一旦数据变化随时可能崩溃。
如果只想用 .NET 标准库正确读取,可以使用 Microsoft.VisualBasic.FileIO.TextFieldParser。虽然命名空间是 Visual Basic,但在 C# 中也能正常使用。
using Microsoft.VisualBasic.FileIO; // Microsoft.VisualBasic.Core(随 Windows 桌面版 SDK 一起提供)
using var parser = new TextFieldParser(path, System.Text.Encoding.UTF8)
{
TextFieldType = FieldType.Delimited,
HasFieldsEnclosedInQuotes = true, // 正确处理被引号括起来的字段
TrimWhiteSpace = false, // 默认值为 true(会默默去除前后空白)。如果空白具有意义,需要将其禁用
};
parser.SetDelimiters(",");
while (!parser.EndOfData)
{
try
{
string[]? fields = parser.ReadFields();
if (fields is null) break;
// 处理 fields[0], fields[1], ...
}
catch (MalformedLineException ex)
{
// 格式不正确的行:可以通过 ErrorLine / ErrorLineNumber 记录内容和行号
logger.LogWarning("CSV 第 {Line} 行格式不正确: {Content}",
ex.LineNumber, parser.ErrorLine);
}
}
TextFieldParser 的实务优势在于,除了引号处理(HasFieldsEnclosedInQuotes)之外,还能通过 MalformedLineException 检测出格式错误的行,并附带行号。在业务系统的 CSV 导入场景中,「有一行格式错误时是拒绝整个文件,还是跳过该行并上报」往往是规范制定的分水岭,而这套异常处理机制正好可以直接映射到规范设计中。
如果还需要列与对象的映射、类型转换,以及数百万行规模的性能,建议引入有实绩的 CSV 库(NuGet 上被广泛使用的那些)。无论选择哪个库,都应确认以下 4 个方面:「RFC 4180 引号处理」「字符编码指定」「格式错误行的处理」「是否支持流式读取」。
写入这一侧由于规范比较简单,自己实现也是现实的选择,但请务必把转义处理统一收敛到一个函数里。
// 写入端的转义处理统一收敛到这一个函数
static string ToCsvField(string? value)
{
value ??= string.Empty;
bool needsQuoting = value.Contains(',') || value.Contains('"')
|| value.Contains('\r') || value.Contains('\n');
return needsQuoting ? $"\"{value.Replace("\"", "\"\"")}\"" : value;
}
只要还残留哪怕一处「只对金额列手写 string.Join」的代码,那里就会成为未来的事故现场。
4. 字符编码──Excel 的行为与 .NET 的陷阱
4.1 如果要用 Excel 打开,请使用带 BOM 的 UTF-8
CSV 乱码问题大多是由Excel 误判文件字符编码引起的。如果双击打开一个没有 BOM(文件开头的几个字节标记)的 UTF-8 CSV 文件,某些环境下会被解读为 ANSI(在日语环境中即 Shift_JIS),导致日语文字全部乱码。而带 BOM 的 UTF-8 会被正确识别为 UTF-8,因此供人用 Excel 双击打开的 CSV,采用带 BOM 的 UTF-8 是目前比较稳妥的选择。
这里又叠加了 .NET 侧的一个陷阱。是否附带 BOM,取决于 Encoding 的传递方式。
// 不带 BOM 的 UTF-8:省略编码指定时的默认行为
File.WriteAllText(path, csv);
// 带 BOM 的 UTF-8:Encoding.UTF8(静态属性)被设置为会输出 BOM
File.WriteAllText(path, csv, Encoding.UTF8);
// 如果想明确表达意图,用构造函数指定更不容易产生误解
File.WriteAllText(path, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
传入 Encoding.UTF8 和省略编码参数,得到的文件结果并不相同──这是一个如果不了解就很难察觉的规范细节。如果已经把「系统间集成用不带 BOM」「面向 Excel 用带 BOM」定为规范,建议在代码中也用 new UTF8Encoding(...) 明确表达意图。
4.2 在 .NET(Core) 中使用 Shift_JIS 需要先注册
在与旧系统集成时,Shift_JIS(代码页 932)的 CSV 至今仍在使用。这里需要注意的重点是,.NET(Core)/.NET 5 及以后版本默认只支持 Unicode 系列和 ASCII 等极少数编码。Encoding.GetEncoding("shift_jis") 在添加 System.Text.Encoding.CodePages 包并注册 CodePagesEncodingProvider 之前会抛出异常。
using System.Text;
// 只需在应用启动时注册一次(Program.cs 等位置)
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
// 这样就可以获取 Shift_JIS(代码页 932)了
Encoding sjis = Encoding.GetEncoding("shift_jis");
从 .NET Framework 时代迁移到 .NET 的 CSV 处理逻辑,在生产环境中第一次收到 Shift_JIS 文件时抛出异常──这是典型的事故场景。迁移时应检查的要点,整理在《从 .NET Framework 迁移到 .NET 之前该确认的事》一文中。
另外,既然使用 Shift_JIS,机型依赖字符(如①、㈱等)、波浪线问题、仅存在于 Unicode 中的字符缺失等问题也无法回避。关于字符编码的这些基础知识,在《理清 Windows 的文字编码》和《Windows 的文字编码与换行符》中有详细说明。
5. 用 Excel 打开会被破坏的数据──前导零丢失、误判为日期、指数表示法
即便字符编码正确,用 Excel 查看时还存在另一类问题。Excel 在打开 CSV 时会对数值进行「解读」。
| 原始数据 | Excel 中的显示 | 发生了什么 |
|---|---|---|
090123456xx(电话号码) |
90123456xx |
被解读为数值,前导零消失 |
007(商品编码) |
7 |
同上 |
1-1(分支编号) |
1月1日 |
被解读为日期 |
1234567890123456(16 位管理编号) |
1.23457E+15 |
变为指数表示法,低位数字丢失 |
而重要的是,这些问题即便把字段用双引号括起来也无法防止。引号只是标示 CSV 语法上的分隔边界,Excel 仍会重新解读其中的内容。
实务上的应对办法有以下 3 种:
- 让用户按照正确的步骤打开 Excel。 不要双击打开,而是使用 Excel 的「导入数据(从文本/CSV)」功能,将列的类型指定为「文本」再读取,这样就不会出问题。这一点值得写进操作手册。
- 如果主要目的是查看,则输出 xlsx。 由于可以在输出端控制单元格格式,这一整类问题会从根本上消失。关于 Excel 文件输出的实现方法,请参考《Excel 报表输出的实现方法》。另外,在服务器或服务中使用 Excel COM 自动化是不推荐的做法,正如《C# 操作 Excel 时 EXCEL.EXE 残留的问题》一文所述,这种方式很容易造成进程残留,请选择基于库的输出方案。
- 以
="007"的形式输出是最后的手段。 这是一种让 Excel 打开时显示为文本的小技巧,但从 CSV 角度看,这变成了「带公式的非标准文件」,会破坏 Excel 以外的导入端。请不要在与其他系统共用的文件中使用这种方式。
6. CSV 注入攻击──不要让用户输入变成公式
对于把用户输入的值(姓名、备注、地址等)写入 CSV 并交由 Excel 打开的应用来说,存在一种名为CSV 注入(CSV Injection)的攻击途径。以 =、+、-、@ 等字符开头的字段会被 Excel 解读为公式,这意味着恶意输入(例如意图启动外部进程的公式,如 =cmd|'/C calc'!A0)有可能在打开该 CSV 的其他用户的环境中被执行。
防范的基本思路是,在输出时对危险的开头字符进行无害化处理。
// 在可能被解读为公式的字段开头加上单引号。
// 在日语环境中,全角的 =+-@ 同样可能被解读为公式的开头,因此也要纳入处理范围
static string SanitizeForExcel(string value)
{
if (value.Length > 0 && value[0]
is '=' or '+' or '-' or '@' or '\t' or '\r' or '\n'
or '=' or '+' or '-' or '@')
{
return "'" + value;
}
return value;
}
字段开头的单引号在 Excel 中相当于「作为文本处理」的标记。需要注意的是,危险的开头字符列表会因电子表格软件的实现和版本而有所不同,因此这类无害化处理更适合设计成「只有开头字符符合这份允许列表(如字母数字等)时才原样通过」的方向,这样对未预料到的情况会更具韧性。不过,如果其他系统会导入这份 CSV,需要注意单引号会作为数据本身残留下来,因此也可以考虑将「供 Excel 打开用」和「供系统导入用」的文件分开处理。是否要结合输入值校验(例如从一开始就不允许以 = 开头等),以及应用整体的安全设计,请参考《Windows 应用程序开发安全性最低限度检查清单》。
7. 运维中的陷阱──CSV 语法之外容易出事故的地方
最后,整理一下 CSV 本身语法之外,实务中容易出事故的地方。
- 不要让数值・日期的格式依赖操作系统的区域设置。
value.ToString()会受操作系统区域设置的影响。在小数点用逗号表示的区域设置下,会输出成1,5,与 CSV 的分隔符发生冲突。用于文件集成的转换应显式指定CultureInfo.InvariantCulture,日期应采用如yyyy-MM-dd这样固定的格式。日期设计的相关内容在《业务应用的日期时间与时区》中有详细说明。 - 大数据量应采用流式处理──但是否可以「逐行处理」取决于规范。
File.ReadAllLines会把全部行作为数组载入内存,不适合大文件。这里可以替换为File.ReadLines(延迟枚举)或StreamReader的逐行读取,但仅限于规范中明确规定「字段内不允许出现换行」的文件。对于遵循 RFC 4180、允许引号内出现换行的 CSV,按行拆分会把一条记录从中间截断。这种情况下,请使用直接从文件路径读取的TextFieldParser(无需将整体载入内存即可处理),或者使用 CSV 库提供的流式读取功能。 - 不要读取「写到一半的文件」,也不要在写到一半时被读取。 输出端先以临时文件名写入再重命名,导入端先确认互斥状态再读取──这一文件集成的基本模式,整理在《文件集成互斥控制基础知识》中;通过文件夹监视方式导入时的注意事项,整理在《FileSystemWatcher 实务指南》中。
- 为列的增减做好准备。 应在规范中明确写清是「按表头行的列名进行对应」还是「列顺序固定・按列数校验,不一致则拒绝」。像
fields[7]这样隐式的魔法数字引用,会因为对接方增加一个不起眼的列而悄无声息地崩溃。 - 提前确定末尾换行和空行的处理方式。 最后一行之后是否有换行、是跳过空行还是拒绝,这些在不同解析器实现中的行为可能不同。
TextFieldParser会跳过空行。
结语
CSV 是一种表面上「只是用逗号分隔」、实际上充满各种方言的集成格式。实务要点可以归纳为 4 条──读取时使用能正确处理引号的组件(不要用 Split 自行实现);字符编码要从打开方(尤其是 Excel)反推确定,并将是否带 BOM 也写入规范;要认识到 Excel 对数值的解读(前导零丢失、误判为日期)在 CSV 这一侧是无法防止的;如果要输出用户输入内容,就要加入注入攻击的防范措施──只要做到这些,CSV 集成中的大多数事故都可以在设计阶段就被消灭。
关于现有 CSV 集成中出现的乱码、数据损坏的调查,与旧系统集成规范的梳理,以及 CSV 导入・输出功能的新开发,往往需要结合实际文件和对接方规范进行判断,如果拿不定主意,欢迎随时咨询。
相关文章
- 理清 Windows 的文字编码
- Windows 的文字编码与换行符
- Excel 报表输出的实现方法
- C# 操作 Excel 时 EXCEL.EXE 残留的问题
- 文件集成互斥控制基础知识
- FileSystemWatcher 实务指南
- 业务应用的日期时间与时区
- 将 .NET Framework 迁移到 .NET 之前该确认的事
- Windows 应用程序开发安全性最低限度检查清单
参考资料
-
IETF, RFC 4180 - Common Format and MIME Type for Comma-Separated Values (CSV) Files。CSV 事实上的标准规范。关于字段内逗号・换行・双引号的引号处理,以及用
""进行转义的规则。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
WinForms/WPF应用的多语言化 ── resx、附属程序集与区域文化切换的实务
本文从实务角度系统梳理Windows桌面应用多语言化的要点:CurrentCulture与CurrentUICulture的区别、resx与附属程序集构成的资源机制、WinForms的Localizable属性、WPF的现实方案选择、运行时语言切换,以及排版、格式、RTL等内容。
不要用 using 包裹 HttpClient —— C# 业务应用的 HTTP 通信实务(创建模式・超时设计・重试)
C# 的 HttpClient 如果每次都用 using 创建会导致套接字耗尽,改成 static 又无法跟上 DNS 变更——本文从 Windows 业务应用的实务角度,整理这两大问题的成因,以及通过 PooledConnectionLifetime、IHttpClien...
不只是 appsettings.json ── Windows 业务应用的配置管理实务(环境差异化设置・敏感信息・写入位置)
本文从实务角度整理 Windows 业务应用的配置管理,涵盖 appsettings.json 的分层、IConfiguration 与 IOptions/IOptionsSnapshot/IOptionsMonitor 的选用方式、环境差异化设置、可写入配置的存放位置、敏...
Windows 业务应用程序的打印与 PDF 输出 ── System.Drawing.Printing / WPF / 报表库的取舍选型
本文按需求整理成判断表,梳理 PrintDocument 的 WinForms 打印、WPF 的 FlowDocument/FixedDocument 打印,以及 PDF 输出的可选方案。并从实务角度讲解分页控制、DPI 偏差等实现陷阱,以及从 Windows 服务打印、依...
用 PerfView 与 dotnet-trace 定位“变慢”的原因 ── .NET 性能排查实务入门
当业务应用出现“变慢”“CPU 占满”“偶尔卡死”时,该用哪个工具看什么?本文梳理 PerfView 与 dotnet-trace 的分工、CPU 采样的解读方法(inclusive/exclusive)、用 ThreadTime 排查阻塞时间,以及与 EventSourc...
常见问题
汇总了咨询这一主题时常见的问题。
- CSV 应该使用什么字符编码?
- 如果接收方已经确定,最基本的原则是配合接收方的规范。如果可以从零决定,那么供人用 Excel 打开的文件采用带 BOM 的 UTF-8、系统间集成采用不带 BOM 的 UTF-8,并将其明确写入规格书,是比较稳妥的出发点。如果在与旧系统集成时被要求使用 Shift_JIS,务必先就机型依赖字符和外字的处理方式达成一致,否则事后很容易在乱码问题上产生责任纠纷。
- 应该引入 CSV 库,还是自己实现就够了?
- 就读取而言,自己用 String.Split 无法正确处理规范中规定的一些情况(字段内的逗号、换行、引号),因此应该借助某种现成组件。如果只用 .NET 标准库,TextFieldParser 支持相当于 RFC 4180 的引号处理。如果还需要列与对象的映射、类型转换,以及大数据量下的性能,建议引入有实绩的 CSV 库。写入方面由于规范较简单,自己实现也是现实的选择,但请务必准备一个统一的引号转义函数,让所有写入逻辑都经过它。
- 是否应该用制表符分隔(TSV)而不是逗号分隔?
- 如果数据中频繁出现逗号(如地址、姓名、金额表示等),改用 TSV 可以降低引号处理的频率,也更便于肉眼确认。但制表符和换行同样可能出现在数据中,并不意味着可以省去引号处理。请注意,不能因为「是 TSV 就不用考虑分隔符」而掉以轻心。如果能与对接方达成一致,比起纠结分隔符本身,在规格书中明确写清「字符编码・引号・换行符・是否有表头」更能有效防止事故。
- 如果目的是用 Excel 打开,是否应该输出 Excel 文件而不是 CSV?
- 如果主要目的是「供人用 Excel 查看・打印」,值得考虑输出 xlsx 格式。CSV 在被 Excel 打开的瞬间就会出现前导零丢失、被误判为日期等问题,这些在 CSV 这一侧是无法完全防止的。而 xlsx 可以在输出端控制单元格格式。另一方面,如果主要目的是导入其他系统,用 Excel 查看只是附带需求,那么保持 CSV 格式、并将「导入步骤(使用数据导入功能)」作为操作手册加以说明,会更现实。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。