藥不藥來當工程師一個不務正業的藥師,誤打誤撞來當了前端工程師。斜槓工程師的人生!

從 100 行 Regex 到 Lucene Parser:複雜驗證邏輯的重構之路

發佈於 最後更新於
12 min read

最近在維護一個關鍵字搜尋功能時,遇到了一個經典的工程問題:手寫的 Regex 驗證邏輯越來越難維護,每次修 bug 都像在拆炸彈。

這篇文章記錄了我如何從「純 Regex 驗證」重構到「Parser + AST 驗證」的過程,以及這兩種方法的優缺點比較。

問題背景

我們的系統允許使用者輸入布林搜尋語法來篩選產品,支援 ANDORNOT 運算子和括號分組:

(星冰樂 OR 那堤 OR 咖啡) AND (NOT (冷萃 OR 巧克力))

這個輸入會被送到後端,轉換成 BigQuery 的 Regex 進行資料篩選。前端的職責是在送出前驗證格式是否正確,給使用者友善的錯誤提示。

原本的 Regex 驗證

最初的實作是用一連串的 Regex 來檢查各種規則:

typescript
function validateKeywordsFormat(input: string): string | null {
  const trimmedInput = input.trim()

  // 檢查是否為空
  if (!trimmedInput) {
    return '請輸入關鍵字'
  }

  // 檢查括號是否配對
  let parenthesesCount = 0
  for (const char of trimmedInput) {
    if (char === '(') parenthesesCount++
    if (char === ')') parenthesesCount--
    if (parenthesesCount < 0) return '括號配對錯誤'
  }
  if (parenthesesCount !== 0) return '括號配對錯誤'

  // 檢查邏輯運算子格式(必須為大寫)
  if (/\b(?:and|not|or)\b/i.test(trimmedInput)) {
    return '邏輯運算子必須使用大寫:AND、NOT、OR'
  }

  // 檢查運算子前後是否有適當的空白
  if (/(?:^|\S)\b(?:AND|NOT|OR)\b(?:\S|$)/.test(trimmedInput)) {
    return '邏輯運算子前後必須有空白間隔'
  }

  // 檢查 AND/OR 不能緊接在左括號後面
  if (/\(\s*(?:AND|OR)\b/.test(trimmedInput)) {
    return 'AND/OR 運算子前面必須有運算元'
  }

  // ... 還有更多規則
}

這段程式碼大約 100 行,看起來還算清晰。但問題來了。

踩到的坑

某天 QA 回報了一個 bug:

輸入:(星冰樂 OR 那堤) AND (NOT (冷萃 OR 巧克力))
錯誤訊息:關鍵字中的邏輯運算子前後必須使用底線,例:hello_NOT_world

這明明是合法的輸入,為什麼會報錯?

追查後發現,問題出在這段 Regex:

typescript
const operatorWithoutSpaces = /(?:^|\S)\b(?:AND|NOT|OR)\b(?:\S|$)/.test(trimmedInput)

這個 Regex 的意圖是「檢查運算子前後是否緊鄰非空白字元」,但它把 (NOT 中的 ( 也當成了「非空白字元」,導致誤判。

修復很簡單,把 \S 改成 [^\s(] 就好:

typescript
const operatorWithoutSpaces = /(?:^|[^\s(])\b(?:AND|NOT|OR)\b(?:[^\s)]|$)/.test(trimmedInput)

但這只是開始。接著又發現 茶 (AND 水) 這種格式沒有被擋住(AND 前面應該要有運算元)。於是又加了一條規則:

typescript
if (/\(\s*(?:AND|OR)\b/.test(trimmedInput)) {
  return 'AND/OR 運算子前面必須有運算元'
}

然後又發現 hello_AND_world 這種底線包圍的情況被誤判...

每次修一個 bug,就可能引入另一個 bug。這就是 Regex 地獄

為什麼 Regex 不適合這種場景?

1. Regex 無法處理巢狀結構

Regex 是為「正規語言」(Regular Language) 設計的,而我們的查詢語法包含巢狀括號,這屬於「上下文無關語言」(Context-Free Language)1

理論上,Regex 無法正確處理任意深度的巢狀結構。雖然現代 Regex 引擎有一些擴充功能(如遞迴匹配),但這會讓 Regex 變得極其複雜。

2. 規則之間會互相干擾

當你有 10 條以上的 Regex 規則時,它們之間的執行順序和邊界條件會變得很難追蹤。一個規則的修改可能會影響其他規則的行為。

3. 錯誤訊息難以精確

Regex 只能告訴你「匹配」或「不匹配」,很難提供精確的錯誤位置和原因。

4. 測試覆蓋困難

要確保所有邊界案例都被正確處理,需要大量的測試案例。而且每次修改都要重新驗證所有案例。

解決方案:使用 Parser

既然我們的語法本質上是 Lucene Query Syntax 的子集,為什麼不直接用現成的 Parser?

lucene 套件

lucene 是一個 npm 套件,使用 PEG.js2 生成的 Parser 來解析 Lucene 查詢語法。它會把輸入字串轉換成 AST (Abstract Syntax Tree):

typescript
import lucene from 'lucene'

const ast = lucene.parse('茶 AND 水')
console.log(ast)
// {
//   left: { field: '<implicit>', term: '茶', ... },
//   operator: 'AND',
//   right: { field: '<implicit>', term: '水', ... }
// }

新的驗證架構

重構後的驗證邏輯分成三層:

typescript
function validateKeywordsFormat(input: string): string | null {
  const trimmed = input.trim()

  // 1. 空值檢查
  if (!trimmed) return '請輸入關鍵字'

  // 2. 預檢查(Parser 無法處理的規則)
  const preError = preValidateKeywords(trimmed)
  if (preError) return preError

  // 3. Parser 解析
  let ast: AST
  try {
    ast = lucene.parse(trimmed)
  } catch {
    return '關鍵字語法錯誤,請檢查括號配對與運算子使用'
  }

  // 4. AST 驗證(檢查不允許的特性)
  const featureError = checkDisallowedFeatures(ast)
  if (featureError) return featureError

  // 5. Term 格式檢查
  const terms = collectTerms(ast)
  for (const { term } of terms) {
    if (/_{2,}/.test(term)) return '請避免使用連續的底線'
    // ... 其他檢查
  }

  return null
}

預檢查:Parser 無法處理的規則

有些規則是我們自訂的,Lucene Parser 不會報錯,需要在 Parse 之前用 Regex 檢查:

typescript
function preValidateKeywords(input: string): string | null {
  // 小寫運算子(lucene 會把 'and' 當成關鍵字,不會報錯)
  if (/(?:^|\s)(?:and|or|not)(?:\s|$)/.test(input)) {
    return '邏輯運算子必須使用大寫:AND、NOT、OR'
  }

  // 運算子結尾(lucene 允許,但我們不允許)
  if (/\b(?:AND|OR|NOT)\s*$/.test(input)) {
    return '表達式不能以邏輯運算子結尾'
  }

  // 連續運算子
  if (/\b(?:AND|OR|NOT)\s+(?:AND|OR|NOT)\b/.test(input)) {
    return '不能有連續的邏輯運算子'
  }

  return null
}

AST 驗證:遍歷語法樹

Parser 成功後,我們可以遍歷 AST 來檢查更複雜的規則:

typescript
function checkDisallowedFeatures(node: AST | Node): string | null {
  function traverse(n: unknown): string | null {
    if (!n || typeof n !== 'object') return null
    const obj = n as Record<string, unknown>

    // 檢查萬用字元(* 或 ?)
    if ('term' in obj && typeof obj.term === 'string') {
      if (obj.term.includes('*') || obj.term.includes('?')) {
        return '不支援萬用字元(* 或 ?)'
      }
    }

    // 檢查 AND/OR 開頭(AST 中會有 start 屬性)
    if ('start' in obj && (obj.start === 'AND' || obj.start === 'OR')) {
      return '表達式不能以 AND 或 OR 開頭'
    }

    // 遞迴檢查子節點
    const leftError = 'left' in obj ? traverse(obj.left) : null
    if (leftError) return leftError

    const rightError = 'right' in obj ? traverse(obj.right) : null
    if (rightError) return rightError

    return null
  }

  return traverse(node)
}

兩種方法的比較

面向純 RegexParser + AST
程式碼量~100 行~150 行
可讀性低(Regex 難讀)高(結構清晰)
維護性低(規則互相干擾)高(分層處理)
擴充性低(新規則可能破壞舊規則)高(新規則獨立)
錯誤處理粗糙精確
效能稍慢(但可忽略)
依賴需要 lucene 套件

最終的驗證規則

重構後,我們的驗證涵蓋了 12 條規則:

#規則錯誤範例錯誤訊息
1空值""請輸入關鍵字
2括號配對((茶)關鍵字語法錯誤,請檢查括號配對與運算子使用
3空括號茶 AND ()同上
4小寫運算子茶 and 水邏輯運算子必須使用大寫:AND、NOT、OR
5運算子前後缺空白茶AND水邏輯運算子前後必須有空白間隔
6AND/OR 開頭AND 茶表達式不能以 AND 或 OR 開頭
7括號內 AND/OR 開頭(AND 茶)同上
8運算子結尾茶 AND表達式不能以邏輯運算子結尾
9連續運算子茶 AND AND 水不能有連續的邏輯運算子
10關鍵字內含運算子helloANDworld邏輯運算子前後必須有空白間隔
11連續底線test__keyword請避免使用連續的底線
12萬用字元茶*不支援萬用字元(* 或 ?)

為什麼要擋萬用字元?

這個規則值得特別說明。Lucene 原生支援萬用字元語法:茶* 表示「以茶開頭的任意字串」,茶? 表示「茶後面接一個任意字元」。

但我們的系統不支援這個功能——輸入 茶* 時,系統只會把它當成字面上的 茶* 去搜尋,不會展開成萬用字元匹配。

如果不擋這個語法,使用者可能會誤以為系統支援萬用字元搜尋,輸入 咖啡* 期待搜到「咖啡豆」、「咖啡粉」等結果,但實際上只會搜到品名剛好是 咖啡* 的產品(如果有的話)。

為了避免這種認知落差,我們選擇在前端直接擋掉,並給出明確的錯誤訊息。

結論

這次重構讓我深刻體會到:選擇正確的工具比硬幹重要

Regex 很強大,但它有適用範圍。當你發現自己在寫第 10 條 Regex 規則,而且每次修改都要擔心會不會破壞其他規則時,就該考慮換個方法了。

Parser 的學習曲線稍高,但它提供了:

  • 結構化的資料:AST 讓你可以用程式邏輯來處理,而不是用 Regex 猜測
  • 清晰的分層:預檢查、解析、AST 驗證各司其職
  • 更好的錯誤處理:Parser 的錯誤訊息通常比 Regex 更有意義

下次遇到類似的驗證需求,不妨先問自己:這個語法有多複雜?有沒有現成的 Parser 可以用?

環境資訊

  • Node.js: v22.16.0
  • TypeScript: 5.x
  • lucene: 2.1.1

本文撰寫時間:2026 年 1 月,技術版本可能隨時間更新,請以官方文件為準。

Reference

  1. Chomsky hierarchy - Wikipedia - 正規語言是 Chomsky 階層中最簡單的一層,無法表達巢狀結構。
  2. PEG.js - 一個 JavaScript 的 Parser Generator,lucene 套件使用它來生成 Lucene Query Parser。
Copyright 2020-2026 - AzureBlue ALL RIGHTS RESERVED.