Laravel核心程式碼學習 -- 路由

kevinyan發表於2018-05-23

路由

路由是外界訪問Laravel應用程式的通路或者說路由定義了Laravel的應用程式向外界提供服務的具體方式:通過指定的URI、HTTP請求方法以及路由引數(可選)才能正確訪問到路由定義的處理程式。無論URI對應的處理程式是一個簡單的閉包還是說是控制器方法沒有對應的路由外界都訪問不到他們,今天我們就來看看Laravel是如何來設計和實現路由的。

我們在路由檔案裡通常是向下面這樣來定義路由的:

Route::get('/user', 'UsersController@index');
複製程式碼

通過上面的路由我們可以知道,客戶端通過以HTTP GET方式來請求 URI "/user"時,Laravel會把請求最終派發給UsersController類的index方法來進行處理,然後在index方法中返回響應給客戶端。

上面註冊路由時用到的Route類在Laravel裡叫門面(Facade),它提供了一種簡單的方式來訪問繫結到服務容器裡的服務router,Facade的設計理念和實現方式我打算以後單開博文來寫,在這裡我們只要知道呼叫的Route這個門面的靜態方法都對應服務容器裡router這個服務的方法,所以上面那條路由你也可以看成是這樣來註冊的:

app()->make('router')->get('user', 'UsersController@index');
複製程式碼

router這個服務是在例項化應用程式Application時在構造方法裡通過註冊RoutingServiceProvider時繫結到服務容器裡的:

//bootstrap/app.php
$app = new Illuminate\Foundation\Application(
    realpath(__DIR__.'/../')
);

//Application: 構造方法
public function __construct($basePath = null)
{
    if ($basePath) {
        $this->setBasePath($basePath);
    }

    $this->registerBaseBindings();

    $this->registerBaseServiceProviders();

    $this->registerCoreContainerAliases();
}

//Application: 註冊基礎的服務提供器
protected function registerBaseServiceProviders()
{
    $this->register(new EventServiceProvider($this));

    $this->register(new LogServiceProvider($this));

    $this->register(new RoutingServiceProvider($this));
}

//\Illuminate\Routing\RoutingServiceProvider: 繫結router到服務容器
protected function registerRouter()
{
    $this->app->singleton('router', function ($app) {
        return new Router($app['events'], $app);
    });
}
複製程式碼

通過上面的程式碼我們知道了Route呼叫的靜態方法都對應於\Illuminate\Routing\Router類裡的方法,Router這個類裡包含了與路由的註冊、定址、排程相關的方法。

下面我們從路由的註冊、載入、定址這幾個階段來看一下laravel裡是如何實現這些的。

路由載入

註冊路由前需要先載入路由檔案,路由檔案的載入是在App\Providers\RouteServiceProvider這個伺服器提供者的boot方法里載入的:

class RouteServiceProvider extends ServiceProvider
{
    public function boot()
    {
        parent::boot();
    }

    public function map()
    {
        $this->mapApiRoutes();

        $this->mapWebRoutes();
    }

    protected function mapWebRoutes()
    {
        Route::middleware('web')
             ->namespace($this->namespace)
             ->group(base_path('routes/web.php'));
    }

    protected function mapApiRoutes()
    {
        Route::prefix('api')
             ->middleware('api')
             ->namespace($this->namespace)
             ->group(base_path('routes/api.php'));
    }
}
複製程式碼
namespace Illuminate\Foundation\Support\Providers;

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']);
        }
    }
}

class Application extends Container implements ApplicationContract, HttpKernelInterface
{
    public function routesAreCached()
    {
        return $this['files']->exists($this->getCachedRoutesPath());
    }

    public function getCachedRoutesPath()
    {
        return $this->bootstrapPath().'/cache/routes.php';
    }
}
複製程式碼

laravel 首先去尋找路由的快取檔案,沒有快取檔案再去進行載入路由。快取檔案一般在 bootstrap/cache/routes.php 檔案中。 方法loadRoutes會呼叫map方法來載入路由檔案裡的路由,map這個函式在App\Providers\RouteServiceProvider類中,這個類繼承自Illuminate\Foundation\Support\Providers\RouteServiceProvider。通過map方法我們能看到laravel將路由分為兩個大組:api、web。這兩個部分的路由分別寫在兩個檔案中:routes/web.php、routes/api.php。

Laravel5.5裡是把路由分別放在了幾個檔案裡,之前的版本是在app/Http/routes.php檔案裡。放在多個檔案裡能更方便地管理API路由和與WEB路由

路由註冊

我們通常都是用Route這個Facade呼叫靜態方法get, post, head, options, put, patch, delete......等來註冊路由,上面我們也說了這些靜態方法其實是呼叫了Router類裡的方法:

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

