Egg 插件開發(fā)

2020-02-06 14:12 更新

插件機(jī)制是我們框架的一大特色。它不但可以保證框架核心的足夠精簡(jiǎn)、穩(wěn)定、高效,還可以促進(jìn)業(yè)務(wù)邏輯的復(fù)用,生態(tài)圈的形成。有人可能會(huì)問了:

  • Koa 已經(jīng)有了中間件的機(jī)制,為啥還要插件呢?
  • 中間件、插件、應(yīng)用它們之間是什么關(guān)系,有什么區(qū)別?
  • 我該怎么使用一個(gè)插件?
  • 如何編寫一個(gè)插件?
  • ...

使用插件章節(jié)我們已經(jīng)討論過前幾點(diǎn),接下來我們來看看如何開發(fā)一個(gè)插件。

插件開發(fā)

使用腳手架快速開發(fā)

你可以直接使用 egg-boilerplate-plugin 腳手架來快速上手。

$ mkdir egg-hello && cd egg-hello
$ npm init egg --type=plugin
$ npm i
$ npm test

插件的目錄結(jié)構(gòu)

一個(gè)插件其實(shí)就是一個(gè)『迷你的應(yīng)用』,下面展示的是一個(gè)插件的目錄結(jié)構(gòu),和應(yīng)用(app)幾乎一樣。

. egg-hello
├── package.json
├── app.js (可選)
├── agent.js (可選)
├── app
│ ├── extend (可選)
│ | ├── helper.js (可選)
│ | ├── request.js (可選)
│ | ├── response.js (可選)
│ | ├── context.js (可選)
│ | ├── application.js (可選)
│ | └── agent.js (可選)
│ ├── service (可選)
│ └── middleware (可選)
│ └── mw.js
├── config
| ├── config.default.js
│ ├── config.prod.js
| ├── config.test.js (可選)
| ├── config.local.js (可選)
| └── config.unittest.js (可選)
└── test
└── middleware
└── mw.test.js

