NodeJS
首发于NodeJS
Egg 源码分析之 egg-core(一)

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

我们团队现在开发的node项目都是基于 Koa 框架实现的,虽然现在也形成了一套团队内的标准,但是在开发的过程中也遇到了一些问题:

  1. 由于没有统一的规范,新人上手和沟通成本比较高,容易出现错误
  2. 仅局限于目前需求进行设计,扩展性不高
  3. 系统部署及配置信息维护成本较高
  4. 业务代码实现起来不是很优雅,比如(1)关于文件的引入,到处的 require,经常会出现忘记 require 或者多余的 require 问题(2)因为在当前请求的上下文 ctx 中封装了很多有用的数据,包括 response,request 以及在中间件中处理的中间结果,但是如果我们想在 service 以下的 js 文件中获取到 ctx 必须需要主动以函数参数的方式传进去,不是特别友好

而阿里团队基于 Koa 开发的 Egg 框架,基于一套统一约定进行应用开发,很好的解决了我们遇到的一些问题,看了 Egg 的官方开发文档 后,比较好奇它是怎么把 controller,service,middleware,extend,route.js 等关联在一起并加载的,后面看了源码发现这块逻辑主要在 egg-core 这个库中实现的,所以关于自己对egg-core源码的学习收获做一个总结:

egg-core 是什么

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

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

  • 一个应用必须指定一个框架才能运行起来,根据需要我们可以给一个应用配置多个不同的插件;
  • 插件只完成特定独立的功能,实现即插即拔的效果;
  • 框架是一个启动器,必须有它才能运行起来。框架还是一个封装器,它可以在已有框架的基础上进行封装,框架也可以配置插件,其中 Egg,EggCore 都是框架;
  • 在框架的基础上还可以扩展出新的框架,也就是说框架是可以无限级继承的,有点像类的继承;
  • 框架、应用、插件的关于 service/controller/config/middleware 的目录结构配置基本相同,称之为加载单元(loadUnit),包括后面源码分析中的 getLoadUnits 函数都是为了获取这个结构;
# 加载单元的目录结构如下图,其中插件和框架没有 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 对象源码:

// 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 的生命周期管理,由于和整个文件加载逻辑关系不大,所以这里不作说明
// 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 函数作准备,我们下面看一下其基础部分的实现:

// 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.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 的实例上使用

// 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 ,后面源码里有详细说明
// 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 对象上,整个加载函数请看下面源码:

// 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 这个函数的实现,一个通用的加载函数:

// 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);
      }
    }
  }

由于知乎文章字数限制,想要了解 loadService 函数,loadController函数, loadRouter函数的实现源码分析,请看下文

张佃鹏:Egg 源码分析之 egg-core(二)zhuanlan.zhihu.com图标

参考文献

编辑于 2018-10-19

文章被以下专栏收录

    在 eggjs 团队的日常协作中,遵循「基于 GitLab 的硬盘式异步协作模式」。 先通过 issue 发起 RFC 召集讨论,再提交 Pull Request 和 Code Review,这样便于沉淀,即使是当时没有参与讨论的开发者,事后也能通过 issue 了解某个功能设计的前因后果。 因此,本专栏用于汇总近期值得关注的 Egg.js 和 Node.js 相关动态,将不定期发布。