插件式可擴展架構設計心得

引子

你們可能不知道,鄙人以前人送外號「過分設計」。做爲一個自信的研發人員,我老是但願我開發的系統能夠解決以後全部的問題,用一套抽象能夠覆蓋以後全部的擴展場景。固然最終每每可以證實個人愚昧與思慮不足。先知曾說過「當一個東西什麼均可以作時,他每每什麼都作不了」。過分的抽象,過分的開放性,每每讓接觸他的人無所適從。講到這裏你可能覺得我要開始講過分設計這個主題了,但其實否則,我只是想以這個話題做爲引子,和你們討論一下關於設計一個插件架構我是如何思考的。
image.pngjavascript

爲何須要插件

咱們的軟件系統每每是要面向持續性的迭代的,在開發之初很難把全部須要支持的功能都想清楚,有時候還須要藉助社區的力量去持續生產新的功能點,或者優化已有的功能。這就須要咱們的軟件系統具有必定的可擴展性。插件模式就是咱們經常選用的方法。java

事實上,現存的大量軟件系統或工具都是使用插件方式來實現可擴展性的。好比你們最熟悉的小可愛——VSCode,其插件擁有量已經超越了他的前輩 Atom,發佈到市場中的數量目前是 24894 個。這些插件幫助咱們定製編輯器的外觀或行爲,增長額外功能,支持更多語法類型,大大提高了開發效率,同時也不斷拓展着自身的用戶羣體。又或者是咱們熟知的瀏覽器 Chrome,其核心競爭力之一也是豐富的插件市場,使其不管是對開發者仍是普通使用者都已成爲了避免可獲取的一個工具。另外還有 Webpack、Nginx 等等各類工具,這邊就不一一贅述了。node

根據目前各個系統的插件設計,總結下來,咱們創造插件主要是幫助咱們解決如下兩種類型的問題:react

  • 爲系統提供全新的能力
  • 對系統現有能力進行定製

同時,在解決上面這類問題的時候作到:webpack

  • 插件代碼與系統代碼在工程上解耦,能夠獨立開發,並對開發者隔離框架內部邏輯的複雜度
  • 可動態化引入與配置

而且進一步地能夠實現:git

  • 經過對多個單一職責的插件進行組合,能夠實現多種複雜邏輯,實現邏輯在複雜場景中的複用

這裏提到的無論是提供新能力,仍是進行能力定製,都既能夠針對系統開發者自己,也能夠針對三方開發者。es6

結合上面的特徵,咱們嘗試簡單描述一下插件是什麼吧。插件通常是可獨立完成某個或一系列功能的模塊。一個插件是否引入必定不會影響系統本來的正常運行(除非他和另外一個插件存在依賴關係)。插件在運行時被引入系統,由系統控制調度。一個系統能夠存在複數個插件,這些插件可經過系統預約的方式進行組合。github

怎麼實現插件模式

插件模式本質是一種設計思想,並無一個一成不變或者是萬金油的實現。但咱們通過長期的代碼實踐,其實已經能夠總結出一套方法論來指導插件體系的實現,而且其中的一些實現細節是存在社區承認度比較高的「最佳實踐」的。本文在攥寫過程當中也參考研讀了社區比較有名的一些項目的插件模式設計,包括但不只限於 Koa、Webpack、Babel 等。web

1. 解決問題前首先要定義問題

實現一套插件模式的第一步,永遠都是先定義出你須要插件化來幫助你解決的問題是什麼。這每每是具體問題具體分析的,並老是須要你對當前系統的能力作必定程度的抽象。好比 Babel,他的核心功能是將一種語言的代碼轉化爲另外一種語言的代碼,他面臨的問題就是,他沒法在設計時就窮舉語法類型,也不瞭解應該如何去轉換一種新的語法,所以須要提供相應的擴展方式。爲此,他將本身的總體流程抽象成了 parse、transform、generate 三個步驟,並主要面向 parse 和 transform 提供了插件方式作擴展性支持。在 parse 這層,他核心要解決的問題是怎麼去作分詞,怎麼去作詞義語法的理解。在 transform 這層要作的則是,針對特定的語法樹結構,應該如何轉換成已知的語法樹結構。typescript

很明顯,babel 他很清楚地定義了 parse 和 transform 兩層的插件要完成的事情。固然也有人可能會說,爲何我必定要定義清楚問題呢,插件體系原本就是爲將來的不肯定性服務的。這樣的說法對,也不對。計算機程序永遠是面向肯定性的,咱們須要有明確的輸入格式,明確的輸出格式,明確的能夠依賴的能力。解決問題必定是在已知的一個框架內的。這就引出了定義問題的一門藝術——如何賦予不肯定以肯定性,在不肯定中尋找肯定。說人話,就是「抽象」,這也是爲何最開始我會以過分設計做爲引子。

