背景与目标
酷家乐云设计工具在服务客户过程中,开发了 OpenApi、规则检测、工具小程序等多种个性化定制方案,以满足商家的广泛需求。OpenApi 实现系统间的对接,规则检测确保设计方案的合规性,而工具小程序允许第三方在工具中运行自定义应用。尽管如此,定制化需求的实际应用中仍面临诸多挑战:客户虽然希望利用酷家乐的众多功能,但也期望这些功能的每个流程环节能够满足他们的特定需求。
在这种情况下,无论是使用 OpenApi 进行系统对接,还是创建新的小程序,都难以将常规功能流程转变为自定义节点。而规则检测虽然对用户方案进行校验,但难以深入到每一个功能的详细流程。以往,我们通过提供大量配置项来应对这种精细化的定制需求,用户可以通过不同的配置组合以产生各种效果,但这反而引发了更多的问题。
配置项的组合需要既满足商家的个性化需求,又保持通用性。这导致配置项的总体功能必须覆盖所有客户的需求,尽管大部分客户只按需使用其中的子集,但是还是有一些客户可能做出出乎意料的组合。随着组合数量的增加,穷举所有可能性变得越发困难,即使是开发人员也越来越难以理解整个功能的逻辑。
尽管已经引入了大量的配置项,但它们依旧难以迅速适应客户的突发需求。每当客户提出新的需求时,我们仍需进行深入研究以探索如何满足这些新的要求。
配置项的错误设置可能会对客户的业务造成负面影响。因此,如何确保每次用户修改配置时都能保持高质量,成为了我们面临的一个新挑战。
为了解决这种问题,我们寻求一个能够通用地描述用户逻辑的脚本引擎,并对我们的需求进行了详细分析。
由于设计工具中有小程序的存在能够提供前端的定制功能,我们更需要考虑如何扩展后端逻辑中的流程,需要运行在 JVM 中。
它需要是零散的,可以在功能流程的各个环节中方便的暴露出自定义的接入点。同时不同的环节会有不同的上下文,要求的输入和输出也不一样。
业务本身已经有预置逻辑,想要以预置的逻辑为模板,客户在此基础上扩展出自己的定制逻辑。可扩展的除了函数还有数据结构。
客户需要自己定义数据结构,这不止体现在中间数据,还体现在最终在工具中的样式,和对接生产的数据上。
客户的定制逻辑可能过于复杂,比如全屋定制场景中对脚线顶线等工艺规则的需求,需要自定义的函数可复用。
客户的规则经常需要非常个性化的想要持久化的数据表格:让客户管理员能够自己定义新的数据表格,以及增删改数据;并且可以在脚本中查询配置的数据。
在设计工具中,有时候即使小程序也太重了,客户希望用已有的功能,同时希望能直接定制相应功能的参数面板。
此外,酷家乐是一家 Saas 公司,服务很多商家。因此还对这个脚本引擎还有技术上的需求,需要它能够服务多租户的场景。
1. 安全性:避免一些恶意脚本的攻击。
2. 租户间的逻辑隔离:客户的所有配置需要按商家维度隔离开。而物理隔离的硬件成本和维护成本太高,只能逻辑隔离,既包括客户自定义的数据结构、数据配置、函数逻辑等功能方面,也包括 cpu、内存等硬件资源放方面,避免互相影响。另一方面又不排除租户间可能出现脚本共享的问题
3. 测试和生产的隔离:规模比较大的商家通常有自己的 IT 系统,他们内部需要测试发布流程,但是都对接酷家乐的线上系统。因此需要我们的脚本引擎也能够实现用户配置脚本的测试生产隔离,用户自定义的数据结构、脚本等都需要区分“测试”和“正式”环境。客户的脚本配置都需要先在测试账号上测试通过后,才发布给本商家的所有用户。
4. 由于后端都是无状态服务,因此我们希望这个脚本引擎是轻量的,能够方便的加载、缓存、执行和销毁。
整体方案
我们考虑了多种在 JVM 上运行的脚本引擎,包括 Groovy、Scala、JavaScript、Jython 和 Drools。虽然这些语言能够提供一定程度的隔离性,但它们不太适合实现与商家的持续共享。特别是在数据结构经过商家扩展后,将我们的更新同步到商家的定制数据结构中会相当困难。我们需要一种方式能够在符号引用的粒度管理依赖关系。Clojure 可以以较低的成本实现这一点,但其学习曲线对新手来说相对陡峭。同时,我们也尝试了低代码引擎,使用 JSON 表示的抽象语法树(AST)来表达逻辑,但这在人工阅读和维护方面过于复杂,且不支持自定义数据结构。
最后我们选择了自研一个脚本引擎,实现符号引用粒度的依赖管理,目前看运行良好,除了满足上述需求外,有一些额外发现:
参考了 TypeScript 语法,但是去掉了很多复杂的语法。我们并不追求引擎有完备的表达能力,只要能够支撑用户“组装”我们提供的基础能力就行。很多不必要的语法都干掉了,拒绝多态,甚至 for 循环和 while 循环都干掉了,使用 lambda 代替。
脚本引擎可以做到完全的静态强类型。可以实现限制 API 访问、CPU 资源限制。
复杂的代码可以转换成基于依赖树的数据,可以用于逻辑的分析管理。
可以做面向用户的脚本代码调试器,方便处理工单。
与直接使用改造现成的开源脚本引擎相比,开发一个约束了语法范围的脚本引擎的成本并不高,而且有完全的掌控能力,不需要受完全用不到的特性的影响。
除了支持一期特定的 TypeScript 子集,还可以支持兼容 Drools 的语法,提供直接运行 Drools 脚本的可行性。
相比直接维护 AST 树的好处:脚本形式易于理解和版本化。加上细粒度的依赖管理,增强了 AI 生成的可行性。
架构
经过持续的演化,目前脚本引擎逻辑上大概拆分成以下模块:
为了实现酷家乐与商家间的共享、以及商家之间的隔离,我们把所有的引用都划分成了多个空间。每个空间有一部分数据结构或者函数定义。不同的用户会看到不同的空间组合,组合内不同空间的所有定义合并后就是用户看到的完整的数据结构或者函数定义。组合内空间之间有优先级,优先级高的会覆盖掉优先级低的。
我们希望用户能够扩展的主要是数据结构和函数,因此定义了自定义对象和函数这两种定义。此外还有支撑用户编排逻辑的基本数据类型,它是不允许用户修改的。加上管理定义的元数据管理器,一起构成了元数据系统。
为了减少脚本编写的错误率,我们希望不管脚本是用什么语法写的,都需要是完全静态强类型的,因此定义了一套类型系统,能够支持类型校验和类型推演。
执行引擎是指脚本的编译、连接和执行整个过程。另外在元数据定义的基础上,定义了基本的数据结构和函数。这样就可以编写并执行基本的脚本。
提供预置 API 的机制。客户自定义逻辑不可能从头开始写,需要对应的功能模块提供一些基础数据和功能函数,客户做的更多是对基础能力的组装。此外也限制客户只能访问预置的 API 和基本的数据结构。
提供客户自定义对象和函数的界面,并且用户的定义有测试和发布两种状态。客户的普通账号只会执行发布状态的定义,而测试账号则会执行测试状态的定义。数据实例存储模块支持客户能够增删改查自定义对象的数据实例。
空间划分
在描述商家间的逻辑隔离与共享时,我们将其建模为空间划分。简而言之,每个商家和环境都对应着一个独立的空间,同时酷家乐的预置功能也占据一个单独空间。这些空间彼此隔离,确保数据与功能的独立性。然而,用户可以跨空间访问,通过所拥有权限的多个空间进行数据和功能访问。这些访问的空间集合,连同其内部的优先级设置,构成了用户的搜索空间。当用户编译或链接脚本时,系统将在用户的搜索空间内寻找所需的符号引用,优先考虑先找到的符号。用户可以在其有权限的空间内定义新的数据结构或函数,但需保证这些定义不会破坏脚本从搜索空间角度观察的编译正确性。
目前,我们主要处理三种空间:酷家乐的预置空间、商家的正式空间以及测试空间。预置空间包含酷家乐的核心功能,而商家的数据结构和函数最初在测试空间中创建或扩展,一旦经过测试并发布,就会被迁移到正式空间。商家可以通过三种角色来交互这些空间:测试账号、正式账号和脚本管理员。测试账号专门用于测试新脚本,其搜索空间包括测试空间、正式空间和预置空间;正式账号用于日常业务,其搜索空间包括正式空间和预置空间;脚本管理员则依据其管理内容的不同,搜索空间会在测试账号和正式账号之间变化。
比如下图中,酷家乐预置了数据结构 A 和 B、函数 C 和 D,商家曾经对 A 和 D 进行过扩展并且发布到正式空间了。这样商家的正式账号访问到的就是正式空间中的 A 和 D,以及预置空间中的 B 和 C。如果 B 和 C 引用了 A 或者 D,正式账号在执行 B、C 脚本时调用的 A 和 D 是正式空间中的。现在商家新对 D 进行了修改,但是还未发布,此时正式账号还是使用的正式空间的 D;但是测试账号使用的 D 就是测试空间的。当测试账号测试没问题了,把 D 发布后,正式账号才会访问到。
如果商家没有开启定制脚本功能,这些商家的搜索空间就只是酷家乐预置空间。当一个商家开启后就会多出测试空间和正式空间两个空间。
元数据
我们希望用户能够自定义数据结构,从而更精确地反映他们的领域需求。为此,我们在元数据模块中引入了“自定义对象”,它类似于简单的 Java 对象(POJO),主要由各种属性组成,并允许用户直接添加新属性或通过继承进行扩展。自定义对象可以通过多种方式实现,包括从 Json 构建、编写代码或使用 Java 类注解。特别是通过 Java 注解方式,自定义对象能够与 Java 代码直接交互,这样不仅方便了用户构建自定义对象,同时也允许用户在酷家乐预置的基础上进行扩展。
同样,我们希望用户能够根据需求自定义函数的逻辑和复用性,以实现和优化他们的业务流程。因此,我们在元数据中定义了“函数”,作为一段具有输入参数和返回值的可执行逻辑。这些函数可以对应 Java 中的静态方法或单例 Bean 的方法,也可以是用户可编辑的脚本。如果是脚本,用户可以直接查看和修改内容,只需确保修改后的脚本可以成功编译。
在用户组装业务数据对象和业务逻辑流程时,我们会为用户提供一些基础数据结构,比如我们在实现功能业务时积累的几何数据或者支撑业务领域的数据结构。这些基础数据结构也体现在元数据定义类型中。基础数据结构有属性和方法,可以在 Java 代码中通过注解的方式进行声明。
此外我们还定义了“插件”,他可以对对象、函数的定义进行修改。在构建完对象和函数的定义后,会解析定义上是否启用了插件。如果启用了插件就会调用插件对定义进行处理。这个处理过程可能会影响对象或者函数的定义,也可能只是触发了一些事件。比如为了让用户能够自定义可持久化的数据表,我们构建了“配置数据”插件,可以对自定义对象进行处理。
编译执行引擎
用户脚本的执行流程基本上分为两个阶段:首先是将脚本编译链接成可执行对象,其次是执行这个对象。在这个过程中,降低用户编写脚本时的错误率是我们需要重点考虑的业务需求。为此,我们倾向于使用静态强类型语法,实现及时的类型检查,同时为了减轻用户的编码负担,我们的系统支持类型自动推断功能,避免用户频繁地声明类型。
在编译和链接阶段,我们构建了一个强大的类型系统,能够进行静态类型检查和类型推断。这个系统将类型统一描述为 Type,包括用于描述元数据的“定义”类型(类似 Java 的 Class),基础简单类型,范型类型,类型参数,函数类型,Void 类型,以及“任意”类型等。
为了未来能够低成本地支持多种语言,包括难以维护的“机器语言”,我们的目标是构建一个能够统一进行语义和逻辑分析、性能优化、依赖管理、版本管理和调试的引擎,而忽略掉具体语法的影响。整体编译链接的过程如下图所示,包括根据特定语法对脚本文本解析成标准 AST 的 Compiler,以及将标准 AST 连接成可执行结构的 Linker。
这里除了编译都是与具体语法无关的。目前编译只支持类似 TypeScript 的语法,大体与 Ts 相似,但是有些区别:
砍掉了很多不需要的语法,甚至 for/while/try-cactch 也砍掉了。通过对已有业务的 drools 脚本的观察,其实 for/while 都是可以用 lambda 代替的,for/while 循环能够提供的自由度是不需要的并且给代码增加太多细节。try-catch 也会大幅增加代码复杂度,我们参考了函数式编程中的 Maybe 语义,用 Maybe 代替 try catch。
彻底的静态强类型。
去掉了 Statement,全部都是 Expression,即每个完整的代码元素都有类型和值。
编译后通过链接,形成可执行的数据结构,都继承自 Value,这里只举出其中一些 Value 子类:
为了确保单个商家的脚本运行不会过度消耗 CPU 资源,进而影响其他商家脚本的性能,我们实施了一项资源保护机制。具体来说,在需要执行函数时,流程执行器会将待执行的函数流程登记至保护器,并将其提交至专用的线程池执行。保护器负责监控每个脚本的执行时间,以顶层脚本为监控单位。如果脚本的执行时间超过预设的阈值,保护器将主动中断脚本执行。
用户脚本依赖于酷家乐提供的基础设施和能力来实现其功能。我们通过注解来配置哪些基础数据结构和 API 可以被用户访问。具体来说,包扫描器会识别并处理这些带有特定注解的类和方法,将它们转换成脚本引擎可以识别和处理的结构。目前,我们的脚本引擎与 Java 数据结构相兼容,实现了双向调用。
同时,酷家乐业务允许声明接口,并让用户实现这些接口以定制特定的业务逻辑。用户可以在配置页面中查看这些接口,并利用脚本来实现它们。当流程执行器在业务逻辑中运行这些接口时,它会使用用户脚本提供的结果,并将这些结果应用到实际的业务场景中。
执行引擎的整体架构如下图:
未来展望
目前脚本引擎已经应用在酷家乐的多个业务中,已经解决了多个领域的 50+个性化需求。目前服务于数十个商家,他们配置了数百个脚本或者对象。此外有更多的业务计划接入脚本引擎。已经使用和计划接入的业务通常有个共同特征:
有比较多的用户自定义的功能细节。
现有配置已经比较复杂,基本是支持到多种配置项再配合 SPEL 表达式的方式进行配置,甚至要多个 SPEL 配置项协作来完成功能。配置功能本身已经比较难以理清。
然而用户还是有更复杂的自定义需求,已经超出 SPEL 的表达能力。
脚本引擎目前基本上能够满足特定场景的需求,但它还存在一些不足之处。在设计初期,我们希望脚本引擎能够支持多种编程语言,但现在看来,初版设计与特定语法过于耦合,实现这一目标将需要一定的重构工作。此外,脚本引擎的线上运营能力还有较大的提升空间,尤其是在线调试功能目前还非常有限。
我们还希望脚本语法能尽可能简洁,便于梳理和表达业务逻辑,但实际上这很难实现。即使对于经验丰富的开发者,也容易回归到过程化的思维方式,这使得自动化语法流程分析变得复杂。从业务角度来看,目前还无法实现“Write once,Run everywhere”
未来,我们计划根据业务方的需求,从补全脚本引擎的功能能力、建立代码数据化的元数据平台、以及为主要业务场景构建高级参数化模型工具包等三个方向对脚本引擎进行优化和扩展。
嘉宾介绍:
郑虎,一位经验丰富的后端开发专家,拥有 10 年的工作经验。他擅长多种编程语言,包括 Java、Scala 和 Clojure 等。他对处理高并发问题有着深入的了解,并能够通过技术手段有效地解决这些挑战。此外,嘉宾也擅长系统性分析,以点带面系统性的规划和解决软件系统中的问题。
评论