egg 是阿里出品的一款 node.js 后端 web 框架,基于 koa 封装,并做了一些约定。
egg 有孕育的含义,因为 egg 的定位是企业级 web 基础框架,旨在帮助开发者孕育适合自己团队的框架。
语雀 就是用 egg 开发的,架构图如下:
盒马,转转二手车、PingWest、小米、58同城等(技术栈选型参考链接)
虽然 egg 本身是用 JavaScript 写的,但是 egg 应用可以采用 Typescript 来写,使用下面的命令创建项目即可(参考链接):
$ npx egg-init --type=ts showcase
会的,只要在 package.json 中添加下面的声明之后,会在项目根目录下动态生成 typings 目录,里面包含各种模型的类型声明(参考链接):
"egg": {
"declarations": true
}
koa 是 egg 的基础框架,egg 是对 koa 的增强。
不会 koa 也可以直接上手 egg,但是会 koa 的话有助于更深层次的理解 egg。
我们采用基础模板、选择国内镜像创建一个 egg 项目:
$ npm init egg --type=simple --registry=china
# 或者 yarn create egg --type=simple --registry=china
解释一下 npm init egg
这种语法:
npm@6 版本引入了
npm-init
语法,等价于npx create-
命令,而npx
命令会去$PATH
路径和node_modules/.bin
路径下寻找名叫create-
的可执行文件,如果找到了就执行,找不到就去安装。也就是说,
npm init egg
会去寻找或下载create-egg
可执行文件,而 create-egg 包就是 egg-init 包的别名,相当于调用了egg-init
。
创建完毕之后,目录结构如下(忽略 README文件 和 test 目录):
├── app
│ ├── controller
│ │ └── home.js
│ └── router.js
├── config
│ ├── config.default.js
│ └── plugin.js
├── package.json
这就是最小化的 egg 项目,用 npm i
或 yarn
安装依赖之后,执行启动命令:
$ npm run dev
[master] node version v14.15.1
[master] egg version 2.29.1
[master] agent_worker#1:23135 started (842ms)
[master] egg started on http://127.0.0.1:7001 (1690ms)
打开 http://127.0.0.1:7001/
会看到网页上显示 hi, egg
。
上面创建的项目只是最小化结构,一个典型的 egg 项目有如下目录结构:
egg-project
├── package.json
├── app.js (可选)
├── agent.js (可选)
├── app/
| ├── router.js # 用于配置 URL 路由规则
│ ├── controller/ # 用于存放控制器(解析用户的输入、加工处理、返回结果)
│ ├── model/ (可选) # 用于存放数据库模型
│ ├── service/ (可选) # 用于编写业务逻辑层
│ ├── middleware/ (可选) # 用于编写中间件
│ ├── schedule/ (可选) # 用于设置定时任务
│ ├── public/ (可选) # 用于放置静态资源
│ ├── view/ (可选) # 用于放置模板文件
│ └── extend/ (可选) # 用于框架的扩展
│ ├── helper.js (可选)
│ ├── request.js (可选)
│ ├── response.js (可选)
│ ├── context.js (可选)
│ ├── application.js (可选)
│ └── agent.js (可选)
├── config/
| ├── plugin.js # 用于配置需要加载的插件
| ├── config.{env}.js # 用于编写配置文件(env 可以是 default,prod,test,local,unittest)
这是由 egg 框架或内置插件约定好的,是阿里总结出来的最佳实践,虽然框架也提供了让用户自定义目录结构的能力,但是依然建议大家采用阿里的这套方案。在接下来的篇章当中,会逐一讲解上述约定目录和文件的作用。
路由定义了**请求路径(URL)和控制器(Controller)**之间的映射关系,即用户访问的网址应交由哪个控制器进行处理。我们打开 app/router.js
看一下:
module.exports = app => {
const {
router, controller } = app
router.get('/', controller.home.index)
};
可以看到,路由文件导出了一个函数,接收 app 对象作为参数,通过下面的语法定义映射关系:
router.verb('path-match', controllerAction)
其中 verb
一般是 HTTP 动词的小写,例如:
router.head
router.options
router.get
router.put
router.post
router.patch
router.delete
或 router.del
除此之外,还有一个特殊的动词 router.redirect
表示重定向。
而 controllerAction
则是通过点(·)语法指定 controller
目录下某个文件内的某个具体函数,例如:
controller.home.index // 映射到 controller/home.js 文件的 index 方法
controller.v1.user.create // controller/v1/user.js 文件的 create 方法
下面是一些示例及其解释:
module.exports = app => {
const {
router, controller } = app
// 当用户访问 news 会交由 controller/news.js 的 index 方法进行处理
router.get('/news', controller.news.index)
// 通过冒号 `:x` 来捕获 URL 中的命名参数 x,放入 ctx.params.x
router.get('/user/:id/:name', controller.user.info)
// 通过自定义正则来捕获 URL 中的分组参数,放入 ctx.params 中
router.get(/^\/package\/([\w-.]+\/[\w-.]+)$/, controller.package.detail)
}
除了使用动词的方式创建路由之外,egg 还提供了下面的语法快速生成 CRUD 路由:
// 对 posts 按照 RESTful 风格映射到控制器 controller/posts.js 中
router.resources('posts', '/posts', controller.posts)
会自动生成下面的路由:
HTTP方法 | 请求路径 | 路由名称 | 控制器函数 |
---|---|---|---|
GET | /posts | posts | app.controller.posts.index |
GET | /posts/new | new_post | app.controller.posts.new |
GET | /posts/:id | post | app.controller.posts.show |
GET | /posts/:id/edit | edit_post | app.controller.posts.edit |
POST | /posts | posts | app.controller.posts.create |
PATCH | /posts/:id | post | app.controller.posts.update |
DELETE | /posts/:id | post | app.controller.posts.destroy |
只需要到 controller 中实现对应的方法即可。 |
当项目越来越大之后,路由映射会越来越多,我们可能希望能够将路由映射按照文件进行拆分,这个时候有两种办法:
手动引入,即把路由文件写到 app/router
目录下,然后再 app/router.js
中引入这些文件。示例代码:
// app/router.js
module.exports = app => {
require('./router/news')(app)
require('./router/admin')(app)
};
// app/router/news.js
module.exports = app => {
app.router.get('/news/list', app.controller.news.list)
app.router.get('/news/detail', app.controller.news.detail)
};
// app/router/admin.js
module.exports = app => {
app.router.get('/admin/user', app.controller.admin.user)
app.router.get('/admin/log', app.controller.admin.log)
};
使用 egg-router-plus 插件自动引入 app/router/**/*.js
,并且提供了 namespace 功能:
// app/router.js
module.exports = app => {
const subRouter = app.router.namespace('/sub')
subRouter.get('/test', app.controller.sub.test) // 最终路径为 /sub/test
}
除了 HTTP verb 之外,Router 还提供了一个 redirect 方法,用于内部重定向,例如:
module.exports = app => {
app.router.get('index', '/home/index', app.controller.home.index)
app.router.redirect('/', '/home/index', 302)
}
egg 约定一个中间件是一个放置在 app/middleware
目录下的单独文件,它需要导出一个普通的函数,该函数接受两个参数:
app.config[${middlewareName}]
传递进来。我们新建一个 middleware/slow.js
慢查询中间件,当请求时间超过我们指定的阈值,就打印日志,代码为:
module.exports = (options, app) => {
return async function (ctx, next) {
const startTime = Date.now()
await next()
const consume = Date.now() - startTime
const {
threshold = 0 } = options || {
}
if (consume > threshold) {
console.log(`${
ctx.url}请求耗时${
consume}毫秒`)
}
}
}
然后在 config.default.js
中使用:
module.exports = {
// 配置需要的中间件,数组顺序即为中间件的加载顺序
middleware: [ 'slow' ],
// slow 中间件的 options 参数
slow: {
enable: true
},
}
这里配置的中间件是全局启用的,如果只是想在指定路由中使用中间件的话,例如只针对 /api
前缀开头的 url 请求使用某个中间件的话,有两种方式:
在 config.default.js
配置中设置 match 或 ignore 属性:
module.exports = {
middleware: [ 'slow' ],
slow: {
threshold: 1,
match: '/api'
},
};
在路由文件 router.js
中引入
module.exports = app => {
const {
router, controller } = app
// 在 controller 处理之前添加任意中间件
router.get('/api/home', app.middleware.slow({
threshold: 1 }), controller.home.index)
}
egg 把中间件分成应用层定义的中间件(app.config.appMiddleware
)和框架默认中间件(app.config.coreMiddleware
),我们打印看一下:
module.exports = app => {
const {
router, controller } = app
console.log(app.config.appMiddleware)
console.log(app.config.coreMiddleware)
router.get('/api/home', app.middleware.slow({
threshold: 1 }), controller.home.index)
}
结果是:
// appMiddleware
[ 'slow' ]
// coreMiddleware
[
'meta',
'siteFile',
'notfound',
'static',
'bodyParser',
'overrideMethod',
'session',
'securities',
'i18n',
'eggLoaderTrace'
]
其中那些 coreMiddleware 是 egg 帮我们内置的中间件,默认是开启的,如果不想用,可以通过配置的方式进行关闭:
module.exports = {
i18n: {
enable: false
}
}
Controller 负责解析用户的输入,处理后返回相应的结果,一个最简单的 helloworld 示例:
const {
Controller } = require('egg');
class HomeController extends Controller {
async index() {
const {
ctx } = this;
ctx.body = 'hi, egg';
}
}
module.exports = HomeController;
当然,我们实际项目中的代码不会这么简单,通常情况下,在 Controller 中会做如下几件事情:
一个真实的案例如下:
const