我們團隊現在開發的node項目都是基于koa架構實作的,雖然現在也形成了一套團隊内的标準,但是在開發的過程中也遇到了一些問題:
- 由于沒有統一的規範,新人上手和溝通成本比較高,容易出現錯誤
- 僅局限于目前需求進行設計,擴充性不高
- 系統部署及配置資訊維護成本較高
- 業務代碼實作起來不是很優雅,比如(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檔案導出的對象了,這裡主要有兩個地方需要注意:
-
為什麼我們可以從每個請求的this.ctx上擷取到service對象呢:
看過koa源碼的同學知道,this.ctx其實是從app.context繼承而來,是以我們隻要把service綁定到app.context上,那麼目前請求的上下文ctx自然可以拿到service對象,eggLoader也是這樣做的
- 針對上述四種使用場景,具體導出執行個體是怎麼處理的呢?
- 如果導出的是一個類,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官方文檔