Egg 框架開發(fā)

2020-02-06 14:12 更新

如果你的團隊遇到過:

  • 維護很多個項目,每個項目都需要復(fù)制拷貝諸如 gulpfile.js / webpack.config.js 之類的文件。
  • 每個項目都需要使用一些相同的類庫,相同的配置。
  • 在新項目中對上面的配置做了一個優(yōu)化后,如何同步到其他項目?

如果你的團隊需要:

  • 統(tǒng)一的技術(shù)選型,比如數(shù)據(jù)庫、模板、前端框架及各種中間件設(shè)施都需要選型,而框架封裝后保證應(yīng)用使用一套架構(gòu)。
  • 統(tǒng)一的默認(rèn)配置,開源社區(qū)的配置可能不適用于公司,而又不希望應(yīng)用去配置。
  • 統(tǒng)一的部署方案,通過框架和平臺的雙向控制,應(yīng)用只需要關(guān)注自己的代碼,具體查看應(yīng)用部署
  • 統(tǒng)一的代碼風(fēng)格,框架不僅僅解決代碼重用問題,還可以對應(yīng)用做一定約束,作為企業(yè)框架是很必要的。Egg 在 Koa 基礎(chǔ)上做了很多約定,框架可以使用 Loader 自己定義代碼規(guī)則。

為此,Egg 為團隊架構(gòu)師和技術(shù)負責(zé)人提供 框架定制 的能力,框架是一層抽象,可以基于 Egg 去封裝上層框架,并且 Egg 支持多層繼承。

這樣,整個團隊就可以遵循統(tǒng)一的方案,并且在項目中可以根據(jù)業(yè)務(wù)場景自行使用插件做差異化,當(dāng)后者驗證為最佳實踐后,就能下沉到框架中,其他項目僅需簡單的升級下框架的版本即可享受到。

具體可以參見漸進式開發(fā)

框架與多進程

框架的擴展是和多進程模型有關(guān)的,我們已經(jīng)知道多進程模型,也知道 Agent Worker 和 App Worker 的區(qū)別,所以我們需要擴展的類也有兩個 Agent 和 Application,而這兩個類的 API 不一定相同。

在 Agent Worker 啟動的時候會實例化 Agent,而在 App Worker 啟動時會實例化 Application,這兩個類又同時繼承 EggCore。

EggCore 可以看做 Koa Application 的升級版,默認(rèn)內(nèi)置 LoaderRouter 及應(yīng)用異步啟動等功能,可以看做是支持 Loader 的 Koa。

      Koa Application
^
EggCore
^
┌──────┴───────┐
│ │
Egg Agent Egg Application
^ ^
agent worker app worker

如何定制一個框架

你可以直接通過 egg-boilerplate-framework 腳手架來快速上手。

$ mkdir yadan && cd yadan
$ npm init egg --type=framework
$ npm i
$ npm test

但同樣,為了讓大家了解細節(jié),接下來我們還是手把手來定制一個框架,具體代碼可以查看示例

框架 API

Egg 框架提供了一些 API,所有繼承的框架都需要提供,只增不減。這些 API 基本都有 Agent 和 Application 兩份。

egg.startCluster

Egg 的多進程啟動器,由這個方法來啟動 Master,主要的功能實現(xiàn)在 egg-cluster 上。所以直接使用 EggCore 還是單進程的方式,而 Egg 實現(xiàn)了多進程。

const startCluster = require('egg').startCluster;
startCluster({
// 應(yīng)用的代碼目錄
baseDir: '/path/to/app',
// 需要通過這個參數(shù)來指定框架目錄
framework: '/path/to/framework',
}, () => {
console.log('app started');
});

所有參數(shù)可以查看 egg-cluster

egg.Application 和 egg.Agent

進程中的唯一單例,但 Application 和 Agent 存在一定差異。如果框架繼承于 Egg,會定制這兩個類,那 framework 應(yīng)該 export 這兩個類。

egg.AppWorkerLoader 和 egg.AgentWorkerLoader

框架也存在定制 Loader 的場景,覆蓋原方法或者新加載目錄都需要提供自己的 Loader,而且必須要繼承 Egg 的 Loader。

框架繼承

框架支持繼承關(guān)系,可以把框架比作一個類,那么基類就是 Egg 框架,如果想對 Egg 做擴展就繼承。

首先定義一個框架需要實現(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)用啟動時需要指定框架名(在 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)是一個 loadUnit,那么相應(yīng)目錄和文件(如 app 和 config)都會被加載,查看框架被加載的文件。

框架繼承原理

