從egg-helper開發中學習egg源碼

開發egg-helper插件目的

  • 所有的工具函數維護在 app/util 文件內,在使用時需要手動require,如果多個文件使用,需要多個require,致使業務代碼實現不優雅
  • 在工具函數內部無法直接讀取配置文件,通常是使用傳參的方式
  • Egg也提供Helper框架擴展,但是需將工具函數維護在 app/extend/helper.js 文件內,我更傾向於單獨維護

開發經歷

第一版

使用egg官方文檔提供的loadToContext方法,將 app/helper 內所有文件掛載到ctx.helper對象
loadToContext官方文檔,請戳這裏

代碼實現

// --------egg-helper/app.js -------
module.exports = app => {
  const dir = app.loader.getLoadUnits().map(unit => {
    return path.join(unit.path, 'app/helper');
  });
  app.loader.loadToContext(dir, 'helper', {
    inject: app,
    call: true,
  });
};
...

缺點:

  • 覆蓋掉原有的ctx.helper對象(這裏可以選擇修改掛載屬性名稱來避免覆蓋,但爲了和egg保持一致,所以未選擇此方案)

源碼學習

打開egg-core工程,根據package.json找到入口文件

...
// -------- index.js -------
module.exports = {
  EggCore, 
  EggLoader,
  BaseContextClass,
  utils,
};

在index.js文件內,export出四個對象
EggCore:
egg-core核心類,繼承於koa,初始化Application(app即Application的實例)對象方法和屬性

// -------- egg.js -------
...
class EggCore extends KoaApplication {
  constructor() {
    ...
    const Loader = this[EGG_LOADER];
    assert(Loader, "Symbol.for('egg#loader') is required");
    this.loader = new Loader({  // 實例化loder,即EggLoader
      baseDir: options.baseDir,
      app: this, 
      plugins: options.plugins,
      logger: this.console,
      serverScope: options.serverScope,
    });
    const Controller = this.BaseContextClass;
    this.Controller = Controller; // 定義Controller使用基類
    const Service = this.BaseContextClass;
    this.Service = Service; // 定義Service使用基類
    ...
  }
}
...

EggLoader:
egg-core核心類

  • 提供加載方法,例如loadToContext、loadToApp
  • 提供獲取egg基礎信息方法,例如 getAppInfo
  • 掛載 /lib/loader/mixin目錄下定義的load函數,具體加載順序在 egg/lib/loader/app_worker_loader.js中定義
// -------- egg_loader.js -------
class EggLoader {
  ...
  // 將property掛載到ctx
  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(); // 實例化ContextLoader
     this.timing.end(timingKey);
  },
  // 獲取當前 應用/框架/插件下所有文件路徑,返回路徑數組
  getLoadUnits() {
    if (this.dirs) {
      return this.dirs;
    }
    const dirs = this.dirs = [];
    // 插入插件路徑
    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',
    });
    debug('Loaded dirs %j', dirs);
    return dirs;
  }
 }
...
const loaders = [
  require('./mixin/plugin'),
  require('./mixin/config'),
  require('./mixin/extend'),
  require('./mixin/custom'),
  require('./mixin/service'),
  require('./mixin/middleware'),
  require('./mixin/controller'),
  require('./mixin/router'),
];
// 將mixin/*.js文件下的對象掛載EggLoader原型
for (const loader of loaders) {
  Object.assign(EggLoader.prototype, loader);
}

BaseContextClass
基類,定義了類的屬性,Service和Controller都是繼承了基類
utils
工具函數

通過Demo來分析helper的掛載和調用

demo/app目錄結構如下所示

調用步驟
在這裏插入圖片描述

第一步 調用loadToContent

app.loader.loadToContext() // 調用EggLoader實例方法

第二步 實例化ContextLoader並調用load方法

ContextLoader繼承自FileLoader,FileLoader在下面會講到

// -------- context_loader.js -------

class ContextLoader extends FileLoader {
  constructor(options) {
    ...
    // target在未調用load方法前是空對象
    // 在調用load方法後,target是一個以文件層級作爲層級的對象,包含文件路徑及export出的對象(下面有樣本)
    const target = options.target = {};
    if (options.fieldClass) {
      options.inject[options.fieldClass] = target;
    }
    super(options);

    const app = this.options.inject; // 當前注入對象
    const property = options.property; // 掛載屬性名

    // 實例化時,使用Object.defineProperty將屬性名掛載到app.context上
    // 此時僅僅是掛載了屬性名,且定義了getter方法,值爲空對象
    // 當調用了load方法後,纔是我們所期望的值
    Object.defineProperty(app.context, property, {
     // 當獲取ctx屬性時,執行該方法
      get() {
        ...
      },
    });
  }
}

第三步 調用ctx.helper

// -------- controller/home.js -------
class HomeController extends Controller {
  async index() {
    // 在controller中調用工具函數
    this.ctx.body =this.ctx.helper.util.demo();
  }
}

此步驟獲取ctx屬性,執行上一步已定義的getter方法