那區(qū)別在哪兒呢?

  1. 插件沒有獨(dú)立的 router 和 controller。這主要出于幾點(diǎn)考慮:路由一般和應(yīng)用強(qiáng)綁定的,不具備通用性。一個(gè)應(yīng)用可能依賴很多個(gè)插件,如果插件支持路由可能導(dǎo)致路由沖突。如果確實(shí)有統(tǒng)一路由的需求,可以考慮在插件里通過中間件來實(shí)現(xiàn)。
  2. 插件需要在 package.json 中的 eggPlugin 節(jié)點(diǎn)指定插件特有的信息:{String} name - 插件名(必須配置),具有唯一性,配置依賴關(guān)系時(shí)會(huì)指定依賴插件的 name。{Array} dependencies - 當(dāng)前插件強(qiáng)依賴的插件列表(如果依賴的插件沒找到,應(yīng)用啟動(dòng)失?。?。{Array} optionalDependencies - 當(dāng)前插件的可選依賴插件列表(如果依賴的插件未開啟,只會(huì) warning,不會(huì)影響應(yīng)用啟動(dòng))。{Array} env - 只有在指定運(yùn)行環(huán)境才能開啟,具體有哪些環(huán)境可以參考運(yùn)行環(huán)境。此配置是可選的,一般情況下都不需要配置。{ "name": "egg-rpc", "eggPlugin": { "name": "rpc", "dependencies": [ "registry" ], "optionalDependencies": [ "vip" ], "env": [ "local", "test", "unittest", "prod" ] }}
  3. 插件沒有 plugin.js:eggPlugin.dependencies 只是用于聲明依賴關(guān)系,而不是引入插件或開啟插件。如果期望統(tǒng)一管理多個(gè)插件的開啟和配置,可以在上層框架處理。

插件的依賴管理

和中間件不同,插件是自己管理依賴的。應(yīng)用在加載所有插件前會(huì)預(yù)先從它們的 package.json 中讀取 eggPlugin > dependencies 和 eggPlugin > optionalDependencies 節(jié)點(diǎn),然后根據(jù)依賴關(guān)系計(jì)算出加載順序,舉個(gè)例子,下面三個(gè)插件的加載順序就應(yīng)該是 c => b => a

// plugin a
{
"name": "egg-plugin-a",
"eggPlugin": {
"name": "a",
"dependencies": [ "b" ]
}
}

// plugin b
{
"name": "egg-plugin-b",
"eggPlugin": {
"name": "b",
"optionalDependencies": [ "c" ]
}
}

// plugin c
{
"name": "egg-plugin-c",
"eggPlugin": {
"name": "c"
}
}

注意:dependencies 和 optionalDependencies 的取值是另一個(gè)插件的 eggPlugin.name,而不是 package name。

dependencies 和 optionalDependencies 是從 npm 借鑒來的概念,大多數(shù)情況下我們都使用 dependencies,這也是我們最推薦的依賴方式。那什么時(shí)候可以用 optionalDependencies 呢?大致就兩種:

  • 只在某些環(huán)境下才依賴,比如:一個(gè)鑒權(quán)插件,只在開發(fā)環(huán)境依賴一個(gè) mock 數(shù)據(jù)的插件
  • 弱依賴,比如:A 依賴 B,但是如果沒有 B,A 有相應(yīng)的降級(jí)方案

需要特別強(qiáng)調(diào)的是:如果采用 optionalDependencies 那么框架不會(huì)校驗(yàn)依賴的插件是否開啟,它的作用僅僅是計(jì)算加載順序。所以,這時(shí)候依賴方需要通過『接口探測(cè)』等方式來決定相應(yīng)的處理邏輯。

插件能做什么?

上面給出了插件的定義,那插件到底能做什么?

擴(kuò)展內(nèi)置對(duì)象的接口

在插件相應(yīng)的文件內(nèi)對(duì)框架內(nèi)置對(duì)象進(jìn)行擴(kuò)展,和應(yīng)用一樣

  • app/extend/request.js - 擴(kuò)展 Koa#Request 類
  • app/extend/response.js - 擴(kuò)展 Koa#Response 類
  • app/extend/context.js - 擴(kuò)展 Koa#Context 類
  • app/extend/helper.js - 擴(kuò)展 Helper 類
  • app/extend/application.js - 擴(kuò)展 Application 類
  • app/extend/agent.js - 擴(kuò)展 Agent 類

插入自定義中間件

  1. 首先在 app/middleware 目錄下定義好中間件實(shí)現(xiàn)'use strict';const staticCache = require('koa-static-cache');const assert = require('assert');const mkdirp = require('mkdirp');module.exports = (options, app) => { assert.strictEqual(typeof options.dir, 'string', 'Must set `app.config.static.dir` when static plugin enable'); // ensure directory exists mkdirp.sync(options.dir); app.loggers.coreLogger.info('[egg-static] starting static serve %s -> %s', options.prefix, options.dir); return staticCache(options);};
  2. 在 app.js 中將中間件插入到合適的位置(例如:下面將 static 中間件放到 bodyParser 之前)const assert = require('assert');module.exports = app => { // 將 static 中間件放到 bodyParser 之前 const index = app.config.coreMiddleware.indexOf('bodyParser'); assert(index >= 0, 'bodyParser 中間件必須存在'); app.config.coreMiddleware.splice(index, 0, 'static');};

在應(yīng)用啟動(dòng)時(shí)做一些初始化工作

  • 我在啟動(dòng)前想讀取一些本地配置// ${plugin_root}/app.jsconst fs = require('fs');const path = require('path');module.exports = app => { app.customData = fs.readFileSync(path.join(app.config.baseDir, 'data.bin')); app.coreLogger.info('read data ok');};
  • 如果有異步啟動(dòng)邏輯,可以使用 app.beforeStart API// ${plugin_root}/app.jsconst MyClient = require('my-client');module.exports = app => { app.myClient = new MyClient(); app.myClient.on('error', err => { app.coreLogger.error(err); }); app.beforeStart(async () => { await app.myClient.ready(); app.coreLogger.info('my client is ready'); });};
  • 也可以添加 agent 啟動(dòng)邏輯,使用 agent.beforeStart API// ${plugin_root}/agent.jsconst MyClient = require('my-client');module.exports = agent => { agent.myClient = new MyClient(); agent.myClient.on('error', err => { agent.coreLogger.error(err); }); agent.beforeStart(async () => { await agent.myClient.ready(); agent.coreLogger.info('my client is ready'); });};

設(shè)置定時(shí)任務(wù)

  1. 在 package.json 里設(shè)置依賴 schedule 插件{ "name": "your-plugin", "eggPlugin": { "name": "your-plugin", "dependencies": [ "schedule" ] }}
  2. 在 ${plugin_root}/app/schedule/ 目錄下新建文件,編寫你的定時(shí)任務(wù)exports.schedule = { type: 'worker', cron: '0 0 3 * * *', // interval: '1h', // immediate: true,};exports.task = async ctx => { // your logic code};

全局實(shí)例插件的最佳實(shí)踐

許多插件的目的都是將一些已有的服務(wù)引入到框架中,如 egg-mysqlegg-oss。他們都需要在 app 上創(chuàng)建對(duì)應(yīng)的實(shí)例。而在開發(fā)這一類的插件時(shí),我們發(fā)現(xiàn)存在一些普遍性的問題:

  • 在一個(gè)應(yīng)用中同時(shí)使用同一個(gè)服務(wù)的不同實(shí)例(連接到兩個(gè)不同的 MySQL 數(shù)據(jù)庫)。
  • 從其他服務(wù)獲取配置后動(dòng)態(tài)初始化連接(從配置中心獲取到 MySQL 服務(wù)地址后再建立連接)。

如果讓插件各自實(shí)現(xiàn),可能會(huì)出現(xiàn)各種奇怪的配置方式和初始化方式,所以框架提供了 app.addSingleton(name, creator) 方法來統(tǒng)一這一類服務(wù)的創(chuàng)建。需要注意的是在使用 app.addSingleton(name, creator) 方法時(shí),配置文件中一定要有 client 或者 clients 為 key 的配置作為傳入 creator 函數(shù) 的 config。

插件寫法

我們將 egg-mysql 的實(shí)現(xiàn)簡(jiǎn)化之后來看看如何編寫此類插件:

// egg-mysql/app.js
module.exports = app => {
// 第一個(gè)參數(shù) mysql 指定了掛載到 app 上的字段,我們可以通過 `app.mysql` 訪問到 MySQL singleton 實(shí)例
// 第二個(gè)參數(shù) createMysql 接受兩個(gè)參數(shù)(config, app),并返回一個(gè) MySQL 的實(shí)例
app.addSingleton('mysql', createMysql);
}

/**
* @param {Object} config 框架處理之后的配置項(xiàng),如果應(yīng)用配置了多個(gè) MySQL 實(shí)例,會(huì)將每一個(gè)配置項(xiàng)分別傳入并調(diào)用多次 createMysql
* @param {Application} app 當(dāng)前的應(yīng)用
* @return {Object} 返回創(chuàng)建的 MySQL 實(shí)例
*/
function createMysql(config, app) {
assert(config.host && config.port && config.user && config.database);
// 創(chuàng)建實(shí)例
const client = new Mysql(config);

// 做啟動(dòng)應(yīng)用前的檢查
app.beforeStart(async () => {
const rows = await client.query('select now() as currentTime;');
app.coreLogger.info(`[egg-mysql] init instance success, rds currentTime: ${rows[0].currentTime}`);
});

return client;
}

初始化方法也支持 Async function,便于有些特殊的插件需要異步化獲取一些配置文件:

async function createMysql(config, app) {
// 異步獲取 mysql 配置
const mysqlConfig = await app.configManager.getMysqlConfig(config.mysql);
assert(mysqlConfig.host && mysqlConfig.port && mysqlConfig.user && mysqlConfig.database);
// 創(chuàng)建實(shí)例
const client = new Mysql(mysqlConfig);

// 做啟動(dòng)應(yīng)用前的檢查
const rows = await client.query('select now() as currentTime;');
app.coreLogger.info(`[egg-mysql] init instance success, rds currentTime: ${rows[0].currentTime}`);

return client;
}

可以看到,插件中我們只需要提供要掛載的字段以及對(duì)應(yīng)服務(wù)的初始化方法,所有的配置管理、實(shí)例獲取方式都由框架封裝并統(tǒng)一提供了。

應(yīng)用層使用方案

單實(shí)例
  1. 在配置文件中聲明 MySQL 的配置。// config/config.default.jsmodule.exports = { mysql: { client: { host: 'mysql.com', port: '3306', user: 'test_user', password: 'test_password', database: 'test', }, },};
  2. 直接通過 app.mysql 訪問數(shù)據(jù)庫。// app/controller/post.jsclass PostController extends Controller { async list() { const posts = await this.app.mysql.query(sql, values); },}
多實(shí)例
  1. 同樣需要在配置文件中聲明 MySQL 的配置,不過和單實(shí)例時(shí)不同,配置項(xiàng)中需要有一個(gè) clients 字段,分別申明不同實(shí)例的配置,同時(shí)可以通過 default 字段來配置多個(gè)實(shí)例中共享的配置(如 host 和 port)。需要注意的是在這種情況下要用 get 方法指定相應(yīng)的實(shí)例。(例如:使用 app.mysql.get('db1').query(),而不是直接使用 app.mysql.query() 得到一個(gè) undefined)。// config/config.default.jsexports.mysql = { clients: { // clientId, access the client instance by app.mysql.get('clientId') db1: { user: 'user1', password: 'upassword1', database: 'db1', }, db2: { user: 'user2', password: 'upassword2', database: 'db2', }, }, // default configuration for all databases default: { host: 'mysql.com', port: '3306', },};
  2. 通過 app.mysql.get('db1') 來獲取對(duì)應(yīng)的實(shí)例并使用。// app/controller/post.jsclass PostController extends Controller { async list() { const posts = await this.app.mysql.get('db1').query(sql, values); },}
動(dòng)態(tài)創(chuàng)建實(shí)例

我們可以不需要將配置提前申明在配置文件中,而是在應(yīng)用運(yùn)行時(shí)動(dòng)態(tài)的初始化一個(gè)實(shí)例。

// app.js
module.exports = app => {
app.beforeStart(async () => {
// 從配置中心獲取 MySQL 的配置 { host, post, password, ... }
const mysqlConfig = await app.configCenter.fetch('mysql');
// 動(dòng)態(tài)創(chuàng)建 MySQL 實(shí)例
app.database = await app.mysql.createInstanceAsync(mysqlConfig);
});
};

通過 app.database 來使用這個(gè)實(shí)例。

// app/controller/post.js
class PostController extends Controller {
async list() {
const posts = await this.app.database.query(sql, values);
},
}

注意,在動(dòng)態(tài)創(chuàng)建實(shí)例的時(shí)候,框架也會(huì)讀取配置中 default 字段內(nèi)的配置項(xiàng)作為默認(rèn)配置。

插件的尋址規(guī)則

框架在加載插件的時(shí)候,遵循下面的尋址規(guī)則:

  • 如果配置了 path,直接按照 path 加載。
  • 沒有 path 根據(jù) package 名去查找,查找的順序依次是:應(yīng)用根目錄下的 node_modules應(yīng)用依賴框架路徑下的 node_modules當(dāng)前路徑下的 node_modules (主要是兼容單元測(cè)試場(chǎng)景)

插件規(guī)范

我們非常歡迎您貢獻(xiàn)新的插件,同時(shí)也希望您遵守下面一些規(guī)范:

  • 命名規(guī)范npm 包名以 egg- 開頭,且為全小寫,例如:egg-xx。比較長的詞組用中劃線:egg-foo-bar對(duì)應(yīng)的插件名使用小駝峰,小駝峰轉(zhuǎn)換規(guī)則以 npm 包名的中劃線為準(zhǔn) egg-foo-bar => fooBar對(duì)于可以中劃線也可以不用的情況,不做強(qiáng)制約定,例如:userservice(egg-userservice) 還是 user-service(egg-user-service) 都可以
  • package.json 書寫規(guī)范按照上面的文檔添加 eggPlugin 節(jié)點(diǎn)在 keywords 里加上 egg、egg-plugin、eggPlugin 等關(guān)鍵字,便于索引{ "name": "egg-view-nunjucks", "version": "1.0.0", "description": "view plugin for egg", "eggPlugin": { "name": "nunjucks", "dep": [ "security" ] }, "keywords": [ "egg", "egg-plugin", "eggPlugin", "egg-plugin-view", "egg-view", "nunjucks" ],}

為何不使用 npm 包名來做插件名?

Egg 是通過 eggPlugin.name 來定義插件名的,只在應(yīng)用或框架具備唯一性,也就是說多個(gè) npm 包可能有相同的插件名,為什么這么設(shè)計(jì)呢?

首先 Egg 插件不僅僅支持 npm 包,還支持通過目錄來找插件。在漸進(jìn)式開發(fā)章節(jié)提到如何使用這兩個(gè)配置來進(jìn)行代碼演進(jìn)。目錄對(duì)單元測(cè)試也比較友好。所以 Egg 無法通過 npm 的包名來做唯一性。

更重要的是 Egg 可以使用這種特性來做適配器。比如模板開發(fā)規(guī)范定義的插件名為 view,而存在 egg-view-nunjucks,egg-view-react 等插件,使用者只需要更換插件和修改模板,不需要?jiǎng)?Controller, 因?yàn)樗械哪0宀寮紝?shí)現(xiàn)了相同的 API。

將相同功能的插件賦予相同的插件名,具備相同的 API,可以快速切換。這在模板、數(shù)據(jù)庫等領(lǐng)域非常適用。


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

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)