桌面客户端插件可扩展架构

插件式可扩展架构是一种设计应用程序的方法,可以在运行时动态加载和卸载插件。该架构的目的是使应用程序更加灵活和可扩展。使用插件式可扩展架构,应用程序的各个部分可以独立开发和更新,而不需要对整个应用程序进行修改或重新编译。

架构设计优势

对于我们团队开发的桌面应用来说,它有如下优势:

  • 减少安装及启动时间,动态扩展功能
    利用插件管理器在运行时动态加载和扩展的机制,让程序的功能在不修改原有代码的情况下进行扩展。
    (首次安装插件会耗费些时间,之后启动都较快)
  • 开发更加灵活和高效
    开发人员可以专注于插件的具体功能,而不必担心影响应用程序的总体结构。
    (主程序和插件逻辑不会深度耦合,开发更专注,减少沟通成本)
  • 升级和维护更加容易和快速
    开发人员只需要发布新插件即可,不必重新编译或修改应用程序的主体部分。
    (更重要的是,用户不需要频繁安装客户端)
  • 降低应用程序的复杂性,便于移植
    开发人员可以根据需要选择和使用插件,而不必将所有功能都集成到应用程序中。
    (融合出版和幼师云客户端,我们只需要实现相同插件管理器,就可以集成所有插件,便于功能移植)

架构基本组成

可以分为三个主要部分:应用程序、插件和插件管理器。如下图所示:
主要部分

  • 应用程序:应用程序是该架构的主体。它提供一个框架,使得插件可以被加载、管理和运行。
  • 插件:插件是应用程序的一部分,可以在运行时被加载和卸载。每个插件可以实现一些特定的功能或功能组合。插件可以被其他插件调用,可以使用框架提供的API进行通信和交互。
  • 插件管理器:插件管理器是用于管理插件的一种组件。它负责加载和卸载插件,管理插件之间的依赖关系以及提供插件之间通信的接口。

架构设计的几大要素

当开始设计插件架构时,我们所要思考的问题往往离不开以下几点。整个设计过程其实就是为每一点选择合适的方案,最后形成一套插件体系。这几点分别是:

  • 如何注入、配置、初始化插件
  • 插件如何为主程序提供扩展能力
  • 主程序赋予的能力与插件输入输出的含义
  • 多个插件之间的关系

注入、配置、初始化插件

注入本质上是让程序感知到插件的存在。注入的方式一般可以分为 声明式 和 编程式。两者可以单独也可以结合使用。

声明式就是通过配置信息,告诉程序应该去哪里去取什么插件,程序运行时无需关心插件是如何实现,按照约定与配置去加载对应的插件。

编程式的就是程序提供某种注册 API,开发者通过将插件传入 API 中来完成注册。

初始化的时机分为三种,按实际需求去选择:注入即初始化、统一初始化、运行时初始化(有的插件随时安装随时使用,有的需要重启服务,在注入时即初始化)
图片展示三种初始化方式

插件如何为程序提供扩展能力

这个过程的前提是插件与程序间建立一份契约,约定好对接的方式。这份契约可以包含文件结构、配置格式、API接口。

以VSCode插件为例:

  • 文件结构:沿用了 NPM 的传统,约定了目录下 package.json 承载元信息。
  • 配置格式:约定了 main 的配置路径作为代码入口,activationEvents定义激活方式,私有字段 contributes 声明命令与配置(该字段可配置菜单和视图容器)
  • API 接口:约定了扩展必须提供 activate 和 deactivate 两个接口。并提供了 vscode 下各项 API 来完成注册。

下面以两个开源项目插件配置为例,简单介绍插件的配置格式

VSCode 插件配置格式

我们只需要关心package.json和extension.ts,结构如下:

