1. 15天学习计划走到第14天,先复盘整个路线
按照“2026年15天学习完 Egg.js”的计划,今天已经是第14天。走到这一步,大多数知识点的学习和基础练习已经结束,真正留给今天的任务只有一件:把分散的技能点串成一个能交付、能上线、能拿得出手的完整项目。如果你也正在用15天这类极限周期学一个框架,会发现真正拉开差距的从来不是“懂多少API”,而是能不能在有限时间内把项目从“本地能跑”推进到“结构清晰、配置合理、接口安全、逻辑可测”的状态。
先说清楚我这15天是怎么排的,方便你在参考时自由调整:
- 第1天到第3天:掌握 Egg.js 的目录结构、application/context/request/response 这些核心对象,以及“约定优于配置”的开发模式。
- 第4天到第5天:中间件、路由、控制器、服务,把一次完整请求的链路彻底跑通。
- 第6天到第7天:插件机制与扩展机制,这周是 Egg 的灵魂,包括如何用 app.xxx 扩展、如何封装自己的插件。
- 第8天到第9天:数据持久化,选的是 Sequelize 方案,覆盖模型定义、迁移、关联查询。
- 第10天到第12天:业务实战,做一个带登录鉴权、权限校验、资源管理的小系统。
- 第13天:单元测试和接口自测,把覆盖率和常见测试套路补齐。
第14天则用来解决一个我看来所有框架学习中最后也最容易被忽略的问题:怎么让项目在别人的机器、服务器以及生产环境下也能稳定运行。这一天的实战价值,比前面任何一天都高。很多初学者学到这阶段容易陷入“教程抄完即毕业”的状态,觉得接口能返回数据就万事大吉,但实际工作中最难处理的往往是环境差异、配置隔离、部署流程、依赖版本这类比写业务更琐碎、又更容易让人连续加班的事情。
所以这篇文章的记录重点,我不打算再重复最基本的 Egg.js 入门示例,而是完整还原我第14天的实操过程:从配置分层开始,到中间件与插件的搭配使用,再到鉴权模块的实现、测试排错和部署准备,把“能上线”这个目标拆开揉碎。你学完后,如果手上正好有一个写到一半的 Egg.js 项目,完全可以按同一条路径推进。
2. 学会从应用配置到框架扩展的细节处理
2.1 环境配置分层与 mixin 机制
Egg.js 的配置设计非常顺手,核心逻辑是用不同的文件区分运行环境。我第6天刚接触时只会改config.default.js,把所有配置一股脑塞进去,直到第9天做用户模块时才意识到,如果不区分环境,本地连的数据库地址和线上完全没法共存。
Egg.js 配置规则是:默认配置config.default.js永远加载,环境变量EGG_SERVER_ENV或NODE_ENV决定叠加哪个具体环境的配置,比如config.prod.js、config.local.js、config.test.js。取值时框架会把同名字段做合并,数组是 concat,对象是浅拷贝,不是简单覆盖。
以我当时项目里的数据库配置为例:
// config.default.js exports.sequelize = { dialect: 'mysql', host: '127.0.0.1', port: 3306, database: 'egg_blog_dev', username: 'root', password: '', timezone: '+08:00', }; // config.prod.js exports.sequelize = { host: '10.0.0.12', database: 'egg_blog_prod', username: 'deploy', password: process.env.DB_PASSWORD, };这里有两个细节我提醒你注意。第一,线上密码不要写死,用环境变量注入;没人想看到你因为把数据库密码提交到 Git 仓库而被运维约谈。第二,timezone: '+08:00'务必加上,否则 Sequelize 读取 DATETIME 字段时会默认按 UTC 处理,前端展示时间直接少8小时,这种问题排查起来非常耗时,而且坑得非常隐蔽。
如果你用的是 egg-sequelize 插件,还需要关注字段是否走 Sequelize 的同步,生产环境建议用 migration 管理表结构,而不是直接sequelize.sync(),后者在你改字段类型时会干出什么我不敢想。
2.2 配置内容的敏感字段与运行时取值
光有环境配置文件还不够,我第14天特意做了一次“配置体检”,把所有写在配置里的敏感内容全部清理了一遍。判断标准很简单:任何一个配置项,如果换了环境或换了接手的人会不一样,就不能硬编码在代码里。
推荐的做法是在config.default.js中读取process.env并设置默认值:
// config.default.js exports.cluster = { listen: { port: process.env.PORT || 7001, }, }; exports.redis = { client: { port: process.env.REDIS_PORT || 6379, host: process.env.REDIS_HOST || '127.0.0.1', password: process.env.REDIS_PASSWORD || '', db: 0, }, };有没有默认值这件事很重要,它决定了你的项目会不会在别人本地拉下来之后,因为缺少一个环境变量而直接启动失败。我在实际项目里吃过一次亏:线上启动脚本给某个服务传了配置,本地开发时没设置,Egg 直接抛异常,排查了大半个小时才发现是漏了环境变量。所以凡是牵扯到环境差异的配置,务必三件套齐全:默认值、环境变量读取、注释说明这个配置是干什么的以及哪类环境要覆盖。
2.3 在扩展机制中注册自己的通用方法
配置搞定之后,今天上午我还优化了项目里一个最明显的坏味道:很多公共逻辑散落在各个 Controller 里。比如响应格式统一处理、从 token 里解析用户信息、分页参数整理,每个 Controller 各写一份,改一处漏一处。
Egg.js 提供了四类扩展点,分别是对Application、Context、Request、Response的扩展。统一的响应结构我挂在Context上,因为每次请求都是一个独立的 context 实例,在中间件或者控制器里都能拿到:
// app/extend/context.js module.exports = { success(data, message = 'ok', code = 0) { this.body = { code, message, data, }; }, fail(message = 'fail', code = 1) { this.body = { code, message, data: null, }; }, };用的时候在任何 Controller 里直接this.success(...)或者ctx.fail(...)就行。说实话这个方法很笨,但它能强制整个项目所有接口返回结构一致,前端联调的体验会提升非常明显。你也完全可以在这个基础上加类似pageInfo、parseToken之类的方法,原则只有一个:全局通用的能力优先考虑扩展,局部通用的能力才放进 Service,避免 Service 层变成垃圾箱。
3. 中间件和插件的组合,才是 Egg.js 真正出效果的地方
3.1 理解洋葱模型与中间件执行顺序
第5天初次接触中间件时,我就发现大家常用 koa 洋葱模型来类比:请求从最外层打进最内层,响应从最内层逐层返回,中间件可以同时在进入和离开时做两件事。如果你只把中间件当成“过滤请求”的工具,很多高级玩法就错过了。
我用一段典型代码说明中间件顺序为什么重要。假设你有三个中间件,分别是日志、鉴权、耗时统计:
// config/config.default.js exports.middleware = ['logger', 'auth', 'cost'];实际执行时,请求会按照logger -> auth -> cost -> 业务控制器 -> cost -> auth -> logger的顺序走完。等于是入栈出栈的关系。那鉴权中间件必须放在 cost 前面,否则“没登录的用户也跑完了统计逻辑”,浪费性能倒在其次,更严重的是某些中间件可能会因为拿不到用户信息直接抛错。
我建议在你项目里保留一个记录响应耗时的中间件,这对调优和排查问题非常有用:
// app/middleware/cost.js module.exports = () => { return async function cost(ctx, next) { const start = Date.now(); await next(); const duration = Date.now() - start; ctx.logger.info(`[reporter] ${ctx.method} ${ctx.url} cost=${duration}ms`); }; };记得中间件文件必须返回箭头函数或普通函数,Egg 在加载时会调用它拿到真正的中间件函数;如果直接导出函数本身,大概率会在运行时报“middleware must be a function”之类的错误。
3.2 从生态中挑选能力对口的插件
Egg.js 的插件生态不算多,但每个都很关键。我这15天实际用到的插件组合如下:
| 插件 | 用途 | 配置要点 |
|---|---|---|
| egg-sequelize | ORM、数据库映射 | 注意版本和 mysql2 匹配 |
| egg-jwt | 签发与校验 token | 统一配置 secret |
| egg-cors | 跨域处理 | 区分本地联调和线上域名 |
| egg-validate | 参数校验 | 在控制器里做入参校验 |
| egg-redis | 分布式缓存与 session 存储 | 生产环境必备 |
| egg-router-plus | 增强版路由 | 支持命名空间与正则 |
拿 egg-cors 举例,这个插件配置不当会造成一个非常诡异的现象:本地 axios 请求接口一切正常,部署到测试环境后浏览器直接报 CORS 错误。原因在于我把允许的域名写死在了配置里,而测试环境的访问域名根本没有加进白名单。
exports.cors = { origin: (ctx) => { const origin = ctx.get('Origin'); if (/^https?:\/\/localhost(:\d+)?$/.test(origin)) return origin; if (origin && origin.endsWith('.yourdomain.com')) return origin; return ''; }, credentials: true, allowMethods: [ 'GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH' ], };这里把origin配置成函数,要比写死数组灵活很多。加上credentials: true后,前端请求就必须明确携带withCredentials: true,否则 Cookie 不会随请求发送,登录态也就一直建立不起来。
3.3 从“会用”到“能自己写一个插件”
第14天我还做了一件事:把项目里的统一鉴权逻辑抽成了一个本地插件,复习了 Egg.js 插件机制。Egg 插件本质上就是一个“迷你应用”,它有自己的app、ctx、middleware、config、extend,并且能被多个项目复用。
写插件时建议注意这几个要素:
- 包名必须以
egg-开头(如果是本地插件可以放lib/plugin下并命名为egg-xxx,但那只是目录名,正式发包时还是得按 npm 规则)。 - package.json 里
eggPlugin字段声明这个插件依赖了哪些插件。 - 插件代码里可以通过
app.config读取使用方传入的配置项,并提供默认值。
举个例子,写一个给现有项目用的egg-auth插件:
// app/lib/plugin/egg-auth module.exports = (app) => { app.config.auth = app.config.auth || { expires: 60 * 60 * 2, ignore: [ '/api/login', '/api/register' ], }; app.beforeStart(async () => { app.logger.info('[egg-auth] 插件启动完成'); }); };不过我这里先打住,因为这个属于“进阶改造”,如果你现在只是一个学了十几天的初学者,老老实实先把中间件和扩展机制用透,比一上来追求插件化更稳妥。我是在第13天的测试时反复复制一坨鉴权代码到多个中间件里,才下定决心抽插件的。
4. 鉴权模块实战:从零写一个能过测试的登录接口
4.1 模型定义与迁移文件的取舍
登录鉴权是所有管理系统都绕不开的核心模块,也是第14天项目实战的重头戏。我用的是 Sequelize 管理用户数据,先定义一个用户模型:
// app/model/user.js module.exports = (app) => { const { STRING, INTEGER, DATE } = app.Sequelize; const User = app.model.define('user', { id: { type: INTEGER, primaryKey: true, autoIncrement: true }, username: { type: STRING(32), unique: true, allowNull: false }, password: { type: STRING(128), allowNull: false }, nickname: { type: STRING(64), allowNull: false }, createdAt: { type: DATE }, updatedAt: { type: DATE }, }); return User; };密码在数据库里绝对不能存明文,这一点我是从第9天开始就严格执行的。用 bcrypt 对密码做哈希,哪怕是同一个密码,每次生成的哈希值也不同,可以有效防止彩虹表攻击:
// app/service/user.js const bcrypt = require('bcryptjs'); async findOrCreate(username, password) { const hash = bcrypt.hashSync(password, 10); const [ user, created ] = await this.ctx.model.User.findOrCreate({ where: { username }, defaults: { username, password: hash, nickname: '新用户', }, }); return { user, created }; }关于建表,我第9天图省事用app.beforeStart里执行sync()自动同步,数据库的字段能创建出来,但坑在于:线上环境一旦运行sync(),而某张表已经存在,模型里字段名和数据库不一致时框架不会删字段,却可能因为类型不一致引发奇怪报错。建议正式的开发流程里创建 migration 文件来管理表结构,团队协作时也能用 migration 做表结构评审。
4.2 Service 层负责业务,Controller 只做转发
写到这里必须强调一个 Egg.js 社区最看重的分层思想:Controller 只负责接收请求、调用 Service 返回结果,任何复杂的逻辑和数据库操作都要下沉到 Service。很多从 Express 转过来的人很习惯在路由回调里写完所有逻辑,到了 Egg 依然把几十行代码堆在 Controller 里,结果就是单元测试极其难写,因为你很难 mock 掉一堆函数。
我把登录和注册的 Controller 写得非常薄:
// app/controller/auth.js const Controller = require('egg').Controller; class AuthController extends Controller { async register() { const { ctx, service } = this; ctx.validate({ username: 'string', password: 'string' }, ctx.request.body); const result = await service.auth.register(ctx.request.body); ctx.success(result); } async login() { const { ctx, service } = this; ctx.validate({ username: 'string', password: 'string' }, ctx.request.body); const result = await service.auth.login(ctx.request.body); ctx.success(result); } } module.exports = AuthController;Service 里的实现:
// app/service/auth.js const Service = require('egg').Service; const bcrypt = require('bcryptjs'); class AuthService extends Service { async register(body) { const { ctx } = this; const exists = await ctx.model.User.findOne({ where: { username: body.username } }); if (exists) { ctx.fail('用户名已存在', 10001); return; } const hash = bcrypt.hashSync(body.password, 10); const user = await ctx.model.User.create({ username: body.username, password: hash, nickname: body.username, }); const token = await this.generateToken(user); return { token, user: { id: user.id, username: user.username, nickname: user.nickname } }; } async login(body) { const { ctx, app } = this; const user = await ctx.model.User.findOne({ where: { username: body.username } }); if (!user) { ctx.fail('用户名或密码错误', 10002); return; } const valid = bcrypt.compareSync(body.password, user.password); if (!valid) { ctx.fail('用户名或密码错误', 10003); return; } const token = await this.generateToken(user); return { token, user: { id: user.id, username: user.username, nickname: user.nickname } }; } async generateToken(user) { const { app } = this; return app.jwt.sign( { id: user.id, username: user.username }, app.config.jwt.secret, { expiresIn: app.config.jwt.expires } ); } } module.exports = AuthService;这里ctx.fail()是前面扩展 context 时定义的方法,统一了错误的返回结构。你可能会问,为什么 Service 里要用ctx.fail()而不是直接throw?两者都可以,但我个人偏好:业务提示类错误用返回体处理,系统异常类错误用ctx.throw()或boom处理。这样接口联调时状态码和业务码的语义清晰,不会一个 500 把多个业务错误掩盖掉。
4.3 自定义鉴权中间件保护接口
有了 token,还需要一个中间件去校验受保护接口的请求头。这里要留意 egg-jwt 的用法,它自带一个jwt中间件,但它的实现是比较通用化的,我建议自己写一层薄封装,方便之后扩展白名单、黑名单或刷新 token 的逻辑:
// app/middleware/auth.js module.exports = () => { return async function auth(ctx, next) { const token = ctx.get('Authorization') || ''; if (!token || !/^Bearer\s/.test(token)) { ctx.fail('未登录', 401); return; } try { const decoded = ctx.app.jwt.verify(token.replace('Bearer ', ''), ctx.app.config.jwt.secret); ctx.state.user = decoded; await next(); } catch (e) { ctx.fail('Token 无效或已过期', 401); } }; };注册时配合 ignore 参数把放行接口标出来:
// config/config.default.js exports.middleware = ['cost', 'auth']; exports.auth = { ignore: [ '/api/login', '/api/register', '/api/public', ], };这里我碰到过几次顺序问题:如果我把 auth 放在 cost 前面,那每次请求的成本统计里就包含了 token 校验的时间,逻辑上没问题,但如果你只想统计纯业务耗时,就把它放在 auth 后面。中间件顺序一定要结合你的业务目标来决定,没有绝对正确的套路。
4.4 用 curl 实测整个链路
代码写完不等于验完,我一般会先用 curl 快速走一遍接口,省得在调试工具里来回切换。下面是第14天实测登录接口的过程:
# 注册 curl -X POST http://127.0.0.1:7001/api/register \ -H 'Content-Type: application/json' \ -d '{"username":"testuser","password":"123456"}' # 响应 # {"code":0,"message":"ok","data":{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","user":{"id":1,"username":"testuser","nickname":"testuser"}}} # 携带 token 访问受保护接口 curl http://127.0.0.1:7001/api/users/1 \ -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' # 不带 token 访问,应返回 401 curl http://127.0.0.1:7001/api/users/1 # {"code":1,"message":"未登录","data":null}这就是一条完整的鉴权链路。整个流程跑通后,我给项目的 jsdoc 补上关键注释,一瞬间觉得这个项目至少可以拿给另一个后端同事接手了。
5. 测试与排错:第14天踩过的5个坑
5.1 版本兼容问题:egg 与 sequelize 匹配不当
Egg.js 版本文档更新得不算频繁,但 Sequelize 的迭代速度很快。我第8天按一份旧教程安装时,直接装了个sequelize@6,随后版本冲突,数据库连接建立了但查询时字段映射错乱。
排查方法:先看package.json里 egg 的版本,再去查 egg-sequelize 插件对应的依赖范围,最后确认 sequelize 主版本。不要因为 npm install 不报错就认为没版本问题。下面是一组经过验证的稳妥搭配:
- Egg 3.x + egg-sequelize 6.x + sequelize 6.x
- Egg 2.x + egg-sequelize 5.x + sequelize 5.x
安装命令尽量使用:
npm i egg-sequelize@6 sequelize@6 mysql2 --save5.2 ESLint 从入门到劝退
第4天我第一次跑npm test时,被 ESLint 一大片报错整懵了。Egg 官方模板自带一套严格配置,包括强制分号、函数括号前空格、不允许未使用变量等。如果一开始不写规范代码,最后会花大量时间改格式。
我给你的建议是:从第三天起就把 VS Code 的 ESLint 插件真实跑起来,editor 里出现黄色波浪线立刻处理,别等到最后再统一改。真的遇到整个文件已存在大量旧格式问题,再考虑// eslint-disable-line这种临时的“还债”手段,但千万别把它当成常规操作。
5.3 egg-bin dev 偶发提示端口占用
开发时间内反复重启进程,偶尔会碰到端口无法启动。常见原因是旧的 worker 进程没被回收干净,尤其 macOS 上egg-bin dev有时会留下 agent 进程。
解决方式简单粗暴,先查进程再手动清理:
lsof -i :7001 kill -9 <PID>同时也提醒你,Egg 的 cluster 模式默认会启动 agent 和多个 worker,日常开发时如果不需要多进程调试,可以设置单进程跑,能少踩很多“进程间调试信息不透明”的坑:
EGG_SERVER_ENV=local egg-bin dev --workers=15.4 session 存储不稳定,接入 redis
Egg 自带内存型 session,但开发阶段随意重启就会丢登录态。一旦项目里有多实例部署的需求,内存 session 几乎不可用。我在第11天就把 session 切换到了 egg-redis:
exports.session = { key: 'EGG_SESS', maxAge: 7 * 24 * 3600 * 1000, renew: true, }; exports.redis = { client: { port: 6379, host: '127.0.0.1', password: '', db: 0, }, };前提是要先装好一个 Redis 服务,否则应用启动时会直接往 Redis 写数据,失败则抛异常。本地没有 Redis 的话直接用 Docker 起一个,一分钟搞定:
docker run -d --name egg-redis -p 6379:6379 redis:7-alpine这里有一个细节:session 换到 redis 后,调试完记得观察 Redis 里是否堆积了大量过期 key,建议给 key 统一设置 TTL,否则等到线上流量上来,你会发现 Redis 内存涨得吓人。
5.5 部署环境 node 版本导致的兼容问题
Egg 3.x 对 Node 版本有要求,低于 14 基本跑不起来;Egg 2.x 也需要 Node 8 或以上。我在第14天下午部署到一台旧服务器时,发现 Node 版本是 12,启动直接报SyntaxError。所以在配置 CI 或收集部署文档时,最好明确写清推荐的 Node 版本。
如果部署环境无法升级 Node,有一种临时方案是先在本地用相同 Node 版本跑一次构建,把产物传到服务器。但治本的方法还是给项目增加.nvmrc文件,比如:
# .nvmrc 18.20.4然后在部署脚本里nvm use或 CI 阶段node-version统一指定,这样至少不会因为本机和服务器 Node 版本差异出现“本地好好的,一上线就崩”的灵异事件。
6. 第15天收尾清单与长期扩展方向
6.1 最后的查漏补缺:文档与目录整理
15天计划最后一天,不可能再学新东西,更应该做的是让项目像一份可交付的成果。我给自己列的清单是:
- 每个 Service 方法和 Controller 方法补上功能注释。
README.md写明项目启动步骤、环境变量清单、测试命令。- 检查 package.json 里的 scripts 是否覆盖 dev、start、test、lint。
- 确认
.gitignore排除了 node_modules、logs、本地环境变量文件。 - 把依赖做一次
npm audit,顺手处理中高危漏洞。
很多初学阶段的人都会忽略 README,但换位思考一下,一个后端项目如果别人拿到手不知道跑起来需要哪些环境变量,对你的评价会大打折扣。文档写得不求多么华丽,清晰准确即可。
6.2 压测:内容不多,但值得做一次
Egg.js 本身性能基础不错,但业务代码写得好不好会明显影响吞吐量。第15天我会用简单的ab或wrk快速做一次压测,不追求压到瓶颈,主要看接口压测时有没有明显的内存上涨、超时或连接异常。
比如一个简单的 GET 接口,最好能跑出每秒几百到上千 QPS,如果明显很低,优先查中间件是否太肥、SQL 是否有 N+1 查询、日志是否同步写入阻塞了事件循环。这块需要单独一篇长文,这里就点到为止。
6.3 未来的方向:Egg.js 之后该学什么
严格说 Egg.js 是一个企业级框架,掌握它之后,你后续可以按这个顺序继续拓宽:
- 如果你所在团队偏向微服务,可以了解 egg-grpc 或者把服务拆分成独立的 Egg 应用,用消息队列解耦。
- 如果前端学了 React/Vue,可以把 Egg.js 作为纯 API 后端,接上 Token 鉴权与权限模型,做成完整的前后端分离项目。
- 如果对框架内核感兴趣,就去读 Egg.js 源码里 loader、plugin、router 的实现,很多设计思想能直接用到其他语言框架里。
Egg.js 还可以跟 TS 结合,官方早就提供egg-ts-helper等工具做类型推导。如果15天让你产生了对 Node.js 服务端的兴趣,往 TypeScript 方向走是性价比较高的选择。
7. 写在最后:一次极限学习计划带来的真实感受
15天学完一个企业级框架,听起来像口号,真正做下来却比想象中更考验节奏管理。我的体会是:每天不需要贪多,但每天都要写代码,哪怕只写十行也要让知识转化成项目里的实际产物。第14天之前我还在担心信息量太大记不住,但当我把配置、中间件、鉴权、测试、部署这些环节全部串起来之后,Egg.js 对我而言就从一个“有文档的框架”变成了“一堆能解决实际问题的工具”。它对约定式开发、插件扩展和请求链路管理的处理方式也影响了我后来看待其他后端框架的眼光。
如果你也在按自己的节奏学 Egg.js,无论处在第几天,都建议尽早拿真实项目开刀试炼。别光看我的示例和别人的教程,动手把登录注册、权限控制、数据校验这些模块在你自己本地跑通一次,踩过几个坑之后,才算真正入门。最后再分享一个小技巧:第15天开始前,把整份笔记按“配置 / 中间件 / 插件 / 测试 / 部署”五个标签重新归类一遍,你回看时会发现,框架学习最有价值的不是代码量,而是形成了一套稳定、可迁移的排查与设计思路。