你们可能不知道,鄙人以前人送外号“过分设计”。做为一个自信的研发人员,我老是但愿我开发的系统能够解决以后全部的问题,用一套抽象能够覆盖以后全部的扩展场景。固然最终每每可以证实个人愚昧与思虑不足。先知曾说过“当一个东西什么均可以作时,他每每什么都作不了”。过分的抽象,过分的开放性,每每让接触他的人无所适从。讲到这里你可能觉得我要开始讲过分设计这个主题了,但其实否则,我只是想以这个话题做为引子,和你们讨论一下关于设计一个插件架构我是如何思考的。javascript
咱们的软件系统每每是要面向持续性的迭代的,在开发之初很难把全部须要支持的功能都想清楚,有时候还须要借助社区的力量去持续生产新的功能点,或者优化已有的功能。这就须要咱们的软件系统具有必定的可扩展性。插件模式就是咱们经常选用的方法。java
事实上,现存的大量软件系统或工具都是使用插件方式来实现可扩展性的。好比你们最熟悉的小可爱——VSCode,其插件拥有量已经超越了他的前辈 Atom,发布到市场中的数量目前是 24894 个。这些插件帮助咱们定制编辑器的外观或行为,增长额外功能,支持更多语法类型,大大提高了开发效率,同时也不断拓展着自身的用户群体。又或者是咱们熟知的浏览器 Chrome,其核心竞争力之一也是丰富的插件市场,使其不管是对开发者仍是普通使用者都已成为了避免可获取的一个工具。另外还有 Webpack、Nginx 等等各类工具,这边就不一一赘述了。node
根据目前各个系统的插件设计,总结下来,咱们创造插件主要是帮助咱们解决如下两种类型的问题:react
同时,在解决上面这类问题的时候作到:webpack
而且进一步地能够实现:git
这里提到的无论是提供新能力,仍是进行能力定制,都既能够针对系统开发者自己,也能够针对三方开发者。es6
结合上面的特征,咱们尝试简单描述一下插件是什么吧。插件通常是可独立完成某个或一系列功能的模块。一个插件是否引入必定不会影响系统本来的正常运行(除非他和另外一个插件存在依赖关系)。插件在运行时被引入系统,由系统控制调度。一个系统能够存在复数个插件,这些插件可经过系统预约的方式进行组合。github
插件模式本质是一种设计思想,并无一个一成不变或者是万金油的实现。但咱们通过长期的代码实践,其实已经能够总结出一套方法论来指导插件体系的实现,而且其中的一些实现细节是存在社区承认度比较高的“最佳实践”的。本文在攥写过程当中也参考研读了社区比较有名的一些项目的插件模式设计,包括但不只限于 Koa、Webpack、Babel 等。web
实现一套插件模式的第一步,永远都是先定义出你须要插件化来帮助你解决的问题是什么。这每每是具体问题具体分析的,并老是须要你对当前系统的能力作必定程度的抽象。好比 Babel,他的核心功能是将一种语言的代码转化为另外一种语言的代码,他面临的问题就是,他没法在设计时就穷举语法类型,也不了解应该如何去转换一种新的语法,所以须要提供相应的扩展方式。为此,他将本身的总体流程抽象成了 parse、transform、generate 三个步骤,并主要面向 parse 和 transform 提供了插件方式作扩展性支持。在 parse 这层,他核心要解决的问题是怎么去作分词,怎么去作词义语法的理解。在 transform 这层要作的则是,针对特定的语法树结构,应该如何转换成已知的语法树结构。typescript
很明显,babel 他很清楚地定义了 parse 和 transform 两层的插件要完成的事情。固然也有人可能会说,为何我必定要定义清楚问题呢,插件体系原本就是为将来的不肯定性服务的。这样的说法对,也不对。计算机程序永远是面向肯定性的,咱们须要有明确的输入格式,明确的输出格式,明确的能够依赖的能力。解决问题必定是在已知的一个框架内的。这就引出了定义问题的一门艺术——如何赋予不肯定以肯定性,在不肯定中寻找肯定。说人话,就是“抽象”,这也是为何最开始我会以过分设计做为引子。
我在进行问题定义的时候,最常使用的是样本分析法,这种方法并不是捷径,但总归是有点效的。样本分析法,就是先着眼于整理已知待解决的问题,将这些问题做为样本尝试分类和提取共性,从而造成一套抽象模式。而后再经过一些不肯定但可能将来待解决的问题来测试,是否存在没法套用的状况。光说无用,下面咱们仍是以 babel 来举个栗子,固然 babel 的抽象设计其实本质就是有理论支撑的,在有现有理论已经为你作好抽象时,仍是尽可能用现成的就好啦。
Babel 主要解决的问题是把新语法的代码在不改变逻辑的状况下如何转换成旧语法的代码,简单来讲就是 code => code 的一个问题。可是须要转什么,怎么转,这些是会随着语法规范不断更新变化的,所以须要使用插件模式来提高其将来可拓展性。咱们当下要解决的问题也许是如何转换 es6 新语法的内容,以及 JSX 这种框架定制的 DSL。咱们固然能够简单地串联一系列的正则处理,可是你会发现每个插件都会有大量重复的识别分析类逻辑,不但加大了运行开销,同时也很难避免互相影响致使的问题。Babel 选择了把解析与转换两个动做拆开来,分别使用插件来实现。解析的插件要解决的问题是如何解析代码,把 Code 转化为 AST。这个问题对于不一样的语言又能够拆解为相同的两个事情,如何分词,以及如何作词义解析。固然词义解析还能是如何构筑上下文、如何产出 AST 节点等等,就再也不细分了。最终造成的就是下图这样的模式,插件专一解决这几个细分问题。转换这边的,则可分为如何查找固定 AST 节点,以及如何转换,最终造成了 Visitor 模式,这里就再也不详细说了。那么咱们再思考一下,若是将来 ES七、八、9(相对于设计场景的将来)等新语法出炉时,是否是依然可使用这样的模式去解决问题呢?看起来是可行的。
这就是前面所说的在不肯定中寻找肯定性,尽量减小系统自己所面临的不肯定,经过拆解问题去限定问题。
那么定义清楚问题,咱们大概就完成了 1/3 的工做了,下面就是要正式开始思考如何设计了。
插件模式的设计,能够简单也能够复杂,咱们不能期望一套插件模式适合全部的场景,若是真的能够的话,我也不用写这篇文章了,给你们甩一个 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;
上述这个例子有点大块儿,有点稍显粗糙。那么总结下来咱们看一下,在最开始咱们提到的三个方面分别是如何体现的。
因此咱们在设计一个插件架构时呢,也主要就从这三方面是否会被影响考虑便可。那么插件又怎么去影响系统呢,这个过程的前提是插件与系统间创建一份契约,约定好对接的方式。这份契约能够包含文件结构、配置格式、API 签名。仍是结合 VSCode 的例子来看看:
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 有效控制影响范围,能够考虑为插件创造沙箱环境,好比插件内可能会调用 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 。