插件化架构设计(2):插件化从设计到实践该考量的问题汇总
根据《插件式可扩展架构设计心得》精读扩展版
怎么实现插件化模式
插件模式本质是一种设计思想,并没有一个一成不变或者是万金油的实现。但我们经过长期的代码实践,其实已经可以总结出一套方法论来指导插件体系的实现,并且其中的一些实现细节是存在社区认可度比较高的“最佳实践”的。
插件化架构定义
插件化架构又称微核架构,指的是软件的内核相对较小,主要功能和业务逻辑都通过插件实现。插件化架构一般有两个核心的概念:内核和插件。
-
内核(pluginCore)通常只包含系统运行的最小功能;
-
插件(plugin)则是互相独立的模块,一般只会提供单一的功能。
内核除了插件的管理功能,还会将要完成的所有业务进行抽象,抽象出最小粒度的基础接口,供插件方来调用。这样,插件开发的效率将会极大的提高。比方说,浏览器就是一个典型的插件化架构,浏览器是内核,页面是插件,这样通过不同的URL地址加载不同的页面,来提供非常丰富的功能。而且,我们开发网页的时候,浏览器会提供很多API和能力,这些接口通过 window来挂载, 比如,DOM、BOM、Event、Location等等。
插件三要素
设计一个完善的插件化架构的系统,包含三要素:
-
plugCore:插件内核,提供插件运行时,管理插件的加载、运行、卸载等生命周期(类比浏览器);
-
pluginAPI:插件运行时需要的基础接口(类比浏览器例子,相当于window);
-
plugin:相互独立的模块,提供了单一的功能(类比浏览器例子,相当于不同的网页)。
怎么把系统拆解为插件三要素?
实现一套插件模式的第一步,永远都是先定义出你需要插件化来帮助你解决的问题是什么。
这往往是具体问题具体分析的,并总是需要你对当前系统的能力做一定程度的抽象。
解决问题前首先要定义问题
比如 Babel,他的核心功能是将一种语言的代码转化为另一种语言的代码,他面临的问题就是,他无法在设计时就穷举语法类型,也不了解应该如何去转换一种新的语法,因此需要提供相应的扩展方式。为此,他将自己的整体流程抽象成了 parse、transform、generate 三个步骤,并主要面向 parse 和 transform 提供了插件方式做扩展性支持。
-
在 parse 这层,他核心要解决的问题是怎么去做分词,怎么去做词义语法的理解。
-
在 transform 这层要做的则是,针对特定的语法树结构,应该如何转换成已知的语法树结构。
很明显,babel 他很清楚地定义了 parse 和 transform 两层的插件要完成的事情。
当然也有人可能会说,为什么我一定要定义清楚问题呢,插件体系本来就是为未来的不确定性服务的。这样的说法对,也不对。
计算机程序永远是面向确定性的,我们需要有明确的输入格式,明确的输出格式,明确的可以依赖的能力。
解决问题一定是在已知的一个框架内的。这就引出了定义问题的一门艺术——如何赋予不确定以确定性,在不确定中寻找确定。
说人话,就是“抽象”,这也是为什么最开始我会以过度设计作为引子。
Babel 主要解决的问题是把新语法的代码在不改变逻辑的情况下如何转换成旧语法的代码,简单来说就是 code => code 的一个问题。
但是需要转什么,怎么转,这些是会随着语法规范不断更新变化的,因此需要使用插件模式来提升其未来可拓展性。
我们当下要解决的问题也许是如何转换 es6 新语法的内容,以及 JSX 这种框架定制的 DSL。我们当然可以简单地串联一系列的正则处理,但是你会发现每一个插件都会有大量重复的识别分析类逻辑,不但加大了运行开销,同时也很难避免互相影响导致的问题。
Babel 选择了把解析与转换两个动作拆开来,分别使用插件来实现。解析的插件要解决的问题是如何解析代码,把 Code 转化为 AST。这个问题对于不同的语言又可以拆解为相同的两个事情,如何分词,以及如何做词义解析。当然词义解析还能是如何构筑上下文、如何产出 AST 节点等等,就不再细分了。最终形成的就是下图这样的模式,插件专注解决这几个细分问题。转换这边的,则可分为如何查找固定 AST 节点,以及如何转换,最终形成了 Visitor 模式,这里就不再详细说了。那么我们再思考一下,如果未来 ES7、8、9(相对于设计场景的未来)等新语法出炉时,是不是依然可以使用这样的模式去解决问题呢?看起来是可行的。
这就是前面所说的在不确定中寻找确定性,尽可能减少系统本身所面临的不确定,通过拆解问题去限定问题。
插件核心所包含内容
-
插件调度
-
基础组件(引用自建或第三方)
-
基础服务
-
http服务
-
数据格式转换
-
时间转换等
-
事件总线
-
其它
-
自动加载插件文件(异步)
插件系统核心作为插件的环境依赖,为插件提供基本的服务、插件调度、事件和其他的一些基本功能。
插件之间的相互调用可以通过插件核心进行传递,且这个调用是解耦的,不影响插件内部的逻辑。
插件核心所提供的基础组件、业务组件来自于外部依赖,所以插件核心是一个相对精简的核心,可以通过外部依赖来扩展插件核心的功能。
插件化架构可以链接多个子系统,而做到开闭原则。
即插件核心和接口不变,系统可以持续接入新插件,来丰富系统的功能。
由于新接入的插件是一个独立的子系统,它可独立开发,运行和进行版本管理,不会因为接入的系统复杂而增加接入成本。试想,在一个非插件化的系统中,业务系统就算经过良好设计,随着功能模块的增多,代码量激增,暂且不考虑系统构建和测试,我们想要给系统加入新的功能,甚至是修复已有功能的BUG,都会越来越困难和低效,但插件化架构的系统,增加新功能,不是在一个庞大系统代码库中,而是在一个较小的系统或代码仓库中,因此不管已有系统多复杂,开发新的功能的接入复杂度始终一样。同时,开发编译或修复测试一个插件,也比针对整个系统要简单得多。
插件式开发架构要领
不管基于何种语言进行插件式开发框架的设计,有一些共同的要点需要具备。
插件运行主体
基于插件模式进行开发的软件,一般会存在一个运行主体。这个载体作为应用的主入口,并根据各类插件的配置信息,将编译或打包后的插件加载到主体环境中并执行。开发新的插件,无需调整现有运行主体的代码和二进制包。
插件的注入、配置和初始化
插件配置信息
配置信息即插件的描述信息,可以在代码中设置,也可以通过XML文件实现,方式不同,目的一致。
-
插件名称
-
插件版本号
-
插件描述信息
-
依赖的其他插件清单
插件的注入及初始化
插件的注入及初始化一般借助于继承插件基类,并实现插件框架中指定好的标准接口。通过继承插件基类,实现插件的注入;通过实现标准的初始化、启动、关闭等标准接口,实现插件的生命周期管理工作。
插件通信机制
插件通信机制是一种通用概念。当各插件间协同完成一个功能时,彼此进行协调互助的一种机制。交互的形式有很多种,一种是插件对外开放自己的接口
基于虚拟服务总线形式的通信机制
基于虚拟服务总线形式的通信机制,每个插件都有自己的开放接口,这些接口会被注册到虚拟服务总线上,其他插件通过虚拟服务总线,获取到其他插件的接口服务。此处涉及到的内容是面向接口编程。
插件间消息通信
插件间消息通信属于一种开发人员可以自定义的扩展方式,插件运行主体无法定义所有的消息类型及消息的处理方法。
所以用户可以通过约定消息形式以及自定义消息响应函数,实现插件间的通信。但是这样其实增强了插件之间的耦合度,不是特别推荐。笔者建议应用层插件尽量只依赖通用服务型插件及主体运行程序,业务插件保持独立。
插件架如何协同-调度问题
当正式开始设计我们的插件架构时,我们所要思考的问题往往离不开以下几点。整个设计过程其实就是为每一点选择合适的方案,最后形成一套插件体系。这几点分别是:
-
如何注入、配置、初始化插件
-
插件如何影响系统
-
插件输入输出的含义与可以使用的能力
-
复数个插件之间的关系是怎么样的
面就针对每个点详细解释一下
如何注入、配置、初始化插件
注入、配置、初始化其实是几个分开的事情。但都同属于 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这样一个包。
插件如何影响系统
插件对系统的影响我们可以总结为三方面:行为、交互、UI。
-
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,那里面已经有相应的定义。
另外还需要注意的细节是:
-
顺序是先注册先执行,还是反过来,需要给到明确的解释或一致的认知。
-
同一个插件重复注册了该怎么处理。
总结
当你跟着这篇文章的思路,把这些问题都思考清楚之后,想必你的脑海中一定已经有了一个插件架构的雏形了。剩下的可能是结合具体问题,再通过一些设计模式去优化开发者的体验了。个人认为设计一个插件架构,是一定逃不开针对这些问题的思考的,而且只有去真正关注这些问题,才能避开炫技、过度设计等面向未来开发时时常会犯的错误。当然可能还差一些东西,一些推荐的实现方式也可能会过时,这些就欢迎大家帮忙指正啦。
参考文章:
插件化结构利与弊 https://blog.csdn.net/xfxyy_sxfancy/article/details/44274379
精读《插件化思维》 https://zhuanlan.zhihu.com/p/35997606
插件式开发架构综述 https://www.modb.pro/db/131158
转载本站文章《插件化架构设计(2):插件化从设计到实践该考量的问题汇总》,
请注明出处:https://www.zhoulujun.cn/html/webfront/engineer/Architecture/8907.html