如何为平台设计一个插件系统

随着web浏览器的发展,浏览器的性能愈来愈好,WebGL和WebAssembly提供愈来愈多的可能性。不少本来只能在终端运行的程序都开始开发web版本例如CAD的web版本,PS的web版本,figma。这一个个的设计协做平台本来在终端都有插件机制。那么若是在web端能提供一个插件机制,对于有一点编程能力的用户,就能够提供更好的用户体验和开发更多的可能性。如何开发一个好的插件系统呢?javascript

一个javascript的插件系统须要知足如下几个方面:html

安全性

  • 插件不能够发送请求
  • 插件和程序模块不能够非法的调用相互的数据
  • 插件不能够在不受约束的状况下执行
  • 插件不能够任意的修改UI,从而给用户形成误导

稳定性

  • 插件不能影响主程序的稳定性
  • 插件不能够修改主程序中的常量

易开发性

  • 插件应该是容易开发的,即便是面对没有那么多编程经验的设计师,也应该是容易开发的。
  • 插件要可使用调试工具。

效率

  • 插件的执行效率不能太慢从而影响整个主程序的效率。

方案一:iframe沙盒实现方式

当咱们在程序中执行第三方的代码的时候,首先第一个应该会想到的就是iframe。iframe不是咱们天天都会用到的html标签。要理解为何iframe为何安全,咱们有不要想一下iframe标签是用来干什么的。java

iframe比较典型的使用场景就是在一个网页中嵌入一个其余的网页。举个例子来讲,你须要在网站中嵌入谷歌地图的页面来实现地图的展示功能。你不会但愿谷歌地图的页面中的代码有能力访问你自己的一些代码和敏感数据,相应的谷歌地图也不但愿你可以访问他页面中的数据和代码。c++

这意味着一切和iframe的交互都受限于浏览器。当iframe和原网页有不一样的域(imow.cn和google.com),他们是彻底隔绝的。那么网页和iframe交互的惟一办法就是经过 postMessage。这个message是一个string。须要交互的双方能够选择忽略这个message或者作对应的动做。git

iframe和原网页是彻底独立的,其实,若是你想要的话浏览器容许咱们经过另一个线程来建立一个iframe。这里.github

当咱们了解了iframe是如何工做了之后,咱们能够在咱们须要执行第三方插件的时候创一个iframe,将插件的代码在iframe中执行。在iframe中插件能够执行任何代码,也不会影响到主程序,除非经过提早申明好的message。同时咱们能够给iframe的域名设置为null,这意味着根据浏览器跨域保护策略,iframe没法给域名发送任何请求。web

iframe就这样很简单的成为咱们执行第三方插件的沙盒环境,他的安全性也经过浏览器来保证。插件在沙盒中执行,经过主程序提供的api(postMeassge)和主程序进行交互。代码就像下面这个样子编程

const scene = await main.loadScene() // 从主程序获取界面数据
scene.selection[0].width *= 2  // 修改界面数据
scene.createNode({
  type: 'RECTANGLE',
  x: 10, y: 20,
  ...
})
await main.updateScene() // 向主程序发送修改后的界面数据
复制代码

这里主要的代码是loadScene(发送消息给主程序,而后得到主程序界面的document拷贝),而后修改完之后经过调用updateScene(发送更新消息给主程序).这里须要注意的是api

  • 咱们拷贝了整个document而不是在每次须要读取或者修改属性的时候经过message传输.postMessage每次传输须要0.1ms.每秒钟大约只容许1000 messages。
  • 咱们没有让插件直接使用postMessage api,而是包装了一个api给插件用户使用,这样使用起来不会太笨重。

问题#1:async/await 使用起来不是那么方便

这种实现方式第一个问题就是对于一些不那么了解javascript的新手或者设计师来讲,async/await关键字仍是很是陌生的。可是要使用postMessge是一个异步操做。因此不可避免的要使用async/await来控制异步流程。可是若是只是须要在开头和结束的时候调用咱们的api还方便,咱们能够告诉用户在调用咱们的api时候在前面加上async/await即便他们不知道这个关键字的做用也不会对他们的操做形成很是大的困扰。跨域

可是问题是有些插件须要执行很是复杂的逻辑,在修改一个layout的属性的时候有时候会引发其余好几个layout的更新。好比更新外层的layout的属性以后,内部的layout的属性也可能发生了更新,这个时候你须要先提交你的属性,而后在从新或者视图的属性,那这个时候你的代码就会变成这样:

await mian.loadScene()
... 操做 ...
await mian.updateScene()
await mian.loadScene()
... 操做 ...
await mian.updateScene()
await mian.loadScene()
... 操做 ...
await mian.updateScene()
复制代码

