Fork me on GitHub

Egg 源码分析之 egg-core(一)

转载自知乎网络,原文链接:https://zhuanlan.zhihu.com/p/47178799

egg-core 是什么

应用、框架、插件之间的关系

在学习 egg-core 是什么之前,我们先了解一下关于 Egg 框架中应用、框架、插件这三个概念及其之间的关系:

  • 一个应用必须指定一个框架才能运行起来,根据需要我们可以给一个应用配置多个不同的插件;
  • 插件只完成特定独立的功能,实现即插即拔的效果;
  • 框架是一个启动器,必须有它才能运行起来。框架还是一个封装器,它可以在已有框架的基础上进行封装,框架也可以配置插件,其中 Egg,EggCore 都是框架;
  • 在框架的基础上还可以扩展出新的框架,也就是说框架是可以无限级继承的,有点像类的继承;
  • 框架、应用、插件的关于 service/controller/config/middleware 的目录结构配置基本相同,称之为加载单元(loadUnit),包括后面源码分析中的 getLoadUnits 函数都是为了获取这个结构;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 加载单元的目录结构如下图,其中插件和框架没有 controller 和 router.js
# 这个目录结构很重要,后面所有的 load 方法都是针对这个目录结构进行的
loadUnit
├── package.json
├── app
│ ├── extend
│ | ├── helper.js
│ | ├── request.js
│ | ├── response.js
│ | ├── context.js
│ | ├── application.js
│ | └── agent.js
│ ├── service
| ├── controller
│ ├── middleware
│ └── router.js
└── config
├── config.default.js
├── config.prod.js
├── config.test.js
├── config.local.js
└── config.unittest.js

egg-core 的主要工作

Egg.js 的大部分核心代码实现都在 egg-core 库 中,egg-core 主要 export 四个对象:

  • EggCore 类:继承于 Koa ,做一些初始化工作, EggCore 中最主要的一个属性是 loader ,也就是 egg-core 的导出的第二个类 EggLoader 的实例
  • EggLoader 类:整个框架目录结构(controller,service,middleware,extend,router.js)的加载和初始化工作都在该类中实现的,主要提供了几个 load 函数(loadPlugin,loadConfig,loadMiddleware,loadService,loadController,loadRouter 等),这些函数会根据指定目录结构下文件输出形式不同进行适配,最终挂载输出内容。
  • BaseContextClass 类:这个类主要是为了我们在使用框架开发时,在 controller 和 service 作为基类使用,只有继承了该类,我们才可以通过 this.ctx 获取到当前请求的上下文对象
  • utils 对象:提供几个主要的函数,包括转换成中间件函数 middleware ,根据不同类型文件获取文件导出内容函数 loadFile 等

所以 egg-core 做的主要事情就是根据 loadUnit 的目录结构规范,将目录结构中的 config,controller,service,middleware,plugin,router 等文件 load 到 app 或者 context 上,开发人员只要按照这套约定规范,就可以很方便进行开发,以下是 egg-core 的 exports 对象源码:

1
2
3
4
5
6
7
8
9
10
11
12
// egg-core 源码 -> 导出的数据结构
const EggCore = require('./lib/egg');
const EggLoader = require('./lib/loader/egg_loader');
const BaseContextClass = require('./lib/utils/base_context_class');
const utils = require('./lib/utils');

module.exports = {
EggCore,
EggLoader,
BaseContextClass,
utils,
};

EggCore 的具体实现源码学习

EggCore 类源码学习

EggCore 类是算是上文提到的框架范畴,它从 Koa 类继承而来,并做了一些初始化工作,其中有三个主要属性是:

  • loader :这个对象是 EggLoader 的实例,定义了多个 load 函数,用于对 loadUnit 目录下的文件进行加载,后面后专门讲这个类的是实现;
  • router :是 EggRouter 类的实例,从 KoaRouter 继承而来,用于 Egg 框架的路由管理和分发,这个类的实现在后面的 loadRouter 函数会有说明
  • lifecycle :这个属性用于 app 的生命周期管理,由于和整个文件加载逻辑关系不大,所以这里不作说明
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// egg-core 源码 -> EggCore 类的部分实现

