加载器(Loader)


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

应用、框架和插件

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

  1. // 应用配置
  2. // package.json
  3. {
  4. "dependencies": {
  5. "egg": "^2.0.0",
  6. "egg-mysql": "^3.0.0"
  7. }
  8. }
  9. // config/plugin.js
  10. module.exports = {
  11. mysql: {
  12. enable: true,
  13. package: 'egg-mysql',
  14. },
  15. }

当应用达到一定数量,我们会发现大部分应用的配置都是类似的,这时可以基于 Egg 扩展出一个框架,应用的配置就会简化很多。

  1. // 框架配置
  2. // package.json
  3. {
  4. "name": "framework1",
  5. "version": "1.0.0",
  6. "dependencies": {
  7. "egg-mysql": "^3.0.0",
  8. "egg-view-nunjucks": "^2.0.0"
  9. }
  10. }
  11. // config/plugin.js
  12. module.exports = {
  13. mysql: {
  14. enable: false,
  15. package: 'egg-mysql',
  16. },
  17. view: {
  18. enable: false,
  19. package: 'egg-view-nunjucks',
  20. }
  21. }
  22. // 应用配置
  23. // package.json
  24. {
  25. "dependencies": {
  26. "framework1": "^1.0.0",
  27. }
  28. }
  29. // config/plugin.js
  30. module.exports = {
  31. // 开启插件
  32. mysql: true,
  33. view: true,
  34. }

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

  • 我们在应用中完成业务,需要指定一个框架才能运行起来,当需要某个特性场景的功能时可以配置插件(比如 MySQL)。
  • 插件只完成特定功能,当两个独立的功能有互相依赖时,还是分开两个插件,但需要配置依赖。
  • 框架是一个启动器(默认就是 Egg),必须有它才能运行起来。框架还是一个封装器,将插件的功能聚合起来统一提供,框架也可以配置插件。
  • 在框架的基础上还可以扩展出新的框架,也就是说框架是可以无限级继承的,有点像类的继承。
  1. +-----------------------------------+--------+
  2. | app1, app2, app3, app4 | |
  3. +-----+--------------+--------------+ |
  4. | | | framework3 | |
  5. + | framework1 +--------------+ plugin |
  6. | | | framework2 | |
  7. + +--------------+--------------+ |
  8. | Egg | |
  9. +-----------------------------------+--------|
  10. | Koa |
  11. +-----------------------------------+--------+

加载单元(loadUnit)

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

  1. loadUnit
  2. ├── package.json
  3. ├── app.js
  4. ├── agent.js
  5. ├── app
  6. ├── extend
  7. | ├── helper.js
  8. | ├── request.js
  9. | ├── response.js
  10. | ├── context.js
  11. | ├── application.js
  12. | └── agent.js
  13. ├── service
  14. ├── middleware
  15. └── router.js
  16. └── config
  17. ├── config.default.js
  18. ├── config.prod.js
  19. ├── config.test.js
  20. ├── config.local.js
  21. └── 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 配置顺序加载,具体可以查看插件章节
  • 框架按继承顺序加载,越底层越先加载。

比如有这样一个应用配置了如下依赖

  1. app
  2. | ├── plugin2 (依赖 plugin3)
  3. | └── plugin3
  4. └── framework1
  5. | └── plugin1
  6. └── egg

最终的加载顺序为

  1. => plugin1
  2. => plugin3
  3. => plugin2
  4. => egg
  5. => framework1
  6. => app

plugin1 为 framework1 依赖的插件,配置合并后 object key 的顺序会优先于 plugin2/plugin3。因为 plugin2 和 plugin3 的依赖关系,所以交换了位置。framework1 继承了 egg,顺序会晚于 egg。应用最后加载。

请查看 Loader.getLoadUnits 方法

文件顺序

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

  • 加载 plugin,找到应用和框架,加载 config/plugin.js
  • 加载 config,遍历 loadUnit 加载 config/config.{env}.js
  • 加载 extend,遍历 loadUnit 加载 app/extend/xx.js
  • 自定义初始化,遍历 loadUnit 加载 app.jsagent.js
  • 加载 service,遍历 loadUnit 加载 app/service 目录
  • 加载 middleware,遍历 loadUnit 加载 app/middleware 目录
  • 加载 controller,加载应用的 app/controller 目录
  • 加载 router,加载应用的 app/router.js

注意:

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

生命周期

框架提供了这些生命周期函数供开发人员处理:

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

定义如下:

  1. // app.js or agent.js
  2. class AppBootHook {
  3. constructor(app) {
  4. this.app = app;
  5. }
  6. configWillLoad() {
  7. // Ready to call configDidLoad,
  8. // Config, plugin files are referred,
  9. // this is the last chance to modify the config.
  10. }
  11. configDidLoad() {
  12. // Config, plugin files have been loaded.
  13. }
  14. async didLoad() {
  15. // All files have loaded, start plugin here.
  16. }
  17. async willReady() {
  18. // All plugins have started, can do some thing before app ready
  19. }
  20. async didReady() {
  21. // Worker is ready, can do some things
  22. // don't need to block the app boot.
  23. }
  24. async serverDidReady() {
  25. // Server is listening.
  26. }
  27. async beforeClose() {
  28. // Do some thing before app close.
  29. }
  30. }
  31. module.exports = AppBootHook;

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

启动过程如图所示:

加载器(Loader) - 图1

使用 beforeClose 的时候需要注意,在框架的进程关闭处理中是有超时时间的,如果 worker 进程在接收到进程退出信号之后,没有在所规定的时间内退出,将会被强制关闭。

如果需要调整超时时间的话,查看此处文档

弃用的方法:

beforeStart

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

ready

ready 方法注册的任务在 load 结束并且所有的 beforeStart 方法执行结束后顺序执行, HTTP server 监听也是在这个时候开始, 此时代表所有的插件已经加载完毕并且准备工作已经完成, 一般用来执行一些启动的后置任务。开发者应使用 didReady 替换。

beforeClose

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

这个方法不建议在生产环境使用, 可能遇到未执行完就结束进程的问题。

此外,我们可以使用 egg-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 的扩展只能在框架进行

  1. // 自定义 AppWorkerLoader
  2. // lib/framework.js
  3. const path = require('path');
  4. const egg = require('egg');
  5. const EGG_PATH = Symbol.for('egg#eggPath');
  6. class YadanAppWorkerLoader extends egg.AppWorkerLoader {
  7. constructor(opt) {
  8. super(opt);
  9. // 自定义初始化
  10. }
  11. loadConfig() {
  12. super.loadConfig();
  13. // 对 config 进行处理
  14. }
  15. load() {
  16. super.load();
  17. // 自定义加载其他目录
  18. // 或对已加载的文件进行处理
  19. }
  20. }
  21. class Application extends egg.Application {
  22. get [EGG_PATH]() {
  23. return path.dirname(__dirname);
  24. }
  25. // 覆盖 Egg 的 Loader,启动时使用这个 Loader
  26. get [EGG_LOADER]() {
  27. return YadanAppWorkerLoader;
  28. }
  29. }
  30. module.exports = Object.assign(egg, {
  31. Application,
  32. // 自定义的 Loader 也需要 export,上层框架需要基于这个扩展
  33. AppWorkerLoader: YadanAppWorkerLoader,
  34. });

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

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

加载器函数(Loader API)

Loader 还提供一些底层的 API,在扩展时可以简化代码,点击此处查看所有相关 API。

loadFile

用于加载一个文件,比如加载 app/xx.js 就是使用这个方法。

  1. // app/xx.js
  2. module.exports = app => {
  3. console.log(app.config);
  4. };
  5. // app.js
  6. // 以 app/xx.js 为例,我们可以在 app.js 加载这个文件
  7. const path = require('path');
  8. module.exports = app => {
  9. app.loader.loadFile(path.join(app.config.baseDir, 'app/xx.js'));
  10. };

如果文件 export 一个函数会被调用,并将 app 作为参数,否则直接使用这个值。

loadToApp

用于加载一个目录下的文件到 app,比如 app/controller/home.js 会加载到 app.controller.home

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

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

  1. directory 可以为 String 或 Array,Loader 会从这些目录加载文件
  2. property 为 app 的属性
  3. LoaderOptions 为一些配置

loadToContext

与 loadToApp 有一点差异,loadToContext 是加载到 ctx 上而非 app,而且是懒加载。加载时会将文件都放到一个临时对象上,在调用 ctx API 时才实例化对象。

比如 service 的加载就是使用这种模式

  1. // 以下为示例,请使用 loadService
  2. // app/service/user.js
  3. const Service = require('egg').Service;
  4. class UserService extends Service {
  5. }
  6. module.exports = UserService;
  7. // app.js
  8. // 获取所有的 loadUnit
  9. const servicePaths = app.loader.getLoadUnits().map(unit => path.join(unit.path, 'app/service'));
  10. app.loader.loadToContext(servicePaths, 'service', {
  11. // service 需要继承 app.Service,所以要拿到 app 参数
  12. // 设置 call 在加载时会调用函数返回 UserService
  13. call: true,
  14. // 将文件加载到 app.serviceClasses
  15. fieldClass: 'serviceClasses',
  16. });

文件加载后 app.serviceClasses.user 就是 UserService,当调用 ctx.service.user 时会实例化 UserService,所以这个类只有每次请求中首次访问时才会实例化,实例化后会被缓存,同一个请求多次调用也只会实例化一次。

LoaderOptions

ignore [String]

ignore 可以忽略一些文件,支持 glob,默认为空

  1. app.loader.loadToApp(directory, 'controller', {
  2. // 忽略 app/controller/util 下的文件
  3. ignore: 'util/**',
  4. });

initializer [Function]

对每个文件 export 出来的值进行处理,默认为空

  1. // app/model/user.js
  2. module.exports = class User {
  3. constructor(app, path) {}
  4. }
  5. // 从 app/model 目录加载,加载时可做一些初始化处理
  6. const directory = path.join(app.config.baseDir, 'app/model');
  7. app.loader.loadToApp(directory, 'model', {
  8. initializer(model, opt) {
  9. // 第一个参数为 export 的对象
  10. // 第二个参数为一个对象,只包含当前文件的路径
  11. return new model(app, opt.path);
  12. },
  13. });

caseStyle [String]

文件的转换规则,可选为 camelupperlower,默认为 camel

三者都会将文件名转换成驼峰,但是对于首字母的处理有所不同。

  • camel:首字母不变。
  • upper:首字母大写。
  • lower:首字母小写。

在加载不同文件时配置不同

文件 配置
app/controller lower
app/middleware lower
app/service lower

override [Boolean]

遇到已经存在的文件时是直接覆盖还是抛出异常,默认为 false

比如同时加载应用和插件的 app/service/user.js 文件,如果为 true 应用会覆盖插件的,否则加载应用的文件时会报错。

在加载不同文件时配置不同

文件 配置
app/controller true
app/middleware false
app/service false

call [Boolean]

当 export 的对象为函数时则调用,并获取返回值,默认为 true

在加载不同文件时配置不同

文件 配置
app/controller true
app/middleware false
app/service true

CustomLoader

loadToContextloadToApp 可被 customLoader 配置替代。

如使用 loadToApp 加载的代码如下

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

换成 customLoader 后变为

  1. // config/config.default.js
  2. module.exports = {
  3. customLoader: {
  4. // 定义在 app 上的属性名 app.adapter
  5. adapter: {
  6. // 相对于 app.config.baseDir
  7. directory: 'app/adapter',
  8. // 如果是 ctx 则使用 loadToContext
  9. inject: 'app',
  10. // 是否加载框架和插件的目录
  11. loadunit: false,
  12. // 还可以定义其他 LoaderOptions
  13. }
  14. },
  15. };