当前位置: 首页 > 科技观察

使用规则引擎,让你一天上线十次

时间:2023-03-13 08:50:15 科技观察

各位读者朋友们大家好,我是炸薯条,好久没有更新文章了。不知道还有多少读者还记得这个账号。耐心看完本文,可以浏览留言区,我会发个新年红包。业务背景如果你是本账号的老读者,你可能知道我是做数据系统的。作为在线数据服务集团,我们在这里承接的需求虽小,但数量众多。我在一家打车公司工作,运营大佬认为不同的用户在不同的场景下有不同的打车需求,设计了很多细分品类。所以我们的团队会承接这样一类需求:计算不同类别用户的各种实时订单,比如:快递叫单,拼车完成的订单。此类需求的一般处理流程如下:描述下图:当用户在订单流状态的关键节点进行操作时,系统会发送一条MQ消息供其他系统消费。其他系统通过明确的数据口径来判断这个msg是否符合当前的业务逻辑,然后存入db或者丢弃。比如一个需求需要计算:拼车订单量,靠谱的拼车rd告诉你口径是:ifaa.bb.cc==1//表示多个模型发出Unmarshal(bb.cc.ee)查看类型是否为4else//Unmarshal(bb.cc.ff)查看单个模型的类型是否为4(type=4为拼车)。订阅mq,写数据提取和订单判断的逻辑,整个过程写代码1小时,自测1小时,因为你机器太多,上线花了一整天,整体研发效率还不错。第二天,产品又给你提出需求,你要计算拼车的计费量,于是你去找对应业务线的开发同学找一个接入口径,然后重复上面的过程.第三天,产品上线,效果不错。数据增加了3个点。老板很高兴。在周会上,他请PM说几句自己的心路历程。PM感谢老板的栽培,然后淡淡的说了产品的底层逻辑和关键把柄。而他几个聪明的同事发现你是关键,就连夜赶到珠三角,让你这个关键,把他们负责的品类的所有实时订单都抓下来。第四天,你崩溃了,因为你并行接到了4个PM的8个订单。就在你写着准备再次重复上面的过程时,你聪明的老板告诉你,这不对劲:来个萝卜填坑,这是小农时代的做法。现在是21世纪,时代变了。让你想出一个通用的解决方案,让系统走向工业时代!你似懂非懂点点头,查了各种CSDN,博客园,知乎,github,技术交流群里的各种@群lidaxie你有没有遇到过这样的场景。折腾了一番终于有了头绪,于是高兴地向老板报告:老板,我明白了,这个场景用JPATH+ExpressionEval就可以解决了!这样,新的需求只需要写到db里面,插入两个表达式就可以了,再也不用怕20个需求了。你老板笑着点点头,看了一眼手里的劳力士,有意无意地晃了晃,说道:小伙子很不错,想出了办法。赶紧设计方案,争取这周上线,早日搞定。说到经营成果,那时候就少不了你的升职加薪了!这套系统的实现有两个核心需求:数据抽取规则判断数据抽取,即ETL,从mqmsg中抽取关键信息。抽取之后,可能需要对其进行简单的处理(比如msg中的事件时间是timestamp,想转成RFC3339格式),这里可以使用JPATH进行数据抽取(如果你写过爬虫,你必须知道使用xpath提取HTML中的节点消息,jpath是json数据提取规则)。配置一条ETL规则,如图:然后就是数据规则判断,也就是题目中提到的规则引擎。我们这里使用开源库govaluate,比如上面拼车顺序的例子,我们可以配置这样一条规则:cc==1?(in(4,ee)?1:0):(ff==4?1:0)govaluate会根据这个表达式构造一个ast,然后输入参数进行求值(还记得编译吗?原理?)。接下来我们来研究一下这个库~govaluate介绍和使用注意govaluate支持C风格算术/字符串表达式的求值。例如这些例子(例子来自evaluation_test.go):1.100^(23*(2|5))2.5<10&&1<53.(foo==true)||(bar==true)//foo,bararevariables4.theft&&period==24?60//theft和period都是变量。这个库几乎支持你能想象到的任何表达式。有兴趣的可以去看看这个测试文件。此外,它还支持扩展UDF。您可以编写一些函数来支持您的自定义业务逻辑。它还支持执行类方法。有关详细信息,请参阅自述文件。当我们需要使用的时候,只需要这几行代码表达式,err:=govaluate.NewEvaluableExpression("foo>0");parameters:=make(map[string]interface{},8)parameters["foo"]=-1;result,err:=expression.Evaluate(parameters);//result现在设置为“false”,布尔值。通过这个demo我们可以看到它的api被设计成两步,第一步NewEvaluableExpression的功能主要是将表达式展开成AST。Evaluate的主要功能是将用户参数填入AST中进行评估。.举个例子:比如表达式1+foo+4*boo,分两个阶段做的是这样的:那么你能不能直接把这两个步骤复制到生产项目代码中呢?很明显不是。通过观察可以发现,第一步构造ast所依赖的表达式其实是预先确定的,表达式一般不会发生变化。用户没有必要在每次通过API时都构造一个ast,然后对其进行评估。表达式可以存储在db中,并在项目启动或更新配置时加载到内存中。比如一个map[string]*EvaluableExpression可以用来缓存不同表达式的AST,这样用户每次请求只需要遍历AST即可。评价。预编译的好处是显着的,尤其是当您的表达式很复杂时。对于表达式foo>2?1:0,我分别做了currentcompilation和precompilation的benchmark,结果如下:currentcompilation(ast的currentcompilationandconstruction占cpuoverhead的62.3%,而eval只占2%)precompilation(预编译节省了构建AST的成本,节省了大量的cpu资源)建议如果使用这个库,尽可能使用预编译版本。govaluate的原理看来govaluate很有意思。接下来,让我们深入研究一下它的源代码。我们先看第一阶段,表达式展开为AST时的逻辑,我简单画了一张图:以1+foo+4*boo为例,经过parserToken后,我们可以得到一堆token:checkBalanceisnothingto说白了,核心功能就是看括号是否成对出现:而checkExpressionSyntax阶段主要是检查token是否符合预设规则。核心是这个函数:这个函数会检查当前token是否是前一个token的合法值,合法值是预先设置好的。比如NUMERIC的合法值如下:接下来的optimizeTokens函数没什么好说的,主要就是编译regex。更有趣的是步骤planStages。planStages这个大步骤大致分为三个小步骤:??planTokens、reorderStages、elideLiterals。下面一一介绍一下:planTokens的功能让我大开眼界。首先,它使用func来计算不同运算符的优先级。原理接收struct作为参数的是func,参数中的next是连接到这个函数的下一个优先级的func。func优先级是这样打印的:有了operator优先级之后,对于具体的节点,会继续看节点类型,比如func,accesser或者valueType。ValueType节点对于不同的细节类型也有不同的策略,比如数字节点会构建一个Node,括号节点会直接解析下一个token构建优先级更高的树。对于不同的运营商,将在功能链下构建优先级较高的节点,以确保符合数学计算的规律。这里的reorderStages主要是对ast进行重新排序,让ast从普通树变成avl树。树旋转的代码特别激进,比如表达式1+foo+4*boo。planToken执行完后,会变成这样Tree:重新排序的过程就是轮换优先级相同的节点。第一步是交换左右节点:第二步是LL左旋转:这是一个很平衡的算法。elideLiterals这一步是检查叶子节点是否为LITERAL,比如这棵树:这个阶段,每个子节点都会进行dfs计算,直接变成:至此第一阶段的逻辑已经梳理完毕。第二阶段Evaluate的主要作用是将用户参数填入ast进行评估。这个过程比较简单,本文不再赘述。Govaluate是不够的。Govaluate看起来很好,真的是这样吗?事实上,它不是。该项目的最后一次提交是在2017年,也就是6年前。我们在使用过程中也发现了很多小bug,代码不够优雅。简单列几个:弱类型govaluate将所有数值类型都解析成float64进行计算,写代码很酷,但是用1+2+9作为表达式时,可能会得到一个类型为interface{}的结果的fload64。该函数限制了govaluate部分函数的返回值,无法继续进行计算。比如这种情况:看似没有问题,但是执行会报错:参数会去掉转义符。比如这段代码:理论上结果应该包含转义符,但实际结果是:其实是这段代码的鬼,代码比较简单,就不解释了。怪码this关键字:这里就不举例了。这个库中所有方法的接收者都是这个。受了官方建议的影响,看到真的心痛...双否定表肯定:在token解析阶段有这么一段代码。不知道作者为什么要双重否定。就我而言,我将改用isQuote。Govaluateimprovement是一个17年没更新的项目,不知道作者会不会维护。业务发展不等人。Govaluate不能满足我们的服务需求,很多时候用起来很别扭。因此,我根据自己的场景对govaluate做了一些定制化的改造。个人还是很喜欢这个库的,所以fork了一份代码,加了个eplus后缀,修改了上面那些匪夷所思的问题。并增加了一个更个性化的功能:类型提升。这听起来很唬人,但它实际上支持更弱类型的表达式操作。比如我的库支持:'2'-1,'4'*3。要支持这个功能,核心需要修改两个地方:一个地方是typeCheck。比如subStage会检查两个字节点必须是float64类型,我们需要支持字符串运算符num,我们可以扩展typeCheck来检查chek节点是否是floatOrStr。第二名是OperatorOperate。前面我们把String类型放进去让它支持计算,但是str和float毕竟在Go中是无法计算的,所以在计算阶段,我们需要做一个类型提升,即将string类型转为number类型计算前。总结与反思总结Govaluate在我看来还有一些不完善的地方。我们在这里使用它是因为该库是在项目开始时引入的。大量使用在线用例后,迁移这个库的成本是巨大的。对于那些不适应的地方,只能换地方了。如果读者朋友有需求,可以看看市面上其他的表达式开源库,比如gval。当然,如果你的场景比较复杂,需要大量的ifelse或者for循环,那么一个简单的规则引擎可能无法满足你的需求。这时候可以考虑嵌入更完整的脚本库或者嵌入lua,但是这样比较复杂。是的,慎重考虑把这种东西直接放在db里,以后维护起来会很困难。反思govaluate库给了我很多启发。最重要的是表达式的预编译可以节省大量的CPU开销。集团内某项目目前的运行模式是随request编译,构建执行计划dag图。理论上,如果能Precompiled,传入的请求只访问相应param的storage,可以节省大量的CPU开销。脱离govaluate本身,我们的系统选择JPATH+Expr来进行需求的数据提取和条件描述,本质上是因为这里的mq数据是JSON格式的。JSON有一定的局限性。描述数据没有问题,但是描述条件就比较难了。理论上,如果用XML来描述条件,同时也描述数据的交互形式,那么我们可能会构建一个完全不同的系统。最后,来打个小广告吧。如果你也想使用govaluate并且有一些定制化的需求,欢迎star我的库提issue。我想尝试维护一个开源库,呵呵。