當我們說外掛系統的時候,我們在說什麼

雲音樂技術團隊發表於2023-03-15
本文作者:月陌

從一個吸塵器說起

說起外掛系統,大家或許會對這個概念感到陌生,但其實不然,這個看似很抽象的概念其實在我們日常生活中有著很多很直觀體現。最近我準備購置一臺吸塵器,我發現現在的吸塵器已經越來越高階了,一個吸塵器能實現拖地,除蟎等眾多功能,而這一切,都只需要你透過更換不同的吸頭,就能實現。從計算機的視角來看,這個吸塵器其實就是一個功能完備的外掛系統,這些吸頭,就是他的外掛生態。

那這樣做的好處是什麼呢?

  • 對於使用者來說:使用更為便利,原本需要同時購買很多產品才能實現的功能,現在只購買這一個吸塵器就擁有了。
  • 對於廠家來說,那好處就更多了:

    • 一方面,降低了實現複雜度,更利於分工協作,核心部門可以專心研發吸塵器的基礎功能,可以做到更大吸力,更小噪音,增加自己產品的競爭力,至於吸頭可以交給其他部門負責。
    • 另一方面還能利用生態,讓其他廠家也參與其中幫自己生產各種能力的吸頭(這方面戴森就做的特別不錯,網上戴森相關的三方吸頭特別多),進一步擴大自己的品牌影響力。

正是因為有著這麼多好處,所以現在大到汽車,無人機,小到吸塵器,或多或少都會有一些功能選裝配件,這無一不是外掛系統在生活中的體現,那回到我們的計算機世界,外掛系統更是被廣泛應用在各種工具中,例如:Umi,Egg,JQuery,WordPress,Babel,Webpack……

當我們翻開 Umi 的官網,可以在顯眼位置看到下面這段話:

Umi 以路由為基礎的,同時支援配置式路由和約定式路由,保證路由的功能完備,並以此進行功能擴充套件。然後配以生命週期完善的外掛體系,覆蓋從原始碼到構建產物的每個生命週期,支援各種功能擴充套件和業務需求。

從上面那段話我們可以看出兩個點:

  • 以路由為基礎
  • 外掛體系

所以 Umi 其實就是一個以路由為基礎的外掛系統。它的核心功能是路由,其他的功能都是以外掛的形式補充的,比如,你需要用 antd 相關的內容,可以引入 plugin-antd, 如果要使用 dva,可以引入 plugin-dva,想使用封裝好的請求方法,可以引入 plugin-request ……

透過上面的介紹,相信各位心中對外掛系統的已經有了一些自己的認知了,現在讓我們來給外掛系統下個定義。

什麼是外掛系統

說起外掛系統,先讓我們對外掛的定義做個說明,我在網上找了很多的資料,大家說法不一,大多都是以應用程式的維度說明的,根據 維基百科(wikipedia) )的解釋:

在計算機技術中,外掛是一種向現有計算機程式新增特定功能的軟體元件。當一個程式支援外掛時,它支援自定義

外掛必須依賴於應用程式才能發揮自身功能,僅靠外掛是無法正常執行的。相反地,應用程式並不需要依賴外掛就可以執行,這樣一來,外掛就可以載入到應用程式上並且動態更新而不會對應用程式造成任何改變。

但是我理解的外掛更多是一種設計形態,他可以有很多展示形式。最接近我心中對外掛的定義是 handling-plugins-in-php 這篇文章中寫的這句:

所謂外掛是一種能允許非核心程式碼在執行時修改應用程式的處理方式。

根據上面對外掛化的一些介紹,我們可以給外掛系統下一個定義:

外掛係數是一個由實現了外掛化的核心模組,和其配套的外掛模組組成的一種應用組織形式, 其中核心模組能獨立執行並實現某種特定的功能,外掛模組需要在核心模組上執行,並能在應用程式執行時修改程式的處理方式,從而增強或改變程式的處理結果。

其中外掛化的實現大多都是從設計模式演化而來的,大概可以參考的有:觀察者模式,策略模式,裝飾器模式,中介模式,責任鏈模式等等。

外掛系統一般由兩個部分組成:核心系統,外掛模組

img

注:有時候,我們也會稱外掛為:附加元件(add-on),模組(module),擴充套件(extension),他們從某種意義上來說就是外掛。

核心模組

核心模組顧名思義一般是指這個系統的核心功能,它定義了系統的執行方式和基本的業務邏輯。核心系統一般不依賴於任何外掛。

比如上面說的 Umi 的核心就是路由

babel 的核心能力就是語法分析(將 js 檔案轉換 AST)

Webpack 的核心繫統就是打包構建能力。

外掛模組

外掛模組就是遵循對應約定或標準開發的周邊配套的配套設施,外掛模組可能是一個 js 檔案,可能是一個配置檔案,也可能是更復雜的一個應用系統,這完全取決於對應的「核心系統」是如何約定和載入外掛的。