public function post($uri, $action = null)
{
    return $this->addRoute('POST', $uri, $action);
}
....
複製程式碼

可以看到路由的註冊統一都是由router類的addRoute方法來處理的:

//註冊路由到RouteCollection
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)) {
    	//controller@action型別的路由在這裡要進行轉換
        $action = $this->convertToControllerAction($action);
    }

    $route = $this->newRoute(
        $methods, $this->prefix($uri), $action
    );

    if ($this->hasGroupStack()) {
        $this->mergeGroupAttributesIntoRoute($route);
    }

    $this->addWhereClausesToRoute($route);

    return $route;
}

protected function convertToControllerAction($action)
{
    if (is_string($action)) {
        $action = ['uses' => $action];
    }

    if (! empty($this->groupStack)) {        
        $action['uses'] = $this->prependGroupNamespace($action['uses']);
    }
    
    $action['controller'] = $action['uses'];

    return $action;
}
複製程式碼

註冊路由時傳遞給addRoute的第三個引數action可以閉包、字串或者陣列,陣列就是類似['uses' => 'Controller@action', 'middleware' => '...']這種形式的。如果action是Controller@action型別的路由將被轉換為action陣列, convertToControllerAction執行完後action的內容為:

[
	'uses' => 'App\Http\Controllers\SomeController@someAction',
	'controller' => 'App\Http\Controllers\SomeController@someAction'
]
複製程式碼

可以看到把名稱空間補充到了控制器的名稱前組成了完整的控制器類名,action陣列構建完成接下里就是建立路由了,建立路由即用指定的HTTP請求方法、URI字串和action陣列來建立\Illuminate\Routing\Route類的例項:

protected function newRoute($methods, $uri, $action)
{
    return (new Route($methods, $uri, $action))
                ->setRouter($this)
                ->setContainer($this->container);
}
複製程式碼

路由建立完成後將Route新增到RouteCollection中去:

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

router的$routes屬性就是一個RouteCollection物件,新增路由到RouteCollection物件時會更新RouteCollection物件的routes、allRoutes、nameList和actionList屬性

class RouteCollection implements Countable, IteratorAggregate
{
    public function add(Route $route)
    {
        $this->addToCollections($route);

        $this->addLookups($route);

        return $route;
    }
    
    protected function addToCollections($route)
    {
        $domainAndUri = $route->getDomain().$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'])) {
        	//如果時命名路由,將route物件對映到以路由名為key的陣列值中方便查詢
            $this->nameList[$action['as']] = $route;
        }

        if (isset($action['controller'])) {
            $this->addToActionList($action, $route);
        }
    }

}
複製程式碼

RouteCollection的四個屬性

routes中存放了HTTP請求方法與路由物件的對映:

[
	'GET' => [
		$routeUri1 => $routeObj1
		...
	]
	...
]
複製程式碼

allRoutes屬性裡存放的內容時將routes屬性裡的二維陣列變成一維陣列後的內容:

[
	'GET' . $routeUri1 => $routeObj1
	'GET' . $routeUri2 => $routeObj2
	...
]
複製程式碼

nameList是路由名稱與路由物件的一個對映表

[
	$routeName1 => $routeObj1
	...
]
複製程式碼

actionList是路由控制器方法字串與路由物件的對映表

[
	'App\Http\Controllers\ControllerOne@ActionOne' => $routeObj1
]
複製程式碼

這樣就算註冊好路由了。

路由定址

在後面中介軟體的文章裡我們看到HTTP請求是在經過Pipeline通道上的中介軟體的前置操作後到達目的地:

//Illuminate\Foundation\Http\Kernel
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());
    }
    
    protected function dispatchToRouter()
    {
        return function ($request) {
            $this->app->instance('request', $request);

            return $this->router->dispatch($request);
        };
    }
    
}
複製程式碼

上面程式碼可以看到Pipeline的destination就是dispatchToRouter函式返回的閉包:

$destination = function ($request) {
    $this->app->instance('request', $request);
    return $this->router->dispatch($request);
};
複製程式碼

在閉包裡呼叫了router的dispatch方法,路由定址就發生在dispatch的第一個階段findRoute裡:

class Router implements RegistrarContract, BindingRegistrar
{	
    public function dispatch(Request $request)
    {
        $this->currentRequest = $request;

        return $this->dispatchToRoute($request);
    }
    
    public function dispatchToRoute(Request $request)
    {
        return $this->runRoute($request, $this->findRoute($request));
    }
    
    protected function findRoute($request)
    {
        $this->current = $route = $this->routes->match($request);

        $this->container->instance(Route::class, $route);

        return $route;
    }
    
}
複製程式碼

