异步导出框架:设计与使用¶
大数据量导出不该占着一个 HTTP 请求慢慢生成。x-admin 的做法:列表页点「导出」只提交一个任务,后台 worker 游标分批生成 xlsx,完成后站内通知,去「导出记录」页下载。本文讲清楚这套框架怎么用、怎么扩展新模块。
1. 它解决什么问题¶
同步导出(请求内直接返回文件流)有三个硬伤:
- 数据量大时请求超时、内存峰值高;
- 用户必须停在页面上等,关掉页面导出就断了;
- 无法统一限流——几个用户同时点导出就能把服务打满。
异步导出把「提交」和「生成」拆开:
列表页「导出」按钮 → POST /export-tasks(按当前筛选条件)
↓ 立即返回任务记录(pending)
进程内有界队列 → worker(默认 2 个)游标分批取数 → 流式写 xlsx → 存储落盘
↓
导出记录页轮询进度 → success 后下载;失败/取消可见原因
↓
站内通知(成功/失败都推,点击跳导出记录页)
已接入的模块:用户列表(users)、操作日志(operation_logs)、登录日志(login_logs)。
2. 使用方视角¶
2.1 列表页提交¶
「用户管理」「操作日志」「登录日志」页的「导出」按钮会把当前筛选条件原样带给后端:
// frontend/src/pages/Users.tsx
api.post('/export-tasks', {
module: 'users',
params: { keyword, dept_id: deptID, status },
})
提交成功提示「已加入导出队列」,之后可以离开页面干别的。
2.2 导出记录页(/system/exports)¶
- 进度:存在排队/生成中任务时每 2 秒轮询,全部结束自动停轮询;
- 取消:排队中、生成中均可取消,已结束的任务不可取消;
- 下载:仅「已完成」的任务可下载,文件名按全局模板生成;
- 删除:删除记录并清理产物文件;未结束的任务会先转为「请求取消」;
- 文件名配置:弹层里可视化拼装文件名模板(见第 4 节),所见即所得。
3. API 一览¶
路由注册在 internal/router/admin.go(前缀 /admin/v1),全部走登录态:
| 方法 | 路径 | 权限码 | 说明 |
|---|---|---|---|
POST |
/export-tasks |
system:export:submit |
提交任务,body 为 {"module": "users", "params": {…筛选条件}},返回任务记录 |
GET |
/export-tasks |
system:export:list |
分页查当前用户的任务,可按 status 过滤(0 排队 1 生成 2 完成 3 失败 4 已取消) |
POST |
/export-tasks/:id/cancel |
system:export:cancel |
请求取消 |
DELETE |
/export-tasks/:id |
system:export:delete |
删除已结束的记录与产物文件 |
GET |
/export-tasks/:id/download |
system:export:download |
下载 xlsx 产物(流式) |
GET |
/export-tasks/filename-template |
system:export:list |
读当前文件名模板 |
PUT |
/export-tasks/filename-template |
system:export:config |
保存文件名模板 |
任务记录关键字段:module / title / status / total(预估总行数)/ processed(已写入行数)/ file_name / file_size / error / finished_at。注意 file_key(存储相对路径)不会下发前端,下载一律走导出接口,避免越权拼接路径。
归属校验:任务只属于提交人,查别人的任务一律 404(不暴露存在性)。
菜单与权限码见基线迁移 migrations/000001_init.up.sql(「审计」→「导出记录」,按钮码 200–205)。
4. 任务生命周期与设计要点¶
代码主体在 internal/service/export.go。
4.1 状态机¶
pending(0) ── worker 出队 ──→ running(1) ──┬─→ success(2) (写盘成功,记录 file_name/size)
│ │ ├─→ failed(3) (error 记录原因,512 字截断)
│ 取消(canceled=true) ─┘ └─→ canceled(4)
- 取消是协作式的:
Cancel只置canceled标志;worker 每批取数前、以及收尾写盘前都会复查标志,命中即停止并收敛为 canceled。收尾前那次复查是必须的——否则取消请求会丢在最后一批取数之后。 - 服务重启时,
Boot()把上次遗留的 pending/running 任务统一标记失败(「服务重启导致任务中断」),任务不会悬挂。 generate全程 recover 兜底,业务 builder 的 panic 会被记为任务失败而不是带崩 worker。
4.2 游标分批取数¶
业务侧提供两个函数(ExportJob):
Total(ctx):预估总行数,只用于进度条,可为 nil(进度就没有分母);Fetch(ctx, afterID, limit):返回本批rows [][]any和下一批游标nextCursor;next == 0或空批表示取完。
框架侧的防护:
written >= MaxRows时任务失败,提示缩小筛选范围(默认 200000 行);next <= cursor(游标不前进)立即失败,避免业务实现错误变成死循环;- 每批写完更新
processed,前端进度条随批推进。
4.3 有界队列与限流¶
- 队列长度
QueueSize(默认 16),满了直接拒绝提交(429「导出任务过多」),不会无界堆积; - 每用户未结束任务数 ≥
QueueSize时同样拒绝; - 与并发提交竞争队尾时,撤回已建的记录再拒绝,不留孤儿记录。
4.4 文件名模板¶
- 存在
sys_parameters,键export.filename_template,值为片段数组 JSON;由导出记录页可视化配置,参数管理页只读展示; - 片段类型:
title(任务标题)、module(模块标识)、time(时间,格式白名单见FilenameTimeFormats,杜绝 Windows 非法字符)、text(自定义文本,≤32 字);最多 8 段,title/module/time 各最多一个; - 默认模板 =
module + time(20060102150405);任一片段拼空则跳过,整体为空回退「模块-时间」; - 后台生成与前端预览共用
ComposeFilename,所见即所得;配置变更对下一次导出立即生效。
4.5 产物与保留期¶
- 产物写入
storage.Store(与上传文件同一存储抽象,本地盘或对象存储); - 定时任务 handler
clean_export_tasks按keep_days(默认 7 天)删除过期记录并清理文件,参数可传{"days": N}覆盖。
5. 配置¶
configs/config.yaml:
export:
workers: 2 # 并发执行的导出任务数
queue_size: 16 # 等待队列长度,超出后提交返回"导出任务过多"
batch_size: 2000 # 游标分页每批取数行数
max_rows: 200000 # 单个任务允许导出的最大行数
keep_days: 7 # 导出任务与文件的保留天数(定时任务 clean_export_tasks)
6. 扩展:给新模块接入导出¶
以「给字典管理加导出」为例,共四步。
第一步:写 Builder¶
在业务 service 上加 ExportBuilders()(参考 internal/service/user.go 的 users builder):
// ExportBuilders 返回字典模块的异步导出构建器。
func (s *DictService) ExportBuilders() map[string]ExportBuilder {
return map[string]ExportBuilder{
"dicts": func(ctx context.Context, userID uint64, params json.RawMessage) (ExportJob, error) {
var f model.DictFilter
if len(params) > 0 {
if err := json.Unmarshal(params, &f); err != nil {
return ExportJob{}, apperr.New(apperr.CodeInvalidArgument, "导出参数不合法")
}
}
f.Page, f.PageSize = 1, 0 // 关键:清掉分页,让仓库层走游标导出查询
return ExportJob{
Module: "dicts",
Title: "字典列表", // 同时作为工作表名
Headers: []string{"ID", "名称", "键", "值", "状态", "时间"},
Total: func(ctx context.Context) (int64, error) {
return s.repo.Count(ctx, f)
},
Fetch: func(ctx context.Context, afterID uint64, limit int) ([][]any, uint64, error) {
rows, err := s.repo.ExportPage(ctx, f, afterID, limit) // WHERE id > afterID ORDER BY id LIMIT limit
if err != nil {
return nil, 0, err
}
out := make([][]any, 0, len(rows))
for _, r := range rows {
out = append(out, []any{r.ID, r.Name, r.Key, r.Value, r.Status,
r.CreatedAt.Format("2006-01-02 15:04:05")})
}
var next uint64
if len(rows) > 0 {
next = rows[len(rows)-1].ID // 游标 = 本批最后一行业务主键
}
return out, next, nil
},
}, nil
},
}
}
要点:
- Fetch 必须用游标分页(
WHERE id > ? ORDER BY id LIMIT ?),不要用 offset——深分页会越来越慢且数据抖动时会重/漏行; - 游标必须严格前进,否则框架判定异常直接失败;
- builder 里解析
params时自己负责校验(它就是前端列表页的筛选条件 JSON); - 数据能一次性拿到(或由上游组装成
[]map[string]any)时,可以不写游标逻辑,直接用快捷封装NewMapsExportJob(module, title, spec, rows),列序与表头用MapExportSpec{Order, Names}控制,单元格值转换(时间格式化、切片/嵌套 map 转 JSON 等)由MapCellValue统一处理。
第二步:注册 Builder¶
在 internal/bootstrap/providers.go 的 provideExportService 里挂上:
module 名与前端提交的 module 字段一致,重复注册后者覆盖前者。改了 provider 记得 make wire。
第三步:仓库层游标查询¶
给 repo 加 ExportPage(ctx, f, afterID, limit)。注意 Base.Count / 分页查询的签名复用问题:导出查询不走 pagination.Query,直接收 afterID 与 limit 两个参数即可。
第四步:前端入口¶
在列表页加导出按钮(权限显隐用 useAuth().can('system:export:submit')):
不需要动导出记录页——它按 module 无关的方式展示所有任务。
接入自查清单¶
- builder 里清掉了
f.Page / f.PageSize(否则仓库层可能仍按页截断) - Fetch 游标严格前进、空批/
next=0正确表示结束 - 行数据统一过
MapCellValue(时间格式化、nil → 空串) -
make wire重新生成装配 - 前端按钮挂了权限码,提交后 toast 提示 + 失效
['export-tasks']查询缓存
已踩过的坑¶
- excelize 默认工作表名:新建工作簿自带
Sheet1,excel.NewStreamBook已处理改名/去重,业务不要直接操作 excelize 实例; - 取消丢失:只在批间查
canceled不够,收尾写盘前必须再查一次(框架已实现,自己改流程时别删); - Count 签名冲突:导出的
Total走仓库层裸Count(ctx, filter),别和列表页带分页参数的 Count 混用。