关于前端:插件式可扩展架构设计心得

8次阅读

共计 9917 个字符,预计需要花费 25 分钟才能阅读完成。

引子

大家可能不晓得,鄙人之前人送外号“适度设计”。作为一个自信的研发人员,我总是心愿我开发的零碎能够解决之后所有的问题,用一套形象能够笼罩之后所有的扩大场景。当然最终往往可能证实我的愚昧与思虑有余。先知曾说过“当一个货色什么都能够做时,他往往什么都做不了”。适度的形象,适度的开放性,往往让接触他的人莫衷一是。讲到这里你可能认为我要开始讲适度设计这个主题了,但其实不然,我只是想以这个话题作为引子,和大家讨论一下对于设计一个插件架构我是如何思考的。

为什么须要插件

咱们的软件系统往往是要面向持续性的迭代的,在开发之初很难把所有须要反对的性能都想分明,有时候还须要借助社区的力量去继续生产新的性能点,或者优化已有的性能。这就须要咱们的软件系统具备肯定的可扩展性。插件模式就是咱们经常选用的办法。

事实上,现存的大量软件系统或工具都是应用插件形式来实现可扩展性的。比方大家最相熟的小可爱——VSCode,其插件拥有量曾经超过了他的前辈 Atom,公布到市场中的数量目前是 24894 个。这些插件帮忙咱们定制编辑器的外观或行为,减少额定性能,反对更多语法类型,大大晋升了开发效率,同时也一直拓展着本身的用户群体。又或者是咱们熟知的浏览器 Chrome,其外围竞争力之一也是丰盛的插件市场,使其不论是对开发者还是一般使用者都已成为了不可获取的一个工具。另外还有 Webpack、Nginx 等等各种工具,这边就不一一赘述了。

依据目前各个系统的插件设计,总结下来,咱们发明插件次要是帮忙咱们解决以下两种类型的问题:

  • 为零碎提供全新的能力
  • 对系统现有能力进行定制

同时,在解决下面这类问题的时候做到:

  • 插件代码与零碎代码在工程上解耦,能够独立开发,并对开发者隔离框架外部逻辑的复杂度
  • 可动态化引入与配置

并且进一步地能够实现:

  • 通过对多个繁多职责的插件进行组合,能够实现多种简单逻辑,实现逻辑在简单场景中的复用

这里提到的不论是提供新能力,还是进行能力定制,都既能够针对零碎开发者自身,也能够针对三方开发者。

联合下面的特色,咱们尝试简略形容一下插件是什么吧。插件个别是可独立实现某个或一系列性能的模块。一个插件是否引入肯定不会影响零碎本来的失常运行(除非他和另一个插件存在依赖关系)。插件在运行时被引入零碎,由系统控制调度。一个零碎能够存在复数个插件,这些插件可通过零碎预约的形式进行组合。

怎么实现插件模式

插件模式实质是一种设计思维,并没有一个变化无穷或者是万金油的实现。但咱们通过长期的代码实际,其实曾经能够总结出一套方法论来领导插件体系的实现,并且其中的一些实现细节是存在社区认可度比拟高的“最佳实际”的。本文在攥写过程中也参考研读了社区比拟有名的一些我的项目的插件模式设计,包含但不仅限于 Koa、Webpack、Babel 等。

1. 解决问题前首先要定义问题

实现一套插件模式的第一步,永远都是先定义出你须要插件化来帮忙你解决的问题是什么。这往往是具体问题具体分析的,并总是须要你对以后零碎的能力做肯定水平的形象。比方 Babel,他的外围性能是将一种语言的代码转化为另一种语言的代码,他面临的问题就是,他无奈在设计时就穷举语法类型,也不理解应该如何去转换一种新的语法,因而须要提供相应的扩大形式。为此,他将本人的整体流程形象成了 parse、transform、generate 三个步骤,并次要面向 parse 和 transform 提供了插件形式做扩展性反对。在 parse 这层,他外围要解决的问题是怎么去做分词,怎么去做词义语法的了解。在 transform 这层要做的则是,针对特定的语法树结构,应该如何转换成已知的语法树结构。

很显著,babel 他很分明地定义了 parse 和 transform 两层的插件要实现的事件。当然也有人可能会说,为什么我肯定要定义分明问题呢,插件体系原本就是为将来的不确定性服务的。这样的说法对,也不对。计算机程序永远是面向确定性的,咱们须要有明确的输出格局,明确的输入格局,明确的能够依赖的能力。解决问题肯定是在已知的一个框架内的。这就引出了定义问题的一门艺术——如何赋予不确定以确定性,在不确定中寻找确定。说人话,就是“形象”,这也是为什么最开始我会以适度设计作为引子。

