如果你的團(tuán)隊(duì)遇到過:
- 維護(hù)很多個(gè)項(xiàng)目,每個(gè)項(xiàng)目都需要復(fù)制拷貝諸如 gulpfile.js / webpack.config.js 之類的文件。
- 每個(gè)項(xiàng)目都需要使用一些相同的類庫,相同的配置。
- 在新項(xiàng)目中對上面的配置做了一個(gè)優(yōu)化后,如何同步到其他項(xiàng)目?
如果你的團(tuán)隊(duì)需要:
- 統(tǒng)一的技術(shù)選型,比如數(shù)據(jù)庫、模板、前端框架及各種中間件設(shè)施都需要選型,而框架封裝后保證應(yīng)用使用一套架構(gòu)。
- 統(tǒng)一的默認(rèn)配置,開源社區(qū)的配置可能不適用于公司,而又不希望應(yīng)用去配置。
- 統(tǒng)一的部署方案,通過框架和平臺(tái)的雙向控制,應(yīng)用只需要關(guān)注自己的代碼,具體查看應(yīng)用部署
- 統(tǒng)一的代碼風(fēng)格,框架不僅僅解決代碼重用問題,還可以對應(yīng)用做一定約束,作為企業(yè)框架是很必要的。Egg 在 Koa 基礎(chǔ)上做了很多約定,框架可以使用 Loader 自己定義代碼規(guī)則。
為此,Egg 為團(tuán)隊(duì)架構(gòu)師和技術(shù)負(fù)責(zé)人提供 框架定制 的能力,框架是一層抽象,可以基于 Egg 去封裝上層框架,并且 Egg 支持多層繼承。
這樣,整個(gè)團(tuán)隊(duì)就可以遵循統(tǒng)一的方案,并且在項(xiàng)目中可以根據(jù)業(yè)務(wù)場景自行使用插件做差異化,當(dāng)后者驗(yàn)證為最佳實(shí)踐后,就能下沉到框架中,其他項(xiàng)目僅需簡單的升級(jí)下框架的版本即可享受到。
具體可以參見漸進(jìn)式開發(fā)。
框架與多進(jìn)程
框架的擴(kuò)展是和多進(jìn)程模型有關(guān)的,我們已經(jīng)知道多進(jìn)程模型,也知道 Agent Worker 和 App Worker 的區(qū)別,所以我們需要擴(kuò)展的類也有兩個(gè) Agent 和 Application,而這兩個(gè)類的 API 不一定相同。
在 Agent Worker 啟動(dòng)的時(shí)候會(huì)實(shí)例化 Agent,而在 App Worker 啟動(dòng)時(shí)會(huì)實(shí)例化 Application,這兩個(gè)類又同時(shí)繼承 EggCore。
EggCore 可以看做 Koa Application 的升級(jí)版,默認(rèn)內(nèi)置 Loader、Router 及應(yīng)用異步啟動(dòng)等功能,可以看做是支持 Loader 的 Koa。
Koa Application ^ EggCore ^ ┌──────┴───────┐ │ │ Egg Agent Egg Application ^ ^ agent worker app worker
|
如何定制一個(gè)框架
你可以直接通過 egg-boilerplate-framework 腳手架來快速上手。
$ mkdir yadan && cd yadan $ npm init egg --type=framework $ npm i $ npm test
|
但同樣,為了讓大家了解細(xì)節(jié),接下來我們還是手把手來定制一個(gè)框架,具體代碼可以查看示例
框架 API
Egg 框架提供了一些 API,所有繼承的框架都需要提供,只增不減。這些 API 基本都有 Agent 和 Application 兩份。
egg.startCluster
Egg 的多進(jìn)程啟動(dòng)器,由這個(gè)方法來啟動(dòng) Master,主要的功能實(shí)現(xiàn)在 egg-cluster 上。所以直接使用 EggCore 還是單進(jìn)程的方式,而 Egg 實(shí)現(xiàn)了多進(jìn)程。
const startCluster = require('egg').startCluster; startCluster({ // 應(yīng)用的代碼目錄 baseDir: '/path/to/app', // 需要通過這個(gè)參數(shù)來指定框架目錄 framework: '/path/to/framework', }, () => { console.log('app started'); });
|
所有參數(shù)可以查看 egg-cluster
egg.Application 和 egg.Agent
進(jìn)程中的唯一單例,但 Application 和 Agent 存在一定差異。如果框架繼承于 Egg,會(huì)定制這兩個(gè)類,那 framework 應(yīng)該 export 這兩個(gè)類。
egg.AppWorkerLoader 和 egg.AgentWorkerLoader
框架也存在定制 Loader 的場景,覆蓋原方法或者新加載目錄都需要提供自己的 Loader,而且必須要繼承 Egg 的 Loader。
框架繼承
框架支持繼承關(guān)系,可以把框架比作一個(gè)類,那么基類就是 Egg 框架,如果想對 Egg 做擴(kuò)展就繼承。
首先定義一個(gè)框架需要實(shí)現(xiàn) Egg 所有的 API
// package.json { "name": "yadan", "dependencies": { "egg": "^2.0.0" } }
// index.js module.exports = require('./lib/framework.js');
// lib/framework.js const path = require('path'); const egg = require('egg'); const EGG_PATH = Symbol.for('egg#eggPath');
class Application extends egg.Application { get [EGG_PATH]() { // 返回 framework 路徑 return path.dirname(__dirname); } }
// 覆蓋了 Egg 的 Application module.exports = Object.assign(egg, { Application, });
|
應(yīng)用啟動(dòng)時(shí)需要指定框架名(在 package.json 指定 egg.framework,默認(rèn)為 egg),Loader 將從 node_modules 找指定模塊作為框架,并加載其 export 的 Application。
{ "scripts": { "dev": "egg-bin dev" }, "egg": { "framework": "yadan" } }
|
現(xiàn)在 yadan 框架目錄已經(jīng)是一個(gè) loadUnit,那么相應(yīng)目錄和文件(如 app 和 config)都會(huì)被加載,查看框架被加載的文件。
框架繼承原理
使用 Symbol.for('egg#eggPath') 來指定當(dāng)前框架的路徑,目的是讓 Loader 能探測到框架的路徑。為什么這樣實(shí)現(xiàn)呢?其實(shí)最簡單的方式是將框架的路徑傳遞給 Loader,但我們需要實(shí)現(xiàn)多級(jí)框架繼承,每一層框架都要提供自己的當(dāng)前路徑,并且需要繼承存在先后順序。
現(xiàn)在的實(shí)現(xiàn)方案是基于類繼承的,每一層框架都必須繼承上一層框架并且指定 eggPath,然后遍歷原型鏈就能獲取每一層的框架路徑了。
比如有三層框架:部門框架(department)> 企業(yè)框架(enterprise)> Egg
// enterprise const Application = require('egg').Application; class Enterprise extends Application { get [EGG_PATH]() { return '/path/to/enterprise'; } } // 自定義模塊 Application exports.Application = Enterprise;
// department const Application = require('enterprise').Application; // 繼承 enterprise 的 Application class department extends Application { get [EGG_PATH]() { return '/path/to/department'; } }
// 啟動(dòng)需要傳入 department 的框架路徑才能獲取 Application const Application = require('department').Application; const app = new Application(); app.ready();
|
以上均是偽代碼,為了詳細(xì)說明框架路徑的加載過程,不過 Egg 已經(jīng)在本地開發(fā)和應(yīng)用部署提供了很好的工具,不需要自己實(shí)現(xiàn)。
自定義 Agent
上面的例子自定義了 Application,因?yàn)?Egg 是多進(jìn)程模型,所以還需要定義 Agent,原理是一樣的。
// lib/framework.js const path = require('path'); const egg = require('egg'); const EGG_PATH = Symbol.for('egg#eggPath');
class Application extends egg.Application { get [EGG_PATH]() { // 返回 framework 路徑 return path.dirname(__dirname); } }
class Agent extends egg.Agent { get [EGG_PATH]() { return path.dirname(__dirname); } }
// 覆蓋了 Egg 的 Application module.exports = Object.assign(egg, { Application, Agent, });
|
但因?yàn)?Agent 和 Application 是兩個(gè)實(shí)例,所以 API 有可能不一致。
自定義 Loader
Loader 應(yīng)用啟動(dòng)的核心,使用它還能規(guī)范應(yīng)用代碼,我們可以基于這個(gè)類擴(kuò)展更多功能,比如加載數(shù)據(jù)代碼。擴(kuò)展 Loader 還能覆蓋默認(rèn)的實(shí)現(xiàn),或調(diào)整現(xiàn)有的加載順序等。
自定義 Loader 也是用 Symbol.for('egg#loader') 的方式,主要的原因還是使用原型鏈,上層框架可覆蓋底層 Loader,在上面例子的基礎(chǔ)上
// lib/framework.js const path = require('path'); const egg = require('egg'); const EGG_PATH = Symbol.for('egg#eggPath');
class YadanAppWorkerLoader extends egg.AppWorkerLoader { load() { super.load(); // 自己擴(kuò)展 } }
class Application extends egg.Application { get [EGG_PATH]() { // 返回 framework 路徑 return path.dirname(__dirname); } // 覆蓋 Egg 的 Loader,啟動(dòng)時(shí)使用這個(gè) Loader get [EGG_LOADER]() { return YadanAppWorkerLoader; } }
// 覆蓋了 Egg 的 Application module.exports = Object.assign(egg, { Application, // 自定義的 Loader 也需要 export,上層框架需要基于這個(gè)擴(kuò)展 AppWorkerLoader: YadanAppWorkerLoader, });
|
AgentWorkerLoader 擴(kuò)展也類似,這里不再舉例。AgentWorkerLoader 加載的文件可以于 AppWorkerLoader 不同,比如:默認(rèn)加載時(shí),Egg 的 AppWorkerLoader 會(huì)加載 app.js 而 AgentWorkerLoader 加載的是 agent.js。
框架啟動(dòng)原理
框架啟動(dòng)在多進(jìn)程模型、Loader、插件中或多或少都提過,這里系統(tǒng)的梳理下啟動(dòng)順序。
- startCluster 啟動(dòng)傳入 baseDir 和 framework,Master 進(jìn)程啟動(dòng)
- Master 先 fork Agent Worker根據(jù) framework 找到框架目錄,實(shí)例化該框架的 Agent 類Agent 找到定義的 AgentWorkerLoader,開始進(jìn)行加載AgentWorkerLoader,開始進(jìn)行加載 整個(gè)加載過程是同步的,按 plugin > config > extend > agent.js > 其他文件順序加載agent.js 可自定義初始化,支持異步啟動(dòng),如果定義了 beforeStart 會(huì)等待執(zhí)行完成之后通知 Master 啟動(dòng)完成。
- Master 得到 Agent Worker 啟動(dòng)成功的消息,使用 cluster fork App WorkerApp Worker 有多個(gè)進(jìn)程,所以這幾個(gè)進(jìn)程是并行啟動(dòng)的,但執(zhí)行邏輯是一致的單個(gè) App Worker 和 Agent 類似,通過 framework 找到框架目錄,實(shí)例化該框架的 Application 類Application 找到 AppWorkerLoader,開始進(jìn)行加載,順序也是類似的,會(huì)異步等待,完成后通知 Master 啟動(dòng)完成
- Master 等待多個(gè) App Worker 的成功消息后啟動(dòng)完成,能對外提供服務(wù)。
框架測試
在看下文之前請先查看單元測試章節(jié),框架測試的大部分使用場景和應(yīng)用類似。
初始化
框架的初始化方式有一定差異
const mock = require('egg-mock'); describe('test/index.test.js', () => { let app; before(() => { app = mock.app({ // 轉(zhuǎn)換成 test/fixtures/apps/example baseDir: 'apps/example', // 重要:配置 framework framework: true, }); return app.ready(); });
after(() => app.close()); afterEach(mock.restore);
it('should success', () => { return app.httpRequest() .get('/') .expect(200); }); });
|
- 框架和應(yīng)用不同,應(yīng)用測試當(dāng)前代碼,而框架是測試框架代碼,所以會(huì)頻繁更換 baseDir 達(dá)到測試各種應(yīng)用的目的。
- baseDir 有潛規(guī)則,我們一般會(huì)把測試的應(yīng)用代碼放到 test/fixtures 下,所以自動(dòng)補(bǔ)全,也可以傳入絕對路徑。
- 必須指定 framework: true,告知當(dāng)前路徑為框架路徑,也可以傳入絕對路徑。
- app 應(yīng)用需要在 before 等待 ready,不然在 testcase 里無法獲取部分 API
- 框架在測試完畢后需要使用 app.close() 關(guān)閉,不然會(huì)有遺留問題,比如日志寫文件未關(guān)閉導(dǎo)致 fd 不夠。
緩存
在測試多環(huán)境場景需要使用到 cache 參數(shù),因?yàn)?nbsp;mm.app 默認(rèn)有緩存,當(dāng)?shù)谝淮渭虞d過后再次加載會(huì)直接讀取緩存,那么設(shè)置的環(huán)境也不會(huì)生效。
const mock = require('egg-mock'); describe('/test/index.test.js', () => { let app; afterEach(() => app.close());
it('should test on local', () => { mock.env('local'); app = mock.app({ baseDir: 'apps/example', framework: true, cache: false, }); return app.ready(); }); it('should test on prod', () => { mock.env('prod'); app = mock.app({ baseDir: 'apps/example', framework: true, cache: false, }); return app.ready(); }); });
|
多進(jìn)程測試
很少場景會(huì)使用多進(jìn)程測試,因?yàn)槎噙M(jìn)程無法進(jìn)行 API 級(jí)別的 mock 導(dǎo)致測試成本很高,而進(jìn)程在有覆蓋率的場景啟動(dòng)很慢,測試會(huì)超時(shí)。但多進(jìn)程測試是驗(yàn)證多進(jìn)程模型最好的方式,還可以測試 stdout 和 stderr。
多進(jìn)程測試和 mm.app 參數(shù)一致,但 app 的 API 完全不同,不過 SuperTest 依然可用。
const mock = require('egg-mock'); describe('/test/index.test.js', () => { let app; before(() => { app = mock.cluster({ baseDir: 'apps/example', framework: true, }); return app.ready(); }); after(() => app.close()); afterEach(mock.restore); it('should success', () => { return app.httpRequest() .get('/') .expect(200); }); });
|
多進(jìn)程測試還可以測試 stdout/stderr,因?yàn)?nbsp;mm.cluster 是基于 coffee 擴(kuò)展的,可進(jìn)行進(jìn)程測試。
const mock = require('egg-mock'); describe('/test/index.test.js', () => { let app; before(() => { app = mock.cluster({ baseDir: 'apps/example', framework: true, }); return app.ready(); }); after(() => app.close()); it('should get `started`', () => { // 判斷終端輸出 app.expect('stdout', /started/); }); }); |
更多建議: