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 就可以了。
  • 应用完整启动顺序查看框架开发

生命周期

Egg提供了应用启动(beforeStart), 启动完成(ready), 关闭(beforeClose)这三个生命周期方法。

  1. init master process
  2. init agent worker process
  3. loader.load | beforeStart
  4. await agent worker ready
  5. call ready callback
  6. init app worker processes
  7. loader.load | beforeStart
  8. await app workers ready
  9. call ready callback
  10. send egg-ready to master,
  11. agent,app workers

beforeStart

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

ready

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

beforeClose

beforeClose 注册方法在 app/agent 实例的 close 方法被调用后, 按注册的逆序执行。一般用于资源的释放操作, 例如 egg 用来关闭 logger , 删除监听方法等。

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

e.g.:

  1. // app.js
  2. console.time('app before start 200ms');
  3. console.time('app before start 100ms');
  4. app.beforeStart(async () => {
  5. await sleep(200);
  6. console.timeEnd('app before start 200ms');
  7. });
  8. app.beforeStart(async () => {
  9. await sleep(100);
  10. console.timeEnd('app before start 100ms');
  11. });
  12. app.on('server', () => {
  13. console.log('server is ready');
  14. });
  15. app.ready(() => {
  16. console.log('app ready');
  17. cp.execSync(`kill ${process.ppid}`);
  18. console.time('app before close 100ms');
  19. console.time('app before close 200ms');
  20. });
  21. app.beforeClose(async () => {
  22. await sleep(200);
  23. console.timeEnd('app before close 200ms');
  24. });
  25. app.beforeClose(async () => {
  26. await sleep(100);
  27. console.timeEnd('app before close 100ms');
  28. });
  29. // agent.js
  30. console.time('agent before start 200ms');
  31. console.time('agent before start 100ms');
  32. agent.beforeStart(async () => {
  33. await sleep(200);
  34. console.timeEnd('agent before start 200ms');
  35. });
  36. agent.beforeStart(async () => {
  37. await sleep(100);
  38. console.timeEnd('agent before start 100ms');
  39. });
  40. agent.ready(() => {
  41. console.log('agent ready');
  42. console.time('agent before close 200ms');
  43. console.time('agent before close 100ms');
  44. });
  45. agent.beforeClose(async () => {
  46. await sleep(200);
  47. console.timeEnd('agent before close 200ms');
  48. });
  49. agent.beforeClose(async () => {
  50. await sleep(100);
  51. console.timeEnd('agent before close 100ms');
  52. });

print:

  1. agent before start 100ms: 131.096ms
  2. agent before start 200ms: 224.396ms // 并行执行
  3. agent ready
  4. app before start 100ms: 147.546ms
  5. app before start 200ms: 245.405ms // 并行执行
  6. app ready
  7. // 开流量
  8. server is ready
  9. agent before close 100ms: 866.218ms
  10. app before close 100ms: 108.007ms // LIFO, 后注册先执行
  11. app before close 200ms: 310.549ms // 串行执行
  12. agent before close 200ms: 1070.865ms

可以使用 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.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