package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"engines": {
"vscode": "^1.47.0"
},
"activationEvents": [
"onLanguage:java",
"onCommand:java.show.references",
"onCommand:java.show.implementations",
"onCommand:java.open.output",
"onCommand:java.open.serverLog",
"onCommand:java.execute.workspaceCommand",
"onCommand:java.projectConfiguration.update",
"workspaceContains:pom.xml",
"workspaceContains:build.gradle"
]
}

有两个关键字,engines指VSCode兼容版本,activationEvents表示触发事件。onLanguage为语言为java时,输入命令或者工作区中包含pom.xml文件,这些都会加载插件。插件的加载机制是懒加载,只有触发了指定事件才会加载。

extension.ts导出一个activate函数,表示当插件被激活时执行函数内的内容。弹出一个Hello World的提示框。

Rubick 取色器、系统剪贴板插件配置格式

Rubick是一款提升工作效率的开源工具,它的特色就是集成了各种工作场景下的插件,例如取色器、系统剪贴板、文件拖拽中转站等等。插件的配置格式如下:
左边是UI插件,右边是系统插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"name": "rubick-system-color-picker",
"pluginName": "取色器",
"version": "1.0.1",
"description": "取色器",
"main": "index.html",
"preload": "preload.js",
"logo": "https://pic1.zhimg.com/80/v2-5f1810a71af6eefcd77edbbf07ea1cc7_720w.png",
"homePage": "https://gitee.com/rubick-center/rubick-system-color-picker/raw/master/README.md",
"pluginType": "ui",
"features": [
{
"code": "colorpicker",
"explain": "取色器",
"cmds": [
"colorpicker",
"qs",
"取色"
]
}
]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
{
"main": "index.html",
"preload": "preload.js",
"features": [
{
"code": "clipboard",
"explain": "剪贴板历史记录、记录合并复制或粘贴、文件中转站",
"cmds": [
"剪贴板",
"剪切板",
"Clipboard"
]
},
{
"code": "search",
"explain": "剪贴板历史记录搜索",
"mainPush": true,
"cmds": [
{
"type": "over",
"maxLength": 40,
"label": "剪贴板搜索"
}
]
}
],
"name": "rubick-system-clipboard",
"pluginName": "系统剪贴板",
"description": "系统剪贴板增强,合并复制、合并粘贴、文件拖拽中转",
"author": "muwoo",
"homePage": "https://gitee.com/rubick-center/rubick-qjb/raw/master/README.md",
"version": "1.0.0",
"entry": "main.js",
"logo": "https://pic1.zhimg.com/80/v2-8808dd420ee9804ad588aaee01ba9398_720w.png",
"pluginType": "system"
}

主程序提供的能力与插件的输入输出

插件与主程序间最重要的契约就是 API 接口,这涉及了可以使用哪些 API,以及这些 API 的输入输出是什么。

同时,不管是主程序对插件的调用还是插件调用主程序的能力,我们都是需要一个确定的输入输出信息。

主程序提供的能力

主程序提供给插件使用的公共工具,或者可以通过一些方式获取或影响主程序本身的状态。能力的注入我们常使用的方式是参数(环境变量)、上下文对象(global、mainWindow)或者工厂函数闭包。

提供的能力类型主要有下面四种:

  • 纯工具:不影响主程序状态
  • 获取当前主程序状态
  • 修改当前主程序状态
  • API 形式注入功能:例如注册 UI,注册事件等

对于需要提供哪些能力,一般的建议是根据插件需要完成的工作,提供最小够用范围内的能力,尽量减少插件破坏主程序的可能性。

输入输出

输入输出的结构需要与插件的职责强关联,保证易读性及防止过度膨胀
输入 { inPath: 原始图片 } —插件处理中—> 输出 { outPath: 转码后图片 }

如果插件输入输出过于复杂,可能要反思一下抽象是否过于粗粒度。

多个插件之间的关系

通常是一个扩展点对应一个插件,例如Rubick。

复杂程序,在同一个扩展点上注入的插件,例如VSCode。
并且有多种组合方式。常见的形式如下:

