Laravel源码笔记(二)路由

2020-04-14 10:58:07 浏览数 (3)

路由是web服务不可或缺的一部分,一个好的web框架必须具备一整套灵活且丰富的路由系统。Laravel自然也不例外,通过配置文件中一两行代码就可以实现一个具有完整的参数、属性及约束的路由,甚至可以免去写专门的controller。如此强大的功能是如何实现的呢?下面仍然从laravel框架的启动过程出发,探究一下源码中是如何一步步实现路由服务的。

一、总体设计思路

总体上,laravel的路由系统分为两个服务:RouteServiceProviderRoutingServiceProvider。前者提供路由的配置解析与加载服务,主要由 IlluminateRoutingRouter 、IlluminateRoutingRoute 、Illuminate RoutingRouteRegistrar这三个类在IOC容器初始化以及内核启动的过程中实现;后者提供请求的url匹配与参数绑定服务,主要由 IlluminateRoutingRouteCollection、 IlluminateRoutingRoute、 IlluminateRoutingRouter、SymfonyRoutingRouteCompiler和IlluminateRoutingRouteParameterBinder这几个类在内核处理请求的过程中实现。整个路由服务的框架大致如下:

Laravel路由服务架构Laravel路由服务架构

在两个服务周期中都扮演者重要角色的Router路由器,是在laravel初始化的过程中由RoutingServiceProvider注册到IOC容器中的,注册形式为单例模式。laravel为何要把整个系统的路由服务分为RouteService和RoutingService两个部分呢?我的理解是为了便于更好的区分其作用或者说生命周期。我们在实际开发过程中,往往根据需求不同会隔离用户的使用场景,典型的例子就是CMS程序的管理端和用户端。这里可以做个类比,RouteService是路由服务的管理端,而RoutingService即是路由服务的用户端。在设计层面就把两者很好的区分开来,有助于我们在进一步扩展路由服务功能或使用路由服务进行业务开发的过程中,明确组件分工,写出高内聚的代码。

二、路由加载与规则解析

        定义一条最基本的路由规则的语法很简单,调用Facade门面Route类的某个静态方法即可(本质上是调用了已经注册在服务容器中的路由器router实例api,不清楚Facade基本原理的同学可以看这里)。该静态方法对应于Reques请求的请求方式(GET/POST/HEAD/PUT/PUT/DELETE/OPTIONS),传入的参数为请求url及对应动作(一般是controller@method形式,也可是个闭包函数); 也可以在请求方式前添加一些路由的属性如domainprefixmiddleware等,称为前置属性;还可以在请求方式之后添加一些路由约束where或者属性name等。当然也可以在url中传入请求参数。如下是一些路由定义的例子:

