自研规则 / 流程引擎 -ice

1 背景介绍
规则/流程引擎想必大家也都并不陌生,耳熟能详的就有Drools,Esper,Activiti,Flowable 等,很多大厂也热衷于研究自己的规则引擎,都是用于解决灵活场景下的复杂规则与流程问题,想要做到改改配置就可以生成/生效新的规则,脱离硬编码的苦海。毕竟改改配置和在已有基础上编排规则/流程,比硬编码的成本低很多,但是使用市面上现有的规则引擎来编排,一来接入成本和学习成本都不低,二来随着时间的推移,规则变的越发庞大以及一些场景的不适用,更加让人叫苦不迭
1.1 场景
会员营销:由多种条件,流程,奖励组合而成,时间线复杂,代码复用率不高,调整频繁
风控规则:由多种条件组合并返回决策,条件量大且复杂,变动频繁
数据分析:将数据通过分析师自己编排的规则产出想要的数据,千人千面
......
以上场景往往都有一些共性:
灵活业务
追求灵活花里胡哨:产品和运营一直在探索新鲜玩法,导致很多抽象出来的模块往往抗不过两个迭代
今天上线又要调整:因为一些偶发情况,如线上用户参与度不高,及时调整用户参与门槛等(当然也可以在开发前把所有情况考虑到位,但是为了小概率事件做大量的工作,成本过高)
研发测试心力交瘁:研发硬编码,测试测复杂重复逻辑,久而久之变的愈发疲惫
时间线
研发编排错了再来:一般营销类型的会涉及很多时间线,而在当前,测试一个未来要上线的具有不同时间节点属性的活动,硬编码时往往由研发编排时间,测试进行测试,但是当 bug 发生并打乱时间线时,就需要重新编排时间(没有经历过的不用太了解,后面会说)
测试并行孔融让梨:当时间线发生冲突并有多个测试在冲突位置上并发测试,往往由测试自行协调测试顺序,当一方出现问题往往导致后续测试进度不可控
其他问题
依赖挂了难以为继:测试环境为非稳定环境,一旦依赖出了问题难免影响进度,如何能做到简单高效 mock?
修复数据苦不堪言:当线上问题产生时,受影响的客户如何快速高效的补偿?
......
2 设计思路
为了方便理解,设计思路将伴随着一个简单的充值例子展开
2.1 举例
X 公司将在国庆放假期间,开展一个为期七天的充值小活动,活动内容如下:
活动时间:(10.1-10.7)
活动内容:
充值 100 元 送 5 元余额 (10.1-10.7)
充值 50 元 送 10 积分 (10.5-10.7)
活动备注:不叠加送(充值 100 元只能获得 5 元余额,不会叠加赠送 10 积分)
简单拆解一下,想要完成这个活动,我们需要开发如下模块:

图中发现有待发放 key,这个 key 是从哪里来呢:

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

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

看起来也还好
2.3 执行树式实现
类 Drools 实现(When X Then Y)

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

依旧比较简洁,至少比较流程图式,我会比较愿意修改这个。
2.4 变动
上面两种方案的优点在于,可以把一些零散的配置结合业务很好的管理了起来,对配置的小修小改,都是信手拈来,但是真实的业务场景,可能还是要锤爆你,有了灵活的变动,一切都不一样了。
2.4.1 理想
不会变的,放心吧,就这样,上
2.4.2 现实
①充值 100 元改成 80 吧,10 积分变 20 积分吧,时间改成 10.8 号结束吧(微微一笑,毕竟我费了这么大劲,终于提现到价值了!)
②用户参与积极性不高啊,去掉不叠加送吧,都送(稍加思索,费几个脑细胞挪一挪还是可以的,怎么也比改代码再上线强吧!)
③5 元余额不能送太多,设置个库存 100 个吧,对了,库存不足了充 100 元还是得送 10 积分的哈(卒…早知道还不如硬编码了)
以上变动其实并非看起来不切实际,毕竟小编我遇到的变动比这离谱的多的是,上述实现的主要缺点在于,牵一发而动全身,如果考虑不到位,很容易被反噬,而且这还只是一个简单的例子,现实的活动内容要比这复杂的多的多,时间线也是很多条,考虑到这,再加上使用学习框架的成本,往往得不偿失。
怎么办?
2.5 ice 是怎么做的?
2.5.1 引入关系节点
关系节点为了控制业务流转
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)
2.5.2 引入叶子节点
叶子节点为真正处理的节点
Flow
一些条件与规则节点,如例子中的 ScoreFlow
Result
一些结果性质的节点,如例子中的 AmountResult,PointResult
None
一些不干预流程的动作,如装配工作等,如下文会介绍到的 TimeChangeNone
有了以上节点,我们要怎么组装呢?

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

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

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

上面的逻辑中可以看到有一些 AND 节点紧密绑定的关系,为了视图与配置简化,增加了前置(forward)节点概念,当且仅当前置节点执行结果为非 false 时才会执行本节点,语义与 AND 相连的两个节点一致
还有很多可以做的事情在这里不在赘述,如果看到这里并且还能理解的话,应该都会有一些想法(欢迎交流,哈哈哈)。
3 现行设计
理论有了,那就开整吧(本篇主要阐述思想,设计讲解后续更新,先放两张核心的结构图)
3.1 节点类图
框架中核心类关系图:

3.2 执行流程
请求处理和配置的拉取与更新过程:

4 Code
Talk is cheap. Show me the code…
https://github.com/zjn-zjn/ice
还有很多细节功能和设计没有写出来,后续会考虑持续更新~
受限于作者能力问题,代码暂时只能写成这样了,哈哈哈,也会不断的持续优化中~
有更好想法或者更多应用场景或者想一起探讨的小伙伴~ 欢迎交流(QQ 群:587368939)~~
版权声明: 本文为 InfoQ 作者【迟到的月亮】的原创文章。
原文链接:【http://xie.infoq.cn/article/91fa1b493237ed3470c9936b0】。文章转载请联系作者。
评论 (10 条评论)