CSV 格式说明和应用

问题

我们常常将多个字符串 item 使用逗号拼接成一个字符串,用来表示数组,使用时再用逗号切割成为数组。比如安卓机型列表:

ALN-AL10,ALN-AL10,BRA-AL00,ALN-AL00/ALN-AL80

直到有一天,苹果设备也要用到这个机型列表,而它的每个机型都带着逗号,那我们使用逗号切割就得到了错误的数据。

iPhone15: iPhone15,4
iPhone15Plus: iPhone15,5
iPhone15Pro: iPhone16,1
iPhone15Pro_Max: iPhone16,2

为了解决这个问题,首先想到了换一个分隔符,比如 |,再比如用一些不可见字符 0x01。但我们不能保证这些字符串 item 一定不包含这些特殊字符,也许还有更好的方法。

既然是逗号分隔,首先想到的就是 CSV 格式,毕竟 CSV 的全称就是 Comma-Separated Values(逗号分隔值)。它是如何解决这个问题的?

CSV 格式(RFC 4180)

CSV 的正式说明文档:RFC 4180,2005 年由 Y. Shafranovich 发布,是 Informational 级别的标准。

7 条核心规则

  1. 每条记录占一行,以 CRLF(\r\n)结尾
  2. 最后一条记录尾随换行符可选
  3. 可选表头行,与普通记录格式完全相同
  4. 字段以逗号分隔;行内字段数必须一致;空格是字段的一部分,不忽略;最后字段不能有尾随逗号
  5. 字段可以用双引号包裹,也可以不包裹;不包裹时,字段内不能出现双引号
  6. 字段含换行符、双引号、逗号时,必须用双引号包裹
  7. 双引号内出现双引号,必须用两个双引号转义(""

ABNF 语法

RFC 4180 定义的正式语法:

file = [header CRLF] record *(CRLF record) [CRLF]
header = name *(COMMA name)
record = field *(COMMA field)
field = (escaped / non-escaped)

escaped = DQUOTE *(TEXTDATA / COMMA / CR / LF / 2DQUOTE) DQUOTE
non-escaped = *TEXTDATA

TEXTDATA = %x20-21 / %x23-2B / %x2D-7E
;即可见ASCII字符(0x20-0x7E),排除双引号(0x22)和逗号(0x2C)

示例说明

姓名,年龄,城市,备注
张三,30,北京,无备注
李四,25,上海,"喜欢, 打篮球"
王五,28,"广州, 广东",""
"李, 六",35,"""特别"" 市","这是一段
跨行的备注"
"陈, 七","40","深圳",

解读:

原始值 CSV 表示 说明
喜欢, 打篮球 "喜欢, 打篮球" 含逗号 → 引号包裹
广州, 广东 "广州, 广东" 同上
""(空备注) "" 两个双引号表示空字符串
李, 六 "李, 六" 姓名含逗号 → 引号包裹
"特别" 市 """特别"" 市" 含双引号 → 双重转义
跨行字段 "这是一段↵跨行的备注" 含换行 → 引号包裹

使用 CSV 工具包可以非常方便地解析这些数据。后台表格导出 CSV 文件也应当使用 CSV 工具包,而非手动拼接字符串。

其他格式的对比

格式 分隔符 引号规则 适用场景
CSV 逗号 , 双引号包裹,含 ," 需双重转义 表格数据交换
TSV 制表符 \t 同 CSV 规则 Excel 复制粘贴
JSON 数组 无分隔符 无需转义(结构化) API 响应、Web 前端
Pipe-separated | 类似 CSV 日志分析

如果数据中同时包含逗号、双引号和换行符,JSON 数组是更可靠的选择。如果只是简单的一维字符串列表,且确认不会含分隔符,才考虑手工拼接。

Go 语言示例

Go 标准库 encoding/csv 完整支持 RFC 4180:

package main

import (
    "encoding/csv"
    "fmt"
    "io"
    "io"
    "log"
    "strings"
)

func main() {
    in := `姓名,年龄,城市,备注
张三,30,北京,无备注
李四,25,上海,"喜欢, 打篮球"
王五,28,"广州, 广东",""
"李, 六",35,"""特别"" 市","这是一段
跨行的备注"
"陈, 七","40","深圳","包含""双引号""和,逗号"`

    r := csv.NewReader(strings.NewReader(in))
    for {
        record, err := r.Read()
        if err == io.EOF {
            break
        }
        if err != nil {
            log.Fatal(err)
        }
        fmt.Printf("%#v\n", record)
    }
}

输出:

[]string{"姓名", "年龄", "城市", "备注"}
[]string{"张三", "30", "北京", "无备注"}
[]string{"李四", "25", "上海", "喜欢, 打篮球"}
[]string{"王五", "28", "广州, 广东", ""}
[]string{"李, 六", "35", "\"特别\" 市", "这是一段\n跨行的备注"}
[]string{"陈, 七", "40", "深圳", "包含\"双引号\"和,逗号"}

常用工具函数

package csvutil

import (
    "bytes"
    "encoding/csv"
    "strings"
)

// SliceToCsvString 将字符串切片转换为 CSV 字符串
func SliceToCsvString(slice []string) (string, error) {
    var buf bytes.Buffer
    writer := csv.NewWriter(&buf)
    if err := writer.Write(slice); err != nil {
        return "", err
    }
    writer.Flush()
    if err := writer.Error(); err != nil {
        return "", err
    }
    return buf.String(), nil
}

// CsvStringToSlice 将 CSV 字符串转换为字符串切片
func CsvStringToSlice(csvString string) ([]string, error) {
    reader := csv.NewReader(strings.NewReader(csvString))
    reader.ReuseRecord = true
    records, err := reader.Read()
    if err != nil {
        return nil, err
    }
    return records, nil
}

Python 示例

Python 内置 csv 模块,同样完整支持 RFC 4180:

import csv
from io import StringIO

data = """\
姓名,年龄,城市,备注
张三,30,北京,无备注
李四,25,上海,"喜欢, 打篮球"
王五,28,"广州, 广东",""
"李, 六",35,"""特别"" 市","跨行的
备注"
"""

reader = csv.reader(StringIO(data))
for row in reader:
    print(row)

输出:

['姓名', '年龄', '城市', '备注']
['张三', '30', '北京', '无备注']
['李四', '25', '上海', '喜欢, 打篮球']
['王五', '28', '广州, 广东', '']
['李, 六', '35', '"特别" 市', '跨行的\n备注']

Python 的 csv.reader 默认按 RFC 4180 解析。若读取的文件含 BOM(\ufeff),需手动跳过:

with open('data.csv', 'r', encoding='utf-8-sig') as f:
    reader = csv.reader(f)

常见陷阱

1. CRLF 问题

Unix 系统用 LF(\n),旧版 Mac 用 CR(\r),CSV 标准是 CRLF(\r\n)。部分解析器对单 \n 兼容,但严格按 RFC 4180 应当处理 CRLF。

// Go: 强制使用 CRLF 行结尾
reader := csv.NewReader(f)
reader.LazyQuotes = true  // 允许引号跨行

2. BOM 导致首字段前多出一个字符

UTF-8 with BOM 的文件,\ufeff 会被 csv.reader 当作字段内容读取。始终用 utf-8-sig 编码打开文件,或先手动 strip。

# Python
with open('file.csv', encoding='utf-8-sig') as f:
    reader = csv.reader(f)
// Go
import "golang.org/x/text/encoding/unicode"
// 或者读取后手动去掉 \ufeff

3. Excel 兼容性

Microsoft Excel 导出 CSV 时:

  • 对含逗号字段直接裸写,不加引号(不符合 RFC 4180 第5条规则)
  • 数值类型自动去掉前导零(如 00123123
  • 用系统默认编码而非 UTF-8

如果导出的 CSV 要给 Excel 用户,打开时建议另存为 UTF-8 with BOM。

4. 字段数不一致

RFC 4180 要求每行列数相同。解析时发现字段数突变,通常是:

  • 前一行某字段含未转义的换行符
  • 某字段引号不匹配
  • 文件编码问题导致字符被截断

5. 转义字符 \ 不起作用

CSV 的转义只有一种方式:引号包裹,内部双引号用 ""\ 不是 CSV 的转义字符。如果看到 "like\"this",这不是标准 CSV。

各语言工具包推荐

语言 工具包 备注
Go encoding/csv 标准库,完整 RFC 4180
Python csv 标准库,csv.QUOTE_MINIMAL 等多种模式
JavaScript csv-parse / papaparse 浏览器和 Node 都支持
Java OpenCSV / Apache Commons CSV
Rust csv crate 标准库级别质量
PHP str_getcsv() / fgetcsv() 内置函数

总结

当你的数据中可能出现逗号、双引号或换行符时,CSV 的 RFC 4180 标准通过"引号包裹 + 双引号双重转义"机制提供了可靠的解决方案。

核心规则只有两条:

  1. 含特殊字符的字段 → 双引号包裹
  2. 引号内的双引号 → 用两个双引号 "" 表示

使用标准库的工具包处理,不要手写字符串拼接——手写永远会有遗漏的边界情况。