...
// -------- context_loader.js -------
get() {
   if (!this[CLASSLOADER]) {
   // 創建緩存,egg根據每一個請求生成一個Context實例,每個實例不相同
   // 緩存根據Context實例生成的,不同實例緩存不同.這裏是在同一個實例內,即同一個請求,創建一個緩存
   // 在重複獲取屬性時,避免多次執行getInstance方法
     this[CLASSLOADER] = new Map();
   }
   const classLoader = this[CLASSLOADER];

   let instance = classLoader.get(property);
   // 區分當前屬性是否被緩存,有緩存就直接返回緩存值
   if (!instance) {
     // 調用getInstance方法 
     // this指向app.context即ctx
     instance = getInstance(target, this);
     // 緩存屬性
     classLoader.set(property, instance);
   }
   return instance;
},
...

第四步 調用getInstance方法

// 調用getInstance的參數values樣本
// 這也是getter方法內部的target的樣本
{ util:{
    show: [Function: show],
    [Symbol(EGG_LOADER_ITEM_FULLPATH)]:'.../app/helper/util.js',
    [Symbol(EGG_LOADER_ITEM_EXPORTS)]: true
  },
  helper:{
    util:{
      action: [Function: action],
      [Symbol(EGG_LOADER_ITEM_FULLPATH)]:'.../app/helper/helper/util.js',
      [Symbol(EGG_LOADER_ITEM_EXPORTS)]: true
    }
  }
}
// -------- context_loader.js -------
function getInstance(values, ctx) {
// 判斷當前掛載對象是否是目錄,如果是目錄,則不含[EXPORTS]屬性
// 這個屬性在FileLoader中定義,下面會介紹
  const Class = values[EXPORTS] ? values : null;
  let instance;
  if (Class) {
    if (is.class(Class)) {
     // 如果是類,則實例化,例如Service
     // 實例化時會傳入ctx對象,所以在Service實例內可以訪問ctx對象(這個在基類中有定義)
      instance = new Class(ctx);
    } else {
      // 如果不是類,則直接返回,例如 helper/util.js文件export出的對象
      instance = Class;
    }
  } else if (is.primitive(values)) {
    instance = values;
  } else {
    // 如果是目錄,則實例化ClassLoader,在ClassLoader內部也會調用getInstance方法
    // 在ctx上就可以使用 ctx.dirname.dirname.dirname...filename.fn 來調用
    // 例如values的樣本,最終掛載成 ctx.helper.util.action
    instance = new ClassLoader({ ctx, properties: values });
  }
  return instance;
}

class ClassLoader {
  constructor(options) {
    assert(options.ctx, 'options.ctx is required');
    const properties = options.properties;
    // 做緩存,這裏的緩存和ContextLoader內的緩存不同
    // ContextLoader是對 ctx.property 做緩存
    // ClassLoader是對 ctx.property.childProperty[.childProperty...]做緩存
    this._cache = new Map();
    this._ctx = options.ctx;

    for (const property in properties) {
      // 將屬性掛載到ClassLoader實例上
      // 通過這個函數實現ctx.dirname.dirname.dirname...filename.fn
      this.defineProperty(property, properties[property]);
  }
  defineProperty(property, values) {
    Object.defineProperty(this, property, {
      get() {
        let instance = this._cache.get(property);
        if (!instance) {
          // 雖然在這也會調用getInstance方法,但不會立即執行,只會在執行getter時執行,避免資源浪費
          instance = getInstance(values, this._ctx);
          this._cache.set(property, instance);
        }
        return instance;
      },
    });
  }
}

當前版

使用FileLoader實現

調用步驟
在這裏插入圖片描述

代碼實現

// --------egg-helper/app.js -------
module.exports = app => {
  const FileLoader = app.loader.FileLoader;
  const dir = app.loader.getLoadUnits().map(unit => {
    return path.join(unit.path, 'app/helper');
  });
  app.loader.loadToContext()
  new FileLoader({
    directory: dir,
    target: app.Helper.prototype,
    inject: app,
  }).load();
};
...

優點

  • 不會覆蓋原有的ctx.helper對象

源碼學習

// --------file_loader.js -------
class FileLoader {
  constructor(options) {
    assert(options.directory, 'options.directory is required');
    assert(options.target, 'options.target is required');
    this.options = Object.assign({}, defaults, options);
    
    // 首字母是否小寫
    if (this.options.lowercaseFirst === true) {
      deprecate('lowercaseFirst is deprecated, use caseStyle instead');
      this.options.caseStyle = 'lower';
    }
  }
  
  // FileLoader加載文件主方法
  // 該方法主要是獲取指定目錄下文件
  parse() {
    // 文件路徑匹配,可以查看末尾的options配置
    let files = this.options.match;
    if (!files) {
      // 是否加載ts
      files =
        process.env.EGG_TYPESCRIPT === 'true' && require.extensions['.ts']
          ? ['**/*.(js|ts)', '!**/*.d.ts']
          : ['**/*.js'];
    } else {
      files = Array.isArray(files) ? files : [files];
    }
    // 忽略的文件路徑匹配,可以查看末尾的options配置
    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);
    }
    // 指定文件的目錄,可以查看末尾的options配置
    let directories = this.options.directory;
    if (!Array.isArray(directories)) {
      directories = [directories];
    }

