Golang的AST及表达式解析实战(二)

前言

上一篇文章我们分析了golang中的AST结构, 分类。基于这些基础我们就可以开发一下通用的表达式解析工具, 并且在此基础上如果有特殊的需求也可以开发自定义解析函数。

前期回顾: Golang的AST及表达式解析实战(一)

需求定义

假如我们有一个新闻app, 需要针对不同特征的人群发送不同的运营弹窗, 人群特征有:

  • city: 城市, 使用拼音string表示, 如: beijing
  • gender: 性别, 使用int表示, 1男2女
  • hobby: 爱好, 使用字符串表示, 如: football, travel等

弹窗使用h5连接就可以, 其中人群特征字段可能会根据需求变动, 同时各种组合规则也会有变动。假如有一个规则: 针对北京和天津喜欢足球的男性用户推送连接A, 规则可以配置为: in_data(city, "beijing|tianjin") && gender == 1 && hobby == "football"

打印AST

我们做一个简单的规则: in_data("city", "beijing|tianjin") && gender == 1, in_data这里我们使用 '|' 字符做数据集合的分割, 这是一种自定义的处理方式, 后面我们也会讲解基于官方spec的方式。 AST解析如下:

     0  *ast.BinaryExpr {
     1  .  X: *ast.CallExpr {
     2  .  .  Fun: *ast.Ident {
     3  .  .  .  NamePos: -
     4  .  .  .  Name: "in_data"
     5  .  .  }
     6  .  .  Lparen: -
     7  .  .  Args: []ast.Expr (len = 2) {
     8  .  .  .  0: *ast.BasicLit {
    24  .  .  .  .  ValuePos: -
    25  .  .  .  .  Kind: STRING
    26  .  .  .  .  Value: "\"city\""
    27  .  .  .  }
    12  .  .  .  1: *ast.BasicLit {
    13  .  .  .  .  ValuePos: -
    14  .  .  .  .  Kind: STRING
    15  .  .  .  .  Value: "\"beijing|tianjin\""
    16  .  .  .  }
    17  .  .  }
    18  .  .  Ellipsis: -
    19  .  .  Rparen: -
    20  .  }
    21  .  OpPos: -
    22  .  Op: &&
    23  .  Y: *ast.BinaryExpr {
    24  .  .  X: *ast.Ident {
    25  .  .  .  NamePos: -
    26  .  .  .  Name: "gender"
    27  .  .  }
    28  .  .  OpPos: -
    29  .  .  Op: ==
    30  .  .  Y: *ast.BasicLit {
    31  .  .  .  ValuePos: -
    32  .  .  .  Kind: INT
    33  .  .  .  Value: "1"
    34  .  .  }
    35  .  }
    36  }

可以看到我们只需要解析BinaryExpr(二元表达式)和CallExpr(函数调用表达式)就可以实现我们的需求。其中CallExpr需要我们实现一个自定的函数: in_data

构建解析

我们采用自下而上的方法构建, 先开发我们需要解析的token基础功能, 然后在将基础功能组合成为对外开放的API, 特征值我们采用map[string]interface{}的数据格式传递。

解析ast.BinaryExpr节点

ast.BinaryExprX, OP, Y三个核心节点构成, 理解也比较容易, 具体到我们的业务场景中: gender == 1 表达式的X节点类型是ast.Ident, 即字面量标识符, Y节点类型是ast.BasicLit, 即基本字面量值。另一种二元操作情况: 1 == 1, XY节点类型都是ast.BasicLit字面量值。通过这个区别大家就能很好理解二元操作操作符左右节点的含义了, 接下来就很简单, 编写比较二元操作节点的逻辑:

// 比较二元操作节点
func cmpBinary(expr *ast.BinaryExpr, sourceData map[string]interface{}) (bool, error) {
   var (
      xName string
      xVal  interface{}
   )

   // 获取X对应的数据源特征值 
   xName = expr.X.(*ast.Ident).Name
   xVal, ok := sourceData[xName]
   if !ok {
      return false, fmt.Errorf("source data key: %s not exists", xName)
   }
   y := expr.Y.(*ast.BasicLit)

   // 根据不同类型比较
   switch y.Kind {
   case token.INT: // 转换为int64比较
      xValInt64, err := convToInt64(xVal)
      if err != nil {
         return false, err
      }
      yInt64, err := strconv.ParseInt(y.Value, 10, 64)
      if err != nil {
         return false, err
      }
      return cmpInt64(xValInt64, yInt64, expr.Op)
      /* 省略其他类型比较 */
   }

   return false, fmt.Errorf("cmpBinary error")
}