我在进行问题定义的时候,最常应用的是样本分析法,这种办法并非捷径,但总归是有点效的。样本分析法,就是先着眼于整顿已知待解决的问题,将这些问题作为样本尝试分类和提取共性,从而造成一套形象模式。而后再通过一些不确定但可能将来待解决的问题来测试,是否存在无奈套用的状况。光说无用,上面咱们还是以 babel 来举个栗子,当然 babel 的形象设计其实实质就是有实践撑持的,在有现有实践曾经为你做好形象时,还是尽量用现成的就好啦。

Babel 次要解决的问题是把新语法的代码在不扭转逻辑的状况下如何转换成旧语法的代码,简略来说就是 code => code 的一个问题。然而须要转什么,怎么转,这些是会随着语法标准不断更新变动的,因而须要应用插件模式来晋升其将来可拓展性。咱们当下要解决的问题兴许是如何转换 es6 新语法的内容,以及 JSX 这种框架定制的 DSL。咱们当然能够简略地串联一系列的正则解决,然而你会发现每一个插件都会有大量反复的辨认剖析类逻辑,岂但加大了运行开销,同时也很难防止相互影响导致的问题。Babel 抉择了把解析与转换两个动作拆开来,别离应用插件来实现。解析的插件要解决的问题是如何解析代码,把 Code 转化为 AST。这个问题对于不同的语言又能够拆解为雷同的两个事件,如何分词,以及如何做词义解析。当然词义解析还能是如何构筑上下文、如何产出 AST 节点等等,就不再细分了。最终造成的就是下图这样的模式,插件专一解决这几个细分问题。转换这边的,则可分为如何查找固定 AST 节点,以及如何转换,最终造成了 Visitor 模式,这里就不再具体说了。那么咱们再思考一下,如果将来 ES7、8、9(绝对于设计场景的将来)等新语法出炉时,是不是仍然能够应用这样的模式去解决问题呢?看起来是可行的。

这就是后面所说的在不确定中寻找确定性,尽可能减少零碎自身所面临的不确定,通过拆解问题去限定问题。

那么定义分明问题,咱们大略就实现了 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 这个插件,这个插件的性能很简略,就是在状态栏加一个时钟,或者你能够在编辑内容内疾速插入以后工夫。


整个我的项目里最次要的是上面这些内容:

在 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");


钩子机制适宜注入点多,松耦合需要高的插件场景,可能缩小整个零碎中插件调度的复杂度。老本就是额定引了一套钩子机制了,不算高的老本,但也不是必要的。

使用者调度机制

这种模式实质就是将插件提供的能力,对立作为零碎的额定能力对外透出,最初又零碎的开发使用者决定什么时候调用。例如 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.

这里咱们探讨的是,在同一个扩大点上注入的插件,应该以什么模式做组合。常见的模式如下:

笼罩式

只执行最新注册的逻辑,跳过原始逻辑

管道式

输入输出互相连接,个别输入输出是同一个数据类型。

洋葱圈式

在管道式的根底上,如果系统核心逻辑处于两头,插件同时关注进与出的逻辑,则能够应用洋葱圈模型。

这里也能够参考 koa 中的中间件调度模式 https://github.com/koajs/compose

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

集散式

集散式就是每一个插件都会执行,如果有输入则最终将后果进行合并。这里的前提是存在计划,能够对执行后果进行 merge。

另外调度还能够分为 同步 和 异步 两个形式,次要看插件逻辑是否蕴含异步行为。同步的实现会简略一点,不过如果你不能确定,那也能够思考先把异步的一起思考进来。相似 https://www.npmjs.com/package/neo-async 这样的工具能够很好地帮忙你。如果你应用了 tapble,那外面曾经有相应的定义。

另外还须要留神的细节是:

  • 程序是先注册先执行,还是反过来,须要给到明确的解释或统一的认知。
  • 同一个插件反复注册了该怎么解决。

总结

当你跟着这篇文章的思路,把这些问题都思考分明之后,想必你的脑海中肯定曾经有了一个插件架构的雏形了。剩下的可能是联合具体问题,再通过一些设计模式去优化开发者的体验了。集体认为设计一个插件架构,是肯定逃不开针对这些问题的思考的,而且只有去真正关注这些问题,能力避开炫技、适度设计等面向未来开发时时常会犯的谬误。当然可能还差一些货色,一些举荐的实现形式也可能会过期,这些就欢送大家帮忙斧正啦。

作者:ES2049 / armslave00

文章可随便转载,但请保留此原文链接。
十分欢送有激情的你退出 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com。

正文完
 0