用规则引擎让你一天上线十个需求

Posted 薯条的编程修养

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了用规则引擎让你一天上线十个需求相关的知识,希望对你有一定的参考价值。

各位读者朋友大家好,我是薯条,好久没更文章,不知还有多少读者记得这个号,这篇文章写的有点精分,如果你有耐心看完本文,可以翻翻留言区,我会发个新年红包。


+ Expression Eval来解决!这样一来,再来新的需求只需要写在db里插入俩表达式就可以了,20个需求提过来也不用怕。

你的老板微笑着点了点头,看了一眼自己手上的劳力士,有意无意的晃了晃,说:小伙子很上道,自己也琢磨出解法了,赶紧设计方案,争取本周上线,尽快拿到业务结果,到时候升职加薪少不了你的!

函数没啥好说的,主要就是编译一下正则。

比较有意思的是planStages这个步骤。planStages这个大步骤内部大概分成了planTokens、reorderStages、elideLiterals这三个小步骤,下面来一一介绍:

这个表达式,planToken执行完后,会变成这样一颗树:

重排序的过程是把相同优先级的节点进行旋转,第一步是交换左右节点:

第二步是LL左旋:

这样就平衡了,一个非常骚气的算法。

elideLiterals

这个步骤是看叶子节点是否为LITERAL,比如这棵树:

在这个阶段,各个子节点会进行dfs计算直接变成:

至此第一阶段的逻辑梳理完毕。而第二个阶段Evaluate的主要功能是把用户参数填入ast,进行求值。这个过程比较简单,本文不在赘述。

govaluate 不足

govaluate 看起来很美好,真的是这样吗?其实不然,这个项目最后一次commit是2017年,距今已经6年了。我们在使用期间也发现了很多小bug和代码优美度欠缺的地方。下面来简单列举几个:

弱类型

govaluate所有数字类型都是被解析为float64进行计算的,这么玩写代码爽了,但是当你用1+2+9做表达式时,可能会得到一个类型为fload64的interface结果。

函数限制

govaluate的函数有的返回值无法继续做运算。比如这个case:

看起来没有任何问题,但是执行会报错:

参数会去除转义符

比如这段代码:

理论上结果应该含有转义符,实际上结果是:

实际上是这段代码搞的鬼,代码比较简单,就不解释了。

奇奇怪怪的代码

  1. this关键字:这个就不举例子了,这个库里所有方法的接收者都是this,被官方建议熏陶过的我,看的我着实蛋疼...

  2. 双重否定表肯定:token解析阶段有这样的代码,不知道作者为啥要搞个双重否定,我的话,会用一个isQuote代替。


govaluate改进

作为一个17年后就没更新过的项目, 也不知道作者还会不会维护。业务发展是不等人,govaluate对于我们服务来说并不能满足需求,很多时候用起来比较别扭,所以我基于我们的场景对于govaluate做了一些定制改造。我个人还是非常喜欢这个库的,于是把代码fork了一份,加了个eplus后缀,改造了上面那几个匪夷所思的问题。并加了个比较定制化的feature:type promotion。

这个听起来比较唬人, 其实就是支持更弱类型的表达式运算,比如我的库支持:\'2\' -1, \'4\' * 3,要支持这种功能,核心需要改两种地方:

  1. 第一种地方是typeCheck。比如subStage会check两个字节点必须是float64类型, 我们要支持string operator num, 可以把typeCheck扩大为可以是chek node是否为 floatOrStr。
  2. 第二种地方是OpeatorOperate。前面我们把String类型也放进来让它支持计算了,但是在go里str和float终究是无法计算的, 所以到了计算阶段需要做一个type promotion,即把string类型转化为数字类型之后再计算。
总结与反思

总结

govaluate在我心中还是有一些不完美的地方,我们这里用它也是因为项目初期就引入了这个库,在大量的线上用例使用后要迁移这个库成本巨大, 对于用的不爽的地方只能改了。如果读者朋友有需求, 可以看一下市面上其他的表达式开源库, 比如gval。当然,如果你的场景比较复杂, 需要很多if else 或者for循环,那简单的规则引擎可能满足不了你的需求,此时可以考虑内嵌个更完整的脚本库或者嵌入lua, 不过这样就更复杂了, 慎重考虑把这样的东西直接放在db里面, 后期不好维护。

反思

govaluate这个库对我有很多启发,最主要就是表达式的预编译可以节省大量CPU开销,组内某个项目目前的运行方式是随着请求现编译,构建执行计划dag图,理论上如果能预编译,请求到来只是对于对应param访问存储,可以节省大量CPU开销。

跳脱出govaluate本身,我们系统选择JPATH + Expr做数据提取和条件描述做需求,本质上是因为这边的mq数据是JSON格式,JSON有一定的局限性,描述数据没啥问题,但是描述条件就比较困难了,理论上如果用XML这种技能描述条件,又能描述数据的交互形式,那我们可能会构建一个完全不同的系统。


最后稍微打个广告吧, 如果你也想使用govaluate,又有一些定制化的需求,欢迎star我的库然后提个issue, 我想试着维护一个开源库, 嘿嘿。


欢迎加入 随波逐流的薯条 微信群。

薯条目前有草帽群、木叶群、琦玉群,群交流内容不限于技术、投资、趣闻分享等话题。欢迎感兴趣的同学入群交流。

入群请加薯条的个人微信:709834997。并备注:加入薯条微信群。

欢迎关注我的公众号~


教你一招 | 用Python实现简易可拓展的规则引擎

做这个规则引擎的初衷是用来实现一个可序列号为json,容易拓展的条件执行引擎,用在类似工作流的场景中,最终实现的效果希望是这样的:

技术图片

