开源规则引擎——ice:致力于解决灵活繁复的硬编码问题


背景介绍
业务中是否写了大量的 if-else?是否受够了这些 if-else 还要经常变动?
业务中是否做了大量抽象,发现新的业务场景还是用不上?
是否各种调研规则引擎,发现不是太重就是接入或维护太麻烦,最后发现还是不如硬编码?
接下来给大家介绍一款全新的开源规则引擎——ice,以一个简单的例子,从最底层的编排思想,阐述 ice 与其他规则引擎的不同;讲述 ice 是如何使用全新的设计思想,契合解耦和复用的属性,还你最大的编排自由度。
规则引擎的应用场景
规则引擎在很多业务场景中都有应用,例如:
会员营销:由多种条件、流程、奖励组合而成,时间线复杂,代码复用率不高,调整频繁。
风控规则:由多种条件组合并返回决策,条件量大且复杂,变动频繁。
数据分析:将数据通过分析师自己编排的规则产出想要的数据,千人千面。
以上场景往往都存在一些共同痛点:
灵活业务(变动频繁,时效性明显,测试逻辑复杂)
追求灵活花里胡哨:产品和运营一直在探索新鲜玩法,导致很多抽象出来的模块往往扛不过两个迭代。
今天上线又要调整:因为一些偶发情况,如线上用户参与度不高,及时调整用户参与门槛等(当然也可以在开发前把所有情况考虑到位,但是为了小概率事件做大量的工作,成本过高)。
研发测试心力交瘁:研发硬编码,测试验证复杂重复逻辑,久而久之变的愈发疲惫。
时间线(多条时间线交织混乱)
研发编排错了再来:一般营销类型的会涉及很多时间线,而在当前,测试一个未来要上线的具有不同时间节点属性的活动,硬编码时往往由研发编排时间,测试进行测试,但是当 bug 发生并打乱时间线时,就需要重新编排时间(没有经历过的不用太了解,后面会说)。
测试并行孔融让梨:当时间线发生冲突并有多个测试在冲突位置上并发测试,往往由测试自行协调测试顺序,当一方出现问题往往导致后续测试进度不可控。
其他问题
依赖挂了难以为继:测试环境为非稳定环境,一旦依赖出了问题难免影响进度,如何能做到简单高效 mock?
修复数据苦不堪言:当线上问题产生时,受影响的客户如何快速高效的补偿?
开源规则引擎 ice 的设计思路
为了方便理解,设计思路将伴随着一个简单的充值例子展开。
举例
X 公司将在国庆放假期间,开展一个为期七天的充值小活动,活动内容如下:
活动时间:(10.1-10.7)
活动内容:
充值 100 元 送 5 元余额 (10.1-10.7)
充值 50 元 送 10 积分 (10.5-10.7)
活动备注: 不叠加送(充值 100 元只能获得 5 元余额,不会叠加赠送 10 积分)
简单拆解一下,想要完成这个活动,我们需要开发如下模块:

如上图,当用户充值成功后,会产生对应充值场景的参数包裹 Pack(类 Activiti/Drools 的 Fact),包裹里会有充值用户的 uid,充值金额 cost,充值的时间 requestTime 等信息。我们可以通过定义的 key,拿到包裹中的值(类似 map.get(key))。
模块怎么设计无可厚非,重点要讲的是后面的怎么编排实现配置自由,接下来将通过已有的上述节点,讲解不同的规则引擎在核心的编排上的优缺点,并比较 ice 是怎么做的。
流程图式实现
类 Activiti、 Flowable 实现:

流程图式实现,应该是我们最常想到的编排方式了~ 看起来非常的简洁易懂,通过特殊的设计,如去掉一些不必要的线,可以把 UI 做的更简洁一些。但由于有时间属性,其实时间也是一个规则条件,加上之后就变成了:

看起来也还好。
执行树式实现
类 Drool s 实现(When X Then Y):

这个看起来也还好,再加上时间线试试:

依旧比较简洁,至少比较流程图式,我会比较愿意修改这个。
计划永远赶不上变化
上面两种方案的优点在于,可以把一些零散的配置结合业务很好的管理了起来,对配置的小修小改,都是信手拈来,但是真实的业务场景,可能还是要锤爆你,有了灵活的变动,一切都不一样了。
理想
不会变的,放心吧,就这样,上!
现实
① 充值 100 元改成 80 吧,10 积分变 20 积分吧,时间改成 10.8 号结束吧(微微一笑,毕竟我费了这么大劲搞规则引擎,终于体现到价值了!)
② 用户参与积极性不高啊,去掉不叠加送吧,都送(稍加思索,费几个脑细胞挪一挪还是可以的,怎么也比改代码再上线强吧!)
③ 5 元余额不能送太多,设置个库存 100 个吧,对了,库存不足了充 100 元还是得送 10 积分的哈(卒…早知道还不如硬编码了)
以上变动其实并非看起来不切实际,毕竟真实线上变动比这离谱的多的是,流程图式和执行树式实现的主要缺点在于,牵一发而动全身,改动一个节点需要瞻前顾后,如果考虑不到位,很容易弄错,而且这还只是一个简单的例子,现实的活动内容要比这复杂的多的多,时间线也是很多条,考虑到这,再加上使用学习框架的成本,往往得不偿失,到头来发现还不如硬编码。
怎么办?
让我们看看 ice 是怎么做的?
引入关系节点
关系节点为了控制业务流转。
AND
所有子节点中,有一个返回 false 该节点也将是 false,全部是 true 才是 true,在执行到 false 的地方终止执行,类似于 Java 的 &&。
ANY
所有子节点中,有一个返回 true 该节点也将是 true,全部 false 则 false,在执行到 true 的地方终止执行,类似于 Java 的 ||。
ALL
所有子节点都会执行,有任意一个返回 true 该节点也是 true,没有 true 有一个节点是 false 则 false,没有 true 也没有 false 则返回 none,所有子节点执行完毕终止
NONE
所有子节点都会执行,无论子节点返回什么,都返回 none。
TRUE
所有子节点都会执行,无论子节点返回什么,都返回 true,没有子节点也返回 true(其他没有子节点返回 none)。
引入叶子节点
叶子节点为真正处理的节点。
Flow
一些条件与规则节点,如例子中的 ScoreFlow。
Result
一些结果性质的节点,如例子中的 AmountResult,PointResult。
None
一些不干预流程的动作,如装配工作等,如下文会介绍到的 TimeChangeNone。
有了以上节点,我们要怎么组装呢?

