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

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

大数据量 CSV 的分块处理

文件超过内存容量时,可以用流式读取 + 批量处理:

func ImportUsersInBatches(r io.Reader, batchSize int, save func([]UserRow) error) error {
	csvR := csv.NewReader(r)
	header, err := csvR.Read()
	if err != nil {
		return fmt.Errorf("read header: %w", err)
	}
	idx := headerIndex(header)

	var batch []UserRow
	line := 1
	for {
		row, err := csvR.Read()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			return fmt.Errorf("line %d: %w", line, err)
		}
		line++

		user, err := parseUser(row, idx)
		if err != nil {
			return fmt.Errorf("line %d: %w", line, err)
		}
		batch = append(batch, user)

		if len(batch) >= batchSize {
			if err := save(batch); err != nil {
				return fmt.Errorf("save batch: %w", err)
			}
			batch = batch[:0]
		}
	}
	if len(batch) > 0 {
		if err := save(batch); err != nil {
			return fmt.Errorf("save final batch: %w", err)
		}
	}
	return nil
}

批量大小通常 100 到 1000 条,太小了数据库往返多,太大了内存压力大。

自定义分隔符和引号规则

不是所有 CSV 都用逗号分隔。标准库可以自定义:

r := csv.NewReader(f)
r.Comma = ';'          // 分号分隔
r.Comment = '#'        // 忽略 # 开头的注释行
r.FieldsPerRecord = 3  // 强制每行 3 字段
r.TrimLeadingSpace = true

处理欧洲地区导出的 CSV 时,分号分隔很常见,因为欧洲小数点用逗号。

CSV 与数据库批量导入

把 CSV 导入数据库的一个稳妥流程:

func ImportToDB(db *sql.DB, r io.Reader) error {
	tx, err := db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()

	stmt, err := tx.Prepare("INSERT INTO users (email, name, age) VALUES (?, ?, ?)")
	if err != nil {
		return err
	}
	defer stmt.Close()

	// ... 逐行读取 CSV ...
	for _, user := range users {
		if _, err := stmt.Exec(user.Email, user.Name, user.Age); err != nil {
			return fmt.Errorf("insert %s: %w", user.Email, err)
		}
	}

	return tx.Commit()
}

使用事务保证原子性,要么全部导入成功,要么全部回滚。导入前最好先做数据校验和去重,避免事务中途失败。

CSV 安全性注意事项

  1. BOM 攻击:某些 CSV 文件开头有 UTF-8 BOM(0xEF 0xBB 0xBF),读取第一列时可能把 BOM 当成字段内容。
  2. 公式注入:Excel 会把以 =+-@ 开头的字段当成公式执行。导出给用户时,可以在前面加单引号或空格。
  3. 换行注入:字段内容如果包含换行,要用引号包裹。
func sanitizeCSVField(s string) string {
	if strings.HasPrefix(s, "=") || strings.HasPrefix(s, "+") {
		return "'" + s
	}
	return s
}

流式写出大 CSV

不要等所有数据准备好再写入,边查边写:

func ExportLargeCSV(w io.Writer, db *sql.DB) error {
	cw := csv.NewWriter(w)
	defer cw.Flush()

	if err := cw.Write([]string{"id", "email", "created_at"}); err != nil {
		return err
	}

	rows, err := db.Query("SELECT id, email, created_at FROM users")
	if err != nil {
		return err
	}
	defer rows.Close()

	for rows.Next() {
		var id int64
		var email, created string
		if err := rows.Scan(&id, &email, &created); err != nil {
			return err
		}
		if err := cw.Write([]string{
			strconv.FormatInt(id, 10),
			email,
			created,
		}); err != nil {
			return err
		}
	}
	return rows.Err()
}

HTTP 响应直接传给 csv.NewWriter(w),用户可以在浏览器里边下载边看到进度。

CSV 解析常见陷阱与验证策略

日期时间解析

CSV 中的时间字段通常以字符串形式存储,需要显式解析:

func parseTime(s, layout string) (time.Time, error) {
    s = strings.TrimSpace(s)
    if s == "" {
        return time.Time{}, nil
    }
    t, err := time.Parse(layout, s)
    if err != nil {
        return time.Time{}, fmt.Errorf("parse time %q with layout %q: %w", s, layout, err)
    }
    return t, nil
}

