实现一门「业务友好」表达式语言

18 阅读4分钟

git地址

做低代码、规则引擎或者复杂表单配置的时候,你一定遇到过这种需求:
「让业务同学用一小段表达式,就能从数据里算出结果」。
这篇文章重点分享两件事:

  1. 这套表达式解析器能处理哪些“看起来很刁钻”、但业务场景里非常常见的写法;
  2. 为什么我们选择基于 ASTJ 这类 AST 解析工具来落地,而不是用一堆正则和 if-else 把字符串硬抠出来。

一、我们这门表达式语言长什么样?

先快速感受一下语法风格,核心能力大致是:

  • 模板插值:${data.a}${user.profile.name}${list[0].value}
  • 算术:+ - * / %
  • 比较:> < >= <= == !=
  • 逻辑:&& || !
  • 空值合并:??
  • 三元运算:cond ? a : b
  • 字面量:数字、字符串、true/false/null
  • 访问:点访问 + 下标访问

看起来和 JS 很像,但我们做了大量“业务向”的增强,来解决几个真实世界里经常踩坑的地方。

下面挑几类有代表性的“特殊场景”展开讲。


二、我们能解析的几个关键「特殊场景」

1. 模板 + 无引号中文文本的自然拼接

业务写表达式,最常见的就是这种文案拼接:

${data.a} + ${data.b} 人
${data.a} + ${data.b} - 人
${data.a} + ${data.b} / 人

你很难指望大家老老实实写成:

${data.a} + ${data.b} + "人"
${data.a} + ${data.b} + "-人"
${data.a} + ${data.b} + "/人"

我们做的事情是:

  1. 支持表达式和无引号的中文文本混写
  2. 在词法阶段通过 tokenizeWithPlainText
    • 判断 / * - 这些字符到底是运算符还是纯文本
    • 把类似 15 人 这种片段在 Token 层转成等价的 15 + "人"

也就是说,这两种写法对引擎来说是等价的:

${data.a} + ${data.b} 人
${data.a} + ${data.b} + "人"

好处是:

  • 业务写法更“自然口语化”,不需要对所有文案都加引号;
  • 解析器内部仍然是一个完全合法的表达式 AST,后续可以照常做静态分析、优化和可视化。

技术细节上,我们在一次 Token 遍历结束后,再走一遍“补全隐式 +”的过程:
当发现前一个 Token 和后一个 Token 都是可拼接的(例如字符串或模板),就自动插入一个 PLUS Token。


2. 区分「运算符」和「文本中的符号」

-*/ 这种字符有两个身份:

  • 运算符:a - b-1count * 2
  • 文本符号:15-人15*人15/人

如果只是靠简单的分隔符 split,很容易把这些文本中的符号误判成运算符,导致:

  • 要么报语法错误;
  • 要么 AST 结构完全错位。

我们的做法是在 tokenizeWithPlainText 里做一个向右看的判断

if (input[pos] === '-' || input[pos] === '*' || input[pos] === '/') {
  let look = pos + 1;
  while (look < input.length && /\s/.test(input[look])) look++;
  const ch = input[look];

  const isOp =
    ch &&
    (
      /[0-9]/.test(ch) || // -3, *2, /10
      ch === '$'        || // -${a}
      ch === '('        || // -(1+2)
      /[a-zA-Z_]/.test(ch) // -value
    );

  if (isOp) {
    // 作为运算符处理
  } else {
    // 留给“纯文本读取”逻辑当普通字符
  }
}

这套规则让我们可以同时支持:

  • -1 / a - b / ${a} * 2 这样的正规表达式;
  • 15-人 / 15*人 / 15/人 这样的业务文案。

3. 模板内容里的「中文路径」与嵌套表达式

一个有意思的场景是:模板内容本身也可能是一段表达式,而且可能包含中文:

${data.a + data.b}
${客户.姓名}

我们在 parsePrimary 中对 TEMPLATE 类型做了两层处理:

  1. 如果模板内容里没有中文

    • 用同一套 Lexer + Parser 再跑一遍,把它当成子表达式;

    • 最终得到类似:

      {
        "type": "Template",
        "content": { "type": "BinaryOp", "operator": "+", ... },
        "hasChinese": false
      }
      
  2. 如果模板内容里包含中文字符

    • 把整段内容当成一个“路径字符串”,例如 "客户.姓名"
    • 在执行阶段走专门的 evaluateChinesePath 逻辑,根据点号分段、允许中文 key 的规则,从上下文中取值。

例如,上下文里有:

{
  客户: {
    姓名: '张三'
  }
}

表达式:

"客户:" + ${客户.姓名}

就能被正确解析并执行到 "客户:张三"

这里我们做了两层保护:

  • 模板内容解析失败时的兜底:如果尝试作为子表达式解析失败,会自动退回到“中文路径字符串”模式;
  • 路径字符合法性校验:只允许由英文/数字/下划线/中文组成的片段,避免把带空格、斜杠的内容误当路径。

4. 可配置占位符语法:${}{{}} 一键切换

不同系统里,模板占位符的写法不一样,比如:

  • 经典 ES 模板字符串:${var}
  • 模板引擎风格:{{var}}
  • 某些平台可能是 <% var %> 之类的语法

我们在表达式引擎外面套了一层 createExpressionEngine(options),支持配置:

const engine = createExpressionEngine({
  placeholderStart: '{{',
  placeholderEnd: '}}'
});

在真正 tokenize 之前,会先把自定义占位符规范化成内部标准的 ${} 形式:

function normalizeExpression(expr) {
  const start = config.placeholderStart; // 比如 '{{'
  const end = config.placeholderEnd;     // 比如 '}}'

  // 把 {{...}} 统一转换成 ${...}
}

这样一来:

  • 对调用方来说,只需要说“我这里的占位符是 {{ ... }}”;
  • 对引擎内部来说,永远都是统一的 AST 规则,解析、执行逻辑完全复用。

5. 完整表达式能力:逻辑、空值合并、三元……

在以上这些“特殊需求”之外,这套表达式本身也具备完整的逻辑能力:

  • ?? 空值合并:a ?? b 只在 anull/undefined 时才用 b
  • && / || 逻辑运算;
  • 嵌套三元:cond ? a : b,支持在 AST 层严格校验 ? : 的配对;
  • 成熟的运算符优先级处理:* / % > + - > 比较 > 逻辑 > ?? > ?:

这些都不是特别“花哨”的点,但一旦缺失,表达式的实际表达力就会大打折扣。


三、为什么要基于 ASTJ,而不是正则 + eval?

如果你只想实现一个最简单的 ${var} 替换,其实一两行正则就够了;
但一旦要支持上面这些复杂场景,基于 AST 思路的方案就非常有优势

这里用“ASTJ”泛指一类 JS AST 解析工具/框架,我们的表达式引擎正是站在 AST 这一层上来设计的。

1. 语法清晰可控,而不是“一坨字符串”

有了 AST,你看到的就不再是这样的字符串:

${data.a} + ${data.b} 人

而是一棵结构化的数据:

{
  "type": "BinaryOp",
  "operator": "+",
  "left": {
    "type": "BinaryOp",
    "operator": "+",
    "left": { "type": "Template", "content": { "type": "Identifier", "name": "data.a" } },
    "right": { "type": "Template", "content": { "type": "Identifier", "name": "data.b" } }
  },
  "right": { "type": "Literal", "value": "人" }
}

这带来的好处是:

  • 容易做静态分析:比如找出表达式依赖了哪些字段;
  • 容易做重写和优化:想把 a + 0 优化掉,改写成 a 非常简单;
  • 容易做可视化:在 UI 里渲染成树形结构,让非前端同学也看得懂。

2. 安全边界清晰,避免直接 eval 的风险

如果用 evalnew Function 来执行表达式,难免会遇到这些问题:

  • 表达式里可以写任意 JS 代码;
  • 有机会访问到 windowdocument 等全局对象;
  • 很难做白名单/黑名单控制。

而基于 ASTJ 的表达式引擎只执行我们定义过的节点类型:

  • LiteralBinaryOpTemplateTernaryMemberAccess……
  • 没有 CallExpression 就不会有任意函数调用;
  • 没有 NewExpression 就不会随便 new;
  • 所有能力都在 AST 层有明确枚举。

这样一来,语言的边界完全可控,对安全性和治理是非常有利的。

3. 易于扩展,业务 DSL 可以逐步成长

当你发现有新的业务诉求时,比如:

  • 想要一个 in 运算符:status in ["draft", "done"]
  • 想要内置一个 dateDiff() 函数;
  • 想要特殊处理一些字段(比如“缺省值”)。

在 AST 方案下,你只需要:

  1. 在词法 + 语法层加上对应的 Token / 节点类型;
  2. 在 Evaluator 里实现该节点的执行逻辑。

整个过程是局部可控的,不会牵扯一堆“字符串切来切去”的逻辑,非常适合迭代演进一门 DSL

4. 和调试、观测天然契合

AST 最大的附赠价值,就是可观测性

  • 我们可以把 AST 打印出来给前端,做成一个“表达式分析报告”;
  • 可以标注每个节点对应的原始字符串位置,帮助业务精准定位问题;
  • 可以记录执行过程中的中间值(对某些场景甚至能实现「逐步求值」的调试体验)。

这些能力都建立在“表达式先变成 AST 再执行”的前提上 ——
而 ASTJ 这样的解析工具,就是帮我们把这一步做扎实的地基。


四、总结

这套表达式解析器在设计上有两个核心目标:

  1. 业务友好:支持自然的中文文案拼接、中文路径、灵活的占位符写法,以及完整的逻辑表达能力;
  2. 工程友好:基于 AST 思路(借助 ASTJ 这类工具)来做解析和执行,带来可控的语法、安全的执行边界和良好的扩展性。

如果你也在做低代码平台、报表配置、规则引擎之类的系统,
不妨尝试用 AST 的视角重新审视你的表达式需求:
从一开始就把语言定义清楚,把解析和执行分开,对后续的演进和维护会有非常大的帮助。