前言

利用 pipeline 进行中间件的层层处理后,接下来 laravel 就会利用请求的 url 来寻找与其对应的路由,laravel 采用对路由注册的 uri 进行正则编译,然后利用 requesturl 进行正则匹配来寻找正确的路由。

前期准备

在上一篇文章中,我们了解了 Pipeline 的原理,我们知道它调用了 dispatchToRouter() 这个函数:

  1. protected function sendRequestThroughRouter($request)
  2. {
  3. $this->app->instance('request', $request);
  4. Facade::clearResolvedInstance('request');
  5. $this->bootstrap();
  6. return (new Pipeline($this->app))
  7. ->send($request)
  8. ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
  9. ->then($this->dispatchToRouter());
  10. }
  11. protected function dispatchToRouter()
  12. {
  13. return function ($request) {
  14. $this->app->instance('request', $request);
  15. return $this->router->dispatch($request);
  16. };
  17. }

这个函数实际上利用的是 Routerdispatch,这个函数的任务是进行路由匹配,并且调用路由绑定的控制器或者闭包函数:

  1. class Router implements RegistrarContract, BindingRegistrar
  2. {
  3. public function dispatch(Request $request)
  4. {
  5. $this->currentRequest = $request;
  6. return $this->dispatchToRoute($request);
  7. }
  8. public function dispatchToRoute(Request $request)
  9. {
  10. $route = $this->findRoute($request);
  11. $request->setRouteResolver(function () use ($route) {
  12. return $route;
  13. });
  14. $this->events->dispatch(new Events\RouteMatched($route, $request));
  15. $response = $this->runRouteWithinStack($route, $request);
  16. return $this->prepareResponse($request, $response);
  17. }
  18. }

我们这篇文章就是讲解第一句: findRoute() 路由匹配:

  1. protected function findRoute($request)
  2. {
  3. $this->current = $route = $this->routes->match($request);
  4. $this->container->instance(Route::class, $route);
  5. return $route;
  6. }

寻找路由的任务由 RouteCollection 负责,这个函数负责匹配路由,并且把 requesturl 参数绑定到路由中:

  1. class RouteCollection implements Countable, IteratorAggregate
  2. {
  3. public function match(Request $request)
  4. {
  5. $routes = $this->get($request->getMethod());
  6. $route = $this->matchAgainstRoutes($routes, $request);
  7. if (! is_null($route)) {
  8. return $route->bind($request);
  9. }
  10. $others = $this->checkForAlternateVerbs($request);
  11. if (count($others) > 0) {
  12. return $this->getRouteForMethods($request, $others);
  13. }
  14. throw new NotFoundHttpException;
  15. }
  16. protected function matchAgainstRoutes(array $routes, $request, $includingMethod = true)
  17. {
  18. return Arr::first($routes, function ($value) use ($request, $includingMethod) {
  19. return $value->matches($request, $includingMethod);
  20. });
  21. }
  22. }

路由正则匹配

如何去寻找请求 request 想要调用的路由呢? laravel 首先对路由进行正则编译,得到路由的正则匹配串,然后利用请求的 url 尝试去匹配,如果匹配成功,那么就会选定该路由:

  1. class Route
  2. {
  3. public function matches(Request $request, $includingMethod = true)
  4. {
  5. $this->compileRoute();
  6. foreach ($this->getValidators() as $validator) {
  7. if (! $includingMethod && $validator instanceof MethodValidator) {
  8. continue;
  9. }
  10. if (! $validator->matches($this, $request)) {
  11. return false;
  12. }
  13. }
  14. return true;
  15. }
  16. protected function compileRoute()
  17. {
  18. if (! $this->compiled) {
  19. $this->compiled = (new RouteCompiler($this))->compile();
  20. }
  21. return $this->compiled;
  22. }
  23. }

