Medusa 定时任务(Scheduled Jobs)编写与自动加载机制全解析
【免费下载链接】medusaThe world's most flexible commerce platform for agents and developers项目地址: https://gitcode.com/GitHub_Trending/me/medusa
导读
Medusa 框架内置了一套基于工作流(Workflow)的定时任务体系,开发者只需在任意模块、插件或自定义目录中放置一个导出了handler与config的 JS/TS 文件,框架启动时便会自动发现该文件、校验配置、注册为名为job-<name>的工作流,并按 Cron 表达式周期性执行。本文以框架 jobs 目录下的示例任务order-summary(见 fixtures 目录)为核心骨架,结合 JobLoader 实现 与其 单元测试,完整讲解任务文件格式、config 参数语义、加载过滤规则与调度执行原理,帮助你写出规范、可调试、可测试的 Medusa 定时任务。
一、一个最小定时任务长什么样
Medusa 中一个定时任务就是一个模块文件,默认导出异步处理函数handler,同时具名导出config配置对象。以框架自带的测试示例 order-summary.ts 为例:
import { MedusaContainer } from "@medusajs/types" export default async function handler(container: MedusaContainer) { console.log(`You have received 5 orders today`) } export const config = { name: "summarize-orders", schedule: "* * * * * *", numberOfExecutions: 2, }这个文件只有两个关键导出:
default(handler):任务的实际执行逻辑,接收container(MedusaContainer类型的依赖注入容器),可以通过container.resolve(...)获取数据库连接、模块服务、Logger 等任意注册资源,并支持异步。config:任务元信息,由name、schedule、numberOfExecutions等字段组成,决定任务注册名与调度策略。
值得注意的是,在框架的fixtures目录 中还放置了扩展名为.md、.txt的同名文件,其内容虽然是合法的任务代码,但加载器会将其静默忽略——这正是下一节要讲的扩展名过滤规则。因此实际项目中的定时任务必须使用.js或.ts扩展名。
二、config 配置项详解
根据 job-loader.ts 中的 CronJobConfig 类型定义 与加载器校验逻辑,config支持以下字段:
| 字段 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
name | string | 必填 | 任务名称。注册时会自动拼接为工作流名job-${name},例如summarize-orders会生成工作流job-summarize-orders |
schedule | string \| SchedulerOptions | 必填 | Cron 表达式字符串(如"* * * * * *",六段式);也可以直接传入一个包含cron、numberOfExecutions等字段的调度器选项对象 |
numberOfExecutions | number | 可选 | 限定任务的总执行次数。示例中的2表示该任务只会被调度执行 2 次,之后不再触发;用于一次性/限期任务的场景 |
这三个字段并非可有可无——加载器在注册前会执行严格的配置校验(validateConfig):
if (!config) { throw new MedusaError( MedusaError.Types.INVALID_ARGUMENT, "Config is required for scheduled jobs." ) } if (!config.schedule) { throw new MedusaError( MedusaError.Types.INVALID_ARGUMENT, "Cron schedule definition is required for scheduled jobs." ) } if (!config.name) { throw new MedusaError( MedusaError.Types.INVALID_ARGUMENT, "Job name is required for scheduled jobs." ) }从 源码 可以看到:config、schedule、name三者缺一不可,否则会抛出INVALID_ARGUMENT类型的MedusaError,且任一配置缺失都会导致任务无法注册。因此编写任务时请确保三个字段同时存在。
schedule 的两种传法
schedule既可以是字符串,也可以是对象。加载器在注册时会进行归一化处理:
const workflowConfig = { name: workflowName, schedule: isObject(config.schedule) ? config.schedule : { cron: config.schedule, numberOfExecutions: config.numberOfExecutions, }, }即:当schedule传入的是对象时,直接透传;当传入字符串时,自动包装为{ cron, numberOfExecutions }。上面的示例任务经过转换后,其调度配置等价于:
{ cron: "* * * * * *", numberOfExecutions: 2, }测试 register-jobs.spec.ts 恰好验证了这一结果——注册完成后,通过WorkflowManager.getWorkflow("job-summarize-orders")能取到工作流,且其options.schedule精确等于{ cron: "* * * * * *", numberOfExecutions: 2 }。
三、任务是如何被自动发现的:JobLoader 加载链路
3.1 资源发现与文件过滤
定时任务的自动发现由 JobLoader 完成,它继承自 ResourceLoader 基类。JobLoader.load()会调用基类的discoverResources(),递归扫描传入的sourceDir,并对每个候选文件执行自定义过滤:
customFiltering = (entry: Dirent) => { const parsedName = parse(entry.name) return ( !entry.isDirectory() && (allowIndex || parsedName.name !== "index") && !parsedName.base.endsWith(".d.ts") && !entry.path.includes("__tests__") && [".js", ".ts"].includes(parsedName.ext) && !this.#excludes.some((exclude) => exclude.test(parsedName.base)) && !exclude.some((exclude) => exclude.test(parsedName.base)) ) }也就是说,只有同时满足以下条件的文件才会被当作任务加载:
- 扩展名必须是
.js或.ts——这是 fixtures 中的order-summary.md、order-summary.txt不会被加载的根本原因; - 文件名不能是
index; - 不以
.d.ts结尾; - 路径中不能包含
__tests__目录; - 不以
_开头(/^_[^/\\]*(\.[^/\\]+)?$/),也不以.spec.[jt]s、.test.[jt]s结尾。
这套规则保证了 README、测试文件、辅助模块等不会误注册为定时任务。对应的行为在测试中得到了直接验证:
it("should not load non js/ts files", async () => { const jobLoader: JobLoader = new JobLoader( join(__dirname, "../__fixtures__/plugin/jobs-with-other-files"), container ) await jobLoader.load() const workflow = WorkflowManager.getWorkflow("job-summarize-orders") expect(workflow).toBeUndefined() })由于jobs-with-other-files目录下只有.md与.txt文件,加载后工作流job-summarize-orders未被注册(toBeUndefined()),从侧面印证了扩展名过滤的确定性。
3.2 校验、注册与工作流化
通过过滤的文件会进入onFileLoaded流程,其类型签名要求导出default(handler)与config两个成员:
protected async onFileLoaded( path: string, fileExports: { default: CronJobHandler config: CronJobConfig } ) { if (isFileSkipped(fileExports)) { return } this.validateConfig(fileExports.config) this.logger.debug(`Registering job from ${path}.`) this.register({ path, config: fileExports.config, handler: fileExports.default, }) }其中isFileSkipped来自 define-file-config.ts,它会检查导出中是否带有框架约定的跳过标记MEDUSA_SKIP_FILE——若任务文件显式导出该标记,则会被跳过不注册,为开发者提供了一种"临时停用任务而不删除文件"的手段。
通过校验后进入register方法,加载器会将 handler 包装为一个Step,再用createWorkflow注册为工作流:
const workflowName = `job-${config.name}` const step = createStep( `${config.name}-as-step`, async (input: ScheduledJobWorkflowInput | undefined, stepContext) => { const { container } = stepContext const context: ScheduledJobContext = { scheduledFor: input?.scheduledFor ? new Date(input.scheduledFor) : new Date(), } try { const res = await handler(container, context) return new StepResponse(res, res) } catch (error) { this.logger.error( `Scheduled job ${config.name} failed with error: ${error.message}` ) throw error } } )这段代码揭示了几个关键点:
- 每个定时任务最终都被注册为名为
job-<name>的工作流(Workflow),复用 Medusa 的工作流调度与执行基础设施; - handler 的执行发生在 step 内部,
container由工作流上下文注入,因此任务内可以直接使用容器解析任何服务; - 任务抛出的异常会被捕获并记录为
Scheduled job <name> failed with error: ...,随后重新抛出,便于在日志中定位失败任务。
3.3 scheduledFor:任务触发时间上下文
从 types.ts 可以看到,handler 的完整签名是:
export type ScheduledJobHandler = ( container: MedusaContainer, context?: ScheduledJobContext ) => Promise<unknown> export type ScheduledJobContext = { scheduledFor: Date } export type ScheduledJobWorkflowInput = { scheduledFor: string }handler 的第二个参数context.scheduledFor表示"本次任务计划执行的时间点"。加载器在调度执行时会从工作流输入中取出scheduledFor并转换为Date;若未提供,则默认取当前时间new Date()。
这一机制的语义验证可见 fixtures 中的 scheduled-for.ts:它的 handler 直接把context.scheduledFor写入全局变量;对应的测试通过LocalWorkflow.run(ulid(), { scheduledFor }, { __type: MedusaContextType })手动触发工作流job-capture-scheduled-for,最终断言global.__medusaScheduledForTest等于传入时间戳转换后的Date对象(见 register-jobs.spec.ts)。如果你的业务需要"按计划时间戳"而非"实际运行时刻"来统计数据(例如补跑某天的订单汇总),务必使用context.scheduledFor。
四、从插件加载:sourceDir 的传参方式
JobLoader的构造函数接受sourceDir: string | string[],支持同时从多个目录发现任务:
constructor(sourceDir: string | string[], container: MedusaContainer) { super(sourceDir, container) }基类 ResourceLoader.discoverResources 会把目录统一规范化为数组,并对每个目录执行access(sourcePath)存在性检查——若目录不存在,会打印No job to load from <path>. skipped.并跳过,而不是报错。这意味着:
- 插件可以把任务文件放在自己的
src/jobs目录下,框架启动时即可自动发现; - 即使某个插件没有 jobs 目录,加载流程也不会中断;
- 多个 sourceDir 之间互不影响,各自独立发现与注册。
测试should registers jobs from plugins即演示了以join(__dirname, "../__fixtures__/plugin/jobs")作为 sourceDir,从"插件形态"的目录中加载order-summary与scheduled-for两个任务,并成功注册工作流job-summarize-orders的过程。
五、任务编写与调试实战建议
结合上述机制,编写一个规范的 Medusa 定时任务可以参考以下实践:
1. 保持文件结构清晰建议将任务统一放在项目的src/jobs(或插件的同名字目录)下,一个文件一个任务,文件名与config.name保持对应关系(如order-summary.ts对应summarize-orders),便于排查与维护。
2. 使用完整的六段 Cron 表达式示例中使用的"* * * * * *"是六段式 Cron(秒 分 时 日 月 周),开发调试时可用它让任务每秒触发;生产环境请根据实际频率改写为合理的表达式,避免高频空转。
3. 善用 numberOfExecutions 控制执行次数对于一次性数据修复、迁移类任务,可以通过numberOfExecutions: 1让任务只执行一次,配合schedule即可实现"启动后立即执行且仅执行一次"的语义,无需额外的状态标记。
4. 通过容器获取服务,而非直接 importhandler 的第一个参数是MedusaContainer,应当通过container.resolve(...)获取订单服务、库存服务或 Logger 等资源,这样既能享受依赖注入与测试 mock 的便利,也符合框架的解耦设计。
5. 遵守扩展名与命名约束
- 文件必须为
.js/.ts; - 不要命名为
index、.d.ts,不要放入__tests__目录,不要以_开头或以.spec.ts/.test.ts结尾,否则会被自动过滤; - 若希望临时停用某个任务,可以导出
MEDUSA_SKIP_FILE标记(详见 define-file-config.ts),而不是删除文件或破坏扩展名。
6. 利用日志定位失败任务当 handler 抛出异常时,加载器会输出Scheduled job <name> failed with error: ...,建议在 handler 内部也使用注入的 Logger 记录关键业务步骤,便于区分"调度失败"与"业务失败"。
六、机制总结
回顾整条链路,一个 Medusa 定时任务的生命周期是:
- 发现:
JobLoader递归扫描 sourceDir,按扩展名与命名规则过滤出合法的.js/.ts任务文件(非 JS/TS 文件如.md、.txt被忽略); - 校验:
validateConfig强制要求config、schedule、name三者齐全,缺失即抛INVALID_ARGUMENT; - 注册:handler 被包装为 step,与归一化后的调度配置一起通过
createWorkflow注册为job-<name>工作流; - 执行:工作流调度器按 Cron 触发 step,向 handler 注入
container与包含scheduledFor的上下文,numberOfExecutions控制累计执行次数; - 失败处理:异常被记录日志后重新抛出,等待调度器与监控体系进一步处理。
这套设计让定时任务与 Medusa 的工作流引擎深度统一,开发者无需关心底层的调度实现,只需遵循"default handler + config"的约定编写任务文件,即可获得自动发现、自动注册、可单测(通过WorkflowManager.getWorkflow断言注册结果)的完整能力。
【免费下载链接】medusaThe world's most flexible commerce platform for agents and developers项目地址: https://gitcode.com/GitHub_Trending/me/medusa
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考