我在進行問題定義的時候,最常使用的是樣本分析法,這種方法並不是捷徑,但總歸是有點效的。樣本分析法,就是先着眼於整理已知待解決的問題,將這些問題做爲樣本嘗試分類和提取共性,從而造成一套抽象模式。而後再經過一些不肯定但可能將來待解決的問題來測試,是否存在沒法套用的狀況。光說無用,下面咱們仍是以 babel 來舉個栗子,固然 babel 的抽象設計其實本質就是有理論支撐的,在有現有理論已經爲你作好抽象時,仍是儘可能用現成的就好啦。

image.png

Babel 主要解決的問題是把新語法的代碼在不改變邏輯的狀況下如何轉換成舊語法的代碼,簡單來講就是 code => code 的一個問題。可是須要轉什麼,怎麼轉,這些是會隨着語法規範不斷更新變化的,所以須要使用插件模式來提高其將來可拓展性。咱們當下要解決的問題也許是如何轉換 es6 新語法的內容,以及 JSX 這種框架定製的 DSL。咱們固然能夠簡單地串聯一系列的正則處理,可是你會發現每個插件都會有大量重複的識別分析類邏輯,不但加大了運行開銷,同時也很難避免互相影響致使的問題。Babel 選擇了把解析與轉換兩個動做拆開來,分別使用插件來實現。解析的插件要解決的問題是如何解析代碼,把 Code 轉化爲 AST。這個問題對於不一樣的語言又能夠拆解爲相同的兩個事情,如何分詞,以及如何作詞義解析。固然詞義解析還能是如何構築上下文、如何產出 AST 節點等等,就再也不細分了。最終造成的就是下圖這樣的模式,插件專一解決這幾個細分問題。轉換這邊的,則可分爲如何查找固定 AST 節點,以及如何轉換,最終造成了 Visitor 模式,這裏就再也不詳細說了。那麼咱們再思考一下,若是將來 ES七、八、9(相對於設計場景的將來)等新語法出爐時,是否是依然可使用這樣的模式去解決問題呢?看起來是可行的。
image.png
這就是前面所說的在不肯定中尋找肯定性,儘量減小系統自己所面臨的不肯定,經過拆解問題去限定問題。

那麼定義清楚問題,咱們大概就完成了 1/3 的工做了,下面就是要正式開始思考如何設計了。

2. 插件架構設計繞不開的幾大要素

插件模式的設計,能夠簡單也能夠複雜,咱們不能期望一套插件模式適合全部的場景,若是真的能夠的話,我也不用寫這篇文章了,給你們甩一個 npm 地址就完事了。這也是爲何在設計以前咱們必定要先定義清楚問題。具體選擇什麼方式實現,必定是根據具體解決的問題權衡得出的。不過呢,這事終歸仍是有跡可循,有法可依的。

當正式開始設計咱們的插件架構時,咱們所要思考的問題每每離不開如下幾點。整個設計過程其實就是爲每一點選擇合適的方案,最後造成一套插件體系。這幾點分別是:

  • 如何注入、配置、初始化插件
  • 插件如何影響系統
  • 插件輸入輸出的含義與可使用的能力
  • 複數個插件之間的關係是怎麼樣的

下面就針對每一個點詳細解釋一下

如何注入、配置、初始化插件

注入、配置、初始化實際上是幾個分開的事情。但都同屬於 Before 的事情,因此就放在一塊兒講了。

先來說一講注入,其實本質上就是如何讓系統感知到插件的存在。注入的方式通常能夠分爲 聲明式 和 編程式。聲明式就是經過配置信息,告訴系統應該去哪裏去取什麼插件,系統運行時會按照約定與配置去加載對應的插件。相似 Babel,能夠經過在配置文件中填寫插件名稱,運行時就會去 modules 目錄下去查找對應的插件並加載。編程式的就是系統提供某種註冊 API,開發者經過將插件傳入 API 中來完成註冊。兩種對比的話,聲明式主要適合本身單獨啓動不用接入另外一個軟件系統的場景,這種狀況通常使用編程式進行定製的話成本會比較高,可是相對的,對於插件命名和發佈渠道都會有一些限制。編程式則適合於須要在開發中被引入一個外部系統的狀況。固然也能夠兩種方式都進行支持。