    // 文件導出的過濾,可以查看末尾的options配置
    const filter = is.function(this.options.filter)
      ? this.options.filter
      : null;
    const items = [];
    debug('parsing %j', directories);
    for (const directory of directories) {
      // 獲取指定目錄所有文件路徑,並匹配上面創建的規則(files),返回匹配的路徑數組
      const filepaths = globby.sync(files, { cwd: directory });
      for (const filepath of filepaths) {
        const fullpath = path.join(directory, filepath);
        // 保證當前路徑是文件而非目錄
        if (!fs.statSync(fullpath).isFile()) continue;
        // 將文件路徑按照"/"切割,並將包含"-"和"_"的文件名轉換爲駝峯形式
        const properties = getProperties(filepath, this.options);
        // 在文件路徑前拼上指定的文件目錄
        const pathName = directory.split(/[/\\]/).slice(-1) + '.' + properties.join('.');
        // 加載文件的關鍵函數
        // 獲取了export對象,具體信息在下面
        const exports = getExports(fullpath, this.options, pathName);
        // 過濾export
        if (exports == null || (filter && filter(exports) === false)) continue;
        
        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;
  }
  
  // 是FileLoader主方法
  // 該方法將指定目錄下的所有文件,按照文件層次組成對象(就是上面提到的target對象)
  load() {
    // 執行parse方法,獲取指定文件目錄下所有文件,返回爲數組
    // 例 [{fullpath:"",exports:"",properties:"" }]
    const items = this.parse();
    // 引用賦值,target改變後,this.options.target也將改變
    const target = this.options.target;
    for (const item of items) {
      // 通過reduce函數,將target屬性不斷傳遞下去,最後形成以文件層級爲鍵名的對象
      // 例 {help1:{util1:{...}}}
      item.properties.reduce((target, property, index) => {
        let obj;
        const properties = item.properties.slice(0, index + 1).join('.');
        // 當前屬性是否爲最後一位,如果是最後一位,則代表當前是文件的路徑,而非目錄
        if (index === item.properties.length - 1) {
         // 防止屬性覆蓋,同一個文件夾下文件名不允許重複,所以這裏主要是防止覆蓋掉target對象原來的屬性
          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;
          // 如果當前是文件且exports對象不是簡單數據類型
          if (obj && !is.primitive(obj)) {
            obj[FULLPATH] = item.fullpath;
            // 這裏的EXPORTS屬性,即是在FileLoader裏使用的EXPORTS
            obj[EXPORTS] = true;
          }
        } else {
          // 當前是目錄時,如果target不含該屬性,則創建空對象
          obj = target[property] || {};
        }
        target[property] = obj;
        debug('loaded %s', properties);
        return obj;
      }, target);
    }
    return target;
  }
}

// 加載文件
function getExports(fullpath, { initializer, call, inject }, pathName) {
  // 根據路徑加載文件,在方法內部存在文件是否爲模塊的判斷:
  // 如果當前文件擴展名是node不支持的(默認支持*.js,*.node,*.json),則以fs.readFileSync加載,否則以require加載
  let exports = utils.loadFile(fullpath);
  // 自定義export出的對象,可以查看末尾的options配置
  if (initializer) {
    exports = initializer(exports, { path: fullpath, pathName });
  }
  // 判斷export出對象的類型,egg規定的export類型有多種,例如
  // export default {}    export default app=>{}
  if (is.class(exports) || is.generatorFunction(exports) || is.asyncFunction(exports)) {
    return exports;
  }
  // 判斷是否爲函數
  if (call && is.function(exports)) {
    // 這一步實現注入對象
    // 例如在helper/*.js文件內可以使用app對象 
    exports = exports(inject);
    if (exports != null) {
      return exports;
    }
  }
  return exports;
}

Helper

Helper聲明

// --------egg/lib/application.js -------
...
 get Helper() {
   if (!this[HELPER]) {
      // Helper也是繼承了BaseContextClass和Service、Controller一樣
     class Helper extends this.BaseContextClass {} 
     this[HELPER] = Helper;
   }
   return this[HELPER];
 }
...

Helper實例化

在框架擴展中加載的

// --------egg/app/extend/context.js -------
...
get helper() {
   if (!this[HELPER]) {
     this[HELPER] = new this.app.Helper(this);
   }
   return this[HELPER];
 },
  ...

egg-core
egg
插件開發–egg官方文檔

LoaderOptions

Param Type Description
directory String/Array directories to be loaded
target Object attach the target object from loaded files
match String/Array match the files when load, default to **/*.js(if process.env.EGG_TYPESCRIPT was true, default to [ '**/*.(js|ts)', '!**/*.d.ts' ])
ignore String/Array ignore the files when load
initializer Function custom file exports, receive two parameters, first is the inject object(if not js file, will be content buffer), second is an options object that contain path
caseStyle String/Function set property’s case when converting a filepath to property list.
override Boolean determine whether override the property when get the same name
call Boolean determine whether invoke when exports is function
inject Object an object that be the argument when invoke the function
filter Function a function that filter the exports which can be loaded
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章