【Egg】搞定Egg—学习优秀的服务设计

989 阅读13分钟

前言

egg 是什么?

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

egg 和 koa 是什么关系?

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

本文内容:

  • 创建简单的Egg项目
  • 如何搭建服务(结合目录约定来看MVC)
    • 目录约定
    • 服务核心设计理念(Application)
    • 加载插件
    • 错误处理
    • 生命周期
    • 框架扩展
    • 定制框架

一、创建简单的Egg项目

$ npm init egg --type=simple --registry=china

# 或者 

$ yarn create egg --type=simple --registry=china

创建完毕之后,目录结构基本如下:

├── app
│   ├── controller
│   │   └── home.js
│   └── router.js
├── config
│   ├── config.default.js
│   └── plugin.js
├── package.json

这就是最小化的 egg 项目。

关于启动项目,egg有提供托管工具

  • 开发环境 egg-bin 类似nodemon
  • 生产环境 egg-script 类似pm2

二、如何搭建服务(结合目录约定来看MVC)

1. 目录约定

上面创建的项目只是最小化结构,一个典型的 egg 项目有如下目录结构:

egg-project
├── package.json
├── app.js (可选)
├── app/
|   ├── router.js                 # 用于配置 URL 路由规则
|   ├── router/                   # 所有router的集合  
│   ├── controller/               # 用于存放控制器(解析用户的输入、加工处理、返回结果)
│   ├── service/ (可选)            # 用于编写业务逻辑层
│   ├── util/ (可选)               # 集合公共工具方法
│   ├── constant/ (可选)           # 集合常量以及ts变量声明
│   ├── middleware/ (可选)         # 用于编写中间件
│   ├── schedule/ (可选)           # 用于设置定时任务
│   ├── model/ (可选)              # 用于存放数据库模型
│   ├── 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 ...)
├── bin/ (可选)
|   ├── run.sh # 用于启动服务
│   └── stop.sh/ # 用于停止服务
├── logs/ (可选) 
├── test/(可选)
├── build.sh (可选) # 构建
├── control.sh (可选) # 控制如何服务启动
├── tsconfig.json (可选)
└── .eslintrc (可选)

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

2. 服务核心设计理念(Application)

Application
└── app/
    ├── router/           # 所有router的集合  
    ├── router.js         # 用于配置 URL 路由规则,定义了 请求路径(URL) 和 控制器(Controller) 之间的映射关系
    ├── controller/       # 用于存放控制器(解析用户的输入、加工处理、返回结果)
    ├── service/ (可选)    # 用于编写业务逻辑层
    ├── util/ (可选)       # 集合公共工具方法
    ├── constant/ (可选)   # 集合常量以及ts变量声明
    ├── middleware/ (可选) # 用于编写中间件
    ├── schedule/ (可选)   # 用于设置定时任务
    ├── model/ (可选)      # 用于存放数据库模型
    ├── public/ (可选)     # 用于放置静态资源
    ├── view/ (可选)       # 用于放置模板文件
    └── extend/ (可选)     # 用于框架的扩展
        ├── helper.js (可选)
        ├── request.js (可选)
        ├── response.js (可选)
        ├── context.js (可选)
        ├── application.js (可选)
        └── agent.js (可选)

2.1 路由(Router)

路由定义了 请求路径(URL)  和 控制器(Controller)  之间的映射关系,即用户访问的网址应交由哪个控制器进行处理。

2.1.1 配置Router

我们打开 app/router.js 看一下, 路由文件导出了一个函数,接收 app 对象作为参数

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

通过下面的语法定义映射关系:

router.verb('匹配path', controllerAction)

其中 `verb` 一般是 HTTP 动词的小写,例如:

-   HEAD - `router.head`
-   OPTIONS - `router.options`
-   GET - `router.get`
-   PUT - `router.put`
-   POST - `router.post`
-   PATCH - `router.patch`
-   DELETE - `router.delete``router.del`

除此之外,还有一个特殊的动词 router.redirect 表示重定向。

router.redirect('/', '/home/index', 302)
1)匹配path

主要有以下三种形式:固定path、动态path、正则;

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)
}
2)controllerAction

controllerAction 指得是通过点(·)链式调用指定 controller 目录下某个文件内的某个具体函数 (commonjs来的),例如:

controller.home.index     #  映射到 controller/home.js 文件的 index 方法
controller.v1.user.create #  映射到 controller/v1/user.js 文件的 create 方法

2.2.2 分组管理Router

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

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

    image.png

    image.png

  • 使用 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
    }
    

2.2 控制器(Controller)

