开放与集成:酷家乐云设计工具插件系统的秘密
在酷家乐云设计工具推出插件化二开系统之前,基于 HTTP 的 OpenAPI 已经运作多年,很多客户使用 OpenAPI 把我们的 SaaS 服务和自己的信息系统集成到了一起。这部分客户因此可以将自己的业务流程运行得更加简单和高效。这也是 OpenAPI 的特点,擅长在不同系统间做数据上的对接和系统的集成。而在另一方面,越来越多的需求指向了一个方向:客户希望在酷家乐中扩展功能。这让我们开始考虑为酷家乐提供一个插件系统,允许第三方开发者开发在酷家乐内运行的功能。我们在 2021 年启动了这个项目,并将这套插件系统取了个对外的名称,叫做酷家乐工具小程序。
酷家乐云设计工具在技术上的两个显著的特点会明显影响小程序架构的设计。首先酷家乐的客户端是基于 Web 技术的,大部分用户直接在 PC 浏览器中使用酷家乐,小程序也同样会运行在浏览器中。这意味着我们的小程序系统只能在浏览器的技术栈中寻找方案。另一方面,酷家乐作为一款设计软件,他的 Web 页面是一个相对胖的客户端,大部分 API 的实现完全由前端提供,小部分会涉及到对后端接口的调用。
这篇文章将回顾我们在设计酷家乐工具小程序时想要达到的目标,以及为了这些目标所做的权衡和选择。
引入第三方开发者所产生的一个最显著的不同就是,尽管大部分开发者都会尽力为用户提供最好的功能体验,仍然会有部分代码的质量无法保证,甚至本身就是恶意的。因此,在引入小程序后,依旧保证酷家乐的安全性和可用性是我们首先关注的目标。
另一方面,酷家乐一直致力于为用户提供易用、好用的设计软件。这样的理念也需要延续到小程序上,我们希望用户在使用小程序时,感受到的也是易用和好用。有些插件系统会让用户承担管理插件的责任,比如控制处于激活状态的插件的数量,以免过多的插件将整个系统的运行速度拖慢到影响体验的程度。更糟糕的情况是不同插件的功能会产生冲突,需要用户去仔细处理这些冲突。而我们希望用户在使用小程序时只需要关心小程序的功能本身,不增加其他负担。
除了终端用户,我们也希望开发小程序的工作对于开发者来说是简单易学的,并且可以支持他们开发出功能强大的小程序。
综合上面的介绍,我们为小程序架构设置了下面四个目标,其中安全性和可用性高于易用性和扩展性:
安全性
小程序不能绕过 API 访问酷家乐的任何数据或功能。这包括小程序不能以酷家乐的域名发送后端请求。
不同小程序之间的数据是隔离的,除非它们之间有显式的授权。
小程序不能误导用户,把自己的功能伪装成其他小程序的或酷家乐原生的功能。
可用性
小程序自身的错误和异常不能影响到酷家乐本身的正常运行。
小程序不能影响酷家乐的性能,以至于影响用户正常的使用体验。
易用性
用户使用小程序的体验尽量向酷家乐的原生功能靠齐,同时不必为管理小程序付出额外的成本。
开发小程序的门槛尽量低,开发者可以用自己以前的技能、经验和工具来开发小程序。
扩展性
小程序给开发者尽量多的发挥空间,以支持开发者开发出多种多样、功能强大的小程序,满足各类用户需求。
在我们一开始设计小程序架构的时候,对这些目标只有一个模糊的概念。在经过一次次对技术方案以及目标本身的重要性和优先级进行深入探讨后,最终形成了明确的目标和技术方案。
首先摆在我们面前的问题是,要以怎样的方式去运行小程序的代码?酷家乐运行在浏览器中,我们要做的事情可以抽象得描述成,为一个基于 Web 的 CAD 软件——或者更宽泛的说,设计软件——开发一个插件系统。这对于我们的团队来说,是一件全新的事情,同时也充满了挑战。我们首先想到的是学习别人的经验,避免自己把路走弯了。我们研究了多个在线设计软件的插件系统,最终发现了两种可选方案。
第一种方案是大家都能直接想到的<iframe> 。我们首先来看下这种方案下的小程序基本架构。
酷家乐提供一个<iframe>容器,与酷家乐不同源,小程序的所有代码运行在这个<iframe> 中。酷家乐与小程序之间唯一的通讯方式是使用 postMessage 方法。
让我们考察这种架构是否能满足上面提出的目标。首先考虑安全性。<iframe>常用于将一个网站的内容嵌入到另一网站中,防止攻击是浏览器天然需要考虑的,经过多年的发展,浏览器在安全上的工作已经非常成熟。特别对于酷家乐来说,由于 V8 出色的性能,我们推荐和引导用户使用 chromium 内核的浏览器。而在 chromium 中 <iframe> 运行在一个独立的进程中,这使得能被用来攻击的功能变得非常少,只有 postMessage。postMessage 本质上只能传输数据,用它来提供 API 可以容易地做到只暴露必要的信息,API 的安全性也能得到保证。因此,整体上来说,这个方案的安全性是完全满足要求的。
再来进一步看可用性。由于 <iframe> 运行在一个独立的进程,除了会竞争操作系统的计算资源之外,其内部功能运行几乎不干扰酷家乐本身的运行,整体隔离性非常好。
然而,这个方案也有一些不做。由于 postMessage 完全是一个异步消息的机制,用它来实现的 API 也只能是异步的。这时候小程序的代码就会是下面的样子,大量使用 await 和 async function,或者在同步方法中使用回调函数。开发者要理解异步机制,并仔细处理异步逻辑来保证程序有正确的结果。这种要求有一定的门槛,给开发小程序增加了难度。
async function asnycMiniappFunction() {
// ...
const wall = await IDP.DB.createWall(start, end);
const door = await IDP.DB.createDoor(wall, postion);
return door;
}
function miniappFunction() {
// ...
IDP.createWall(start, end).then(wall => {
IDP.DB.createDoor(wall, postion);
})
}
此外,由于 postMessage 的底层是在两个进程之间进行数据的传输,需要经历序列化、反序列化等过程,整体性能一般。在典型的 API 的数据量大小下,大约可支持每秒 1000 次的 API 调用。这样的性能基本够用,但也称不上好。考虑到酷家乐是一个复杂的设计软件,会提供丰富的 API,小程序也会相应的频繁调用 API,这个不足之处可能会被放大。
综合来说,这个方案在安全性和可用性上表现非常不错,但在易用性和扩展性上存在一些不足。如果没有更好的方案,这仍然是一个可用的方案。
寻求比 <iframe> 更好的方案,就要解决 <iframe> 存在的问题。由于 JavaScript 的单线程机制,想要为小程序提供同步 API,就得把小程序运行在主线中。这种情况下还要保证安全性,就需要一个定制的解释器或者 JIT 编译器,在解释或编译代码时能检测出不安全(比如试图访问 API 之外的 JavaScript 对象)的代码;或者一个的嵌入式的虚拟机。同时考虑到酷家乐本身就是 Web 技术实现,小程序本身也会在 Web 运行,而且 Web 的开发人员众多,所以我们我希望编程语言还是 JavaScript。不论是解释器、JIT 编译器还是虚拟机,都有很高的开发成本,我们倾向于寻找现成的实现。类似 QuickJS 或 Duktape 这样的 JavaScript 虚拟机满足我们的需求,我们最终选择的是 QuickJS。在考虑使用虚拟机后,小程序的架构变成了这个样子:
小程序的一部分代码运行在 QuickJS 虚拟机中,这个虚拟机运行在酷家乐的主线程中,通过 WebAssembly 运行。虚拟机本身只实现 JavaScript 的语言规范,不提供 API。我们向虚拟机内注入酷家乐的 API,使得这虚拟机变成了专门运行酷家乐小程序的沙盒环境。
另一部分代码运行在 <iframe> 中。这部分代码的重要职责是为小程序提供 UI。此外,在 <iframe> 中也可使用浏览器的提供的能力,让小程序可以做到上传文件、调用后端接口等功能,保证了足够的扩展性。
由于这两部分代码运行在浏览器的不同页面中,通过我们的封装,它们可以通过 postMessage 进行通信。
这个方案弥补了 <iframe> 方案在易用性和扩展性上的一些不足,让我们看看再其他目标上的表现。在安全性上,虚拟机内的代码只能受控的访问我们注入进去的外部 JavaScript 对象,不主动注入的对象则无法访问,原理上是安全的。并且在实践上,与 <iframe> 相比也没有明显差别,虚拟机的架构让其内部的代码很难找到漏洞访问到虚拟机外,并且 QuickJS 上面还有一层 WebAssembly。因此,我们认为安全性是满足目标的。
在可用性上,由于虚拟机运行在 JavaScript 主线程,如果消耗了过多的 CPU 时间或者内存,会明显影响酷家乐的运行。一个简单的例子是,如果小程序中的代码出现了死循环,那么就会使整个酷家乐失去响应,这是无法容忍的事情。不过虚拟机在原理上可以监控和限制内部代码消耗的资源,QuickJS 也实现了这一点,可以限制内存和设置中断代码的规则。下面的代码设置了一个一秒后触发中断的 InterruptHandler,并限制最多使用 1MB 内存。使用这个机制,我们可以实现一个 InterruptHandler 在小程序陷入卡死时终止其运行,并设置内存使用上限,保证整体的可用性。因此,这个方案在可用性上,依旧可以满足我们的目标。
const result = QuickJS.evalCode("let i=0; while(true){i++}", {
shouldInterrupt: shouldInterruptAfterDeadline(Date.now() + 1000),
memoryLimitBytes: 1024 * 1024,
})
console.log(result)
虚拟机也带来了两个主要的问题要解决。
首先是 JavaScript 的执行效率明显低于浏览器的 JavaScript 虚拟机,QuickJS 相较于 V8 有 30 到 40 倍的性能差距,再由 WebAssembly 运行 QuickJS,性能影响更大,会有约百倍的差距。不过我们本来也不预期在虚拟机内做 CPU 密集型的功能,这个问题不会成为阻碍。
其次是调试虚拟机内的代码比较困难,QuickJS 并未提供一个官方的 Debug 工具,开发者也无法用浏览器自带的 Developer Tools 来调试代码。我们针对这个问题做了一个开关,让开发者在开发阶段可以让原本运行在虚拟机中代码直接运行在浏览器中,调试完代码后再放到虚拟机中运行。
最终,出于酷家乐的实际情况我们选择了这个方案。我们通过在安全性和可用性上作出了在实践上微不足道的牺牲,换取了小程序可以使用同步接口,让代码更加易写,以及支持更高频率的 API 调用。对于酷家乐这样一款设计软件来说,组合各种细颗粒度的 API 来完成上层功能是很常见的,这个权衡对我们来说利大于弊。
我们在设计小程序架构的时候,也添加了不少约束来达成我们设计的目标。这些约束通常为了达成更重要的目标而放弃了次要的目标。这里我们将介绍其中两个重要的约束。
第一个约束是,同一时间只运行一个小程序。这显然会影响扩展性,使用户无法同时使用两个及以上小程序。小程序的功能也会因此受到限制,在这个约束下,开发者在设计小程序功能时必须考虑因用户打开其他小程序而被关闭的情况,那么开发者就会倾向于不提供需要长时间驻留的功能。比如一个会根据用户设计内容变化而不断给出建议的小程序是难以实用的,会在用户启动其他小程序使被关闭,功能也就中断了。虽然有这样的限制,我们还是加上了这个约束,因为这关乎对我们来说更重要的可用性目标。随着同时运行的小程序数量的增加,势必会同步增加 API 调用频率和小程序本身的代码运行时间,这些都会挤占非常宝贵的主线程的运行时间,将明显的拖慢酷家乐的运行速度。因此,综合考虑下,我们加上了这个约束。
第二个约束是,小程序的 UI 只能运行在酷家乐提供几个有限的 UI 容器内,并且这几个容器的外框都明显地告诉用户,这是一个小程序。这是出于安全上的考虑,避免小程序将自己伪造成酷家乐原生的功能,对用户进行恶意的引导。与安全相比,UI 上的代价就显得微不足道了。
不论是虚拟机加 iframe 的架构,还是我们所加的额外约束,都是我们面对酷家乐工具这个特定的软件以及我们所关心的特定目标时所做出的选择,肯定不适用于所有场景。事实上,在酷家乐工具小程序逐渐发展的过程中,用户使用小程序的工作流会轻易地走到酷家乐工具之外。用户希望在工具外的其他页面、在移动端能继续他的工作流,开发者也因此希望能在这些地方进行二次开发,为用户提供功能。这个场景就和酷家乐工具很不一样,我们直接使用了 iframe 作为底层框架。
除了使用场景的不同,时间的流逝也会导致目标的变化。比如当用户使用的小程序不断增多时,同时使用多个小程序的概率就会逐渐增加。当这个问题明显影响用户使用小程序时,我们就会寻求新的方案来规避问题。经过两年的发展,我们也确实开始在内部尝试了这样的方案。
设计和实现一个插件系统是一件有相当挑战的事情,特别是酷家乐还是一款运行在 Web 的设计软件,这让我们遇到了更多的限制和挑战。希望我们的经验可以给到读者参考和启发。
FCon 全球金融科技大会将于 11 月在上海开幕,会议聚焦当前金融行业遇到的问题,围绕金融企业在数字化转型过程中的痛点,例如数据治理,智能化、数字化风控,数字化投研,数字化营销,IT 技术能力等方向进行深入交流,扫码或点击「阅读原文」可查看全部演讲专题。
前 100 人可享 5 折特惠购票,咨询购票请联系:17310043226(微信同手机号)。
微信扫码关注该文公众号作者