// 根据操作符比较整型值
func cmpInt64(x, y int64, op token.Token) (bool, error) {
   switch op {
   case token.EQL:
      return x == y, nil
   case token.LSS:
      return x < y, nil
   /* 省略其他二元符号的比较 */
   default:
      return false, fmt.Errorf("not support number op: %v", op)
   }
}

示例代码中我省略一些其他数据类型和操作符的比较逻辑, 比较简单, 大家可以参看末尾的完整代码,至此二元操作符就解析完成了。

解析ast.CallExpr节点

ast.CallExpr表示次交点是一个函数调用, 核心由FunArgs两个字段构成, 通过观察节点结构我们也很容易构建自定义的函数功能, 扩充表达式处理能力, 我们先以本例的in_data函数实现为例:

func matchFunc(expr *ast.CallExpr, sourceData map[string]interface{}) (bool, error) {
   // 获取函数名称
   funIdent, ok := expr.Fun.(*ast.Ident)
   if !ok {
      return false, fmt.Errorf("CallExpr node error, node info: %v", expr)
   }

   switch funIdent.Name {
   case funcInData: // in_data 字符串常量
      return InOp(expr.Args, sourceData)
   /* ...可以扩展其他函数实现... */
   default:
      return false, fmt.Errorf("not support func: %s", funIdent.Name)
   }
}
// in_data实现逻辑
func InOp(args []ast.Expr, sourceData map[string]interface{}) (bool, error) {
   if len(args) != 2 {
      return false, fmt.Errorf("in_data args length error")
   }
   // 1. 第一个参数为特征字段名称
   keyIdent, ok := args[0].(*ast.Ident)
   if !ok {
      return false, fmt.Errorf("in_data args key type error, node: %v", args)
   }
   // 2. 获取字段特征值
   data, ok := sourceData[keyIdent.Name]
   if !ok {
      return false, fmt.Errorf("in_data args key type error, node: %v", args)
   }
   dataString, err := convToString(data)
   if err != nil {
      return false, err
   }
   
   // 3. 参数二我们定义为参数值, 这个函数我们定义为使用 | 分割字面量字符串
   valBasicLit, ok := args[1].(*ast.BasicLit)
   if !ok {
      return false, fmt.Errorf("in_data args val type error, node: %#v", args)
   }
   // 4. 这段你懂得 :)
   valList := strings.Split(strings.Trim(valBasicLit.Value, "\""), "|")
   for _, val := range valList {
      if val == dataString {
         return true, nil
      }
   }
   return false, nil
}

这样我们就实现了一个自定义的函数解析, 理解了AST的结构之后, 这就是一个把大象装进冰箱分几步的问题, 是不是很简单 :)。

解析AST整体结构

完成了下层的表达式节点和函数节点的解析后, 我们就可以封装上层接受整体AST节点的解析函数

// 递归解析ast
// param: expr, AST整体结构
// param: sourceData, 数据源, 特征字段对应特征值
func judge(expr ast.Expr, sourceData map[string]interface{}) (bool, error) {

   switch t := expr.(type) {
   case *ast.BinaryExpr: // 解析二元表达式
      if isBinaryLeaf(t) { // 如果是二元表达式, 则返回当前二元表达式的比较结果
         return cmpBinary(t, sourceData) // 函数定义见二元表达式解析
      }
      // 非叶子节点需要递归比较X, Y两个子节点下的表达式结果
      lRes, err := judge(t.X, sourceData)
      if err != nil {
         return false, err
      }
      rRes, err := judge(t.Y, sourceData)
      if err != nil {
         return false, err
      }

      // 针对逻辑运算符做比较
      switch t.Op {
      case token.LAND:
         return lRes && rRes, nil
      case token.LOR:
         return lRes || rRes, nil
      }
      return false, fmt.Errorf("not support op xx")
   case *ast.CallExpr: // 匹配到函数
      return matchFunc(t, sourceData)
   case *ast.ParenExpr: // 匹配到括号表达式
      return judge(t.X, sourceData)
   default:
      return false, errors.New(fmt.Sprintf("%#v type is not support", expr))
   }
}

