
今天是15天学完 Egg.js 的第3天。前两天的内容大多停留在项目怎么初始化、目录结构长什么样、配置文件里都有哪些开关属于把骨架搭起来、能跑通npm run dev的阶段。第3天开始进入真正练手艺的环节一个HTTP请求从进来到出去Egg.js 内部到底带着这个请求走了一条什么样的路。我会把路由、控制器、Service、中间件这四块串成一条线来讲目标就一个——你随便看一眼业务需求能快速拆出接口、拆出逻辑、拆出公共处理而不是继续在 demo 里打转。1. 第3天应该学什么15天计划中的关键转折点1.1 前两天的进度盘点从能跑到知道为什么能跑我假设你已经完成了下面这些操作用npm init egg --typesimple生成了一个空项目装好了依赖启动后看到了默认页面也大概扫了一眼app目录下面那些文件夹。如果这些还没搞定建议先回去补上否则今天的内容会像在听天书。前两天的操作停留在“照着文档做”今天要往前推一步理解约定。Egg.js 最大的特点就是把你想偷懒的地方都做成了规范比如路由文件放哪、Controller 怎么命名、Service 怎么挂载全是目录结构说了算。一个请求从浏览器发出来大致会经历这样的链路中间件 → 路由Router→ 控制器Controller→ 服务Service→ 返回响应这条链路看着简单但每一环都决定了你的代码好不好改、好不好测、别人接手时会不会骂人。第3天的核心就是把这条链路装进脑子里之后的第4天到第15天不管是搞数据库、做鉴权、写定时任务还是上部署都是在这条链路的不同位置上加东西。1.2 先把 app 目录读懂约定优于配置的关键打开项目app目录下面通常会有这几个子目录app/ ├── controller/ # 控制器接收参数、调度服务、返回响应 ├── service/ # 服务层业务逻辑、数据操作 ├── middleware/ # 中间件请求前置和后置处理 ├── router.js # 路由定义 URL 与控制器方法的对应关系 ├── extend/ # 扩展给 ctx、app、request 挂自定义方法 ├── schedule/ # 定时任务后面第8天再细讲 └── view/ # 模板目录不是所有项目都用到注意router.js是文件不是文件夹。Egg.js 启动时会默认加载app/router.js在里面导出一个接收app参数的方法你在这个方法里定义路由规则。我见过不少新手拿到项目后完全凭感觉放代码逻辑全堆在 Controller 里公共逻辑复制粘贴最后改一个功能要牵连三四个地方。第3天最重要的一课就是把代码放对地方比把代码写出来更值钱。2. 路由进阶实操让URL设计真正贴合业务2.1 入门第一课路由方法的完整写法路由的核心作用就是把 URL 和 HTTP 方法映射到 Controller 的某个方法上。最简单的路由定义长这样// app/router.js module.exports app { const { router, controller } app; router.get(/, controller.home.index); router.get(/user/:id, controller.user.info); router.post(/user, controller.user.create); router.put(/user/:id, controller.user.update); router.delete(/user/:id, controller.user.delete); };这里的controller.user.info并不是随便写的它对应app/controller/user.js文件里导出的UserController类中的info方法。Egg.js 通过文件名和一层目录结构自动完成了对象的挂载。这里有一个新手最容易踩的坑路由里的:id是路径参数在控制器里要通过ctx.params.id拿而不是ctx.query.id。两种参数类型不同取法也不同后面对照着看一次就记住了。另外还要注意一个规则如果同时定义了/user/:id和/user/list这种路由尽量把更具体的路由放在前面否则list可能被:id吞掉。顺序问题虽然现在不太容易触发但一旦触发了排查起来会让人崩溃。2.2 RESTful 资源路由一张表省掉一半路由代码如果你的接口要同时支持增删改查手动写router.get、router.post、router.put、router.delete会写得很烦。Egg.js 提供了router.resources方法一条语句自动生成一整套 RESTful 路由router.resources(posts, /posts, controller.posts);这条语句等价于下面这张表请求方法URL控制器方法语义GET/postsposts.index列表GET/posts/newposts.new新建页一般返回表单POST/postsposts.create创建GET/posts/:postIdposts.show详情GET/posts/:postId/editposts.edit编辑页一般返回表单PUT/posts/:postIdposts.update更新DELETE/posts/:postIdposts.destroy删除也就是说只要 Controller 里有index、create、show、update、destroy这些方法一套接口就自动挂上了。这里有一个细节资源路由的参数名默认是postId不是id。在控制器里拿的时候得写ctx.params.postId忘了这一点会 404 404 找半天。我在实际项目里通常是先明确业务是单纯的数据资源还是更像一套自定义动作。RESTful 资源路由适合标准的增删改查一些特殊动作比如login、logout、upload用普通路由更合适。两者混着用但要能说清楚为什么这个接口走 resources、那个接口走 get。2.3 URL 重定向与跳转别在控制器里写死页面地址有些业务场景需要接口内部跳转比如未登录时跳到登录页/login。在控制器里可以用ctx.redirect实现class AuthController extends Controller { async checkLogin() { const { ctx } this; if (!ctx.session.user) { ctx.redirect(/login); return; } ctx.body { ok: true, user: ctx.session.user }; } }这里需要记住一个原则ctx.redirect执行之后一定要return或者用else包裹后续逻辑否则代码还会继续往下执行容易出现“又跳转又返回数据”的混乱情况。多讲一句关于状态码的问题。ctx.redirect默认是 302 临时重定向如果你要做那种永久性的地址迁移建议手动设置ctx.status 301之后再 redirect否则对搜索引擎和外部调用方不友好。3. 控制器与ctx参数处理和响应输出的正确姿势3.1 Controller 的 this 和 ctx一条贯穿全链路的大通道控制器类继承了egg.Controller所以在控制器方法里可以拿到this.ctx、this.app、this.service、this.config等属性。其中ctx是这个请求的生命周期里最重要的对象它承载了请求和响应相关的几乎所有信息。一个最简单的控制器长这样// app/controller/user.js const Controller require(egg).Controller; class UserController extends Controller { async info() { const { ctx } this; ctx.body { code: 0, data: ok, }; } } module.exports UserController;注意每个方法前面都要加async。虽然不写也能跑但 Egg.js 的整个体系是异步的后面一旦你在方法里await ctx.service.user.getInfo()忘记加async就会得到一个未捕获的 Promise 异常非常难排查。所以从一开始就养成async习惯是最省事的选择。3.2 参数获取完整姿势query、params、body、header、cookie这是第3天必须练熟的基本功。我直接列一张对照表参数来源获取方式示例URL 查询参数ctx.query/user?namezhang取ctx.query.name路径参数ctx.params/user/:id取ctx.params.id请求体ctx.request.bodyPOST JSON 数据请求头ctx.get(user-agent)取指定 headerCookiectx.cookies.get(token)读 Cookie表单重复字段ctx.queries?hobbyahobbyb取数组具体到代码async create() { const { ctx } this; // 1. query 查询参数 const source ctx.query.source || unknown; // 2. 路径参数 const id ctx.params.id; // 3. 请求体 const payload ctx.request.body; // 4. header const ua ctx.get(user-agent); // 5. cookie const token ctx.cookies.get(token, { signed: false }); ctx.body { source, id, payload, ua, token, }; }这里有个高频坑ctx.query拿到的是字符串比如?age18取出来是18而不是数字18。如果你直接拿去和数字比较会出现类型不匹配。建议在必要的地方做一次类型转换或者用后面会讲到的校验插件统一处理。再说一下ctx.request.body。Egg.js 内置了 bodyParser默认能解析 JSON 和表单格式的请求体。如果接口接收超大文本最好在config.default.js里调整 bodyParser 的 limit 配置或者改走文件上传方案否则会直接被拦下来。3.3 统一响应格式让前端不再到处猜字段我见过很多项目的接口返回值五花八门有的成功返回{ data: ... }失败返回{ message: xx }状态码也乱前端对接一个个接口都像在解码。第3天就可以开始建立自己的约定。我的习惯是统一一个格式{ code: 0, // 0 表示成功非 0 表示业务错误 message: success, data: {} }具体处理可以封装在app/extend/context.js里给ctx挂两个方法// app/extend/context.js module.exports { success(data null, message success) { this.body { code: 0, message, data, }; this.status 200; }, fail(message error, code 1, status 200) { this.body { code, message, data: null, }; this.status status; }, };这样控制器里就很干净async list() { const { ctx } this; const list await ctx.service.post.list(ctx.query.page); ctx.success(list); }有异常时直接用框架自带的ctx.throw(400, 参数错误)或者自己在代码里ctx.fail(xxx)。统一格式最大的好处不是好看而是你后面写前端请求封装时只需要解析一种数据结构就够了。4. Service层实战业务逻辑从控制器中彻底解放4.1 为什么业务逻辑一定要下沉到 Service不写 Service 的项目不是不能跑是跑到后面会非常痛苦。想想这个场景登录逻辑里要校验用户、要写日志、要同步通知。如果你把这三件事全写在 Controller 里第二个接口也需要“校验用户”时怎么办复制粘贴然后改一个 bug 要改两个地方这就是 Controller 变臃肿的开始。Service 层的定位是独立承载业务逻辑和数据访问。控制器只负责三件事取参数、调 Service、返回响应。这样做有实打实的好处多处复用不同 Controller 可以调同一个 Service 方法。便于测试Service 不依赖具体请求逻辑好写单测。结构清晰以后接数据库改动集中在 Service 层Controller 几乎不用动。4.2 一个带异常处理的 Service 示例以用户信息查询为例我们先不接数据库用模拟数据把结构搭出来// app/service/user.js const Service require(egg).Service; class UserService extends Service { async getUserById(id) { const { ctx } this; // 这里先模拟数据后面替换成数据库查询 const mockUsers { 1: { id: 1, name: 张三, role: admin }, 2: { id: 2, name: 李四, role: user }, }; if (!mockUsers[id]) { ctx.throw(404, 用户不存在); } return mockUsers[id]; } } module.exports UserService;看到这里要注意Service 里可以通过this.ctx、this.app、this.config访问框架对象。ctx.throw(404, 用户不存在)抛出的异常会被 Egg.js 的默认异常处理器捕获并转换成对应的 HTTP 响应。所以你不必在 Service 里写一堆return null再让 Controller 去判断直接用异常来表达业务失败代码会顺很多。等后面接了真实数据库这个方法只需要把中间模拟数据那一段替换成this.app.mysql.get(user, { id })即可Controller 层不需要任何改动。这正是分层带来的迁移红利。4.3 Service 的调用约定与挂载规则Controller 里调 Service 的标准姿势是ctx.service.user.getUserById(id)。这里有两个规则容易乱文件名决定挂载名。app/service/user.js对应ctx.service.userapp/service/admin/user.js对应ctx.service.admin.user。类名和方法名要对应。类名习惯用大驼峰但不影响外部调用真正决定调用路径的是文件名。还有一个非常常见的 this 上下文问题Service 的方法必须是普通的类方法不要用箭头函数。如果你写成class UserService extends Service { getUserById async (id) { // 这里的 this 已经变了访问不到 this.ctx }; }那就踩了大坑。类字段的箭头函数不会继承Service实例的 this运行时会报Cannot read properties of undefined。记住一句口诀Egg.js 里凡是 Controller 和 Service 的方法都老老实实用async xxx() {}的写法。5. 中间件入门在洋葱模型里看懂请求的来龙去脉5.1 中间件的加载顺序与洋葱模型中间件是请求进到路由之前、以及响应离开之后要执行的一层代码。你可以把中间件想象成安检闸机乘客请求从第一个闸机进去做一次检查然后进入下一个闸机最后上车路由/控制器下车之后再从最后一个闸机反向出来再做一次检查。Egg.js 的中间件默认放在app/middleware目录启用方式是在config.default.js里配置exports.middleware [ requestLog ];中间件的核心机制是next()。一个请求会按顺序进入每个中间件执行到await next()时进入下一个中间件等后面的所有逻辑执行完再回到当前中间件的next()之后继续执行。这个模型就是著名的“洋葱模型”。文字描述一遍执行顺序中间件A 前置代码 → 中间件B 前置代码 → 控制器/Service → 中间件B 后置代码 → 中间件A 后置代码你可以把“前置代码”理解为请求进来时的处理把“后置代码”理解为响应出去前的处理两者之间夹着真正的业务逻辑。5.2 一个实用的请求日志中间件实战光讲理论不过瘾直接写一个中间件来记录每个请求的耗时// app/middleware/requestLog.js module.exports (options, app) { return async function requestLog(ctx, next) { const start Date.now(); console.log([request] ${ctx.method} ${ctx.url}); await next(); const cost Date.now() - start; console.log([response] ${ctx.status} cost ${cost}ms); }; };然后在config.default.js里启用exports.middleware [ requestLog ];重启服务后随便访问一个接口控制台会输出类似下面的内容[request] GET /user/1 [response] 200 cost 23ms这个日志中间件虽然简单但已经体现了中间件的完整用法请求进来时记录开始时间调用await next()让请求继续往下走等控制器处理完后再记录结束时间。如果你想对某些路径单独生效还可以在 config 里通过match或ignore做过滤exports.middleware [ requestLog ]; exports.requestLog { match: /user, };这样只有/user开头的请求才会经过这个中间件其余路径完全不受影响。5.3 写中间件最常见的三个错误第一个错误是忘记调用await next()。如果中间件里没有执行next请求会被这个中间件卡住后面的路由永远进不去接口一直 pending。调试的时候看到请求迟迟不返回先检查是不是中间件把 next 吃了。第二个错误是把中间件当成普通函数却想当然地认为return next()是对的。标准写法是await next()用 return 虽然也能跑但后续代码不会执行容易在“响应前处理”的地方漏逻辑。第三个错误是状态码已经被改了还想在后置代码里读原始 body。中间件在await next()之后可以读ctx.body如果你要统一格式化响应这确实是个方便的挂载点。但要小心别把已经设置好的结构又整体改一遍避免重复包装。6. 第3天高频踩坑与排查心得6.1 路由访问 404 的排查顺序如果接口返回 404别急着怀疑框架按下面的顺序查请求方法和路由是否匹配。router.get配的是 GET你用 POST 请求自然 404。路径是否完全一致。/user/:id和/user/:id/并不一样尾部斜杠都可能是问题。控制器文件名和方法名是否写对。controller.user.info要求user.js中有info方法大小写和拼写错一个字母都不行。是否在router.js里导出了方法。如果你用的是router.resources(posts, /posts, controller.posts)还要检查控制器里是不是有show、edit对应的方法。资源路由要求方法名严格匹配缺一个就少一个接口而且是静默的不在代码里直接报错。6.2 返回 JSON 变成乱码或格式不对ctx.body直接赋对象时Egg.js 会自动设置Content-Type: application/json并序列化 JSON。但如果你手贱做了这两件事就会出问题在中间件或控制器里手动ctx.set(Content-Type, text/html)再去赋对象前端拿到一坨字符串。直接用ctx.res.write()写响应绕过框架的 body 机制会和ctx.body冲突整出诡异结果。正确做法只有一个要返回 JSON就用ctx.body 对象让框架处理。如果之前设置了错误的 Content-Type重置回来即可ctx.set(Content-Type, application/json; charsetutf-8); ctx.body { code: 0, data: {} };6.3 this 上下文丢失最坑的一类运行时报错我在前面的几节里反复强调this.ctx就是因为 this 上下文是第3天最容易炸的点。在 Controller 和 Service 里this是 Egg.js 根据请求动态绑定的实例。一旦你把这个方法抽出来单独调用或者回调里用了箭头函数丢掉了 this就会报Cannot read properties of undefined。比如这样写就会炸async info() { const { ctx } this; const userId ctx.params.id; const handler ctx.service.user.getUserById; // 把方法单独拿出来 const user await handler(userId); // this 已经丢了 ctx.body user; }解法很简单方法在调用时一定要挂在对应的对象上去调用比如ctx.service.user.getUserById(userId)或者在类里用普通方法并确保没有拆出来单独引用。另外顺带提一句在异步回调比如setTimeout、Promise.then里想用this.ctx先在外面把const { ctx } this存下来别在回调里直接写this.ctx否则大概率 undefined。这个习惯能帮你省下一整晚的调试时间。第3天学到这里你已经能把一个请求从“URL 进来”到“响应出去”的完整链路说清楚了。我个人在实际操作中最强的感受是Egg.js 的分层并不复杂难的是开始阶段总是手痒想把逻辑堆在 Controller 里。其实你只要多坚持一个习惯就能省下后面大量的重构时间——写任何接口之前先问自己一句除了“接参数、调逻辑、返数据”这个控制器方法还有没有多余的活有就挪到 Service 去。第4天我们开始碰状态管理相关的机制持续把链路一步步补完整。