試工具Rea:解析器與回溯軌跡全解析)
正則表達式這玩意兒寫的時候感覺自己就是語言學(xué)家調(diào) bug 的時候又覺得自己像個算命先生。尤其面對那種一長串(?...)、(?!...)、嵌套分組、堆了七八個量詞的老古董正則你很難說清楚它到底在干什么、為什么這一段匹配上了、那一段又吞了字符。去年我就在這種狀態(tài)里憋了很久最后決定動手寫一個工具叫ReaRegular Expression Assistant。它的核心能力不是幫你“驗證正則對不對”而是把正則從字符串到匹配結(jié)果的每一步都拆開以語法樹、匹配軌跡、回溯路徑的方式攤在眼前。這篇文章就把 Rea 的設(shè)計思路、解析器實現(xiàn)、執(zhí)行引擎可視化以及我實測中踩過的幾個深坑完整梳理一遍。如果你也經(jīng)常被復(fù)雜正則困住或者想做一個類似的“看得見執(zhí)行過程”的開發(fā)者工具這篇應(yīng)該能給你不少能直接用的方案。1. 為什么我動手寫 Rea市面上沒有講清楚正則的調(diào)試工具1.1 正則難讀的本質(zhì)你不是不會寫是看不到它的結(jié)構(gòu)很多人覺得正則難是因為直接把一串字符當成了“規(guī)則本身”。但正則其實是一門微型語言它從用戶輸入到最終匹配至少要經(jīng)過兩層解釋第一層是字符串層也就是你肉眼看到的那串字符第二層是語法層它要把*、、{}、[]、()、^、$這些東西識別成量詞、字符組、分組、斷言第三層才是執(zhí)行層也就是用語法樹去和目標文本做狀態(tài)匹配。問題在于市面上絕大多數(shù)正則工具只給你輸入框、測試文本、匹配結(jié)果列表三個東西。你看到的是“匹配了”或者“沒匹配”但看不到匹配引擎在中間到底走了哪條路。比如這個很常見的密碼校驗正則^(?.*\d)(?.*[a-z])(?.*[A-Z]).{8,16}$它能在大多數(shù)工具里正常工作但如果你想知道“為什么第二個斷言失敗”工具只會在原地轉(zhuǎn)一圈給個淡淡的“無匹配”。正則本身成了一個黑盒調(diào)試就變成了盲猜。Rea 想解決的就是這件事把黑盒打開。它會把正則解析成語法樹按分組、量詞、斷言的邏輯層級展開然后按執(zhí)行引擎的每一步把當前匹配位置、捕獲組快照、嘗試過的分支、回溯跳轉(zhuǎn)都記錄下來最后在界面上用“步驟列表 文本進度條 回溯連線”的方式回放。這樣一來正則不再是猜謎而是有跡可循的調(diào)試對象。1.2 一個案例說明現(xiàn)有工具的不足我在某個日志清洗項目里遇到過一條線上規(guī)則^(?:[^,]),(\d{4}-\d{2}-\d{2}),(.*)$意圖是從逗號分隔的日志行里取出日期和剩余內(nèi)容。工具測試時規(guī)則是通的但到了生產(chǎn)里有一批行死活匹配不上。后來逐行看發(fā)現(xiàn)某些日志的時間字段里多了個空格比如2024-01-02 03:04:05更關(guān)鍵的是前面有個字段本身包含逗號導(dǎo)致[^,]把后面的結(jié)構(gòu)全打亂了。這種問題用現(xiàn)成工具很難定位。你看到“沒匹配”三個字但到底是因為日期格式錯了、字段分隔符不對、還是分組量詞吃掉了不該吃的字符沒有執(zhí)行軌跡你只能繼續(xù)加日志、改正則、再測反復(fù)試錯。Rea 的定位就是把這個調(diào)試過程從“試”變成“看”讓引擎每走一步都留痕。1.3 Rea 的目標用戶和使用方式Rea 主要面向幾類人常年寫復(fù)雜正則的后端、數(shù)據(jù)開發(fā)需要在規(guī)則提交前快速確認執(zhí)行路徑做爬蟲、日志解析、網(wǎng)絡(luò)安全策略的工程師正則往往就是核心邏輯搞錯一次就全線崩帶新人的團隊可以把 Rea 當教學(xué)工具展示什么是回溯、什么是零寬斷言、為什么貪婪匹配會“吞掉”后面的部分想自己實現(xiàn)迷你正則引擎的人Rea 的解析和執(zhí)行代碼本身就是一份可以拆著看的參考。它不是用來替代常規(guī)正則測試器的。常規(guī)工具負責“結(jié)果對不對”Rea 負責“為什么對、為什么錯、瓶頸在哪”。在實際使用里這兩者配合起來效率最高。2. Rea 的架構(gòu)設(shè)計為什么我要自己寫一個解析器而不是復(fù)用現(xiàn)成正則引擎2.1 四層架構(gòu)詞法層、語法層、執(zhí)行層、渲染層Rea 的整體結(jié)構(gòu)分成四層每一層職責單一數(shù)據(jù)傳輸方向也很明確。層級主要職責輸入輸出詞法層把正則字符串切成有意義的最小單元正則源字符串Token 數(shù)組語法層把 Token 按優(yōu)先級組裝成 ASTToken 數(shù)組語法樹節(jié)點執(zhí)行層用 AST 模擬正則匹配過程并記錄步驟AST 待測文本步驟記錄數(shù)組渲染層把 AST 和步驟記錄可視化步驟記錄 AST界面交互展示這樣分層的好處是每一層都可以單獨測試。詞法層不關(guān)心語法對不對語法層不關(guān)心文本匹配執(zhí)行層只關(guān)心狀態(tài)遷移渲染層只關(guān)心怎么把數(shù)據(jù)畫出來。后面我排查引擎行為不一致的問題時基本都能快速定位到具體某層而不是整鍋粥一塊煮。2.2 為什么選 TypeScript 而不是 Python 后端 網(wǎng)頁前端一開始我確實想過用 Python 做解析引擎再包一層 Web 接口前端用 Canvas 畫軌跡。但很快否了。原因有三個第一Rea 的核心交互發(fā)生在瀏覽器里從輸入正則到看到軌跡最好是在一個頁面內(nèi)完成不要有網(wǎng)絡(luò)往返。純前端的 TypeScript 方案最干凈打開頁面即用也能直接做成離線工具。第二正則的方言差異很大。JavaScript 自己就有 ECMAScript 正則語義如果后端用 Python 的regex庫做解析那 Rea 展示的行為和用戶瀏覽器里的實際行為就會出現(xiàn)差異。干脆直接用 TypeScript 按 ECMAScript 語義實現(xiàn)一個小型引擎反而能讓“工具行為”和“用戶環(huán)境行為”保持更近。第三調(diào)試工具本身得容易調(diào)試。TypeScript 有完整的類型定義AST 節(jié)點的結(jié)構(gòu)、步驟記錄的數(shù)據(jù)結(jié)構(gòu)都可以用類型約束起來改起來不容易改出隱蔽的運行時錯誤。2.3 為什么不直接復(fù)用現(xiàn)成的解析庫社區(qū)里確實有很好的正則解析庫比如regexpp、regexp-tree這些能生成標準 AST也省很多事。但我最終還是自己寫了解析器。原因很實際現(xiàn)成庫給出的 AST 偏向“規(guī)范正確”但不太照顧“可視化友好”。Rea 的語法樹既要做結(jié)構(gòu)展示又要和執(zhí)行引擎的步驟一一對應(yīng)。我希望每個節(jié)點上都掛著stepIndex、capturesAffected這類執(zhí)行期信息還要在渲染時能給分組、量詞、斷言分別上色。自己生成 AST意味著我能完全控制節(jié)點類型和副作用標注。當然自己寫解析器不是逞能。我的實現(xiàn)目標很明確覆蓋 ECMAScript 正則的常見語法子集包括字符類、分組、命名捕獲、非捕獲分組、前瞻斷言、后顧斷言、量詞和分支就好。像復(fù)雜的vflag Unicode 集合運算這種極少數(shù)用到的特性我直接選擇不支持并在界面上給出“暫不支持”的提示。與其做個半吊子支持不如明確邊界把核心路徑打磨穩(wěn)。2.4 執(zhí)行引擎的定位不是追求性能而是追求“可觀察”Rea 的執(zhí)行引擎不需要像原生 V8 正則引擎那樣快它的核心指標是每個決策都要能記錄下來。所以我實現(xiàn)的是基于 AST 的遞歸回溯模擬器每個節(jié)點進入、嘗試、成功、失敗、回溯都會產(chǎn)生一條步驟記錄。這種實現(xiàn)方式在很短文本上性能完全夠用。因此我在 Rea 里留了一條默認保護線待測文本長度超過 5000 字符或者步驟數(shù)超過 5 萬步就停止執(zhí)行并給出提示。這個限制不是偷懶是考慮到瀏覽器渲染壓力同時也是為了讓用戶意識到一個正則跑出幾萬步本身就是需要警惕的信號。3. 解析器實現(xiàn)從一串字符串到一棵語法樹3.1 詞法層先切出最小單元詞法層做的事情很簡單把源字符串掃一遍根據(jù)當前字符是不是\、是不是[、是不是(來切出不同類型 token。我定義的基礎(chǔ) token 類型大概是這樣的type TokenType | char // 普通字符 | escape // \d \w \s \n \uXXXX 等轉(zhuǎn)義 | classStart // [ | classEnd // ] | groupStart // ( | groupEnd // ) | charClassEnd? // 用不到 | quantifier // * ? { | alternation // | | anchor // ^ $ | lookahead // (? (?! 等 | lookbehind // (? (?!詞法需要特別注意轉(zhuǎn)義字符。比如\d應(yīng)該作為一個Escapetoken它的值是d類型是“數(shù)字類”\.也是一個Escapetoken但語義是“轉(zhuǎn)義后的點字符”。轉(zhuǎn)義后面跟著什么字符決定了這個 token 到底代表一類字符還是代表一個普通字面字符這個判斷放到語法層再做會更清晰。3.2 語法層用遞歸下降處理優(yōu)先級正則的優(yōu)先級從高到低大約是原子字符、字符類、分組 量詞 連接順序拼接 分支|。因此我用了遞歸下降解析器四個方法互相調(diào)用class RegexParser { private tokens: Token[]; private pos: number 0; parse(): Node { const expr this.parseAlternation(); if (this.pos ! this.tokens.length) { throw new Error(Unexpected token at position ${this.pos}); } return expr; } private parseAlternation(): Node { const children [this.parseSequence()]; while (this.match(alternation)) { children.push(this.parseSequence()); } return children.length 1 ? children[0] : { type: Alternation, children, captures: [] }; } private parseSequence(): Node { const children: Node[] []; while (!this.check(groupEnd) !this.check(alternation) !this.isEnd()) { children.push(this.parseRepeat()); } return children.length 1 ? children[0] : { type: Sequence, children }; } private parseRepeat(): Node { const atom this.parseAtom(); if (this.check(quantifier)) { const q this.eat(quantifier) as QuantifierToken; return { type: Repeat, atom, min: q.min, max: q.max, greedy: q.greedy, }; } return atom; } private parseAtom(): Node { const token this.peek(); // 根據(jù) token 類型分別處理普通字符、轉(zhuǎn)義類、字符類、分組、斷言 } }這個結(jié)構(gòu)很經(jīng)典但對正則來說有幾個容易寫錯的地方。最典型的是分組解析左括號(后面可能是?、?!、?:、?name、?、?!這些尾巴必須在同一層處理干凈。我把所有分組入口都集合在parseAtom里private parseGroup(): GroupNode { this.eat(groupStart); const start this.prevToken(); let type: GroupType capturing; let name: string | undefined; if (this.peek().type flag) { const flag this.eat(flag); if (flag.value ?:) type noncapturing; if (flag.value ?) type lookahead; // 肯定前瞻 if (flag.value ?!) type negativeLookahead; if (flag.value ?) type lookbehind; if (flag.value ?!) type negativeLookbehind; // ?name 要單獨解析 } const body this.parseAlternation(); this.eat(groupEnd); return { type: Group, groupType: type, name, body, // 捕獲組編號在整棵 AST 構(gòu)建完成后統(tǒng)一分配 }; }這里有一個非常容易踩的坑命名捕獲組(?name...)的前綴?和后顧斷言(?...)的前綴?只差一個字符。詞法層如果分開處理必須做到最長的匹配優(yōu)先。我在詞法掃描時看到?會把后面的字符一起拿到看第三個字符是、!還是別的。如果是或!就是后顧斷言否則就當作命名分組開始。3.3 捕獲組編號的分配時機正則里捕獲組編號有其固定規(guī)則按(在正則字符串中出現(xiàn)的順序編號從左到右遇到普通捕獲組就遞增。非捕獲組(?:...)不參與編號但后顧斷言、前瞻斷言里的捕獲組卻參與編號。Rea 的做法是先完整生成 AST然后做一次后序遍歷給分組節(jié)點統(tǒng)一分配捕獲組索引。索引分配的信息會保存在 AST 節(jié)點上執(zhí)行引擎在記錄捕獲組快照時直接讀取。這個設(shè)計避免了在解析過程中動態(tài)編號造成的狀態(tài)混亂尤其當分組嵌套很深時后序遍歷比邊解析邊編號要穩(wěn)得多。3.4 字符類內(nèi)部的“第二套語法”字符類[...]內(nèi)部和外部的語法規(guī)則完全不同。普通上下文里.就是任意字符就是量詞但在[]里大部分元字符都失去特殊含義只是加號本身-在中間是范圍連接符在首尾或轉(zhuǎn)義后是普通字符。^出現(xiàn)在[后面第一個位置表示取反出現(xiàn)在其他地方就是普通字符。Rea 的詞法層會單獨維護一個“字符類上下文模式”。進入[后詞法不再識別量詞、分支、分組而只識別普通字符、轉(zhuǎn)義字符、范圍符號-、以及結(jié)尾的]。這樣函數(shù)雖然多了一個狀態(tài)分支但后續(xù)解析邏輯清爽很多。4. 可視化執(zhí)行引擎把回溯變成看得見的軌跡4.1 回溯的本質(zhì)是選擇點的撤銷回溯是正則引擎最核心、也最讓人頭疼的機制。很多人的概念是“匹配失敗了就回溯”其實回溯發(fā)生在“當前路徑匹配不下去時回到最近一個還能選其他路的選擇點”。想理解回溯可以把正則匹配想象成走迷宮。你到了一個岔路口左邊墻上有標記“這是分叉點”你選擇走左岔路。走了幾十米發(fā)現(xiàn)是死胡同于是退回到岔路口撿起沒走過的右岔路再走。回溯的成本不一定高但如果在同一個分叉點反復(fù)試、試完又回到上個分叉點、疊了好幾層量詞路徑數(shù)量就會爆炸。Rea 的可視化引擎核心就是記錄每個分叉點的“打開/關(guān)閉”動作。每當一個節(jié)點嘗試一個分支就產(chǎn)生一條try步驟如果分支失敗就產(chǎn)生一條backtrack步驟。這樣用戶能看到引擎到底做了幾次選擇、每次選擇消耗了什么字符。4.2 步驟記錄器的數(shù)據(jù)結(jié)構(gòu)執(zhí)行引擎在遞歸回溯過程中會產(chǎn)生海量中間信息但如果全部記錄性能會很差。Rea 對記錄內(nèi)容做了取舍不記錄每一次字符比較而是記錄“節(jié)點級事件”。一個CharNode的比較只記錄一次成功或失敗一個量詞的每次重復(fù)循環(huán)都記錄為一次迭代一個斷言節(jié)點記錄其成功/失敗以及結(jié)束位置。核心步驟類型如下interface Step { type: enter | try | match | fail | backtrack | end; nodeId: number; // AST 節(jié)點唯一 id nodeLabel: string; // 便于展示的節(jié)點描述 position: number; // 進入該節(jié)點時的文本位置 nextPosition: number; // 離開該節(jié)點后的文本位置 captureSnapshot: CaptureSnapshot; // 當前捕獲組快照 stepIndex: number; // 全局遞增步驟號 depth: number; // 遞歸深度渲染時用來做縮進 }捕獲組快照不能每次都深拷貝整個對象那樣文本稍長就會內(nèi)存爆炸。我采用了“按需記錄”的方式只在進入捕獲組失敗、退出捕獲組、或者回溯發(fā)生時記錄當前捕獲組狀態(tài)。每一步事件都帶一個引用指向“上一次發(fā)生了捕獲變化的快照”渲染層再按需合并出完整狀態(tài)。這個優(yōu)化在回放幾千步的匹配過程時非常關(guān)鍵。4.3 執(zhí)行器代碼骨架執(zhí)行器本質(zhì)上就是遍歷 AST 的遞歸函數(shù)。以一個簡化版為例function tryNode(node: Node, pos: number, ctx: MatchContext): number | null { switch (node.type) { case Char: { const ok ctx.text[pos] node.value; recordStep(try, node, pos, ok ? pos 1 : pos, ctx); return ok ? pos 1 : null; } case Star: case Plus: case Repeat: { return tryQuantifier(node, pos, ctx); } case Sequence: { let cur pos; for (const child of node.children) { const next tryNode(child, cur, ctx); if (next null) { recordStep(backtrack, node, cur, cur, ctx); return null; } cur next; } return cur; } case Group: { if (node.groupType capturing) { ctx.captureStart(node.index); } const endPos tryNode(node.body, pos, ctx); if (node.groupType capturing endPos ! null) { ctx.captureEnd(node.index, endPos); } return endPos; } default: return null; } }注意這里Sequence一旦遇到子節(jié)點失敗直接返回失敗由上層決定是否回溯。真正選擇點的管理放在Alternation和Repeat節(jié)點里。比如Alternation需要依次嘗試每個子分支失敗后記錄一次backtrack再試下一個分支case Alternation: { for (const child of node.children) { const result tryNode(child, pos, ctx); if (result ! null) return result; recordStep(backtrack, node, pos, pos, ctx); } return null; }量詞的邏輯更麻煩。因為貪婪量詞會先盡可能多匹配匹配到上限后再逐漸讓出字符做后續(xù)匹配。這個“讓出字符”就是回溯在量詞上的表現(xiàn)。Rea 在tryQuantifier里用一個循環(huán)先按貪婪策略吞字符吞到吞不下為止然后把一次次的“吞入”和后續(xù)“回吐”都記錄下來function tryQuantifier(node: RepeatNode, pos: number, ctx: MatchContext): number | null { let count 0; let cur pos; const attempts: number[] []; while (count (node.max ?? Infinity) (node.min 0 || count node.min)) { const next tryNode(node.atom, cur, ctx); if (next null) break; attempts.push(cur); cur next; count; } // 貪婪時先嘗試繼續(xù)多匹配 while (count (node.max ?? Infinity)) { const next tryNode(node.atom, cur, ctx); if (next null) break; attempts.push(cur); cur next; count; } // 回退嘗試減少重復(fù)次數(shù) while (count node.min) { if (node.min 0 || count node.min) { const rest tryNode(node.next, cur, ctx); // 簡化真實的遞歸結(jié)構(gòu)需要掛載 next 指針 if (rest ! null) return rest; recordStep(backtrack, node, cur, cur, ctx); } count--; cur count 0 ? attempts[count - 1] : pos; } return null; }實際引擎里node.next不像上面這么簡單因為 AST 是樹結(jié)構(gòu)處理量詞時需要把“匹配完重復(fù)內(nèi)容后的剩余部分”作為 continuation 傳入否則很難正確回溯。我在實現(xiàn)里給每個節(jié)點增加next指針結(jié)構(gòu)上變成一個“AST 鏈式 continuation”的組合執(zhí)行器在量詞回退時就能把當前位置交給后續(xù)節(jié)點繼續(xù)嘗試。node.next這個概念是 Rea 能正確可視化回溯的關(guān)鍵。如果不做 continuation只靠遞歸返回值來回退那么一旦量詞內(nèi)部失敗上層就傻眼了根本不知道還有“少吞一個字符再試后續(xù)”這一條路。4.4 一個完整案例的回放拿a(b|c)*d匹配abccd來演示。這個正則的邏輯是先匹配a然后(b|c)*可以匹配零個或多個b或c最后匹配一個d。在 Rea 步驟列表里你會看到近似這樣的事件序列步驟事件節(jié)點位置說明0enterSequence0進入整個表達式1matchChara0 - 1成功消耗 a2enterRepeat (bc)*13tryGroup (bc)1 - 24tryGroup (bc)2 - 35tryGroup (bc)3 - 46tryGroup (bc)4 - 57backtrackRepeat (bc)*5 - 48matchChard4 - 5后續(xù) d 匹配成功9endSequence5整體匹配完成這里第 7 步就是關(guān)鍵。用戶從前到后看會覺得“明明文本第三個字符就是 d為什么引擎還要先吞掉再回退”因為正則里的貪婪量詞在不了解后續(xù)節(jié)點的情況下只能先盡可能多吞吞到吞不動了再交還。這個“交還”動作就是回溯也是很多正則性能問題的來源。通過 Rea 的步驟列表新手能直觀看到*在*和后面如果還跟著其他必須匹配的內(nèi)容很容易出現(xiàn)“先多吃再吐出來”的現(xiàn)象。如果后續(xù)內(nèi)容很多回溯次數(shù)會呈指數(shù)級增長。5. 實測中的坑正則語義和 Rea 表現(xiàn)不一致的五個修復(fù)5.1 斷言和普通節(jié)點對“當前匹配位置”的處理完全不同剛開始實現(xiàn)斷言時我的執(zhí)行器統(tǒng)一用“節(jié)點結(jié)束位置”來推進position。這在普通字符、分組、量詞上沒問題但碰到(?...)、(?...)就錯了。斷言不會消耗字符它從左到右檢查內(nèi)部子表達式能不能匹配能匹配就返回成功但匹配位置始終保持在斷言開始的位置。我第一次實測時發(fā)現(xiàn)a(?b)c匹配abc的時候Rea 展示的步驟是“a 之后斷言 b 成功c 直接從位置 2 開始匹配”而斷言的內(nèi)部匹配“從位置 1 到位置 2 消耗了 b”也被記錄成了普通消耗。這就導(dǎo)致用戶看到的位置推進和實際引擎行為對不上。修復(fù)方式是給斷言節(jié)點單獨立一條規(guī)則內(nèi)部執(zhí)行完成后位置必須恢復(fù)為斷言入口位置。Rea 的步驟記錄里還會額外標記“零寬斷言不消耗字符”讓用戶一眼看懂為什么斷言里的 b 沒有影響后面的 c。5.2 捕獲組編號在非捕獲組和后顧斷言里的偏移正則里有一個新手極其容易寫錯的地方(?:...)不算捕獲組編號但前瞻/后顧斷言里的(...)要算。比如(?(a))\1中\(zhòng)1指的是前瞻里的捕獲組(?:(b))\2則是非法的因為非捕獲組里即便有(b)編號也只會是 1不存在 2。Rea 的后序遍歷編號時只是在普通Group節(jié)點上遞增編號。但我一開始把斷言內(nèi)部的Group和后顧斷言內(nèi)部的子表達式也統(tǒng)一編號了這導(dǎo)致(?(a))b的捕獲組編號和原生引擎不一致。測試時我拿一個帶后顧斷言的復(fù)雜正則一對一對比連續(xù)錯了幾十步才意識到問題。后來我把“編號分配”和“執(zhí)行路徑”分開考慮只要 AST 節(jié)點是Group且類型為capturing無論它出現(xiàn)在斷言內(nèi)部還是普通分支都參與編號但執(zhí)行時斷言內(nèi)部的捕獲組快照要臨時保存斷言失敗時恢復(fù)原狀。這才能和原生語義保持一致。5.3 指數(shù)級回溯不能被當成普通步驟Rea 做到一半時我拿(a)$配一個aaaaaaaaaaaaaaaaaaaaaaaaX。這個正則自己會跑出天文數(shù)字一樣的步驟因為a可以分成很多組又可以讓每組重復(fù)多次幾乎每一種分組方式都要試一遍。如果引擎老老實實把所有步驟都記錄到數(shù)組里頁面直接卡死。這里我設(shè)置了一個非常務(wù)實的保護步驟數(shù)超過 50000 就中止執(zhí)行并且單獨標記“檢測到疑似災(zāi)難性回溯”。用戶看到的現(xiàn)象不是普通的失敗列表而是一條醒目的警告當前正則可能在更長文本上導(dǎo)致嚴重性能問題。這個警告本身就是調(diào)試結(jié)論遠比“無匹配”三個字有價值。從實現(xiàn)上講50000 步的保護閾值并不是隨便定的。我實測過普通復(fù)雜正則在一個幾十字符的文本上通常只跑幾百到幾千步50000 足以覆蓋絕大多數(shù)正常場景超過這個數(shù)大概率是正則本身出了問題。5.4 字符類中]和-的邊界沒有想象中簡單我一開始處理[]時過于想當然在字符類里遇到]就結(jié)束遇到-就當作范圍連接符。但實際測[]-]這種正則時徹底翻車。這個正則是匹配]或-兩個字符之一的。它的結(jié)構(gòu)是[開始第一個字符是]但因為]緊跟在[后面所以它不應(yīng)該被視為結(jié)束符然后-是普通字符最后一個]才是結(jié)束符。詞法層必須知道“當前是不是緊跟在[或[^之后”。只有在跟在開始位置的字符才是普通字符否則才會作為結(jié)束符處理。類似地[a-]里的-在結(jié)尾沒有構(gòu)成范圍也只是普通字符。這些邊界情況在正則文檔里只有一兩句話在 Rea 里卻是一整組測試用例。5.5 命名分組和反向引用的映射關(guān)系Rea 支持(?name...)和\kname。一開始我把名字和編號映射關(guān)系存在一個全局 Map 里解析一個正則、清空一次。結(jié)果遇到命名分組在后顧斷言里、并且正則主體里還有同名分組時Map 被覆蓋了。雖然 ECMAScript 規(guī)范不允許同一個正則里出現(xiàn)重復(fù)的命名分組但執(zhí)行到斷言內(nèi)部時捕獲組狀態(tài)是嵌套的不能簡單使用同一個對象。我最后改成每個捕獲組快照包含兩份信息一份是編號索引到字符串位置的數(shù)組另一份是名字到編號的 Map。斷言執(zhí)行時這兩份數(shù)據(jù)一起進入獨立棧幀。這樣即使斷言內(nèi)部和外部都有命名分組回退時也不會互相污染。5.6 與原生正則行為對齊的驗證方法做這個工具最大的風險是自己實現(xiàn)了一個“看起來挺對”的引擎但實際行為和瀏覽器里的new RegExp不一致。那 Rea 不但沒用還會誤導(dǎo)人。所以 Rea 里內(nèi)置了一組對照測試把用戶輸入的正則先交給原生RegExp執(zhí)行一次拿到結(jié)果同時讓 Rea 自己的引擎執(zhí)行再比較兩者的匹配區(qū)間、捕獲組內(nèi)容是否一致。不一致時Rea 會在界面右下角彈出一條“與原生行為不一致”的提示并標出第一個出現(xiàn)差異的步驟位置。這個機制幫我在開發(fā)階段發(fā)現(xiàn)了很多隱蔽問題。比如$在 ECMAScript 里默認只匹配到文本末尾但如果設(shè)置了多行模式它還會匹配換行符之前的位置比如\b的“單詞邊界”邏輯在中文文本里和英文文本里的表現(xiàn)完全不同。這些如果不做對照測試靠人眼很難發(fā)現(xiàn)。6. Rea 在實際項目里的用法日志巡檢和新人培訓(xùn)6.1 用步驟回放定位日志正則的誤判我們后端有一段時間日志告警規(guī)則老出問題。有一條規(guī)則要過濾掉所有包含SECRET關(guān)鍵字的日志行正則寫成了^(?!.*SECRET).*$這條正則單獨的匹配結(jié)果看起來沒錯但它有一個隱藏性能問題負向前瞻里有個.*SECRET如果失敗后續(xù)的.*還要繼續(xù)跑。在幾萬行的日志流上跑正則引擎的回溯開銷會被放大。用 Rea 把一條幾百字符的日志樣本放進去回放會看到步驟數(shù)到了幾千步。原因就是負向前瞻在每個字符位置都做一次“往后找 SECRET”的嘗試找不到再前進一個字符重復(fù)執(zhí)行??吹竭@個軌跡之后我們把規(guī)則改成了先用indexOf做快速排除再在數(shù)據(jù)流里跑正則日志處理速度立刻上來一個數(shù)量級。Rea 不能直接替你優(yōu)化正則但老實地把每一步選擇攤開來看優(yōu)化點會自己浮出來。6.2 在代碼評審和規(guī)則變更前做“執(zhí)行軌跡審查”以前評審正則改動只能看 diff 里的正則字符串憑經(jīng)驗判斷“這樣改應(yīng)該沒問題”。有了 Rea 之后我們形成了一個小流程任何涉及復(fù)雜正則的改動都要附帶一張 Rea 生成的步驟概覽截圖至少包括分支嘗試次數(shù)、最大回溯深度、捕獲組命中次數(shù)這幾個指標。這樣做的好處是評審人不用逐字讀正則直接看數(shù)字就能判斷這次改動是不是引入了新的性能隱患。如果一次改動讓步驟數(shù)從 200 漲到 8000哪怕功能測試全過我也會打回去重新設(shè)計因為步驟數(shù)暴漲意味著后面文本一旦變長引擎遲早卡死。6.3 拿 Rea 給新人講清楚“貪婪與懶惰”的區(qū)別我經(jīng)常拿 Rea 給組里新人演示貪婪量詞和懶惰量詞的區(qū)別。比如.*?這個經(jīng)典寫法。很多測試器只告訴新人“懶惰量詞盡量少匹配”但說不清“盡量少”到底是怎么做到的。在 Rea 里跑a.*?b匹配axxbyyb能看到引擎先讓.*?匹配零個字符然后立刻嘗試匹配b失敗于是回退讓.*?多匹配一個字符再嘗試匹配b……重復(fù)這個過程直到找到第一個能接上b的位置。這個逐步“擠牙膏”的過程在步驟列表里一目了然比任何文字說明都直觀。6.4 可擴展的方向從匹配軌跡到自動生成解釋Rea 現(xiàn)在的核心是“展示軌跡”但后續(xù)可以往“解釋軌跡”方向走。語法樹已經(jīng)結(jié)構(gòu)化了執(zhí)行步驟也已經(jīng)標注了節(jié)點語義完全可以再加一個自然語言生成模塊把“當前位置嘗試匹配數(shù)字字符失敗回溯到量詞分支”翻譯成人話。如果能生成一句話“這個正則失敗在第 12 步因為第 3 個捕獲組沒有匹配到預(yù)期格式”那才是真正的正則老師。另一個擴展方向是正則簡化。通過 AST 分析出冗余的嵌套分組、重復(fù)的字符類、不可能匹配到的分支然后給出提示。這個方向有挑戰(zhàn)但對日常維護歷史正則的人來說價值巨大。7. 最后分享一點使用經(jīng)驗Rea 開發(fā)到現(xiàn)在我自己最深的體會是正則調(diào)試工具真正的價值不在于“多炫”而在于“把不確定性變成確定性”。以前遇到一個復(fù)雜正則我習(xí)慣先猜猜錯就再改改完再測循環(huán)往復(fù)。現(xiàn)在我會先把正則丟進 Rea看一眼它的執(zhí)行軌跡很多時候問題根源就在前 50 步里。如果你也想做個類似的東西我建議從一個小得多的范圍開始先支持字符、字符類、量詞、分組這四類把可視化做好再慢慢加斷言和反向引用。不要一上來就想著完整實現(xiàn) ECMAScript 全部特性否則大概率會被邊界情況拖死。再給一個小技巧Rea 的步驟記錄里我最??吹牟皇恰捌ヅ涑晒Α钡哪切┎襟E而是backtrack步驟?;厮菰蕉嗾f明正則的“決策”質(zhì)量越差越值得優(yōu)化。一個正則如果回溯步驟超過總步驟的 30%基本就該重寫了。這句話扔給團隊里的新人比講十分鐘理論都好用。