const KoaApplication = require('koa');
const EGG_LOADER = Symbol.for('egg#loader');

class EggCore extends KoaApplication {
constructor(options = {}) {
super();
const Loader = this[EGG_LOADER];
//初始化 loader 对象
this.loader = new Loader({
baseDir: options.baseDir, //项目启动的根目录
app: this, // EggCore 实例本身
plugins: options.plugins, //自定义插件配置信息,设置插件配置信息有多种方式,后面我们会讲
logger: this.console,
serverScope: options.serverScope,
});
}
get [EGG_LOADER]() {
return require('./loader/egg_loader');
}
// router 对象
get router() {
if (this[ROUTER]) {
return this[ROUTER];
}
const router = this[ROUTER] = new Router({ sensitive: true }, this);
this.beforeStart(() => {
this.use(router.middleware());
});
return router;
}
// 生命周期对象初始化
this.lifecycle = new Lifecycle({
baseDir: options.baseDir,
app: this,
logger: this.console,
});
}

EggLoader 类源码学习

如果说 EggCore 是 Egg 框架的精华所在,那么 EggLoader 可以说是 EggCore 的精华所在,下面我们主要从 EggLoader 的实现细节开始学习 EggCore 这个库:

EggLoader 首先对 app 中的一些基本信息(pkg/eggPaths/serverEnv/appInfo/serverScope/baseDir 等)进行整理,并且定义一些基础共用函数(getEggPaths/getTypeFiles/getLoadUnits/loadFile),所有的这些基础准备都是为了后面介绍的几个 load 函数作准备,我们下面看一下其基础部分的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// egg-core源码 -> EggLoader 中基本属性和基本函数的实现

class EggLoader {
constructor(options) {
this.options = options;
this.app = this.options.app;
//pkg 是根目录的 package.json 输出对象
this.pkg = utility.readJSONSync(path.join(this.options.baseDir, 'package.json'));
// eggPaths 是所有框架目录的集合体,虽然我们上面提到一个应用只有一个框架,但是框架可以在框架的基础上实现多级继承,所以是多个 eggPath
//在实现框架类的时候,必须指定属性 Symbol.for('egg#eggPath') ,这样才能找到框架的目录结构
//下面有关于 getEggPaths 函数的实现分析
this.eggPaths = this.getEggPaths();
this.serverEnv = this.getServerEnv();
//获取 app 的一些基本配置信息(name,baseDir,env,scope,pkg 等)
this.appInfo = this.getAppInfo();
this.serverScope = options.serverScope !== undefined
? options.serverScope
: this.getServerScope();
}
//递归获取继承链上所有 eggPath
getEggPaths() {
const EggCore = require('../egg');
const eggPaths = [];
let proto = this.app;
//循环递归的获取原型链上的框架 Symbol.for('egg#eggPath') 属性
while (proto) {
proto = Object.getPrototypeOf(proto);
//直到 proto 属性等于 EggCore 本身,说明到了最上层的框架类,停止循环
if (proto === Object.prototype || proto === EggCore.prototype) {
break;
}
const eggPath = proto[Symbol.for('egg#eggPath')];
const realpath = fs.realpathSync(eggPath);
if (!eggPaths.includes(realpath)) {
eggPaths.unshift(realpath);
}
}
return eggPaths;
}

//函数输入:config 或者 plugin ,函数输出:当前环境下的所有配置文件
//该函数会根据 serverScope,serverEnv 的配置信息,返回当前环境对应 filename 的所有配置文件
//比如我们的 serverEnv=prod,serverScope=online,那么返回的 config 配置文件是 ['config.default', 'config.online', 'config.prod', 'config.online_prod']
//这几个文件加载顺序非常重要,因为最终获取到的 config 信息会进行深度的覆盖,后面的文件信息会覆盖前面的文件信息
getTypeFiles(filename) {
const files = [ `${filename}.default` ];
if (this.serverScope) files.push(`${filename}.${this.serverScope}`);
if (this.serverEnv === 'default') return files;

files.push(`${filename}.${this.serverEnv}`);
if (this.serverScope) files.push(`${filename}.${this.serverScope}_${this.serverEnv}`);
return files;
}

//获取框架、应用、插件的 loadUnits 目录集合,上文有关于 loadUnits 的说明
//这个函数在下文中介绍的 loadSerivce,loadMiddleware,loadConfig,loadExtend 中都会用到,因为 plugin,framework,app 中都会有关系这些信息的配置
getLoadUnits() {
if (this.dirs) {
return this.dirs;
}
const dirs = this.dirs = [];
//插件目录,关于 orderPlugins 会在后面的loadPlugin函数中讲到
if (this.orderPlugins) {
for (const plugin of this.orderPlugins) {
dirs.push({
path: plugin.path,
type: 'plugin',
});
}
}
//框架目录
for (const eggPath of this.eggPaths) {
dirs.push({
path: eggPath,
type: 'framework',
});
}
//应用目录
dirs.push({
path: this.options.baseDir,
type: 'app',
});
return dirs;
}

//这个函数用于读取某个 loadUnit 下的文件具体内容,包括 js 文件,json 文件及其它普通文件
loadFile(filepath, ...inject) {
if (!filepath || !fs.existsSync(filepath)) {
return null;
}
if (inject.length === 0) inject = [ this.app ];
let ret = this.requireFile(filepath);
//这里要注意,如果某个 js 文件导出的是一个函数,且不是一个 Class,那么Egg认为这个函数的格式是:app => {},输入是 EggCore 实例,输出是真正需要的信息
if (is.function(ret) && !is.class(ret)) {
ret = ret(...inject);
}
return ret;
}
}

各个 loader 函数的实现源码分析

上文中只是介绍了 EggLoader 中的一些基本属性和函数,那么如何将 loadUnits 中的不同类型的文件分别加载进来呢,eggCore 中每一种类型(service/controller 等)的文件加载都在一个独立的文件里实现。比如我们加载 controller 文件可以通过 ‘./mixin/controller’ 目录下的 loadController 完成,加载 service 文件可以通过 ‘./mixin/service’ 下的 loadService 函数完成,然后将这些方法挂载 EggLoader 的原型上,这样就可以直接在 EggLoader 的实例上使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// egg-core 源码 -> 混入不同目录文件的加载方法到 EggLoader 的原型上

const loaders = [
require('./mixin/plugin'), // loadPlugin方法
require('./mixin/config'), // loadConfig方法
require('./mixin/extend'), // loadExtend方法
require('./mixin/custom'), // loadCustomApp和loadCustomAgent方法
require('./mixin/service'), // loadService方法
require('./mixin/middleware'), // loadMiddleware方法
require('./mixin/controller'), // loadController方法
require('./mixin/router'), // loadRouter方法
];

for (const loader of loaders) {
Object.assign(EggLoader.prototype, loader);
}

我们按照上述 loaders 中定义的元素顺序,对各个 load 函数的源码实现进行一一分析:

loadPlugin 函数

插件是一个迷你的应用,没有包含 router.js 和 controller 文件夹,我们上文也提到,应用和框架里都可以包含插件,而且还可以通过环境变量和初始化参数传入,关于插件初始化的几个参数:

  • enable: 是否开启插件
  • env: 选择插件在哪些环境运行
  • path: 插件的所在路径
  • package: 和 path 只能设置其中一个,根据 package 名称去 node_modules 里查询 plugin ,后面源码里有详细说明
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// egg-core 源码 -> loadPlugin 函数部分源码

loadPlugin() {
//加载应用目录下的 plugins
// readPluginConfigs 这个函数会先调用我们上文提到的 getTypeFiles 获取到 app 目录下所有的 plugin 文件名,然后按照文件顺序进行加载并合并,并规范 plugin 的数据结构
const appPlugins = this.readPluginConfigs(path.join(this.options.baseDir, 'config/plugin.default'));

//加载框架目录下的 plugins
const eggPluginConfigPaths = this.eggPaths.map(eggPath => path.join(eggPath, 'config/plugin.default'));
const eggPlugins = this.readPluginConfigs(eggPluginConfigPaths);

//可以通过环境变量 EGG_PLUGINS 配置 plugins,从环境变量加载 plugins
let customPlugins;
if (process.env.EGG_PLUGINS) {
try {
customPlugins = JSON.parse(process.env.EGG_PLUGINS);
} catch (e) {
debug('parse EGG_PLUGINS failed, %s', e);
}
}

//从启动参数 options 里加载 plugins
//启动参数的 plugins 和环境变量的 plugins 都是自定义的 plugins,可以对默认的应用和框架 plugin 进行覆盖
if (this.options.plugins) {
customPlugins = Object.assign({}, customPlugins, this.options.plugins);
}

this.allPlugins = {};
this.appPlugins = appPlugins;
this.customPlugins = customPlugins;
this.eggPlugins = eggPlugins;

//按照顺序对 plugin 进行合并及覆盖
// _extendPlugins 在合并的过程中,对相同 name 的 plugin 中的属性进行覆盖,有一个特殊处理的地方,如果某个属性的值是空数组,那么不会覆盖前者
this._extendPlugins(this.allPlugins, eggPlugins);
this._extendPlugins(this.allPlugins, appPlugins);
this._extendPlugins(this.allPlugins, customPlugins);

const enabledPluginNames = [];
const plugins = {};
const env = this.serverEnv;
for (const name in this.allPlugins) {
const plugin = this.allPlugins[name];
// plugin 的 path 可能是直接指定的,也有可能指定了一个 package 的 name,然后从 node_modules 中查找
//从 node_modules 中查找的顺序是:{APP_PATH}/node_modules -> {EGG_PATH}/node_modules -> $CWD/node_modules
plugin.path = this.getPluginPath(plugin, this.options.baseDir);
//这个函数会读取每个 plugin.path 路径下的 package.json,获取 plugin 的 version,并会使用 package.json 中的 dependencies,optionalDependencies, env 变量作覆盖
this.mergePluginConfig(plugin);
// 有些 plugin 只有在某些环境(serverEnv)下才能使用,否则改成 enable=false
if (env && plugin.env.length && !plugin.env.includes(env)) {
plugin.enable = false;
continue;
}
//获取 enable=true 的所有 pluginnName
plugins[name] = plugin;
if (plugin.enable) {
enabledPluginNames.push(name);
}
}

//这个函数会检查插件的依赖关系,插件的依赖关系在 dependencies 中定义,最后返回所有需要的插件
//如果 enable=true 的插件依赖的插件不在已有的插件中,或者插件的依赖关系存在循环引用,则会抛出异常
//如果 enable=true 的依赖插件为 enable=false,那么该被依赖的插件会被改为 enable=true
this.orderPlugins = this.getOrderPlugins(plugins, enabledPluginNames, appPlugins);

//最后我们以对象的方式将 enable=true 的插件挂载在 this 对象上
const enablePlugins = {};
for (const plugin of this.orderPlugins) {
enablePlugins[plugin.name] = plugin;
}
this.plugins = enablePlugins;
}

loadConfig 函数

配置信息的管理对于一个应用来说非常重要,我们需要对不同的部署环境的配置进行管理,Egg 就是针对环境加载不同的配置文件,然后将配置挂载在 app 上,

加载 config 的逻辑相对简单,就是按照顺序加载所有 loadUnits 目录下的 config 文件内容,进行合并,最后将 config 信息挂载在 this 对象上,整个加载函数请看下面源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// egg-core 源码 -> loadConfig 函数分析

