Egg 在 Koa 的基础上进行增强,最重要的就是基于一定的约定,将功能不同的代码分类放置到不同的目录下管理,这对整体团队的开发成本提升有着明显的效果。Loader 实现了这套约定,并且抽象了很多底层 API,以便于进一步扩展。
应用、框架和插件
Egg 是一个底层框架,应用可以直接使用,但 Egg 本身的插件比较少。因此,应用需要自己配置插件来增加各种特性,比如 MySQL。
// 应用配置
// package.json
{
"dependencies": {
"egg": "^2.0.0",
"egg-mysql": "^3.0.0"
}
}
// config/plugin.js
module.exports = {
mysql: {
enable: true,
package: 'egg-mysql'
}
}
当应用数量达到一定规模时,会发现大部分应用的配置都相似。这时,可以基于 Egg 扩展出一个框架,进而简化应用的配置。
// 框架配置
// 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 的顺序去加载(应用、框架、插件各有不同):
- 加载 plugin,找到应用和框架,加载
config/plugin.js
。 - 加载 config,遍历 loadUnit 加载
config/config.{env}.js
。 - 加载 extend,遍历 loadUnit 加载
app/extend/xx.js
。 - 自定义初始化,遍历 loadUnit 加载
app.js
和agent.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
)
定义方法如下:
// 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.js
和 agent.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
实现了 AppWorkerLoader
和 AgentWorkerLoader
,上层框架基于这两个类来扩展。Loader 的扩展只能在框架进行。
// 自定义 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.xx
,app/extend/filter.js
等等。
以上只是说明 Loader
的写法,具体可以查看框架开发。
加载器函数(Loader API)
Loader 提供了一些基础 API,方便在扩展时简化代码。想了解所有相关 API,请点击此处。
loadFile
此函数用来加载文件,例如加载 app/xx.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
。
// 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)
directory
可以是字符串或数组。Loader 会从这些目录中加载文件。property
是 app 的属性名。LoaderOptions
包含了一些配置选项。
loadToContext
loadToContext
与 loadToApp
略有不同,它是将文件加载到 ctx
上,而不是 app
,并且支持懒加载。加载操作会将文件放到一个临时对象中,在调用 ctx
API 时才去实例化。
例如,加载 service 文件的方式就用到了这种模式:
// 以下为示例,请使用 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 匹配模式,默认值为空。
app.loader.loadToApp(directory, 'controller', {
// 忽略 app/controller/util 目录下的文件
ignore: 'util/**',
});
initializer [Function]
对每个文件 export 的值进行处理,此项默认为空。
// 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]
设置文件命名的转换规则,可选项为 camel
、upper
或 lower
,默认值为 camel
。
这些选项都会将文件名转换为驼峰命名,但是首字符的大小写处理不同:
camel
:首字母保持不变。upper
:首字母转为大写。lower
:首字母转为小写。
根据不同文件类型设置相应的转换规则,如下表所示:
文件类型 | caseStyle 配置 |
---|---|
app/controller | lower |
app/middleware | lower |
app/service | lower |
override [Boolean]
当存在同名文件时,是否覆盖原有文件,或抛出异常。默认值为 false
。
例如,当同时加载应用和插件中的 app/service/user.js
文件时:
- 若
override
设为true
,则应用中的文件会覆盖插件中的同名文件。 - 若设为
false
,则在尝试加载应用中的文件时会报错。
根据不同文件类型设置 override
的配置值,如下表所示:
文件类型 | override 配置 |
---|---|
app/controller | true |
app/middleware | false |
app/service | false |
call [Boolean]
若 export 出的对象是函数,则可以调用此函数并获取其返回值,默认值为 true
。
根据不同文件类型设置 call
的配置值,如下表所示:
文件类型 | call 配置 |
---|---|
app/controller | true |
app/middleware | false |
app/service | true |
CustomLoader
loadToContext
和 loadToApp
方法可以通过 customLoader
的配置来替代。
以下是用 loadToApp
方法加载代码的示例:
// app.js
module.exports = (app) => {
const directory = path.join(app.config.baseDir, 'app/adapter');
app.loader.loadToApp(directory, 'adapter');
};
改为使用 customLoader
后的写法是:
// config/config.default.js
module.exports = {
customLoader: {
// 在 app 对象上定义的属性名为 app.adapter
adapter: {
// 路径相对于 app.config.baseDir
directory: 'app/adapter',
// 如果用于 ctx,则应该使用 loadToContext 方法
inject: 'app',
// 是否加载框架和插件的目录
loadunit: false,
// 也可以定义其他 LoaderOptions
},
},
};
参考链接: