前言
egg框架的使用过程中会发现有一些非常方便和优雅的地方,比如对各个环境下配置的合并和加载,对controller,service,middleware的集成和建立关联,对插件扩展等,从源码中可以发现egg是继承于egg-core的,而这些核心逻辑的实现都是在egg-core里完成的,因此可以说egg框架的核心在于egg-core。下面就对egg-core的源码进行一些解读,来体会框架设计的精妙之处。
模块关系
egg-core源码的入口导出了EggCore、EggLoader、BaseContextClass、utils 四个模块,其中EggCore类是基类,做一些初始化工作;EggLoader类是最核心的一个部分,对整个框架的controller,service,middleware等进行初始化和加载集成,并建立相互关联。BaseContextClass是另一个基类, 用来接收ctx对象,挂载在上下文上,egg框架中的 controller 和 service 都继承该类,所以都能通过this.ctx拿到上下文对象。utils则是定义了一些框架中用到的方法。看一张图片会比较清晰:
核心模块
egg-core
egg-core继承于 Koa ,在该类中主要完成一些初始化工作,大概可以分为
- 对初始化参数的处理,包括对传入的应用的目录,运行的类型的判断等。
constructor(options = {}) {
options.baseDir = options.baseDir || process.cwd();
options.type = options.type || 'application';
assert(typeof options.baseDir === 'string', 'options.baseDir required, and must be a string');
assert(fs.existsSync(options.baseDir), `Directory ${options.baseDir} not exists`);
assert(fs.statSync(options.baseDir).isDirectory(), `Directory ${options.baseDir} is not a directory`);
assert(options.type === 'application' || options.type === 'agent', 'options.type should be application or agent');
- 关键属性的初始化和挂载,包括Controller、Service、lifecycle、loader、router等。
this.console = new EggConsoleLogger();
this.BaseContextClass = BaseContextClass;
const Controller = this.BaseContextClass;
this.Controller = Controller;
const Service = this.BaseContextClass;
this.Service = Service;
this.lifecycle = new Lifecycle({
baseDir: options.baseDir,
app: this,
logger: this.console,
});
this.loader = new Loader({
baseDir: options.baseDir,
app: this,
plugins: options.plugins,
logger: this.console,
serverScope: options.serverScope,
env: options.env,
});
- 生命周期函数的初始化和监听,中间件use方法的定义。
beforeStart(scope) {
this.lifecycle.registerBeforeStart(scope);
}
ready(flagOrFunction) {
return this.lifecycle.ready(flagOrFunction);
}
beforeClose(fn) {
this.lifecycle.registerBeforeClose(fn);
}
use(fn) {
assert(is.function(fn), 'app.use() requires a function');
debug('use %s', fn._name || fn.name || '-');
this.middleware.push(utils.middleware(fn));
return this;
}
egg-loader
整个框架目录结构(controller,service,middleware,extend,router)的加载和初始化工作都在该类中实现的。egg-loader中定义了一系列初始化的全局方法和加载loader的基础方法。将所有分开写在各个文件中的loader方法统一在该类中引入进行加载,会根据目录结构规范将目录结构中的 config,controller,service,middleware,plugin,router 等文件 load 到 app 或者 context 上,最终挂载输出内容。开发人员只要按照这套约定规范,就可以很方便进行开发。
// 加载文件
loadFile(filepath, ...inject) {
filepath = filepath && this.resolveModule(filepath);
if (!filepath) {
return null;
}
// function(arg1, args, ...) {}
if (inject.length === 0) inject = [ this.app ];
let ret = this.requireFile(filepath);
if (is.function(ret) && !is.class(ret)) {
ret = ret(...inject);
}
return ret;
}
requireFile(filepath) {
const timingKey = `Require(${this[REQUIRE_COUNT]++}) ${utils.getResolvedFilename(filepath, this.options.baseDir)}`;
this.timing.start(timingKey);
const ret = utils.loadFile(filepath);
this.timing.end(timingKey);
return ret;
}
// 挂载到App上
loadToApp(directory, property, opt) {
const target = this.app[property] = {};
opt = Object.assign({}, {
directory,
target,
inject: this.app,
}, opt);
const timingKey = `Load "${String(property)}" to Application`;
this.timing.start(timingKey);
new FileLoader(opt).load();
this.timing.end(timingKey);
}
// 挂载到上下文上
loadToContext(directory, property, opt) {
opt = Object.assign({}, {
directory,
property,
inject: this.app,
}, opt);
const timingKey = `Load "${String(property)}" to Context`;
this.timing.start(timingKey);
new ContextLoader(opt).load();
this.timing.end(timingKey);
}
在加载的过程中会用到 file_loader 和 context_loader 两个类,这两个是Load加载流程中的基础类,提供了基础的加载上下文和方法,这个在后面会有细说。
具体loader模块
err-loader中会将具体的一些loader模块require进来并挂载在原型链上,这样在err-loader中就可以访问到具体的loader模块中的方法。这些loader模块包括plugin、config、service、middleware、controller等,分别负责各自的一些逻辑,并且模块间也存在着相关的联系。下面这张图中大致标注出了各个loader的依赖关系,比如加载 middleware 时会用到 config 关于应用中间件的配置,对内部中间件进行 use 的主动加载。具体的关系图如下:
下面会挑其中的重点Loader进行具体分析:
plugin_loader
首先来看插件加载模块,该模块是err-core中一个非常重要的模块,egg-core中的插件大致可以分为3类:框架级插件、应用级插件、用户自定义插件。这3种插件如何进行共存和覆盖,如何根据环境变量和开启开关进行加载?这就是plugin-loader中做的控制。该模块整体做了4件事情:
- 从以上说的3类插件的目录中读取插件,并按照框架级插件、应用级插件、用户自定义插件的顺序进行插件的加载和覆盖,后面的插件会覆盖前面的插件,得到最后合并后的插件。
- 根据当前环境变量和插件的配置对插件是否开启进行处理,因为有一些插件只有在特定的环境下才会开启。
- 对所有的框架进行依赖关系的检查和相应的处理,如果有依赖插件的缺失或者循环引用,会抛出错误。如果有依赖关系的插件没有开启,那么也会将改插件开启。
- 经过以上3步处理后,将最终符合开启条件的插件对象挂载在 this 对象上,完成插件的处理流程。
file_loader
这是一个基础的loader模块,通过提供一个 load 函数对工程的目录结构和文件内容进行解析,这个函数如下,其核心在于调用了parse方法对文件路径进行解析,对解析后的数组中的对象的属性进行重新分割处理。
load() {
const items = this.parse();
//items的形式: [{ properties: [ 'a', 'b', 'c'], exports1,fullpath1}, { properties: [ 'a', 'b', 'c'], exports2,fullpath2}]
const target = this.options.target;
for (const item of items) {
debug('loading item %j', item);
// item { properties: [ 'a', 'b', 'c'], exports }
// => target = {a: {b: {c: exports1, d: exports2}}}
// => target.a.b.c = exports
item.properties.reduce((target, property, index) => {
let obj;
const properties = item.properties.slice(0, index + 1).join('.');
if (index === item.properties.length - 1) {
if (property in target) {
if (!this.options.override) throw new Error(`can't overwrite property '${properties}' from ${target[property][FULLPATH]} by ${item.fullpath}`);
}
obj = item.exports;
if (obj && !is.primitive(obj)) {
obj[FULLPATH] = item.fullpath;
obj[EXPORTS] = true;
}
} else {
obj = target[property] || {};
}
target[property] = obj;
debug('loaded %s', properties);
return obj;
}, target);
}
return target;
}
对应的parse方法如下,代码中已经进行了关键语句的注释
parse() {
//最终生成 [{ properties: [ 'a', 'b', 'c'], exports,fullpath}] 形式,
//properties 文件路径名称的数组, exports 是导出对象, fullpath 是文件的绝对路径
let files = this.options.match;
if (!files) {
files = (process.env.EGG_TYPESCRIPT === 'true' && utils.extensions['.ts'])
? [ '**/*.(js|ts)', '!**/*.d.ts' ]
: [ '**/*.js' ];
} else {
files = Array.isArray(files) ? files : [ files ];
}
let ignore = this.options.ignore;
if (ignore) {
ignore = Array.isArray(ignore) ? ignore : [ ignore ];
ignore = ignore.filter(f => !!f).map(f => '!' + f);
files = files.concat(ignore);
}
//文件目录转换为数组
let directories = this.options.directory;
if (!Array.isArray(directories)) {
directories = [ directories ];
}
const filter = is.function(this.options.filter) ? this.options.filter : null;
const items = [];
debug('parsing %j', directories);
for (const directory of directories) {
//每个文件目录下面可能还会有子文件夹,所以 globby.sync 函数是获取所有文件包括子文件下的文件的路径
const filepaths = globby.sync(files, { cwd: directory });
for (const filepath of filepaths) {
// 拼接成完整文件路径
// app/service/foo/bar.js
const fullpath = path.join(directory, filepath);
// 如果不是文件跳过,进行下一次循环
if (!fs.statSync(fullpath).isFile()) continue;
// get properties
// foo/bar.js => ['foo', 'bar' ]
const properties = getProperties(filepath, this.options);
// app/service ['foo', 'bar' ] => service.foo.bar
const pathName = directory.split(/[/\\]/).slice(-1) + '.' + properties.join('.');
// get exports from the file
const exports = getExports(fullpath, this.options, pathName);
// ignore exports when it's null or false returned by filter function
if (exports == null || (filter && filter(exports) === false)) continue;
// set properties of class
if (is.class(exports)) {
exports.prototype.pathName = pathName;
exports.prototype.fullPath = fullpath;
}
items.push({ fullpath, properties, exports });
debug('parse %s, properties %j, export %j', fullpath, properties, exports);
}
}
return items;
}
}
最后会返回一个解析后的 target 对象,对象的层级结构跟目录结构相对应,最内层是文件的导出对象或者方法。大概的格式如下:
target = {
a: {
b: {
c: exports1,
d: exports2,
}
}
}
导出的这个 target 对象会在 context_loader 里用到。service、controller的loader实现 均借助了该基类。
context_loader
context_loader类是用于处理上下文挂载的基类,继承于file_loader。上文说了 file_loader 的作用是对文件目录的解析生成 target 对象。而 context_loader 类就是在这基础上进一步实现了 target 对象的挂载。用于将 FileLoader 解析出来的 target 挂载在 app.context 上 对应传入的 property 属性上。
service_loader
service_loader处理service文件夹下文件的加载,该模块中直接就导出了一个 loadService 的方法。该方法把service的文件目录('app/service') 和 解析后需要挂载的属性('service')作为参数传入 egg_loader的 loadToContext 方法中,loadToContext方法会创建一个 ContextLoader 类的实例,并调用其load()方法,通过上文说的 file_loader 和 context_loader 中的核心逻辑,实现将 app/service 文件夹下的文件路径和导出解析成target对象,最终挂载在app.context.service下。
middleware_loader
中间件的加载,主要做了3件事:
- 将通过 FileLoader 实例加载 app/middleware 目录下的所有文件并导出,然后将 middleware 挂载在 app 上,可以通过app.middleware进行访问
- 对中间件函数进行包装,统一处理成async function(ctx, next) 形式
- 对在 config 中配置了 appMiddleware 和 coreMiddleware 的中间件直接调用 app.use 使用,其它中间件只是挂载在 app 上。
controller_loader
constroller的加载跟service还是有区别的:
- constroller挂载在app上,service挂载在app.ctx上,constroller的调用只需要访问到对应的service名称,而service的调用需要具体到导出的函数,因此两者使用egg_loader中方法不同,一个是loadToApp(调用FileLoader实例),一个是LoadToContext(调用ContextLoader实例)
- controller 中生成的函数最终还是在 router.js 中当作一个中间件使用,所以我们需要将 controller 中内容转换为中间件形式 async function(ctx, next) ,这跟service相比在调用FileLoad类实例的load函数时就要多传一个 initializer 函数,对exports的内容进行处理。
具体controller_loader类中做的事情也是围绕以上两点,解析 app/controller 文件目录生成targe对象,完成在app上对 controller 属性的挂载。同时对 initializer 函数进行了各种情况下的处理。
config_loader
config_loader对整个应用的配置加载做了管理,会根据当前环境的不同,加载不同的配置环境,并和默认的配置合并后得到最终的配置。config_loader对配置维度的加载有2个维度,大的维度来说,先会加载plugin的配置文件,再加载framework的配置文件,最后才是app的配置文件。小的维度来说会先从基本路径下的config/config 目录下加载默认的配置文件,然后根据当前的serverEnv的不同,加载不同环境的配置文件,最后将当前环境下的配置文件和默认的配置文件进行合并得到最终的配置文件。总结来说:先分别按照plugin、framework、app的顺序合并得到默认的配置和当前环境下的配置。然后用合并默认的配置和当前环境下的配置,得到的才是最终的配置。
router_loader
router_loader中其实就做了加载一下 app/router 目录下的文件而已。这是因为具体的router的逻辑,都交给了eggcore中的router属性,而而 router 又是 Router 类的实例,Router 类是基于 koa-router 实现的。所以egg中关于router的原理跟koa大致是相同的,这里就不展开说了。
总结
看完 egg-core 的源码之后,还是有很多收获的,在我看来有以下几点值得借鉴和思考:
- 规范和代码风格的重要性,在多人合作中这一点尤其重要。而egg-core则通过定义和实现了关于目录解析和属性挂载的这一套规范,解决了规范一致性的问题,同时通过controller、service的分层设计,让代码的可读性和易维护性也得到了大大增强。
- 框架的扩展和继承的设计,err-core本身是基础koa的,而本身也被egg继承。通过这种框架之间的继承可以根据实际需求方便地构建出需要的框架。而具体到里面的各个load类,也是通过先定义了 fileLoader 和 contextLoader 两个基类,被其他loader类频繁地进行依赖和调用。而 egg_loader类 和其他 loader 类也是解耦的,将其他 loader类 加载到egg_loader类的原型链上进行访问,入口统一,内容代码独立,体现了很好的设计思想。
- Symbol和Symbol.for的使用,对已有或缓存的内容的判断和加载通过了Symbol来实现,跟用全局Map对象进行维护更优雅和方便。