loadConfig() {
this.configMeta = {};
const target = {};
//这里之所以先加载 app 相关的 config ,是因为在加载 plugin 和 framework 的 config 时会使用到 app 的 config
const appConfig = this._preloadAppConfig();

// config的加载顺序为:plugin config.default -> framework config.default -> app config.default -> plugin config.{env} -> framework config.{env} -> app config.{env}
for (const filename of this.getTypeFiles('config')) {
// getLoadUnits 函数前面有介绍,获取 loadUnit 目录集合
for (const unit of this.getLoadUnits()) {
const isApp = unit.type === 'app';
//如果是加载插件和框架下面的 config,那么会将 appConfig 当作参数传入
//这里 appConfig 已经加载了一遍了,又重复加载了,不知道处于什么原因,下面会有 _loadConfig 函数源码分析
const config = this._loadConfig(unit.path, filename, isApp ? undefined : appConfig, unit.type);
if (!config) {
continue;
}
// config 进行覆盖
extend(true, target, config);
}
}
this.config = target;
}

_loadConfig(dirpath, filename, extraInject, type) {
const isPlugin = type === 'plugin';
const isApp = type === 'app';

let filepath = this.resolveModule(path.join(dirpath, 'config', filename));
//如果没有 config.default 文件,则用 config.js 文件替代,隐藏逻辑
if (filename === 'config.default' && !filepath) {
filepath = this.resolveModule(path.join(dirpath, 'config/config'));
}
// loadFile 函数我们在 EggLoader 中讲到过,如果 config 导出的是一个函数会先执行这个函数,将函数的返回结果导出,函数的参数也就是[this.appInfo extraInject]
const config = this.loadFile(filepath, this.appInfo, extraInject);
if (!config) return null;

//框架使用哪些中间件也是在 config 里作配置的,后面关于 loadMiddleware 函数实现中有说明
// coreMiddleware 只能在框架里使用
if (isPlugin || isApp) {
assert(!config.coreMiddleware, 'Can not define coreMiddleware in app or plugin');
}
// middleware 只能在应用里定义
if (!isApp) {
assert(!config.middleware, 'Can not define middleware in ' + filepath);
}
//这里是为了设置 configMeta,表示每个配置项是从哪里来的
this[SET_CONFIG_META](config, filepath);
return config;
}

loadExtend 相关函数

这里的 loadExtend 是一个笼统的概念,其实是针对 Koa 中的 app.response,app.respond,app.context 以及 app 本身进行扩展,同样是根据所有 loadUnits 下的配置顺序进行加载

下面看一下 loadExtend 这个函数的实现,一个通用的加载函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// egg-core -> loadExtend 函数实现

// name输入是 "response"/"respond"/"context"/"app" 中的一个,proto 是被扩展的对象
loadExtend(name, proto) {
//获取指定 name 所有 loadUnits 下的配置文件路径
const filepaths = this.getExtendFilePaths(name);
const isAddUnittest = 'EGG_MOCK_SERVER_ENV' in process.env && this.serverEnv !== 'unittest';
for (let i = 0, l = filepaths.length; i < l; i++) {
const filepath = filepaths[i];
filepaths.push(filepath + `.${this.serverEnv}`);
if (isAddUnittest) filepaths.push(filepath + '.unittest');
}

//这里并没有对属性的直接覆盖,而是对原先的 PropertyDescriptor 的 get 和 set 进行合并
const mergeRecord = new Map();
for (let filepath of filepaths) {
filepath = this.resolveModule(filepath);
const ext = this.requireFile(filepath);

const properties = Object.getOwnPropertyNames(ext)
.concat(Object.getOwnPropertySymbols(ext));
for (const property of properties) {
let descriptor = Object.getOwnPropertyDescriptor(ext, property);
let originalDescriptor = Object.getOwnPropertyDescriptor(proto, property);
if (!originalDescriptor) {
const originalProto = originalPrototypes[name];
if (originalProto) {
originalDescriptor = Object.getOwnPropertyDescriptor(originalProto, property);
}
}
//如果原始对象上已经存在相关属性的 Descriptor,那么对其 set 和 get 方法进行合并
if (originalDescriptor) {
descriptor = Object.assign({}, descriptor);
if (!descriptor.set && originalDescriptor.set) {
descriptor.set = originalDescriptor.set;
}
if (!descriptor.get && originalDescriptor.get) {
descriptor.get = originalDescriptor.get;
}
}
//否则直接覆盖
Object.defineProperty(proto, property, descriptor);
mergeRecord.set(property, filepath);
}
}
}

参考文献