從 100 行 Regex 到 Lucene Parser:複雜驗證邏輯的重構之路
最近在維護一個關鍵字搜尋功能時,遇到了一個經典的工程問題:手寫的 Regex 驗證邏輯越來越難維護,每次修 bug 都像在拆炸彈。
這篇文章記錄了我如何從「純 Regex 驗證」重構到「Parser + AST 驗證」的過程,以及這兩種方法的優缺點比較。
問題背景
我們的系統允許使用者輸入布林搜尋語法來篩選產品,支援 AND、OR、NOT 運算子和括號分組:
(星冰樂 OR 那堤 OR 咖啡) AND (NOT (冷萃 OR 巧克力))
這個輸入會被送到後端,轉換成 BigQuery 的 Regex 進行資料篩選。前端的職責是在送出前驗證格式是否正確,給使用者友善的錯誤提示。
原本的 Regex 驗證
最初的實作是用一連串的 Regex 來檢查各種規則:
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:
const operatorWithoutSpaces = /(?:^|\S)\b(?:AND|NOT|OR)\b(?:\S|$)/.test(trimmedInput)
這個 Regex 的意圖是「檢查運算子前後是否緊鄰非空白字元」,但它把 (NOT 中的 ( 也當成了「非空白字元」,導致誤判。
修復很簡單,把 \S 改成 [^\s(] 就好:
const operatorWithoutSpaces = /(?:^|[^\s(])\b(?:AND|NOT|OR)\b(?:[^\s)]|$)/.test(trimmedInput)
但這只是開始。接著又發現 茶 (AND 水) 這種格式沒有被擋住(AND 前面應該要有運算元)。於是又加了一條規則:
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):
import lucene from 'lucene'
const ast = lucene.parse('茶 AND 水')
console.log(ast)
// {
// left: { field: '<implicit>', term: '茶', ... },
// operator: 'AND',
// right: { field: '<implicit>', term: '水', ... }
// }
新的驗證架構
重構後的驗證邏輯分成三層:
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 檢查:
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 來檢查更複雜的規則:
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)
}
兩種方法的比較
| 面向 | 純 Regex | Parser + AST |
|---|---|---|
| 程式碼量 | ~100 行 | ~150 行 |
| 可讀性 | 低(Regex 難讀) | 高(結構清晰) |
| 維護性 | 低(規則互相干擾) | 高(分層處理) |
| 擴充性 | 低(新規則可能破壞舊規則) | 高(新規則獨立) |
| 錯誤處理 | 粗糙 | 精確 |
| 效能 | 快 | 稍慢(但可忽略) |
| 依賴 | 無 | 需要 lucene 套件 |
最終的驗證規則
重構後,我們的驗證涵蓋了 12 條規則:
| # | 規則 | 錯誤範例 | 錯誤訊息 |
|---|---|---|---|
| 1 | 空值 | "" | 請輸入關鍵字 |
| 2 | 括號配對 | ((茶) | 關鍵字語法錯誤,請檢查括號配對與運算子使用 |
| 3 | 空括號 | 茶 AND () | 同上 |
| 4 | 小寫運算子 | 茶 and 水 | 邏輯運算子必須使用大寫:AND、NOT、OR |
| 5 | 運算子前後缺空白 | 茶AND水 | 邏輯運算子前後必須有空白間隔 |
| 6 | AND/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
- Chomsky hierarchy - Wikipedia - 正規語言是 Chomsky 階層中最簡單的一層,無法表達巢狀結構。 ↩
- PEG.js - 一個 JavaScript 的 Parser Generator,lucene 套件使用它來生成 Lucene Query Parser。 ↩