• 学习egg.js,看这一篇就够了!


    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
    
    • 1

    用ts写egg案例

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

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

    "egg": {
      "declarations": true
    }
    
    • 1
    • 2
    • 3

    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
    
    • 1
    • 2

    解释一下 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    这就是最小化的 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)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    打开 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)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

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

    路由(Router)

    路由定义了**请求路径(URL)控制器(Controller)**之间的映射关系,即用户访问的网址应交由哪个控制器进行处理。我们打开 app/router.js 看一下:

    module.exports = app => {
      const { router, controller } = app
      router.get('/', controller.home.index)
    };
    
    • 1
    • 2
    • 3
    • 4

    可以看到,路由文件导出了一个函数,接收 app 对象作为参数,通过下面的语法定义映射关系:

    router.verb('path-match', controllerAction)
    
    • 1

    其中 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 方法
    
    • 1
    • 2

    下面是一些示例及其解释:

    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)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    除了使用动词的方式创建路由之外,egg 还提供了下面的语法快速生成 CRUD 路由:

    // 对 posts 按照 RESTful 风格映射到控制器 controller/posts.js 中
    router.resources('posts', '/posts', controller.posts)
    
    • 1
    • 2

    会自动生成下面的路由:

    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)
      };
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
    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
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5

    除了 HTTP verb 之外,Router 还提供了一个 redirect 方法,用于内部重定向,例如:

    module.exports = app => {
      app.router.get('index', '/home/index', app.controller.home.index)
      app.router.redirect('/', '/home/index', 302)
    }
    
    • 1
    • 2
    • 3
    • 4

    中间件(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}毫秒`)
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    然后在 config.default.js 中使用:

    module.exports = {
      // 配置需要的中间件,数组顺序即为中间件的加载顺序
      middleware: [ 'slow' ],
      // slow 中间件的 options 参数
      slow: {
        enable: true
      },
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    这里配置的中间件是全局启用的,如果只是想在指定路由中使用中间件的话,例如只针对 /api 前缀开头的 url 请求使用某个中间件的话,有两种方式:

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

      module.exports = {
        middleware: [ 'slow' ],
        slow: {
          threshold: 1,
          match: '/api'
        },
      };
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
    2. 在路由文件 router.js 中引入

      module.exports = app => {
        const { router, controller } = app
        // 在 controller 处理之前添加任意中间件
        router.get('/api/home', app.middleware.slow({ threshold: 1 }), controller.home.index)
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5

    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)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    结果是:

    // appMiddleware
    [ 'slow' ] 
    // coreMiddleware
    [
      'meta',
      'siteFile',
      'notfound',
      'static',
      'bodyParser',
      'overrideMethod',
      'session',
      'securities',
      'i18n',
      'eggLoaderTrace'
    ]
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    其中那些 coreMiddleware 是 egg 帮我们内置的中间件,默认是开启的,如果不想用,可以通过配置的方式进行关闭:

    module.exports = {
      i18n: {
        enable: false
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    控制器(Controller)

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

    const { Controller } = require('egg');
    class HomeController extends Controller {
      async index() {
        const { ctx } = this;
        ctx.body = 'hi, egg';
      }
    }
    module.exports = HomeController;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    当然,我们实际项目中的代码不会这么简单,通常情况下,在 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;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    由于 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;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    然后让所有 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);
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    在 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'
    ]
    
    • 1
    • 2
    • 3
    • 4

    服务(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;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    在 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;
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    注意,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
    
    • 1
    • 2
    • 3

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

    模板渲染

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

    config.view = {
      defaultExtension: '.nj',
      defaultViewEngine: 'nunjucks',
      mapping: {
        '.nj': 'nunjucks',
        '.hbs': 'handlebars',
        '.ejs': 'ejs',
      },
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

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

    • 后缀是 .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
    
    • 1
    • 2

    然后在 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',
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

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

    app/view
    ├── ejs.ejs
    ├── handlebars.hbs
    └── nunjunks.nj
    
    • 1
    • 2
    • 3
    • 4

    代码分别是:

    
    

    ejs

      <% items.forEach(function(item){ %>
    • <%= item.title %>
    • <% }); %>

    handlebars

    {{#each items}}
  • {{title}}
  • {{~/each}}

    nunjunks

      {% for item in items %}
    • {{ item.title }}
    • {% endfor %}
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    然后在 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)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    接下来实现 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

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

    const { Service } = require('egg')
    
    class ViewService extends Service {
      getItems() {
        return [
          { title: 'foo', id: 1 },
          { title: 'bar', id: 2 },
        ]
      }
    }
    
    module.exports = ViewService
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

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

    GET http://localhost:7001/nunjunks
    GET http://localhost:7001/handlebars
    GET http://localhost:7001/ejs
    
    • 1
    • 2
    • 3

    你可能会问,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)
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    它内部最终会把调用转发给 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'], // 只有在指定运行环境才能开启
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

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

    app.myPlugin.xxx()
    
    • 1

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

    exports.myPlugin = {
      hello: 'world'
    }
    
    • 1
    • 2
    • 3

    一个插件其实就是一个『迷你的应用』,包含了 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 前台启动
    
    • 1
    • 2
    • 3

    然后安装 egg-mongoose 插件:

    $ npm i egg-mongoose
    # 或者 yarn add egg-mongoose
    
    • 1
    • 2

    config/plugin.js 中开启插件:

    exports.mongoose = {
      enable: true,
      package: 'egg-mongoose',
    }
    
    • 1
    • 2
    • 3
    • 4

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

    config.mongoose = {
      client: {
        url: 'mongodb://127.0.0.1/example',
        options: {}
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    然后在 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)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    在控制器中调用 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35

    最后配置 RESTful 路由映射:

    module.exports = app => {
      const {router, controller} = app
      router.resources('users', '/users', controller.user)
    }
    
    • 1
    • 2
    • 3
    • 4

    集成 MySQL

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

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

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

    $ npm i egg-mysql
    # 或者 yarn add egg-mysql
    
    • 1
    • 2

    config/plugin.js 中开启插件:

    exports.mysql = {
      enable: true,
      package: 'egg-mysql',
    }
    
    • 1
    • 2
    • 3
    • 4

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

    config.mysql = {
      client: {
        host: 'localhost',
        port: '3306',
        user: 'root',
        password: 'root',
        database: 'cms',
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

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

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

    如果启动的时候报错:

    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
    
    • 1

    是因为你使用了 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
    
    • 1
    • 2

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

    npm install egg-sequelize mysql2 --save 
    yarn add egg-sequelize mysql2
    
    • 1
    • 2

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

    exports.sequelize = {
      enable: true,
      package: 'egg-sequelize',
    }
    
    • 1
    • 2
    • 3
    • 4

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

    config.sequelize = {
      dialect: 'mysql',
      host: '127.0.0.1',
      port: 3306,
      database: 'example',
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    然后在 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';
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    创建 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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    添加 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    最后配置 RESTful 路由映射:

    module.exports = app => {
      const {router, controller} = app
      router.resources('books', '/books', controller.book)
    }
    
    • 1
    • 2
    • 3
    • 4

    自定义插件

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

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

    默认的目录结构为:

    ├── config
    │   └── config.default.js
    ├── package.json
    
    • 1
    • 2
    • 3

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

    {
      "eggPlugin": {
        "name": "myPlugin",
        "dependencies": [ "registry" ],
        "optionalDependencies": [ "vip" ],
        "env": [ "local", "test", "unittest", "prod" ]
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    上述字段的含义为:

    • 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'
      }
      
      • 1
      • 2
      • 3
      • 4
    • 在启动时做一些初始化工作:在 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    也就是说,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)
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9

    执行任务的类型有两种:

    • 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')
      })
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    错误处理

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

    egg-error

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

    Internal Server Error, real status: 500
    
    • 1

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

    module.exports = {
      onerror: {
        accepts: () => 'json',
      },
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5

    那么就会把错误调用栈直接以 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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

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

    module.exports = {
      onerror: {
          accepts: (ctx) => {
            if (ctx.get('content-type') === 'application/json') return 'json';
            return 'html';
          }
      },
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

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

    module.exports = {
      onerror: {
        errorPageUrl: '/public/error.html',
      },
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5

    此时生产环境的报错会被重定向到该路径,并在后面带上了参数 ?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 = '

    error

    ' ctx.status = 500 }, json(err, ctx) { // json hander ctx.body = {message: 'error'} ctx.status = 500 }, }, }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

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

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

    • 当请求为 HTML 时,会返回 HTML:

      404 Not Found

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

    module.exports = {
      notfound: {
        pageUrl: '/404.html',
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    但是上面只是将默认的 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' } : '

    Page Not Found

    ' } } }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

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

    config.middleware = ['notfoundHandler']
    
    • 1

    生命周期

    在 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38

    图解

    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]
      },
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    扩展点方法里面的 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/.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
    
    • 1
    • 2

    生成如下目录结构:

    ├── app
    │?? ├── extend
    │?? │?? ├── application.js
    │?? │?? └── context.js
    │?? └── service
    │??     └── test.js
    ├── config
    │?? ├── config.default.js
    │?? └── plugin.js
    ├── index.js
    ├── lib
    │?? └── framework.js
    ├── package.json
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    可以看到,除了多了一个 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,
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

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

    npm link # 或者 yarn link
    
    • 1

    然后到 egg 项目下运行:

    npm link my-framework
    
    • 1

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

    "egg": {
      "framework": "my-framework"
    },
    
    • 1
    • 2
    • 3

    自定义框架的实现原理是基于类的继承,每一层框架都必须继承上一层框架并且指定 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'
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

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

    文件

    应用

    框架

    插件

    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
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    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加载逻辑')
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84

    最后的输出结果为:

    自定义plugin加载逻辑
    自定义config加载逻辑
    自定义application extend加载逻辑
    自定义request extend加载逻辑
    自定义response extend加载逻辑
    自定义context extend加载逻辑
    自定义helper extend加载逻辑
    自定义custom app加载逻辑
    自定义service加载逻辑
    自定义middleware加载逻辑
    自定义controller加载逻辑
    自定义router加载逻辑
    自定义load逻辑
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

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

    先自我介绍一下,小编13年上师交大毕业,曾经在小公司待过,去过华为OPPO等大厂,18年进入阿里,直到现在。深知大多数初中级java工程师,想要升技能,往往是需要自己摸索成长或是报班学习,但对于培训机构动则近万元的学费,着实压力不小。自己不成体系的自学效率很低又漫长,而且容易碰到天花板技术停止不前。因此我收集了一份《java开发全套学习资料》送给大家,初衷也很简单,就是希望帮助到想自学又不知道该从何学起的朋友,同时减轻大家的负担。添加下方名片,即可获取全套学习资料哦

  • 相关阅读:
    SpringBoot+Vue开发记录(四)
    我的创作二周年纪念日
    计算机组成原理---第五章中央处理器---控制器的功能和工作原理
    SOME/IP 协议介绍(三)参数和数据结构的序列化
    数组06-滑动窗口
    什么是微服务架构
    大模型微调概览
    Linux系统彻底卸载MySQL数据库
    Typescript本地浏览器调试
    [激光原理与应用-15]:《激光原理与技术》-1- 什么是激光,激光概述
  • 原文地址:https://blog.csdn.net/begefefsef/article/details/126081130