CSV 导入是很多后台系统都会遇到的功能。用户从表格软件导出一份文件,上传到系统里批量创建用户、商品或订单。Go 标准库的 encoding/csv 能完成大部分基础工作,但真正难的是边界:表头不对怎么办,某一行邮箱为空怎么办,转换失败怎么告诉用户,文件很大时能不能逐行处理。
本文用“导入用户”做例子。我们会逐行读取 CSV,校验表头,把字段转换成结构体,并记录带行号的错误。
示例 CSV
假设文件内容:
name,email,age
张三,zhang@example.com,28
李四,li@example.com,31
目标结构:
type ImportUser struct {
Name string
Email string
Age int
}
读取入口:
func ImportUsers(r io.Reader) ([]ImportUser, error) {
reader := csv.NewReader(r)
reader.TrimLeadingSpace = true
// 后面逐行处理
return nil, nil
}
TrimLeadingSpace 可以处理逗号后多余空格,但它不是完整清洗。字段值仍然建议自己 strings.TrimSpace。
校验表头
先读第一行:
header, err := reader.Read()
if err != nil {
if errors.Is(err, io.EOF) {
return nil, errors.New("empty csv")
}
return nil, fmt.Errorf("read header: %w", err)
}
比较表头:
func equalHeader(got []string, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if strings.TrimSpace(got[i]) != want[i] {
return false
}
}
return true
}
使用:
if !equalHeader(header, []string{"name", "email", "age"}) {
return nil, fmt.Errorf("invalid header: %v", header)
}
表头校验能避免用户上传错模板。不要直接按位置读取却不检查表头,否则用户把列顺序改了,你可能把邮箱当成姓名导入。
逐行读取
逐行循环:
var users []ImportUser
line := 1
for {
record, err := reader.Read()
line++
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, fmt.Errorf("read line %d: %w", line, err)
}
user, err := parseUserRecord(line, record)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, nil
line 从 1 开始,因为表头是第 1 行。读一条记录后加到第 2 行。错误里带行号,用户才能知道该改哪一行。
解析和校验字段
解析函数:
func parseUserRecord(line int, record []string) (ImportUser, error) {
if len(record) != 3 {
return ImportUser{}, fmt.Errorf("line %d: expected 3 columns, got %d", line, len(record))
}
name := strings.TrimSpace(record[0])
email := strings.TrimSpace(record[1])
ageText := strings.TrimSpace(record[2])
if name == "" {
return ImportUser{}, fmt.Errorf("line %d: name is required", line)
}
if !strings.Contains(email, "@") {
return ImportUser{}, fmt.Errorf("line %d: invalid email", line)
}
age, err := strconv.Atoi(ageText)
if err != nil {
return ImportUser{}, fmt.Errorf("line %d: invalid age %q", line, ageText)
}
if age < 0 || age > 120 {
return ImportUser{}, fmt.Errorf("line %d: age out of range", line)
}
return ImportUser{Name: name, Email: email, Age: age}, nil
}
真实邮箱校验比 strings.Contains(email, "@") 复杂,但入门示例先表达校验位置。不要把所有校验塞在主循环里,单独函数更容易测试。
收集多行错误
有些导入希望“一次返回所有错误”,而不是遇到第一行错误就停止。可以定义错误列表:
type RowError struct {
Line int
Message string
}
type ImportResult struct {
Users []ImportUser
Errors []RowError
}
解析时返回结果:
func ImportUsersWithReport(r io.Reader) (ImportResult, error) {
reader := csv.NewReader(r)
header, err := reader.Read()
if err != nil {
return ImportResult{}, err
}
if !equalHeader(header, []string{"name", "email", "age"}) {
return ImportResult{}, errors.New("invalid header")
}
var result ImportResult
line := 1
for {
record, err := reader.Read()
line++
if errors.Is(err, io.EOF) {
break
}
if err != nil {
result.Errors = append(result.Errors, RowError{Line: line, Message: err.Error()})
continue
}
user, err := parseUserRecord(line, record)
if err != nil {
result.Errors = append(result.Errors, RowError{Line: line, Message: err.Error()})
continue
}
result.Users = append(result.Users, user)
}
return result, nil
}
这样用户可以一次修多行。是否允许部分成功,要看业务。如果导入用户,可能希望有错误就不写入数据库;如果导入日志标签,可能允许成功的先入库。规则要提前定义。
大文件不要一次读进内存
csv.Reader 本身是流式读取的,但上面示例把所有用户 append 到切片里。如果文件很大,可以按批处理:
const batchSize = 500
var batch []ImportUser
for {
// 读出 user
batch = append(batch, user)
if len(batch) == batchSize {
if err := saveBatch(ctx, batch); err != nil {
return err
}
batch = batch[:0]
}
}
if len(batch) > 0 {
if err := saveBatch(ctx, batch); err != nil {
return err
}
}
批量写入时要考虑事务。如果任何一行失败都要回滚,就在事务里处理;如果允许部分成功,就要返回清楚报告。导入功能一半是技术问题,一半是产品规则。
测试解析函数
parseUserRecord 很适合表驱动测试:
func TestParseUserRecord(t *testing.T) {
tests := []struct {
name string
record []string
wantErr bool
}{
{name: "valid", record: []string{"张三", "a@example.com", "20"}},
{name: "missing name", record: []string{"", "a@example.com", "20"}, wantErr: true},
{name: "bad age", record: []string{"张三", "a@example.com", "old"}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := parseUserRecord(2, tt.record)
if tt.wantErr && err == nil {
t.Fatal("expected error")
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
把解析和校验拆出来,测试会很轻。不要只通过上传接口做大而全测试,那样失败时很难定位是哪一行逻辑错了。
小结
Go 的 encoding/csv 足够处理多数 CSV 导入场景。实践重点是表头校验、逐行读取、字段转换、错误带行号、是否收集多行错误,以及大文件的批处理策略。
导入功能要对用户友好。与其返回“格式错误”,不如告诉用户“第 12 行 age 不是数字”。错误报告越具体,支持成本越低,代码也更可信。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。