Controller 负责解析用户的输入,处理后返回相应的结果
Controller 可以调用任何一个 Service 上的任何方法,值得注意的是:Service 是懒加载的,即只有当访问到它的时候框架才会去实例化它。

在 Controller 中会做如下几件事情:

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

一个真实样例

const { Controller } = require('egg');
class CommonDb extends Controller {
  /**
   * 通用查询
   */
  async commonQuery(ctx: Context): Promise<void> {
    console.info(ctx.params)
    const data = await this.service.commonDbService.queryData({
      ...(ctx.request.body || {}),
      dbName: ctx.params.dbName,
      tableName: ctx.params.tableName,
    })
    ctx.body = CommonRes.getSuccessRes(data)
  }
}
module.exports = CommonDb;

自定义Controller基类

由于 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);
  }
}

this.ctx获取上下文

在 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):渲染模板

2.3 服务(Service)

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

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

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

一个简单的 Service 示例,结合上述 commonDBController:

// app/service/commonDbService.ts
const { Service } = require('egg').Service;

class CommonDbService extends Service {
  /**
   * 查询数据
   */
  public async queryData(params: DbCondition) {
    DbUtil.allowHandleDB(params?.dbName)
    const sql = DbUtil.generateQuerySql(params)
    // 表限制
    const data = await this.app.mysql.get(params?.dbName).query(sql)
    return data
  }
}

module.exports = CommonDbService;

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

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

2.4 工具集合(Util)

集合各种工具方法。

2.5 中间件(appMiddleware)

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

Egg的两种中间件

egg 把中间件分成应用层定义的中间件(app.config.appMiddleware)和框架默认中间件(app.config.coreMiddleware

# appMiddleware
[ 'aaaa' ] 

# coreMiddleware
[ 
    'meta',
    'siteFile', 
    'notfound', 
    'static', 
    'bodyParser', 
    'overrideMethod', 
    'session', 
    'securities', 
    'i18n', 
    'eggLoaderTrace' 
]

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

module.exports = {
  i18n: {
    enable: false
  }
}

配置中间件

该函数需要导出一个普通的函数,该函数接受两个参数:

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

该函数返回的是一个async/await koa中间件。

如下所示:

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}毫秒`)
    }
  }
}

image.png

启用中间件

1)全局启用

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

module.exports = {
  // 配置需要的中间件,数组顺序即为中间件的加载顺序
  middleware: [ 'aaaa' ],
  // aaaa 中间件的 options 参数
  aaaa: {
    enable: true
  },
}
2)指定路由中使用中间件

例如只针对 /api 前缀开头的 url 请求使用某个中间件的话,有两种方式:

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

    module.exports = {
      middleware: [ 'aaaa' ],
      aaaa: {
        threshold: 1,
        match: '/api'
      },
    };
    
  2. 在路由文件 router.js 中引入

在绑定router中绑定URL和Controller之间,加入中间件。

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

image.png

2.6 定时任务(Schedule)

一个复杂的业务场景中,不可避免会有定时任务的需求,比如定时更新redis。

image.png

2.7 数据库模型(Models)

用于存放数据库模型,特别注意,可以在这里维护数据库字段说明(ts等)

2.8 静态资源(Public)

用于放置静态资源

2.9 模板(View)

用于放置模板文件

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

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,帮助我们找到对应渲染引擎的类。

3. 加载插件

3.1 在应用或框架的 config/plugin.js 中声明插件配置

image.png

3.2 插件自定义配置

开启插件后,就可以使用插件提供的功能了,如果插件包含需要用户自定义的配置,可以在 config.{env}.js 中进行指定。

image.png

4. 错误处理

全局监听拦截,无论是koa还是egg实例,都继承了http.Emiter,所以,每个地方处理出问题,捕获到之后都可以this.emit()向外发布具体的错误信息。

所以,在全局可以在app实例上直接监听error事件,再做进一步处理,该报错报错,该记日志记日志。

一个实际case展示:

1)全局监听error事件

image.png

2)发布错误

就是触发this.emit()即可,但是真正的业务实践,我们需要权衡发布的最佳时机。

2.1)定义中间件(最先执行中间件)

image.png

2.2)启用中间件(必须注意顺序

image.png

2.3)全局封装报错方法

Tips: 一个关键问题,是上下游规范,报错规范统一

注意 tryCatch 以及 throw new Error()

5. 生命周期

在 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

6.框架扩展

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 对象。

7.定制框架

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 '/mypath/to/enterprise'
  }
}

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

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

DiDi的NodeX(DEgg)就是这层封装。

除了使用 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,它是一个基类,根据文件加载的规则提供了一些内置的方法,它本身并不会去调用这些方法,而是由继承类调用。

也就是说我们自定义的 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 等。