Skip to content

Egg 在 Koa 的基础上进行增强,最重要的就是基于一定的约定,将功能不同的代码分类放置到不同的目录下管理,这对整体团队的开发成本提升有着明显的效果。Loader 实现了这套约定,并且抽象了很多底层 API,以便于进一步扩展。

应用、框架和插件

Egg 是一个底层框架,应用可以直接使用,但 Egg 本身的插件比较少。因此,应用需要自己配置插件来增加各种特性,比如 MySQL。

js
// 应用配置
// package.json
{
  "dependencies": {
    "egg": "^2.0.0",
    "egg-mysql": "^3.0.0"
  }
}

// config/plugin.js
module.exports = {
  mysql: {
    enable: true,
    package: 'egg-mysql'
  }
}

当应用数量达到一定规模时,会发现大部分应用的配置都相似。这时,可以基于 Egg 扩展出一个框架,进而简化应用的配置。

js
// 框架配置
// package.json
{
  "name": "framework1",
  "version": "1.0.0",
  "dependencies": {
    "egg-mysql": "^3.0.0",
    "egg-view-nunjucks": "^2.0.0"
  }
}

// config/plugin.js
module.exports = {
  mysql: {
    enable: false,
    package: 'egg-mysql'
  },
  view: {
    enable: false,
    package: 'egg-view-nunjucks'
  }
}

// 应用配置
// package.json
{
  "dependencies": {
    "framework1": "^1.0.0"
  }
}

// config/plugin.js
module.exports = {
  // 开启插件
  mysql: true,
  view: true
}

从上面的使用场景可以看出应用、插件和框架三者之间的关系。

  • 在应用中完成业务,需要指定框架才能运行。当应用需要某一特定功能时,可以通过配置插件来获得,例如 MySQL。
  • 插件专注于完成特定的功能。如果两个独立功能之间存在依赖,可以分成两个插件,但需要相互配置依赖。
  • 框架是一个启动器(默认是 Egg),有了框架应用才能运行。框架还起到封装器的作用,将多个插件的功能聚合起来统一提供,并且框架也可以配置插件。
  • 在框架的基础上还可以扩展新的框架,也就是说,框架可以无限级继承,这有点类似于类的继承。
+-----------------------------------+--------+
|      app1, app2, app3, app4       |        |
+-----+--------------+--------------+        |
|     |              |  framework3  |        |
+     |  framework1  +--------------+ plugin |
|     |              |  framework2  |        |
+     +--------------+--------------+        |
|                   Egg             |        |
+-----------------------------------+--------|
|                   Koa                      |
+-----------------------------------+--------+

加载单元(loadUnit)

Egg 将应用、框架和插件都称为加载单元(loadUnit),因为在代码结构上几乎没有什么差异。下面是一种典型的目录结构:

loadUnit
├── package.json
├── app.js
├── agent.js
├── app
│   ├── extend
│   │   ├── helper.js
│   │   ├── request.js
│   │   ├── response.js
│   │   ├── context.js
│   │   ├── application.js
│   │   └── agent.js
│   ├── service
│   ├── middleware
│   └── router.js
└── config
    ├── config.default.js
    ├── config.prod.js
    ├── config.test.js
    ├── config.local.js
    └── config.unittest.js

不过,还存在一些差异,如下表所示:

文件应用框架插件
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

文件按表格内的顺序从上到下加载。

在加载过程中,Egg 会遍历所有的 loadUnit 加载上述的文件(应用、框架、插件各有不同),加载时有一定的优先级:

  • 按插件 => 框架 => 应用的顺序依次加载。
  • 插件之间的顺序由依赖关系决定,被依赖方先加载,无依赖者按 object key 的配置顺序加载。具体可以查看插件章节
  • 框架按继承顺序加载,越底层越先加载。

例如,有这样一个应用配置了如下依赖:

app
| ├── plugin2 (依赖 plugin3)
| └── plugin3
└── framework1
    | └── plugin1
    └── egg

最终的加载顺序为:

=> plugin1
=> plugin3
=> plugin2
=> egg
=> framework1
=> app

plugin1 是 framework1 依赖的插件。由于 plugin2 和 plugin3 的依赖关系,因此交换了它们的位置。由于 framework1 继承了 egg,因此它的加载顺序会晚于 egg。应用将最后加载。

更多信息请查看 Loader.getLoadUnits 方法。

文件顺序

上文已经列出了默认会加载的文件。Egg 会按照如下文件顺序进行加载,每个文件或目录再根据 loadUnit 的顺序去加载(应用、框架、插件各有不同):

  1. 加载 plugin,找到应用和框架,加载 config/plugin.js
  2. 加载 config,遍历 loadUnit 加载 config/config.{env}.js
  3. 加载 extend,遍历 loadUnit 加载 app/extend/xx.js
  4. 自定义初始化,遍历 loadUnit 加载 app.jsagent.js
  5. 加载 service,遍历 loadUnit 加载 app/service 目录。
  6. 加载 middleware,遍历 loadUnit 加载 app/middleware 目录。
  7. 加载 controller,加载应用的 app/controller 目录。
  8. 加载 router,加载应用的 app/router.js

请注意:

  • 加载时如果遇到同名文件将会被覆盖。比如,如果想要覆盖 ctx.ip,可以在应用的 app/extend/context.js 中直接定义 ip
  • 应用完整启动顺序请查看框架开发

生命周期

框架提供了以下生命周期函数供开发者使用:

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

定义方法如下:

js
// app.js 或 agent.js
class AppBootHook {
  constructor(app) {
    this.app = app;
  }

  configWillLoad() {
    // 准备调用 configDidLoad,
    // 配置文件和插件文件将被引用,
    // 这是修改配置的最后机会。
  }

  configDidLoad() {
    // 配置文件和插件文件已被加载。
  }

  async didLoad() {
    // 所有文件已加载,这里开始启动插件。
  }

  async willReady() {
    // 所有插件已启动,在应用准备就绪前可执行一些操作。
  }

  async didReady() {
    // worker 已准备就绪,在这里可以执行一些操作,
    // 这些操作不会阻塞应用启动。
  }

  async serverDidReady() {
    // 服务器已开始监听。
  }

  async beforeClose() {
    // 应用关闭前执行一些操作。
  }
}

module.exports = AppBootHook;

开发者使用类的方式定义 app.jsagent.js 后,框架将自动加载并实例化这个类,并在各个生命周期阶段调用相应的方法。

启动过程如图所示:

在使用 beforeClose 时,需要注意:框架在处理关闭进程时设有超时限制。如果 worker 进程在收到退出信号后,未能在规定时间内退出,则会被强制终止。

如需调整超时时间,请查阅相关文档

弃用的方法:

beforeStart

beforeStart 方法在加载过程中调用,所有方法并行执行。通常用于执行一些异步任务,例如检查连接状态等。例如,egg-mysql 使用 beforeStart 来检查 MySQL 的连接状态。所有 beforeStart 任务结束后,应用将进入 ready 状态。不建议执行耗时长的方法,可能导致应用启动超时。插件开发者应使用 didLoad 替代,应用开发者应使用 willReady 替代。

ready

注册到 ready 方法的任务将在加载结束后,所有 beforeStart 方法执行完毕后顺序执行,HTTP 服务器监听也在此时开始。此时代表所有插件已加载完成且准备工作已完成,通常用于执行一些启动后置任务。开发者应使用 didReady 替代。

beforeClose

beforeClose 注册方法在 app/agent 实例的 close 方法调用后,按注册的逆序执行。通常用于资源释放操作,例如 egg 用于关闭日志、移除监听器等。开发者不应直接使用 app.beforeClose,而是通过定义类的形式,实现 beforeClose 方法。

此方法不建议在生产环境使用,因可能会出现未完全执行结束就结束进程的情况。

另外,我们可以使用 @eggjs/development 来查看加载过程。

文件加载规则

框架在加载文件时会进行转换,因为文件命名风格与 API 风格有所差异。我们推荐文件使用下划线命名,而 API 使用驼峰命名。例如 app/service/user_info.js 会转换为 app.service.userInfo

框架也支持其它风格命名的文件;连字符和驼峰方式命名的文件同样支持:

  • app/service/user-info.js => app.service.userInfo
  • app/service/userInfo.js => app.service.userInfo

Loader 也提供了 caseStyle 设置来强制指定命名方式,如将 model 加载时的 API 首字母大写,app/model/user.js => app.model.User,可指定 caseStyle: 'upper'

扩展 Loader

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

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

Egg 基于 Loader 实现了 AppWorkerLoaderAgentWorkerLoader,上层框架基于这两个类来扩展。Loader 的扩展只能在框架进行

js
// 自定义 AppWorkerLoader
// lib/framework.js
const path = require('path');
const egg = require('egg');

class YadanAppWorkerLoader extends egg.AppWorkerLoader {
  constructor(opt) {
    super(opt);
    // 自定义初始化
  }

  loadConfig() {
    super.loadConfig();
    // 对 config 进行处理
  }

  load() {
    super.load();
    // 自定义加载其他目录
    // 或对已加载的文件进行处理
  }
}

class Application extends egg.Application {
  protected override customEggPaths() {
    return [path.dirname(__dirname), ...super.customEggPaths()];
  }
  // 覆盖 Egg 的 Loader,启动时使用这个 Loader
  protected override customEggLoader() {
    return YadanAppWorkerLoader;
  }
}

module.exports = Object.assign(egg, {
  Application,
  // 自定义的 Loader 也需要 export,上层框架需要基于这个扩展
  AppWorkerLoader: YadanAppWorkerLoader,
});

通过 Loader 提供的这些 API,可以很方便地定制团队的自定义加载,例如 this.model.xxapp/extend/filter.js 等等。

以上只是说明 Loader 的写法,具体可以查看框架开发

加载器函数(Loader API)

Loader 提供了一些基础 API,方便在扩展时简化代码。想了解所有相关 API,请点击此处

loadFile

此函数用来加载文件,例如加载 app/xx.js 就会用到它。

