Go 基础体系 · 第 98/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go Excelize 实战:读写 Excel、样式、公式与流式大文件

本文以 Go 1.26.4、稳定版 github.com/xuri/excelize/v2 v2.11.0 为基准。Excelize 可以创建、读取和修改 XLSX,支持单元格、样式、公式、图表、图片以及流式读写。真正困难的不是 SetCellValue,而是定义表格 schema、处理日期与金额、限制不可信压缩包、控制大文件内存、避免公式注入,并在写失败时不向用户发布半成品。

1. 固定版本与最小项目

mkdir report-service && cd report-service
go mod init example.com/report-service
go get github.com/xuri/excelize/v2@v2.11.0
go mod tidy
go test ./...

固定模块版本,升级时用脱敏工作簿回归样式、日期、公式、图片和大文件内存。支持多个查看器时需分别验证。

最小写入函数用 errors.Join 保留关闭错误:

func writeSmallReport(filename string) (err error) {
	file := excelize.NewFile()
	defer func() {
		if closeErr := file.Close(); closeErr != nil {
			err = errors.Join(err, fmt.Errorf("close workbook: %w", closeErr))
		}
	}()

	if err := file.SetCellValue("Sheet1", "A1", "ID"); err != nil {
		return fmt.Errorf("set header: %w", err)
	}
	if err := file.SetCellValue("Sheet1", "A2", "article-7"); err != nil {
		return fmt.Errorf("set id: %w", err)
	}
	if err := file.SaveAs(filename); err != nil {
		return fmt.Errorf("save report: %w", err)
	}
	return nil
}

2. 先定义工作簿契约

电子表格是对外数据 API。应先定义 sheet 名、列顺序、表头、类型、必填、时区、金额单位、枚举、空值、最大长度和版本,而不是把结构体反射成列。列新增通常比改名安全,删除或移动会破坏用户公式和导入脚本。

type reportRow struct {
	ID          string
	Title       string
	PublishedAt time.Time
	AmountCents int64
	Status      string
}

var reportHeaders = []string{
	"ID",
	"Title",
	"Published At (UTC)",
	"Amount (CNY)",
	"Status",
}

机器导入应有 schema version,例如 _meta sheet;隐藏不是安全控制。展示报表追求样式,机器交换追求稳定结构,关键系统间交换优先 JSON/Parquet 等明确格式。

3. 单元格坐标与类型语义

Excel 列是字母、行从 1 开始。动态数据使用 CoordinatesToCellName,并处理错误:

func setRow(file *excelize.File, sheet string, rowIndex int, row reportRow) error {
	values := []any{row.ID, safeText(row.Title), row.PublishedAt.UTC(), float64(row.AmountCents) / 100, row.Status}
	for column, value := range values {
		cell, err := excelize.CoordinatesToCellName(column+1, rowIndex)
		if err != nil {
			return fmt.Errorf("cell at column %d row %d: %w", column+1, rowIndex, err)
		}
		if err := file.SetCellValue(sheet, cell, value); err != nil {
			return fmt.Errorf("set %s: %w", cell, err)
		}
	}
	return nil
}

Excel 数值基于 IEEE 754 双精度;长 ID、银行卡号和订单号应写文本,避免科学计数或丢低位。金额以分存 int64,导出小数仅作展示。

4. Sheet 生命周期与名称约束

const sheetName = "Articles"

file := excelize.NewFile()
if err := file.SetSheetName("Sheet1", sheetName); err != nil {
	return fmt.Errorf("rename sheet: %w", err)
}
index, err := file.NewSheet("Summary")
if err != nil {
	return fmt.Errorf("new summary sheet: %w", err)
}
file.SetActiveSheet(index)

用户输入不能直接作为 sheet 名;应按 OOXML 规则清理、截断并为冲突生成稳定后缀。公式引用特殊名称时需要正确引用。

5. 样式复用与展示层边界

样式通过 NewStyle 创建 ID,再批量复用。不要每个单元格创建一个等价 style,工作簿会膨胀且可能触及样式数量限制。