// 解析 "2025-03-15 14:30:00"
func parseDateTime(s string) (time.Time, error) {
    return parseTime(s, "2006-01-02 15:04:05")
}

不同地区日期格式不同:美国常用 03/15/2025,欧洲常用 15.03.2025。导入时最好让用户指定格式,或自动探测前几个有效值。

浮点数精度问题

CSV 中的货币值用浮点数解析会损失精度:

// 不推荐
price, _ := strconv.ParseFloat(row[idx["price"]], 64)

// 推荐:用整数表示分
priceCents, _ := strconv.ParseInt(row[idx["price"]], 10, 64)
price := float64(priceCents) / 100

或者直接使用 shopspring/decimal 这类高精度库处理财务数据。

大型 CSV 的流式处理

处理 1GB 以上的 CSV 时,流式处理和并发解析非常重要:

func ImportLargeCSV(ctx context.Context, r io.Reader, workerCount int) error {
    csvR := csv.NewReader(r)
    header, err := csvR.Read()
    if err != nil {
        return err
    }
    idx := headerIndex(header)

    rows := make(chan []string, workerCount*2)
    g, ctx := errgroup.WithContext(ctx)

    // 读取 goroutine
    g.Go(func() error {
        defer close(rows)
        for {
            row, err := csvR.Read()
            if errors.Is(err, io.EOF) {
                return nil
            }
            if err != nil {
                return err
            }
            select {
            case rows <- row:
            case <-ctx.Done():
                return ctx.Err()
            }
        }
    })

    // 多个 worker
    for i := 0; i < workerCount; i++ {
        g.Go(func() error {
            for row := range rows {
                _, err := parseUser(row, idx)
                if err != nil {
                    return fmt.Errorf("parse row: %w", err)
                }
                // save to database...
            }
            return nil
        })
    }

    return g.Wait()
}

Worker 数量不要无脑设置成 CPU 核数。如果瓶颈在数据库写入,设太多 worker 会堆积内存。先 benchmark 找出最佳并发度。

CSV 文件编码检测

虽然 UTF-8 是现代标准,但遗留系统可能输出 GBK、Shift-JIS 等编码:

import (
    "golang.org/x/text/encoding/simplifiedchinese"
    "golang.org/x/text/transform"
)

func decodeGBK(r io.Reader) io.Reader {
    return transform.NewReader(r, simplifiedchinese.GBK.NewDecoder())
}

检测编码的方法:读取文件前几个字节,检查 BOM 标记(0xEF 0xBB 0xBF 为 UTF-8)。无 BOM 时,尝试用 UTF-8 解码,失败再尝试其他编码。

校验规则引擎

复杂导入场景可以用规则表代替硬编码校验:

type Rule struct {
    Column   string
    Required bool
    MinLen   int
    MaxLen   int
    Pattern  *regexp.Regexp
}

var importRules = []Rule{
    {Column: "email", Required: true, Pattern: regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)},
    {Column: "name", Required: true, MinLen: 1, MaxLen: 50},
    {Column: "age", Required: false, Pattern: regexp.MustCompile(`^\d+$`)},
}

func validateRow(row []string, idx map[string]int) error {
    for _, rule := range importRules {
        colIdx, ok := idx[rule.Column]
        if !ok && rule.Required {
            return fmt.Errorf("missing required column: %s", rule.Column)
        }
        val := strings.TrimSpace(row[colIdx])
        if rule.Required && val == "" {
            return fmt.Errorf("%s is required", rule.Column)
        }
        if rule.MinLen > 0 && len([]rune(val)) < rule.MinLen {
            return fmt.Errorf("%s too short", rule.Column)
        }
        if rule.MaxLen > 0 && len([]rune(val)) > rule.MaxLen {
            return fmt.Errorf("%s too long", rule.Column)
        }
        if rule.Pattern != nil && !rule.Pattern.MatchString(val) {
            return fmt.Errorf("%s format invalid", rule.Column)
        }
    }
    return nil
}