代码语言:javascript复制
//仅包含基础动作的路由
Route::get('foo','controller@method');    
//添加前置属性的路由
Route::middleware('web')->namespace($this->namespace)->post('/foo/{id}', function ($id) { // });
//添加前置属性和后置约束的完整路由
Route::domain('route.domain.name')->get('foo','controller@method')->where('one','(. )');

        此外,可以用路由组的形式定义多条路由,路由组内共享路由属性,甚至还可嵌套新的路由组。实际上,所有 laravel 路由都定义在位于 routes 目录下的路由文件中,这些文件内的路由被laravel视为一个大的路由组在RouteService启动的过程中通过Route门面加载出来(所以路由配置文件不需要声明对Route门面的引用):

代码语言:javascript复制
class RouteServiceProvider
{
    protected $namespace = 'AppHttpControllers';
    protected function mapWebRoutes()
    {
        Route::middleware('web')
             ->namespace($this->namespace)        
             ->group(base_path('routes/web.php'));
    }
}

        那么RouteServiceProvider是在何时启动又如何加载路由缓存的呢?这里我们先放一下,来看看一条路由规则是如何被Router路由器解析的。

2.1 路由解析

       所谓路由解析,就是将路由定义中的一系列属性(包括约束和动作)等按一定规则解析并缓存起来,以待后用。这里的解析主要由前面提到的三个类负责,即IlluminateRoutingRouter 、IlluminateRoutingRoute 、Illuminate RoutingRouteRegistrar。RouteRegistrar 主要负责位于group 、method 这些函数之前的属性注册,Route主要负责位于group 、method这些函数之后的属性注册,而Router则是解析过程中一个中转,将domain、prefix这些熟悉的注册处理转交给RouteRegistrar,并在自身处理method之后返回生成的路由实例Route,将where、name等约束的处理交给Route进行。路由解析的过程如下:

  • Router通过魔术方法__call()把最前面如domain()等自身中没有的方法传递给RouteRegistrar
代码语言:javascript复制
class Router implements RegistrarContract, BindingRegistrar
{
    public function __call($method, $parameters)
    {
        if (static::hasMacro($method)) {
            return $this->macroCall($method, $parameters);
        }
        return (new RouteRegistrar($this))->attribute($method, $parameters[0]);
    }
}
  • RouteRegistrar将allowedAttributes中的属性存入attributes,再将passthru中的请求方法回调给Router
代码语言:javascript复制
class RouteRegistrar
{
    protected $attributes = [];
    protected $passthru = ['get', 'post', 'put', 'patch', 'delete', 'options', 'any',];
    protected $allowedAttributes = ['as', 'domain', 'middleware', 'name', 'namespace', 'prefix',];

    public function __call($method, $parameters)
    {
        if (in_array($method, $this->passthru)) {
            return $this->registerRoute($method, ...$parameters);
        }
        if (in_array($method, $this->allowedAttributes)) {
            return $this->attribute($method, $parameters[0]);
        }
        throw new BadMethodCallException("Method [{$method}] does not exist.");
    }

    public function attribute($key, $value)
    {
        if (! in_array($key, $this->allowedAttributes)) {
            throw new InvalidArgumentException("Attribute [{$key}] does not exist.");
        }
        $this->attributes[array_get($this->aliases, $key, $key)] = $value;
        return $this;
    }

    protected function registerRoute($method, $uri, $action = null)
    {
        if (! is_array($action)) {
            $action = array_merge($this->attributes, $action ? ['uses' => $action] : []);
        }
        return $this->router->{$method}($uri, $this->compileAction($action));
    }
  •  Router在自身的get()post()等接口中创建Route实例并添加到自身的routes数组中。在创建路由实例过程中,Router进行的工作主要包括给路由的控制器添加namespace,给路由的uri添加group的prefix前缀,更新路由的属性信息,为路由添加router-pattern 正则约束,等等。
代码语言:javascript复制
class Router implements RegistrarContract, BindingRegistrar
{
    protected $routes;

    public function get($uri, $action = null)
    {
        return $this->addRoute(['GET', 'HEAD'], $uri, $action);
    }

    protected function addRoute($methods, $uri, $action)
    {
        return $this->routes->add($this->createRoute($methods, $uri, $action));
    }

    protected function createRoute($methods, $uri, $action)
    {
        if ($this->actionReferencesController($action)) {
            $action = $this->convertToControllerAction($action);
        }
        $route = $this->newRoute(
            $methods, $this->prefix($uri), $action
        );
        if ($this->hasGroupStack()) {
            $this->mergeGroupAttributesIntoRoute($route);
        }
        $this->addWhereClausesToRoute($route);
        return $route;
    }
}
  • 注意,这里存储路由的routes实际上是一个特殊的RouteCollection集合,里面存放路由的方式有[domain][method]的二级索引和[domain.method]单级索引两种。 此外,RouteCollection还会更新自身的命名查询和动作查询数组,方便程序在需要时通过多种方式灵活的查询路由。
代码语言:javascript复制
class RouteCollection implements Countable, IteratorAggregate
{
    protected $routes = [];
    protected $allRoutes = [];
    protected $nameList = [];
    protected $actionList = [];

    public function add(Route $route)
    {
        $this->addToCollections($route);
        $this->addLookups($route);
        return $route;
    }

    protected function addToCollections($route)
    {
        $domainAndUri = $route->domain().$route->uri();
        foreach ($route->methods() as $method) {
            $this->routes[$method][$domainAndUri] = $route;
        }
        $this->allRoutes[$method.$domainAndUri] = $route;
    }

    protected function addLookups($route)
    {
        $action = $route->getAction();
        if (isset($action['as'])) {
            $this->nameList[$action['as']] = $route;
        }
        if (isset($action['controller'])) {
            $this->addToActionList($action, $route);
        }
    }

    protected function addToActionList($action, $route)
    {
        $this->actionList[trim($action['controller'], '\')] = $route;
    }
  • 前一步可以看到get()post()等Router接口最后返回的是其创建的路由实例,这样后续的属性配置过程又转交给了Route。Route在实例化过程中通过RouteAction::parse()接口将路由属性参数中的回调解析出来并以‘user’=>‘controller@method’的键值对形式放回,方便后面的匹配调用。并提供pattern()、where()等接口进一步修改路由属性参数。
代码语言:javascript复制
class Route
{
    public $action;

    public function __construct($methods, $uri, $action)
    {
        $this->uri = $uri;
        $this->methods = (array) $methods;
        $this->action = $this->parseAction($action);
        if (in_array('GET', $this->methods) && ! in_array('HEAD', $this->methods)) {
            $this->methods[] = 'HEAD';
        }
        if (isset($this->action['prefix'])) {
            $this->prefix($this->action['prefix']);
        }
    }

    protected function parseAction($action)
    {
        return RouteAction::parse($this->uri, $action);
    }
}

2.2 路由加载        

        现在让我们再回过头来看看,RouteServiceProvider是在什么时候启动map()进行路由解析的呢?当系统内核Kernel初始化结束后,就会调用 handle 函数,这个函数用于 laravel 各个功能服务的注册启动,还有request 的分发:

代码语言:javascript复制
class Kernel implements KernelContract
{
    protected function sendRequestThroughRouter($request)
    {
        $this->app->instance('request', $request);
        Facade::clearResolvedInstance('request');
        $this->bootstrap();
        return (new Pipeline($this->app))
                    ->send($request)
                    ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
                    ->then($this->dispatchToRouter());
    }

    public function bootstrap()
    {
        if (! $this->app->hasBeenBootstrapped()) {
            $this->app->bootstrapWith($this->bootstrappers());
        }
    }
}

        这里boostrap()函数会调用一系列系统的启动器Bootstrapper进行系统基础服务的启动,这些基础服务就配置在config/service.php中,当然其中也包括RouteService。那么路由的解析需要每次启动服务的时候都进行吗?答案当然是否定的。因为对于开发者来说,route文件的配置其实是很少改动的,因此laravel在这里使用了静态文件缓存将解析好的路由规则缓存起来,缓存路径为/bootstrap/cache/routes.php。这样当每次需要加载路由的时候,先在缓存路径下查询解析好的静态路由文件,如果找到的话就直接加载;如果没有找到静态文件,就进行routes/web.php文件的动态解析并保存。

代码语言:javascript复制
class RouteServiceProvider extends ServiceProvider
{
    public function boot()
    {
        $this->setRootControllerNamespace();
        if ($this->app->routesAreCached()) {
            $this->loadCachedRoutes();
        } else {
            $this->loadRoutes();
            $this->app->booted(function () {
                $this->app['router']->getRoutes()->refreshNameLookups();
                $this->app['router']->getRoutes()->refreshActionLookups();
            });
        }
    }

    protected function loadCachedRoutes()
    {
        $this->app->booted(function () {
            require $this->app->getCachedRoutesPath();
        });
    }

    protected function loadRoutes()
    {
        if (method_exists($this, 'map')) {
            $this->app->call([$this, 'map']);
        }
    }

三、路由匹配与参数绑定

        接下来就是路由与请求的匹配问题了。既然是请求的匹配,那么运行肯定也是在内核处理请求的过程中:

代码语言:javascript复制
class Kernel implements KernelContract
{
    protected function dispatchToRouter()
    {
        return function ($request) {
            $this->app->instance('request', $request);
            return $this->router->dispatch($request);
        };
    }
}

这里实际上是在Router的dispatch()方法中进行的,过程大致为:

  • 路由器先找到匹配的路由并与请求绑定
  • 向系统发出一个RouteMatched事件;
  • 让请求走一遍路由规则声明的中间件;
  • 调用路由绑定的控制器或者闭包函数返回Response。
代码语言:javascript复制
class Router implements RegistrarContract, BindingRegistrar
{
    public function dispatch(Request $request)
    {
        $this->currentRequest = $request;
        return $this->dispatchToRoute($request);
    }

    public function dispatchToRoute(Request $request)
    {
        $route = $this->findRoute($request);
        $request->setRouteResolver(function () use ($route) {
            return $route;
        });
        $this->events->dispatch(new EventsRouteMatched($route, $request));
        $response = $this->runRouteWithinStack($route, $request);
        return $this->prepareResponse($request, $response);
    }

    protected function findRoute($request) 
    { 
       $this->current = $route = $this->routes->match($request);
       $this->container->instance(Route::class, $route);
       return $route; 
    }

3.1 路由匹配

        可以看到在findRoute()函数中寻找路由的任务主要由RouteCollection负责,这个集合提供一个match()函数负责匹配路由。在这个match()函数中,laravel先查找当前请求方式下存储的所有路由(前面按请求方式作为索引存储的数组还记得不?这里派上用场了),然后遍历这个集合,调用每个route的matches()接口,找到第一个返回true(即匹配)的路由就返回,并且把url中的请求参数保存到路由中。如果未在指定方法下找到route匹配,则遍历其它方法下的路由集合进行匹配,并将所有匹配的路由的对应methods记录,然后判断请求方式是否为OPTIONS:

  • 如果是,返回一个响应OPTIONS方法的的new Route,其response带有类似 ['allow' => 允许方式 ] 响应头提示所有匹配请求的方法
  • 如果不是,则返回MethodNotAllowedHttpException
代码语言:javascript复制
class RouteCollection implements Countable, IteratorAggregate
{
    public function match(Request $request)
    {
        $routes = $this->get($request->getMethod());
        $route = $this->matchAgainstRoutes($routes, $request);
        if (! is_null($route)) {
            return $route->bind($request);
        }
        $others = $this->checkForAlternateVerbs($request);
        if (count($others) > 0) {
            return $this->getRouteForMethods($request, $others);
        }
        throw new NotFoundHttpException;
    }

    protected function matchAgainstRoutes(array $routes, $request, $includingMethod = true)
    {
        return Arr::first($routes, function ($value) use ($request, $includingMethod) {
            return $value->matches($request, $includingMethod);
        });
    }

    protected function checkForAlternateVerbs($request)
    {
        $methods = array_diff(Router::$verbs, [$request->getMethod()]);
        $others = [];
        foreach ($methods as $method) {
            if (! is_null($this->matchAgainstRoutes($this->get($method), $request, false))) {
                $others[] = $method;
            }
        }
        return $others;
    }

    protected function getRouteForMethods($request, array $methods)
    {
        if ($request->method() == 'OPTIONS') {
            return (new Route('OPTIONS', $request->path(), function () use ($methods) {
                return new Response('', 200, ['Allow' => implode(',', $methods)]);
            }))->bind($request);
        }
        $this->methodNotAllowed($methods);
    }

        那么一个Route实例具体是如何判断一个请求request实例与自己匹配的呢?接下来就是我们今天的主角——正则表达式大显身手的时候了! laravel 首先对路由进行正则编译,得到路由的正则匹配串regex,然后利用请求的参数url尝试去匹配,如果匹配成功,那么就会选定该路由:

代码语言:javascript复制
class 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;
    }

    public static function getValidators()
    {
        if (isset(static::$validators)) {
            return static::$validators;
        }
        return static::$validators = [
            new UriValidator, new MethodValidator,
            new SchemeValidator, new HostValidator,
        ];
    }

        每个路由最终都分别调用了UrlValidator、MethodValidator、SchemaValidaor、HostValidator四个验证器对请求的参数进行了校验,除了请求方式外其余三个都必须匹配才算匹配成功。所谓校验,其实就是直接从request对象中获取相应参数进行判断。Laravel对于url和host的校验如下:

代码语言:javascript复制
class UriValidator implements ValidatorInterface
{
    public function matches(Route $route, Request $request)
    {
        $path = $request->path() == '/' ? '/' : '/'.$request->path();
        return preg_match($route->getCompiled()->getRegex(), rawurldecode($path));
    }
}

class HostValidator implements ValidatorInterface
{
    public function matches(Route $route, Request $request)
    {
        if (is_null($route->getCompiled()->getHostRegex())) {
            return true;
        }
        return preg_match($route->getCompiled()->getHostRegex(), $request->getHost());
    }
}

         就是一个正则匹配preg_match()搞定!所以问题关键在于进行正则匹配的regex是如何获得的。这里laravel发挥了不重复造轮子的精神,重用了Symfony库的RouteCompiler组件进行正则编译。

代码语言:javascript复制
class Route
{
    protected function compileRoute()
    {
        if (! $this->compiled) {
            $this->compiled = (new RouteCompiler($this))->compile();
        }
        return $this->compiled;
    }

    public function getCompiled()
    {
        return $this->compiled;
    }
}

class RouteCompiler
{
    public function compile()
    {
        $optionals = $this->getOptionalParameters();
        $uri = preg_replace('/{(w ?)?}/', '{$1}', $this->route->uri());
        return (
            new SymfonyComponentRoutingRoute($uri, $optionals, $this->route->wheres, [], $this->route->domain() ?: '')
        )->compile();
    }
}

       需要注意的是,在调用symfony的路由编译之前laravel自身的RouteCompiler先进行了一些特殊的正则处理,这是因为路由的url规则中可能还有形如

  • /{user?}/{name?}/

这一类的可选参数,但是对于 symfony 来说,'? '没有任何特殊意义,因此 laravel 需要把表示可选参数提取出来,另外传递给 SymfonyRoute 构造函数。在getOptionalParameters()里面的preg_match_all('/{(w ?)?}/', $this->route->uri(), $matches)这句话作用是把可选参数名值提取出来,并通过array_fill_keys()处理得到如下的命名数组:

  • optionals = array ( 'user' = null, 'name' = null )

而compile()中的preg_replace('/{(w ?)?}/', '{$1}', $this->route->uri())这句话的作用就是把可选参数中的‘?'符合给去掉,得到正常的带参数url

  • /{user?}/{name?}/

3.2 正则编译

        接下来就是symfony的RouteCompiler类的编译过程了。

代码语言:javascript复制
class RouteCompiler implements RouteCompilerInterface
{
    public static function compile(Route $route)
    {
        $hostVariables = array();
        $variables = array();
        $hostRegex = null;
        $hostTokens = array();
        if ('' !== $host = $route->getHost()) {
            $result = self::compilePattern($route, $host, true);
            $hostVariables = $result['variables'];
            $variables = $hostVariables;
            $hostTokens = $result['tokens'];
            $hostRegex = $result['regex'];
        }
        $path = $route->getPath();
        $result = self::compilePattern($route, $path, false);
        $staticPrefix = $result['staticPrefix'];
        $pathVariables = $result['variables'];
        foreach ($pathVariables as $pathParam) {
            if ('_fragment' === $pathParam) {
                throw new InvalidArgumentException(sprintf('Route pattern "%s" cannot contain "_fragment" as a path parameter.', $route->getPath()));
            }
        }
        $variables = array_merge($variables, $pathVariables);
        $tokens = $result['tokens'];
        $regex = $result['regex'];
        return new CompiledRoute(
            $staticPrefix,
            $regex,
            $tokens,
            $pathVariables,
            $hostRegex,
            $hostTokens,
            $hostVariables,
            array_unique($variables)
        );
    }
}

         路由的正则编译由两个部分构成:主域的正则编译与 uri 的正则编译。这两个部分的编译功能由函数compilePattern 负责Host和path的匹配结果最终合并放入CompiledRoute对象中。所以整个正则编译的核心就在copilePattern()中:

代码语言:javascript复制
class RouteCompiler implements RouteCompilerInterface
{
    const REGEX_DELIMITER = '#';
    const SEPARATORS = '/,;.:-_~ *=@|';
    const VARIABLE_MAXIMUM_LENGTH = 32;

    private static function compilePattern(Route $route, $pattern, $isHost)
    {
        $tokens = array();
        $variables = array();
        $matches = array();
        $pos = 0;
        $defaultSeparator = $isHost ? '.' : '/';
        preg_match_all('#{w }#', $pattern, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
        foreach ($matches as $match) {
            $varName = substr($match[0][0], 1, -1);
            $precedingText = substr($pattern, $pos, $match[0][1] - $pos);
            $pos = $match[0][1]   strlen($match[0][0]);
            if (!strlen($precedingText)) {
                $precedingChar = '';
            } elseif ($useUtf8) {
                preg_match('/.$/u', $precedingText, $precedingChar);
                $precedingChar = $precedingChar[0];
            } else {
                $precedingChar = substr($precedingText, -1);
            }
            $isSeparator = '' !== $precedingChar && false !== strpos(static::SEPARATORS, $precedingChar);
            if ($isSeparator && $precedingText !== $precedingChar) {
                $tokens[] = array('text', substr($precedingText, 0, -strlen($precedingChar)));
            } elseif (!$isSeparator && strlen($precedingText) > 0) {
                $tokens[] = array('text', $precedingText);
            }
            $regexp = $route->getRequirement($varName);
            if (null === $regexp) {
                $followingPattern = (string) substr($pattern, $pos);
                $nextSeparator = self::findNextSeparator($followingPattern, $useUtf8);
                $regexp = sprintf(
                    '[^%s%s] ',
                    preg_quote($defaultSeparator, self::REGEX_DELIMITER),
                    $defaultSeparator !== $nextSeparator && '' !== $nextSeparator ? preg_quote($nextSeparator, self::REGEX_DELIMITER) : ''
                );
                if (('' !== $nextSeparator && !preg_match('#^{w }#', $followingPattern)) || '' === $followingPattern) {
                    $regexp .= ' ';
                }
            } 
            $tokens[] = array('variable', $isSeparator ? $precedingChar : '', $regexp, $varName);
            $variables[] = $varName;
        }
        if ($pos < strlen($pattern)) {
            $tokens[] = array('text', substr($pattern, $pos));
        }
        $firstOptional = PHP_INT_MAX;
        if (!$isHost) {
            for ($i = count($tokens) - 1; $i >= 0; --$i) {
                $token = $tokens[$i];
                if ('variable' === $token[0] && $route->hasDefault($token[3])) {
                    $firstOptional = $i;
                } else {
                    break;
                }
            }
        }
        $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' : '');
        if ($needsUtf8) {
            $regexp .= 'u';
            for ($i = 0, $nbToken = count($tokens); $i < $nbToken;   $i) {
                if ('variable' === $tokens[$i][0]) {
                    $tokens[$i][] = true;
                }
            }
        }
        return array(
            'staticPrefix' => self::determineStaticPrefix($route, $tokens),
            'regex' => $regexp,
            'tokens' => array_reverse($tokens),
            'variables' => $variables,
        );
    }

    private static function determineStaticPrefix(Route $route, array $tokens)
    {
        if ('text' !== $tokens[0][0]) {
            return ($route->hasDefault($tokens[0][3]) || '/' === $tokens[0][1]) ? '' : $tokens[0][1];
        }
        $prefix = $tokens[0][1];
        if (isset($tokens[1][1]) && '/' !== $tokens[1][1] && false === $route->hasDefault($tokens[1][3])) {
            $prefix .= $tokens[1][1];
        }
        return $prefix;
    }

    private static function findNextSeparator($pattern, $useUtf8)
    {
        if ('' == $pattern) {
            return '';
        }
        if ('' === $pattern = preg_replace('#{w }#', '', $pattern)) {
            return '';
        }
        if ($useUtf8) {
            preg_match('/^./u', $pattern, $pattern);
        }
        return false !== strpos(static::SEPARATORS, $pattern[0]) ? $pattern[0] : '';
    }
}

         这里虽然代码很多,但核心还是一条正则匹配:preg_match_all('#{w }#', $pattern, $matches, PREG_OFF SET_CAPTURE | PREG_SET_ORDER)。仔细研究一下这条语句,发现采用了PREG_SET_ORDER模式得到的是一个子匹配结果的顺序索引数组(便于接下来的遍历)。同时设置PREG_OFF_SET_CAPTURE标志以便于在匹配中定位字符串位置($pos = $match[0][1] strlen($match[0][0]))。此外,这里采用正则表达式采用‘#’作为分割符是为了和uri中的‘/’区分开来。清楚了这句话的作用,就可以根据上一步compile()函数中的思路,大致梳理一下compilePattern()的编译过程了:

  • 首先,循环遍历匹配结果({w }),计算得到变量名($varName)、变量名前的字符串($precedingText)、变量名前第一个字符($precedingChar)以及该字符是否为url的分割符($isSeparator);
  • 对于不以分隔符结尾或者不是单个分隔符的$precedingText,直接作为文本text属性存入tokens数组中;
  • 对于$varName获取其对应的路由约束wheres作为正则表达式。如果没有的话,则利用当前默认分隔符$defaultSeperator(‘.’或‘/’)以及后继表达式中的下一个分隔符$nextSeperator(这里不一定是默认分隔符,如‘/index.htm’中的‘.’)来构建正则表达式——‘[^%s%s] ’。这里‘ ’表示非贪婪模式匹配,是为了减少贪婪型正则表达式的回溯导致的性能浪费。关于什么是非贪婪模式匹配,感兴趣的同学可以看这里
  • 再将precedingChar、regex、 varName以一定次序作为variable属性存入tokens中,一次子匹配的处理就完成了;
  • 遍历匹配结果结束后,若此时获取变量位置pos还未到url末尾,说明最后pos至末尾也是一段静态文本,直接作为text属性存入tokens;
  • 接着,遍历刚才得到的tokens数组,得到第一个可选参数的数组下标(hasDefault()中实际查找的是illuminationRouteCompiler传入的可选参数名数组$optionals);
  • 最后,再次循环遍历$tokens数组,将获取的若干token变量拼接为pattern最终的路由正则表达式$regex。

        此外,代码中还有一些关于字符编码的特殊处理,这里就不再赘述了。这里以路由‘prefix/{foo}/{baz?}.{ext?}/tail’为例,得到的最终tokens如下:

代码语言:javascript复制
array(
    'staticPrefix' => ‘prefix',
    'regex' => '#^/prefix/(?P[^/]  )?(?:/(?P[^/.]  )(?:.(?P[^/]  ))?)?/tai l$#s', //下一步再解释
    'tokens' => array(
        array('text', '/tail/'),
        array('variable', '.', '[^./]  ', 'ext'),
        array('variable', '/', '[^/.] ', 'bar'),
        array('variable', '/', '[^/] ', 'foo'),
        array('text', 'prefix')
    ),
    'variables' => array('foo', 'bar', 'ext')
);

        最后看看computeRegexp()中tokens是如何被拼接的:

代码语言:javascript复制
class RouteCompiler implements RouteCompilerInterface
{
    private static function computeRegexp(array $tokens, $index, $firstOptional)
    {
        $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;
            }
        }
}

        这里有两个关键点。首先,拼接出的regex采用了子命名组语法,即(?P<参数>表达式)的形式。这里是为了后面与请求url进行参数绑定的时候方便取出变量名和变量值。其次,这里用到了上一步获取的第一个可选参数位置,因为在子命名组语法中规定:

  • 若当前路由参数不是可选参数的时候,正则表达式就是固定模式,例如:/(?P<foo>[^/] )
  • 若当前路由参数是可选参数的时候,需要在正则表达式中不断叠加非捕获分组(?,再最后设置{tokens数组长度-第一个可选参数出现位置}个可选分组)?)?...,例如 (?:/(?P<baz>[^/] )(?:/(? P<ext>[^/] ))?)?
  • 若当前路由参数是可选参数且为第一个参数时,可使用普通的捕获分组,其后的token再全部按非捕获分组处理

通过第一个位置和遍历位置的计算,可以拼接出符合上述规则的正则表达式。       

        最后,还要添加开始符^,结束符$、最两侧分隔符#、单行修正符s,如果是主域的则表达式,还要添加不区分大 小写的修正符i。这里仍然以路由‘prefix/{foo}/{baz?}.{ext?}/tail’为例,其拼接过程如下:

代码语言:javascript复制
/prefix /prefix/(?P[^/]  )
/prefix/(?P[^/]  )?(?:/(?P[^/.]  )
/prefix/(?P[^/]  )?(?:/(?P[^/.]  )(?:.(?P[^/]  ))?)?
/prefix/(?P[^/]  )?(?:/(?P[^/.]  )(?:.(?P[^/]  ))?)?/tail
#^/prefix/(?P[^/]  )?(?:/(?P[^/.]  )(?:.(?P[^/]  ))?)?/tai l$#s

3.3 参数绑定

        得到一个路由的正则表达式regex之后,laravel就可以后续处理请求的时候使用它了:一是用来匹配url,二是用来获取url参数。前者我们已经在前面的步骤讲过,而后者的核心原理也大同小异。这里laravel采用RouteParameterBinder负责路由的参数绑定:

代码语言:javascript复制
class RouteParameterBinder
{
    protected $route;

    public function parameters($request)
    {
        if (! is_null($this->route->compiled->getHostRegex())) {
            $parameters = $this->bindHostParameters(
                $request, $parameters
            );
        }
        return $this->replaceDefaults($parameters);
    }

    protected function bindPathParameters($request)
    {
        $path = '/'.ltrim($request->decodedPath(), '/');
        preg_match($this->route->compiled->getRegex(), $path, $matches);
        return $this->matchToKeys(array_slice($matches, 1));
    }

    protected function bindHostParameters($request, $parameters)
    {
        preg_match($this->route->compiled->getHostRegex(), $request->getHost(), $matches);
        return array_merge($this->matchToKeys(array_slice($matches, 1)), $parameters);
    }

    protected function matchToKeys(array $matches)
    {
        if (empty($parameterNames = $this->route->parameterNames())) {
            return [];
        }
        $parameters = array_intersect_key($matches, array_flip($parameterNames));
        return array_filter($parameters, function ($value) {
            return is_string($value) && strlen($value) > 0;
        });
    }

    protected function replaceDefaults(array $parameters)
    {
        foreach ($parameters as $key => $value) {
            $parameters[$key] = isset($value) ? $value : Arr::get($this->route->defaults, $key);
        }
        foreach ($this->route->defaults as $key => $value) {
            if (! isset($parameters[$key])) {
                $parameters[$key] = $value;
            }
        }
        return $parameters;
    }
}

绑定过程如下:

  • 取出路由编译好的regex和hostregex,分别与请求的path和host进行正则匹配——preg_match($this->route->compiled->getRegex(), $path, $matches),这里是带非捕获子命名组的非贪婪模式匹配,因此将返回一个一系列以参数名为索引的子匹配组。
  • 将匹配得到的matches[1]子命名数组与路由本身的参数名数组parameterNames进行array_intersect_key()与array_filter(),得到形式为 array(’路由参数名1’=>’url参数值1’, ’路由参数名2’=>’url参数值2’, ...) 的数组
  • parameterNames也通过正则匹配获得:preg_match_all('/{(.*?)}/', $this->domain().$this->uri, $matches)。这里注意去除匹配的可选参数名中的‘?’;
  • Route将url绑定或获得的路由参数组存入自己的parameters中。

四、小结

       写到这里,大家应该都比较清楚laravel路由系统的工作原理了吧(可能对自己的讲解水平有地蜜汁自信了><)。概括一下本次的收获:路由系统的核心,其实就是url这个特殊的字符串的处理,而其中的关键问题是如何同时处理字符串的匹配和参数提取。如果今后遇到类似的问题,我们应该自然地想到程序员手中的这把尚方宝剑(其实是把双刃剑)——正则表达式,去斩杀字符串这个我们永远的共同敌人!

0 人点赞