而後是插件配置,配置的主要目的是實現插件的可定製,由於一個插件在不一樣使用場景下,可能對於其行爲須要作一些微調,這時候若是每一個場景都去作一個單獨的插件那就有點小題大做了。配置信息通常在注入時一塊兒傳入,不多會支持注入後再進行從新配置。配置如何生效其實也和插件初始化的有點關聯,初始化這事能夠分爲方式和時機兩個細節來說,咱們先講講方式。常見的方式我大概列舉兩種。一種是工廠模式,一個插件暴露出來的是一個工廠函數,由調用者或者插件架構來將提供配置信息傳入,生成插件實例。另外一種是運行時傳入,插件架構在調度插件時會經過約定的上下文把配置信息給到插件。工廠模式我們繼續拿 babel 來舉例吧。

function declare<
    O extends Record<string, any>,
    R extends babel.PluginObj = babel.PluginObj
>(
    builder: (api: BabelAPI, options: O, dirname: string) => R,
): (api: object, options: O | null | undefined, dirname: string) => R;

上面代碼中的 builder 呢就是咱們說到的工廠函數了,他最終將產出一個 Plugin 實例。builder 經過 options 獲取到配置信息,而且這裏設計上還支持經過 api 設置一些運行環境信息,不過這並非必須的,因此不細說了。簡化一下就是:

type TPluginFactory<OPTIONS, PLUGIN> = (options: OPTIONS) => PLUGIN;

因此初始化呢,天然也能夠是經過調用工廠函數初始化、初始化完成後再注入、不須要初始化三種。通常咱們不選擇初始化完成後再注入,由於解耦的訴求,咱們儘可能在插件中只作聲明。是否使用工廠模式則看插件是否須要初始化這一步驟。大部分狀況下,若是你決定很差,仍是推薦優先選擇工廠模式,能夠應對後面更多複雜場景。初始化的時機也能夠分爲注入即初始化、統一初始化、運行時才初始化。不少狀況下 注入即初始化、統一初始化 能夠結合使用,具體的區分我嘗試經過一張表格來對應說明:

注入即初始化 統一初始化 運行時才初始化
是不是純邏輯型 均可以使用
是否須要預掛載或修改系統 不是
插件初始化是否有相互依賴關係 不是 不是
插件初始化是否有性能開銷 均可以使用 不是

另外還有個問題也在這裏提一下,在一些系統中,咱們可能依賴許多插件組合來完成一件複雜的事情,爲了屏蔽單獨引入並配置插件的複雜性,咱們還會提供一種 Preset 的概念,去打包多個插件及其配置。使用者只須要引入 Preset 便可,不用關內心面有哪些插件。例如 Babel 在支持 react 語法時,其實要引入 syntax-jsx transform-react-jsx transform-react-display-name transform-react-pure-annotationsd 等多個插件,最終給到的是 preset-react 這樣一個包。

插件如何影響系統

插件對系統的影響咱們能夠總結爲三方面:行爲、交互、展現。單獨一個插件可能只涉及其中一點。根據具體場景,有些方面也沒必要去影響,好比一個邏輯引擎類型的系統,就大機率不須要展現這塊的東西啦。

VSCode 插件大體覆蓋了這三個,因此咱們能夠拿一個簡單的插件來看下。這裏咱們選擇了 Clock in status bar 這個插件,這個插件的功能很簡單,就是在狀態欄加一個時鐘,或者你能夠在編輯內容內快速插入當前時間。
image.png
image.png
整個項目裏最主要的是下面這些內容:
image.png
在 package.json 中,經過擴展的 contributes 字段爲插件註冊了一個命令,和一個配置菜單。

"main": "./extension", // 入口文件地址
"contributes": {
  "commands": [{
    "command": "clock.insertDateTime",
    "title": "Clock: Insert date and time"
  }],
  "configuration": {
    "type": "object",
    "title": "Clock configuration",
    "properties": {
      "clock.dateFormat": {
        "type": "string",
        "default": "hh:MM TT",
        "description": "Clock: Date format according to https://github.com/felixge/node-dateformat"
      }
    }
  }
},

在入口文件 extension.js 中則經過系統暴露的 API 建立了狀態欄的 UI,並註冊了命令的具體行爲。

'use strict';

// The module 'vscode' contains the VS Code extensibility API
// Import the module and reference it with the alias vscode in your code below
const
  clockService = require('./clockservice'),
  ClockStatusBarItem = require('./clockstatusbaritem'),
  vscode = require('vscode');