用规则表的好处是校验逻辑可以外置到配置文件,运营人员可以直接调整导入规则,不需要改代码发版。

真实项目用例

在实际团队协作中,下面是几个推荐的工作流:

代码审查清单

  • 函数是否处理了所有 error 返回值
  • 并发代码是否有明确的退出路径和 WaitGroup
  • 用户输入是否经过校验和清洗
  • 敏感配置是否通过环境变量或加密存储注入
  • 测试是否覆盖了正常路径和至少一个错误路径
  • 日志是否包含足够的上下文信息但不泄露敏感数据
  • 接口设计是否符合最小接口原则

CI/CD 集成建议

  • 每次提交前运行 go fmt ./...
  • CI 中运行 go vet ./...golangci-lint run
  • 单元测试使用 go test -race ./... 检测数据竞争
  • 关键路径的 benchmark 加入回归测试
  • 使用 go mod verify 确保依赖完整性

性能调优检查点

  • 使用 pprof 分析 CPU 和内存使用
  • 关注 benchmark 的 allocs/op,减少高频路径的堆分配
  • 检查数据库查询是否使用索引
  • 确认外部 HTTP 调用有合理的超时设置
  • 缓存热点数据,但注意缓存一致性和过期策略

面试高频考点

如果你正在准备 Go 相关面试,以下概念是高频考点:

  1. goroutine 和线程的区别
  2. channel 的缓冲和非缓冲用法
  3. defer 的执行顺序和与返回值的关系
  4. map 的并发不安全性和解决方案
  5. interface 的隐式实现和类型断言
  6. slice 的底层数组和 append 机制
  7. GC 的基本原理和调优参数
  8. context 的使用场景和超时控制
  9. error 的包装和 errors.Is/errors.As
  10. sync.Mutex vs sync.RWMutex vs atomic

掌握这些概念意味着你具备了独立开发 Go 服务的基础能力。继续在实际项目中磨练,你会越来越熟悉 Go 的工程风格和最佳实践。

常见问题(FAQ)

Q: 这个特性在实际项目中真的有用吗?
A: 是的。本文介绍的技术来源于真实后端开发场景。无论是标准库工具还是工程实践,在日常服务开发中都会反复用到。

Q: Go 版本会影响示例代码吗?
A: 本文代码主要针对 Go 1.20+ 编写。较新版本(如 1.22、1.23)的语法可能有微调,但核心概念保持不变。如有版本差异,文中会特别说明。

Q: 学习 Go 应该先学标准库还是直接上框架?
A: 强烈建议先学标准库。框架是对标准库的封装和扩展。只有理解了标准库的能力边界,才能正确选择和使用框架,也才能在框架出问题时快速定位。

Q: 代码里的错误处理为什么都是显式的 if err != nil
A: 这是 Go 的设计哲学。显式错误处理让失败路径清晰可见,不会隐藏在任何 try-catch 之后。习惯了之后,你会发现这种写法实际上降低了排查错误的难度。

Q: 并发相关代码怎么测试?
A: 使用 Go 内置的 -race 标志检测数据竞争:go test -race ./...。结合 sync.WaitGroupcontext.WithTimeout 编写有退出路径的并发测试,避免 goroutine 泄漏。

常见坑与避坑指南

  1. 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
  2. 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。defer 是一个好习惯。
  3. 不要忽略错误:即使 defer file.Close() 可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。
  4. 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用 sync.WaitGroupcontext 管理生命周期。
  5. 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
  6. 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。

延伸阅读与实践建议

读完本文后,建议完成以下实践:

  1. 把文中所有示例代码在自己的机器上跑一遍
  2. 给示例代码补充错误分支的测试用例
  3. 尝试基于本文内容构建一个小型完整项目
  4. 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
  5. 订阅 Go 官方博客,关注语言演进和最佳实践更新

参考资源

  • Go 官方网站:https://go.dev/
  • Go 标准库文档:https://pkg.go.dev/std
  • Go by Example:https://gobyexample.com/
  • Effective Go:https://go.dev/doc/effective_go
  • Go 常见问题:https://go.dev/doc/faq
  • Go 项目实战社区案例和开源项目源码

本文力求在讲解技术细节的同时兼顾工程实用性。Go 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南