如何判断叶子节点呢?一种情况是是 特征字段 op 特征值 形式的表达式, 形如: age == 18, 这种类型的二元表达式X*ast.Ident, Y节点是*ast.BasicLit, 即标识符与基本字面量的比较。还有另一种形式如: "name" == 23这样两个基本字面量比较的情况, X, Y两个节点都是*ast.BasicLit, 我们只处理第一种情况, 所以判断叶子节点的逻辑为:

func isBinaryLeaf(expr *ast.BinaryExpr) bool {
   _, lType := expr.X.(*ast.Ident)
   _, rType := expr.Y.(*ast.BasicLit)
   return lType && rType
}

这样我们就完成了需要解析的所有工作, 接下来只需要构建最上层逻辑, 即解析字符串的AST结构并传参调用就可以了

func Match(exprRule string, sourceData map[string]interface{}) (bool, error) {
   if len(exprRule) == 0 {
      return false, errors.New("empty exprRule")
   }

   // 解析表达式
   exprAst, err := parser.ParseExpr(exprRule)
   if err != nil {
      return false, fmt.Errorf("parse exprRule err: %w", err)
   }
   
   // 打印AST节点
   // fset := token.NewFileSet()
   // ast.Print(fset, exprAst)

   res, err := judge(exprAst, sourceData)
   if err != nil {
      return false, fmt.Errorf("judge err: %w", err)
   }
   return res, nil
}

这样整体就完成了表达式的解析。我们实现了一个自定义函数: in_data 的解析, 调用形式可能和我们平常接触的函数函数不太一样, 之所以这样做是因为想模拟一个不常用的场景, 通过对于节点的解析和实现逻辑的扩充可以开放一下思维, 让我们基于AST可以实现一些可高度自定义的功能。

我们也可以基于golang官方的spec规则去构建表达式, 这样就只能限制在golang自己的词法解析范围内, 优点是可以让我们更快的接入, 省却自己解析数据的操作, 缺点是不具有通用性, 好比通用协议和自定义协议的区别。比如: in_data("name", []string{"cooper", "jack"}), []string会自动识字符串切片类型, *ast.CallExpr.Args.1解析如下:

    12  .  .  *ast.CompositeLit {
    13  .  .  .  Type: *ast.ArrayType {
    14  .  .  .  .  Lbrack: -
    15  .  .  .  .  Elt: *ast.Ident {
    16  .  .  .  .  .  NamePos: -
    17  .  .  .  .  .  Name: "string"
    18  .  .  .  .  }
    19  .  .  .  }
    20  .  .  .  Lbrace: -
    21  .  .  .  Elts: []ast.Expr (len = 2) {
    22  .  .  .  .  0: *ast.BasicLit {
    23  .  .  .  .  .  ValuePos: -
    24  .  .  .  .  .  Kind: STRING
    25  .  .  .  .  .  Value: "\"cooper\""
    26  .  .  .  .  }
    27  .  .  .  .  1: *ast.BasicLit {
    28  .  .  .  .  .  ValuePos: -
    29  .  .  .  .  .  Kind: STRING
    30  .  .  .  .  .  Value: "\"jack\""
    31  .  .  .  .  }
    32  .  .  .  }
    33  .  .  .  Rbrace: -
    34  .  .  .  Incomplete: false
    35  .  .  }

Test

测试用例如下, 大家可以参考:

func TestMatch(t *testing.T) {
   ruleList := []rule{
      {
         data: map[string]interface{}{
            "name": "cooper",
            "age":  11,
            "sex":  2,
         },
         rule: `(age > 10 && in_data("name", "cooper|jack")) || sex == 1`,
         res:  true,
      },
      {
         data: map[string]interface{}{
            "sex":  11,
            "addr": "aaa",
         },
         rule: `addr == "aaa" && sex == 1`,
         res:  false,
      },
   }

   for _, ca := range ruleList {
      res, err := Match(ca.rule, ca.data)
      if err != nil && res != ca.res {
         t.Logf("res: %v, err: %v", ca.res, err)
      }
   }
}

总结与思考

构建和解析抽象语法树可以帮助我们做一些常规开发难以实现的功能, 比如规则解析, 代码生成, 语法检查等。本文通过构建一个简单的规则解析工具, 了解了不同节点的结构以及实现自定义函数的过程。

golang ast全部节点

本文工具地址

猜你喜欢

转载自juejin.im/post/7126508340191952903