為什麼要做外掛化

外掛化最重要的意義就是提升整個系統的可擴充套件性,用一句話來概:外掛化能將不斷擴張的功能分散在外掛中,內部集中維護核心不變邏輯。

它有以下幾個顯著的好處:

  1. 維護成本低:只需要關注核心系統的穩定性就行了。
  2. 易於協同開發:由於核心系統和外掛系統完全是單向依賴關係,而且外掛之間基本彼此獨立,減少了「溝通協作」成本,易於團隊和第三方開發人員能夠擴充套件應用程式,這能很好的利用社群生態。
  3. 降低應用程式(核心包)大小:透過不載入未使用的功能來減小應用程式的大小,大大增加了核心包適用範圍。
  4. 輕鬆增加新功能:在工具開發之初開發者很難就想全應用程式的所有功能,如果把所有功能都寫入核心包可能會帶來巨大的升級維護成本。但是透過外掛系統這種方式,就可以在不影響核心功能基礎上快速新增新的功能。

外掛的形式

總的來說主要有下面幾種外掛化形式(個人整理)

  • 約定式外掛
  • 注入式外掛
  • 事件式外掛
  • 插槽式外掛

約定式外掛

這個是最簡單的,只要我們做好約定,就可以很輕鬆的實現,約定式外掛一般依賴核心系統載入自身

如果約定比較簡單,只是一些配置式的約定,就完全可以使用簡單的 JSON 配置來實現。比如 cms 腳手架 中的每個模板就可以理解為一個外掛。我們透過不同的配置約定了模板的展示形式,模板位置,互動問題…… 剩下的就可以由使用者完全按自己的需要建立一個新的模板。

image-20210822214015009

但是純 JSON 能表達的資訊量還是有限的。所以通常為了實現更復雜的外掛能力,我們也會通常會需要使用函式,比如我們約定一個外掛結構是 {name, action},action 可以指定一個 js 函式

module.exports = {
  "name": "increase",
  "action": (data) => data.value + 1
}

再更進一步,透過約定的目錄結構來區分功能,比較有代表性的就是Egg,它透過目錄結構區分出controllermiddlewareschedule……,不同的目錄結構天然對應著不同的生命週期。比如在schedule目錄下定義的檔案就會自動當作定時任務執行,其中scheduletask方法的結構都是約定好的。

module.exports = {
  schedule: {
    interval: '1m', // 1 分鐘間隔
    type: 'all', // 指定所有的 worker 都需要執行
  },
  async task(ctx) {
    const res = await ctx.curl('http://www.api.com/cache', {
      dataType: 'json',
    });
    ctx.app.cache = res.data;
  },
};
舉例:Egg

注入式外掛

這類外掛通常是需要使用核心系統提供的API生命週期,這類外掛通常就是一個函式,該函式會接收一個 API 集合,比如Umi,它就是很標準的注入式外掛,它的外掛形式是一個函式,接收一個 api 集合:

export default (api) => {
  // your plugin code here
};

跟約定式外掛不同的是,這類外掛,通常會主動呼叫相關 API 方法把自己的函式或能力注入。

export default function (api: IApi) {
  api.logger.info('use plugin');

  api.modifyHTML(($) => {
    $('body').prepend(`<h1>hello Umi plugin</h1>`);
    return $;
  });

}
舉例:webpack, egg, babel

事件外掛化

顧名思義,透過事件的方式提供外掛開發的能力,最常見比如 dom 事件:

document.on("focus", callback);

雖然只是普通的業務程式碼,但這本質上就是外掛機制:

  • 可擴充:可以重複定義 N 個 focus 事件相互獨立。
  • 事件相互獨立:每個 callback 之間互相不受影響。

也可以解釋為,事件機制就是在一些階段放出鉤子,允許使用者程式碼擴充整體框架的生命週期。

service worker 就更明顯,業務程式碼幾乎完全由一堆時間監聽構成,比如 install 時機,隨時可以新增一個監聽,將 install 時機進行 delay,而不需要侵入其他程式碼。

舉例:service workerdom events

插槽外掛化

這種外掛通常是對 UI 元素的擴充套件,最經典的代表就是 React 和 Vue 了,它們的元件化其實就是外掛的另一種表現。

While React itself is a plugin system in a way, it focuses on the abstraction of the UI.

一個帶插槽的元件就可以理解為一個核心繫統,而插槽就是提供出的外掛入口。這樣的好處是實現了 UI 解耦,父元素就不需要知道子元素的具體例項,它只用提供合適的插槽位置就行。

function Menu({ plugins }) {
    return <div clssName="my-menu">
        {plugins.map(p => <div clssName="my-menuitem" style={p.style}>{p.name}</div>)}
    <div>
}

