老王帶你看原始碼|Laravel 的路由匹配的過程都幹了些什麼?

寫PHP的老王發表於2019-09-27

Laravel 的路由配置有很多,可以設定域名,設定請求協議,設定請求方式,請求路徑。那麼,Laravel在獲取到請求之後,去匹配路由都做了些什麼呢?本文以Laravel5.8原始碼講解,帶你一步步看原始碼。

文章首發於公眾號【寫PHP的老王】,更多內容可以關注公眾號

Laravel 路由解析例項

Laravel 預設路由的驗證器有四個,UriValidator,MethodValidator,SchemeValidator,HostValidator分別處理uri的匹配,請求方法的匹配,協議的匹配,域名的匹配。

舉幾個例子:

  • HostValidator驗證域名是符合domain的配置
Route::domain('{account}.blog.dev')->function({
    return 'Hello';
});
  • UriValidator驗證請求的uri是否符合路由配置,MethodValidator驗證當前請求方法是否是get方法

    Route::get('/home/posts/{id?}',function($id=null){
    return 'get post '.$id;
    })
  • SchemeValidator驗證訪問協議,主要用於驗證安全路由。只能驗證是http,或者https

Route::get('foo', array('https', function(){}));

只有當四個驗證器都通過才認為當前請求匹配路由成功。

那這四個驗證器都是怎麼驗證的呢?

Laravel 路由匹配驗證器

請求方法驗證

class MethodValidator implements ValidatorInterface
{
    public function matches(Route $route, Request $request)
    {
        return in_array($request->getMethod(), $route->methods());
    }
    SchemeValidator
}

請求方式的驗證最簡單,就是驗證當前請求方式是否是當前路由允許的請求方式。而路由的允許的請求方式在路由例項化的時候就建立好了。

請求協議驗證

class SchemeValidator implements ValidatorInterface
{
    public function matches(Route $route, Request $request)
    {
        if ($route->httpOnly()) {
            return ! $request->secure();
        } elseif ($route->secure()) {
            return $request->secure();
        }

        return true;
    }
}

通過獲取當前請求的Request,判斷是否是https,與當前路由的配置進行比較

域名驗證以及uri的驗證

這兩種驗證本質上都是一樣的。通過對路由的配置進行編譯分解,獲取uri獲取域名匹配的正規表示式,然後通過正規表示式進行匹配。如果匹配成功,則驗證通過。

這裡以UriValidator為例說明

class UriValidator implements ValidatorInterface
{
    /**
     * Validate a given rule against a route and request.
     *
     * @param  \Illuminate\Routing\Route  $route
     * @param  \Illuminate\Http\Request  $request
     * @return bool
     */
    public function matches(Route $route, Request $request)
    {
        $path = $request->path() === '/' ? '/' : '/'.$request->path();

        return preg_match($route->getCompiled()->getRegex(), rawurldecode($path));
    }
}

這裡的關鍵是getCompiled返回的這個物件。getCompiled返回的是Symfony\Component\Routing\CompiledRoute這個物件包含了當前路由編譯之後的uri匹配正規表示式,域名匹配正規表示式等資訊。

CompiledRoute是誰返回的?

在每個路由獲取驗證器進行驗證之前,都會執行compileRoute方法建立CompiledRoute物件。

//Illuminate\Routing\Route
public function matches(Request $request, $includingMethod = true)
{
    $this->compileRoute();
    foreach ($this->getValidators() as $validator) {
        if (! $includingMethod && $validator instanceof MethodValidator) {
            continue;
        }
        if (! $validator->matches($this, $request)) {
            return false;
        }
    }
    return true;
}
protected function compileRoute()
{
    if (! $this->compiled) {
        $this->compiled = (new RouteCompiler($this))->compile();
    }
    return $this->compiled;
}

Illuminate\Routing\RouteCompilercompile方法如下:

//use Symfony\Component\Routing\Route as SymfonyRoute;
public function compile()
{
    $optionals = $this->getOptionalParameters();
    $uri = preg_replace('/\{(\w+?)\?\}/', '{$1}', $this->route->uri());
    return (
        new SymfonyRoute($uri, $optionals, $this->route->wheres, ['utf8' => true], $this->route->getDomain() ?: '')
    )->compile();
}
//Symfony\Component\Routing\Route 程式碼
//compiler_class Symfony\\Component\\Routing\\RouteCompiler
public function compile()
{
    if (null !== $this->compiled) {
        return $this->compiled;
    }
    $class = $this->getOption('compiler_class');
    return $this->compiled = $class::compile($this);
}

可以看出,最終是由Symfony\Component\Routing\RouteCompilercompile返回最終的compileRoute物件。

路由編譯都幹了些什麼?

//Symfony\Component\Routing\RouteCompiler 原始碼
public static function compile(Route $route)
{
    ...
    if ('' !== $host = $route->getHost()) {
        $result = self::compilePattern($route, $host, true);

        $hostVariables = $result['variables'];
        $variables = $hostVariables;

        $hostTokens = $result['tokens'];
        $hostRegex = $result['regex'];
    }
    ...
}

RouteCompiler::compile輸入引數是當前需要匹配的路由。首先判斷路由是否有域名配置,如果有域名配置則對域名配置進行正規表示式編譯,獲取域名的匹配正規表示式,已經匹配表示式中的變數資訊。

//Symfony\Component\Routing\RouteCompiler 原始碼
public static function compile(Route $route)
{
    ...
    $path = $route->getPath();
    $result = self::compilePattern($route, $path, false);
    $staticPrefix = $result['staticPrefix'];
    $pathVariables = $result['variables'];
    ...
    $variables = array_merge($variables, $pathVariables);
    $tokens = $result['tokens'];
    $regex = $result['regex'];
    ...
}

然後獲取路由的uri配置,對配置進行解析獲取配置中的匹配正規表示式,變數陣列,字首資訊。

域名,路徑匹配規則解析之後,根據解析後的資料建立一個CompiledRoute物件,並返回

因此,在路由編譯過程中,主要是根據路由配置,解析出匹配的正規表示式,變數陣列,字首資訊。並將這些解析之後的資料建立的CompiledRoute物件返回給呼叫方。這樣,呼叫方就能夠直接通過CompiledRoute的屬性直接獲取到路由解析之後的匹配規則。

匹配規則怎麼解析的?

//Symfony\Component\Routing\RouteCompiler 原始碼
private static function compilePattern(Route $route, $pattern, $isHost)
{
    ...
    preg_match_all('#\{(!)?(\w+)\}#', $pattern, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
    foreach ($matches as $match) {
        ...
        if ($isSeparator && $precedingText !== $precedingChar) {
            $tokens[] = ['text', substr($precedingText, 0, -\strlen($precedingChar))];
        } elseif (!$isSeparator && \strlen($precedingText) > 0) {
            $tokens[] = ['text', $precedingText];
        }
        ...
        if ($important) {
            $token = ['variable', $isSeparator ? $precedingChar : '', $regexp, $varName, false, true];
        } else {
            $token = ['variable', $isSeparator ? $precedingChar : '', $regexp, $varName];
        }
        ...
    }
    ...
}

首先通過正規表示式匹配是否由變數配置,例如Route::get('/posts/{id}'),Route::domain('{account}.blog.dev')。如果有變數,則對配置規則進行擷取,將配置規則中不包含變數的部分$tokens[] = ['text', $precedingText]; ,對所有變數$token = ['variable', $isSeparator ? $precedingChar : '', $regexp, $varName, false, true]儲存解析後的資訊。

//Symfony\Component\Routing\RouteCompiler 原始碼
private static function compilePattern(Route $route, $pattern, $isHost)
{
    ...
    if ($pos < \strlen($pattern)) {
        $tokens[] = ['text', substr($pattern, $pos)];
    }
    // find the first optional token
    $firstOptional = PHP_INT_MAX;
    if (!$isHost) {
        for ($i = \count($tokens) - 1; $i >= 0; --$i) {
            $token = $tokens[$i];
            // variable is optional when it is not important and has a default value
            if ('variable' === $token[0] && !($token[5] ?? false) && $route->hasDefault($token[3])) {
                $firstOptional = $i;
            } else {
                break;
            }
        }
    }
    ...

當配置資訊中不包含任何變數,則進入這段程式碼中第一個if判斷裡面,將匹配規則儲存在token陣列中。

區分當前解析是對域名的匹配還是對uri的匹配,如果對uri的匹配,則找出變數中第一個可選引數的位置。

這一步是把路由配置轉換成可匹配的規則token。方便後續通過每個token生成匹配正規表示式。

//Symfony\Component\Routing\RouteCompiler 原始碼
private static function computeRegexp(array $tokens, int $index, int $firstOptional): string
{
    $token = $tokens[$index];
    if ('text' === $token[0]) {
        return preg_quote($token[1], self::REGEX_DELIMITER);
    } else {
        if (0 === $index && 0 === $firstOptional) {
            return sprintf('%s(?P<%s>%s)?', preg_quote($token[1], self::REGEX_DELIMITER), $token[3], $token[2]);
        } else {
            $regexp = sprintf('%s(?P<%s>%s)', preg_quote($token[1], self::REGEX_DELIMITER), $token[3], $token[2]);
            if ($index >= $firstOptional) {
                $regexp = "(?:$regexp";
                $nbTokens = \count($tokens);
                if ($nbTokens - 1 == $index) {
                    // Close the optional subpatterns
                    $regexp .= str_repeat(')?', $nbTokens - $firstOptional - (0 === $firstOptional ? 1 : 0));
                }
            }
            return $regexp;
        }
    }
}

通過解析獲取的token陣列,儲存了所有的匹配規則陣列。如果當前匹配規則token是text型別,則在對字串進行轉義處理,返回作為匹配的正規表示式。

如果是變數,則根據是否是可選的(上一步已經找到了第一個可選引數的位置),在正規表示式中新增可選標識。

//Symfony\Component\Routing\RouteCompiler 原始碼
private static function compilePattern(Route $route, $pattern, $isHost)
{
    ...
    $regexp = '';
    for ($i = 0, $nbToken = \count($tokens); $i < $nbToken; ++$i) {
        $regexp .= self::computeRegexp($tokens, $i, $firstOptional);
    }
    $regexp = self::REGEX_DELIMITER.'^'.$regexp.'$'.self::REGEX_DELIMITER.'sD'.($isHost ? 'i' : '');
    ...
    return [
        'staticPrefix' => self::determineStaticPrefix($route, $tokens),
        'regex' => $regexp,
        'tokens' => array_reverse($tokens),
        'variables' => $variables,
    ];

根據每個token獲取每個匹配規則的正規表示式,將所有的正規表示式拼接成一個正規表示式,並加上正規表示式前字尾。這樣就獲取了一個完整可匹配的正規表示式。

然後將字首,匹配正規表示式,匹配規則陣列tokens,變數陣列返回給呼叫方。供呼叫方生成CompiledRoute物件。

總結

文章比較長,主要是根據呼叫鏈一步步分析每個方法的作用,介紹Laravel如何實現路由匹配的動能,希望對大家有幫助。最後附上Laravel路由匹配過程呼叫流程圖

Laravel 的路由匹配都幹了些什麼

寫PHP的老王

相關文章