这个代码一会儿就变的不可控了,并且用户也很难确认何时应该要提交个人属性更新。

问题#2:拷贝视图给iframe的操做是很是昂贵的

iframe这种实现方式的第二个问题就是,当你须要给插件发送视图信息的时候你须要序列化你的document发送给你iframe,当你的视图很是很是大的时候,这个序列化的操做是很是耗时的,甚至会致使内存溢出。 即便咱们可使用增量的加载数据或者懒加载数据这种方式仍然有他的问题:

  • 首先这种方式是很是难实现的,即便有比较好的方案实现了之后,面对比较大的视图,性能仍然不是很理想,并且对于插件开发者来讲是很是难理解的,这违背了咱们的插件易开发性。
  • 异步方法须要等待你须要到的数据达到才能开始后面的操做,对于异步流程控制来讲也是一个挑战(steam? Rx?)。

总的来讲若是你的主程序有很是大的document要交给第三方插件来进行操做,那么iframe的这种实现方式就不是很是理想的解决方案

eval

若是能在主线程上执行插件代码,那么在性能上就会好不少,可是咱们又不能简单的eval(code)执行插件代码,由于这样是很不安全的。

什么致使eval不安全

若是咱们退一步想:是什么使eval方法不安全?若是咱们只是执行一段很单纯的代码

let code = 'let a = (7 + 1) * 8;'
eval(code)
复制代码

若是只是一段逻辑代码,那么这个代码是没有什么不安全的。之因此认为eval执行的代码不安全是由于在插件代码中有可能会发送网络请求,修改全局的state变量,或者直接修改dom对象等等这些使得咱们的插件代码变的不可控,换句话来讲是插件具备浏览器api访问的能力让咱们插件的代码变的不可控

是否是能把全局的对象藏起来?

若是咱们能把全局的对象藏起来,保证插件代码中只能作变量的赋值或者一些if判断的逻辑代码,没有了全局对象xhr,插件将没法发送请求,没有document对象,插件也不具有访问dom的能力,那么插件能力是否是能在咱们的可控范围里面了。

隐藏全局对象,理论上是可行的。可是咱们很难仅仅经过隐藏全局对象来建立一个绝对安全的运行环境。举例来讲,咱们如今把window对象设置为null,可是代码仍是能够经过({}).constructor来访问全局对象。因此找到全部有可能访问危险api的对象,把全部的路所有堵死是很是难的一件事情。

是否是咱们能够找到一个这些全局对象从一开始就不存在的沙盒环境?

方案二:将javascript编译成WebAssembly

Duktape是一个轻量级的用c++写的javascript解释器,他能够将javascript编译成WebAssembly,通过test262测试以后,能够肯定他全面的支持了ES5的语法。

这种实现方法有如下几种优缺点

  • 首先这是一种安全的执行环境,由于Duktape不支持任何的浏览器API。做为WebAssembly执行,他自己就是一个沙盒环境,他能够经过提供一个白名单的API和主程序进行交互。
  • 这个解释器是运行在主线程上的。这意味着咱们能够建立一个基于主线程的API。(共享document等)
  • 他可能会比本来的javascript慢一些,由于JIT解释器在编译的时候作了不少的优化,可是做为WebAssembly我相信这个性能应该也是能够被接受的。
  • 他须要用浏览器来编译WebAssembly,这会有一些性能消耗。
  • 浏览器的调试工具就不能用了。

看起来好像不错,可是他做为一个线上项目的表现到底怎么样呢?一个javascript引擎来执行另一个引擎?WebAssembly自己也是比较新的一个东西,咱们是否是真的须要一个相对复杂的解决方案?有没有更简单的方法了?

方案三:Realms

这个技术能够建立一个沙盒环境来支持插件,当我看到他readme文档的时候,就一会儿提起了个人兴趣,Intuitions

  • sandbox
  • iframe without DOM
  • principled version of Node's 'vm' module
  • sync Worker

这不就是咱们须要的吗?他的代码看起来是这个样子

let g = window; // outer global
let r = new Realm(); // root realm

let f = r.evaluate("(function() { return 17 })");

f() === 17 // true

Reflect.getPrototypeOf(f) === g.Function.prototype // false
Reflect.getPrototypeOf(f) === r.global.Function.prototype // true
复制代码

是否是很酷。这个技术其实能够用如今已有的可是不经常使用的一个javascript功能来实现。代码想这样

function simplifiedEval(scopeProxy, code) {
 'use strict'
  with (scopeProxy) {
    eval(code)
  }
}
复制代码

这个就像一个简单版本的Realms,可是管中窥豹,咱们能够看见两个关键代码withProxy对象。

with(obj)表达式建立了一个做用域,当寻找变量的时候,可使用这个obj的属性.看个例子:

with (Math) {
  a = PI * r * r
  x = r * cos(PI)
  y = r * sin(PI)
  console.log(x,  y)
}
复制代码

在这个例子里,当咱们访问PI,cos,sin的时候,就会找到Math的属性。可是console由于Math没有就仍然会找到全局对象。

知道了with表达式,接下来就是Proxy对象,这个对象有下面几个特性

  • 他是一个普通的javascript对象,能够经过obj.x访问对象的属性值.
  • 咱们能够实现一个对象属性的get方法来实现obj.x操做,实际上只执行这个get方法.
const scopeProxy = new Proxy(whitelist, {
  get(target, prop) {
    // target === whitelist
    if (prop in target) {
      return target[prop]
    }
    return undefined
  }
}
复制代码

接下来咱们就能够把这个scopeProxy对象做为参数传入with中,他就捕获做用域全部的变量查找,在这个scopeProxy的get方法中进行查找这个变量:

with (scopeProxy) {
  document // undefined!
  eval("xhr") // undefined!
}
复制代码

这里只有whitelist的属性会被返回,其余都会返回undefined.可是其实利用一些相似({}).constructor表达式仍是有可能访问全局对象的.此外,这个沙盒其实仍是须要访问一些全局对象的方法的,相似Object.keys

为要给咱们的插件系统访问受限全局api的方法而后又不会把window搞乱,Realms沙盒经过建立一个和主程序同源的iframe用来拷贝须要用到的全局API。这个iframe和咱们第一种实现中建立的iframe不同,他不是做为运行程序的沙盒。当你建立一个和主程序同源的iframe之后

  1. 他会拷贝一份分开的全局对象(好比:Object.prototype)。
  2. 这些全局对象能够从父文档中访问,也就是说咱们能够在Realms访问这些全局对象.

咱们将这些全局对象放入到Proxy的白名单(whitelist)中,这样在插件代码中就能够访问这些全局对象了。经过建立iframe来拷贝全局对象有一个很重要的好处:即便是经过({}).constructor对象访问到的全局对象,也会是iframe中拷贝的全局对象。这样的实现方式有这些优势:

  • 他在主程序中运行。
  • 由于他自己仍是javascript,因此他仍然用JIT编译解析,浏览器对javascript的优化仍是有效。
  • 浏览器开发工具也仍是有效的。

那么就剩下最后一个问题.他真的够安全了吗?

这样看起来结合了iframe的Realms看起来彷佛已经挺不错的了,并且他自己也是tc39下面的项目,因此可靠性应该也不错。可是光有一个安全的沙盒环境是不够的,你的插件确定须要和主程序进行交互,那么咱们就确定要为咱们的插件系统提供API,提供给插件的API系统也必定要是安全的。

举个例子,console.log是浏览器的api是否是javascript功能,那么咱们要为插件系统提供一个console.log方法,咱们能够这样写:

realm.evaluate(USER_CODE, { log: console.log })
复制代码

或者为了隐藏方法自己,咱们能够要求他只传参数

realm.evaluate(USER_CODE, { log: (...args) => { console.log(...args) } })
复制代码

看起来是这么回事,很惋惜,这实际上是一个安全漏洞,即便是第二种方法咱们仍是在Realms外面建立了一个匿名方法,而后直接传入到Realms中。这意味着插件能够经过方法的原型链访问到外部。

正确建立console.log方法的方法是,将这个方法经过Realms包裹起来在Realms内部建立像这样

// 建立一个工厂方法
// 这个工厂方法返回一个新的方法他保存一个闭包
const safeLogFactory = realm.evaluate(` (function safeLogFactory(unsafeLog) { return function safeLog(...args) { unsafeLog(...args); } }) `);

// 建立一个安全的方法
const safeLog = safeLogFactory(console.log);

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

// 使用
realm.evaluate(`log("Hello outside world!")`, { log: safeLog });
复制代码

一般来讲,在沙盒中不该该可以访问到外部的任何对象包括做用域。由于咱们的插件和主程序运行在一个线程中,因此在提供api的时候要很是当心,特别是当你的api须要在realm内部操做外部对象的时候。这对于开发api的开发人员来讲是否是有点太不友好了,一不当心就产生了安全隐患,(todo:完善起来)。

结论

若是咱们的主程序不是特别复杂并且庞大的话,第一种经过iframe的实现方式应该是最为简单的。

若是咱们的主程序自己就是经过WebAssembly建立的例如CAD网页版,咱们想第二种方式多是比较适合他们的,或者他们提供更加优秀的基于WebAssembly的解决方案

最后一种方式若是咱们能提供一种简单又安全的开发API的办法,这应该是一种性价比比较高的解决方案。

相关文章
相关标签/搜索