管道式

输入输出相互衔接,一般输入输出是同一个数据类型。
插件间关系

覆盖式

只执行最新注册的逻辑,跳过原始逻辑
插件间关系

洋葱圈式

在管道式的基础上,如果主程序核心逻辑处于中间,插件同时关注进与出的逻辑,则可以使用洋葱圈模型。
这里也可以参考 koa 中的中间件调度模式 https://github.com/koajs/compos
插件间关系

集散式

集散式就是每一个插件都会执行,如果有输出则最终将结果进行合并。这里的前提是存在方案,可以对执行结果进行 merge。
插件间关系

我们只需要做简单了解,毕竟我们不是做复杂系统和IDE。本次分享只关注一个扩展点对应一个插件。

有了以上理论基础,在我们的脑海中一定已经有了一个插件架构的雏形。剩下的可能是结合具体问题,再通过一些设计模式去优化开发和使用体验。

融合出版客户端插件架构设计

插件模式的设计,可以简单也可以复杂,我们不能指望一套插件模式适合所有的场景,在设计之前我们一定要先定义清楚问题。具体选择什么方式实现,一定是根据具体解决的问题权衡得出的。

回顾一下架构组成和四个要素,就可以开始进行设计
架构组成:应用程序、插件管理器、插件
四个要素:

  • 如何注入、配置、初始化插件
  • 插件如何为主程序提供扩展能力
  • 主程序赋予的能力与插件输入输出的含义
  • 多个插件之间的关系

主程序启动流程

启动流程

插件管理器

VSCode和Rubick的插件管理器是带UI界面的插件中心。插件的安装、卸载、更新等操作都在插件中心进行。融合出版目前无UI管理界面,从CMS拉取插件清单。
CMS插件配置

在CMS中动态配置插件列表,客户端启动时统一下载、更新插件。
Alt text

当然对于使用场景少、体积较大的插件,会采用不同策略,在运行时去下载并初始化。

插件配置、注入、初始化

配置

约定插件配置

1
2
3
4
5
6
7
8
9
// courseware-packer 插件
{
"version": "0.0.1",
"entry": "0.0.1.asar/electron-main.js",
"type": "asar",
"uri": "https://public.yitong.com/update/electron-client-resource/release/plugin/courseware-packer/0.0.1.asar.zip",
"md5": "47f50baaea77a874974f0d3d9ed47c18",
"sha1": "b8aecef65b27b3c98bfad3f3befc5891fb8cb4d4"
}

注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module.exports = async function init(params: any) {
const Packer = require("./index");
const packer = new Packer(params);
const funs = {
"plugin.courseware-packer.createView": (data: any) => packer.createView(data),
"plugin.courseware-packer.closeView": (data: any) => packer.closeView(data),
"plugin.courseware-packer.packaging": (data: any) => packer.packaging(data)
};
const commands: any[] = [];
Object.keys(funs).forEach((item: any) => {
commands.push(item);
});

return {
...funs,
commands,
};
};

初始化

运行时初始化(部分插件运行时下载再初始化)
调用插件命令 plugin.courseware-packer.createView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @description TODO: 插件管理
* 1. 检查并下载插件
* 2. 导入 各个插件入口文件
*/
export const pluginManager = () => {
const plugins: any = {};
const pluginCallback = async (_event: any, { command, ...rest }: IPCPlugin) => {
const [pluginType, pluginName] = command.split('.');
if (!pluginType && !pluginName) return '';
const entry = (await downloadElectronResource(pluginType, pluginName)) as string;
const pluginInit = require(entry);
if (!plugins[pluginName]) {
plugins[pluginName] = await pluginInit(params);
}
return plugins[pluginName]?.commands?.includes(command)
? plugins[pluginName]?.[command]?.(rest)
: '';
};
return {
[IPC.SEND.PLUGIN]: pluginCallback,
[IPC.INVOKE.PLUGIN]: pluginCallback,
};
};

