前端插件式可擴展架構(gòu)設(shè)計心得
引子
大家可能不知道,鄙人之前人送外號“過度設(shè)計”。作為一個自信的研發(fā)人員,我總是希望我開發(fā)的系統(tǒng)可以解決之后所有的問題,用一套抽象可以覆蓋之后所有的擴展場景。當然最終往往能夠證明我的愚昧與思慮不足。先知曾說過“當一個東西什么都可以做時,他往往什么都做不了”。過度的抽象,過度的開放性,往往讓接觸他的人無所適從。講到這里你可能以為我要開始講過度設(shè)計這個主題了,但其實不然,我只是想以這個話題作為引子,和大家討論一下關(guān)于設(shè)計一個插件架構(gòu)我是如何思考的。
為什么需要插件
我們的軟件系統(tǒng)往往是要面向持續(xù)性的迭代的,在開發(fā)之初很難把所有需要支持的功能都想清楚,有時候還需要借助社區(qū)的力量去持續(xù)生產(chǎn)新的功能點,或者優(yōu)化已有的功能。這就需要我們的軟件系統(tǒng)具備一定的可擴展性。插件模式就是我們常常選用的方法。事實上,現(xiàn)存的大量軟件系統(tǒng)或工具都是使用插件方式來實現(xiàn)可擴展性的。比如大家最熟悉的小可愛——VSCode,其插件擁有量已經(jīng)超越了他的前輩 Atom,發(fā)布到市場中的數(shù)量目前是 24894 個。這些插件幫助我們定制編輯器的外觀或行為,增加額外功能,支持更多語法類型,大大提升了開發(fā)效率,同時也不斷拓展著自身的用戶群體。又或者是我們熟知的瀏覽器 Chrome,其核心競爭力之一也是豐富的插件市場,使其不論是對開發(fā)者還是普通使用者都已成為了不可獲取的一個工具。另外還有 Webpack、Nginx 等等各種工具,這邊就不一一贅述了。根據(jù)目前各個系統(tǒng)的插件設(shè)計,總結(jié)下來,我們創(chuàng)造插件主要是幫助我們解決以下兩種類型的問題:
- 為系統(tǒng)提供全新的能力
- 對系統(tǒng)現(xiàn)有能力進行定制
同時,在解決上面這類問題的時候做到:
- 插件代碼與系統(tǒng)代碼在工程上解耦,可以獨立開發(fā),并對開發(fā)者隔離框架內(nèi)部邏輯的復雜度
- 可動態(tài)化引入與配置
并且進一步地可以實現(xiàn):
- 通過對多個單一職責的插件進行組合,可以實現(xiàn)多種復雜邏輯,實現(xiàn)邏輯在復雜場景中的復用
這里提到的不管是提供新能力,還是進行能力定制,都既可以針對系統(tǒng)開發(fā)者本身,也可以針對三方開發(fā)者。結(jié)合上面的特征,我們嘗試簡單描述一下插件是什么吧。插件一般是可獨立完成某個或一系列功能的模塊。一個插件是否引入一定不會影響系統(tǒng)原本的正常運行(除非他和另一個插件存在依賴關(guān)系)。插件在運行時被引入系統(tǒng),由系統(tǒng)控制調(diào)度。一個系統(tǒng)可以存在復數(shù)個插件,這些插件可通過系統(tǒng)預定的方式進行組合。
怎么實現(xiàn)插件模式
插件模式本質(zhì)是一種設(shè)計思想,并沒有一個一成不變或者是萬金油的實現(xiàn)。但我們經(jīng)過長期的代碼實踐,其實已經(jīng)可以總結(jié)出一套方法論來指導插件體系的實現(xiàn),并且其中的一些實現(xiàn)細節(jié)是存在社區(qū)認可度比較高的“最佳實踐”的。本文在攥寫過程中也參考研讀了社區(qū)比較有名的一些項目的插件模式設(shè)計,包括但不僅限于 Koa、Webpack、Babel 等。
1. 解決問題前首先要定義問題
實現(xiàn)一套插件模式的第一步,永遠都是先定義出你需要插件化來幫助你解決的問題是什么。這往往是具體問題具體分析的,并總是需要你對當前系統(tǒng)的能力做一定程度的抽象。比如 Babel,他的核心功能是將一種語言的代碼轉(zhuǎn)化為另一種語言的代碼,他面臨的問題就是,他無法在設(shè)計時就窮舉語法類型,也不了解應該如何去轉(zhuǎn)換一種新的語法,因此需要提供相應的擴展方式。為此,他將自己的整體流程抽象成了 parse、transform、generate 三個步驟,并主要面向 parse 和 transform 提供了插件方式做擴展性支持。在 parse 這層,他核心要解決的問題是怎么去做分詞,怎么去做詞義語法的理解。在 transform 這層要做的則是,針對特定的語法樹結(jié)構(gòu),應該如何轉(zhuǎn)換成已知的語法樹結(jié)構(gòu)。很明顯,babel 他很清楚地定義了 parse 和 transform 兩層的插件要完成的事情。當然也有人可能會說,為什么我一定要定義清楚問題呢,插件體系本來就是為未來的不確定性服務的。這樣的說法對,也不對。計算機程序永遠是面向確定性的,我們需要有明確的輸入格式,明確的輸出格式,明確的可以依賴的能力。解決問題一定是在已知的一個框架內(nèi)的。這就引出了定義問題的一門藝術(shù)——如何賦予不確定以確定性,在不確定中尋找確定。說人話,就是“抽象”,這也是為什么最開始我會以過度設(shè)計作為引子。我在進行問題定義的時候,最常使用的是樣本分析法,這種方法并非捷徑,但總歸是有點效的。樣本分析法,就是先著眼于整理已知待解決的問題,將這些問題作為樣本嘗試分類和提取共性,從而形成一套抽象模式。然后再通過一些不確定但可能未來待解決的問題來測試,是否存在無法套用的情況。光說無用,下面我們還是以 babel 來舉個栗子,當然 babel 的抽象設(shè)計其實本質(zhì)就是有理論支撐的,在有現(xiàn)有理論已經(jīng)為你做好抽象時,還是盡量用現(xiàn)成的就好啦。
Babel 主要解決的問題是把新語法的代碼在不改變邏輯的情況下如何轉(zhuǎn)換成舊語法的代碼,簡單來說就是 code => code 的一個問題。但是需要轉(zhuǎn)什么,怎么轉(zhuǎn),這些是會隨著語法規(guī)范不斷更新變化的,因此需要使用插件模式來提升其未來可拓展性。我們當下要解決的問題也許是如何轉(zhuǎn)換 es6 新語法的內(nèi)容,以及 JSX 這種框架定制的 DSL。我們當然可以簡單地串聯(lián)一系列的正則處理,但是你會發(fā)現(xiàn)每一個插件都會有大量重復的識別分析類邏輯,不但加大了運行開銷,同時也很難避免互相影響導致的問題。Babel 選擇了把解析與轉(zhuǎn)換兩個動作拆開來,分別使用插件來實現(xiàn)。解析的插件要解決的問題是如何解析代碼,把 Code 轉(zhuǎn)化為 AST。這個問題對于不同的語言又可以拆解為相同的兩個事情,如何分詞,以及如何做詞義解析。當然詞義解析還能是如何構(gòu)筑上下文、如何產(chǎn)出 AST 節(jié)點等等,就不再細分了。最終形成的就是下圖這樣的模式,插件專注解決這幾個細分問題。轉(zhuǎn)換這邊的,則可分為如何查找固定 AST 節(jié)點,以及如何轉(zhuǎn)換,最終形成了 Visitor 模式,這里就不再詳細說了。那么我們再思考一下,如果未來 ES7、8、9(相對于設(shè)計場景的未來)等新語法出爐時,是不是依然可以使用這樣的模式去解決問題呢?看起來是可行的。
這就是前面所說的在不確定中尋找確定性,盡可能減少系統(tǒng)本身所面臨的不確定,通過拆解問題去限定問題。那么定義清楚問題,我們大概就完成了 1/3 的工作了,下面就是要正式開始思考如何設(shè)計了。
2. 插件架構(gòu)設(shè)計繞不開的幾大要素
插件模式的設(shè)計,可以簡單也可以復雜,我們不能指望一套插件模式適合所有的場景,如果真的可以的話,我也不用寫這篇文章了,給大家甩一個 npm 地址就完事了。這也是為什么在設(shè)計之前我們一定要先定義清楚問題。具體選擇什么方式實現(xiàn),一定是根據(jù)具體解決的問題權(quán)衡得出的。不過呢,這事終歸還是有跡可循,有法可依的。當正式開始設(shè)計我們的插件架構(gòu)時,我們所要思考的問題往往離不開以下幾點。整個設(shè)計過程其實就是為每一點選擇合適的方案,最后形成一套插件體系。這幾點分別是:
- 如何注入、配置、初始化插件
- 插件如何影響系統(tǒng)
- 插件輸入輸出的含義與可以使用的能力
- 復數(shù)個插件之間的關(guān)系是怎么樣的
下面就針對每個點詳細解釋一下
如何注入、配置、初始化插件
注入、配置、初始化其實是幾個分開的事情。但都同屬于 Before 的事情,所以就放在一起講了。先來講一講注入,其實本質(zhì)上就是如何讓系統(tǒng)感知到插件的存在。注入的方式一般可以分為 聲明式 和 編程式。聲明式就是通過配置信息,告訴系統(tǒng)應該去哪里去取什么插件,系統(tǒng)運行時會按照約定與配置去加載對應的插件。類似 Babel,可以通過在配置文件中填寫插件名稱,運行時就會去 modules 目錄下去查找對應的插件并加載。編程式的就是系統(tǒng)提供某種注冊 API,開發(fā)者通過將插件傳入 API 中來完成注冊。兩種對比的話,聲明式主要適合自己單獨啟動不用接入另一個軟件系統(tǒng)的場景,這種情況一般使用編程式進行定制的話成本會比較高,但是相對的,對于插件命名和發(fā)布渠道都會有一些限制。編程式則適合于需要在開發(fā)中被引入一個外部系統(tǒng)的情況。當然也可以兩種方式都進行支持。然后是插件配置,配置的主要目的是實現(xiàn)插件的可定制,因為一個插件在不同使用場景下,可能對于其行為需要做一些微調(diào),這時候如果每個場景都去做一個單獨的插件那就有點小題大作了。配置信息一般在注入時一起傳入,很少會支持注入后再進行重新配置。配置如何生效其實也和插件初始化的有點關(guān)聯(lián),初始化這事可以分為方式和時機兩個細節(jié)來講,我們先講講方式。常見的方式我大概列舉兩種。一種是工廠模式,一個插件暴露出來的是一個工廠函數(shù),由調(diào)用者或者插件架構(gòu)來將提供配置信息傳入,生成插件實例。另一種是運行時傳入,插件架構(gòu)在調(diào)度插件時會通過約定的上下文把配置信息給到插件。工廠模式咱們繼續(xù)拿 babel 來舉例吧。
- function declare<
- O extends Record<string, any>,
- R extends babelbabel.PluginObj = babel.PluginObj
- >(
- builder: (api: BabelAPI, options: O, dirname: string) => R,
- ): (api: object, options: O | null | undefined, dirname: string) => R;
上面代碼中的 builder 呢就是我們說到的工廠函數(shù)了,他最終將產(chǎn)出一個 Plugin 實例。builder 通過 options 獲取到配置信息,并且這里設(shè)計上還支持通過 api 設(shè)置一些運行環(huán)境信息,不過這并不是必須的,所以不細說了。簡化一下就是:
- type TPluginFactory<OPTIONS, PLUGIN> = (options: OPTIONS) => PLUGIN;
所以初始化呢,自然也可以是通過調(diào)用工廠函數(shù)初始化、初始化完成后再注入、不需要初始化三種。一般我們不選擇初始化完成后再注入,因為解耦的訴求,我們盡量在插件中只做聲明。是否使用工廠模式則看插件是否需要初始化這一步驟。大部分情況下,如果你決定不好,還是推薦優(yōu)先選擇工廠模式,可以應對后面更多復雜場景。初始化的時機也可以分為注入即初始化、統(tǒng)一初始化、運行時才初始化。很多情況下 注入即初始化、統(tǒng)一初始化 可以結(jié)合使用,具體的區(qū)分我嘗試通過一張表格來對應說明:
另外還有個問題也在這里提一下,在一些系統(tǒng)中,我們可能依賴許多插件組合來完成一件復雜的事情,為了屏蔽單獨引入并配置插件的復雜性,我們還會提供一種 Preset 的概念,去打包多個插件及其配置。使用者只需要引入 Preset 即可,不用關(guān)心里面有哪些插件。例如 Babel 在支持 react 語法時,其實要引入 syntax-jsx transform-react-jsx transform-react-display-name transform-react-pure-annotationsd 等多個插件,最終給到的是 preset-react這樣一個包。
插件如何影響系統(tǒng)
插件對系統(tǒng)的影響我們可以總結(jié)為三方面:行為、交互、展示。單獨一個插件可能只涉及其中一點。根據(jù)具體場景,有些方面也不必去影響,比如一個邏輯引擎類型的系統(tǒng),就大概率不需要展示這塊的東西啦。VSCode 插件大致覆蓋了這三個,所以我們可以拿一個簡單的插件來看下。這里我們選擇了 Clock in status bar 這個插件,這個插件的功能很簡單,就是在狀態(tài)欄加一個時鐘,或者你可以在編輯內(nèi)容內(nèi)快速插入當前時間。
整個項目里最主要的是下面這些內(nèi)容:
在 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 中則通過系統(tǒng)暴露的 API 創(chuàng)建了狀態(tài)欄的 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;
上述這個例子有點大塊兒,有點稍顯粗糙。那么總結(jié)下來我們看一下,在最開始我們提到的三個方面分別是如何體現(xiàn)的。
- UI:我們通過系統(tǒng) API 創(chuàng)建了一個狀態(tài)欄組件。我們通過配置信息構(gòu)建了一個 配置頁。
- 交互:我們通過注冊命令,增加了一項指令交互。
- 邏輯:我們新增了一項插入當前時間的能力邏輯。
所以我們在設(shè)計一個插件架構(gòu)時呢,也主要就從這三方面是否會被影響考慮即可。那么插件又怎么去影響系統(tǒng)呢,這個過程的前提是插件與系統(tǒng)間建立一份契約,約定好對接的方式。這份契約可以包含文件結(jié)構(gòu)、配置格式、API 簽名。還是結(jié)合 VSCode 的例子來看看:
- 文件結(jié)構(gòu):沿用了 NPM 的傳統(tǒng),約定了目錄下 package.json 承載元信息。
- 配置格式:約定了 main 的配置路徑作為代碼入口,私有字段 contributes 聲明命令與配置。
- API 簽名:約定了擴展必須提供 activate 和 deactivate 兩個接口。并提供了 vscode 下各項 API 來完成注冊。
UI 和 交互的定制邏輯,本質(zhì)上依賴系統(tǒng)本身的實現(xiàn)方式。這里重點講一下一般通過哪些模式,去調(diào)用插件中的邏輯。
直接調(diào)用
這個模式很直白,就是在系統(tǒng)的自身邏輯中,根據(jù)需要去調(diào)用注冊的插件中約定的 API,有時候插件本身就只是一個 API。比如上面例子中的 activate 和 deactivate 兩個接口。這種模式很常見,但調(diào)用處可能會關(guān)注比較多的插件處理相關(guān)邏輯。
鉤子機制(事件機制)
系統(tǒng)定義一系列事件,插件將自己的邏輯掛載在事件監(jiān)聽上,系統(tǒng)通過觸發(fā)事件進行調(diào)度。上面例子中的 clock.insertDateTime 命令也可以算是這類,是一個命令觸發(fā)事件。在這個機制上,webpack 是一個比較明顯的例子,我們來看一個簡單的 webpack 插件:
- // 一個 JavaScript 命名函數(shù)。
- function MyExampleWebpackPlugin() {
- };
- // 在插件函數(shù)的 prototype 上定義一個 `apply` 方法。
- MyExampleWebpackPlugin.prototype.apply = function(compiler) {
- // 指定一個掛載到 webpack 自身的事件鉤子。
- compiler.plugin('webpacksEventHook', function(compilation /* 處理 webpack 內(nèi)部實例的特定數(shù)據(jù)。*/, callback) {
- console.log("This is an example plugin!!!");
- // 功能完成后調(diào)用 webpack 提供的回調(diào)。
- callback();
- });
- };
這里的插件就將“在 console 打印 This is an example plugin!!!”這一行為注冊到了 webpacksEventHook 這個鉤子上,每當這個鉤子被觸發(fā)時,會調(diào)用一次這個邏輯。這種模式比較常見,webpack 也專門做了一份封裝服務這個模式,https://github.com/webpack/tapable[1]。通過定義了多種不同調(diào)度邏輯的鉤子,你可以在任何系統(tǒng)中植入這款模式,并能滿足你不同的調(diào)度需求(調(diào)度模式我們在下一部分中詳細講述)。
- const {
- SyncHook,
- SyncBailHook,
- SyncWaterfallHook,
- SyncLoopHook,
- AsyncParallelHook,
- AsyncParallelBailHook,
- AsyncSeriesHook,
- AsyncSeriesBailHook,
- AsyncSeriesWaterfallHook
- } = require("tapable");
鉤子機制適合注入點多,松耦合需求高的插件場景,能夠減少整個系統(tǒng)中插件調(diào)度的復雜度。成本就是額外引了一套鉤子機制了,不算高的成本,但也不是必要的。
使用者調(diào)度機制
這種模式本質(zhì)就是將插件提供的能力,統(tǒng)一作為系統(tǒng)的額外能力對外透出,最后又系統(tǒng)的開發(fā)使用者決定什么時候調(diào)用。例如 JQuery 的插件會注冊 fn 中的額外行為,或者是 Egg 的插件可以向上下文中注冊額外的接口能力等。這種模式我個人認為比較適合又需要定制更多對外能力,又需要對能力的出口做收口的場景。如果你希望用戶通過統(tǒng)一的模式調(diào)用你的能力,那大可嘗試一下。你可以嘗試使用新的 Proxy 特性來實現(xiàn)這種模式。不管是系統(tǒng)對插件的調(diào)用還是插件調(diào)用系統(tǒng)的能力,我們都是需要一個確定的輸入輸出信息的,這也是我們上面 API 簽名所覆蓋到的信息。我們會在下一部分專門講一講。
插件輸入輸出的含義與可以使用的能力
插件與系統(tǒng)間最重要的契約就是 API 簽名,這涉及了可以使用哪些 API,以及這些 API 的輸入輸出是什么。
可以使用的能力
是指插件的邏輯可以使用的公共工具,或者可以通過一些方式獲取或影響系統(tǒng)本身的狀態(tài)。能力的注入我們常使用的方式是參數(shù)、上下文對象或者工廠函數(shù)閉包。提供的能力類型主要有下面四種:
- 純工具:不影響系統(tǒng)狀態(tài)
- 獲取當前系統(tǒng)狀態(tài)
- 修改當前系統(tǒng)狀態(tài)
- API 形式注入功能:例如注冊 UI,注冊事件等
對于需要提供哪些能力,一般的建議是根據(jù)插件需要完成的工作,提供最小夠用范圍內(nèi)的能力,盡量減少插件破壞系統(tǒng)的可能性。在部分場景下,如果不能通過 API 有效控制影響范圍,可以考慮為插件創(chuàng)造沙箱環(huán)境,比如插件內(nèi)可能會調(diào)用 global 的接口等。
輸入輸出
當我們的插件是處在我們系統(tǒng)一個特定的處理邏輯流程中的(常見于直接調(diào)用機制或鉤子機制),我們的插件重點關(guān)注的就是輸入與輸出。此時的輸入與輸出一定是由邏輯流程本身所處的邏輯來決定的。輸入輸出的結(jié)構(gòu)需要與插件的職責強關(guān)聯(lián),盡量保證可序列化能力(為了防止過度膨脹以及本身的易讀性),并根據(jù)調(diào)度模式有額外的限制條件(下面會講)。如果你的插件輸入輸出過于復雜,可能要反思一下抽象是否過于粗粒度了。另外還需要對插件邏輯保證異常捕捉,防止對系統(tǒng)本身的破壞。還是 Babel Parser 那個例子。
- {
- parseExprAtom(refExpressionErrors: ?ExpressionErrors): N.Expression;
- getTokenFromCode(code: number): void; // 內(nèi)部再調(diào)用 finishToken 來影響邏輯
- updateContext(prevType: TokenType): void; // 內(nèi)部通過修改 this.state 來改變上下文信息
- }
意料之中的輸入,堅信不疑的輸出
復數(shù)個插件之間的關(guān)系是怎么樣的
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.
這里我們討論的是,在同一個擴展點上注入的插件,應該以什么形式做組合。常見的形式如下:
覆蓋式
只執(zhí)行最新注冊的邏輯,跳過原始邏輯
管道式
輸入輸出相互銜接,一般輸入輸出是同一個數(shù)據(jù)類型。
洋蔥圈式
在管道式的基礎(chǔ)上,如果系統(tǒng)核心邏輯處于中間,插件同時關(guān)注進與出的邏輯,則可以使用洋蔥圈模型。
這里也可以參考 koa 中的中間件調(diào)度模式 https://github.com/koajs/compose[2]
- const middleware = async (...params, next) => {
- // before
- await next();
- // after
- };
集散式
集散式就是每一個插件都會執(zhí)行,如果有輸出則最終將結(jié)果進行合并。這里的前提是存在方案,可以對執(zhí)行結(jié)果進行 merge。
另外調(diào)度還可以分為 同步 和 異步 兩個方式,主要看插件邏輯是否包含異步行為。同步的實現(xiàn)會簡單一點,不過如果你不能確定,那也可以考慮先把異步的一起考慮進來。類似 https://www.npmjs.com/package/neo-async[3] 這樣的工具可以很好地幫助你。如果你使用了 tapble,那里面已經(jīng)有相應的定義。另外還需要注意的細節(jié)是:
- 順序是先注冊先執(zhí)行,還是反過來,需要給到明確的解釋或一致的認知。
- 同一個插件重復注冊了該怎么處理。
總結(jié)
當你跟著這篇文章的思路,把這些問題都思考清楚之后,想必你的腦海中一定已經(jīng)有了一個插件架構(gòu)的雛形了。剩下的可能是結(jié)合具體問題,再通過一些設(shè)計模式去優(yōu)化開發(fā)者的體驗了。個人認為設(shè)計一個插件架構(gòu),是一定逃不開針對這些問題的思考的,而且只有去真正關(guān)注這些問題,才能避開炫技、過度設(shè)計等面向未來開發(fā)時時常會犯的錯誤。當然可能還差一些東西,一些推薦的實現(xiàn)方式也可能會過時,這些就歡迎大家?guī)兔χ刚病?nbsp;