16 KiB
name, description
| name | description |
|---|---|
| new-async-task | 项目专用:指导如何在 wavelet 项目中新增异步任务(TaskHandler)。当用户提到新增任务、Asynq、后台任务、定时任务、任务日志、任务重试、TaskMeta、TaskParam 或 PayloadValidator 时必须使用本技能,覆盖常量定义、参数校验、处理器实现、统一注册、Worker 路由、Cron 调度、AppendLog 日志和测试验证。 |
新增异步任务开发指南
本项目中异步任务基于 Asynq(Redis 任务队列)构建。开发者只需编写业务逻辑,框架负责执行记录、状态流转、日志、重试等全部外围工作。
架构概览
DispatchTask (创建记录 + 入队)
│
▼
Asynq Redis Queue
│
▼
ProcessTask (状态 pending→running,调用 handler)
│
▼
TaskHandler.Execute (← 你写这一步)
│
成功 → succeeded + 存储结果
失败 → failed + 记录错误 + Asynq 可能自动重试
关键文件:
internal/task/handler.go—TaskHandler接口、TaskResult类型、PayloadValidator可选接口internal/task/constants.go— 任务类型常量、TaskMeta、TaskParam、DispatchableTasksinternal/task/executor.go— 注册、下发、执行、日志追加、参数校验分发internal/task/handlers/register.go— 内置任务处理器统一注册点,Admin API 和 Worker 都依赖它internal/task/worker/worker.go— Worker 启动、Asynq mux 路由、队列配置internal/task/scheduler/scheduler.go— Cron 定时调度internal/model/task_execution.go—TaskExecutionGORM 模型internal/apps/admin/task/routers.go— 管理 API(下发、查询、重试)
新增任务的完整步骤
第 1 步:在 constants.go 添加常量和 TaskMeta
打开 internal/task/constants.go,添加三样东西:
1. Asynq 任务类型常量(作为 Redis 消息类型标识,格式 {module}:{action}):
const CleanupUnusedUploadsTask = "upload:cleanup_unused"
2. 管理员下发用的任务类型常量(用于 Admin API 调用):
const TaskTypeCleanupUploads = "cleanup_unused_uploads"
3. 在 DispatchableTasks 切片中添加 TaskMeta:
不带参数的任务:
{
Type: TaskTypeCleanupUploads,
AsynqTask: CleanupUnusedUploadsTask,
Name: "清理未使用上传",
Description: "清理超过1小时未使用的上传文件",
SupportsTime: false,
MaxRetry: 3,
Queue: QueueDefault,
Retryable: true,
}
带参数的任务(Params 定义前端表单字段):
{
Type: TaskTypeSendEmail,
AsynqTask: SendEmailTask,
Name: "发送邮件",
Description: "异步发送系统邮件",
SupportsTime: false,
MaxRetry: 3,
Queue: QueueDefault,
Retryable: true,
Params: []TaskParam{
{
Name: "to",
Label: "接收邮箱 (To)",
Type: "string",
Required: true,
Placeholder: "receiver@example.com",
Description: "接收邮件的目标邮箱地址",
},
{
Name: "subject",
Label: "邮件主题 (Subject)",
Type: "string",
Required: true,
Placeholder: "请输入邮件主题",
Description: "发送邮件的主题标题",
},
{
Name: "body",
Label: "邮件内容 (Body)",
Type: "text",
Required: true,
Placeholder: "请输入邮件内容(支持 HTML 格式)",
Description: "发送邮件的内容主体",
},
},
}
TaskParam 字段说明:
| 字段 | 作用 |
|---|---|
Name |
参数键名,与 Handler 中 Payload 结构体的 JSON tag 一致 |
Label |
前端表单显示的标签 |
Type |
前端控件类型:string(单行输入)、text(多行文本)、number(数字) |
Required |
前端是否标为必填(仅前端提示,不做服务端校验) |
Placeholder |
前端输入框占位文字 |
Description |
前端显示的参数说明 |
注意:TaskParam 纯粹是前端表单元数据。服务端参数校验通过 Handler 实现 PayloadValidator 接口完成(见第 2 步)。
第 2 步:实现 TaskHandler(+ 可选 PayloadValidator)
在 internal/apps/<module>/ 下创建 tasks.go。
无参数的任务(参考 internal/apps/upload/tasks.go)
只需实现 TaskHandler 接口:
type CleanupUnusedUploadsHandler struct{}
func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
task.AppendLog(ctx, "开始扫描...")
// ... 直接执行业务逻辑,忽略 payload ...
return &task.TaskResult{Message: "完成"}, nil
}
带参数的任务(参考 internal/apps/user/tasks.go)
需要定义 Payload 结构体,并实现 PayloadValidator 接口进行参数校验:
// 定义载荷结构体(字段与 TaskParam.Name 对应)
type SendEmailPayload struct {
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
}
type SendEmailHandler struct{}
// ValidatePayload 实现 task.PayloadValidator 接口
// 框架在 Admin 下发时自动调用,校验失败返回 error,前端收到 400 + 错误信息
func (h *SendEmailHandler) ValidatePayload(payload []byte) ([]byte, error) {
if len(payload) == 0 {
return nil, errors.New("任务参数不能为空")
}
var req SendEmailPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, fmt.Errorf("无效的 JSON 格式: %w", err)
}
// 标准化:Trim 空白
req.To = strings.TrimSpace(req.To)
req.Subject = strings.TrimSpace(req.Subject)
req.Body = strings.TrimSpace(req.Body)
// 校验必填字段
if req.To == "" || req.Subject == "" || req.Body == "" {
return nil, errors.New("to、subject、body 不能为空")
}
// 返回标准化后的 bytes,会作为最终 payload 存入 DB 和入队
return json.Marshal(req)
}
// Execute 实现 task.TaskHandler 接口
func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
var req SendEmailPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, fmt.Errorf("解析参数失败: %w", err)
}
task.AppendLog(ctx, "开始发送邮件到: %s, 主题: %s", req.To, req.Subject)
// ... 业务逻辑 ...
msg := fmt.Sprintf("邮件成功发送至: %s", req.To)
task.AppendLog(ctx, "%s", msg)
return &task.TaskResult{Message: msg}, nil
}
PayloadValidator 接口说明:
type PayloadValidator interface {
ValidatePayload(payload []byte) ([]byte, error)
}
这是一个可选接口。框架在 Admin 下发任务时通过 task.ValidateAndNormalizePayload 自动检测:
- Handler 实现了
PayloadValidator→ 调用ValidatePayload,校验通过返回标准化后的 bytes,失败返回 error(前端收到 400) - Handler 没实现 → 直接透传原始 payload,不做任何校验
不需要修改 Admin dispatch handler。routers.go 中的 DispatchTask 函数已经是通用的,会自动调用对应 Handler 的 ValidatePayload。
Execute 中仍然需要 json.Unmarshal,因为任务可能通过 Scheduler 或重试触发,不经过 Admin 校验路径。但不需要重复必填校验。
返回值约定:
- 成功:返回
&task.TaskResult{Message: "摘要", Detail: "可选详细JSON"}。ProcessTask会将Message + "\n" + Detail存入TaskExecution.Result。 - 失败:返回
nil, fmt.Errorf("...")。ProcessTask会将错误信息存入TaskExecution.ErrorMessage,状态标记为failed,并将 error 返回给 Asynq 触发自动重试。
第 3 步:统一注册 Handler,并在 worker.go 添加路由
新增任务必须同时满足两条链路:
- Admin API 下发任务时能找到 Handler,从而调用
PayloadValidator做服务端参数校验。 - Worker 消费 Asynq 消息时能路由到
task.ProcessTask,再由框架分发到 Handler。
3.1 在 internal/task/handlers/register.go 注册处理器
打开 internal/task/handlers/register.go,导入你的业务模块并在 Register() 中注册:
package handlers
import (
"github.com/Rain-kl/Wavelet/internal/apps/upload"
"github.com/Rain-kl/Wavelet/internal/apps/user"
"github.com/Rain-kl/Wavelet/internal/task"
)
func Register() {
task.RegisterHandler(task.CleanupUnusedUploadsTask, &upload.CleanupUnusedUploadsHandler{})
task.RegisterHandler(task.SendEmailTask, &user.SendEmailHandler{})
}
Register() 会被 Admin task API 和 Worker 共同调用。不要只在 worker 包里注册,否则 Admin dispatch 时 ValidateAndNormalizePayload 找不到 Handler,带参数任务会绕过 PayloadValidator。
3.2 在 internal/task/worker/worker.go 添加 Asynq 路由
在 StartWorker() 的 mux 中添加任务类型路由:
mux := asynq.NewServeMux()
mux.Use(taskLoggingMiddleware)
mux.HandleFunc(task.CleanupUnusedUploadsTask, task.ProcessTask)
mux.HandleFunc(task.SendEmailTask, task.ProcessTask)
所有路由都指向同一个 task.ProcessTask,它内部根据 Asynq task type 查找对应的 handler 分发执行。
worker.go 的 init() 应保持调用统一注册函数:
func init() {
taskhandlers.Register()
}
第 4 步(可选):添加 Cron 定时调度
如果任务需要定时执行,编辑 internal/task/scheduler/scheduler.go,在 StartScheduler() 中注册:
if _, err = scheduler.Register(
config.Config.Scheduler.CleanupUnusedUploadsTaskCron,
asynq.NewTask(task.CleanupUnusedUploadsTask, nil),
asynq.Unique(23*time.Hour),
asynq.MaxRetry(3),
); err != nil {
return
}
然后在 internal/config/model.go 的 schedulerConfig 中添加字段:
type schedulerConfig struct {
CleanupUnusedUploadsTaskCron string `mapstructure:"cleanup_unused_uploads_task_cron"`
YourNewTaskCron string `mapstructure:"your_new_task_cron"`
}
在 config.example.yaml 的 scheduler 段添加:
scheduler:
cleanup_unused_uploads_task_cron: "0 */2 * * *"
your_new_task_cron: "0 */6 * * *"
注意:通过 Scheduler 定时触发的任务不会创建 TaskExecution 记录(直接入队 Asynq)。ProcessTask 执行时若在数据库中找不到对应记录,会打印警告日志但仍会正常执行业务逻辑。如果需要状态追踪,应通过 Admin API 手动下发。
参数校验的完整链路
前端表单(根据 TaskMeta.Params 动态渲染)
→ POST /api/v1/admin/tasks/dispatch { task_type, payload: JSON字符串 }
→ task.ValidateAndNormalizePayload(asynqTaskType, payload)
→ Handler 实现了 PayloadValidator?
→ 是:调用 ValidatePayload(),校验 + 标准化
→ 否:直接透传
→ task.DispatchTask(validatedPayload) 存入 DB + 入队 Asynq
→ ProcessTask → handler.Execute(payload) → handler 内 json.Unmarshal 使用参数
关键点:Admin dispatch handler (routers.go) 是通用的,不需要按任务类型写 if 分支。新增带参数的任务时,只需在 Handler 上实现 ValidatePayload 方法即可。
注意:ValidateAndNormalizePayload 依赖 Handler 已注册。新增任务后必须更新 internal/task/handlers/register.go,否则 Admin API 不会执行该任务的 PayloadValidator。
AppendLog 使用指南
task.AppendLog(ctx, format string, args ...interface{}) 在 TaskHandler.Execute 内部调用,将日志追加到 TaskExecution.Log 字段。管理端 API 可查看完整执行日志。
日志格式:每行自动添加 [HH:MM:SS] 时间前缀,使用 SQL COALESCE(log, '') || ? 拼接到 log 字段末尾。
何时记录日志:
| 时机 | 示例 |
|---|---|
| 任务开始时 | task.AppendLog(ctx, "开始扫描,阈值: %s", threshold) |
| 参数解析成功后 | task.AppendLog(ctx, "开始发送邮件到: %s, 主题: %s", req.To, req.Subject) |
| 批量处理的每一批次 | task.AppendLog(ctx, "本批次处理 %d 条记录", len(batch)) |
| 关键中间状态 | task.AppendLog(ctx, "已删除对象 %s", filePath) |
| 遇到可继续的错误时 | task.AppendLog(ctx, "清理文件失败 [ID:%d]: %v", id, err) |
| 任务完成时 | task.AppendLog(ctx, "共处理 %d 个,成功 %d 个", total, success) |
降级行为:如果 ctx 中没有 taskID(比如 Scheduler 触发的任务没有数据库记录),AppendLog 会降级为 logger.InfoF 输出到应用日志,不会报错。
不要过度记录:避免在循环中对每条记录都追加日志(尤其是处理量很大的场景),可以在批次级别记录摘要。每个 AppendLog 都会执行一次数据库 UPDATE。
框架自动处理的事项
开发者不需要关心以下内容,全部由框架透明处理:
- 参数校验分发:
ValidateAndNormalizePayload自动检测 Handler 是否实现PayloadValidator,实现了就调用,否则透传 - 记录创建:
DispatchTask自动创建TaskExecution记录,生成唯一TaskID(格式{triggeredBy}_{taskType}_{snowflakeID}) - 状态流转:
pending→running→succeeded/failed - 耗时统计:自动记录
StartedAt、FinishedAt、Duration(毫秒) - 错误记录:失败时自动存储
ErrorMessage - 结果存储:成功时自动存储
TaskResult.Message和TaskResult.Detail - OTel 追踪:自动创建 Span,记录任务类型、Payload 大小、TaskID
- 重试计数:手动重试时自动递增
RetryCount,校验RetryCount < MaxRetry - 队列路由:根据
TaskMeta.Queue投递到对应优先级队列 - 前端表单渲染:
ListTaskTypesAPI 返回DispatchableTasks(含Params),前端据此动态渲染参数表单
重试机制
项目中存在两层重试机制,互不干扰:
Asynq 自动重试:ProcessTask 返回 error 时,Asynq 根据入队时设置的 MaxRetry 自动重试。但 TaskExecution 记录在第一次失败时已标记为 failed,后续重试会覆盖同一条记录。
管理端手动重试:通过 POST /api/v1/admin/tasks/executions/{id}/retry 触发。会创建一个全新的 TaskExecution 记录(新 TaskID retry_{count}_{originalTaskID}),RetryCount 递增,Payload 原样复制。校验条件:状态必须为 failed、Retryable 为 true、RetryCount < MaxRetry。
参考:现有任务处理器
internal/apps/upload/tasks.go—CleanupUnusedUploadsHandler:无参数任务,展示游标分页批量处理、每批次 AppendLog、事务内操作、单条失败 continue 不终止。internal/apps/user/tasks.go—SendEmailHandler:带参数任务,展示 Payload 结构体定义、PayloadValidator实现(ValidatePayload用于校验+标准化)、Execute中 json.Unmarshal 使用参数。
测试与验证
新增或调整异步任务后,至少做以下验证:
- 单测 Handler 本身:无参数任务验证业务结果;带参数任务额外覆盖
ValidatePayload的成功、非法 JSON、缺失必填字段。 - 单测 Admin dispatch:
POST /api/v1/admin/tasks/dispatch对合法 payload 返回 200,对非法 payload 返回 400,并包含清晰错误信息。 - 单测 Retry:可重试失败任务应创建新的
TaskExecution,达到MaxRetry、非 failed 状态、Retryable=false都应拒绝。 - 跑目标包测试:
go test ./internal/task ./internal/apps/admin/task ./internal/apps/<module>
- 最后跑全量测试:
go test ./...
测试环境中如需下发/重试任务,使用 miniredis 初始化 task.AsynqClient;不要把 internal/task 依赖塞回通用 internal/testhelper,否则容易让 model/task 测试产生 import cycle。