主程序提供能力

要使用主程序提供能力,首先约定插件包文件结构

文件结构

Alt text
electron-main.js 脚本入口、node_modules 脚本依赖、package.json 脚本包信息、index.html 页面入口、assets 页面资源

API 接口

供插件脚本使用:参数、上下文对象或者工厂函数闭包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const params = {
// 上下文对象:global、mainWindow
electron,
mainWindow: globalManager().get('mainWindow'), // 主窗口
// 参数:环境变量
IPC,
PATH,
IS_PRODUCTION,
getUserDataDirPath,
props,
// 工厂函数闭包:日志、刷新token、下载依赖插件
logger,
getUploadToken,
downloadElectronResource,
}

供插件UI渲染进程使用:
preload.js中IPC通信方法

  • 常规通信方法:窗口放大、缩小、弹窗、下载插件、Loading等
  • 插件专用通信方法:插件初始化时注册的方法

Alt text

目前已有的插件

有三种类型的插件:plugin插件、bin插件、shell插件
bin只是扩展使用二进制文件,主逻辑还是在主程序中。
shell插件是一个资源包,供课件打包合并使用。
plugin插件就是按照上诉结构进行设计。

插件 插件名称 类型 依赖项 备注
coreutils 计算hash bin 可使用npm-yt-ouid替代(评估中)
ImageMagick 图片转码 bin
ffprobe 多媒体分析 bin ffmpeg命令替代(评估中)
ffmpeg 音视频编解码 bin
object-storage 文件存储 plugin ImageMagick、ffprobe、ffmpeg
game-player 游戏播放 plugin
game-editor 游戏编辑 plugin game-player
main-html 融合出版内容 plugin all
courseware-packer 课件打包 plugin electron-shell
electron-shell 壳程序 shell

插件间的关系及输入输出

大部分是一个扩展点对应一个插件,例如游戏编辑器、游戏播放器等

少量是一个扩展点上对应多个插件,类似管道式,输入输出相互衔接,一般输入输出是同一个数据类型。
以 object-storage 上传插件为例:

选择视频地址 —(ffmpeg插件)—> 转码后地址 —(object-storage插件)—> 上传后远端地址
选择图片地址 —(ImageMagick插件)—> 转码后地址 —(object-storage插件)—> 上传后远端地址

如上,一个初级版本的插件式可扩展架构就已经设计完了。

总结及思考

插件式可扩展架构,把扩展功能从应用程序中剥离出来,降低了应用程序的复杂度,让应用程序功能更容易实现。扩展功能与应用程序以一种很松的方式耦合,两者在保持接口不变的情况下,可以独立变化和发布。基于插件架构设计并不复杂,相反它比之前幼师云客户端的设计更简单,更容易理解。

当然,融合出版客户端作为一个初级版本的插件架构,目前还存在一些已知的问题:

  • 目前插件配置项过于简单,并没有考虑插件和程序版本关系、插件间的依赖关系、配置入口页面等。
  • 部分二进制插件(音视图转码插件)逻辑还写在主程序中,后期会规范统一,移植到插件中。
  • 插件管理器过于简单,后续会考虑增加带UI的安装、卸载、更新等操作。
  • 部分插件未实现异常捕捉,可能影响主程序稳定。同时也会通过API控制插件影响范围,可以考虑为插件创造沙箱环境,比如插件内可能会调用 global 的接口等

除此之外,后续也会根据实际业务需求去进行优化,向着VSCode、NTQQ等业界优秀产品看齐。

参考资料

VSCode多进程架构和插件加载原理

VSCode源码解析-依赖注入

VSCode插件运行机制

Rubick插件开发

Rubick插件配置

插件式可扩展架构设计心得

架构方法论


桌面客户端插件可扩展架构
http://example.com/20230911-桌面客户端插件可扩展架构/
作者
csorz
发布于
2023年9月9日
许可协议