用解析树计算自定义表达式
不久前,我们遇到了这样的需求:项目方需要对各个业务系统进行监控,如果业务系统的分值低于某个预定的分数,则监控系统会自动为相关负责人发送告警信息。
需求
看起来并不难,我们把资源的状态由高到低分为致命、严重、警告三个级别,整个业务系统的状态受最严重节点的影响,例如:如果业务系统中有一个资源的状态是致命,那么整个业务系统就是致命。
然而需求方有很多项目采用了负载均衡或分布式部署,某个节点宕机并不影响整个系统继续运行,这种简的单规则并不能有效判断系统的整体运行状态。
在上图中,虽然可用区A挂掉了,但整个系统依旧在运行,不能简单地把整个系统判处死刑。看来“分数”的概念是必须的,需要对系统中的每个节点或集群进行打分,最终计算出整个系统的分数,再根据预先设定的阈值判断系统的最终状态。
方案
针对这种情况,我们设计了三种表达式:MIN(取集群的最小值)、MAX(取集群的最大值)、AVG(取集群的加权平均)。具体来说,每个表达式计算的是一个集群的分数,当然,一个集群可以只包含一个节点,也可以包含其他集群。这样一来,上图可以用下面的表达式计算:
AVG(可用区A * 1, 可用区B * 1)
= AVG(MAX(ECSA1, ECSA2) * 1, MAX(ECSB1, ECSB2) * 1)
其中“* 1,”表示权重是1,对于上式来说,可用区A和可用区B权重相同。上式等于:
AVG(MAX(20, 20) * 1, MAX(100, 100) * 1) = (20 + 100) / (1 + 1) = 60
业务的健康度是60分,将产生一个相应级别的告警。
根据这种规则,可以进行任意嵌套:
MAX(AVG(60*3, 70*2, MAX(40, 70) * 4), MAX(30, 40, AVG(30*3, 90*7)), 50)
接下来的问题是,代码在有较强可读性的前提下,怎样根据给出的表达式计算出结果?
算法
在发布任务前,我给出了几个测试用例:
AVG(AVG(10*2, 2*2) * 1, 20 * 10) = 19
MAX(MIN(10, 5),10,MAX(20,21) = 21
MAX(AVG(60*3, MAX(40, 70) * 4)) = 66
MAX(AVG(60*3, 70*2, MAX(40, 70) * 4), MAX(30, 40, AVG(30*3, 90*7))) = 72
AVG(30*3, 90*7, AVG(20*4, 30*4) * 1) = 68
10分钟后,一个远程工作的小伙伴领取了这个任务,并且很快提交了结果。
他的思路是将表达式转换为解析树:
这样一来,就可以自底向上逐步计算出结果。
或许可以继续向下解析,将60*3分解为:
就这个项目来说,只有AVG涉及到加权运算符,因此这种分解没有必要。此外,对于集群的加权,比如 AVG(MAX(40,10) * 2),将被分解为:
对于AVG的子节点来说,要么是完整的表达式,比如a*b;或者被拆分成两个表达式,比如a和*b。在计算时,可以通过标准化处理,将所有a*b变为a和*b,最终保证AVG的叶子结点一定是偶数个,且每两个都能组成一对。
表达式是一个字符串,由三种元素构成:运算符(AVG, MAX, MIN, 乘号)、分隔符(左右括号,逗号)、数字,因此定义了下面的静态变量:
1 /*
2 * 自定义规则中的符号, 其中 MAX, MIN, AVG 是运算符, WEIGHT是加权符号
3 */
4 final static public String MAX = "MAX"; //求最大
5 final static public String MIN = "MIN"; //求最小
6 final static public String AVG = "AVG"; //加权平均
7 final static public String WEIGHT = "*"; //加权乘法
8 final static public String SEPERATOR = ",";
9 final static public String BRACKETS_L = "(";
10 final static public String BRACKETS_R = ")";
之后通过遍历字符串的方式识别出节点,将节点插入解析树,主要代码如下:
1 static OPTree build(String expression) {
2 // 表达式语法树
3 OPTree tree = null;
4 // 语法树的当前节点
5 OPTree.Node currentNode = null;
6 String sign = ""; // 运算符标记
7 String num = ""; // 数字标记
8 // 表达式示例: MAX(10,-1,30,MIN(40,50),AVG(60*3, 70*2))
9 // 将 "MAX(10,2)*4" 转换为 "MAX(10,2),*4", 从而让MAX(10,2)和"*4"平级,简化处理
10 char[] chars = expression.replaceAll(" ", "").replaceAll("\\)\\*", "),\\*").toCharArray();
11 for(char c : chars) {
12 if(('0' <= c && c <= '9') || c == WEIGHT.charAt(0)) {
13 num += c;
14 continue;
15 }
16
17 sign += c;
18 // 1. 遇到MAX, MIN, AVG,将其作为根节点插入语法树,并将currentNode指向它;
19 switch (sign) {
20 case MAX:
21 case MIN:
22 case AVG:
23 if (tree == null) {
24 tree = new OPTree(sign);
25 currentNode = tree.getRoot();
26 } else {
27 currentNode = tree.insert(sign, currentNode);
28 }
29 sign = "";
30 break;
31 // 2. 遇到“(”, 直接将运算符标记和数字符标记清空
32 case BRACKETS_L:
33 sign = "";
34 num = "";
35 break;
36 // 3. 遇到“,”:
37 // 3.1 如果num非空, 直接将num作为currentNode的叶子节点
38 // 3.2 如果num为空,直接将currentNode指向上一层节点
39 case SEPERATOR:
40 assert currentNode != null;
41 if (!num.isEmpty()) {
42 tree.insertChild(valueTranse(num), currentNode);
43 } else {
44 currentNode = currentNode.getParent();
45 }
46 num = "";
47 sign = "";
48 break;
49 // 4. 遇到 “)”,
50 // 4.1 如果num非空,先将num作为currentNode的叶子节点
51 // 4.2 如果num为空,直接将currentNode指向上一层节点
52 case BRACKETS_R:
53 assert tree != null;
54 if (!num.isEmpty()) {
55 tree.insertChild(valueTranse(num), currentNode);
56 }
57 else {
58 currentNode = currentNode.getParent();
59 }
60 num = "";
61 sign = "";
62 break;
63 }
64 }
65
66 return tree;
67 }
如果去掉注释和一些预先设置好的静态变量,核心代码约为50行,这可比反复使用正则表达式匹配漂亮多了。
算法预先定义了curentNode作为解析树的当前节点,用sign和num记录表达式的符号和数字,从左到右遍历表达式中的每一个字符:
0. 遇到数字或"-",拼接到num末尾;
1. 遇到MAX, MIN, AVG,将其作为根节点插入语法树,并将currentNode指向它,再将sign清空;
2. 遇到“(”, 直接将sign和num清空;
3. 遇到“,”,如果num非空, 直接将num作为currentNode的叶子节点,否则直接将currentNode指向上一层节点;之后将sign和num清空;
4. 遇到 “)”, 如果num非空,先将num作为currentNode的叶子节点,否则直接将currentNode指向上一层节点;之后将sign和num清空;
当遍历过最后一个节点后,解析树构造完成。
以MAX(10,-1,30,MIN(40,50),AVG(60*3, 70*2))为例,验证整个过程:
最终,curentNode将指向解析树的根节点。
有了解析树后,就可以通过后序遍历,自底向上算出整个业务系统的分值。
出处:微信公众号 "我是8位的"
本文以学习、研究和分享为主,如需转载,请联系本人,标明作者和出处,非商业用途!
扫描二维码关注作者公众号“我是8位的”