Gitee 企业版插件化方案探索

背景

红薯给前后端分离方案提了扩展性的需求,体现扩展性的最直接的方式就是插件化,插件化能让第三方服务商为企业版开发增强功能,以满足用户各种各样的需求,既能发展企业版的开发者生态,也能分散企业版开发团队的压力和工作量。

最初的方案

在收到插件化需求后,本应是先调研类似方案的,但由于当时的资料收集效率有限,所以就决定先以已知的一些库/工具的相关技术进行设计。最初是参考 Vue 组件和 Webpack 插件的开发方法,插件编写风格和 Vue 组件类似,而插件和企业版各个组件之间的交互则是采用 Webpack 的 hooks 方式来实现。

以周报功能为例,我们可以将它制作成插件,而这个插件的效果是给工作台页面追加两个组件和一个页面,如下图所示:

插件目录结构如下:

  • gitee-plugin-week-report/
    • src/
      • DropdownItem.vue: 下拉菜单项组件
      • Box.vue:周报内容板块组件
      • Page.vue: 周报页面组件
    • index.js:插件入口文件

在 DropdownItem.vue 中添加“写周报”菜单项,路由到周报页面:

1
2
3
4
5
6
<template>
<router-item class="item" :to="{ name: 'dashboard#week_reports' }">
<i class="iconfont icon-calendar" />
<span class="text">写周报</span>
</router-item>
</template>

在 index.js 中定义插件:

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
37
38
39
40
41
42
import WeekReportBox from './src/Box'
import WeekReportDropdownItem from './src/DropdownItem'

const plugin = {
// 追加新路由映射
routes: [
{
path: 'dashboard/week_reports',
name: 'dashboard#week_reports',
// 按需加载周报页面
component: () => import('./src/Page')
}
],
// 绑定钩子
hooks: {
// 在导航栏的新建下拉框里添加一个 "写周报" 菜单项
'app.navbar.newDropdown.createItem': function () {
return { component: WeekReportDropdownItem }
},
// 在工作台侧栏中添加周报内容板块
'app.dashboard.aside.createItem': function () {
return {
// 用于排序的序号
index: 1,
// 组件
component: WeekReportBox,
// 传给组件的参数
props: {
// 当前插件的信息
currentPlugin: this.plugin,
// 当前语言代码
locale: this.locale
}
}
}
}
}

export default function (app) {
// 注册插件
app.plugin('GiteePluginWeekReport', plugin)
}

现在需要插件化的组件有两个:

  • DashboardAside.vue:工作台的侧栏组件
  • Navbar.vue: 导航栏组件

这两个组件的插件化代码都一样,组件实例上已挂载 $plugin 对象,可调用它的 useHook() 方法来使用钩子,核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
<div>
<component
v-for="(item, i) in items"
:key="i"
:is="item.component"
v-bind="item.props()"
/>
</div>
</template>
<script>
export default {
data() {
return {
items: []
}
},
created() {
// 收集插件附加的组件
this.items = this.$plugin.useHook('app.dashboard.aside.createItem')
}
}
</script>

从上面的示例可以看出这个方案比较简单,问题也很明显:

  • 开发难度较高: 适合熟悉 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
2
3
4
5
6
with (Math) {
a = PI * r * r
x = r * cos(PI)
y = r * sin(PI)
console.log(x, y)
}

使用 Proxy 对象可以解决这个问题,代理对象的 get 方法,然后在内部的代码访问属性时判断该属性是否在白名单内,如果不在则返回 undefined,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const scopeProxy = new Proxy(whitelist, {
get(target, prop) {
// here, target === whitelist
if (prop in target) {
return target[prop]
}
return undefined
}
})

with (scopeProxy) {
document // undefined!
eval("xhr") // undefined!
}

实际上这种方法仍然可以通过 ({}).constructor. 等表达式来访问某些全局变量,此外,沙盒确实需要访问某些全局变量,例如,Object 是一个全局变量,通常用于合法的 JavaScript 代码(例如 Object.keys)中。

为了使插件能够访问这些全局变量而不会弄乱窗口,Realms 沙盒通过创建一个同源iframe 实例化了所有这些全局变量的新副本,该 iframe 并未像第一个方案那样用作沙盒,同源 iframe 不受 CORS 限制,相反,如果在与父文档相同的来源中创建了,则:

  1. 它带有所有全局变量的单独副本,例如 Object.prototype
  2. 这些全局对象能够被父级文档访问

这里引用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Create a factory function in the target realm.
// The factory return a new function holding a closure.
const safeLogFactory = realm.evaluate(`
(function safeLogFactory(unsafeLog) {
return function safeLog(...args) {
unsafeLog(...args);
}
})
`);

// Create a safe function
const safeLog = safeLogFactory(console.log);

// Test it, abort if unsafe
const outerIntrinsics = safeLog instanceof Function;
const innerIntrinsics = realm.evaluate(`log instanceof Function`, { log: safeLog });
if (outerIntrinsics || !innerIntrinsics) throw new TypeError();

// Use it
realm.evaluate(`log("Hello outside world!")`, { log: safeLog });

这样做带来了问题,尽管可以构建安全的 API,但开发人员每次想要给 API 添加新功能时都要担心对象来源问题,那么该如何解决它?

问题在于,直接在 Realms shim 之上构建 API 会使得每个 API 端点都需要审核,包括其输入和输出值,与 Realms shim 的接触面的面积太大,如下图所示:

Figma 团队在文章中给出的方案是创建一个安全的 VM API 作为中间层,供 Figma API 调用,并替它完成与 Realms shim 的交互,关系如下图所示:

总结

结合上述方案,我们可以得出插件系统的大致实现思路:

  1. 使用 Realms 创建一个隔离沙盒,为插件提供运行环境
  2. 实现一个安全的 VM API,供 API 与沙盒内的代码进行交互
  3. 基于 VM API 实现 API,供插件调用

到这里,我们对插件化方案的探索就暂时结束了,具体的实现方案等以后需要开发插件系统时再考虑。

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×