可以看出,路由的正则编译由 RouteCompiler 类专门负责:

  1. class RouteCompiler
  2. {
  3. public function __construct($route)
  4. {
  5. $this->route = $route;
  6. }
  7. public function compile()
  8. {
  9. $optionals = $this->getOptionalParameters();
  10. $uri = preg_replace('/\{(\w+?)\?\}/', '{$1}', $this->route->uri());
  11. return (
  12. new SymfonyRoute($uri, $optionals, $this->route->wheres, [], $this->route->domain() ?: '')
  13. )->compile();
  14. }
  15. }

可以看出, laravel 真正的正则编译是重用 symfony 框架的,但是在利用 symfony 进行正则编译之前,laravel 先对路由的 uri 进行了一些处理,以适应 symfony 的要求。

路由可选参数转换

对于 laravel 来说,可以选择某个路由 url 的参数是可选的,通常来说,这种可选参数都有默认值。 laravel 利用 ? 来表示可选参数:

  1. $router->get('{foo?}/{baz?}', function ($name = 'taylor', $age = 25) {
  2. return $name.$age;
  3. });

但是对于 symfony 来说, ? 没有任何特殊意义, symfony 利用 SymfonyRoute 类进行路由初始化,并把第二个参数作为可选参数,因此 laravel 需要把可选参数提取出来,然后赋给 SymfonyRoute 构造函数。

可选参数的提取由 getOptionalParameters 负责:

  1. protected function getOptionalParameters()
  2. {
  3. preg_match_all('/\{(\w+?)\?\}/', $this->route->uri(), $matches);
  4. return isset($matches[1]) ? array_fill_keys($matches[1], null) : [];
  5. }

preg_match_all 函数用于进行正则表达式全局匹配,成功返回整个模式匹配的次数(可能为零),如果出错返回 FALSE。

默认排序方式为 PREG_PATTERN_ORDER,结果排序为 $matches[0] 保存完整模式的所有匹配, $matches[1] 保存第一个子组的所有匹配,以此类推。

若排序方式为 PREG_SET_ORDER,结果排序为 $matches[0] 包含第一次匹配得到的所有匹配(包含子组), $matches[1] 是包含第二次匹配到的所有匹配(包含子组)的数组,以此类推。

{foo?}/{baz?} 为例,得到的 matches[0]:

  1. matches[0] = array (
  2. 0 = '{foo?}',
  3. 1 = '{baz?}',
  4. )

得到的结果 matchesmatches[1] 是被匹配上的字符串,以 {foo?}/{baz?} 为例,得到的 matches[1]:

  1. matches[1] = array (
  2. 0 = 'foo',
  3. 1 = 'baz',
  4. )

array_fill_keys 函数负责使用指定的键和值填充数组,例如上例中就可以得到:

  1. optionals = array (
  2. foo = null,
  3. baz = null,
  4. )

得到可选参数的数组 optionals 后,就要将路由的 uri? 替换掉,这也就是 preg_replace 的作用,以 {foo?}/{baz?} 为例,最后得到的替换结果为 {foo}/{baz}

Symfony 路由初始化

symfony 的路由初始化中,由很多参数:

  • path 是路由的 uri
  • defaults 是路由可选参数
  • requirements 是路由的参数正则约束
  • options 路由的选项参数,例如路由正则编译类等
  • host 是路由的主域
  • schenes 是 web 的协议,例如 http, https
  • methods 是调用的方法,例如 getpost
  • condition
  1. namespace Symfony\Component\Routing;
  2. class Route implements \Serializable
  3. {
  4. public function __construct($path, array $defaults = array(), array $requirements = array(), array $options = array(), $host = '', $schemes = array(), $methods = array(), $condition = '')
  5. {
  6. $this->setPath($path);
  7. $this->setDefaults($defaults);
  8. $this->setRequirements($requirements);
  9. $this->setOptions($options);
  10. $this->setHost($host);
  11. $this->setSchemes($schemes);
  12. $this->setMethods($methods);
  13. $this->setCondition($condition);
  14. }
  15. public function setOptions(array $options)
  16. {
  17. $this->options = array(
  18. 'compiler_class' => 'Symfony\\Component\\Routing\\RouteCompiler',
  19. );
  20. return $this->addOptions($options);
  21. }
  22. }

可以看出, laravel 初始化路由的时候,分别初始化了 pathdefaultsrequirementshost,其余都是默认值。其中 host 是路由的 domain 去除 httphttps 之后的主域。

  1. public function domain()
  2. {
  3. return isset($this->action['domain'])
  4. ? str_replace(['http://', 'https://'], '', $this->action['domain']) : null;
  5. }

路由的正则编译

路由的编译由 symfonyroute 类完成:

  1. public function compile()
  2. {
  3. if (null !== $this->compiled) {
  4. return $this->compiled;
  5. }
  6. $class = $this->getOption('compiler_class');
  7. return $this->compiled = $class::compile($this);
  8. }

compiler_class 是初始化的时候提供的类 Symfony\\Component\\Routing\\RouteCompiler.

下面是就是路由编译的主要功能实现:

compile 函数

  1. namespace Symfony\Component\Routing;
  2. class RouteCompiler implements RouteCompilerInterface
  3. {
  4. public static function compile(Route $route)
  5. {
  6. $hostVariables = array();
  7. $variables = array();
  8. $hostRegex = null;
  9. $hostTokens = array();
  10. if ('' !== $host = $route->getHost()) {
  11. $result = self::compilePattern($route, $host, true);
  12. $hostVariables = $result['variables'];
  13. $variables = $hostVariables;
  14. $hostTokens = $result['tokens'];
  15. $hostRegex = $result['regex'];
  16. }
  17. $path = $route->getPath();
  18. $result = self::compilePattern($route, $path, false);
  19. $staticPrefix = $result['staticPrefix'];
  20. $pathVariables = $result['variables'];
  21. foreach ($pathVariables as $pathParam) {
  22. if ('_fragment' === $pathParam) {
  23. throw new \InvalidArgumentException(sprintf('Route pattern "%s" cannot contain "_fragment" as a path parameter.', $route->getPath()));
  24. }
  25. }
  26. $variables = array_merge($variables, $pathVariables);
  27. $tokens = $result['tokens'];
  28. $regex = $result['regex'];
  29. return new CompiledRoute(
  30. $staticPrefix,
  31. $regex,
  32. $tokens,
  33. $pathVariables,
  34. $hostRegex,
  35. $hostTokens,
  36. $hostVariables,
  37. array_unique($variables)
  38. );
  39. }
  40. }

可以看出,路由的正则编译由两个部分构成:主域的正则编译与 uri 的正则编译。这两个部分的编译功能由函数 compilePattern 负责,这个函数会有返回三种数据结果,以 /foo/{bar} 为例:

  • variables 代表正则匹配的路由参数,如 bar
  • tokens 代表正则匹配的普通路由字符串,如 foo
  • regex 代表路由匹配的正则表达式结果
  • 有时候也会有 $staticPrefix,这个是路由 url 前没有路由参数的字符串前缀,如 /foo/.

compilePattern 函数

由于 symfony 原始的正则编译稍微复杂,本文剔除了一些处理 utf8 和异常处理的代码,特意挑选计算正则表达式的主干代码,如下:

  1. private static function compilePattern(Route $route, $pattern, $isHost)
  2. {
  3. $tokens = array();
  4. $variables = array();
  5. $matches = array();
  6. $pos = 0;
  7. $defaultSeparator = $isHost ? '.' : '/';
  8. preg_match_all('#\{\w+\}#', $pattern, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
  9. foreach ($matches as $match) {
  10. $varName = substr($match[0][0], 1, -1);
  11. $precedingText = substr($pattern, $pos, $match[0][1] - $pos);
  12. $pos = $match[0][1] + strlen($match[0][0]);
  13. if (!strlen($precedingText)) {
  14. $precedingChar = '';
  15. } else {
  16. $precedingChar = substr($precedingText, -1);
  17. }
  18. $isSeparator = '' !== $precedingChar && false !== strpos(static::SEPARATORS, $precedingChar);
  19. if ($isSeparator && $precedingText !== $precedingChar) {
  20. $tokens[] = array('text', substr($precedingText, 0, -strlen($precedingChar)));
  21. } elseif (!$isSeparator && strlen($precedingText) > 0) {
  22. $tokens[] = array('text', $precedingText);
  23. }
  24. $regexp = $route->getRequirement($varName);
  25. if (null === $regexp) {
  26. $followingPattern = (string) substr($pattern, $pos);
  27. $nextSeparator = self::findNextSeparator($followingPattern, $useUtf8);
  28. $regexp = sprintf(
  29. '[^%s%s]+',
  30. preg_quote($defaultSeparator, self::REGEX_DELIMITER),
  31. $defaultSeparator !== $nextSeparator && '' !== $nextSeparator ? preg_quote($nextSeparator, self::REGEX_DELIMITER) : ''
  32. );
  33. if (('' !== $nextSeparator && !preg_match('#^\{\w+\}#', $followingPattern)) || '' === $followingPattern) {
  34. $regexp .= '+';
  35. }
  36. }
  37. $tokens[] = array('variable', $isSeparator ? $precedingChar : '', $regexp, $varName);
  38. $variables[] = $varName;
  39. }
  40. if ($pos < strlen($pattern)) {
  41. $tokens[] = array('text', substr($pattern, $pos));
  42. }
  43. // find the first optional token
  44. $firstOptional = PHP_INT_MAX;
  45. if (!$isHost) {
  46. for ($i = count($tokens) - 1; $i >= 0; --$i) {
  47. $token = $tokens[$i];
  48. if ('variable' === $token[0] && $route->hasDefault($token[3])) {
  49. $firstOptional = $i;
  50. } else {
  51. break;
  52. }
  53. }
  54. }
  55. // compute the matching regexp
  56. $regexp = '';
  57. for ($i = 0, $nbToken = count($tokens); $i < $nbToken; ++$i) {
  58. $regexp .= self::computeRegexp($tokens, $i, $firstOptional);
  59. }
  60. $regexp = self::REGEX_DELIMITER.'^'.$regexp.'$'.self::REGEX_DELIMITER.'s'.($isHost ? 'i' : '');
  61. return array(
  62. 'staticPrefix' => 'text' === $tokens[0][0] ? $tokens[0][1] : '',
  63. 'regex' => $regexp,
  64. 'tokens' => array_reverse($tokens),
  65. 'variables' => $variables,
  66. );
  67. }

下面本文将以 prefix/{foo}/{baz}.{ext}/tail 为例,来详细讲一下路由 uri 的正则编译过程。

preg_match_all 全匹配

由于 preg_match_all 使用了 PREG_SET_ORDER,因此结果数组 matches 中每一个元素都是一次匹配的结果,本例中:

  1. $matches = array (
  2. 0 = array (
  3. 0 = array (
  4. 0 = "{foo}",
  5. 1 = 8
  6. )
  7. )
  8. 1 = array (
  9. 0 = array (
  10. 0 = "{baz}",
  11. 1 = 14
  12. )
  13. )
  14. 2 = array (
  15. 0 = array (
  16. 0 = "{ext}",
  17. 1 = 20
  18. )
  19. )
  20. )

接下来,程序会用循环来分别处理各个匹配的结果。

变量

每个匹配结果都会先计算变量: varNameprecedingTextprecedingCharisSeparator

  • varName 匹配结果会将路由参数提取出来,本例中:foobazext
  • precedingText 是两个路由参数之间的字符串,本例中:prefix//.
  • precedingChar 是每个路由参数之前的字符,也就是 precedingText 的最后一个字符,本例中://.
  • isSeparator 判断 precedingChar 是否是 url 的间隔符,本例中:truetruetrue

tokens-text

precedingText 记录进 tokens 数组,key 为 text
第一次循环,tokens:

  1. tokens = array (
  2. 0 = text,
  3. 1 = prefix,
  4. )

第二次循环与第三次循环由于 precedingText == precedingChar,所以并不会记录。

构建 regexp

若在路由定义的过程中利用 where 属性或者 pattern 为路由的参数设置正则约束,那么此时就会将约束规则赋给 regexp,否则就会启用构建 regexp 的过程:

  1. $followingPattern = (string) substr($pattern, $pos);
  2. $nextSeparator = self::findNextSeparator($followingPattern, $useUtf8);
  3. $regexp = sprintf(
  4. '[^%s%s]+',
  5. preg_quote($defaultSeparator, self::REGEX_DELIMITER),
  6. $defaultSeparator !== $nextSeparator && '' !== $nextSeparator ? preg_quote($nextSeparator, self::REGEX_DELIMITER) : ''
  7. );
  8. if (('' !== $nextSeparator && !preg_match('#^\{\w+\}#', $followingPattern)) || '' === $followingPattern) {
  9. $regexp .= '+';
  10. }

构建 regexp 有两个部分,

  • 寻找 nextSeparator
  1. private static function findNextSeparator($pattern, $useUtf8)
  2. {
  3. if ('' == $pattern) {
  4. return '';
  5. }
  6. if ('' === $pattern = preg_replace('#\{\w+\}#', '', $pattern)) {
  7. return '';
  8. }
  9. return false !== strpos(static::SEPARATORS, $pattern[0]) ? $pattern[0] : '';
  10. }

这个函数的意义在于为路由的 uri 的路由参数寻找非默认间隔符,例如,路由可以这样设置 uri

  1. /{baz}.{ext}/

默认的间隔符就是 /,如果不设置非默认间隔符的时候,那么 regexp = [^/]mobile.html 这样的请求就会被 {baz} 这个参数全部匹配到,{ext} 就没有任何参数来对应。设置了非默认间隔符后 regexp = [^/.], baz 就会匹配 mobileext 就会匹配 html

  • 侵占型正则表达式
  1. if (('' !== $nextSeparator && !preg_match('#^\{\w+\}#', $followingPattern)) || '' === $followingPattern) {
  2. $regexp .= '+';
  3. }

为了减少贪婪型正则表达式的回溯导致的性能浪费,当后续字符串已经结束或者不存在 /{x}{y} 这样情况的时候,程序将贪婪型正则表达式改为侵占型正则表达式。有关正则表达式的模式请查看:正则表达式之 贪婪与非贪婪模式详解(概述)

tokens-variable

获取路由参数和正则表达式之后,就要更新 tokens,分别将 isSeparator, regexp, varName 更新到结果数组中。

prefix/{foo}/{baz}.{ext}/tail 为例,$tokens 在各个循环时值为:

  1. $tokens = array (
  2. 0 = array (
  3. 0 = text’,
  4. 1 = '/prefix'
  5. )
  6. 1 = array (
  7. 0 = variable’,
  8. 1 = '/',
  9. 0 = ‘[^/]++’,
  10. 1 = 'foo'
  11. )//第一次循环结束
  12. 2 = array (
  13. 0 = variable’,
  14. 1 = '/',
  15. 0 = ‘[^/\.]++’,
  16. 1 = 'baz'
  17. )//第二次循环结束
  18. 3 = array (
  19. 0 = variable’,
  20. 1 = '.',
  21. 0 = ‘[^/]++’,
  22. 1 = 'ext'
  23. )//循环结束
  24. 4 = array (
  25. 0 = text’,
  26. 1 = '/tail'
  27. )// 循环外
  28. )

默认路由参数

接下来就要计算首个默认路由参数在整个路由 url 的位置,以便在生成正则表达式中使用:

  1. $firstOptional = PHP_INT_MAX;
  2. if (!$isHost) {
  3. for ($i = count($tokens) - 1; $i >= 0; --$i) {
  4. $token = $tokens[$i];
  5. if ('variable' === $token[0] && $route->hasDefault($token[3])) {
  6. $firstOptional = $i;
  7. } else {
  8. break;
  9. }
  10. }
  11. }

计算正则表达式

所有的 tokens 数组都构建完毕,接下来就需要利用这个数组来构建正则表达式了。

  1. $regexp = '';
  2. for ($i = 0, $nbToken = count($tokens); $i < $nbToken; ++$i) {
  3. $regexp .= self::computeRegexp($tokens, $i, $firstOptional);
  4. }
  5. $regexp = self::REGEX_DELIMITER.'^'.$regexp.'$'.self::REGEX_DELIMITER.'s'.($isHost ? 'i' : '');
  1. private static function computeRegexp(array $tokens, $index, $firstOptional)
  2. {
  3. $token = $tokens[$index];
  4. if ('text' === $token[0]) {
  5. // Text tokens
  6. return preg_quote($token[1], self::REGEX_DELIMITER);
  7. } else {
  8. // Variable tokens
  9. if (0 === $index && 0 === $firstOptional) {
  10. // When the only token is an optional variable token, the separator is required
  11. return sprintf('%s(?P<%s>%s)?', preg_quote($token[1], self::REGEX_DELIMITER), $token[3], $token[2]);
  12. } else {
  13. $regexp = sprintf('%s(?P<%s>%s)', preg_quote($token[1], self::REGEX_DELIMITER), $token[3], $token[2]);
  14. if ($index >= $firstOptional) {
  15. $regexp = "(?:$regexp";
  16. $nbTokens = count($tokens);
  17. if ($nbTokens - 1 == $index) {
  18. // Close the optional subpatterns
  19. $regexp .= str_repeat(')?', $nbTokens - $firstOptional - (0 === $firstOptional ? 1 : 0));
  20. }
  21. }
  22. return $regexp;
  23. }
  24. }
  25. }

computeRegexp 函数的大致流程为:

  • tokens 当前元素是 text ,不是路由参数的时候,直接赋值原字符串即可
  • url 中路由参数都是可选参数,且没有任何 text,那么第一个可选参数使用捕获分组
  • 若当前路由参数是可选参数的时候,需要在正则表达式中不断叠加非捕获分组 (?,再最后设置为可选分组 )?,例如 (?:/(?P<baz>[^/]++)(?:/(?P<ext>[^/]++))?)?
  • 若当前路由参数不是可选参数的时候,正则表达式就是固定模式,例如: /(?P<foo>[^/]++)

利用 computeRegexp 函数拼接正则表达式后,还要在最两侧分隔符、开始符 ^,结束符 $、单行修正符 s,如果是主域的正则表达式,还要添加不区分大小写的修正符 i

prefix/{foo}/{baz}.{ext}/tail 为例,每次生成的正则表达式如下:

  1. /prefix
  2. /prefix/(?P<foo>[^/]++)
  3. /prefix/(?P<foo>[^/]++)/(?P<baz>[^/\.]++)
  4. /prefix/(?P<foo>[^/]++)/(?P<baz>[^/\.]++)\.(?P<ext>[^/]++)
  5. /prefix/(?P<foo>[^/]++)/(?P<baz>[^/\.]++)\.(?P<ext>[^/]++)/tail
  6. #^/prefix/(?P<foo>[^/]++)/(?P<baz>[^/\.]++)\.(?P<ext>[^/]++)/tail$#s

{foo?}/{baz?}.{ext?} 为例,每次生成的正则表达式如下:

  1. /(?P<foo>[^/]++)?
  2. /(?P<foo>[^/]++)?(?:/(?P<baz>[^/\.]++)
  3. /(?P<foo>[^/]++)?(?:/(?P<baz>[^/\.]++)(?:\.(?P<ext>[^/]++))?)?
  4. #^/(?P<foo>[^/]++)?(?:/(?P<baz>[^/\.]++)(?:\.(?P<ext>[^/]++))?)?$#s