使用 Symbol.for('egg#eggPath') 來指定當(dāng)前框架的路徑,目的是讓 Loader 能探測到框架的路徑。為什么這樣實現(xiàn)呢?其實最簡單的方式是將框架的路徑傳遞給 Loader,但我們需要實現(xiàn)多級框架繼承,每一層框架都要提供自己的當(dāng)前路徑,并且需要繼承存在先后順序。

現(xiàn)在的實現(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';
}
}

// 啟動需要傳入 department 的框架路徑才能獲取 Application
const Application = require('department').Application;
const app = new Application();
app.ready();

以上均是偽代碼,為了詳細說明框架路徑的加載過程,不過 Egg 已經(jīng)在本地開發(fā)應(yīng)用部署提供了很好的工具,不需要自己實現(xiàn)。

自定義 Agent

上面的例子自定義了 Application,因為 Egg 是多進程模型,所以還需要定義 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,
});

但因為 Agent 和 Application 是兩個實例,所以 API 有可能不一致。

自定義 Loader

Loader 應(yīng)用啟動的核心,使用它還能規(guī)范應(yīng)用代碼,我們可以基于這個類擴展更多功能,比如加載數(shù)據(jù)代碼。擴展 Loader 還能覆蓋默認(rèn)的實現(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();
// 自己擴展
}
}

class Application extends egg.Application {
get [EGG_PATH]() {
// 返回 framework 路徑
return path.dirname(__dirname);
}
// 覆蓋 Egg 的 Loader,啟動時使用這個 Loader
get [EGG_LOADER]() {
return YadanAppWorkerLoader;
}
}

// 覆蓋了 Egg 的 Application
module.exports = Object.assign(egg, {
Application,
// 自定義的 Loader 也需要 export,上層框架需要基于這個擴展
AppWorkerLoader: YadanAppWorkerLoader,
});

AgentWorkerLoader 擴展也類似,這里不再舉例。AgentWorkerLoader 加載的文件可以于 AppWorkerLoader 不同,比如:默認(rèn)加載時,Egg 的 AppWorkerLoader 會加載 app.js 而 AgentWorkerLoader 加載的是 agent.js。

框架啟動原理

框架啟動在多進程模型、Loader、插件中或多或少都提過,這里系統(tǒng)的梳理下啟動順序。

  • startCluster 啟動傳入 baseDir 和 framework,Master 進程啟動
  • Master 先 fork Agent Worker根據(jù) framework 找到框架目錄,實例化該框架的 Agent 類Agent 找到定義的 AgentWorkerLoader,開始進行加載AgentWorkerLoader,開始進行加載 整個加載過程是同步的,按 plugin > config > extend > agent.js > 其他文件順序加載agent.js 可自定義初始化,支持異步啟動,如果定義了 beforeStart 會等待執(zhí)行完成之后通知 Master 啟動完成。
  • Master 得到 Agent Worker 啟動成功的消息,使用 cluster fork App WorkerApp Worker 有多個進程,所以這幾個進程是并行啟動的,但執(zhí)行邏輯是一致的單個 App Worker 和 Agent 類似,通過 framework 找到框架目錄,實例化該框架的 Application 類Application 找到 AppWorkerLoader,開始進行加載,順序也是類似的,會異步等待,完成后通知 Master 啟動完成
  • Master 等待多個 App Worker 的成功消息后啟動完成,能對外提供服務(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)前代碼,而框架是測試框架代碼,所以會頻繁更換 baseDir 達到測試各種應(yīng)用的目的。
  • baseDir 有潛規(guī)則,我們一般會把測試的應(yīng)用代碼放到 test/fixtures 下,所以自動補全,也可以傳入絕對路徑。
  • 必須指定 framework: true,告知當(dāng)前路徑為框架路徑,也可以傳入絕對路徑。
  • app 應(yīng)用需要在 before 等待 ready,不然在 testcase 里無法獲取部分 API
  • 框架在測試完畢后需要使用 app.close() 關(guān)閉,不然會有遺留問題,比如日志寫文件未關(guān)閉導(dǎo)致 fd 不夠。

緩存

在測試多環(huán)境場景需要使用到 cache 參數(shù),因為 mm.app 默認(rèn)有緩存,當(dāng)?shù)谝淮渭虞d過后再次加載會直接讀取緩存,那么設(shè)置的環(huán)境也不會生效。

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

多進程測試

很少場景會使用多進程測試,因為多進程無法進行 API 級別的 mock 導(dǎo)致測試成本很高,而進程在有覆蓋率的場景啟動很慢,測試會超時。但多進程測試是驗證多進程模型最好的方式,還可以測試 stdout 和 stderr。

多進程測試和 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);
});
});

多進程測試還可以測試 stdout/stderr,因為 mm.cluster 是基于 coffee 擴展的,可進行進程測試。

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


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號