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.Error 和 Close,不要在每行累积 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 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go 爬虫实践:Colly、chromedp、限速与合规边界
- 下一篇:Go 对象存储:S3/MinIO 上传、分片、预签名与生命周期
- 延伸:Go 文件与文件系统:os、io/fs、path 和 filepath
- 延伸:Go I/O 抽象:io.Reader、Writer、Copy 与流式处理
- 延伸:Go Asynq 异步任务:Redis 队列、重试、定时与唯一任务
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论