egg 介绍

egg 是什么?

egg 是阿里出品的一款 node.js 后端 web 框架,基于 koa 封装,并做了一些约定。

为什么叫 egg ?

egg 有孕育的含义,因为 egg 的定位是企业级 web 基础框架,旨在帮助开发者孕育适合自己团队的框架。

哪些产品是用 egg 开发的?

语雀 就是用 egg 开发的,架构图如下:

语雀架构图

哪些公司在用 egg?

盒马,转转二手车、PingWest、小米、58同城等(技术栈选型参考链接

egg 支持 Typescript 吗?

虽然 egg 本身是用 JavaScript 写的,但是 egg 应用可以采用 Typescript 来写,使用下面的命令创建项目即可(参考链接):

$ npx egg-init --type=ts showcase

用ts写egg案例

用 JavaScript 写 egg 会有智能提示吗?

会的,只要在 package.json 中添加下面的声明之后,会在项目根目录下动态生成 typings 目录,里面包含各种模型的类型声明(参考链接):

egg: {
  declarations: true
}

egg 和 koa 是什么关系?

koa 是 egg 的基础框架,egg 是对 koa 的增强。

学习 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 <initializer> 语法,等价于 npx create-<initializer> 命令,而 npx 命令会去 $PATH 路径和 node_modules/.bin 路径下寻找名叫 create-<initializer> 的可执行文件,如果找到了就执行,找不到就去安装。

也就是说,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 iyarn 安装依赖之后,执行启动命令:

$ 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 框架或内置插件约定好的,是阿里总结出来的最佳实践,虽然框架也提供了让用户自定义目录结构的能力,但是依然建议大家采用阿里的这套方案。在接下来的篇章当中,会逐一讲解上述约定目录和文件的作用。

路由(Router)

路由定义了 请求路径(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 动词的小写,例如:

  • HEAD - router.head
  • OPTIONS - router.options
  • GET - router.get
  • PUT - router.put
  • POST - router.post
  • PATCH - router.patch
  • DELETE - router.deleterouter.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 中实现对应的方法即可。

当项目越来越大之后,路由映射会越来越多,我们可能希望能够将路由映射按照文件进行拆分,这个时候有两种办法:

  1. 手动引入,即把路由文件写到 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)
    };
    
  2. 使用 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)
}

中间件(Middleware)

egg 约定一个中间件是一个放置在 app/middleware 目录下的单独文件,它需要导出一个普通的函数,该函数接受两个参数:

  • options: 中间件的配置项,框架会将 app.config[${middlewareName}] 传递进来。
  • app: 当前应用 Application 的实例。

我们新建一个 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 请求使用某个中间件的话,有两种方式:

  1. config.default.js 配置中设置 match 或 ignore 属性:

    module.exports = {
      middleware: [ 'slow' ],
      slow: {
        threshold: 1,
        match: '/api'
      },
    };
    
  2. 在路由文件 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)

Controller 负责解析用户的输入,处理后返回相应的结果,一个最简单的 helloworld 示例:

const { Controller } = require('egg');
class HomeController extends Controller {
  async index() {
    const { ctx } = this;
    ctx.body = 'hi, egg';
  }
}
module.exports = HomeController;

当然,我们实际项目中的代码不会这么简单,通常情况下,在 Controller 中会做如下几件事情:

  • 接收、校验、处理 HTTP 请求参数
  • 向下调用服务(Service)处理业务
  • 通过 HTTP 将结果响应给用户

一个真实的案例如下:

const { Controller } = require('egg');
class PostController extends Controller {
  async create() {
    const { ctx, service } = this;
    const createRule = {
      title: { type: 'string' },
      content: { type: 'string' },
    };
    // 校验和组装参数
    ctx.validate(createRule);
    const data = Object.assign(ctx.request.body, { author: ctx.session.userId });
    // 调用 Service 进行业务处理
    const res = await service.post.create(data);
    // 响应客户端数据
    ctx.body = { id: res.id };
    ctx.status = 201;
  }
}
module.exports = PostController;

由于 Controller 是类,因此可以通过自定义基类的方式封装常用方法,例如:

// app/core/base_controller.js
const { Controller } = require('egg');
class BaseController extends Controller {
  get user() {
    return this.ctx.session.user;
  }
  success(data) {
    this.ctx.body = { success: true, data };
  }
  notFound(msg) {
    this.ctx.throw(404, msg || 'not found');
  }
}
module.exports = BaseController;

然后让所有 Controller 继承这个自定义的 BaseController:

// app/controller/post.js
const Controller = require('../core/base_controller');
class PostController extends Controller {
  async list() {
    const posts = await this.service.listByUser(this.user);
    this.success(posts);
  }
}

