Gem ScriptCore - 基于 MRuby 的脚本沙箱 (SaaS 快速开发套件之番外篇)

jasl · September 29, 2018 · Last by kxu1988 replied at August 09, 2019 · 6578 hits

这个 Gem 可以说是 SaaS 快速开发套件 系列的番外篇了,我曾在上一篇 WorkflowCore 中提到了一句,这里做一个稍微正式的介绍,但其实这并不创新,因为这是游戏领域玩剩下的做法。

项目地址: https://github.com/rails-engine/script_core

在业务系统中,有很多涉及到计算的场景,比如:

  • 电商类应用中,优惠券的优惠规则需要由运营人员设计
  • 管理信息系统,根据业务表单的数据进行计算,比如计算员工的薪水、报销金额

传统的实践中,有两种典型的做法:

  • 硬编码逻辑,优点是很容易的支持复杂的需求,缺点是每次改动都需要发版,视团队开发流程和项目规模上线和修改的成本可能会高,并且发版往往有风险
  • 抽取算法做成一定程度可配置的,比如将优惠券抽取成满减、折扣、赠送等,并且根据业务场景定义可配置项,优点是在设计支持范围内无需开发者介入运营过程,解放人力且安全,缺点是总会遇到一些意料之外需求,对于支持复杂的规则难度较大

Shopify Script —— 来自世界上最大的 Rails 项目 Shopify 的解决方案

一点题外话,必须吹一波,Shopify 在互联网的流量排名上也非常靠前,也就是说访问量很大。这家公司在商业上比较成功,之前 Tim Cook 访加拿大有专程拜访 Shopify,对 Ruby 和 Rails 的生态贡献巨大,Shopify 的员工里有好几位 Rails 或 Ruby core team 的成员,包括 5.2 的 Bootsnap 等 Rails 的组件或 gem 都由 Shopify 贡献(具体可以看他们的 Github),并且 Shopify 本身提供的服务,尤其是后台管理系统和开发者站点的设计和文档,非常值得学习,这个网站和他们的博客应该是 Rails 开发者一定要去了解一下的。

Shopify 是一家提供电商站点搭建的 SaaS 服务,和国内的一些同类服务不同的是,Shopify 允许客户深度定制站点,不仅仅是前端,有很多地方需要定制逻辑:

  • 结算时的折扣逻辑:全局的优惠、指定条件的用户折扣、某些条件下的折扣等
  • 赠品、包含某些产品买 x 送 x
  • 运费计算
  • 优惠券
  • 税费规则
  • 支付渠道规则
  • ...

可以看到 Shopify 运营上的定制需求非常的复杂,可是 Shopify 的解决方案却非常简单:提供一套可编程能力来实现这些规则的定制!然后在合适的位置和时机去触发由用户(店主)编写好的脚本就好了。

这个功能被叫做 Shopify Script。

官方的介绍:https://www.shopify.com/enterprise/ecommerce-checkout-shopify-scripts

官方也提供了一些参考示例:https://github.com/Shopify/shopify-scripts

另外可以 google 关键词 shopify script 有很多官方和非官方的介绍和案例

鲜为人知的是(几乎没有相关资料和文章介绍),它所使用的表达式引擎叫做 ESS,是完全开源的,它实际上是一个 MRuby 虚拟机,并且是一个沙箱环境,也就是说来自用户的非可信(untrusted)代码,可以放心的交给它运行。

甚至 Shopify 为其提供了 BugBounty 计划来悬赏涉及安全和导致崩溃的 Bug!所以它的安全性稳定性是与顶级大厂的利益捆绑的。

就是这样的一个通用方案解决了所有上述场景的需求,而且对于我们 Ruby 开发者来说,没有额外的学习成本。论强大,有什么表达式能比一个图灵完备的流行编程语言强大呢?论表达能力,有什么语言表达能力能胜过 Ruby 呢?

ESS 原理、用法、特性、试玩参阅:https://mruby.science/https://github.com/Shopify/ess

ESS 的特点是:

  • 沙箱独立于 Web 服务进程,互不干扰
  • 沙箱和服务进程通过 MessagePack 协议(一种二进制的类 Json 序列化协议)通讯
  • 服务进程可以传递数据给沙箱,(包括回传数据)兼容几乎所有的 Ruby 内置类型:Hash、Array、Integer、Float、String、Symbol、Boolean、Time
  • 可以传递异常信息给服务进程,虚拟了 stdout 方便调试
  • 能够对执行指令的步数进行限制
  • 能够对内存消耗进行限制
  • 能够对执行时间进行限制
  • 裁减掉了对 OS 有安全风险的标准库,如 IO 相关等
  • 通过 Linux 内核的 Seccomp 机制禁止有安全隐患的系统调用,进一步提升安全性
  • MRuby 的性能较高
  • 除开实际逻辑代码,还可以传入一个预编译好的 MRuby 字节码,相当于可以在表达式引擎内提供业务相关的库或函数