// this method is called when your extension is activated
// your extension is activated the very first time the command is executed
function activate(context) {
  // Use the console to output diagnostic information (console.log) and errors (console.error)
  // This line of code will only be executed once when your extension is activated

  // The command has been defined in the package.json file
  // Now provide the implementation of the command with  registerCommand
  // The commandId parameter must match the command field in package.json
  context.subscriptions.push(new ClockStatusBarItem());

  context.subscriptions.push(vscode.commands.registerTextEditorCommand('clock.insertDateTime', (textEditor, edit) => {
    textEditor.selections.forEach(selection => {
      const
        start = selection.start,
        end = selection.end;

      if (start.line === end.line && start.character === end.character) {
        edit.insert(start, clockService());
      } else {
        edit.replace(selection, clockService());
      }
    });
  }));
}

exports.activate = activate;

// this method is called when your extension is deactivated
function deactivate() {
}

exports.deactivate = deactivate;

上述這個例子有點大塊兒,有點稍顯粗糙。那麼總結下來咱們看一下,在最開始咱們提到的三個方面分別是如何體現的。

  • UI:咱們經過系統 API 建立了一個狀態欄組件。咱們經過配置信息構建了一個 配置頁。
  • 交互:咱們經過註冊命令,增長了一項指令交互。
  • 邏輯:咱們新增了一項插入當前時間的能力邏輯。

因此咱們在設計一個插件架構時呢,也主要就從這三方面是否會被影響考慮便可。那麼插件又怎麼去影響系統呢,這個過程的前提是插件與系統間創建一份契約,約定好對接的方式。這份契約能夠包含文件結構、配置格式、API 簽名。仍是結合 VSCode 的例子來看看:

  • 文件結構:沿用了 NPM 的傳統,約定了目錄下 package.json 承載元信息。
  • 配置格式:約定了 main 的配置路徑做爲代碼入口,私有字段 contributes 聲明命令與配置。
  • API 簽名:約定了擴展必須提供 activate 和 deactivate 兩個接口。並提供了 vscode 下各項 API 來完成註冊。

UI 和 交互的定製邏輯,本質上依賴系統自己的實現方式。這裏重點講一下通常經過哪些模式,去調用插件中的邏輯。

直接調用

這個模式很直白,就是在系統的自身邏輯中,根據須要去調用註冊的插件中約定的 API,有時候插件自己就只是一個 API。好比上面例子中的 activate 和 deactivate 兩個接口。這種模式很常見,但調用處可能會關注比較多的插件處理相關邏輯。

鉤子機制(事件機制)

系統定義一系列事件,插件將本身的邏輯掛載在事件監聽上,系統經過觸發事件進行調度。上面例子中的 clock.insertDateTime 命令也能夠算是這類,是一個命令觸發事件。在這個機制上,webpack 是一個比較明顯的例子,咱們來看一個簡單的 webpack 插件:

// 一個 JavaScript 命名函數。
function MyExampleWebpackPlugin() {

};

// 在插件函數的 prototype 上定義一個 `apply` 方法。
MyExampleWebpackPlugin.prototype.apply = function(compiler) {
  // 指定一個掛載到 webpack 自身的事件鉤子。
  compiler.plugin('webpacksEventHook', function(compilation /* 處理 webpack 內部實例的特定數據。*/, callback) {
    console.log("This is an example plugin!!!");

    // 功能完成後調用 webpack 提供的回調。
    callback();
  });
};

這裏的插件就將「在 console 打印 This is an example plugin!!!」這一行爲註冊到了 webpacksEventHook 這個鉤子上,每當這個鉤子被觸發時,會調用一次這個邏輯。這種模式比較常見,webpack 也專門作了一份封裝服務這個模式,https://github.com/webpack/tapable。經過定義了多種不一樣調度邏輯的鉤子,你能夠在任何系統中植入這款模式,並能知足你不一樣的調度需求(調度模式咱們在下一部分中詳細講述)。

const {
    SyncHook,
    SyncBailHook,
    SyncWaterfallHook,
    SyncLoopHook,
    AsyncParallelHook,
    AsyncParallelBailHook,
    AsyncSeriesHook,
    AsyncSeriesBailHook,
    AsyncSeriesWaterfallHook
} = require("tapable");

tapable
鉤子機制適合注入點多,鬆耦合需求高的插件場景,可以減小整個系統中插件調度的複雜度。成本就是額外引了一套鉤子機制了,不算高的成本,但也不是必要的。

使用者調度機制

這種模式本質就是將插件提供的能力,統一做爲系統的額外能力對外透出,最後又系統的開發使用者決定何時調用。例如 JQuery 的插件會註冊 fn 中的額外行爲,或者是 Egg 的插件能夠向上下文中註冊額外的接口能力等。這種模式我我的認爲比較適合又須要定製更多對外能力,又須要對能力的出口作收口的場景。若是你但願用戶經過統一的模式調用你的能力,那大可嘗試一下。你能夠嘗試使用新的 Proxy 特性來實現這種模式。

