Go 入门:CSV 导入导出别只会 strings.Split

用 encoding/csv 处理真实 CSV 文件,覆盖表头、引号、空值、逐行读取、错误定位和导出细节。

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 工具就会从“临时脚本”变成可以放心交给别人使用的工具。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页