天天看點

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/controler/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
           
eggCore的主要工作

egg.js的大部分核心代碼實作都在egg-core庫中,egg-core主要export四個對象:

  • EggCore類:繼承于Koa,做一些初始化工作,EggCore中最主要的一個屬性是loader,也就是egg-core的導出的第二個類EggLoader的執行個體
  • EggLoader類:整個架構目錄結構(controller,service,middleware,extend,route.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上,開發人員隻要按照這套約定規範,就可以很友善進行開發,以下是EggCore的exports對象源碼:

//egg-core源碼 -> index檔案導出的資料結構
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,
};

           

EggLoader的具體實作源碼學習

EggCore類源碼學習

EggCore類是算是上文提到的架構範疇,它從Koa類繼承而來,并做了一些初始化工作,其中有三個主要屬性是:

  • loader:這個對象是EggLoader的執行個體,定義了多個load函數,用于對loadUnit目錄下的檔案進行加載,後面後專門講這個類的是實作
  • router:是EggRouter類的執行個體,從koa-router繼承而來,用于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);
        // register router middleware
        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中的不同類型的檔案分别加載進來呢,egg-core中每一種類型(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 = []; // enabled plugins that configured explicitly
    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的邏輯相對簡單,就是按照順序加載所有loadUnit目錄下的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) {
          // don't override descriptor
          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函數

如何在egg架構中使用service

loadService函數的實作是所有load函數中最複雜的一個,我們不着急看源碼,先看一下service在egg架構中如何使用

//egg-core源碼 -> 如何在egg架構中使用service

//方式1:app/service/user1.js
//這個是最标準的做法,導出一個class,這個class繼承了require('egg').Service,其實也就是我們上文提到的eggCore導出的BaseContextClass
//最終我們在業務邏輯中擷取到的是這個class的一個執行個體,在load的時候是将app.context當作建立執行個體的參數
//在controller中調用方式:this.ctx.service.user1.find(1)
const Service = require('egg').Service;
class UserService extends Service {
  async find(uid) {
    //此時我們可以通過this.ctx,this.app,this.config,this.service擷取到有用的資訊,尤其是this.ctx非常重要,每個請求對應一個ctx,我們可以查詢到目前請求的所有資訊
    const user = await this.ctx.db.query('select * from user where uid = ?', uid);
    return user;
  }
}
module.exports = UserService;

//方式2:app/service/user2.js
//這個做法是我模拟了一個BaseContextClass,當然也就可以實作方法1的目的,但是不推薦
class UserService {
  constructor(ctx) {
    this.ctx = ctx;
    this.app = ctx.app;
    this.config = ctx.app.config;
    this.service = ctx.service;
  }
  async find(uid) {
    const user = await this.ctx.db.query('select * from user where uid = ?', uid);
    return user;
  }
}
module.exports = UserService;

//方式3:app/service/user3.js
//service中也可以export函數,在load的時候會主動調用這個函數,把appInfo參數傳入,最終擷取到的是函數傳回結果
//在controller中調用方式:this.ctx.service.user3.getAppName(1),這個時候在service中擷取不到目前請求的上下文ctx
module.exports = (appInfo) => {
    return {
        async getAppName(uid){
            return appInfo.name;
        }
    }
};

//方式4:app/service/user4.js
//service也可以直接export普通的原生對象,load的時候會将該普通對象傳回,同樣擷取不到目前請求的上下文ctx
//在controller中調用方式:this.ctx.service.user4.getAppName(1)
module.exports = {
    async getAppName(uid){
        return appInfo.name;
    }
};
           

我們上面列舉了service下的js檔案的四種寫法,都是從每次請求的上下文this.ctx擷取到service對象,然後就可以使用到每個service檔案導出的對象了,這裡主要有兩個地方需要注意:

  1. 為什麼我們可以從每個請求的this.ctx上擷取到service對象呢:

    看過koa源碼的同學知道,this.ctx其實是從app.context繼承而來,是以我們隻要把service綁定到app.context上,那麼目前請求的上下文ctx自然可以拿到service對象,eggLoader也是這樣做的

  2. 針對上述四種使用場景,具體導出執行個體是怎麼處理的呢?
    • 如果導出的是一個類,EggLoader會主動以ctx對象去初始化這個執行個體并導出,是以我們就可以直接在該類中使用this.ctx擷取目前請求的上下文了
    • 如果導出的是一個函數,那麼EggLoader會以app作為參數運作這個函數并将結果導出
    • 如果是一個普通的對象,直接導出

FileLoader類的實作分析

在實作loadService函數時,有一個基礎類就是FileLoader,它同時也是loadMiddleware,loadController實作的基礎,這個類提供一個load函數根據目錄結構和檔案内容進行解析,傳回一個target對象,我們可以根據檔案名以及子檔案名以及函數名稱擷取到service裡導出的内容,target結構類似這樣:

{
    "file1": {
        "file11": {
            "function1": a => a
        }
    },
    "file2": {
        "function2": a => a
    }
}


           

下面我們先看一下fileLoader這個類的實作

//egg-core源碼 -> FileLoader實作

class FileLoader {
  constructor(options) {
    /*options裡幾個重要參數的含義:
    1.directory: 需要加載檔案的所有目錄
    2.target: 最終加載成功後的目标對象
    3.initializer:一個初始化函數,對檔案導出内容進行初始化,這個在loadController實作時會用到
    4.inject:如果某個檔案的導出對象是一個函數,那麼将該值傳入函數并執行導出,一般都是this.app
    */
    this.options = Object.assign({}, defaults, options);
  }
  load() {
    //解析directory下的檔案,下面有parse函數的部分實作
    const items = this.parse();
    const target = this.options.target;
    //item1 = { properties: [ 'a', 'b', 'c'], exports1 },item2 = { properties: [ 'a', 'b', 'd'], exports2 }
    // => target = {a: {b: {c: exports1, d: exports2}}}
    //根據檔案路徑名稱遞歸生成一個大的對象target,我們通過target.file1.file2就可以擷取到對應的導出内容
    for (const item of items) {
      item.properties.reduce((target, property, index) => {
        let obj;
        const properties = item.properties.slice(0, index + 1).join('.');
        if (index === item.properties.length - 1) {
          obj = item.exports;
          if (obj && !is.primitive(obj)) {
            //這步驟很重要,确定這個target是不是一個exports,有可能隻是一個路徑而已
            obj[FULLPATH] = item.fullpath;
            obj[EXPORTS] = true;
          }
        } else {
          obj = target[property] || {};
        }
        target[property] = obj;
        return obj;
      }, target);
    }
    return target;
  }
  
  //最終生成[{ properties: [ 'a', 'b', 'c'], exports,fullpath}]形式,properties檔案路徑名稱的數組,exports是導出對象,fullpath是檔案的絕對路徑
  parse() {
    //檔案目錄轉換為數組
    let directories = this.options.directory;
    if (!Array.isArray(directories)) {
      directories = [ directories ];
    }
    //周遊所有檔案路徑
    const items = [];
    for (const directory of directories) {
      //每個檔案目錄下面可能還會有子檔案夾,是以globby.sync函數是擷取所有檔案包括子檔案下的檔案的路徑
      const filepaths = globby.sync(files, { cwd: directory });
      for (const filepath of filepaths) {
        const fullpath = path.join(directory, filepath);
        if (!fs.statSync(fullpath).isFile()) continue;
        //擷取檔案路徑上的以"/"分割的所有檔案名,foo/bar.js => [ 'foo', 'bar' ],這個函數會對propertie同一格式,預設為駝峰
        const properties = getProperties(filepath, this.options);
        //app/service/foo/bar.js => service.foo.bar
        const pathName = directory.split(/[/\\]/).slice(-1) + '.' + properties.join('.');
        //getExports函數擷取檔案内容,并将結果做一些處理,看下面實作
        const exports = getExports(fullpath, this.options, pathName);
        //如果導出的是class,會設定一些屬性,這個屬性下文中對于class的特殊處理地方會用到
        if (is.class(exports)) {
          exports.prototype.pathName = pathName;
          exports.prototype.fullPath = fullpath;
        }
        items.push({ fullpath, properties, exports });
      }
    }
    return items;
  }
}

//根據指定路徑擷取導出對象并作預處理
function getExports(fullpath, { initializer, call, inject }, pathName) {
  let exports = utils.loadFile(fullpath);
  //用initializer函數對exports結果做預處理
  if (initializer) {
    exports = initializer(exports, { path: fullpath, pathName });
  }
  //如果exports是class,generatorFunction,asyncFunction則直接傳回    
  if (is.class(exports) || is.generatorFunction(exports) || is.asyncFunction(exports)) {
    return exports;
  }
  //如果導出的是一個普通函數,并且設定了call=true,預設是true,會将inject傳入并調用該函數,上文中提到過好幾次,就是在這裡實作的
  if (call && is.function(exports)) {
    exports = exports(inject);
    if (exports != null) {
      return exports;
    }
  }
  //其它情況直接傳回
  return exports;
}

           

ContextLoader類的實作分析

上文中說道loadService函數其實最終把service對象挂載在了app.context上,是以為此提供了ContextLoader這個類,繼承了FileLoader類,用于将FileLoader解析出來的target挂載在app.context上,下面是其實作:

//egg-core -> ContextLoader類的源碼實作

class ContextLoader extends FileLoader {
  constructor(options) {
    const target = options.target = {};
    super(options);
    //FileLoader已經講過inject就是app
    const app = this.options.inject;
    //property就是要挂載的屬性,比如"service"
    const property = options.property;
    //将service屬性挂載在app.context上
    Object.defineProperty(app.context, property, {
      get() {
        //做緩存,由于不同的請求ctx不一樣,這裡是針對同一個請求的内容進行緩存
        if (!this[CLASSLOADER]) {
          this[CLASSLOADER] = new Map();
        }
        const classLoader = this[CLASSLOADER];
        //擷取導出執行個體,這裡就是上文用例中擷取this.ctx.service.file1.fun1的實作,這裡的執行個體就是this.ctx.service,實作邏輯請看下面的getInstance的實作
        let instance = classLoader.get(property);
        if (!instance) {
          //這裡傳入的this就是為了初始化require('egg').Service執行個體時當作參數傳入
          //this會根據調用者的不同而改變,比如是app.context的執行個體調用那麼就是app.context,如果是app.context子類的執行個體調用,那麼就是其子類的執行個體
          //就是因為這個this,我們service裡繼承require('egg').Service,才可以通過this.ctx擷取到目前請求的上下文
          instance = getInstance(target, this);
          classLoader.set(property, instance);
        }
        return instance;
      },
    });
  }
}

//values是FileLoader/load函數生成target對象
function getInstance(values, ctx) {
  //上文FileLoader裡實作中我們講過,target對象是一個由路徑和exports組裝成的一個大對象,這裡Class是為了确定其是不是一個exports,有可能是一個路徑名
  const Class = values[EXPORTS] ? values : null;
  let instance;
  if (Class) {
    if (is.class(Class)) {
        //這一步很重要,如果是類,就用ctx進行初始化擷取執行個體
      instance = new Class(ctx);
    } else {
      //普通對象直接導出,這裡要注意的是如果是exports函數,在FileLoader實作中已經将其執行并轉換為了對象
      //function和class分别在子類和父類的處理的原因是,function的處理邏輯loadMiddleware,loadService,loadController公用,而class的處理邏輯loadService使用
      instance = Class;
    }
  } else if (is.primitive(values)) {
    //原生類型直接導出
    instance = values;
  } else {
    //如果目前的target部分是一個路徑,那麼會建立一個ClassLoader執行個體,這個ClassLoader中又會遞歸的調用getInstance
    //這裡之是以建立一個類,一是為了做緩存,二是為了在每個節點擷取到的都是一個類的執行個體
    instance = new ClassLoader({ ctx, properties: values });
  }
  return instance;
}
           

loadService的實作

有了ContextLoader類,那實作loadService函數就非常容易了,如下:

//egg-core -> loadService函數實作源碼
//loadService函數調用loadToContext函數
loadService(opt) {
    opt = Object.assign({
      call: true,
      caseStyle: 'lower',
      fieldClass: 'serviceClasses',
      directory: this.getLoadUnits().map(unit => path.join(unit.path, 'app/service')), //所有加載單元目錄下的service
    }, opt);
    const servicePaths = opt.directory;
    this.loadToContext(servicePaths, 'service', opt);
}
//loadToContext函數直接建立ContextLoader執行個體,調用load函數實作加載
loadToContext(directory, property, opt) {
    opt = Object.assign({}, {
      directory,
      property,
      inject: this.app,
    }, opt);
    new ContextLoader(opt).load();
}
           
loadMiddleware函數

中間件是koa架構中很重要的一個環節,通過app.use引入中間件,使用洋蔥圈模型,是以中間件加載的順序很重要。

  • 如果在上文中的config中配置的中間件,系統會自動用app.use函數使用該中間件
  • 所有的中間件我們都可以在app.middleware中通過中間件name擷取到,便于在業務中動态使用
//egg-core -> loadMiddleware函數實作源碼

loadMiddleware(opt) {
    const app = this.app;
    // load middleware to app.middleware
    opt = Object.assign({
      call: false,   //call=false表示如果中間件導出是函數,不會主動調用函數做轉換
      override: true,
      caseStyle: 'lower',
      directory: this.getLoadUnits().map(unit => join(unit.path, 'app/middleware')) //所有加載單元目錄下的middleware
    }, opt);
    const middlewarePaths = opt.directory;
    //将所有中間件middlewares挂載在app上,這個函數在loadController實作中也用到了,看下文的實作
    this.loadToApp(middlewarePaths, 'middlewares', opt);
    //将app.middlewares中的每個中間件重新綁定在app.middleware上,每個中間件的屬性不可配置,不可枚舉
    for (const name in app.middlewares) {
      Object.defineProperty(app.middleware, name, {
        get() {
          return app.middlewares[name];
        },
        enumerable: false,
        configurable: false,
      });
    }
    //隻有在config中配置了appMiddleware和coreMiddleware才會直接在app.use中使用,其它中間件隻是挂載在app上,開發人員可以動态使用
    const middlewareNames = this.config.coreMiddleware.concat(this.config.appMiddleware);
    const middlewaresMap = new Map();
    for (const name of middlewareNames) {
      //如果config中定義middleware在app.middlewares中找不到或者重複定義,都會報錯
      if (!app.middlewares[name]) {
        throw new TypeError(`Middleware ${name} not found`);
      }
      if (middlewaresMap.has(name)) {
        throw new TypeError(`Middleware ${name} redefined`);
      }
      middlewaresMap.set(name, true);
      const options = this.config[name] || {};
      let mw = app.middlewares[name];
      //中間件的檔案定義必須exports一個普通function,并且接受兩個參數:
      //options: 中間件的配置項,架構會将 app.config[${middlewareName}] 傳遞進來, app: 目前應用 Application 的執行個體
      //執行exports的函數,生成最終要的中間件
      mw = mw(options, app);
      mw._name = name;
      //包裝中間件,最終轉換成async function(ctx, next)形式
      mw = wrapMiddleware(mw, options);
      if (mw) {
        app.use(mw);
        this.options.logger.info('[egg:loader] Use middleware: %s', name);
      } else {
        this.options.logger.info('[egg:loader] Disable middleware: %s', name);
      }
    }
}

//通過FileLoader執行個體加載指定屬性的所有檔案并導出,然後将該屬性挂載在app上
loadToApp(directory, property, opt) {
    const target = this.app[property] = {};
    opt = Object.assign({}, {
      directory,
      target,
      inject: this.app,
    }, opt);
    new FileLoader(opt).load();
}
           
loadController函數

controller中生成的函數最終還是在router.js中當作一個中間件使用,是以我們需要将controller中内容轉換為中間件形式async function(ctx, next),其中initializer這個函數就是用來針對不同的情況将controller中的内容轉換為中間件的,下面是loadController的實作邏輯:

//egg-core源碼 -> loadController函數實作源碼

loadController(opt) {
    opt = Object.assign({
      caseStyle: 'lower',
      directory: path.join(this.options.baseDir, 'app/controller'),
      //這個配置,上文有提到,是為了對導出對象做預處理的函數
      initializer: (obj, opt) => {
        //如果是普通函數,依然直接調用它生成新的對象
        if (is.function(obj) && !is.generatorFunction(obj) && !is.class(obj) && !is.asyncFunction(obj)) {
          obj = obj(this.app);
        }
        if (is.class(obj)) {
          obj.prototype.pathName = opt.pathName;
          obj.prototype.fullPath = opt.path;
          //如果是一個class,class中的函數轉換成async function(ctx, next)中間件形式,并用ctx去初始化該class,是以在controller裡我們也可以使用this.ctx.xxx形式
          return wrapClass(obj);
        }
        if (is.object(obj)) {
          //如果是一個Object,會遞歸的将該Object中每個屬性對應的函數轉換成async function(ctx, next)中間件形式形式
          return wrapObject(obj, opt.path);
        }
        // support generatorFunction for forward compatbility
        if (is.generatorFunction(obj) || is.asyncFunction(obj)) {
          return wrapObject({ 'module.exports': obj }, opt.path)['module.exports'];
        }
        return obj;
      },
    }, opt);
    //loadController函數同樣是通過loadToApp函數将其導出對象挂載在app下,controller裡的内容在loadRouter時會将其載入
    const controllerBase = opt.directory;
    this.loadToApp(controllerBase, 'controller', opt);
  },
           
loadRouter函數

loadRouter函數特别簡單,隻是require加載一下app/router目錄下的檔案而已,而所有的事情都交給了EggCore類上的router屬性去實作

而router又是Router類的執行個體,Router類是基于koa-router實作的

//egg-core源碼 -> loadRouter函數源碼實作

loadRouter() {
    this.loadFile(this.resolveModule(path.join(this.options.baseDir, 'app/router')));
}

//設定router屬性的get方法
get router() {
    //緩存設定
    if (this[ROUTER]) {
      return this[ROUTER];
    }
    //建立Router執行個體,其中Router類是繼承koa-router實作的
    const router = this[ROUTER] = new Router({ sensitive: true }, this);
    //在啟動前将router中間件載入引用
    this.beforeStart(() => {
      this.use(router.middleware());
    });
    return router;
}
  
//将router上所有的method函數代理到EggCore上,這樣我們就可以通過app.get('/async', ...asyncMiddlewares, 'subController.subHome.async1')的方式配置路由
utils.methods.concat([ 'all', 'resources', 'register', 'redirect' ]).forEach(method => {
  EggCore.prototype[method] = function(...args) {
    this.router[method](...args);
    return this;
  };
})  
           

Router類繼承了KoaRouter類,并對其的method相關函數做了擴充,解析controller的寫法,同時提供了resources方法,為了相容restAPI的方式

關于restAPI的使用方式和實作源碼我們這裡就不介紹了,可以看官方文檔,有具體的格式要求,下面看一下Router類的部分實作邏輯:

//egg-core源碼 -> Router類實作源碼

class Router extends KoaRouter {
  constructor(opts, app) {
    super(opts);
    this.app = app;
    //對method方法進行擴充
    this.patchRouterMethod();
  }
  
  patchRouterMethod() {
    //為了支援generator函數類型,以及擷取controller類中導出的中間件
    methods.concat([ 'all' ]).forEach(method => {
      this[method] = (...args) => {
        //spliteAndResolveRouterParams主要是為了拆分router.js中的路由規則,将其拆分成普通中間件和controller生成的中間件部分,請看下文源碼
        const splited = spliteAndResolveRouterParams({ args, app: this.app });
        args = splited.prefix.concat(splited.middlewares);
        return super[method](...args);
      };
    });
  }
  
  //傳回router裡每個路由規則的字首和中間件部分
  function spliteAndResolveRouterParams({ args, app }) {
    let prefix;
    let middlewares;
    if (args.length >= 3 && (is.string(args[1]) || is.regExp(args[1]))) {
        // app.get(name, url, [...middleware], controller)的形式
        prefix = args.slice(0, 2);
        middlewares = args.slice(2);
      } else {
        // app.get(url, [...middleware], controller)的形式
        prefix = args.slice(0, 1);
        middlewares = args.slice(1);
      }
      //controller部分肯定是最後一個
      const controller = middlewares.pop();
      //resolveController函數主要是為了處理router.js中關于controller的兩種寫法:
      //寫法1:app.get('/async', ...asyncMiddlewares, 'subController.subHome.async1')
      //寫法2:app.get('/async', ...asyncMiddlewares, subController.subHome.async1)
      //最終從app.controller上擷取到真正的controller中間件,resolveController具體函數實作就不介紹了
      middlewares.push(resolveController(controller, app));
      return { prefix, middlewares };
  }
           

總結

以上便是我對EggCore的大部分源碼的實作的學習總結,其中關于源碼中一些debug代碼以及timing運作時間記錄的代碼都删掉了,關于app的生命周期管理的那部分代碼和loadUnits加載邏輯關系不大,是以沒有講到。EggCore的核心在于EggLoader,也就是plugin,config, extend, service, middleware, controller, router的加載函數,而這幾個内容加載必須按照順序進行加載,存在依賴關系,比如:

  • 加載middleware時會用到config關于應用中間件的配置
  • 加載router時會用到關于controller的配置
  • 而config,extend,service,middleware,controller的加載都必須依賴于plugin,通過plugin配置擷取插件目錄
  • service,middleware,controller,router的加載又必須依賴于extend(對app進行擴充),因為如果exports是函數的情況下,會将app作為參數執行函數

EggCore是一個基礎架構,其最重要的是需要遵循一定的限制和約定,可以保證一緻的代碼風格,而且提供了插件和架構機制,能使相同的業務邏輯實作複用,後面看有時間再寫一下egg架構的源碼

參考文獻

  • agg-core源碼
  • egg源碼
  • egg官方文檔

繼續閱讀