小型应用中的脚本功能:宏不需要容器
免责声明:我维护 Aussom,一个适用于 JVM 的 Apache 2.0 解释型语言。我在此主张的正是这一方案,并且我已尽量说清它在哪些场景下是错误选择。
你的应用已有(或应当有)一个脚本功能:宏系统、规则层、插件钩子,或者某种能让用户扩展应用、按自身需求定制应用的机制。
二十年来,JVM 上的标准答案是嵌入 Groovy 或某个 JavaScript 引擎,然后依靠安全管理器(Security Manager)。这个选项现在已经消失了。JEP 486 在 JDK 24 中禁用了它,而且无法重新开启。OpenJDK 自己的说法是:该平台不再提供沙箱。
几乎所有此后写下的方案都在假设你运行的是多租户云平台。隔离堆、资源计量、微型虚拟机、进程外执行单元。这些建议都很好,但如果你发布的是桌面应用、内部工具或小型自托管服务器,它们几乎毫无用处。
小型应用需要不同的答案。以下就是理由。
常见选项,以及它们为何不适用
注意看真正的摩擦点在哪里:不是威胁模型,而是分发。你能让用户安装什么,什么能塞进下载包,以及在背后没有任何运维团队时你能支持什么。
小型应用实际需要的是:简单
简单优先,而且这不是安慰奖,这就是产品本身。
上面每个选项都是一个系统,必须正确配置才能安全;而且每个都有足够多的活动部件,小团队总会在其中某处出错。
一个你看不透的边界,不是你能信任的边界。 这对小型应用比大型应用更重要,因为要看谁来为软件负责。云平台有安全团队,可以在周五下午修补所有实例。桌面应用则会发布到用户机器上,并常年运行在用户最后安装的版本上。你往往是从论坛帖子里才发现问题。
所以,可能被错误配置的事项数量,远比其中任何一项的强度更重要。一个你从头到尾都能读懂的解析器,脚本所能做的一切都是你写下的清单,在实际中更可靠,因为你可以检查它是否成立。
还有另外两件事同样重要:
阻止失控脚本。 你的用户真正会遇上的故障通常不是数据被盗,而是宏里的无限循环把界面卡死。人们每周都会注意到这种事;他们从不会注意到沙箱在其中正常工作。
并非用户编写的脚本。 如果用户写的是自己的宏,那就没有什么恶意代码需要防御。沙箱防御的是来自别处的脚本;不管那脚本来自社区论坛、项目文件、同事,还是应用市场,这就成了宏病毒问题。
你能得到什么
一个 jar 包。不需要替换运行时、不需要构建步骤、不需要第二个进程、不需要后台守护进程。
下面这些数字来自一台八核笔记本电脑上的负载测试,当时还有其他任务在运行。
创建一个引擎大约需要 2.6 毫秒(在 JVM 预热之后),空闲时占用 0.37 MB。这不是笔误。你可以让每个打开的文档都拥有自己的引擎,用完扔掉,然后在两次按键之间再创建一个。
两万个引擎可以同时装进一个 JVM,总共占用 7.5 GB,29 秒内构建完成。从十个引擎到两万个引擎,每个引擎的占用都是稳定的 0.37 MB。
一千个引擎同时运行,吃满全部八个核心。 四轮共执行四千次脚本,返回的错误答案数为零。
停止一个脚本只需从任意线程调用一次,并且在一毫秒内生效。
你的用户已经认识这套语法: 大括号和分号,class、public、new、this。if、while、for、switch、return。try/catch/throw。// 注释。任何写过 Java、C# 或 JavaScript 的人,一眼就能读懂 Aussom 宏,其余部分也能猜个八九不离十。参数上的类型是可选的,因此简短的东西保持简短。列表和映射使用你预期中的字面量语法:[1, 2, 3] 和 { x: 1, y: "two"},for (item : list) 用来迭代。
当你需要 Java 时,它就在那里,而且按你的规则来。 Aussom 中的 extern class 会把 Aussom 类绑定到 Java 类,因此文件、网络,还有你自己的应用对象,只要你决定允许,就都可以访问。这样做的安全性在于:由你来指明哪些是可访问的。打开允许列表开关,任何不在列表中的内容都会在脚本解析时被拒绝,一行代码都还没运行:
这就是它与“先嵌入一个通用 JVM 脚本引擎、然后再设法限制它”的区别。 使用 Aussom 时,预期模型正好相反:Java 集成是显式暴露的,允许列表从空开始。
解释器 aussom-base 是一个 Apache 2 许可的构件,可在 Maven Central 上获得。 每一行源代码都可以在 GitLab 和 GitHub 上下载和检查。
Hello world
这是策略类,它规定了用户代码可以做什么。在这个例子里,它启用了用于处理简单表达式的脚本模式。
这个类就是全部的安全配置;没有别的地方需要看。反射、把字符串当代码求值、调试器、测试运行器,以及脚本可用来收集机器指纹的系统属性,在基类中全都默认关闭。你只需要在自己的策略里打开需要的东西。
现在运行用户输入到设置框里的内容:
这就是一次完整的定制。六行代码,你的用户就可以自己写折扣规则,而不是提交功能请求。
如果表达式失控,就停止它:
脚本会在下一个检查点停止:每个循环、每次调用。运行中的代码几乎会立即停止,在我的测量中大约只需十分之一毫秒。用户会看到即时响应,而且 UI 线程从一开始就不会被阻塞。
这个表面实在太小,不太可能搞错。这就是论点。
上面的例子用的是直接的 Engine API;当你想要持有脚本、取消它,并检查发生了什么时,这是值得使用的 API。如果你更愿意走 javax.script,Aussom 也实现了 JSR 223。Embedding Aussom 概述 用一屏篇幅对这两种方式做了对比,帮助你选择。
这会把你带到哪里
你希望用户能够写出折扣规则、重命名模式或图表公式。你不想发布一个插件 SDK,更不想分发容器。
整个功能就是一个 jar 包、一个策略类,以及六行用于求值用户输入代码的代码。一个引擎占用 0.37 MB,创建时间不到三毫秒。
你会正确配置它,因为几乎没有什么需要配置。对一个脚本功能来说,这很难被超越。
本文《小型应用中的脚本:宏并不需要容器》首发于 foojay。