Go CSV 导入入门:逐行读取、校验和错误报告

用用户导入场景讲 Go encoding/csv 的基本用法,包括逐行读取、表头校验、字段转换、错误行号和批量处理策略。

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 不是数字”。错误报告越具体,支持成本越低,代码也更可信。

继续阅读

探索更多技术文章

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

全部文章 返回首页