它的缺点有:

  • 启动进程的开销比较大,视系统配置,调用一次大致等同于一次条件稍微复杂但有优化过的数据库查询的消耗
  • 由于使用了 Linux 内核的特性,在 macOS 上无法使用沙箱的额外安全特性,无法在 Windows 平台使用
  • MRuby 没有 Date 类型,需要转化成 Time(时间部分为 0)
  • MRuby 的 Time 类型不支持修改时区,跨时区应用要小心
  • MRuby 没有 BigDecimal 类型,ESS 自己实现了 Decimal 类型,但是没有在 ESS 的序列化模块实现转换逻辑,所以无法透明传递

有人会担心使用脚本会不会增加运营人员的难度?答案是相比上文提到的“抽取算法做成一定程度可配置的”的方案而言,并没有增加难度,而且解决了这种方案对特别复杂的逻辑束手无策的情况。

只需要让暴露给运营人员的配置器,生成相应的 Ruby 脚本即可,这并不难,有人为 Shopify Script 做了一个 https://jgodson.github.io/shopify-script-creator/

对于特别复杂的逻辑,开发人员协助运营编写好脚本即可。

性能,在 MBP 2018(Core i9 8gen),计算第一百项 fib,耗时约 3ms。

为什么要做 ScriptCore?

首先 ScriptCore 是 ESS 的 fork,因为 ESS 只为 Shopify Script 的场景考虑,我希望在 ScriptCore 能做出这些改进:

  • 工具链
    • MRuby 的 Gem 需要在编译时静态编译进可执行文件,有没有可能允许开发者添加额外的 Gem?
    • 为用户编写脚本提供便利的扩展库需要预编译成字节码来提高性能,ESS 并没有暴露编译器 mrbc
    • 开发者编辑扩展库时能否像 Assets Pipeline 那样监视变动自动重编译?
    • 编译 ESS 和 扩展库 的 Rake 脚本,以及 Cap 等常见部署工具的插件
  • 易用性
    • 自动解决时区问题(通过控制启动 ESS 进程时的 TZ 环境变量即可解决)
    • 支持 BigDecimal 的传递
  • 标准实践
    • 提供脚手架来方便扩展库开发(其实我已经逆向出了一部分 Shopify 的扩展库了,放在 https://github.com/jasl/shopify_stdlib_mutable
    • 扩展库源码、自定义 MRuby 的编译配置 如何放置?如何组织?

但由于精力有限,这些工作是需要很大耐心和实践,有兴趣的朋友可以来研究研究。

总结

  • 通过引入表达式,可以灵活的解决由运营人员主导的多变规则的需求,解放开发者的生产力
  • 希望 ESS 能够打消对于在项目中使用通过编译原理解决问题的恐惧
  • 希望 ScriptCore 能够提供开箱即用的开发体验

最后,通过表达式引擎来控制流程的流转,以及表单中数据的求值,WorkflowCore + FormCore + ScriptCore 就是我提出的信息系统的终极解决方案!

首先 ScriptCore 是 ESS 的 fork

是不是应该说 clone? fork 是基于别人的源码再开发。

Reply to Rei

我已经改了一些东西了 还有一些半成品

Reply to jasl

意思是你有 Shopify 源码?

Reply to Rei

我跟 shopify 的人聊过的,ess 他们生产在用是没错的,至于 mruby 那边,你看过 shopify script 和 ess 用法对比会发现他们肯定是有一套扩展库在里面。我也苦恼了很久在里面,直到我发现,ess 的测试用例都是他们的真实脚本,那么我逆向了他们放在项目里测试用的 mruby 字节码,我就知道了他们是如何设计标准库的。

另外 ess 极其小众,维护这个项目的人几乎不处理社区的 issues(bugbounty 除外,但其实那些修正最终都是提交给 MRuby)。

这个项目我憋了很久,因为 ess 最初的许可证是专利,在我询问半年后回答我他们忘改了...等上游改正后我才大胆的公开我的版本

Reply to jasl

奥,我看漏眼了,顶楼有写 Shopify 的开源库 https://github.com/Shopify/ess

更新了一下性能测试,随便抄了个 fib 的代码,跑了求第 100 项,3ms 可以的,比我原文中描述的快了非常多,可能是 mruby 1.4.1 带来的,我原始的实验好像还是在 1.3 上

@jasl 如果 spawn 一个 ruby,然后 remove 一些 unsafe 的东西,比如 undef File, IO, remove `等等, 会不会更简单一些?

Reply to femto

MRI 不好裁剪,mruby 的标准库的粒度细多了,比如元编程在 mruby 里都是一个 gem

@jasl 我看你说 mruby 没有 bigdecimal? 据我所知,bigdecimal 就是 bigint+ 精度,比如 (a,4) 就是 a/10000.0, 那么+,-实现只要对齐就行了,实现就是 bigint 相乘,精度相加,比如 (a,4)(b,2) 就是 (a*b, 4+2),/实现也是类似。mruby 有 bigint 么?

Reply to femto

mruby 没有内建 BigDecimal 类型(MRI 这个是标准库的一部分),当然可以实现一套,但是缺点是无法跟 MRI 的 BigDecimal 做直接映射

Reply to femto

Shopify 已经基于 mpdecimal 给 mruby 做了一个了,ESS 还有我的 ScriptCore 都内置,但是因为无法直接映射,所以使用的时候得做手动处理一下

@jasl 是 Decimal 类么?稍微试了一下, x = Decimal.new("1515") * Decimal.new("1515") x=x*x*x*x x=x*x*x*x x=x*x*x*x puts x

123827471039368400410635789691185425627549561183592154304459242100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 数字一大就错了。

另外 integer,乘着乘着就变浮点数了,这是 mruby 的特性么?

ps,感觉接下去就比较 specific 了,要不然我去 github 上开个 issue 讨论?

Reply to femto

mruby 没有 BigDecimal 类型,mruby Number 和 Float 的长度限制定义在 mruby 源码的常量里,Shopify 实现的 mruby-mpdecimal 的最大长度应该受 mpdecimal 这个 c 库的限制

Reply to femto

大 Integer 变浮点数,这个没研究过,mruby 一些行为跟 MRI 不同,主要是面向性能和内存消耗考虑的,毕竟 mruby 可以跑在单片机上

@jasl 那就看看要支持到什么程度了,或者自己实现一套。跟 MRI 做交互的话,序列话反序列化类似 [:bigdecimal,value,scale] ,虽然不知道 msgpack 啥样,反正一个值表示类型,然后再把值和精度 表示上,就可以跟 MRI 做交互了。查了一下 mruby bigint, 一个是https://github.com/chasonr/mruby-bignum, 一个是https://github.com/chasonr/mruby-gmp-bignum/ , 有 gmp support 的。

Reply to femto

那些都没有 ESS 自带的完备,另外首先需不需要那么高的技术指标本身就要商榷,我想不到什么场景需要这么大的数字要求,一般数据库能支持的无符号 bigint 才大概 20 位,另外 msgpack 的 integer 最大也是 8 bytes,就算 mruby 支持,不再搞点 trick 还是不行。

沙箱一定要支持 Decimal 是为了算钱用的。

另外做映射并不是不能做,mruby 端的序列化、反序列化要在 C++ 的部分实现,ESS 的沙箱一部分测试不开源的(用了 Shopify 系统的源码做用例),改出来了也不好测试

从 WorkflowCore 的帖子跳转过来。https://ruby-china.org/topics/37555

我对 ScriptCore 的理解是存储一个公式到数据库中,然后可以在代码中执行获取结果。

我在 stackoverflow 上看到两种解决方法

  1. 使用 instance_eval(会有安全问题)
function = "(a/b)*100"
a = 25.0
b = 50

instance_eval function
# => 50.0
  1. 使用 Gem dentaku,这是一个 math and logic formula parser and evaluator,地址https://github.com/rubysolo/dentaku
require 'dentaku'

a = 25.0
b = 50
function = "(#{a}/#{b})*100"

calculator = Dentaku::Calculator.new
calculator.evaluate(function) # => 50.0

我觉得使用这两种方法就可以解决工作流中条件判断的需求,所以没有太看明白 ScriptCore 跟上述的区别,请指教?

Reply to kxu1988

先说 dentaku,你看看他的 contributor 😂 我在做 ScriptCore 之前很早就做了 https://github.com/jasl/dentaku_calculator

其实我解决表达式引擎的最开始就是从他开始的,我给他强化了更友好的错误提示(其实比 ScriptCore 能给出的错误信息友好得多),如果他能满足你的需求,直接用是完全没问题的。

dentaku 很适合简单场景,他的文法类似 Excel 表达式,对于复杂表达式就比较捉急了,我记得过去做知人的时候有的公司的计薪方案复杂的就算用 Ruby 来写,也会特别长。

后来我发现了 Shopify 基于 mruby 的 ESS,我对技术选型的态度是,如果强大的方案没有增加简单需求的复杂度,那么选择强大的方案。还有什么样的表达式能够比 Ruby 表达能力更强呢?这就是我最终选择 mruby 方案的原因

既然已经是基于 Ruby 语言来构建表达式了,那自然就达到类似 instance_eval 的目的了,但差别在于 instance_eval 是和应用共享上下文的,也就是说这里首先有“致命”级别的安全隐患,其次,没法做有效的资源限制,有问题的表达式会拖累 Rails 进程,也就是说这个方案常规而言是严重不推荐的。

Reply to jasl

感谢回复,我现在想做的审批系统比较轻量级,希望仅仅给客户有限的条件判断(为了让客户觉得 Web 端设计流程非常简单),那 dentaku 应该可以满足我的需求😀

Reply to kxu1988

嗯,你用哪个也都在我的魔爪下😂

不过我是觉得你用 ScriptCore 别给他讲太多语法就好了...

Reply to jasl

也对,正好周末再研究一下 ScriptCore😉

You need to Sign in before reply, if you don't have an account, please Sign up first.