尋找路由的任務由 RouteCollection 負責,這個函式負責匹配路由,並且把 request 的 url 引數繫結到路由中:

class RouteCollection implements Countable, IteratorAggregate
{
    public function match(Request $request)
    {
        $routes = $this->get($request->getMethod());

        $route = $this->matchAgainstRoutes($routes, $request);

        if (! is_null($route)) {
            //找到匹配的路由後,將URI裡的路徑引數繫結賦值給路由(如果有的話)
            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);
        });
    }
}

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;
    }
}
複製程式碼

$routes = $this->get($request->getMethod());會先載入註冊路由階段在RouteCollection裡生成的routes屬性裡的值,routes中存放了HTTP請求方法與路由物件的對映。

然後依次呼叫這堆路由里路由物件的matches方法, matches方法, matches方法裡會對HTTP請求物件進行一些驗證,驗證對應的Validator是:UriValidator、MethodValidator、SchemeValidator、HostValidator。 在驗證之前在$this->compileRoute()裡會將路由的規則轉換成正規表示式。

UriValidator主要是看請求物件的URI是否與路由的正則規則匹配能匹配上:

class UriValidator implements ValidatorInterface
{
    public function matches(Route $route, Request $request)
    {
        $path = $request->path() == '/' ? '/' : '/'.$request->path();

        return preg_match($route->getCompiled()->getRegex(), rawurldecode($path));
    }
}
複製程式碼

MethodValidator驗證請求方法, SchemeValidator驗證協議是否正確(http|https), HostValidator驗證域名, 如果路由中不設定host屬性,那麼這個驗證不會進行。

一旦某個路由通過了全部的認證就將會被返回,接下來就要將請求物件URI裡的路徑引數繫結複製給路由引數:

路由引數繫結

class Route
{
    public function bind(Request $request)
    {
        $this->compileRoute();

        $this->parameters = (new RouteParameterBinder($this))
                        ->parameters($request);

        return $this;
    }
}

class RouteParameterBinder
{
    public function parameters($request)
    {
        $parameters = $this->bindPathParameters($request);

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

        return $this->replaceDefaults($parameters);
    }
    
    protected function bindPathParameters($request)
    {
            preg_match($this->route->compiled->getRegex(), '/'.$request->decodedPath(), $matches);

            return $this->matchToKeys(array_slice($matches, 1));
    }
    
    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;
        });
    }
}

複製程式碼

賦值路由引數完成後路由定址的過程就結束了,結下來就該執行通過匹配路由中對應的控制器方法返回響應物件了。

namespace Illuminate\Routing;
class Router implements RegistrarContract, BindingRegistrar
{	
    public function dispatch(Request $request)
    {
        $this->currentRequest = $request;

        return $this->dispatchToRoute($request);
    }
    
    public function dispatchToRoute(Request $request)
    {
        return $this->runRoute($request, $this->findRoute($request));
    }
    
    protected function runRoute(Request $request, Route $route)
    {
        $request->setRouteResolver(function () use ($route) {
            return $route;
        });

        $this->events->dispatch(new Events\RouteMatched($route, $request));

        return $this->prepareResponse($request,
            $this->runRouteWithinStack($route, $request)
        );
    }
    
    protected function runRouteWithinStack(Route $route, Request $request)
    {
        $shouldSkipMiddleware = $this->container->bound('middleware.disable') &&
                            $this->container->make('middleware.disable') === true;
	//收集路由和控制器裡應用的中介軟體
        $middleware = $shouldSkipMiddleware ? [] : $this->gatherRouteMiddleware($route);

        return (new Pipeline($this->container))
                    ->send($request)
                    ->through($middleware)
                    ->then(function ($request) use ($route) {
                        return $this->prepareResponse(
                            $request, $route->run()
                        );
                    });
    
    }
}

namespace Illuminate\Routing;
class Route
{
    public function run()
    {
        $this->container = $this->container ?: new Container;
        try {
            if ($this->isControllerAction()) {
                return $this->runController();
            }
            return $this->runCallable();
        } catch (HttpResponseException $e) {
            return $e->getResponse();
        }
    }

}
複製程式碼

這裡我們主要介紹路由相關的內容,runRoute的過程通過上面的原始碼可以看到其實也很複雜, 會收集路由和控制器裡的中介軟體,將請求通過中介軟體過濾才會最終到達目的地路由,執行目的路由地run()方法,裡面會判斷路由對應的是一個控制器方法還是閉包然後進行相應地呼叫,最後把執行結果包裝成Response物件返回給客戶端。 下一節我們就來學習一下這裡提到的中介軟體。

本文已經收錄在系列文章Laravel原始碼學習裡,歡迎訪問閱讀。

相關文章