如上图,使用树形结构(对传统树做了镜像和旋转),执行顺序还是类似于中序遍历,从 root 执行,root 是个关系节点,从上到下执行子节点,若用户充值金额是 70 元,执行流程:
这个时候可以看到,之前需要剥离出的时间,已经可以融合到各个节点上了,把时间配置还给节点,如果没到执行时间,如发放积分的节点 10.5 日之后才生效,那么在 10.5 之前,可以理解为这个节点不存在。
变化的灵活快速应对
对于 ① 直接修改节点配置就可以。
对于 ② 直接把 root 节点的 ANY 改成 ALL 就可以(叠加送与不叠加送的逻辑在这个节点上,属于这个节点的逻辑就该由这个节点去解决)。
对于 ③ 由于库存的不足,相当于没有给用户发放,则 AmountResul 返回 false,流程还会继续向下执行,不用做任何更改。
再加一个棘手的问题,当时间线复杂时,测试工作以及测试并发要怎么做?
一个 10.1 开始的活动,一定是在 10.1 之前开发上线完毕,比如我在 9.15 要怎么去测试一个 10.1 开始的活动?在 ice 中,只需要稍微修改一下:

如图,引入一个负责更改时间的节点 TimeChangeNone(更改包裹中的 requestTime),后面的节点执行都是依赖于包裹中的时间即可,TimeChangeNone 类似于一个改时间的插件一样,如果测试并行,那就给多个测试每人在自己负责的业务上加上改时间插件即可。
ice 的特性
为什么这么拆解呢?为什么这样就能解决这些变动与问题呢?
其实,就是使用树形结构解耦,流程图式和执行树式实现在改动逻辑的时候,不免需要瞻前顾后,但是 ice 不需要,ice 的业务逻辑都在本节点上,每一个节点都可以代表单一逻辑,比如我改不叠加送变成叠加送这一逻辑就只限制在那个 ANY 节点逻辑上,只要把它改成我想要的逻辑即可,至于子节点有哪些,不用特别在意,节点之间依赖包裹流转,每个节点执行完的后续流程不需要自己指定。
因为自己执行完后的执行流程不再由自己掌控,就可以做到复用:

如图,参与活动这里用到的 TimeChangeNone,如果现在还有个 H5 页面需要做呈现,不同的呈现也与时间相关,怎么办?只需要在呈现活动这里使用同一个实例,更改其中一个,另一个也会被更新,避免了到处改时间的问题。
同理,如果线上出了问题,比如 sendAmount 接口挂了,由于是 error 不会反回 false 继续执行,而是提供了可选策略,比如将 Pack 以及执行到了哪个节点落盘起来,等到接口修复,再继续丢进 ice 重新跑即可(由于落盘时间是发生问题时间,完全不用担心活动结束了的修复不生效问题),同样的,如果是不关键的业务如头像服务挂了,但是依然希望跑起来,只是没有头像而已,这样可以选择跳过错误继续执行。这里的落盘等规则不细展开描述。同样的原理也可以用在 mock 上,只需要在 Pack 中增加需要 mock 的数据,就可以跑起来。
引入前置节点

上面的逻辑中可以看到有一些 AND 节点紧密绑定的关系,为了视图与配置简化,增加了前置(forward)节点概念,当且仅当前置节点执行结果为非 false 时才会执行本节点,语义与 AND 相连的两个节点一致。
Talk is cheap. Show me the code…
github:https://github.com/zjn-zjn/ice
gitee:https://gitee.com/waitmoon/ice
欢迎大家使用体验开源的规则/流程引擎 ice。如果有遇到问题,欢迎提 issue 来交流。大家也可以添加作者微信:lwaitmoonl ,备注“ice”,进入交流群。
Dev for Dev 专栏介绍
Dev for Dev(Developer for Developer)是声网 Agora 与 RTC 开发者社区共同发起的开发者互动创新实践活动。透过工程师视角的技术分享、交流碰撞、项目共建等多种形式,汇聚开发者的力量,挖掘和传递最具价值的技术内容和项目,全面释放技术的创造力。
评论