講給前端的正則表達式(4):避免災難性回溯

做者:wanago

翻譯:瘋狂的技術宅javascript

原文:https://wanago.io/2019/09/23/...
未經容許嚴禁轉載前端

前文:java

正則表達式能夠解決許多問題,但也有多是使咱們頭痛的根源。 最近 Cloudfare 的一次停機事故就是因爲正則表達式致使全球大量機器上的 CPU 峯值飆升至100%。在本文中,咱們將會學習須要注意的狀況,例如災難性的回溯。爲了幫助咱們理解問題,還分析了貪婪懶惰量詞以及爲何 lookahead 可能會有所幫助。程序員

有些人遇到問題時會想:「我知道,我將使用正則表達式。」如今他們有兩個問題了。

Jamie Zawinski面試

image.png

深刻研究量詞

正則表達式引擎很是複雜。儘管咱們能夠用 regexp 創造奇蹟,但須要考慮可能會遇到的一些問題。因此須要更深刻地研究如何去執行某些正則表達式。正則表達式

貪婪量詞

在本系列文章的前幾部分中,咱們使用了 + 之類的量詞。它告訴引擎至少匹配一個。express

const expression = /e+/;
 
expression.test('Hello!'); // true
expression.test('Heeeeello!'); // true
expression.test('Hllo!'); // false

讓咱們仔細看看第二個例子: /e+/.test('Heeeeello!')。咱們可能想知道用這個表達式匹配多少個字母。segmentfault

因爲默認狀況下量詞是貪婪的,所以咱們會匹配儘量多的字母。能夠用 match函數來確認這一點。瀏覽器

'Heeeeello!'.match(/e+/);
// ["eeeee", index: 1, input: "Heeeeello!", groups: undefined]

另外一個不錯的例子是處理一些 HTML 標籤:安全

const string = 'Beware of <strong>greedy</strong> quantifiers!';
 
/<.+>/.test(string); // true

最初的猜想多是它與 <strong> 之類的東西匹配。不徹底是!

string.match(/<.+>/);
// ["<strong>greedy</strong>" (...) ]

如你所見,貪婪的量詞與最長的字符串匹配!

惰性量詞

在本系列中,咱們還將介紹 ? 量詞。這意味着匹配零或一次。

function wereFilesFound(string) {
  return /[1-9][0-9]* files? found/.test(string);
}
 
wereFilesFound('0 files found');  // false
wereFilesFound('No files found'); // false
wereFilesFound('1 file found');   // true
wereFilesFound('2 files found');  // true

有趣的是,經過將其添加到貪婪的量詞中,咱們告訴它重複儘量少的次數,所以使其變得懶惰

const string = 'Beware of <strong>greedy</strong> quantifiers!';
string.match(/<.+?>/);
 
// ["<strong>", (...) ]

災難性的回溯

要了解量詞如何影響正則表達式的行爲,咱們須要仔細研究被稱爲回溯的過程。

先讓咱們看一下這段看似清白的代碼!

const expression = /^([0-9]+)*$/;

乍一看,它能夠成功檢測到一系列數字。讓咱們分解一下它的工做方式。

expression.test('123456789!');
  • 首先,引擎處理 [0-9]+。它是貪婪的,因此它會首先嚐試匹配儘量多的數字。首先匹配的是 123456789
  • 而後引擎嘗試應用 * 量詞,但沒有其餘數字了

    • 由於用的是 $ 符號,因此咱們但願字符串以數字結尾—— ! 符號不會發生這種狀況
  • 如今因爲回溯,在 [[0-9]+] 中匹配的字符數量減小了。它匹配 12345678
  • 而後使用 * 量詞,所以 ([0-9]+)* 產生兩個子字符串:123456789

    • 因爲上述子字符串均不在字符串末尾,所以與 $ 匹配失敗
  • 引擎經過減小 [0-9]+ 匹配的位數來保持回溯

上述過程會產生多種不一樣的組合。

咱們的字符串以 ! 符號結尾。所以,正則表達式引擎嘗試回溯,直到在提供的字符串的末尾找到數字爲止。

[12345678][9]!
 
[1234567][89]!
 
[1234567][8][9]!
 
[123456][789]!
 
[123456][7][89]!
 
[123456][78][9]!

通過了大量的計算,可是沒有找到匹配的結果。這可能會致使性能大幅降低。若是使用很是長的字符串,瀏覽器可能會掛起,從而破壞用戶體驗。

經過將貪婪量詞更改成惰性量詞,有時能夠提升性能,可是這個特定的例子並不屬於這種狀況。

先行斷言(Lookahead)

要解決上述問題,最直接方法是徹底重寫正則表達式。上面的解決方案並不老是很容易,並且有可能會形成很大的痛苦。解決上述問題的方法是使用先行斷言(lookahead)

在最基本的形式中,它聲明 x 僅會在其後跟隨 y 時才匹配。

const expression = /x(?=y)/;
 
expression.test('x'); // false
expression.test('xy'); // true

咱們將其稱爲正向先行斷言。僅當 x 後面跟隨 y 時,用負向先行斷言匹配 x

const expression = /x(?!y)/;
 
expression.test('x'); // true
expression.test('xy'); // false

先行斷言很酷的地方在於它是原子性的。在知足條件後,引擎將不會回溯並嘗試其餘排列。

回溯引用(Backreference)

咱們在這裏須要涉及到的的另外一個問題是回溯引用。

const expression = /(a|b)(c|d)\1\2/;

上面的 \1 表示第一個捕獲組的內容,而 \2 表示第二個捕獲組的內容。

expression.test('acac'); // true
expression.test('adad'); // true
expression.test('bcbc'); // true
expression.test('bdbd'); // true
 
expression.test('abcd'); // false

咱們能夠結合使用先行斷言和回溯引用來處理回溯問題:

const expression = /^(?=([0-9]+))\1*$/

這看起來很複雜。讓咱們對它進行分解。

  • 表達式 (?=([0-9]+)) 尋找最長的數字字符串,由於 + 是貪婪的
  • 引擎不會回溯尋找不一樣的組合
  • 表達式 (?=([0-9]+))\1 的回溯引用指出,先行查找的內容須要出如今字符串中

因爲上述全部緣由,咱們能夠安全地測試很長的字符串,而不會產生性能問題。

const expression = /^(?=([0-9]+))\1*$/;
expression.test('5342193376141170558801674478263705216832 D:'); //false
expression.test('7558004377221767420519835955607645787848'); // true

總結

在本文中,咱們更深刻地研究了量詞。能夠將它們分爲貪婪懶惰兩種量詞,而且它們可能會對性能產生影響。咱們還討論了量詞可能致使的另外一個問題:災難性回溯。咱們還學習瞭如何使用 先行斷言(lookahead) 來改善性能,而不只僅是去重寫表達式。有了這些知識,咱們能夠編寫更好的代碼,避免出現Cloudflare這樣的問題。


本文首發微信公衆號:前端先鋒

歡迎掃描二維碼關注公衆號,天天都給你推送新鮮的前端技術文章

歡迎掃描二維碼關注公衆號,天天都給你推送新鮮的前端技術文章


歡迎繼續閱讀本專欄其它高贊文章:


相關文章
相關標籤/搜索