js
// app/xx.js
module.exports = (app) => {
  console.log(app.config);
};

// app.js
// 以 app/xx.js 为例子,在 app.js 中加载此文件:
const path = require('path');
module.exports = (app) => {
  app.loader.loadFile(path.join(app.config.baseDir, 'app/xx.js'));
};

如果文件导出了一个函数,这个函数会被调用,app 作为参数传入;如果不是函数,则直接使用文件导出的值。

loadToApp

此函数用来将一个目录下的文件加载到 app 对象上,例如 app/controller/home.js 会被加载到 app.controller.home

js
// app.js
// 以下只是示例,加载 controller 请用 loadController
module.exports = (app) => {
  const directory = path.join(app.config.baseDir, 'app/controller');
  app.loader.loadToApp(directory, 'controller');
};

loadToApp 有三个参数:loadToApp(directory, property, LoaderOptions)

  1. directory 可以是字符串或数组。Loader 会从这些目录中加载文件。
  2. property 是 app 的属性名。
  3. LoaderOptions 包含了一些配置选项。

loadToContext

loadToContextloadToApp 略有不同,它是将文件加载到 ctx 上,而不是 app,并且支持懒加载。加载操作会将文件放到一个临时对象中,在调用 ctx API 时才去实例化。

例如,加载 service 文件的方式就用到了这种模式:

js
// 以下为示例,请使用 loadService
// app/service/user.js
const Service = require('egg').Service;
class UserService extends Service {}
module.exports = UserService;

// app.js
// 获取所有的 loadUnit
const servicePaths = app.loader
  .getLoadUnits()
  .map((unit) => path.join(unit.path, 'app/service'));

app.loader.loadToContext(servicePaths, 'service', {
  // service 需要继承 app.Service,因此需要 app 参数
  // 设置 call 为 true,会在加载时调用函数,并返回 UserService
  call: true,
  // 将文件加载到 app.serviceClasses
  fieldClass: 'serviceClasses',
});

文件加载完成后,app.serviceClasses.user 就代表 UserService 类。当调用 ctx.service.user 时,会实例化 UserService 类。因此,这个类只有在每次请求中首次被访问时才会实例化。实例化后,对象会被缓存,同一个请求中多次调用也只实例化一次。

LoaderOptions

ignore [String]

ignore 可用于忽略某些文件,支持 glob 匹配模式,默认值为空。

js
app.loader.loadToApp(directory, 'controller', {
  // 忽略 app/controller/util 目录下的文件
  ignore: 'util/**',
});

initializer [Function]

对每个文件 export 的值进行处理,此项默认为空。

js
// app/model/user.js
module.exports = class User {
  constructor(app, path) {}
};

// 从 app/model 目录加载,且可以在加载时进行一些初始化处理
const directory = path.join(app.config.baseDir, 'app/model');
app.loader.loadToApp(directory, 'model', {
  initializer(model, opt) {
    // 第一个参数为 export 的对象
    // 第二个参数为一个对象,里面包含当前文件的路径
    return new model(app, opt.path);
  },
});

caseStyle [String]

设置文件命名的转换规则,可选项为 camelupperlower,默认值为 camel

这些选项都会将文件名转换为驼峰命名,但是首字符的大小写处理不同:

  • camel:首字母保持不变。
  • upper:首字母转为大写。
  • lower:首字母转为小写。

根据不同文件类型设置相应的转换规则,如下表所示:

文件类型caseStyle 配置
app/controllerlower
app/middlewarelower
app/servicelower

override [Boolean]

当存在同名文件时,是否覆盖原有文件,或抛出异常。默认值为 false

例如,当同时加载应用和插件中的 app/service/user.js 文件时:

  • override 设为 true,则应用中的文件会覆盖插件中的同名文件。
  • 若设为 false,则在尝试加载应用中的文件时会报错。

根据不同文件类型设置 override 的配置值,如下表所示:

文件类型override 配置
app/controllertrue
app/middlewarefalse
app/servicefalse

call [Boolean]

若 export 出的对象是函数,则可以调用此函数并获取其返回值,默认值为 true

根据不同文件类型设置 call 的配置值,如下表所示:

文件类型call 配置
app/controllertrue
app/middlewarefalse
app/servicetrue

CustomLoader

loadToContextloadToApp 方法可以通过 customLoader 的配置来替代。

以下是用 loadToApp 方法加载代码的示例:

js
// app.js
module.exports = (app) => {
  const directory = path.join(app.config.baseDir, 'app/adapter');
  app.loader.loadToApp(directory, 'adapter');
};

改为使用 customLoader 后的写法是:

js
// config/config.default.js
module.exports = {
  customLoader: {
    // 在 app 对象上定义的属性名为 app.adapter
    adapter: {
      // 路径相对于 app.config.baseDir
      directory: 'app/adapter',
      // 如果用于 ctx,则应该使用 loadToContext 方法
      inject: 'app',
      // 是否加载框架和插件的目录
      loadunit: false,
      // 也可以定义其他 LoaderOptions
    },
  },
};

参考链接:

Born to build better enterprise frameworks and apps