這種方式最常見的使用領域就是 CMS 系統,靜態頁面生成器……

當然有些情況看似是例外,比如 Tree 的查詢功能,就依賴子元素 TreeNode 的配合。但它依賴的是基於某個約定的子元素,而不是具體子元素的例項,父級只需要與子元素約定介面即可。真正需要關心物理結構的恰恰是子元素,比如插入到 Tree 子元素節點的 TreeNode 必須實現某些方法,如果不滿足這個功能,就不要把元件放在 Tree 下面;而 Tree 的實現就無需顧及啦,只需要預設子元素有哪些約定即可。

舉例:React, gaea-editor。

如何實現外掛化?

一般來說,要實現一個外掛化能力,核心系統需要提供以下能力:

  • 「必須」確定外掛註冊載入方式
  • 「必須」 確定核心系統的生命週期和相關相關暴露 API
  • 「非必須」對外掛暴露合適範圍的上下文,並對不同場景的上下文做隔離(通常是更復雜的外掛系統,比如 vscode,chrome 外掛)
  • 「非必須」確定外掛依賴關係
  • 「非必須」確定外掛和核心繫統的通訊機制

外掛大致流程

一個外掛系統大致流程如下:首先會經歷解析外掛的過程,主要是要找到所有需要載入的外掛。然後將這些外掛都繫結到特定的生命週期或事件上。最後在合適的時機處理和呼叫對應的外掛就行了。

pluginsystem-Page-2

外掛解析(引入)方式

以下列舉了一些常用的外掛引入方式:

  • 透過 npm 名:比如只要 npm 包符合某個字首,就會自動註冊為外掛,例:Umi 約定,只要 npm 包的名稱使用 @umijs 或者 umi-plugin 開頭就會自動載入成外掛。
  • 透過檔名:比如專案中存在 xx.plugin.ts 會自動做到外掛引用,這一般作為輔助方案使用。
  • 透過程式碼:這個很基礎,就是透過程式碼 require 就行,比如 babel-polyfill,不過這個要求外掛執行邏輯正好要在瀏覽器執行,場景比較受限。
  • 透過描述檔案:這是比較常用的方式,幾乎所有的外掛系統都會提供一個入口描述檔案,比如在 package.json或者對應的配置檔案中描述一個屬性,表明了要載入的外掛,比如 .babelrc:
{
  "plugins": ["babel-plugin-myPlugin", "@babel/plugin-transform-runtime"]
}

Umi 的外掛機制

比如 Umi 的外掛,就大致有以下幾個方法:

image-20210822183457082

  • resolvePlugins:也就是解析外掛,是獲取對應外掛的具體程式碼,其中的主要處理邏輯是在 getPlugins 裡,其中大致流程是從配置檔案和約定的位置(包括內建和使用者自定義)獲取對應的外掛地址,然後透過 require 動態載入,形成 [id, apply, opts] 結構,方便後續統一註冊載入。

  • initPlugins:就是註冊外掛的過程,它會呼叫 initPlugin 依次把外掛註冊上去,它透過 Proxy 把 PluginApi,Service 上的方法,還有環境變數都注入 api 物件中,然後供外掛呼叫。

image-20210825100823415

這裡其實就用到了觀察者模式,外掛在呼叫其中特定的方法(api.xxx)的時候,其實就是就會把對應的函式註冊到該方法的鉤子上。

image-20210825101327467

  • applyPlugins:就是呼叫外掛,在特定的生命週期,透過呼叫該方法可以通知所有訂閱該生命週期的函式。

image-20210822190648195

可以從上述步驟中看出,Umi這一套流程也遵循之前我們說的:解析外掛 ——> 註冊外掛 ——> 呼叫外掛 這麼幾個過程。

如何擼一個超簡單的外掛系統

Talk is cheap, show me the code

這裡借一個計算器的例子講一下外掛系統(點選這裡可以去 codesandbox 看實際例子)。

比如說下面這個例子,這個計算器的核心功能是:擁有基本設定值的能力(應該是最簡單的能力了),然後我們在此基礎上提供了兩個方法,自增和自減。

image-20210824210221437

import React, { useState } from "react";
import "antd/dist/antd.css";
import "./index.css";
import { Button } from "antd";

export default function Calculator(props) {
  const { initalValue } = props;
  const [value, setValue] = useState(initalValue || 0);

  const handleInc = () => setValue(value + 1);

  const handleDec = () => setValue(value - 1);


  return (
    <div>
      <div>{value}</div>
      <Button onClick={handleInc}>inc</Button>
      <Button onClick={handleDec}>dec</Button>
    </div>
  );
}

這時候,如果我們想要繼續擴充套件它的能力,不使用外掛化的思想,我們可能會直接在上面擴充套件函式:

export default function Calculator(props) {
  const [value, setValue] = useState(initalValue || 0);

  const handleInc = () => setValue(value + 1);

  const handleDec = () => setValue(value - 1);
  // 新增能力
  const handleSquared = () => setValue(value * value);
  
  return (
    <div>
      <div>{value}</div>
      <Button onClick={handleInc}>inc</Button>
      <Button onClick={handleDec}>dec</Button>
      <Button onClick={handleSquared}>squared</Button>
    </div>
  );
}

如果我們用外掛化的寫法,會怎麼做呢,首先,我們會把一些通用的結構抽離出來,約定一個外掛的結構:

{
    name, // 按鈕名
    exec, // 按下按鈕的執行方法
}

然後寫該外掛被註冊上去的通用方法,比如這裡我們的每個外掛就是一個按鈕

  const buttons = plugins.map((v) => (
    <Button onClick={() => v.exec(value, setValue)}>{v.name}</Button>
  ));

  return (
    <div>
      <div>{value}</div>
      {buttons}
    </div>
  );

這裡,我們透過一個函式包裹一下,把外掛邏輯和渲染邏輯拆分一下,然後把核心外掛(按鈕)也按這個格式補充上:

export default function showCalculator({ initalValue, plugins }) {
  const corePlugins = [
    { name: "inc", exec: (val, setVal) => setVal(val + 1) },
    { name: "dec", exec: (val, setVal) => setVal(val - 1) }
  ];

  const newPlugins = [...corePlugins, ...plugins];

  return <Calculator initalValue={initalValue} plugins={newPlugins} />;
}

現在就有了最簡單一版外掛化的計算器,我們可以擴充套件一個平方外掛:

 showCalculator({ initalValue: 1, plugins: [
    { name: "square", exec: (val, setVal) => setVal(val * val) }
  ]}),

image-20210824210403484

進一步,很多外掛系統都有生命週期的鉤子,我們這邊也模擬一下生命週期,一般來說,生命週期可以透過觀察者模式,這邊寫一個最簡單的事件機制(真的日常開發可以考慮使用 Tapable

const event = {
  eventList: {},
  listen: function (key, fn) {
    if (!this.eventList[key]) {
      this.eventList[key] = [];
    }
    this.eventList[key].push(fn);
  },
  trigger: function (...args) {
    const key = args.splice(0, 1);
    const fns = this.eventList[key];
    if (!fns || fns.length === 0) {
      return false;
    }
    for (let i = 0, len = fns.length; i < len; i++) {
      const fn = fns[i];
      fn.apply(this, args);
    }
  }
};

export default event;

我們主要就是在註冊外掛的時候把對應的生命週期事件都註冊上,這裡我預設所有 on 開頭的都是生命週期鉤子。

newPlugins.forEach(p => {
    // 把所有on開頭的都註冊一下
    Object.keys(p)
    .filter(key => key.indexOf('on') === 0 && typeof p[key] === 'function')
    .forEach(key => event.listen(key, p[key]))
  });

然後我們這邊開放兩個生命週期:onMount 和 onUnMount

 // 這裡就簡單定義兩個生命週期
  const handleMount = () => event.trigger('onMount');

  const handleUnMount = () => event.trigger('onUnMount');

他們的觸發條件也很簡單,就是在對應的元件中寫個 useEffect

  useEffect(() => {
    onMount();
    return () => {
      onUnMount()
    }
  }, []);

這時候,我們在外掛中補充上對應的 onMount 方法,輸出一句話看看:

image-20210824210943619

OK,這樣一個簡單的外掛系統算是就完成了。

最後

其實講了這麼多,主要想給大家傳達的一個外掛化的理念,在做設計的時候可以多思考一下應用的最核心能力,專注核心程式碼的編寫,透過外掛化的方式擴充套件其他能力。這樣你只用關注核心功能的實現是否可靠,由外掛開發者負責其他功能的擴充套件和可靠性。這樣就能保證自己應用在功能穩定的前提下擁有更強的可擴充套件性。同時這樣可以儘量避免寫特別複雜且難以維護的程式碼。

著名的 Javascript 工程師 Nicholas Zakas(JavaScript 高階程式設計高效能 JavaScript 作者,Eslint 作者)曾說過這麼一段話:

一個好的框架或一個好的架構很難做錯事,你的工作是確保最簡單的事情是正確的。一旦你明白了這一點,整個系統就會變得更易於維護。

Nicholas Zakas,Javascript Jabber 075 - 可維護的 Javascript

參考資料

https://en.wikipedia.org/wiki/Plug-in_(computing)

webpack 外掛

Babel 外掛手冊

umi 外掛開發

精讀《外掛化思維》

designing-a-javascript-plugin-system

how-i-created-my-first-plugin-system

Handling Plugins In PHP

Plugin architecture in JavaScript and Node.js with Plug and Play

https://www.bryanbraun.com/2015/02/16/on-designing-great-syst...

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章