headerStyle, err := file.NewStyle(&excelize.Style{
	Font: &excelize.Font{Bold: true, Color: "FFFFFF"},
	Fill: excelize.Fill{Type: "pattern", Color: []string{"1F4E78"}, Pattern: 1},
	Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
if err != nil {
	return fmt.Errorf("new header style: %w", err)
}
if err := file.SetCellStyle(sheetName, "A1", "E1", headerStyle); err != nil {
	return fmt.Errorf("style header: %w", err)
}

6. 日期、时区与 1900/1904 系统

Excel 日期通常存为序列数并配合 number format。工作簿可能使用 1900 或 1904 日期系统;1900 系统还继承历史闰年兼容问题。读取日期不能只把数字当 Unix 时间,也不能仅看显示字符串。

绝对时刻统一存 UTC 并在表头标注;“生日”类日历日期不做时区平移。需要精确往返时额外输出 RFC3339 文本。

dateStyle, err := file.NewStyle(&excelize.Style{NumFmt: 22})
if err != nil {
	return fmt.Errorf("new date style: %w", err)
}
if err := file.SetCellValue(sheetName, "C2", row.PublishedAt.UTC()); err != nil {
	return fmt.Errorf("set published time: %w", err)
}
if err := file.SetCellStyle(sheetName, "C2", "C2", dateStyle); err != nil {
	return fmt.Errorf("style published time: %w", err)
}

7. 公式写入、缓存值与重新计算

SetCellFormula 写公式,但 Excelize 不是完整计算引擎,不能假设复杂函数、外部链接或动态数组已经求值。

if err := file.SetCellValue("Summary", "A1", "Total"); err != nil {
	return fmt.Errorf("set total label: %w", err)
}
if err := file.SetCellFormula("Summary", "B1", "SUM(Articles!D2:D1001)"); err != nil {
	return fmt.Errorf("set total formula: %w", err)
}

产品要说明公式何时重算。账务、权限和业务决策的权威结果必须由服务端计算,不能信任客户端表格。

8. 公式注入与文本安全

当不可信文本以 =, +, -, @ 开头时,Excel/其他表格软件可能把它解释为公式。恶意公式可诱导外部请求、点击执行或数据泄漏。CSV 同样存在此风险,改成 XLSX 并不会自动安全。

文本列应强制文本类型并转义危险首字符。单引号会改变原始值和查看器显示,策略必须在目标软件实测。

func safeText(value string) string {
	trimmed := strings.TrimLeft(value, "\t\r\n ")
	if trimmed == "" {
		return value
	}
	switch trimmed[0] {
	case '=', '+', '-', '@':
		return "'" + value
	default:
		return value
	}
}

要先忽略前导空白再判断,因为某些查看器会跳过控制字符。不要对所有字段盲目转义,否则合法负数被变文本;按 schema 区分数值列和文本列。超链接协议仅允许 https 等白名单,不输出 file:、脚本或不受控 UNC 路径。

9. 普通读取与 Rows 迭代器

GetRows 方便,但会把整张 sheet 读成 [][]string,适合大小已知的小表。大文件使用 Rows 逐行迭代并在结束时 Close

func importArticles(file *excelize.File, importer *Importer) (err error) {
	rows, err := file.Rows("Articles")
	if err != nil {
		return fmt.Errorf("open article rows: %w", err)
	}
	defer func() {
		if closeErr := rows.Close(); closeErr != nil {
			err = errors.Join(err, fmt.Errorf("close article rows: %w", closeErr))
		}
	}()

	rowNumber := 0
	for rows.Next() {
		rowNumber++
		columns, err := rows.Columns()
		if err != nil {
			return fmt.Errorf("read row %d: %w", rowNumber, err)
		}
		if err := importer.Accept(rowNumber, columns); err != nil {
			return fmt.Errorf("accept row %d: %w", rowNumber, err)
		}
	}
	if err := rows.Error(); err != nil {
		return fmt.Errorf("iterate article rows: %w", err)
	}
	return nil
}

迭代器降低结果集合内存,但打开工作簿仍有解析成本。结束检查 rows.ErrorClose,不要在每行累积 defer。

尾部空格、合并单元格和隐藏行列的导入策略必须明确。

10. 导入校验与错误报告

导入分为结构校验和行校验。先验证允许的 sheet、表头、最大行列、版本与必填列,再逐行解析类型。错误报告包括 sheet、行、逻辑列、错误码和脱敏消息;不能只返回“invalid file”。

type rowError struct {
	Sheet   string
	Row     int
	Column  string
	Code    string
	Message string
}

func parseAmount(raw string) (int64, error) {
	value := strings.TrimSpace(raw)
	parts := strings.Split(value, ".")
	if len(parts) > 2 {
		return 0, errors.New("amount has multiple decimal points")
	}
	// 生产代码应按币种小数位严格解析到整数,拒绝隐式浮点舍入。
	return parseCents(parts)
}

错误明细设上限并统计总数。校验完成后再事务写入,或使用 staging table 与最终提交协议,避免半成功。

公式、外部链接、宏、图片和嵌入对象是否允许要列入入口策略。Excelize 处理 XLSX,不应把含宏格式当普通可信数据。

11. StreamWriter 的顺序与刷新协议

大量行导出使用 NewStreamWriter。行必须按递增编号写,流式区域不要和普通写法随意混用;结束必须 Flush,再保存工作簿。

func writeArticles(file *excelize.File, rows []reportRow) error {
	writer, err := file.NewStreamWriter("Sheet1")
	if err != nil {
		return fmt.Errorf("new stream writer: %w", err)
	}
	if err := writer.SetRow("A1", []any{"ID", "Title", "Published At", "Amount"}); err != nil {
		return fmt.Errorf("write header: %w", err)
	}
	for index, row := range rows {
		cell, err := excelize.CoordinatesToCellName(1, index+2)
		if err != nil {
			return fmt.Errorf("row coordinate %d: %w", index+2, err)
		}
		values := []any{row.ID, safeText(row.Title), row.PublishedAt.UTC(), float64(row.AmountCents) / 100}
		if err := writer.SetRow(cell, values); err != nil {
			return fmt.Errorf("write row %d: %w", index+2, err)
		}
	}
	if err := writer.Flush(); err != nil {
		return fmt.Errorf("flush stream: %w", err)
	}
	return nil
}

真正大数据应从数据库游标逐批读取并立即写,且保持稳定排序和快照语义。StreamWriter 的临时目录需要空间、权限和清理监控。

流写中途失败就丢弃临时产物,通过幂等任务重新生成。

12. Context、并发与异步任务

Excelize API 不会自动接受 context.Context。导出循环应在批次或每若干行检查 context;数据库查询、对象存储上传本身也使用同一个 context。取消后停止拉取数据,关闭行迭代器和文件,删除临时产物。

for rowNumber := 2; source.Next(); rowNumber++ {
	if rowNumber%100 == 0 {
		select {
		case <-ctx.Done():
			return context.Cause(ctx)
		default:
		}
	}
	if err := writeSourceRow(writer, rowNumber, source.Row()); err != nil {
		return err
	}
}

不要并发写同一工作簿;并行生成多个工作簿也受 CPU、内存、临时盘和连接池限制。

大导入/导出放持久队列,记录任务 ID、schema、尝试和产物状态,通过幂等 key 从取消、重试或崩溃中恢复。

13. 不可信 XLSX、ZIP bomb 与资源限制

上传入口限制压缩字节,再检查 ZIP 条目数、总解压大小、压缩比和 XML 复杂度。单靠 Content-Length 不足以防 zip bomb。

按业务限制 sheet、行列、字符串、图片像素、公式和时长。解析放在低权限且限制 CPU、内存、临时盘的 worker。

自行解包或提取附件必须拒绝绝对路径和 ..。XML 外部实体、关系外链和嵌入对象均按不可信处理。

14. 图片、图表、数据验证与外部关系

图片限制字节和像素,用户 URL 由媒体服务预取以防 SSRF。图表处理零行,数据验证不替代服务端校验;外部链接不需要时拒绝。

15. 原子发布、对象存储与文件生命周期

直接 SaveAs 到用户可见最终路径可能留下半成品。先在最终目录或受控临时区生成唯一文件,成功 Flush、保存、关闭并校验后,再原子 rename;对象存储则上传到临时 key,校验大小/摘要后发布数据库指针或 copy 到最终 key。

func saveAtomically(file *excelize.File, target string) (err error) {
	directory := filepath.Dir(target)
	temporary, err := os.CreateTemp(directory, ".report-*.xlsx")
	if err != nil {
		return fmt.Errorf("create temporary report: %w", err)
	}
	temporaryName := temporary.Name()
	published := false
	defer func() {
		if published {
			return
		}
		if removeErr := os.Remove(temporaryName); removeErr != nil && !errors.Is(removeErr, fs.ErrNotExist) {
			err = errors.Join(err, fmt.Errorf("remove temporary report: %w", removeErr))
		}
	}()
	if err := temporary.Close(); err != nil {
		return fmt.Errorf("close temporary report: %w", err)
	}

	if err := file.SaveAs(temporaryName); err != nil {
		return fmt.Errorf("save temporary report: %w", err)
	}
	if err := file.Close(); err != nil {
		return fmt.Errorf("close workbook: %w", err)
	}
	if err := os.Rename(temporaryName, target); err != nil {
		return fmt.Errorf("publish report: %w", err)
	}
	published = true
	return nil
}

同目录 rename 提供原子可见基础;严格持久性还需 sync 并按平台验证。下载文件名由服务端生成,产物有 TTL 和访问控制。

16. 失败诊断与可观测性

“文件损坏”先区分生成、传输、兼容和业务错误。记录任务、版本、行列、大小、耗时和分类错误,不记录单元格正文。

内存过高检查 GetRows、整批 slice、样式/图片、并发工作簿和临时文件。文件膨胀常因每格新样式或重复图片。

unzip -t report.xlsx
unzip -l report.xlsx | sort -k1,1nr | head
go test -run TestExport -count=20 ./internal/report
go test -bench=Export -benchmem ./internal/report
go tool pprof ./report.test heap.out

解包工具只用于受控副本,未知文件仍在隔离环境处理。错误需在边界记录一次,内部函数包装并返回,不同时日志和返回。

17. 测试、基准与兼容回归

单测覆盖 schema、safeText、金额和日期。综合测试生成后重开,断言值、公式和样式;fixture 覆盖空表、合并格、日期和恶意压缩结构。

func TestSafeText(t *testing.T) {
	tests := []struct {
		name string
		give string
		want string
	}{
		{name: "plain", give: "article", want: "article"},
		{name: "formula", give: "=1+1", want: "'=1+1"},
		{name: "leading whitespace", give: "  @SUM(A1)", want: "'  @SUM(A1)"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := safeText(tt.give); got != tt.want {
				t.Errorf("safeText(%q) = %q, want %q", tt.give, got, tt.want)
			}
		})
	}
}

基准分别测普通写、StreamWriter、GetRows、Rows,记录行/秒、allocs、峰值 RSS、临时盘和产物大小。

CI 运行 test、race、vet 和目标查看器兼容回归。竞态测试不能证明同一 File 支持任意并发。

18. 生产检查清单

  • 工具链固定 Go 1.26.4,Excelize 固定 v2.11.0,升级带真实 fixture 回归。
  • 工作簿 schema 有版本,列类型、空值、时区、金额和兼容策略明确。
  • 文本列阻止公式注入,公式结果不作为服务端权威计算。
  • 大文件使用 Rows/StreamWriter 和数据库流式来源,所有迭代器、writer、workbook 都关闭。
  • 上传限制压缩/解压大小、行列、字符串、图片、公式、CPU、内存和时间。
  • 单工作簿不并发乱写;任务有 context、幂等键、取消、重试和临时产物清理。
  • 保存到临时位置,完整校验后发布;下载鉴权、TTL、文件名和日志脱敏完整。

Excelize 的生产边界可以概括为:它负责操作 OOXML,不负责替业务定义数据含义,也不是 Excel 的完整计算或安全沙箱。 把 schema、精度、公式策略、资源上限和原子发布协议放在库 API 之外明确实现,才能让“导出 Excel”成为可验证的数据产品,而不是偶尔能打开的附件。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。