资讯动态

Egg.js插件机制与定时任务Schedule:从公共逻辑复用到自动化运维

发布时间:2026/10/14 21:27:31 来源:尧图企业网站定制
今天是《15天学完eggjs》系列的第9天。如果你是从第一天跟过来的应该已经跑通了项目搭建、路由、控制器、Service分层、中间件和基础数据库操作一个能应付日常CRUD的eggjs服务已经能拿得出手了。但从第9天开始我们会从“会写接口”进入“会写框架级代码”的阶段今天要啃的是两个硬骨头插件机制和定时任务Schedule。前者解决“公共逻辑怎么优雅地复用”后者解决“脏活累活能不能让程序自己定点干”。这两个东西几乎每个生产项目都会遇到也是我把它们安排在学习路线中间的核心理由前8天的知识足够支撑你理解它们而掌握之后后面6天去做单元测试和部署上线会顺手很多。1. 为什么第9天要停下来学“扩展能力”1.1 前8天打好的底子与今天的分界线先说清楚今天学的和之前学的到底有什么不同。前面几天我们写的东西本质上都是“业务代码”路由定义了这个URL该走哪个控制器控制器调Service拿数据Service再去操作数据库。这些代码的特点是它们解决的是“某一类具体业务”。而今天要搞的插件机制和定时任务解决的是“框架本身怎么帮你省事”的问题属于框架能力的二次封装。举个例子你就明白了。你在好几个项目里都写过类似的逻辑记录每个请求的耗时、把请求的User-Agent信息提取出来判断是手机还是电脑、给所有接口统一加一个签名校验。如果每个项目都复制粘贴一遍不是不行但后续修改会非常痛苦你会陷入“改一个文件要同时改三四个地方”的泥潭。插件机制就是用来干这个的把通用逻辑独立成一个模块不同项目只要装一下、配一下能力立刻就有了。而定时任务则更直觉一点业务做到一定规模总会有“每天凌晨清理过期数据”“每10分钟统计一次在线人数”“每周一早上生成报表”这类需求eggjs把它们统一收编成Schedule机制避免你自己去裸写定时器管理。再说直白一点前8天是“让项目跑起来”第9天是“让项目在更复杂的场景里活下来”。1.2 一套框架的自我修养五个扩展点一个都不能漏eggjs的扩展体系并不复杂核心思路可以用一句话概括在框架默认提供的几个对象上再挂上你自己的属性和方法。这几个对象就是Application、Context、Request、Response和Helper官方叫五个扩展点。Application全局应用对象可以挂全局配置、全局工具方法。比如你想在项目里能直接通过app.redisClient拿到Redis连接就在这里扩展。Context一次请求的上下文对象生命周期从请求进来到响应出去。适合挂“当前请求才需要”的方法比如从请求头里解析用户信息、判断是不是移动端。Request / Response分别代表请求和响应对象。在Request上扩展通常是为了方便读取请求数据比如拿到真实的客户端IP在Response上扩展一般是为了统一输出格式。Helper通用辅助函数可以在helper里写格式化时间、生成随机验证码这类工具函数在Context里通过this.helper访问。为什么要刻意区分这五个对象因为在框架设计里不同对象有不同的生命周期和语义。如果你把app上的全局方法挂到了ctx上会导致每个请求都白白持有了一份全局逻辑内存和逻辑边界都会出问题。我在带新人的时候经常看到他们把东西一股脑全放在ctx上短期能跑项目一复杂就乱了。第9天把五个扩展点分清楚后面的代码会干净很多。顺便提一句插件里的中间件也是同样的套路中间件本质上是“洋葱模型”里的一层处理逻辑插件只是把中间件和扩展方法、配置打包成一个可复用的整体而已。2. 从零写一个真实能用的eggjs插件2.1 目录结构、命名规则与 package.json 里的隐藏字段很多人第一次接触插件开发都会懵一下因为网上的资料往往是先讲“怎么用插件”很少讲“怎么造插件”。其实eggjs的插件本身也是一个eggjs应用只是结构更精简。我建议你用一个独立目录来练手不要直接塞进业务项目里。目录结构长这样egg-demo-plugin/ ├── app/ │ ├── extend/ │ │ ├── application.js │ │ └── context.js │ └── middleware/ │ └── requestTime.js ├── config/ │ └── config.default.js ├── package.json └── index.js注意这里的关键是package.json里有一个隐藏字段eggPlugin框架就是在加载插件时先读这个字段才知道这个包是个eggjs插件。最简配置如下{ name: egg-demo-plugin, version: 1.0.0, eggPlugin: { name: demo } }eggPlugin.name相当于插件的唯一标识你在业务项目的config/plugin.js里就是用这个名字来开关插件。这一步容易栽坑如果你漏写了eggPlugin字段框架加载时根本不会把它识别为插件运行时会直接报错或者说“插件未找到”。我第一次踩这个坑时查了半小时最后发现是package.json写错了希望你不用经历这个过程。2.2 给 context 和 application 各挂一个工具方法安装插件到业务项目之后扩展点上的方法会直接合并到对应对象上。我们在插件里给Context扩展一个判断移动端的方法// app/extend/context.js const MOBILE_REG /mobile|android|iphone|ipad/i; module.exports { get isMobile() { const ua this.get(user-agent) || ; return MOBILE_REG.test(ua); }, };再给Application扩展一个读取配置名的方法// app/extend/application.js module.exports { get appName() { return this.config.appName || egg-default-app; }, };为什么这里用get关键字写成getter而不是普通函数因为这样调用方可以直接写ctx.isMobile而不是ctx.isMobile()。对于“根据当前状态计算出一个值”的场景getter语义更自然调用时少一对括号也避免了你定义了方法但忘了调用的小问题。这是一个很小的细节但很多老手看别人代码时就是这么一眼判断编码习惯的。业务项目里启用插件// config/plugin.js exports.demo { enable: true, package: egg-demo-plugin, };随后你在任意Controller里就可以这样用// app/controller/demo.js class DemoController extends Controller { async index() { const { ctx } this; ctx.body { name: ctx.app.appName, isMobile: ctx.isMobile, }; } }这套机制的好处是插件提供的扩展点和业务项目的业务代码天然隔离。你不需要在业务里写require(某个插件)再手动调用框架已经把对象注入好了。对于团队协作来说这种“声明式使用”的方式最省心新人只要看配置就知道项目挂了哪些能力。2.3 在插件里塞一个中间件并控制它跑在所有请求之前除了挂方法插件里最常见的还有中间件。我们在插件里加一个统计请求耗时的中间件// app/middleware/requestTime.js module.exports (options, app) { return async function requestTime(ctx, next) { const start Date.now(); await next(); const duration Date.now() - start; ctx.logger.info([demo-plugin] request duration: ${duration}ms); }; }; // config/config.default.js exports.middleware [requestTime];不需要在插件的入口手动注册eggjs加载插件时会自动扫描插件里的app/middleware目录并把你配置的中间件列表里的名字和文件名对应起来。这里的options可以读取你在业务项目里给这个插件单独传的参数比如阈值、开关等。做个简单扩展// 业务项目 config/config.default.js exports.demo { timeThreshold: 100, };然后在插件中间件里读取module.exports (options, app) { return async function requestTime(ctx, next) { const start Date.now(); await next(); const duration Date.now() - start; if (duration options.timeThreshold) { ctx.logger.warn([demo-plugin] slow request: ${duration}ms); } }; };插件中间件还有两个坑值得说。第一是加载顺序插件中间件本质上会和业务中间件一起按顺序执行。如果你希望某个中间件最早跑可以在配置里用beforeMiddleware指定它必须在某个中间件之前即使那个中间件来自另一个插件。第二是options的合并规则业务项目配置里的同名键会覆盖插件里的默认值所以插件作者在config.default.js里要写全默认值否则使用者一旦没传就拿到undefined排查起来很费劲。3. 定时任务Schedule让应用自己干活3.1 为什么官网坚持用“Schedule模式”而不是叫你开定时器先聊一个很实际的问题很多人第一次看到Schedule时觉得多余——“不就是定个闹钟吗我这setInterval不是一样能跑”最大的区别在进程模型上。eggjs应用在生产环境大概率不是单进程跑的框架默认会启动多个Worker进程。如果你在代码里随手写一个setInterval恭喜你每个Worker进程里都会有一份定时器同一个任务会执行多次轻则日志重复重则数据被同时写好几遍。Schedule机制把“谁来执行任务”这件事规范化了你在声明任务时指定执行策略框架会帮你按策略调度而不是让你自己在多进程环境里裸奔。第二个区别是生命周期管理。用setInterval时要自己考虑进程退出时怎么清理而Schedule和eggjs的进程生命周期绑定在一起进程重启、优雅退出时框架会处理定时任务的释放。这一点在发布上线时特别重要不然每次发版你都会担心“那个定时器是不是变成了僵尸循环”。3.2 两种触发姿势interval 与 cron六段表达式别踩坑eggjs的Schedule文件统一放在app/schedule目录下每个文件就是一个任务。触发方式有两种。第一种是interval适合固定间隔的任务// app/schedule/cleanCache.js module.exports { interval: 1h, type: all, immediate: false, async task(ctx) { // 你的业务逻辑 }, };这里的interval可以用字符串形式支持1m分钟、1h小时、1d天这类写法同时也支持毫秒数字。但我不建议用数字因为可读性太差半年后你再看代码根本想不起来这个3600000到底是多少小时。第二种是cron适合“每天几点”“每周几”这种更精细的调度module.exports { cron: 0 0 3 * * *, type: worker, async task(ctx) { // 每天凌晨3点执行 }, };这里有个很多人第一次用就会踩的坑egg-schedule的cron表达式是六段的顺序是“秒 分 时 日 月 周”和Linux系统里常见的五段crontab不一样。也就是说如果你想表达“每天凌晨3点”必须老老实实写成0 0 3 * * *而不是Linux里的0 3 * * *。我见过不止一个同事因为少写了一位数导致任务跑得惊天动地要么每分钟触发一次要么压根不触发。凡是看着像cron但行为诡异的任务第一件事永远先数长度。表达式里还可以用*/15这种步长比如0 */10 * * * *表示每10分钟执行一次。注意第一位的秒位也是可以配置的可以错峰比如一批任务同时执行时可以给不同任务设置不同的秒偏移避免同一秒把所有任务都打出去把数据库瞬时负载拉满。3.3 一个真实任务每天凌晨清理过期文件光说不练没意思我来写一个实际场景清理服务器的临时上传目录。假设项目里用户上传文件后会生成临时文件超过24小时没被使用的应该删除。// app/schedule/cleanTempFiles.js const fs require(fs/promises); const path require(path); module.exports { cron: 0 10 4 * * *, // 每天凌晨4点10分 type: worker, async task(ctx) { const dir path.join(ctx.app.baseDir, uploads/temp); const cutoff Date.now() - 24 * 60 * 60 * 1000; const files await fs.readdir(dir); for (const filename of files) { const filePath path.join(dir, filename); const stat await fs.stat(filePath); if (stat.mtimeMs cutoff) { await fs.unlink(filePath); ctx.logger.info([cleanTempFiles] removed ${filename}); } } }, };这里面有两点想强调。第一task(ctx)里拿到的ctx也是完整Context服务、Helper、扩展方法都能用所以定时任务里调Service、查数据库、发请求都是顺理成章的。你看很多人的第一个定时任务喜欢把业务逻辑全塞在task里文件越来越长其实应该像Controller一样把复杂逻辑拆到Service里Schedule只负责“到点触发”业务归属重新交回Service层。第二这个任务我用的是type: worker含义是这台机器上只会派一个Worker进程去执行。如果只有一台服务器选worker就够了不用选all去所有Worker里重复跑。3.4 多进程下的“重复执行”用 single 避免重复type: worker解决的是单个进程内的重复执行但还没解决多台服务器场景下的重复执行问题。假设你部署了两台机器每台机器都会拉起来一个Worker进程那么即使你配了worker两台机器上仍然会各执行一次。对于“清理临时文件”这种任务重复执行问题不大顶多多删几次已经删掉的文件但对“每天给用户发短信通知”这种任务重复发短信就是事故了。解决思路一般是加一个分布式锁比如基于Redis的SETNX拿到锁的实例才执行本批次任务。以我这边的经验最简单的做法是写一个带锁的Service方法// app/service/notify.js class NotifyService extends Service { async runWithLock(taskName, fn, expireSeconds 60) { const lockKey schedule_lock_${taskName}; const locked await this.app.redis.set(lockKey, Date.now(), EX, expireSeconds, NX); if (locked) { try { await fn(); } finally { await this.app.redis.del(lockKey); } } else { this.ctx.logger.info([schedule] skip ${taskName}, lock not acquired); } } }然后在Schedule的task里调用async task(ctx) { await ctx.service.notify.runWithLock(dailyNotify, async () { // 具体发短信逻辑 }); }这样即便多个实例同时起床最终只会有一台拿到锁去执行别的实例看一眼锁存在就收工。这套逻辑在正式环境里很重要是区分“Demo代码”和“生产代码”的明显分水岭。4. 第9天最容易踩的5个坑4.1 定时任务完全不执行先查时间错了还是类型错了定时任务不触发这是新手最常见的问题原因通常是下面几种。时间单位看错了0 0 3 * * *如果你少写一段编译时会直接不认。调任务的时候建议把cron先改成一个很不正常的时间比如* * * * * *每秒执行加日志确认能跑通再改成目标时间可以快速区分是“任务逻辑问题”还是“表达式问题”。type配错了如果配agent任务是在agent进程里跑的日志要去agent日志里看而不是看应用的运行日志。很多人用agent类型之后以为任务没跑实际是跑在了另一个进程日志不在同一个文件里。任务逻辑如果依赖Service在agent里一般没有完整的Context调用链这时候要么改成worker要么在agent里自己处理数据。还有就是环境问题如果你设置了env选项比如只让任务在prod环境执行本地开发环境自然不跑。在开发机上验收任务前先确认不是环境限制把任务过滤了。4.2 本地调试想要马上触发不要干等时间本地开发时最烦的就是定了一个“每天凌晨3点”的任务然后你想马上验证。我的土办法是写一个临时的调试路由专门用来手动触发目标任务// app/router.js router.get(/__debug/run-schedule, async (ctx) { const schedule require(../app/schedule/cleanTempFiles); await schedule.task(ctx); ctx.body ok; });这里直接把Schedule文件里定义的task函数拿出来调用用当前请求的Context近似替代定时任务里的Context。大多数任务里的逻辑都能这样跑通唯一要注意的是任务依赖的ctx如果涉及定时执行特有的字段可能有差异但作为本地验证完全够用。网关或者运维层面记得把这个调试路由禁止在生产暴露不然有点危险。顺手还能在本地起个一次性脚本直接构造匿名Context来调用逻辑和这个路由是一致的。4.3 插件在本地开发怎么边改边用造插件的时候你肯定不想每次改完插件代码都把包重新发布到npm然后再到业务项目装一遍那样一天能浪费半小时。eggjs官方推荐的做法是本地npm link。在插件目录执行npm link再到业务项目目录执行npm link egg-demo-plugin之后任何插件改动都会直接同步到业务项目的node_modules里改完插件重启应用就能测。但npm link有个坑如果你在业务项目里重新执行npm install有可能把link关系冲掉悄悄变成从npm仓库拉下来的正式包而你本地又没发布过测试时就会看到“插件不存在”。遇到这种情况不要慌重新link一下就好。另外如果插件和应用安装的是同一份eggjslink时要注意依赖的egg版本一致性最好在插件里把peerDependencies里的eggjs版本写清楚避免在运行时出现“两个egg”的灵异问题。4.4 中间件顺序在插件体系里容易失控当项目里的插件多了之后你会发现插件内部中间件的执行顺序并不一定是你预想的“后引入的后执行”。一套比较好的控制方式是如果插件A的中间件必须在插件B的中间件之前执行那么在插件A的配置文件里显式指定beforeMiddleware把B的中间件名列出来。不要指望包名排序或者加载顺序天然正确那些都是黑盒有一段时间会很稳定但一旦加了一个插件就全乱了。显式声明顺序是唯一可控的做法虽然写起来啰嗦一点但排查成本最低。4.5 写Schedule任务时别让进程Crash掉定时任务的task函数里一旦抛出未捕获的异常框架会记录错误日志但任务进程本身不能随意退出。很多人在任务里写业务没做try/catch结果某个半夜任务遇到脏数据直接抛错整个Worker进程白屏重启用户请求跟着一起遭殃。正确做法是在task顶层包一层try/catch或者至少用上eggjs提供的统一错误日志保证异常被接住后只影响本次任务不影响整个进程。还有一个细节任务处理的数据量如果很大建议在任务内自行分批处理不要一次性把所有数据load进内存。我之前写过一个批量同步任务数据到10万条时一度把内存干到500M后面改成游标分页处理才恢复正常。5. 今天之后你应该掌握的能力边界到这里第9天的内容就结束了。我个人在带项目时会拿两个标准来判断一个人是不是真的吃透了今天的知识第一能不能在10分钟里从零搭一个插件把一段通用逻辑包进去并在两个不同项目里复用第二能不能说清楚一个定时任务在单机单进程、单机多进程、多机多实例三种场景下各自会执行几次以及怎么解决重复执行问题。如果你能回答这两个问题今天的任务就算过关了。后面第10天开始我会把重心转向单元测试和工程化相关的内容到时候你会发现今天写的插件和Schedule正好是单测的好素材。定任务时写的带锁Service、插件里的中间件顺序这些都能写成“可证明行为符合预期”的测试用例。这里先留一个问题给你如果有一个定时任务必须在每天高峰期前跑完但某次执行失败后你想在下一分钟自动补偿重跑你的cron表达式应该怎么设计我们下次更新见思路请务必写在评论区我会把有价值的方案统统放进第10天的文章里一起点评。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