翻譯 使用正則的快速路由庫

首先先區分一下概念:
路由是指一個過程,就是利用定義好的一些規則,讓不一樣的URI可以調用不一樣的處理器(一個匿名函數或者一個類中的方法)這樣一個過程。html

日常不少框架所說的定義一個路由就是註冊一個這樣的規則到系統中去。git

slim的路由是使用了FastRoute這個庫,做者寫了一篇帖子,介紹了它寫這個庫的緣由(原文連接):github

使用正則的快速路由庫

前段時間,我在Pux路由庫遇到了一些問題。它號稱比現用的路由庫快幾個數量級的,由於爲了達到這個這個目的,這個庫是經過PHP的C擴展實現的。
然而,粗略地看了Pux的源碼以後,我強烈懷疑這個庫優化了路由處理的錯誤部分,而我不借助於C擴展卻能夠很輕鬆地到更好的性能。當我看了Pux的基準測試以後,發現只測試了幾個很是簡單實際的單路由例子,我就更加確定了個人懷疑。
爲了進一步調查這個問題,我寫了一個小型路由庫:FastRoute,這個庫實現了接下來描述的分發處理。爲了給出預先觀點,我貼出了和Pux庫對比的小型基準測試結果:正則表達式

1 placeholder  | Pux (no ext) | Pux (ext) | FastRoute
-----------------------------------------------------
First route    | 0.17 s       | 0.13 s    | 0.14 s
Last route     | 2.51 s       | 1.20 s    | 0.49 s
Unknown route  | 2.34 s       | 1.10 s    | 0.34 s

9 placeholders | Pux (no ext) | Pux (ext) | FastRoute
-----------------------------------------------------
First route    | 0.22 s       | 0.19 s    | 0.20 s
Last route     | 2.65 s       | 1.78 s    | 0.59 s
Unknown route  | 2.50 s       | 1.49 s    | 0.40 s

這個基準測試使用了一百個路由,而後找出其中最快的路由(最佳例子),最慢的路由(最差的例子)和一個總共的未知平均路由。測試經過設置一個變量分爲兩部分,一部分使用了1個佔位符,另外一部分使用了9個佔位符。整個測試很明顯進行了循環了幾百次。express

關於路由的問題

爲了確保咱們在說同一個事物,讓咱們定義一下"路由"是什麼。在大多數實際的形式中,它是指跟如下形式相似的利用一套路由定義:數組

$r->addRoute('GET', '/user/{name}/{id:\d+}', 'handler0');
$r->addRoute('GET', '/user/{id:\d+}', 'handler1');
$r->addRoute('GET', '/user/{name}', 'handler2');

而後調度處理一個基於它們的URI的過程:數據結構

$d->dispatch('GET', '/user/nikic/42');
// => provides 'handler0' and ['name' => 'nikic', 'id' => '42']

把這個過程提高到一個更抽象的層次,咱們將會爲路由定義提供HTTP方法和任何特定的格式。在本文中,我會考慮的惟一同樣事情是路由的調度階段——路由如何被解析或調度器生成的數據不會被覆蓋。
那麼,路由處理的最耗時的部分是哪裏呢?在一個混亂不堪的,過分被設計的系統中,它多是實例化數十個對象和調用數百個方法的開銷。Pux庫在減小這方面的開銷作的很好。然而,在一個比較原始層次的系統中,依次通過一系列數十個或者數百個路由表達式,以後再經過提供的URI和它們進行匹配這個過程是最耗時的部分。讓這個過程更快就是本文的主題。
合併的正則表達式:
優化這類問題的最基本方法是避免一個個的去匹配那些正則表達式,相反地,把它們結合在一塊兒,變成一個大的正則表達式,這樣的話,你只須要進行一次匹配就能夠了。就拿最後一個例子的路由做說明,合併的正則表達式是這樣的:框架

Individual regexes:

    ~^/user/([^/]+)/(\d+)$~
    ~^/user/(\d+)$~
    ~^/user/([^/]+)$~

Combined regex:

    ~^(?:
        /user/([^/]+)/(\d+)
      | /user/(\d+)
      | /user/([^/]+)
    )$~x

這個轉化很簡單:基本上你只要將那些正則表達式一個個地用OR 鏈接在一塊兒就能夠了。當匹配這個合併的正則表達式,如何找出具體哪一個路由規則被匹配了呢?爲了找出來,讓咱們來看一看preg_match這個函數對一個樣本的輸出:ide

preg_match($regex, '/user/nikic', $matches);
=> [
    "/user/nikic",   # full match
    "", "",          # groups from first route (empty)
    "",              # groups from second route (empty)
    "nikic",         # groups from third route (used!)
]

那麼,在$matches數組中找到第一個非空入口就是訣竅了(固然,沒算上第一個徹底匹配)。函數


這裏我貼上代碼,方便你們測試:

$regex = "~^(?:/user/([^/]+)/(\d+)|/user/(\d+)|/user/([^/]+))$~x";
preg_match($regex, "/user/nikic", $matches);

var_dump($matches);

(?:regexp) 匹配 pattern 但不獲取匹配結果,也就是說這是一個非獲取匹配,不進行存儲供之後使用。這在使用 "或" 字符 (|) 來組合一個模式的各個部分是頗有用。例如, 'industr(?:y|ies) 就是一個比 'industry|industries' 更簡略的表達式。介紹


爲了使用這個結果,你將須要一個額外的數據結構來映射$matches的索引到匹配的路由規則(或者,一些關聯那個路由規則的信息)

[
    1 => ['handler0', ['name', 'id']],
    3 => ['handler1', ['id']],
    4 => ['handler2', ['name']],
]

這裏是一個實現整個處理流程的例子:

public function dispatch($uri) {
    if (!preg_match($this->regex, $uri, $matches)) {
        return [self::NOT_FOUND];
    }

    // find first non-empty match (skipping full match)
    for ($i = 1; '' === $matches[$i]; ++$i);

    list($handler, $varNames) = $this->routeData[$i];

    $vars = [];
    foreach ($varNames as $varName) {
        $vars[$varName] = $matches[$i++];
    }
    return [self::FOUND, $handler, $vars];
}

在找到第一個非空的索引,關聯的數據就能夠被查找到了。經過遍歷$matches數組並配對值和變量名,佔位符的變量就能夠被填充了。
那麼這個方法執行起來效率如何呢?這裏給出了和Pux的比較結果(使用C擴展):

1 placeholder  | Pux (ext) | GPB-NC
-----------------------------------
First route    | 0.13 s    | 0.20 s
Last route     | 1.20 s    | 0.70 s
Unknown route  | 1.10 s    | 0.16 s

9 placeholders | Pux (ext) | GPB-NC
-----------------------------------
First route    | 0.19 s    | 0.41 s
Last route     | 1.78 s    | 4.09 s
Unknown route  | 1.49 s    | 0.30 s

GPB-NC表示「Group position based, non-chunked」調度。如你所見的那樣,在單個佔位符的測試例子這個方法提供了不錯的性能。固然它不能戰勝在最快路由上正確匹配的C擴展實現,可是在最糟糕匹配上,它表現地快了一點,若是一個都沒匹配的話,更快了。

相關文章
相關標籤/搜索