跳到主内容

嵌套结构提取避坑实录:何时放弃正则改用状态机 ​

问题背景:正则不是所有文本提取的终点 ​

很多团队第一次遇到日志、DSL、配置片段提取时,都会自然地写出一条正则:匹配左括号、吞掉中间字符、再匹配右括号。对于 name(value) 这类扁平结构,这种方案开发最快、性能也足够好。

但当输入变成 a(b(c(d)))、a("x)", y(z)),甚至括号不成对时,问题就不再是“正则熟不熟”,而是“正则的计算模型是否适合描述嵌套结构”。真正需要建立的是选型边界:什么深度以内继续用正则,什么场景必须切换到状态机解析。

本文用 JavaScript 给出两套可直接运行的代码:先展示正则在浅层场景中的正确用法,再实现一个小型字符级状态机处理任意深度嵌套,并总结切换信号与常见坑。

方案对比:正则、递归正则与状态机的能力边界 ​

正则适合描述有限、线性、局部的词法规则,例如识别数字、标识符、键值对、固定层数的括号。现代部分正则引擎支持递归子模式,如 PCRE 的 (?R),但 JavaScript 标准正则并不支持通用递归;即便引擎支持,递归正则也往往难以处理字符串内的括号、转义符、注释和错误恢复。

状态机解析则逐个读取字符,维护当前模式与栈。遇到开括号入栈,遇到闭括号出栈;进入字符串后,括号只作为普通字符;遇到反斜杠时跳过下一字符。它的代码比一条长正则多,但行为可解释、可调试、可扩展。

接收待提取文本结构是否线性或层数固定?检查嵌套、转义、容错需求是否使用正则短、快、便于局部抽取使用状态机解析栈、模式、错误位置可控

正则实战:固定两层嵌套可以这样写 ​

下面的示例提取函数名以及最多两层括号中的参数。它不是通用解析器,只适用于层数已知、字符串中不会伪装括号的受控输入。

javascript
const text = 'log(user(name), level) and calc(sum(1, 2))';

// 含义:
// \b([a-zA-Z_]\w*)  单词边界后捕获函数名
// \(                匹配最外层左括号
// (                 开始捕获参数整体
//   [^()]*           第一层普通字符:不能出现任何括号
//   (?:\([^()]*\)   可选地匹配一对“内部不含括号”的子括号
//      [^()]*)*      子括号后仍允许普通字符,并可重复
// )                 结束参数捕获
// \)                匹配最外层右括号
const regex = /\b([a-zA-Z_]\w*)\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g;

let match;
while ((match = regex.exec(text)) !== null) {
  console.log(match[1], '=>', match[2]);
}
// 输出:
// user => name
// log => user(name), level
// sum => 1, 2
// calc => sum(1, 2)

逐段看,[^()]* 是这个方案能保持清晰的关键:它明确禁止跨括号贪婪吞噬。(?:\([^()]*\)[^()]*)* 只允许出现一层完整子括号,因此 a(b(c)) 不会被当作合法的两层参数整体匹配。

这种写法的收益是简单、快速、声明式;边界也很明显:嵌套层数增加时,表达式要按层级继续展开,复杂度呈组合膨胀。若还要支持 "a)"、'\''、注释,正则会迅速变成无人敢改的“咒语”。

状态机实现:任意深度嵌套、字符串与转义 ​

当需求包含任意深度、字符串转义或错误位置报告时,应改为扫描字符。下面的状态机提取每个最外层调用及其完整参数,并在括号不匹配时给出位置。

javascript
function parseCalls(input) {
  const calls = [];
  const stack = [];      // 保存每层调用的名称和起始下标
  let identifier = '';   // 累积当前标识符
  let mode = 'normal';   // normal | single | double
  let escape = false;    // 下一个字符是否被转义

  for (let i = 0; i < input.length; i++) {
    const ch = input[i];

    // 字符串模式中只关心引号和反斜杠,括号不参与匹配
    if (mode !== 'normal') {
      if (escape) {
        escape = false;
      } else if (ch === '\\') {
        escape = true;
      } else if ((mode === 'double' && ch === '"') ||
                 (mode === 'single' && ch === "'")) {
        mode = 'normal';
      }
      continue;
    }

    if (/[A-Za-z_$][\w$]*/.test(ch)) {
      identifier += ch;
    } else if (ch === '(') {
      // 开括号:把当前标识符连同内容起点一起压栈
      stack.push({ name: identifier || '<anonymous>', start: i });
      identifier = '';
    } else if (ch === ')') {
      const frame = stack.pop();
      if (!frame) {
        throw new Error(`第 ${i} 位出现多余的右括号`);
      }

      // start + 1 到 i 之间是当前层参数,内部可继续包含嵌套调用
      const args = input.slice(frame.start + 1, i);
      calls.push({ name: frame.name, args, depth: stack.length + 1 });
      identifier = '';
    } else if (ch === '"' || ch === "'") {
      mode = ch === '"' ? 'double' : 'single';
    } else if (!/\s/.test(ch)) {
      // 逗号、点号等分隔符出现后,旧标识符不再属于后续调用
      identifier = '';
    }
  }

  if (stack.length) {
    const last = stack[stack.length - 1];
    throw new Error(`第 ${last.start} 位的 "${last.name}(" 缺少右括号`);
  }
  return calls;
}

console.log(parseCalls('run("x)", task(a(b(1))), next())'));
// [
//   { name: 'b', args: '1', depth: 3 },
//   { name: 'a', args: 'b(1)', depth: 2 },
//   { name: 'task', args: 'a(b(1))', depth: 1 },
//   { name: 'next', args: '', depth: 1 },
//   { name: 'run', args: '"x)", task(a(b(1))), next()', depth: 0 }
// ]

核心只有三个状态变量:stack 表达嵌套深度,mode 表达当前是否位于字符串内,escape 表达转义。新增方括号支持、块注释或参数 AST,都可以在这个骨架上增量添加;换成一条巨型正则,则通常意味着整体重写。

选型决策框架:出现这些信号就该切换 ​

第一,嵌套深度由输入决定,而不是由协议固定。只要用户能传入 a(b(c(d(...))),就不应依赖手写层数展开。第二,同一字符在不同上下文中含义不同,例如右括号在字符串里是普通文本,在普通模式下才是结构符号。第三,需要错误恢复、行列号、未闭合提示或局部容错。正则擅长告诉你“是否匹配”,不擅长解释“为什么不匹配”。

反过来,如果只是从日志中抽取固定格式的 request_id=xxx,或清洗最多一层括号的标签,继续使用正则更合适。工程决策不是追求最通用的解析器,而是让复杂度与输入契约匹配。

避坑总结:三条来自实战的判断与修正 ​

  1. 贪口号点把字符串里的括号当结构。 常见现象是 f("a)") 被截成 f("a,开发者继续用 [\s\S]* 扩大匹配,结果又吞掉下一个调用。修正方法是浅层场景先限制字符类,深层场景引入字符串模式,不要靠贪婪或非贪婪猜测边界。

  2. 用固定层数正则硬扛任意深度输入。 表达式可能从一层复制到三层,测试看似通过,但六层数据出现后直接漏匹配。可通过构造 a( 重复 100 次的用例快速识别;只要深度来自外部输入,就应使用栈式状态机。

  3. 忽视非法输入,导致错误静默扩散。 典型现象是多余右括号被忽略,缺少右括号时匹配结果为空却没有异常。状态机应在入栈、出栈和扫描结束三个位置校验,并返回具体下标;正则方案则应在匹配后补充括号平衡检查,而不是把“不匹配”误判为“无数据”。

  4. 把可维护性当成审美问题。 当一条正则超过两屏、需要大量注释才能解释,且每次需求变更都要回归全量文本时,维护成本已经超过解析器代码。此时应优先提取词法规则,再用少量状态组合结构规则。

正则依旧是文本处理工具箱中最高效的一层,但嵌套结构属于递归和上下文问题。识别这条选型边界,比再背几个高级语法更能决定系统的稳定性。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。