简单整理下需求

  • 执行结果最终返回=true= or false
  • 支持四则运算,逻辑运算以及自定义函数等
  • 支持多级规则组合,级别理论上无限(Python递归调用深度限制)
  • 序列化成json

实现

json没有条件判断和流程控制,且不可引用对象,是不好序列化规则的,除非用树来保存,但这样又过于臃肿不好阅读。

在苦苦思索的时候,突然灵光一闪~曾经我用过一个自动装机系统—razor,

它使用一种tag语法来匹配机器并打标签,他的语法是这样的:

["or",
 ["=", ["fact", "macaddress"], "de:ea:db:ee:f0:00"]
 ["=", ["fact", "macaddress"], "de:ea:db:ee:f0:01"]]

这表示匹配目标机器的Mac地址等于=de:ea:db:ee:f0:00=或=de:ea:db:ee:f0:00=,这种表达既简洁,又足够灵活这种灵活体现在理论上可以无限嵌套,也可以随意自定义操作函数(这里的=、fact)

这灵感来自于古老的=Lisp=,完全可以实现我们的想法~并且简单、好用,还非常非常灵活!就它了!

因此我就使用这种基于=Json Array=的语法来实现我们的规则引擎。

最后实现的语法规则是这样的:

规则语法 基本语法: [“操作符”, “参数1”, “参数2”, …]

多条判断语句可组合,如:

["操作符",
    ["操作符1", "参数1", "参数2", ...],["操作符2", "参数1", "参数2", ...]
]
["and",
    [">", 0 , 0.05],
    [">", 3, 2]
]

支持的操作符: 比较运算符:

=, !=, >, <, >=, <=

逻辑运算符:

and, or, not, in

四则运算:

+, -, *, /

数据转换:

int, str, upper, lower

其他特殊操作符:

可自定义操作符,例如get,从某http服务获取数据

代码

class RuleParser(object):
    def __init__(self, rule):
        if isinstance(rule, basestring):
            self.rule = json.loads(rule)
        else:
            self.rule = rule
        self.validate(self.rule)

    class Functions(object):

        ALIAS = {
            '=': 'eq',
            '!=': 'neq',
            '>': 'gt',
            '>=': 'gte',
            '<': 'lt',
            '<=': 'lte',
            'and': 'and_',
            'in': 'in_',
            'or': 'or_',
            'not': 'not_',
            'str': 'str_',
            'int': 'int_',
            '+': 'plus',
            '-': 'minus',
            '*': 'multiply',
            '/': 'divide'
        }

        def eq(self, *args):
            return args[0] == args[1]

        def neq(self, *args):
            return args[0] != args[1]

        def in_(self, *args):
            return args[0] in args[1:]

        def gt(self, *args):
            return args[0] > args[1]

        def gte(self, *args):
            return args[0] >= args[1]

        def lt(self, *args):
            return args[0] < args[1]

        def lte(self, *args):
            return args[0] <= args[1]

        def not_(self, *args):
            return not args[0]

        def or_(self, *args):
            return any(args)

        def and_(self, *args):
            return all(args)

        def int_(self, *args):
            return int(args[0])

        def str_(self, *args):
            return unicode(args[0])

        def upper(self, *args):
            return args[0].upper()

        def lower(self, *args):
            return args[0].lower()

        def plus(self, *args):
            return sum(args)

        def minus(self, *args):
            return args[0] - args[1]

        def multiply(self, *args):
            return args[0] * args[1]

        def divide(self, *args):
            return float(args[0]) / float(args[1])

        def abs(self, *args):
            return abs(args[0])
    @staticmethod
    def validate(rule):
        if not isinstance(rule, list):
            raise RuleEvaluationError('Rule must be a list, got {}'.format(type(rule)))
        if len(rule) < 2:
            raise RuleEvaluationError('Must have at least one argument.')

        def _evaluate(self, rule, fns):
        """
        递归执行list内容
        """
        def _recurse_eval(arg):
            if isinstance(arg, list):
                return self._evaluate(arg, fns)
            else:
                return arg

        r = map(_recurse_eval, rule)
        r[0] = self.Functions.ALIAS.get(r[0]) or r[0]
        func = getattr(fns, r[0])
        return func(*r[1:])

    def evaluate(self):
        fns = self.Functions()
        ret = self._evaluate(self.rule, fns)
        if not isinstance(ret, bool):
            logger.warn('In common usage, a rule must return a bool value,'
                        'but get {}, please check the rule to ensure it is true' )
        return ret

解析

这里Functions这个类,就是用来存放操作符方法的,由于有些操作符不是合法的Python变量名,所以需要用ALIAS做一次转换。

当需要添加新的操作,只需在Functions中添加方法即可。由于始终使用array来存储,所以方法接收的参数始终可以用args[n]来访问到,这里没有做异常处理,如果想要更健壮的话可以拓展validate方法,以及在每次调用前检查参数。

整个规则引擎的核心代码其实就是=~evaluate~=这个10行不到的方法,在这里会递归遍历列表,从最里层的列表开始执行,然后层层往外执行,最后执行完毕返回一个Boolean值,当然这里也可以拓展改成允许返回任何值,然后根据返回值来决定后续走向,这便可以成为一个工作流中的条件节点了。

结束语

东西简单粗陋,希望能给大家带来一些帮助或者一些启发~

以上是关于用规则引擎让你一天上线十个需求的主要内容,如果未能解决你的问题,请参考以下文章

三大免费开源的php语言cms系统 用好它们让你一天建好一个网站

在家徒手健身就是这9个动作,让你一天帅24小时

Groovy脚本实现轻量级规则引擎

车牌识别知多少,让你一次看个够

一个规则引擎的可视化方案

搜索引擎下拉词,又一个营销利器上线,让你快速赚钱!