Gitee 企业版插件化方案探索
背景
红薯给前后端分离方案提了扩展性的需求,体现扩展性的最直接的方式就是插件化,插件化能让第三方服务商为企业版开发增强功能,以满足用户各种各样的需求,既能发展企业版的开发者生态,也能分散企业版开发团队的压力和工作量。
最初的方案
在收到插件化需求后,本应是先调研类似方案的,但由于当时的资料收集效率有限,所以就决定先以已知的一些库/工具的相关技术进行设计。最初是参考 Vue 组件和 Webpack 插件的开发方法,插件编写风格和 Vue 组件类似,而插件和企业版各个组件之间的交互则是采用 Webpack 的 hooks 方式来实现。
以周报功能为例,我们可以将它制作成插件,而这个插件的效果是给工作台页面追加两个组件和一个页面,如下图所示:
插件目录结构如下:
- gitee-plugin-week-report/
- src/
- DropdownItem.vue: 下拉菜单项组件
- Box.vue:周报内容板块组件
- Page.vue: 周报页面组件
- index.js:插件入口文件
- src/
在 DropdownItem.vue 中添加“写周报”菜单项,路由到周报页面:
1 | <template> |
在 index.js 中定义插件:
1 | import WeekReportBox from './src/Box' |
现在需要插件化的组件有两个:
- DashboardAside.vue:工作台的侧栏组件
- Navbar.vue: 导航栏组件
这两个组件的插件化代码都一样,组件实例上已挂载 $plugin
对象,可调用它的 useHook()
方法来使用钩子,核心代码如下:
1 | <template> |
从上面的示例可以看出这个方案比较简单,问题也很明显:
- 开发难度较高: 适合熟悉 Vue 的开发者,对其他开发者不友好
- 难以开发和调试: 插件依赖的数据和方法需要由页面主动注入到 Vue 实例中,但由于开发者拿不到 Gitee 前端项目代码,所以很难去开发和测试插件
- 不安全: 默认插件开发者是完全可信的,插件和原生组件享有同等待遇,可以访问全局变量、操作页面中的任意元素
显然,这个方案是不符合需求的。
Figma 的方案
红薯在提插件化需求时顺带贴出了Figma 的插件系统文章作为参考,从这篇文章中可以知道 Figma 是采用沙盒机制来运行插件的,他们团队有尝试过 iframe、Duktape 和 Realms 来创建一个隔离的插件运行环境,下面对它们做些简单的介绍。
iframe
Figma 团队最初尝试的方案,用 <iframe>
充当插件的沙盒,沙盒的安全性由浏览器厂商保证,这种方案存在以下问题:
- 涉及较多的异步操作,需要开发者熟悉异步编程
- 页面与插件通信成本较高
- 系统开销较大
Duktape
Duktape 是用 C++ 写的轻量级 JavaScript 编译器,Figma 团队的第二个方案是将它编译为 WebAssembly 然后在浏览器上运行,这种方案有以下特性:
- 解释器在主线程中运行。这意味着我们可以创建基于主线程的 API。
- 以合理的方式保证安全。Duktape 不支持任何浏览器 API,它作为 WebAssembly 运行,而 WebAssembly 本身是一个沙盒环境,无法访问浏览器 API,也就是说,默认情况下,插件代码只能通过明确列入白名单的 API 与外界进行通信。
- 由于该解释器不是 JIT,因此它比常规的 JavaScript 慢,但这还可以接受。
- 它需要浏览器编译一个中等大小的 WASM 二进制文件,这有些成本。
- 浏览器调试工具不起作用。需要花些时间为解释器实现一个控制台,然后工具控制台内容来调试插件。
- Duktape 只支持 ES5。但现在可以用 Babel 这样的工具来转义较新 JavaScript 版本的代码。
由于性能、浏览器支持以及重新发明轮子的成本问题,Figma 团队并没有采用该方案。
Realms
Figma 团队从 Realms shim 中发现了一种技术,也就是利用 JavaScript 现有的 with(obj)
语句和 Proxy 对象来实现沙盒,with(obj)
能够创建一个作用域并将对象的属性作为作用域内的变量,但这对象的属性是固定的,内部的代码仍然可以绕过它访问全局对象,例如:
1 | with (Math) { |
使用 Proxy 对象可以解决这个问题,代理对象的 get 方法,然后在内部的代码访问属性时判断该属性是否在白名单内,如果不在则返回 undefined,例如:
1 | const scopeProxy = new Proxy(whitelist, { |
实际上这种方法仍然可以通过 ({}).constructor.
等表达式来访问某些全局变量,此外,沙盒确实需要访问某些全局变量,例如,Object 是一个全局变量,通常用于合法的 JavaScript 代码(例如 Object.keys
)中。
为了使插件能够访问这些全局变量而不会弄乱窗口,Realms 沙盒通过创建一个同源iframe 实例化了所有这些全局变量的新副本,该 iframe 并未像第一个方案那样用作沙盒,同源 iframe 不受 CORS 限制,相反,如果在与父文档相同的来源中创建了,则:
- 它带有所有全局变量的单独副本,例如
Object.prototype
- 这些全局对象能够被父级文档访问
这里引用 Figma 的原文示例图:
这个使用 Realms 的沙盒方案有很多不错的特性:
- 它运行在主线程。
- 速度很快,因为它仍然使用浏览器的 JavaScript JIT 来执行代码。
- 浏览器开发者工具能够正常使用。
但仅凭 Realms 的沙盒无法让插件执行任何操作,我们仍然需要为插件实现一些安全的 API,毕竟大多数插件确实需要显示一些 UI 并发出网络请求才能用。
至于安全性,先思考以下示例,沙盒默认不包含 console
对象,毕竟它是浏览器的 API 而不是 JavaScript 功能,但可以将其作为全局变量传递到沙盒。
1 | realm.evaluate(USER_CODE, { log: console.log }) |
或者,将原始值隐藏在函数中,以使沙盒无法对其进行修改:
1 | realm.evaluate(USER_CODE, { log: (...args) => { console.log(...args) } }) |
然而这样做还是有安全漏洞。即使在第二个示例中,匿名函数也是在 realm 之外创建的,它却被直接提供给 realm,这意味着该插件可以通过遍历 log
函数的原型链来到达沙盒外部。
正确的 console.log
实现方法是将它包在 realm 内部创建的函数中,例如:
1 | // Create a factory function in the target realm. |
这样做带来了问题,尽管可以构建安全的 API,但开发人员每次想要给 API 添加新功能时都要担心对象来源问题,那么该如何解决它?
问题在于,直接在 Realms shim 之上构建 API 会使得每个 API 端点都需要审核,包括其输入和输出值,与 Realms shim 的接触面的面积太大,如下图所示:
Figma 团队在文章中给出的方案是创建一个安全的 VM API 作为中间层,供 Figma API 调用,并替它完成与 Realms shim 的交互,关系如下图所示:
总结
结合上述方案,我们可以得出插件系统的大致实现思路:
- 使用 Realms 创建一个隔离沙盒,为插件提供运行环境
- 实现一个安全的 VM API,供 API 与沙盒内的代码进行交互
- 基于 VM API 实现 API,供插件调用
到这里,我们对插件化方案的探索就暂时结束了,具体的实现方案等以后需要开发插件系统时再考虑。