Egg.js 原始碼分析(1)
前端時間抽出時間針對 ofollow,noindex">Koa2 原始碼進行了簡單的學習, koa 原始碼是一個很簡單的庫, 針對分析過程, 想手把手的實現一個型別 koa 的框架,其 程式碼 , 根據一步步的完善實現一個簡單版本的Koa, 每一個步驟一個 Branch , 如: stpe-1 , 對應的是我想實現第一步的程式碼, 程式碼僅供自己簡單的學習,很多地方不完善,只是想體驗下Koa 的思想。下面幾點是我對Koa 的簡單理解:
- 所有的NodeJS 框架最基本的核心就是通過原生庫
http
orhttps
啟動一個後端服務http.createServer(this.serverCallBack()).listen(...arg)
, 然後所有的請求都會進入serverCallBack
方法, 然後我們可以通過攔截,在這個方法中處理不同的請求 - Koa 是一個洋蔥模型, 其是基於中介軟體來實現的.通過
use
來新增一箇中間件,koa-router
其實就是一個koa
的中介軟體,我們的所有的請求都會將所有的中介軟體都執行一遍,洋蔥模型如下圖所示


上面是我對Koa 原始碼分析的一些簡單的理解, 後面我會將對Koa 的理解,進一步的記錄下來。 Koa 是一個很小巧靈活的框架, 不像Express, Express 已經集成了很多的功能, 很多功能不再需要第三方的框架,比如說路由功能, Koa 需要引用第三方的庫koa-router 來實現路由等。但是express 則不需要,下面是Koa 和Express, 兩個實現一個簡單的功能的Demo , 我們可以比較下其使用方式:
// Express const express = require('express') const app = express() app.get('/', function (req, res) { res.send('Hello World!') }) app.listen(3000, function () { console.log('Example app listening on port 3000!') }) 複製程式碼
// Koa var Koa = require('koa'); // 引用第三方路由庫 var Router = require('koa-router'); var app = new Koa(); var router = new Router(); router.get('/', (ctx, next) => { // ctx.router available }); // 應用中介軟體: router app .use(router.routes()) .use(router.allowedMethods()); app.listen(3000); 複製程式碼
哈哈,我們上面說了很多的廢話(文字表達能力問題), 其實我是想分析下,怎麼基於Koa 框架去應用, eggjs 就是基於Koa 框架基礎上試下的一個框架, 我們下面來具體分析下 eggjs 框架。
Eggjs 基本使用
我們根據快速入門, 可以很快搭建一個Egg 專案框架,
$ npm i egg-init -g $ egg-init egg-example --type=simple $ cd egg-example $ npm i 複製程式碼
我們可以用 npm run dev
快速啟動專案.然後開啟 localhost:7001
,就可以看到頁面輸出:
hi, egg.
說明我們專案初始化已經完成,而且已經啟動成功。我們現在可以學習下egg專案生成的相關程式碼。其程式碼檔案結構如下:

分析整個檔案結構,找了整個專案都沒有發現app.js之類的入口檔案(我一般學習一個新的框架,都會從入口檔案著手),,發現 app 資料夾下面的應該對專案很重要的程式碼:
1, controller資料夾,我們從字面理解,應該是控制層的檔案,其中有一個home.js 程式碼如下:
'use strict'; const Controller = require('egg').Controller; class HomeController extends Controller { async index() { this.ctx.body = 'hi, egg'; } } module.exports = HomeController; 複製程式碼
這個類繼承了egg 的Controller 類, 暫時還沒有發現這個專案哪個地方有引用這個 Controller 類?
2, 一個 router.js 檔案, 從字面意義上我們可以理解其為一個路由的檔案,其程式碼如下:
'use strict'; /** * @param {Egg.Application} app - egg application */ module.exports = app => { const { router, controller } = app; router.get('/', controller.home.index); }; 複製程式碼
這個檔案暴露除了一個方法, 從目前來猜測應該就是路由的一些配置, 但是找遍整個專案也沒有發現,哪個地方引用了這個方法, router.get('/', controller.home.index);
, 但是從這個get 方法的第二個引數, 其似乎指向的是Controller 裡面的home.js 檔案index 方法,我們可以嘗試修改下home.js 中的 this.ctx.body = 'hi, egg -> hello world!';
, 然後重新執行 npm run dev
, 發現頁面輸出是 hi, egg -> hello world!
, 看來 controller.home.index
這個指向的是home.js 裡的index 方法無疑了, 但是 controller.home.index
這個index 方法繫結的是在一個 controller
物件上,什麼時候繫結的呢?
我們接下來帶著如下疑問來學些 eggjs :
- 沒有類似的app.js 入口檔案,執行
npm run dev
如何啟動一個專案(啟動server, 監聽埠, 新增中介軟體)?
- 我們開啟頁面
http://localhost:7001/
,怎麼去通過router.js 去查詢路由的,然後呼叫對應的回撥函式?
- Controller 是如何繫結到app 上面的controller 物件上的?
eggjs 啟動
我們先檢視一開始用 egg-init
命令建立的專案的package.json 檔案,檢視 scripts
,裡面有一系列的命令,如下圖:

npm run start
來啟動程式, 但是其中有一個命令
debug
, 我們可以可以通過
npm run debug
命令來除錯eggjs 程式, 其對用的命令是
egg-bin debug
, 所以我們整個入口就是這個命令,我們下面來具體分析下
egg-bin debug
是如何工作的.
egg-bin
egg-bin 中的 start-cluster
檔案, 呼叫了eggjs 的入口方法: require(options.framework).startCluster(options);
其中options.framework指向的就是一個絕對路徑 D:\private\your_project_name\node_modules\egg
(也就是 egg 模組), 直接執行 D:\private\your_project_name\node_modules\egg\index.js
暴露出來的 exports.startCluster = require('egg-cluster').startCluster;
的 startCluster
方法。 下面我們就來分析 egg-cluster 模組。
egg-cluster
egg-cluster 的專案結構如下, 其中有兩個主要的檔案: master.js
, app_worker.js
兩個檔案,

master.js
是跟nodejs的多執行緒有關,我們先跳過這一塊,直接研究 app_worker.js
檔案,學習eggjs 的啟動過程。下面我們就是app_worker.js 執行的主要步驟。
-
const Application = require(options.framework).Application;
, 引入eggjs 模組, optons.framework 指向的就是D:\private\your_project_name\node_modules\egg
-
const app = new Application(options);
(建立一個 egg 例項) -
app.ready(startServer);
呼叫egg 物件的** ready ** 方法,其startServer 是一個回撥函式,其功能是呼叫nodejs 原生模組http
orhttps
的createServer
建立一個nodejs 服務(server = require('http').createServer(app.callback());
, 我們後續會深入分析這個方法)。
上面三個步驟, 已經啟動了一個nodejs 服務, 監聽了埠。也就是已經解決了我們的第一個疑問:
沒有類似的app.js 入口檔案,執行npm run dev 如何啟動一個專案(啟動server, 監聽埠, 新增中介軟體)?
上面其實我們還是隻是分析了eggjs啟動的基本流程, 還沒有涉及eggjs 的核心功能庫,也就是** egg ** 和** egg-core** 兩個庫,但是我們上面已經初例項化了一個eggjs 的物件 const app = new Application(options);
, 下面我們就從這個入口檔案來分析eggjs 的核心模組。
egg & egg-core
egg 和egg-core 模組下面有幾個核心的類,如下:
Application(egg/lib/applicaton.js)----->EggApplication(egg/lib/egg.js)----->EggCore(egg-core/lib/egg.js)----->KoaApplication(koa)
從上面的關係可以,eggjs 是基於 koa 的基礎上進行擴充套件的,所以我們從基類的建構函式開始進行分析(因為new Application 會從繼類開始的建構函式開始執行)。
EggCore(egg-core/lib/egg.js)
我們將建構函式進行精簡,程式碼如下

