我們接著

張佃鵬:Egg 源碼分析之 egg-core(一)?

zhuanlan.zhihu.com
圖標

繼續分析接下來的三個 load 函數 loadService,loadController,loadRouter 的源碼實現:

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

總結

以上便是我對 egg-core 的大部分源碼的實現的學習總結,其中關於源碼中一些 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 框架的源碼學習心得

參考文獻

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

推薦閱讀:

相关文章