CSV 是最不起眼、也最容易出事故的数据格式。运营同事从后台导出一份表,财务系统给你一份结算明细,供应商发来一批商品编码,很多时候都是 CSV。初学者常犯的错误是用 strings.Split(line, ",") 处理。只要字段里出现逗号、换行或双引号,这个办法马上失效。
Go 标准库提供了 encoding/csv,它能正确处理引号、转义和多行字段。入门阶段先学会用标准库读写 CSV,比自己发明解析规则可靠得多。
读取一整个文件
小文件可以一次性读完:
package main
import (
"encoding/csv"
"fmt"
"os"
)
func main() {
f, err := os.Open("users.csv")
if err != nil {
panic(err)
}
defer f.Close()
r := csv.NewReader(f)
records, err := r.ReadAll()
if err != nil {
panic(err)
}
for _, row := range records {
fmt.Println(row)
}
}
ReadAll 简单,但它会把所有记录放进内存。如果文件只有几百行,完全没问题;如果文件几百万行,就应该逐行读。选择 API 时要看文件规模,不要因为示例短就照搬到导入任务里。
逐行读取
逐行读取更适合导入:
for {
row, err := r.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("read csv: %w", err)
}
fmt.Println(row)
}
io.EOF 表示文件正常结束,不是错误。CSV 解析错误则需要返回给调用方。真实导入里最好告诉用户第几行失败,而不是只说“格式错误”。
处理表头
大多数业务 CSV 都有表头。不要假设列顺序永远不变,可以先读表头,建立列名到下标的映射。
func headerIndex(header []string) map[string]int {
m := make(map[string]int, len(header))
for i, name := range header {
m[strings.TrimSpace(name)] = i
}
return m
}
使用时:
header, err := r.Read()
if err != nil {
return err
}
idx := headerIndex(header)
emailCol, ok := idx["email"]
if !ok {
return fmt.Errorf("missing email column")
}
nameCol, ok := idx["name"]
if !ok {
return fmt.Errorf("missing name column")
}
这样即使用户把 name,email 换成 email,name,程序仍然能正常导入。对于面向外部客户的模板,这个小设计能少很多客服沟通。
空值和校验
CSV 里所有字段读出来都是字符串。你需要自己处理空值、数字和日期。
type UserRow struct {
Email string
Name string
Age int
}
func parseUser(row []string, idx map[string]int) (UserRow, error) {
email := strings.TrimSpace(row[idx["email"]])
if email == "" {
return UserRow{}, fmt.Errorf("email is required")
}
ageText := strings.TrimSpace(row[idx["age"]])
age, err := strconv.Atoi(ageText)
if err != nil {
return UserRow{}, fmt.Errorf("bad age %q", ageText)
}
return UserRow{
Email: email,
Name: strings.TrimSpace(row[idx["name"]]),
Age: age,
}, nil
}
如果某些字段允许空值,就要明确表示。比如年龄为空时可以用 *int:
func parseOptionalInt(s string) (*int, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return nil, err
}
return &n, nil
}
空字符串、零值和缺失不是一回事。导入程序如果混淆它们,后面很容易出现“为什么用户年龄都变成 0”的问题。
定位第几行出错
逐行读取时可以自己维护行号。表头是第一行,数据从第二行开始:
line := 1
for {
row, err := r.Read()
if errors.Is(err, io.EOF) {
break
}
line++
if err != nil {
return fmt.Errorf("line %d: %w", line, err)
}
user, err := parseUser(row, idx)
if err != nil {
return fmt.Errorf("line %d: %w", line, err)
}
_ = user
}
错误信息写到行号,使用者才能回到 Excel 或文本编辑器里修正。导入工具是否好用,往往就差这一点。
字段数量不一致
csv.Reader 默认要求每行字段数一致。如果业务允许某些行少列,可以调整 FieldsPerRecord,但要谨慎。
r.FieldsPerRecord = -1
设置为 -1 表示允许字段数变化。这样虽然更宽松,但解析函数要自己检查下标是否存在。入门阶段如果模板是你控制的,建议保持严格,让错误尽早暴露。
写出 CSV
导出也应该用 csv.Writer:
func exportUsers(w io.Writer, users []UserRow) error {
cw := csv.NewWriter(w)
defer cw.Flush()
if err := cw.Write([]string{"email", "name", "age"}); err != nil {
return err
}
for _, u := range users {
row := []string{u.Email, u.Name, strconv.Itoa(u.Age)}
if err := cw.Write(row); err != nil {
return err
}
}
return cw.Error()
}
Flush 会把缓冲数据写出去,但它本身不返回错误,所以最后要检查 cw.Error()。这个细节经常被漏掉。写文件失败、磁盘满、客户端断开,都可能在 flush 时才暴露。
Excel 和中文
中文 CSV 在 Excel 里打开可能遇到编码问题。现代系统一般使用 UTF-8,但某些旧环境会期望带 BOM。是否加 BOM 要看你的用户。不要为了“兼容 Excel”在所有导出里默认加 BOM,先确认消费方。
如果确实需要:
func writeBOM(w io.Writer) error {
_, err := w.Write([]byte{0xEF, 0xBB, 0xBF})
return err
}
这个函数应该在写 CSV 内容之前调用。内部系统之间传文件,通常保持纯 UTF-8 更干净。
小结
CSV 看似只是逗号分隔,实际会遇到引号、换行、空值、表头变化、编码和错误定位。Go 的 encoding/csv 已经处理了最容易写错的解析细节,入门时不要用 strings.Split 代替它。
导入时逐行读取、按表头找列、清楚地区分空值和零值,并把错误定位到行号。导出时使用 csv.Writer,记得 Flush 后检查错误。把这些基本动作做好,CSV 工具就会从“临时脚本”变成可以放心交给别人使用的工具。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。