無論是系統對插件的調用仍是插件調用系統的能力,咱們都是須要一個肯定的輸入輸出信息的,這也是咱們上面 API 簽名所覆蓋到的信息。咱們會在下一部分專門講一講。

插件輸入輸出的含義與可使用的能力

插件與系統間最重要的契約就是 API 簽名,這涉及了可使用哪些 API,以及這些 API 的輸入輸出是什麼。

可使用的能力

是指插件的邏輯可使用的公共工具,或者能夠經過一些方式獲取或影響系統自己的狀態。能力的注入咱們常使用的方式是參數、上下文對象或者工廠函數閉包。

提供的能力類型主要有下面四種:

  • 純工具:不影響系統狀態
  • 獲取當前系統狀態
  • 修改當前系統狀態
  • API 形式注入功能:例如註冊 UI,註冊事件等

對於須要提供哪些能力,通常的建議是根據插件須要完成的工做,提供最小夠用範圍內的能力,儘可能減小插件破壞系統的可能性。在部分場景下,若是不能經過 API 有效控制影響範圍,能夠考慮爲插件創造沙箱環境,好比插件內可能會調用 global 的接口等。

輸入輸出

當咱們的插件是處在咱們系統一個特定的處理邏輯流程中的(常見於直接調用機制或鉤子機制),咱們的插件重點關注的就是輸入與輸出。此時的輸入與輸出必定是由邏輯流程自己所處的邏輯來決定的。輸入輸出的結構須要與插件的職責強關聯,儘可能保證可序列化能力(爲了防止過分膨脹以及自己的易讀性),並根據調度模式有額外的限制條件(下面會講)。若是你的插件輸入輸出過於複雜,可能要反思一下抽象是否過於粗粒度了。

另外還須要對插件邏輯保證異常捕捉,防止對系統自己的破壞。

仍是 Babel Parser 那個例子。

{
  parseExprAtom(refExpressionErrors: ?ExpressionErrors): N.Expression;
  getTokenFromCode(code: number): void; // 內部再調用 finishToken 來影響邏輯
  updateContext(prevType: TokenType): void; // 內部經過修改 this.state 來改變上下文信息
}

意料之中的輸入,堅信不疑的輸出

複數個插件之間的關係是怎麼樣的

Each plugin should only do a small amount of work, so you can connect them like building blocks. You may need to combine a bunch of them to get the desired result.

這裏咱們討論的是,在同一個擴展點上注入的插件,應該以什麼形式作組合。常見的形式以下:

覆蓋式

只執行最新註冊的邏輯,跳過原始邏輯
image.png

管道式

輸入輸出相互銜接,通常輸入輸出是同一個數據類型。
image.png

洋蔥圈式

在管道式的基礎上,若是系統核心邏輯處於中間,插件同時關注進與出的邏輯,則可使用洋蔥圈模型。
image.png
這裏也能夠參考 koa 中的中間件調度模式 https://github.com/koajs/compose

const middleware = async (...params, next) => {
  // before
  await next();
  // after
};

集散式

集散式就是每個插件都會執行,若是有輸出則最終將結果進行合併。這裏的前提是存在方案,能夠對執行結果進行 merge。
image.png

另外調度還能夠分爲 同步 和 異步 兩個方式,主要看插件邏輯是否包含異步行爲。同步的實現會簡單一點,不過若是你不能肯定,那也能夠考慮先把異步的一塊兒考慮進來。相似 https://www.npmjs.com/package/neo-async 這樣的工具能夠很好地幫助你。若是你使用了 tapble,那裏面已經有相應的定義。

另外還須要注意的細節是:

  • 順序是先註冊先執行,仍是反過來,須要給到明確的解釋或一致的認知。
  • 同一個插件重複註冊了該怎麼處理。

總結

當你跟着這篇文章的思路,把這些問題都思考清楚以後,想必你的腦海中必定已經有了一個插件架構的雛形了。剩下的多是結合具體問題,再經過一些設計模式去優化開發者的體驗了。我的認爲設計一個插件架構,是必定逃不開針對這些問題的思考的,並且只有去真正關注這些問題,才能避開炫技、過分設計等面向將來開發時時常會犯的錯誤。固然可能還差一些東西,一些推薦的實現方式也可能會過期,這些就歡迎你們幫忙指正啦。

做者:ES2049 / armslave00
文章可隨意轉載,但請保留此原文連接。 很是歡迎有激情的你加入 ES2049 Studio,簡歷請發送至 caijun.hcj@alibaba-inc.com 。
相關文章
相關標籤/搜索