定下“15天学完 Egg.js”这个目标的时候我给自己打了不少气。Egg.js 是阿里开源的企业级 Node.js 框架里面有 MVC、插件、多进程、定时任务这些开箱即用的能力对于做中后台服务来说学习曲线其实很友好。今天是我这门“突击课”的第12天时间已过大半基础部分基本过完接下来就是要啃那些让 Egg.js 变得真正好用的机制了。今天的关键词是中间件和插件这两个概念是框架的“灵魂开关”搞懂它们你就能把很多通用逻辑从 Controller 里解放出来也让自己的代码不再越写越臃肿。这篇记录是我学习过程的复盘也是一个可以直接照着练的实操手册。如果你和我一样刚开始接触 Egg.js或者写了一些接口但觉得路由里各种重复校验太多今天的内容会很对你的胃口就算你已经能熟练写中间件后面关于插件封装和坑位排查的部分也能帮你少走点弯路。1. 第12天该学什么先从15天计划说起1.1 前11天的路线图这15天计划不是随便拍脑袋定的我的目标是15天之后能独立用 Egg.js 起一个带完整业务逻辑的服务端应用并且懂升级、排查、部署。前11天我基本是按“能用→好用→完善”的节奏在推进第1天搭建 Egg.js 项目跑通egg-bin dev看完目录结构。第2-3天写路由、Controller、Service实现了一个简单的用户管理 RESTful API。第4天引入参数校验体系在 controller 入口统一校验请求体。第5-6天接 MySQL用 egg-sequelize 定义 Model做迁移和基础 CRUD。第7天实现登录和 Token 发放把 session/Token 的状态管理搞清楚。第8天看 egg-security 默认安全头、CSRF、白名单等配置理解框架替你挡住了什么。第9天用 egg-schedule 写了定时任务。第10天捋了一遍多进程模型知道了 agent 和 worker 的关系。第11天补单元测试用 egg-mock 对部分接口做了回归。走到第12天接口能写、数据能存、测试能跑但这离“真正好用”还有距离。日志怎么统一记录权限怎么统一校验跨域、限流、错误码怎么拆成公共能力这些如果全部写在 controller 里几十个接口下来就是灾难。1.2 为什么把中间件和插件放在第12天因为中间件和插件是 Egg.js 处理公共逻辑的标准方式也是框架可插拔设计的最直观体现。中间件解决的是“在请求前后统一做点事”的问题比如打日志、校验登录、解析 header、限流插件则是把一堆相关能力打包成一个独立的模块比如 egg-sequelize、egg-jwt、egg-cors都是插件形态。只有认清这两个概念你才不会重复造轮子也不会把十段重复代码分散在十几个 controller 里。所以今天的目标很明确一是彻底理解中间件的洋葱模型二是能手动写一个可复用的插件。下面我会先讲原理再做实战最后把容易踩的坑都整理出来。2. 中间件机制像洋葱一样处理请求2.1 洋葱模型到底是个啥中间件的执行模型叫做“洋葱模型”它是理解 Egg.js 中间件的基础。想象一下请求从最外层剥进核心业务处理完之后再一层层把响应带出来。假设有两个中间件 A 和 B请求到达的顺序是进入中间件 A执行await next()之前的逻辑。中间件 A 调用next()请求进入中间件 B。B 里的逻辑执行完后继续往核心控制器走。控制器返回后响应重新回到 B 里面执行 B 在next()之后的代码。之后响应再回到 A执行 A 在next()之后的代码。第一次接触会觉得有点绕但用“剥洋葱”来记就很简单一层一层往内进再一层一层往外出。中间件里的代码可以分成两段await next()前面是“入境”时做的事情后面是“出境”时做的事情这正是日志计时的核心用法请求进入时记录开始时间业务处理完返回时计算总耗时。为什么框架要选择洋葱模型而不是最简单的顺序执行因为一个完整的请求过程有两个时机需要处理在业务执行之前要做校验、鉴权、加参数在业务执行之后要做日志记录、耗时计算、异常兜底。如果模型只支持“按顺序一个个跑完”你很难用同一个中间件同时覆盖这两个时机。洋葱模型把“前处理”和“后处理”都放进了同一个函数里靠await next()把它们分隔开这是整套机制设计最巧妙的地方。要注意的是如果某个中间件里没有调用await next()请求链条就会在这里断掉。比如权限校验不通过时中间件直接返回 401不往下走这是合理的但如果你是做日志中间件忘了await next()后面的控制器永远执行不了这就要排查好久。2.2 十分钟写一个可用的日志中间件在 Egg.js 里写中间件非常固定在app/middleware目录下创建一个文件导出的是一个工厂函数。我们以accessLog.js为例use strict; module.exports (options, app) { return async function accessLog(ctx, next) { const start Date.now(); await next(); const cost Date.now() - start; ctx.logger.info([access] ${ctx.method} ${ctx.url} ${ctx.status} - ${cost}ms); }; };这里有几个要点第一外层函数接收options和app。options是你在配置文件里给这个中间件传入的参数app是应用实例可以访问app.config、app.logger等。第二内层函数接收ctx和nextctx是当前请求上下文next是请求下一层中间件的函数。第三日志语句写在await next()之后才能拿到ctx.status和完整的耗时。然后需要在config/config.default.js里开启它exports.middleware [accessLog];如果想让这个中间件支持阈值参数比如超过 1000ms 的慢请求单独打一条日志可以这样配置exports.accessLog { slow: 1000 };再回到中间件代码里读取options.slow当cost options.slow时用ctx.logger.warn输出。这样中间件就从“固定功能”变成了“可配置功能”这也是框架提倡的写法。这里的工厂模式有没有必要很有必要。框架在启动时并不知道每个中间件需要什么配置它只能统一做一件事找到app/middleware下的文件按照约定调用这个工厂函数并把配置值作为第一个参数传进去。如果你直接导出中间件函数框架就很难把可变的options注入进去。2.3 中间件的三种常用启用方式第一种方式是全局启用也就是上面写的exports.middleware [accessLog]。一旦配置所有接口都会走这个中间件适合日志、跨域、安全这类需要全覆盖的逻辑。第二种方式是路由局部启用。如果在 controller 里只有某个业务分组需要做权限校验就可以直接挂在路由上module.exports (app) { const { router, controller, middleware } app; router.post(/login, controller.auth.login); router.get(/private/info, middleware.auth(), controller.private.info); };这里的middleware.auth()会返回一个中间件函数它只作用于当前这条路由不会影响其他接口。第三种方式是在插件中声明启用。你可以把中间件写到插件内部然后在插件自己的config/config.default.js里配置exports.middleware应用启用插件后这个中间件就自动生效。这比在应用入口写一堆配置更干净也方便复用。三种方式的使用场景可以简单对比如下启用方式作用范围典型场景全局中间件所有请求日志、跨域、安全头、限流路由局部中间件单条或个别路由权限校验、文件上传限制插件内置中间件启用插件后跟随插件第三方能力整体接入提示全局中间件数组里的名字要和app/middleware下的文件名保持一致。比如文件名是access_log.js的时候很容易在配置里纠结到底是写access_log还是accessLog我一般统一用小驼峰文件名accessLog.js配置里也写accessLog这样最省心。3. 插件机制让功能真正变成“积木”3.1 中间件和插件差在哪很多人会把插件理解成“大号中间件”其实不对。中间件本质是一段请求处理链路上的逻辑而插件是一个完整的模块它可以包含中间件、扩展方法、Service、脚本、配置甚至还可以依赖别的插件。用类比来说中间件像是一道通关闸口插件则像一个完整的“小程序包”闸口只是它提供的一项能力。我们看几个熟知的插件egg-sequelize 不只是一个数据库中间件它需要初始化连接、提供 Model 基类、注入不同模型的访问器egg-jwt 要负责密钥配置、签名和验证两套逻辑egg-cors 要处理跨域响应头。这些能力只靠中间件很难表达清楚因为中间件能访问的信息只有ctx和配置参数而插件可以在应用启动阶段做初始化、向 context 扩展属性、注册 service甚至定义自己的中间件。从项目组织角度看插件本身就是一个小型 Egg.js 应用它的目录结构可以复用框架的加载规范。这也是为什么 Egg.js 生态里第三方能力都以插件形式存在而不是让你复制中间件代码。中间件与插件的区别可以用这张表概括维度中间件插件本质请求处理链路上的函数完整的功能包可包含内容只能处理请求上下文中间件、扩展、Service、配置、依赖复用范围项目内或少量路由多项目、多应用共享复杂度低高典型例子日志、鉴权、限流egg-sequelize、egg-jwt、egg-cors3.2 手写一个本地插件egg-auth接下来我带你写一个最小的本地插件用来演示插件的基本结构。这个插件我取名egg-auth功能不强但结构完整你可以把它当作一个模板。先在app/plugin/下建一个插件目录app/plugin/egg-auth/ ├── package.json ├── config │ └── config.default.js └── app ├── middleware │ └── auth.js └── extend └── context.jspackage.json里至少需要有名字和主入口信息{ name: egg-auth, version: 1.0.0, egg: { framework: egg } }然后在项目的config/plugin.js中启用这个本地插件const path require(path); module.exports { auth: { enable: true, path: path.join(__dirname, ../app/plugin/egg-auth) } };最后是插件内部的内容。在插件里给ctx扩展一个currentUser属性use strict; module.exports { get currentUser() { return this.state.userId ? { id: this.state.userId } : null; } };context.js里的this就是当前的ctx这样在 controller 里可以直接通过ctx.currentUser拿到当前登录用户非常方便。至于插件里的auth.js中间件到底怎么写我在第4章完整展开这里先不重复。为什么不把插件直接发布成 npm 包再安装因为开发调试期本地插件更高效。第3天你在插件里加一个配置项第4天想改回来如果是 npm 包就要重新npm publish再安装非常痛苦。本地app/plugin目录可以立刻看到改动效果等稳定之后再把目录复制成独立 npm 包发布这是一种更务实的开发顺序。3.3 插件的执行顺序与命名冲突插件多了以后最麻烦的问题就是执行顺序。中间件是按数组顺序执行的插件之间也有先后关系。比如你的 auth 中间件想要读取ctx.currentUser那它就必须在业务中间件之前执行如果你使用了 jwt 插件又引入了另一个依赖登录态的插件你需要用dependencies字段声明依赖关系这样框架会保证被依赖的插件先加载。在插件的package.json里可以这样声明依赖{ name: my-business-plugin, version: 1.0.0, eggPlugin: { dependencies: [jwt] } }另一个很实际的问题是命名冲突。如果应用内部的中间件叫auth.js某个插件也叫auth.js新加载的插件可能不会像你想象的那样“叠加生效”而是可能覆盖或者被覆盖。所以第三方插件通常都有自己的前缀我们在项目里定义通用中间件时也尽量避开egg-开头的包名比如内部中间件用innerAuth外装插件用egg-auth从一开始就减少冲突的可能。注意本地插件通过path注册时path一定要用绝对路径。我一开始直接写path.join(./app/plugin/egg-auth)结果 dev 模式没问题部署到服务器后应用目录一换就找不到插件。从__dirname出发拼接config/plugin.js所在路径是更稳妥的做法。4. 实战写一个权限校验中间件并接入私有接口4.1 需求分析和流程设计前面讲了这么多原理现在做一个完整实战。假设我们的项目里有一个私有接口/private/info只有登录用户才能访问。客户端调用时需要把登录后拿到的 token 放在请求头authorization里如果没带、token 无效直接返回统一的 401如果校验通过把用户 ID 放到ctx.state.userId上后面的 controller 直接取用。整体流程是请求进入 auth 中间件 - 读取 header 里的 token - 从应用令牌表里查找用户 - 找到就写入state并next()- 找不到就终止并返回 401。为了演示我们用一个内存Map当令牌表真实项目可以把 token 存在 Redis 里校验逻辑保持不变。4.2 中间件实现先写app/middleware/auth.jsuse strict; module.exports (options, app) { return async function auth(ctx, next) { const token ctx.get(authorization) || ctx.query.token; if (!token) { ctx.status 401; ctx.body { code: 401, message: 缺少访问凭证 }; return; } const tokenInfo app.tokenStore.get(token); if (!tokenInfo || tokenInfo.expireAt Date.now()) { ctx.status 401; ctx.body { code: 401, message: 凭证无效或已过期 }; return; } // 把用户信息放入 state方便后续 controller/service 使用 ctx.state.userId tokenInfo.userId; // 校验通过继续往下走 await next(); }; };这里的tokenInfo.expireAt是一个过期时间戳我们把它放在后面登录接口生成 token 时一起写入。这样即使 token 字符串还在内存里只要过了实效也能立刻拒绝比单纯判断“Map 里有没有”更接近真实场景。然后需要在启动阶段初始化 token 表。新建app.jsuse strict; module.exports (app) { app.tokenStore new Map(); };4.3 登录接口和私有接口登录接口负责发放 token。新建app/controller/auth.jsuse strict; const Controller require(egg).Controller; class AuthController extends Controller { async login() { const { ctx, app } this; const { username, password } ctx.request.body; if (username ! admin || password ! 123456) { ctx.body { code: 1, message: 用户名或密码错误 }; return; } const token token_${Date.now()}_${Math.random().toString(36).slice(2)}; const expireAt Date.now() 2 * 60 * 60 * 1000; // 2小时有效 app.tokenStore.set(token, { userId: 1, expireAt }); ctx.body { code: 0, data: { token, expireAt } }; } } module.exports AuthController;再写私有接口app/controller/private.jsuse strict; const Controller require(egg).Controller; class PrivateController extends Controller { async info() { const { ctx } this; ctx.body { code: 0, data: { userId: ctx.state.userId, message: 私有接口访问成功 } }; } } module.exports PrivateController;关键在路由的挂载方式。把 auth 中间件只加在私有路由上use strict; module.exports (app) { const { router, controller, middleware } app; router.post(/login, controller.auth.login); router.get(/private/info, middleware.auth(), controller.private.info); };这样/login完全不受影响/private/info必须带 token 才能访问。4.4 用 curl 验证完整链路启动服务后先用登录接口拿 tokencurl -X POST http://127.0.0.1:7001/login \ -H Content-Type: application/json \ -d {username:admin,password:123456}返回结果里会有一个 token。接下来不带 token 访问私有接口curl http://127.0.0.1:7001/private/info应该看到{code:401,message:缺少访问凭证}带上 token 再试curl http://127.0.0.1:7001/private/info \ -H authorization: token_xxxx这次就能拿到{code:0,data:{userId:1,message:私有接口访问成功}}到这里一个最基本的权限中间件就跑通了。从代码量看中间件只有二十几行但它把所有私有接口的鉴权逻辑都收拢到了一处后续如果要加管理员判断、踢人下线、统一埋点都只需要改这一个文件。4.5 把它装回插件里如果你觉得这个中间件以后还要在别的项目里用就可以把刚才写的auth.js挪到第3章那个egg-auth插件的app/middleware目录里。之后插件的config/config.default.js里加上exports.middleware [auth]; exports.auth { // 你需要的配置项 };应用启用插件后auth中间件就自动生效无需再在config/config.default.js里重复声明。这种“一次封装多项目复用”的效果就是插件机制最值钱的地方。5. 我踩过的坑与排查技巧5.1 中间件没生效时先查这五个地方我经常收到类似“为什么中间件没走”的问题大多数原因都集中在这几点app/middleware目录下文件名和config.middleware数组里的名字不一致。比如文件叫auth.js配置里写authorization那当然不生效。中间件文件没有导出工厂函数而是直接导出了一个普通函数。Egg 需要的是(options, app) middleware这样的工厂写错后框架加载时会直接报错或者变成不可用。忘记在配置里开启。很多人把文件放到app/middleware就以为会自动生效其实还需要在config里加入数组或用路由use挂载。中间件里没有调用await next()。这个前面说过调用链断了后面的控制器自然不执行。如果你看到日志只打印了前半段多半是这个问题。路由顺序不对。如果是局部挂载要确保中间件挂在你真正要保护的那条路由上而不是写在路由后面或错误分组。这几个问题可以整理成一张速查表症状可能原因处理方式中间件完全没日志未在配置中启用加入exports.middleware [xx]启动报“module is not a function”导出格式不对改成module.exports (options, app) { return async (ctx, next) {} }请求卡住没有响应没调用await next()检查分支逻辑确保正常路径调用next()只对部分接口生效路由挂载范围不对确认是全局还是局部启用5.2 洋葱顺序带来的几个反直觉问题“先写这个中间件再写那个中间件”的视觉顺序和“谁先执行、谁后执行”并不完全一样。中间件数组[a, b]进入请求时先执行 a 再执行 b响应回来时先 b 后 a。所以在写日志中间件时await next()之后的代码才是拿到最终ctx.status的地方在写计时中间件时如果把结束时间放在next()之前算出来的耗时几乎都是 0这是一个特别容易犯的错。还有一个常见问题是在多个中间件之间共享数据。不要随便往ctx上挂临时变量尤其是属性名太通用时很容易被其他插件覆盖。Egg 里约定把当前请求内的数据放在ctx.state上比如ctx.state.userId这个对象是专门用来保存这类状态的就算多个中间件同时读写也不容易出问题。5.3 排查插件问题时怎么调插件出了问题时第一步是看启动日志。Egg 在应用启动时会打印加载了哪些插件、哪些中间件如果某个插件没有加载日志里会直接体现。第二步是确认插件的config/plugin.js是否真的enable: true我同事不止一次因为复制配置时把插件写成了enable: false结果功能悄悄消失排查半天。开发本地插件还有一个技巧先不要一上来就做成插件在应用里把所有逻辑跑通确认中间件和扩展方法没问题后再按插件目录挪进去。这样出问题时你能分清是“代码逻辑错了”还是“插件加载坑了”而不是两个问题混在一起。这个习惯帮我节省了大量时间。6. 今天的复盘三个印象深刻的细节回顾第12天中间件和插件这两个概念其实不难难的是真正理解它们为什么这样设计。我有三个印象特别深的点第一洋葱模型不是一种“高级玩法”而是中间件组合的底层逻辑。理解了await next()前后分层的含义很多看似混乱的日志输出、响应头覆盖问题都能一眼看穿。第二插件是比中间件更完整的复用单元。写一个中间件容易写一个能给别人用的插件要考虑配置项、命名、顺序、扩展入口这些约束让插件不能只靠“拷贝代码”来交付。第三把通用逻辑从 controller 里抽出来是工程代码从“能跑”到“好维护”的关键一步。今天这个 auth 中间件如果用传统写法每个私有接口的 controller 都要重复一段 token 校验代码复制粘贴三次还行三十次就很危险。明天是第13天我准备把今天的中间件和插件机制接进一个更完整的项目里看看在真实多模块业务下日志埋点、权限校验、接口分组三个功能叠加起来会不会有执行顺序上的坑。到时候我继续把过程写下来也给同样在突击 Egg.js 的朋友留一份实时排坑记录。到这儿第12天的学习记录就结束了。如果你今天也卡在某个中间件不生效的坑里可以按上面第5节的方法逐项排查一遍通常都能找到答案。我们下一期见。