英特爾架構師乾貨分享之Egg運行原理

從egg創建工程起,並沒有明確的入口文件,找了半天才大概明白,於是把過程寫下來

參考https://github.com/SunShinewyf/issue-blog/issues/30

關於egg

egg是阿里開源的一個框架,為企業級框架和應用而生,相較於express和koa,有更加嚴格的目錄結構和規範,使得團隊可以在基於egg定製化自己的需求或者根據egg封裝出適合自己團隊業務的更上層框架

egg定位

天豬曾經在這篇優秀的博文中給出關於egg的定位,如下圖:

英特爾架構師乾貨分享之Egg運行原理

egg.png

可以看到egg處於的是一箇中間層的角色,基於koa,不同於koa以middleware為主要生態,egg根據不同的業務需求和場景,加入了plugin,extends等這些功能,可以讓開發者擺脫在使用middleware功能時無法控制使用順序的被動狀態,而且還可以增加一些請求無關的一些功能。除此之外,egg還有很多其他優秀的功能,在這裡不詳述。想了解更多可以移步這裡初始化項目

egg有直接生成整個項目的腳手架功能,只需要執行如下幾條命令,就可以生成一個新的項目:

$ npm i egg-init -g
$ egg-init helloworld --type=simple
$ cd egg-helloworld
$ npm i

啟動項目:

$ npm run dev
$ open localhost:7001

egg是如何運行起來的

下面通過追蹤源碼來講解一下egg究竟是如何運行起來的:

查看egg-init腳手架生成的項目文件,可以看到整個項目文件是沒有嚴格意義上的入口文件的,根據package.json中的script命令,可以看到執行的直接是egg-bin dev的命令。找到egg-bin文件夾中的dev.js,會看到裡面會去執行start-cluster文件:

//dev.js構造函數中
this.serverBin = path.join(__dirname, '../start-cluster');
// run成員函數
* run(context) {
//省略
yield this.helper.forkNode(this.serverBin, devArgs, options);
}

移步到start-cluster.js文件,可以看到關鍵的一行代碼:

require(options.framework).startCluster(options);

其中options.framework打印信息為:

/Users/wyf/Project/egg-example/node_modules/egg

找到對應的egg目錄中的index.js文件:

exports.startCluster = require('egg-cluster').startCluster;

繼續追蹤可以看到最後運行的其實就是egg-cluster中的startCluster,並且會fork出agentWorker和appWorks,官方文檔對於不同進程的fork順序以及不同進程之間的IPC有比較清晰的說明,

主要的順序如下:

  • Master 啟動後先 fork Agent 進程
  • Agent 初始化成功後,通過 IPC 通道通知 Master
  • Master 再 fork 多個 App Worker
  • App Worker 初始化成功,通知 Master
  • 所有的進程初始化成功後,Master 通知 Agent 和 Worker 應用啟動成功

通過代碼邏輯也可以看出它的順序:

//在egg-ready狀態的時候就會執行進程之間的通信
this.ready(() => {
//省略代碼
const action = 'egg-ready';
this.messenger.send({ action, to: 'parent' });
this.messenger.send({ action, to: 'app', data: this.options });
this.messenger.send({ action, to: 'agent', data: this.options });
});

this.on('agent-exit', this.onAgentExit.bind(this));
this.on('agent-start', this.onAgentStart.bind(this));
this.on('app-exit', this.onAppExit.bind(this));
this.on('app-start', this.onAppStart.bind(this));
this.on('reload-worker', this.onReload.bind(this));
// fork app workers after agent started
this.once('agent-start', this.forkAppWorkers.bind(this));

通過上面的代碼可以看出,master進程會去監聽當前的狀態,比如在檢測到agent-start的時候才去fork AppWorkers,在當前狀態為egg-ready的時候,會去執行如下的進程之間的通信:

master ---> parent
master ---> agent
master ---> app

fork出了appWorker之後,每一個進程就開始幹活了,在app_worker.js文件中,可以看到進程啟動了服務,具體代碼:

//省略代碼 

function startServer() {
let server;
if (options.https) {
server = require('https').createServer({
key: fs.readFileSync(options.key),
cert: fs.readFileSync(options.cert),
}, app.callback());
} else {
server = require('http').createServer(app.callback());
}
//省略代碼
}

然後就迴歸到koa中的入口文件乾的事情了。

除此之外,每一個appWorker還實例化了一個Application:

const Application = require(options.framework).Application;
const app = new Application(options);

在實例化application(options)時,就會去執行node_modules->egg模塊下面loader目錄下面的邏輯,也就是agentWorker進程和多個appWorkers進程要去執行的加載邏輯,具體可以看到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(); // 依賴 controller

}
}

這也是下面要講的東西了

在真正執行業務代碼之前,egg會先去幹下面一些事情:

加載插件

egg中內置瞭如下一系列插件:

  • onerror 統一異常處理
  • Session Session 實現
  • i18n 多語言
  • watcher 文件和文件夾監控
  • multipart 文件流式上傳
  • security 安全
  • development 開發環境配置
  • logrotator 日誌切分
  • schedule 定時任務
  • static 靜態服務器
  • jsonp jsonp 支持
  • view 模板引擎

加載插件的邏輯是在egg-core裡面的plugin.js文件,先看代碼:

loadPlugin() {
//省略代碼
//把本地插件,egg內置的插件以及app的框架全部集成到allplugin中
this._extendPlugins(this.allPlugins, eggPlugins);
this._extendPlugins(this.allPlugins, appPlugins);
this._extendPlugins(this.allPlugins, customPlugins);

//省略代碼
//遍歷操作
for (const name in this.allPlugins) {
const plugin = this.allPlugins[name];
//對插件名稱進行一些校驗
this.mergePluginConfig(plugin);
//省略代碼
}
if (plugin.enable) {
//整合所有開啟的插件
enabledPluginNames.push(name);
}
}

如上代碼(只是貼出了比較關鍵的地方),這段代碼主要是將本地插件、egg中內置的插件以及應用的插件進行了整合。其中this.allPlugins的結果如下:

英特爾架構師乾貨分享之Egg運行原理

egg2.png

可以看出,this.allPlugins包含了所有內置的插件以及本地開發者自定義的插件。先獲取所有插件的相關信息,然後將所有插件進行遍歷,執行this.mergePluginConfig()函數,這個函數主要是對插件名稱進行一些校驗。之後還對項目中已經開啟的插件進行整合。plugin.js文件還做了一些其他事情,比如獲取插件路徑,讀取插件配置等等,這裡不一一講解。

擴展內置對象

包括插件裡面定義的擴展以及開發者自己寫的擴展,這也是這裡講的內容。

在對內置對象進行擴展的時候,實質上執行的是extend.js文件,擴展的對象包括如下幾個:

  • Application
  • Context
  • Request
  • Response
  • Helper

通過閱讀extend.js文件可以知道,其實最後每個對象的擴展都是直接調用的loadExtends這個函數。拿Application這個內置對象進行舉例:

loadExtend(name, proto) {
// All extend files
const filepaths = this.getExtendFilePaths(name);
// if use mm.env and serverEnv is not unittest
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}.js`);
if (isAddUnittest) filepaths.push(filepath + '.unittest.js');
}
const mergeRecord = new Map();
for (let filepath of filepaths) {
filepath = utils.resolveModule(filepath);
if (!filepath) {
continue;
} else if (filepath.endsWith('/index.js')) {
// TODO: remove support at next version
deprecate(`app/extend/${name}/index.js is deprecated, use app/extend/${name}.js instead`);
}
const ext = utils.loadFile(filepath);

//獲取內置對象的原有屬性
const properties = Object.getOwnPropertyNames(ext)
.concat(Object.getOwnPropertySymbols(ext));

//對屬性進行遍歷
for (const property of properties) {
if (mergeRecord.has(property)) {
debug('Property: "%s" already exists in "%s",it will be redefined by "%s"',
property, mergeRecord.get(property), filepath);
}
// Copy descriptor
let descriptor = Object.getOwnPropertyDescriptor(ext, property);
let originalDescriptor = Object.getOwnPropertyDescriptor(proto, property);
if (!originalDescriptor) {
// try to get descriptor from originalPrototypes
const originalProto = originalPrototypes[name];
if (originalProto) {
originalDescriptor = Object.getOwnPropertyDescriptor(originalProto, property);
}
}
//省略代碼
//將擴展屬性進行合併
Object.defineProperty(proto, property, descriptor);
mergeRecord.set(property, filepath);
}

debug('merge %j to %s from %s', Object.keys(ext), name, filepath);
}
},

將filepaths進行打印,如下圖:

英特爾架構師乾貨分享之Egg運行原理

egg3.png

可以看出,filepaths包含所有的對application擴展的文件路徑,這裡會首先將所有插件中擴展或者開發者自己自定義的擴展文件的路徑獲取到,然後進行遍歷,並且對內置對象的一些原有屬性和擴展屬性進行合併,此時對內置對象擴展的一些屬性就會添加到內置對象中。所以在執行業務代碼的時候,就可以直接通過訪問application.屬性(或方法)進行調用。

加載中間件

對中間件的加載主要是執行的egg-core中的middleware.js文件,裡面的代碼思想也是和上面加載內置對象是一樣的,也是將插件中的中間件和應用中的中間件路徑全部獲取到,然後進行遍歷。

遍歷完成之後執行中間件就和koa一樣了,調用co進行包裹遍歷。

加載控制器

對控制器的加載主要是執行的egg-core中的controller.js文件

egg的官方文檔中,插件的開發這一節提到:

插件沒有獨立的 router 和 controller

所以在加載controller的時候,主要是load應用裡面的controller即可。詳見代碼;

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)) {
obj = obj(this.app);
}
if (is.promise(obj)) {
const displayPath = path.relative(this.app.baseDir, opt.path);
throw new Error(`${displayPath} cannot be async function`);
}

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);
}
if (is.generatorFunction(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);
},

這裡主要是針對controller的類型進行判斷(是否是Object,class,promise,generator),然後分別進行處理

加載service

加載service的邏輯是egg-core中的service.js,service.js這個文件比較簡單,代碼如下:

loadService(opt) {
// 載入到 app.serviceClasses
opt = Object.assign({
call: true,
caseStyle: 'lower',
fieldClass: 'serviceClasses',
directory: this.getLoadUnits().map(unit => path.join(unit.path, 'app/service')),
}, opt);
const servicePaths = opt.directory;
this.loadToContext(servicePaths, 'service', opt);
},

首先也是先獲取所有插件和應用中聲明的service.js文件目錄,然後執行this.loadToContext()

loadToContext()定義在egg-loader.js文件中,繼續追蹤,可以看到在loadToContext()函數中實例化了ContextLoader並執行了load(),其中ContextLoader繼承自FileLoader,而且load()是聲明在FileLoader類中的。

通過查看load()代碼可以發現裡面的邏輯也是將屬性添加到上下文(ctx)對象中的。也就是說加載context對象是在加載service的時候完成的。

而且值得一提的是:在每次刷新頁面重新加載或者有新的請求的時候,都會去執行context_loader.js裡面的邏輯,也就是說ctx上下文對象的內容會隨著每次請求而發生改變,而且service對象是掛載在ctx對象下面的,對於service的更新,這裡有一段代碼:

// define ctx.service
Object.defineProperty(app.context, property, {
get() {
// distinguish property cache,
// cache's lifecycle is the same with this context instance
// e.x. ctx.service1 and ctx.service2 have different cache
if (!this[CLASSLOADER]) {
this[CLASSLOADER] = new Map();
}
const classLoader = this[CLASSLOADER];

//先判斷是否有使用
let instance = classLoader.get(property);
if (!instance) {
instance = getInstance(target, this);
classLoader.set(property, instance);
}
return instance;
},
});

在更新service的時候,首先會去獲取service是否掛載在ctx中,如果沒有,則直接返回,否則實例化service,這也就是service模塊中的延遲實例化

加載路由

加載路由的邏輯主要是egg-core中的router.js文件

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

可以看出很簡單,只是加載應用文件下的router.js文件

加載配置

直接加載配置文件並提供可配置的方法。

設置應用信息

對egg應用信息的設置邏輯是對應的egg-core中的egg-loader.js,裡面主要是提供一些方法獲取整個app的信息,包括appinfo,name,path等,比較簡單,這裡不一一列出

執行業務邏輯

然後就會去執行如渲染頁面等的邏輯

總結

這裡只是我個人針對源代碼以及斷點調試總結的一些東西.

轉發+關注,私信“私聊”即可獲取內部資源分享!


分享到:


相關文章: