利用AST实现动态下发版本规则

540 阅读6分钟

一个需求

举个例子:假设由于ios 13.0.0以上和android 8.0到12.0存在系统bug,我们需要对该RN特定页面线上进行降级到web页面,同时我们并不想发布RN版本,这个时候就希望可以通过服务端下发降级规则,然后app中若命中系统版本和RN页面路由则进行降级到web页面。

一个想法

因为这类规则拥有一定的逻辑性,那么我们能不能像写代码一样写版本比较规则,比如我们需要ios的版本大于等于13.0.0, android的版本大于8.0.0小于12, 如下方示例所示:

(system == "ios" && systemVersion >= "13.0.0") 
|| (system == "android" && systemVersion > "8.0.0" && systemVersion <= "12")

这样我们既能一目了然,同时也可以具有很强的扩展性,降低维护难度, 想法已经建立了,那么就开干吧。

怎么实现

如果我们将上述的表达式的操作符作为二叉树的根节点,操作符左右两边的操作数作为二叉树的左节点和右节点, 那么我们将得到下图所示的二叉树。

image.png

然后有另外一个问题产生,那么我们如何计算这棵二叉树呢?

简单的计算方法为:
步骤1: 计算左子树的结果
步骤2: 计算右子树的结果
步骤3: 计算该棵树的结果

举个例子:

我们先假设system为ios, systemVersion为12.0.0,表达式二叉树先只看左子树。

image.png

按照上述的计算方法,我们来计算该棵树的结果

步骤1:计算 && 的左子树,&&的左子树为(==,system, ios), 由于system为ios, 这个结果为true。
步骤2:计算 && 的右子树,&&的右子树为(≥, systemVersion, 13.0.0), 由于systemVersion为12.0.0, 这个结果为false。
步骤3:计算 && 树的结果,由上述步骤可知,&&左子树为true,右子树为false,那么&&树为(&&,true, false), 这个结果为false。

该树的结果为false。

若左子树或右子树为&&或者||树,我们则进行递归子树计算,这样就可以计算复杂一些的二叉树了。

那么现在又有一个问题了,我们如何通过表达式来生成一个二叉树?

我们可以想到AST也是一个棵树,那么AST生成的树和我们是否相似呢?那我们把这个左子树表达式(system == "ios" && systemVersion >= "13.0.0")生成一个AST,可以得到如下结构的树:

image.png

发现这个AST的整体结构和我们想要的结构类似,所以我们可以通过生成AST的方法来生成我们想要的二叉树。

生成AST

生成AST的方法通常可以直接使用诸如Babel等工具生成。

当然,我们也可以手动编写词法分析和语法分析的代码来生成AST。在本文的示例中,我们是自行实现生成AST的方法的,具体的实现方法可以在示例代码中的scanner.jsparser.js文件中找到。

相比于通用的工具,手动实现生成AST方法可以只实现需要用到的语法,这样就可以在词法分析和语法分析的过程中对不支持的语法提前进行报错,同时代码体积也可以更加轻量。

这里简单介绍一下本文手动实现的AST结构,方便阅读下方其他代码:

interface Node {
  kind: SyntaxKind,
}

enum SyntaxKind = {
  // 括号表达式 
  // 该类型AST拥有的属性
  //   kind: 'ParenExpression'
  //   expression: 括号内部表达式的AST
  ParenExpression: 'ParenExpression', 

  // 标识符,本文中主要指变量
  // 该类型AST拥有的属性
  //   kind: 'Identifier'
  //   text: 变量的名称
  Identifier: 'Identifier',

  // 字面量
  // 该类型AST拥有的属性
  //   kind: 'StringLiteral'
  //   text: 具体的字面量值
  StringLiteral: 'StringLiteral', // 字符串

  // 二元运算符
  GreaterThanToken: 'GreaterThanToken', // >
  GreaterThanEqualsToken: 'GreaterThanEqualsToken', // >=
  LessThanToken: 'LessThanToken', // <
  LessThanEqualsToken: 'LessThanEqualsToken', // <=
  EqualsEqualsToken: 'EqualsEqualsToken', // ==
  ExclamationEqualsToken: 'ExclamationEqualsToken', // !=  
  AmpersandAmpersandToken: 'AmpersandAmpersandToken', // &&
  BarBarToken: 'BarBarToken', // ||
  
  // 二元表达式 
  // 该类型AST拥有的属性
  //   kind: 'BinaryExpression'
  //   operator: 指上述具体的二元运算符,如'GreaterThanToken' 、 'GreaterThanEqualsToken' 等
  //   left: 左值的AST
  //   right: 右值的AST
  BinaryExpression: 'BinaryExpression',
};

先跑起来

通过上一步,我们已经生成AST了,那么我们接下来就是运行AST并得到结果:

function runAST(ast, params) {
  switch (ast.kind) {
    // 字符串
    case SyntaxKind.StringLiteral:
      return ast.text;
    // 标识符
    case SyntaxKind.Identifier:
      return params[ast.text];
    // 二元表达式
    case SyntaxKind.BinaryExpression:
      return runBinaryExpression(ast, params);
    // 括号表达式
    case SyntaxKind.ParenExpression:
      return runAST(ast.expression, params);
    default:
      throw new Error(`invalid ast.kind: ${ast.kind}`);
  }
}

runAST用于计算语法树节点的值。输入参数 ast 是一个语法树节点,表示一个表达式,例如 1 > 2。params 是一个包含变量名和值的对象,用于存储运行时的变量值。

这个函数首先获取语法树节点的类型 ast.kind,然后根据不同的类型,执行相应的操作。例如:
对于字符串字面量节点 SyntaxKind.StringLiteral,我们直接返回其文本值。
对于标识符节点 SyntaxKind.Identifier,我们则从 params 对象中获取其对应的值并返回。
对于二元表达式节点 SyntaxKind.BinaryExpression,我们调用 runBinaryExpression 函数来计算其值。
对于括号表达式节点 SyntaxKind.ParenExpression,我们则递归调用 runAST 函数来计算其表达式的值。

接下来我们看一下runBinaryExpression的实现:

function runBinaryExpression(ast, params) {
  // 二元运算符
  let operator = ast.operator;

  // 计算左值
  let leftValue = runAST(ast.left, params);
  
  // 计算右值
  let rightValue = runAST(ast.right, params);

  switch (operator) {
    // &&
    case SyntaxKind.AmpersandAmpersandToken:
      return Boolean(leftValue && rightValue);
    // ||
    case SyntaxKind.BarBarToken:
      return Boolean(leftValue || rightValue);
    // >
    case SyntaxKind.GreaterThanToken:
      return gt(leftValue, rightValue);
    // >=
    case SyntaxKind.GreaterThanEqualsToken:
      return gte(leftValue, rightValue);
    // <
    case SyntaxKind.LessThanToken:
      return lt(leftValue, rightValue);
    // <=
    case SyntaxKind.LessThanEqualsToken:
      return lte(leftValue, rightValue);
    // ==
    case SyntaxKind.EqualsEqualsToken:
      return eq(leftValue, rightValue);
    // !=
    case SyntaxKind.ExclamationEqualsToken: 
      return neq(leftValue, rightValue);

    default:
      throw new Error("unknown operator");
  }
}

注:gt、gte、lt、lte、eq、neq是大小判断方法,这个方法可以支持版本号大小的判断。这里篇幅有限,具体实现可参见demo中runAST.js。

runBinaryExpression用于计算二元运算表达式的值。输入参数 ast 是一个二元运算表达式的语法树节点,例如 1 > 2的ast。params 是一个包含变量名和值的对象,用于存储运行时的变量值。

这个函数首先获取运算符类型 operator,分别调用 runAST 函数来计算左值和右值。接着,根据不同的运算符类型,使用对应的方法来计算二元表达式的结果,并返回计算结果。

总结:整体运行方法和上述介绍实现方法的时候是一致的,可以结合起来一起看。

优化一下

反思一下整体过程,是要先把下方的规则编译成AST,然后在运行AST,如果整个过程都在客户端中执行,那么必然是需要消耗不少的时间。

那么是不是可以把编译成的AST进行序列化保存,然后传输序列化后的AST到客户端,这样客户端就可以减少生成AST这个步骤了。

那怎么序列化呢?我们先看一下这棵二叉树。

image.png

这棵树有根节点(操作符),左节点(左值的树),右节点(右值的树),那么我们就可以用一个长度为3的数组表示子树。

["==", "system", "ios"]

数组第一个元素是操作符,第二个元素是左值,第三个元素是右值。

简单的理解了,那复杂的呢,左右节点都是子树而不是叶子结点的时候呢?

["&&", ["==", "system", "ios"], [">=", "systemVersion", "13.0.0"]]

这个就把左值和右值换成子树数组即可,左右子节点依次递归就可以得到完整的二叉树了,这样就完成了序列化工作。

但是运行的时候发现一个问题,我们无法分清["==", "system", "ios"]中的 "system" 和 "ios" 是变量名称还是值。

为了解决这个问题,我们将引入一个新的指令ident, 表示是变量名称,则上述数组将改为

["==", ["ident", "system"], "ios"]
["&&", ["==", ["ident", "system"], "ios"], [">=", ["ident", "systemVersion"], "13.0.0"]]

这样我们运行的时候就可以区分出变量和值了。

序列化和运行的代码可以基于运行AST代码进行改造,这里可以直接看demo中toJson.js和 runJson.js文件。

到此为止

回到文章开头的那个问题

假设由于ios 13.0.0以上和android 8.0到12.0存在系统bug,我们需要对该RN特定页面线上进行降级到web页面。

我们就可以写如下规则了,规则如下:

path == "path/to/page" 
&& 
(
  (system == "ios" && systemVersion >= "13.0.0") 
  || 
  (system == "android" && systemVersion > "8.0.0" && systemVersion <= "12")
)

生成规则

const dsl =  'path == "path/to/page" && ((system == "ios" && systemVersion >= "13.0.0") || (system == "android" && systemVersion > "8.0.0" && systemVersion <= "12"))';

// 可以调用toJson来生成规则json,(可在服务端生成,下发至前端)
const json = toJson(dsl);

得到的json

[
  "&&",
  [
    "==", ["Ident", "path"], "path/to/page"
  ],
  [
    "||",
    [
      "&&",
      [
        "==", ["Ident", "system"], "ios"
      ],
      [
        ">=", ["Ident", "systemVersion"], "13.0.0"
      ]
    ],
    [
      "&&",
      [
        "&&",
        [
          "==", ["Ident", "system"], "android"
        ],
        [
          ">", ["Ident", "systemVersion"], "8.0.0"
        ]
      ],
      [
        "<=", ["Ident","systemVersion"], "12"
      ]
    ]
  ]
]

规则匹配

// 前端可以调用runJson来执行规则,返回匹配结果
const env = { system: "ios", systemVersion: "13.0.1", path: "path/to/page2" }
const isMatch = runJson(json, env);
// isMatch = false

代码仓库地址:github.com/fzliang/DSL…