在 Controller 中通过 this.ctx 可以获取上下文对象,方便获取和设置相关参数,例如:

  • ctx.query:URL 中的请求参数(忽略重复 key)
  • ctx.quries:URL 中的请求参数(重复的 key 被放入数组中)
  • ctx.params:Router 上的命名参数
  • ctx.request.body:HTTP 请求体中的内容
  • ctx.request.files:前端上传的文件对象
  • ctx.getFileStream():获取上传的文件流
  • ctx.multipart():获取 multipart/form-data 数据
  • ctx.cookies:读取和设置 cookie
  • ctx.session:读取和设置 session
  • ctx.service.xxx:获取指定 service 对象的实例(懒加载)
  • ctx.status:设置状态码
  • ctx.body:设置响应体
  • ctx.set:设置响应头
  • ctx.redirect(url):重定向
  • ctx.render(template):渲染模板

this.ctx 上下文对象是 egg 框架和 koa 框架中最重要的一个对象,我们要弄清楚该对象的作用,不过需要注意的是,有些属性并非直接挂在 app.ctx 对象上,而是代理了 request 或 response 对象的属性,我们可以用 Object.keys(ctx) 看一下:

[
  'request', 'response', 'app', 'req', 'res', 'onerror', 'originalUrl', 'starttime', 'matched',
  '_matchedRoute', '_matchedRouteName', 'captures', 'params', 'routerName', 'routerPath'
]

服务(Service)

Service 是具体业务逻辑的实现,一个封装好的 Service 可供多个 Controller 调用,而一个 Controller 里面也可以调用多个 Service,虽然在 Controller 中也可以写业务逻辑,但是并不建议这么做,代码中应该保持 Controller 逻辑简洁,仅仅发挥「桥梁」作用。

Controller 可以调用任何一个 Service 上的任何方法,值得注意的是:Service 是懒加载的,即只有当访问到它的时候框架才会去实例化它。

通常情况下,在 Service 中会做如下几件事情:

  • 处理复杂业务逻辑
  • 调用数据库或第三方服务(例如 GitHub 信息获取等)

一个简单的 Service 示例,将数据库中的查询结果返回出去:

// app/service/user.js
const { Service } = require('egg').Service;

class UserService extends Service {
  async find(uid) {
    const user = await this.ctx.db.query('select * from user where uid = ?', uid);
    return user;
  }
}

module.exports = UserService;

在 Controller 中可以直接调用:

class UserController extends Controller {
  async info() {
    const { ctx } = this;
    const userId = ctx.params.id;
    const userInfo = await ctx.service.user.find(userId);
    ctx.body = userInfo;
  }
}

注意,Service 文件必须放在 app/service 目录,支持多级目录,访问的时候可以通过目录名级联访问:

app/service/biz/user.js => ctx.service.biz.user
app/service/sync_user.js => ctx.service.syncUser
app/service/HackerNews.js => ctx.service.hackerNews

Service 里面的函数,可以理解为某个具体业务逻辑的最小单元,Service 里面也可以调用其他 Service,值得注意的是:Service 不是单例,是 请求级别 的对象,框架在每次请求中首次访问 ctx.service.xx 时延迟实例化,所以 Service 中可以通过 this.ctx 获取到当前请求的上下文。

模板渲染

egg 框架内置了 egg-view 作为模板解决方案,并支持多种模板渲染,例如 ejs、handlebars、nunjunks 等模板引擎,每个模板引擎都以插件的方式引入,默认情况下,所有插件都会去找 app/view 目录下的文件,然后根据 config\config.default.js 中定义的后缀映射来选择不同的模板引擎:

config.view = {
  defaultExtension: '.nj',
  defaultViewEngine: 'nunjucks',
  mapping: {
    '.nj': 'nunjucks',
    '.hbs': 'handlebars',
    '.ejs': 'ejs',
  },
}

上面的配置表示,当文件:

  • 后缀是 .nj 时使用 nunjunks 模板引擎
  • 后缀是 .hbs 时使用 handlebars 模板引擎
  • 后缀是 .ejs 时使用 ejs 模板引擎
  • 当未指定后缀时默认为 .html
  • 当未指定模板引擎时默认为 nunjunks

接下来我们安装模板引擎插件:

$ npm i egg-view-nunjucks egg-view-ejs egg-view-handlebars --save
# 或者 yarn add egg-view-nunjucks egg-view-ejs egg-view-handlebars

然后在 config/plugin.js 中启用该插件:

exports.nunjucks = {
  enable: true,
  package: 'egg-view-nunjucks',
}
exports.handlebars = {
  enable: true,
  package: 'egg-view-handlebars',
}
exports.ejs = {
  enable: true,
  package: 'egg-view-ejs',
}

然后添加 app/view 目录,里面增加几个文件:

app/view
├── ejs.ejs
├── handlebars.hbs
└── nunjunks.nj

代码分别是:

<\\!-- ejs.ejs 文件代码 -->
<h1>ejs</h1>
<ul>
  <% items.forEach(function(item){ %>
    <li><%= item.title %></li>
  <% }); %>
</ul>
      
<\\!-- handlebars.hbs 文件代码 -->
<h1>handlebars</h1>
{{#each items}}
  <li>{{title}}</li>
{{~/each}}
    
<\\!-- nunjunks.nj 文件代码 -->
<h1>nunjunks</h1>
<ul>
{% for item in items %}
  <li>{{ item.title }}</li>
{% endfor %}
</ul>

然后在 Router 中配置路由:

module.exports = app => {
  const { router, controller } = app
  router.get('/ejs', controller.home.ejs)
  router.get('/handlebars', controller.home.handlebars)
  router.get('/nunjunks', controller.home.nunjunks)
}

接下来实现 Controller 的逻辑:

const Controller = require('egg').Controller

class HomeController extends Controller {
  async ejs() {
    const { ctx } = this
    const items = await ctx.service.view.getItems()
    await ctx.render('ejs.ejs', {items})
  }

  async handlebars() {
    const { ctx } = this
    const items = await ctx.service.view.getItems()
    await ctx.render('handlebars.hbs', {items})
  }

  async nunjunks() {
    const { ctx } = this
    const items = await ctx.service.view.getItems()
    await ctx.render('nunjunks.nj', {items})
  }
}

module.exports = HomeController

我们把数据放到了 Service 里面:

const { Service } = require('egg')

class ViewService extends Service {
  getItems() {
    return [
      { title: 'foo', id: 1 },
      { title: 'bar', id: 2 },
    ]
  }
}

module.exports = ViewService

访问下面的地址可以查看不同模板引擎渲染出的结果:

GET http://localhost:7001/nunjunks
GET http://localhost:7001/handlebars
GET http://localhost:7001/ejs

你可能会问,ctx.render 方法是哪来的呢?没错,是由 egg-view 对 context 进行扩展而提供的,为 ctx 上下文对象增加了 renderrenderViewrenderString 三个方法,代码如下:

const ContextView = require('../../lib/context_view')
const VIEW = Symbol('Context#view')

module.exports = {
  render(...args) {
    return this.renderView(...args).then(body => {
      this.body = body;
    })
  },

  renderView(...args) {
    return this.view.render(...args);
  },

  renderString(...args) {
    return this.view.renderString(...args);
  },

  get view() {
    if (this[VIEW]) return this[VIEW]
    return this[VIEW] = new ContextView(this)
  }
}

它内部最终会把调用转发给 ContextView 实例上的 render 方法,ContextView 是一个能够根据配置里面定义的 mapping,帮助我们找到对应渲染引擎的类。

插件

上节课讲解模板渲染的时候,我们已经知道如何使用插件了,即只需要在应用或框架的 config/plugin.js 中声明:

exports.myPlugin = {
  enable: true, // 是否开启
  package: 'egg-myPlugin', // 从 node_modules 中引入
  path: path.join(__dirname, '../lib/plugin/egg-mysql'), // 从本地目录中引入
  env: ['local', 'unittest', 'prod'], // 只有在指定运行环境才能开启
}

开启插件后,就可以使用插件提供的功能了:

app.myPlugin.xxx()

如果插件包含需要用户自定义的配置,可以在 config.default.js 进行指定,例如:

exports.myPlugin = {
  hello: 'world'
}

一个插件其实就是一个『迷你的应用』,包含了 Service中间件配置框架扩展等,但是没有独立的 RouterController,也不能定义自己的 plugin.js

在开发中必不可少要连接数据库,最实用的插件就是数据库集成的插件了。

集成 MongoDB

首先确保电脑中已安装并启动 MongoDB 数据库,如果是 Mac 电脑,可以用下面的命令快速安装和启动:

$ brew install mongodb-community
$ brew services start mongodb/brew/mongodb-community # 后台启动
# 或者使用 mongod --config /usr/local/etc/mongod.conf 前台启动

然后安装 egg-mongoose 插件:

$ npm i egg-mongoose
# 或者 yarn add egg-mongoose

config/plugin.js 中开启插件:

exports.mongoose = {
  enable: true,
  package: 'egg-mongoose',
}

config/config.default.js 中定义连接参数:

config.mongoose = {
  client: {
    url: 'mongodb://127.0.0.1/example',
    options: {}
  }
}

然后在 model/user.js 中定义模型:

module.exports = app => {
  const mongoose = app.mongoose
  const UserSchema = new mongoose.Schema(
    {
      username: {type: String, required: true, unique: true}, // 用户名
      password: {type: String, required: true}, // 密码
    },
    { timestamps: true } // 自动生成 createdAt 和 updatedAt 时间戳
  )
  return mongoose.model('user', UserSchema)
}

在控制器中调用 mongoose 的方法:

const {Controller} = require('egg')

class UserController extends Controller {
  // 用户列表 GET /users
  async index() {
    const {ctx} = this
    ctx.body = await ctx.model.User.find({})
  }

  // 用户详情 GET /users/:id
  async show() {
    const {ctx} = this
    ctx.body = await ctx.model.User.findById(ctx.params.id)
  }

  // 创建用户 POST /users
  async create() {
    const {ctx} = this
    ctx.body = await ctx.model.User.create(ctx.request.body)
  }

  // 更新用户 PUT /users/:id
  async update() {
    const {ctx} = this
    ctx.body = await ctx.model.User.findByIdAndUpdate(ctx.params.id, ctx.request.body)
  }

  // 删除用户 DELETE /users/:id
  async destroy() {
    const {ctx} = this
    ctx.body = await ctx.model.User.findByIdAndRemove(ctx.params.id)
  }
}

module.exports = UserController

最后配置 RESTful 路由映射:

module.exports = app => {
  const {router, controller} = app
  router.resources('users', '/users', controller.user)
}

集成 MySQL

首先确保电脑中已安装 MySQL 数据库,如果是 Mac 电脑,可通过下面的命令快速安装和启动:

$ brew install mysql
$ brew services start mysql # 后台启动
# 或者 mysql.server start 前台启动
$ mysql_secure_installation # 设置密码

官方有个 egg-mysql 插件,可以连接 MySQL 数据库,使用方法非常简单:

$ npm i egg-mysql
# 或者 yarn add egg-mysql

config/plugin.js 中开启插件:

exports.mysql = {
  enable: true,
  package: 'egg-mysql',
}

config/config.default.js 中定义连接参数:

config.mysql = {
  client: {
    host: 'localhost',
    port: '3306',
    user: 'root',
    password: 'root',
    database: 'cms',
  }
}

然后就能在 Controller 或 Service 的 app.mysql 中获取到 mysql 对象,例如:

class UserService extends Service {
  async find(uid) {
    const user = await this.app.mysql.get('users', { id: 11 });
    return { user }
  }
}

如果启动的时候报错:

ERROR 5954 nodejs.ER_NOT_SUPPORTED_AUTH_MODEError: ER_NOT_SUPPORTED_AUTH_MODE: Client does not support authentication protocol requested by server; consider upgrading MySQL client

是因为你使用了 MySQL 8.x 版本,而 egg-mysql 依赖了 ali-rds 这个包,这是阿里自己封装的包,里面又依赖了 mysql 这个包,而这个包已经废弃,不支持 caching_sha2_password 加密方式导致的。可以在 MySQL workbench 中运行下面的命令来解决:

ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password'
flush privileges

但是更好的集成 MySQL 的方式是借助 ORM 框架来帮助我们管理数据层的代码,sequelize 是当前最流行的 ORM 框架,它支持 MySQL、PostgreSQL、SQLite 和 MSSQL 等多个数据源,接下来我们使用 sequelize 来连接 MySQL 数据库,首先安装依赖:

npm install egg-sequelize mysql2 --save 
yarn add egg-sequelize mysql2

然后在 config/plugin.js 中开启 egg-sequelize 插件:

exports.sequelize = {
  enable: true,
  package: 'egg-sequelize',
}

同样要在 config/config.default.js 中编写 sequelize 配置

config.sequelize = {
  dialect: 'mysql',
  host: '127.0.0.1',
  port: 3306,
  database: 'example',
}

然后在 egg_example 库中创建 books 表:

CREATE TABLE `books` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'primary key',
  `name` varchar(30) DEFAULT NULL COMMENT 'book name',
  `created_at` datetime DEFAULT NULL COMMENT 'created time',
  `updated_at` datetime DEFAULT NULL COMMENT 'updated time',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='book';

创建 model/book.js 文件,代码是:

module.exports = app => {
  const { STRING, INTEGER } = app.Sequelize
  const Book = app.model.define('book', {
    id: { type: INTEGER, primaryKey: true, autoIncrement: true },
    name: STRING(30),
  })
  return Book
}

添加 controller/book.js 控制器:

const Controller = require('egg').Controller

class BookController extends Controller {
  async index() {
    const ctx = this.ctx
    ctx.body = await ctx.model.Book.findAll({})
  }

  async show() {
    const ctx = this.ctx
    ctx.body = await ctx.model.Book.findByPk(+ctx.params.id)
  }

  async create() {
    const ctx = this.ctx
    ctx.body = await ctx.model.Book.create(ctx.request.body)
  }

  async update() {
    const ctx = this.ctx
    const book = await ctx.model.Book.findByPk(+ctx.params.id)
    if (!book) return (ctx.status = 404)
    await book.update(ctx.request.body)
    ctx.body = book
  }

  async destroy() {
    const ctx = this.ctx
    const book = await ctx.model.Book.findByPk(+ctx.params.id)
    if (!book) return (ctx.status = 404)
    await book.destroy()
    ctx.body = book
  }
}

module.exports = BookController

最后配置 RESTful 路由映射:

module.exports = app => {
  const {router, controller} = app
  router.resources('books', '/books', controller.book)
}

自定义插件

掌握了插件的使用,接下来就要讲讲如何自己写插件了,首先根据插件脚手架模板创建一个插件项目:

npm init egg --type=plugin
# 或者 yarn create egg --type=plugin

默认的目录结构为:

├── config
│   └── config.default.js
├── package.json

插件没有独立的 router 和 controller,并且需要在 package.json 中的 eggPlugin 节点指定插件特有的信息,例如:

{
  eggPlugin: {
    name: myPlugin,
    dependencies: [ registry ],
    optionalDependencies: [ vip ],
    env: [ local, test, unittest, prod ]
  }
}

上述字段的含义为:

  • name - 插件名,配置依赖关系时会指定依赖插件的 name。
  • dependencies - 当前插件强依赖的插件列表(如果依赖的插件没找到,应用启动失败)。
  • optionalDependencies - 当前插件的可选依赖插件列表(如果依赖的插件未开启,只会 warning,不会影响应用启动)。
  • env - 指定在某些运行环境才开启当前插件

那插件里面能做什么呢?

  • 扩展内置对象:跟应用一样,在 app/extend/ 目录下定义 request.jsresponse.js 等文件。

    例如 egg-bcrypt 库只是简单的对 extend.js 进行了扩展:

    egg-bcrypt

    在项目中直接调用 ctx.genHash(plainText)ctx.compare(plainText, hash) 即可。

  • 插入自定义中间件:在 app/middleware 中写中间件,在 app.js 中使用

    例如 egg-cors 库就是定义了一个 cors.js 中间件,该中间件就是原封不动的用了 koa-cors

    egg-cors

    直接在 config/config.default.js 中进行配置,例如:

    exports.cors = {
      origin: '*',
      allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH'
    }
    
  • 在启动时做一些初始化工作:在 app.js 中添加同步或异步初始化代码

    例如 egg-elasticsearch 代码:

    egg-elasticsearch

    只是在启动前建立了一个 ES 连接而已,beforeStart 方法中还可以定义异步启动逻辑,虽然上面的代码是同步的,即用不用 beforeStart 封装都无所谓,但是如果有异步逻辑的话,可以封装一个 async 函数。

  • 设置定时任务:在 app/schedule/ 目录下添加定时任务,定时任务下一节会详细讲。

定时任务

一个复杂的业务场景中,不可避免会有定时任务的需求,例如:

  • 每天检查一下是否有用户过生日,自动发送生日祝福
  • 每天备份一下数据库,防止操作不当导致数据丢失
  • 每周删除一次临时文件,释放磁盘空间
  • 定时从远程接口获取数据,更新本地缓存

egg 框架提供了定时任务功能,在 app/schedule 目录下,每一个文件都是一个独立的定时任务,可以配置定时任务的属性和要执行的方法,例如创建一个 update_cache.js 的更新缓存任务,每分钟执行一次:

const Subscription = require('egg').Subscription

class UpdateCache extends Subscription {
  // 通过 schedule 属性来设置定时任务的执行间隔等配置
  static get schedule() {
    return {
      interval: '1m', // 1 分钟间隔
      type: 'all', // 指定所有的 worker 都需要执行
    }
  }

  // subscribe 是真正定时任务执行时被运行的函数
  async subscribe() {
    const res = await this.ctx.curl('http://www.api.com/cache', {
      dataType: 'json',
    })
    this.ctx.app.cache = res.data
  }
}

module.exports = UpdateCache

也就是说,egg 会从静态访问器属性 schedule 中获取定时任务的配置,然后按照配置来执行 subscribe 方法。执行任务的时机可以用 interval 或者 cron 两种方式来指定:

  • interval 可以使数字或字符串,如果是数字则表示毫秒数,例如 5000 就是 5 秒,如果是字符类型,会通过 ms 这个包转换成毫秒数,例如 5 秒可以直接写成 5s

  • cron 表达式则通过 cron-parser 进行解析,语法为:

  *    *    *    *    *    *
  ┬    ┬    ┬    ┬    ┬    ┬
  │    │    │    │    │    |
  │    │    │    │    │    └ day of week (0 - 7) (0 or 7 is Sun)
  │    │    │    │    └───── month (1 - 12)
  │    │    │    └────────── day of month (1 - 31)
  │    │    └─────────────── hour (0 - 23)
  │    └──────────────────── minute (0 - 59)
  └───────────────────────── second (0 - 59, optional)

执行任务的类型有两种:

  • worker 类型:只有一个 worker 会执行这个定时任务(随机选择)
  • all 类型:每个 worker 都会执行这个定时任务

使用哪种类型要看具体的业务了,例如更新缓存的任务肯定是选择 all,而备份数据库的任务选择 worker 就够了,否则会重复备份。

有一些场景我们可能需要手动的执行定时任务,例如应用启动时的初始化任务,可以通过 app.runSchedule(schedulePath) 来运行。app.runSchedule 接受一个定时任务文件路径(app/schedule 目录下的相对路径或者完整的绝对路径),在 app.js 中代码为:

module.exports = app => {
  app.beforeStart(async () => {
    // 程序启动前确保缓存已更新
    await app.runSchedule('update_cache')
  })
}

错误处理

在开发环境下会提供非常友好的可视化界面帮助开发者定位问题,例如当我们把 model.User 换成小写之后调用该方法:

egg-error

直接定位到错误所在的行,方便开发者快速调试。不过放心,在生产环境下,egg 不会把错误栈暴露给用户,而是返回下面的错误提示:

Internal Server Error, real status: 500

如果我们的项目是前后端分离的,所有返回都是 JSON 格式的话,可以在 config/plugin.js 中进行如下配置:

module.exports = {
  onerror: {
    accepts: () => 'json',
  },
};

那么就会把错误调用栈直接以 JSON 的格式返回:

{
    message: Cannot read property 'find' of undefined,
    stack: TypeError: Cannot read property 'find' of undefined\\
    at UserController.index (/Users/keliq/code/egg-project/app/controller/user.js:7:37),
    name: TypeError,
    status: 500
}

accepts 函数是 content negotiation 的思想的具体实现,即让用户自己决定以何种格式返回,这也体现了 egg 极大的灵活性,例如我们希望当 content-type 为 `` 的时候返回 JSON 格式,而其他情况下则返回 HTML,可以这么写:

module.exports = {
  onerror: {
      accepts: (ctx) => {
        if (ctx.get('content-type') === 'application/json') return 'json';
        return 'html';
      }
  },
};

不过我们也可以在 config/config.default.js 中自定义错误:

module.exports = {
  onerror: {
    errorPageUrl: '/public/error.html',
  },
};

此时生产环境的报错会被重定向到该路径,并在后面带上了参数 ?real_status=500。实际上,egg 的错误是由内置插件 egg-onerror 来处理的,一个请求的所有处理方法(Middleware、Controller、Service)中抛出的任何异常都会被它捕获,并自动根据请求想要获取的类型返回不同类型的错误:

module.exports = {
  onerror: {
    all(err, ctx) {
      // 在此处定义针对所有响应类型的错误处理方法
      // 注意,定义了 config.all 之后,其他错误处理方法不会再生效
      ctx.body = 'error'
      ctx.status = 500
    },
    html(err, ctx) { // 处理 html hander
      ctx.body = '<h3>error</h3>'
      ctx.status = 500
    },
    json(err, ctx) { // json hander
      ctx.body = {message: 'error'}
      ctx.status = 500
    },
  },
}

不过有一点需要注意的是:框架并不会将服务端返回的 404 状态当做异常来处理,egg 如果发现状态码是 404 且没有 body 时,会做出如下的默认响应:

  • 当请求为 JSON 时,会返回 JSON:{ message: Not Found }

  • 当请求为 HTML 时,会返回 HTML:<h1>404 Not Found</h1>

很多厂都是自己写 404 页面的,如果你也有这个需求,也可以自己写一个 HTML,然后在 config/config.default.js 中指定:

module.exports = {
  notfound: {
    pageUrl: '/404.html',
  }
}

但是上面只是将默认的 HTML 请求的 404 响应重定向到指定的页面,如果你想和自定义异常处理一样,完全自定义服务器 404 时的响应,包括定制 JSON 返回的话,只需要加入一个 middleware/notfound_handler.js 中间件:

module.exports = () => {
  return async function (ctx, next) {
    await next()
    if (ctx.status === 404 && !ctx.body) {
      ctx.body = ctx.acceptJSON ? { error: 'Not Found' } : '<h1>Page Not Found</h1>'
    }
  }
}

当然,别忘了在 config/config.default.js 中引入该中间件:

config.middleware = ['notfoundHandler']

生命周期

在 egg 启动的过程中,提供了下面几个生命周期钩子方便大家调用:

  • 配置文件即将加载,这是最后动态修改配置的时机(configWillLoad
  • 配置文件加载完成(configDidLoad
  • 文件加载完成(didLoad
  • 插件启动完毕(willReady
  • worker 准备就绪(didReady
  • 应用启动完成(serverDidReady
  • 应用即将关闭(beforeClose

只要在项目根目录中创建 app.js,添加并导出一个类即可:

class AppBootHook {
  constructor(app) {
    this.app = app
  }

  configWillLoad() {
    // config 文件已经被读取并合并,但是还并未生效,这是应用层修改配置的最后时机
    // 注意:此函数只支持同步调用
  }

  configDidLoad() {
    // 所有的配置已经加载完毕,可以用来加载应用自定义的文件,启动自定义的服务
  }

  async didLoad() {
    // 所有的配置已经加载完毕,可以用来加载应用自定义的文件,启动自定义的服务
  }

  async willReady() {
    // 所有的插件都已启动完毕,但是应用整体还未 ready
    // 可以做一些数据初始化等操作,这些操作成功才会启动应用
  }

  async didReady() {
    // 应用已经启动完毕
  }

  async serverDidReady() {
    // http / https server 已启动,开始接受外部请求
    // 此时可以从 app.server 拿到 server 的实例
  }

  async beforeClose() {
    // 应用即将关闭
  }
}

module.exports = AppBootHook

图解

egg-lifecycle

框架扩展

egg 框架提供了下面几个扩展点

  • Application: Koa 的全局应用对象(应用级别),全局只有一个,在应用启动时被创建
  • Context:Koa 的请求上下文对象(请求级别),每次请求生成一个 Context 实例
  • Request:Koa 的 Request 对象(请求级别),提供请求相关的属性和方法
  • Response:Koa 的 Response 对象(请求级别),提供响应相关的属性和方法
  • Helper:用来提供一些实用的 utility 函数

也就是说,开发者可以对上述框架内置对象进行任意扩展。扩展的写法为:

const BAR = Symbol('bar') 

module.exports = {
  foo(param) {}, // 扩展方法
  get bar() { // 扩展属性
    if (!this[BAR]) {
      this[BAR] = this.get('x-bar')
    }
    return this[BAR]
  },
}

扩展点方法里面的 this 就指代扩展点对象自身,扩展的本质就是将用户自定义的对象合并到 Koa 扩展点对象的原型上面,即:

  • 扩展 Application 就是把 app/extend/application.js 中定义的对象与 Koa Application 的 prototype 对象进行合并,在应用启动时会基于扩展后的 prototype 生成 app 对象,可通过 ctx.app.xxx 来进行访问:
  • 扩展 Context 就是把 app/extend/context.js 中定义的对象与 Koa Context 的 prototype 对象进行合并,在处理请求时会基于扩展后的 prototype 生成 ctx 对象。
  • 扩展 Request/Response 就是把 app/extend/<request|response>.js 中定义的对象与内置 requestresponse 的 prototype 对象进行合并,在处理请求时会基于扩展后的 prototype 生成requestresponse 对象。
  • 扩展 Helper 就是把 app/extend/helper.js 中定义的对象与内置 helper 的 prototype 对象进行合并,在处理请求时会基于扩展后的 prototype 生成 helper 对象。

定制框架

egg 最为强大的功能就是允许团队自定义框架,也就是说可以基于 egg 来封装上层框架,只需要扩展两个类:

  • Application:App Worker 启动时会实例化 Application,单例
  • Agent:Agent Worker 启动的时候会实例化 Agent,单例

定制框架步骤:

npm init egg --type=framework --registry=china
# 或者 yarn create egg --type=framework --registry=china

生成如下目录结构:

├── app
│   ├── extend
│   │   ├── application.js
│   │   └── context.js
│   └── service
│       └── test.js
├── config
│   ├── config.default.js
│   └── plugin.js
├── index.js
├── lib
│   └── framework.js
├── package.json

可以看到,除了多了一个 lib 目录之外,其他的结构跟普通的 egg 项目并没有任何区别,我们看一下 lib/framework.js 中的代码:

const path = require('path')
const egg = require('egg')
const EGG_PATH = Symbol.for('egg#eggPath')

class Application extends egg.Application {
  get [EGG_PATH]() {
    return path.dirname(__dirname)
  }
}

class Agent extends egg.Agent {
  get [EGG_PATH]() {
    return path.dirname(__dirname)
  }
}

module.exports = Object.assign(egg, {
  Application,
  Agent,
})

可以看到,只是自定义了 Application 和 Agent 两个类,然后挂载到 egg 对象上面而已。而这两个自定义的类里面将访问器属性 Symbol.for('egg#eggPath') 赋值为 path.dirname(__dirname),也就是框架的根目录。为了能够在本地测试自定义框架,我们首先去框架项目(假设叫 my-framework)下运行:

npm link # 或者 yarn link

然后到 egg 项目下运行:

npm link my-framework

最后在 egg 项目的 package.json 中添加下面的代码即可:

egg: {
  framework: my-framework
},

自定义框架的实现原理是基于类的继承,每一层框架都必须继承上一层框架并且指定 eggPath,然后遍历原型链就能获取每一层的框架路径,原型链下面的框架优先级更高,例如:部门框架(department)> 企业框架(enterprise)> Egg

const Application = require('egg').Application
// 继承 egg 的 Application
class Enterprise extends Application {
  get [EGG_PATH]() {
    return '/path/to/enterprise'
  }
}

const Application = require('enterprise').Application
// 继承 enterprise 的 Application
class Department extends Application {
  get [EGG_PATH]() {
    return '/path/to/department'
  }
}

定时框架的好处就是层层递进的业务逻辑复用,不同部门框架直接用公司框架里面的写好的业务逻辑,然后补充自己的业务逻辑。虽然插件也能达到代码复用的效果,但是业务逻辑不好封装成插件,封装成框架会更好一些,下面就是应用、框架和插件的区别:

文件 应用 框架 插件
package.json
config/plugin.{env}.js
config/config.{env}.js
app/extend/application.js
app/extend/request.js
app/extend/response.js
app/extend/context.js
app/extend/helper.js
agent.js
app.js
app/service
app/middleware
app/controller
app/router.js

除了使用 Symbol.for('egg#eggPath') 来指定当前框架的路径实现继承之外,还可以自定义加载器,只需要提供 Symbol.for('egg#loader') 访问器属性并自定义 AppWorkerLoader 即可:

const path = require('path')
const egg = require('egg')
const EGG_PATH = Symbol.for('egg#eggPath')
const EGG_LOADER = Symbol.for('egg#loader')

class MyAppWorkerLoader extends egg.AppWorkerLoader {
  // 自定义的 AppWorkerLoader
}

class Application extends egg.Application {
  get [EGG_PATH]() {
    return path.dirname(__dirname)
  }

  get [EGG_LOADER]() {
    return MyAppWorkerLoader
  }
}

AppWorkerLoader 继承自 egg-core 的 EggLoader,它是一个基类,根据文件加载的规则提供了一些内置的方法,它本身并不会去调用这些方法,而是由继承类调用。

  • loadPlugin()
  • loadConfig()
  • loadAgentExtend()
  • loadApplicationExtend()
  • loadRequestExtend()
  • loadResponseExtend()
  • loadContextExtend()
  • loadHelperExtend()
  • loadCustomAgent()
  • loadCustomApp()
  • loadService()
  • loadMiddleware()
  • loadController()
  • loadRouter()

也就是说我们自定义的 AppWorkerLoader 中可以重写这些方法:

const {AppWorkerLoader} = require('egg')
const {EggLoader} = require('egg-core')

// 如果需要改变加载顺序,则需要继承 EggLoader,否则可以继承 AppWorkerLoader
class MyAppWorkerLoader extends AppWorkerLoader {
  constructor(options) {
    super(options)
  }

  load() {
    super.load()
    console.log('自定义load逻辑')
  }

  loadPlugin() {
    super.loadPlugin()
    console.log('自定义plugin加载逻辑')
  }

  loadConfig() {
    super.loadConfig()
    console.log('自定义config加载逻辑')
  }

  loadAgentExtend() {
    super.loadAgentExtend()
    console.log('自定义agent extend加载逻辑')
  }

  loadApplicationExtend() {
    super.loadApplicationExtend()
    console.log('自定义application extend加载逻辑')
  }

  loadRequestExtend() {
    super.loadRequestExtend()
    console.log('自定义request extend加载逻辑')
  }

  loadResponseExtend() {
    super.loadResponseExtend()
    console.log('自定义response extend加载逻辑')
  }

  loadContextExtend() {
    super.loadContextExtend()
    console.log('自定义context extend加载逻辑')
  }

  loadHelperExtend() {
    super.loadHelperExtend()
    console.log('自定义helper extend加载逻辑')
  }

  loadCustomAgent() {
    super.loadCustomAgent()
    console.log('自定义custom agent加载逻辑')
  }

  loadCustomApp() {
    super.loadCustomApp()
    console.log('自定义custom app加载逻辑')
  }

  loadService() {
    super.loadService()
    console.log('自定义service加载逻辑')
  }

  loadMiddleware() {
    super.loadMiddleware()
    console.log('自定义middleware加载逻辑')
  }

  loadController() {
    super.loadController()
    console.log('自定义controller加载逻辑')
  }

  loadRouter() {
    super.loadRouter()
    console.log('自定义router加载逻辑')
  }
}

最后的输出结果为:

自定义plugin加载逻辑
自定义config加载逻辑
自定义application extend加载逻辑
自定义request extend加载逻辑
自定义response extend加载逻辑
自定义context extend加载逻辑
自定义helper extend加载逻辑
自定义custom app加载逻辑
自定义service加载逻辑
自定义middleware加载逻辑
自定义controller加载逻辑
自定义router加载逻辑
自定义load逻辑

从输出结果能够看出默认情况下的加载顺序。如此以来,框架的加载逻辑可以完全交给开发者,如何加载 Controller、Service、Router 等。

Creativity requires the courage to let go of certainties.