從上圖可知,建構函式就是初始化了很多基礎的屬性,其中有兩個屬性很重要:
-
this.lifecycle
負責整個eggjs 例項的生命週期,我們後續會深入分析整個生命週期
-
this.loader
(egg-core/lib/loader/egg_loader.js)解決了eggjs 為什麼在服務啟動後,會自動載入,將專案路徑下的router.js
,controller/**.js
, 以及service/**.js
繫結到app
例項上, 我們接下來會重點分析這個loader.
EggApplication(egg/lib/egg.js)
我們將建構函式進行精簡,程式碼如下

這個建構函式同樣也是初始化了很多基礎的屬性, 但是其中有呼叫 EggCore 建構函式初始化的 loader 的 loadConfig()
方法, 這個方法顧名思義就是去載入配置,其指向的是: egg/lib/loader/app_worker_loader .js
的方法 loadConfig
, 這個方法,如下:
loadConfig() { this.loadPlugin(); super.loadConfig(); } 複製程式碼
其會載入所有的Plugin ,然後就載入所有的Config.
this.loadPlugin() 指向的是 egg-core/lib/loader/mixin/plgin.js
的方法 loadPlugin
, 其會載入三種plugin:
-
const appPlugins = this.readPluginConfigs(path.join(this.options.baseDir, 'config/plugin.default'));
,應用配置的plugin , 也就是your-project-name/config/plugin.js
, 也就是每個應用需要配置的特殊的外掛 -
const eggPluginConfigPaths = this.eggPaths.map(eggPath => path.join(eggPath, 'config/plugin.default'));
, 也就是從eggjs 框架配置的外掛, 其路徑是在egg/config/plugin.js
, 也就是框架自帶的外掛 -
process.env.EGG_PLUGINS
第三種, 是啟動專案是,命令列帶引數EGG_PLUGINS
的外掛, 應該使用不廣。
最後將所有的plugin 掛在在app例項上 this.plugins = enablePlugins;
,。(後續會學習怎麼這些plugin 怎麼工作的。)
接下來會執行 super.loadConfig()
方法, 其指向的是 egg-core/lib/loader/mixin/config.js
的 loadConfig()
方法, 其同樣會載入四種config:
-
const appConfig = this._preloadAppConfig();
, 應用配置的config , 也就是每個應用的特殊配置,其會載入兩個配置:
const names = [ 'config.default', `config.${this.serverEnv}`, ]; 複製程式碼
第一個一定會載入對應的 config.default
配置, 也就是 your-project-name/config/config.default.js
,跟執行環境沒有關係的配置, 其次會載入跟執行環境有關的配置,如: config.prod.js
, config.test.js
, config.local.js
, config.unittest.js
- 會去載入所有的plugin 外掛目錄
if (this.orderPlugins) { for (const plugin of this.orderPlugins) { dirs.push({ path: plugin.path, type: 'plugin', }); } } 複製程式碼
- 會去載入egg 專案目錄, 也就是egg/config 目錄
for (const eggPath of this.eggPaths) { dirs.push({ path: eggPath, type: 'framework', }); } 複製程式碼
- 回去載入應用專案的目錄, 也就是也就是
your-project-name/config
最後將合併的config 掛載在app 例項上 this.config = target;
我們可以開啟 egg/config/config.default.js
檔案,可以檢視下,預設的都有什麼配置,其中一個配置如下:
config.cluster = { listen: { path: '', port: 7001, hostname: '', }, }; 複製程式碼
很明顯,這應該是一個對server 啟動的配置,我們暫且可以這樣猜測。
我們上面有分析在 egg-cluster/lib/app_worker.js
中,我們初始化 app 後,我們有呼叫 app.ready(startServer);
方法,我們可以猜測 startServer
方法就是啟動nodejs server 的地方。
在 startServer
方法中,初始化了一個http server server = require('http').createServer(app.callback());
, 然後我們給listen server.listen(...args);;
, 這樣算是node js 的server 啟動起來了, 我們可以檢視下,我可以檢視args 的引數:
const args = [ port ]; if (listenConfig.hostname) args.push(listenConfig.hostname); debug('listen options %s', args); server.listen(...args); 複製程式碼
這裡給args 添加了prot 埠引數, 我們可以跳轉到prot定義的地方:
const app = new Application(options); const clusterConfig = app.config.cluster || /* istanbul ignore next */ {}; const listenConfig = clusterConfig.listen || /* istanbul ignore next */ {}; const port = options.port = options.port || listenConfig.port; 複製程式碼
我們可以看到port 最終來源於: app.config.cluster.listen.port
,從這裡我們得知, eggjs 的config 的使用方式。
問題:
如果我們不想在eggjs 專案啟動時,預設開啟的埠不是 7001 ,我們改怎麼操作呢?
我們應該有如下兩種方式:
- 在執行npm run debug 命令時,新增相應的引數
- 我們可以在我們專案的config/config.default.js 中新增配置,將預設的給覆蓋掉,如:
module.exports = appInfo => { const config = exports = {}; // use for cookie sign key, should change to your own and keep security config.keys = appInfo.name + '_1541735701381_1116'; // add your config here config.middleware = []; config.cluster = { listen: { path: '', port: 7788, hostname: '', }, }; return config; }; 複製程式碼
如上,我們再次啟動專案的時候,開啟的埠就是: 7788了。
思考:
我們已經知道可以在config 中進行相應的配置了, 我們還有什麼其他的應用在config 上面呢?
我們知道在不同的執行環境下,會載入不同的配置,那如果我們在開發的時候,呼叫api 的路徑是: http://dev.api.com
, 但是在上線的時候,我們呼叫的app的路徑是: http://prod.api.com
, 我們就可以在 config.prod.js
中配置 apiURL:http://prod.api.com
, 在 config.local.js
配置: apiURL:http://prod.api.com
然後我們在我們呼叫API的地方通過 app.apiURL
就可以。
Application(egg/lib/application.js)
Application(egg/lib/applicaton.js)----->EggApplication(egg/lib/egg.js)----->EggCore(egg-core/lib/egg.js)----->KoaApplication(koa)
我們已經將上述的兩個核心的類: EggApplication(egg/lib/egg.js)----->EggCore(egg-core/lib/egg.js), 我們現在來分析最上層的類: Application(egg/lib/applicaton.js)。
我們還是從建構函式入手,我們發現了一行很重要的程式碼 this.loader.load();
其指向的是: app_worker_loader.js (egg/lib/loader/app_worker_loader.js)的load 方法, 其實現如下:
load() { // app > plugin > core this.loadApplicationExtend(); this.loadRequestExtend(); this.loadResponseExtend(); this.loadContextExtend(); this.loadHelperExtend(); // app > plugin this.loadCustomApp(); // app > plugin this.loadService(); // app > plugin > core this.loadMiddleware(); // app this.loadController(); // app this.loadRouter(); // Dependent on controllers } 複製程式碼
從這個方法可知,載入了一大批的配置,我們可以進行一一的分析:
this.loadApplicationExtend();
這個方法會去給應用載入很多的擴充套件方法, 其載入的路徑是: app\extend\application.js, 會將對應的物件掛載在app 應用上。 (使用方法可以參考egg-jsonp/app/extend/applicaton.js 或者egg-session/app/extend/application.js)
this.loadResponseExtend();
this.loadResponseExtend();
this.loadContextExtend();
this.loadHelperExtend();
,
跟 this.loadApplicationExtend();
載入的方式是一樣的,只是對應的名稱分別是: request.js, response.js, helper.js, context.js
this.loadCustomApp();
定製化應用, 載入的檔案是對應專案下的app.js (your_project_name/app.js), 其具體的程式碼實現如下: (egg-core/lib/loader/mixin/custom.js)
[LOAD_BOOT_HOOK](fileName) { this.timing.start(`Load ${fileName}.js`); for (const unit of this.getLoadUnits()) { const bootFilePath = this.resolveModule(path.join(unit.path, fileName)); if (!bootFilePath) { continue; } const bootHook = this.requireFile(bootFilePath); // bootHook 是載入的檔案 if (is.class(bootHook)) { // if is boot class, add to lifecycle this.lifecycle.addBootHook(bootHook); } else if (is.function(bootHook)) { // if is boot function, wrap to class // for compatibility this.lifecycle.addFunctionAsBootHook(bootHook); } else { this.options.logger.warn('[egg-loader] %s must exports a boot class', bootFilePath); } } // init boots this.lifecycle.init(); this.timing.end(`Load ${fileName}.js`); }, 複製程式碼
從上可知** bootHook** 對應的就是載入的檔案,從上面的 if
else
可知, app.js 必須暴露出來的是一個 class 或者是一個 function ,然後呼叫 this.lifecycle.addFunctionAsBootHook(bootHook);
, 其程式碼如下:
addFunctionAsBootHook(hook) { assert(this[INIT] === false, 'do not add hook when lifecycle has been initialized'); // app.js is export as a funciton // call this function in configDidLoad this[BOOT_HOOKS].push(class Hook { constructor(app) { this.app = app; } configDidLoad() { hook(this.app); } }); } 複製程式碼
將對應的hook push 到this.lifecycle 的 BOOT_HOOKS 陣列中, 並且包裝成了一個類, 且在 configDidLoad 呼叫對應的hook.然後呼叫了 this.lifecycle.init();
去初始化生命週期:
init() { assert(this[INIT] === false, 'lifecycle have been init'); this[INIT] = true; this[BOOTS] = this[BOOT_HOOKS].map(t => new t(this.app)); this[REGISTER_BEFORE_CLOSE](); } 複製程式碼
這個 init 方法做了三件事情:
- 將lifecycle 的INIT 狀態標記為: true
- 將BOOT_HOOKS 對應的類, 例項化一個物件,儲存在 BOOTS 上
- 呼叫REGISTER_BEFORE_CLOSE方法,其中會呼叫我們的hook 的 beforeClose 方法。
this.loadCustomApp();
方法如下:
loadCustomApp() { this[LOAD_BOOT_HOOK]('app'); this.lifecycle.triggerConfigWillLoad(); }, 複製程式碼
所以接下執行 this.lifecycle.triggerConfigWillLoad();
triggerConfigWillLoad() { for (const boot of this[BOOTS]) { if (boot.configWillLoad) { boot.configWillLoad(); } } this.triggerConfigDidLoad(); } triggerConfigDidLoad() { for (const boot of this[BOOTS]) { if (boot.configDidLoad) { boot.configDidLoad(); } } this.triggerDidLoad(); } 複製程式碼
其中 boot.configDidLoad();
就是我們app.js 定義的hook, 被加工成的Hook 類:
class Hook { constructor(app) { this.app = app; } configDidLoad() { hook(this.app); } } 複製程式碼
然後就將app.js 與eggjs 關聯起來了。
this.loadService();
查詢的your_project_name/app/service/ .js, 然後將檔名稱作為一個作為屬性,掛載在 context**上下文上,然後將對應的js 檔案,暴露的方法賦值在這個屬性上, 比如說我們在如下路徑下: your_project_name/app/service/home.js
, 其程式碼如下:
'use strict'; // app/service/home.js const Service = require('egg').Service; class HomeService extends Service { async find() { // const user = await this.ctx.db.query('select * from user where uid = ?', uid); const user = [ { name: 'Ivan Fan', age: 18, }, ]; return user; } } module.exports = HomeService; 複製程式碼
我們在其他的地方就可以通過: this.ctx.service.home.find()
方法呼叫service裡面的方法了,如在controller 中呼叫:
'use strict'; const Controller = require('egg').Controller; class HomeController extends Controller { async index() { // this.ctx.body = 'hi, egg'; this.ctx.body = await this.ctx.service.home.find(); } } module.exports = HomeController; 複製程式碼
this.loadController();
這個方法是去載入controller , 其程式碼如下:
loadController(opt) { this.timing.start('Load Controller'); opt = Object.assign({ caseStyle: 'lower', directory: path.join(this.options.baseDir, 'app/controller'), initializer: (obj, opt) => { // return class if it exports a function // ```js // module.exports = app => { //return class HomeController extends app.Controller {}; // } // ``` 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; return wrapClass(obj); } if (is.object(obj)) { 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); const controllerBase = opt.directory; this.loadToApp(controllerBase, 'controller', opt); this.options.logger.info('[egg:loader] Controller loaded: %s', controllerBase); this.timing.end('Load Controller'); }, 複製程式碼
其載入的路徑是: app/controller 下面的js 檔案。
this.loadRouter();
這個方法,顧名思義就是去載入router, 其程式碼如下:
loadRouter() { this.timing.start('Load Router'); // 載入 router.js this.loadFile(this.resolveModule(path.join(this.options.baseDir, 'app/router'))); this.timing.end('Load Router'); }, 複製程式碼
只會載入對應專案下的 app/router.js
, 也就是路由應該只有一個入口檔案.如下Demo:
'use strict'; /** * @param {Egg.Application} app - egg application */ module.exports = app => { const { router, controller } = app; router.get('/', controller.home.index); }; 複製程式碼
如上程式碼實現路由。
TODO....