LaravelのRoute::get()へ渡すactionが何者なのか探ってみる
IT技術
概要
LaravelのRoute::get()が受け取る第2引数actionがどんな仕組みで動いているのか、ソースコードから探っていきます。
ゴール
Route::get()へ渡す第2引数actionが何を指しており、どんな仕組みで動作するのか理解することを目指します。
環境
- PHP8.1
- Laravel10.0
TL;DR
配列形式・文字列形式いずれも最終的には、Controller@Methodの形式の文字列となる。
LaravelのRouteオブジェクトは、文字列に含まれる@を境界にコントローラクラスのインスタンスを組み立て、メソッド名から具体的なメソッドを呼び出す。
用語
- action: Route::get()の第2引数。文字列Controller@methodや配列['Controler', 'method']のような形式がある
背景
Laravelでパスと対応するコントローラを紐づけるとき、以下のように書くことが多いでしょう。
1// 配列
2Route::get('/user', [UserController::class, 'index']);
3// コントローラ名@メソッド名の文字列
4Route::get('/user', 'UserController@index');
ここで、第2引数([UserController::class, 'index'], UserController@index)に注目してみます。
ドキュメントや書籍では当たり前のように書かれていますが、公式ドキュメントでも書式を定義しているところは見当たりませんでした。
APIドキュメントでも型にのみ言及しています。
> get(string $uri, array|string|callable $action = null)
用例からなんとなく使い方は分かりますが、それでは中々頭に残りません。暗記するのではなく、仕組みを理解して使いこないしたいところです。
そこで、ドキュメントに書かれていないならソースコードを覗くしかないじゃない、ということでRoute::get()の第2引数actionがどのように参照されているのか探ってみます。
更に、actionをもとにコントローラのメソッドがどのように呼び出されるのか流れも見ておきたいです。
仕組みと処理の流れが掴めれば、ルーティングに関する設定を書くときも自信をもって臨めるようになるはずです。
※ 今回はコントローラのメソッドが呼び出される仕組みをたどりたいので、Callableをactionとして渡すケースは対象外とします。
私的前置き
色々書きましたが実際はLaravelのソースコードも読んでみたいな〜と思っていたので、好奇心が理由の大半だったりします(:
大方針
Laravelのソースコードを覗いてみることにしました。
ですが、コントローラのメソッドが呼ばれる仕組み1つをたどるにしても、すべてを読み解こうとすると膨大な量のコードが襲いかかってきます。ただ闇雲に読んでいくだけでは心が折れてしまいます。
そこで、目的を見据え、どうやって読んでいくのか簡単な地図をつくっておきます。
目的・方針が定まっていれば、広大なLaravelのコードの中から何を読むべきか取捨選択でき、迷うことなく目的地へといたれるはずです。
目的
なぜRoute::get()に文字列や配列が指定できるのか明らかにしたいです。
おそらくこれらをまとめて扱えるよう共通の形式に変換しているような処理があるはずなので、actionを加工しているっぽい処理を中心に探っていきます。
更に、actionをもとにどうやってコントローラのメソッドが呼ばれるのかおおまかな流れを見ておきたいです。
なんとなく、リフレクション的な仕組みでインスタンスをつくって動的にメソッドを呼び出しているんじゃないかなとは思いますが、正確な理解を得たいところです。
方針
Route::get()の引数がどんな形となるのかは、じっくり見ていきます。
actionを加工しているような処理をざっくりと探していき、該当しそうなところを見つけたら掘り下げていきます。
そして、コントローラのメソッドが実際に呼ばれる処理は、概要だけを掴むにとどめておきます。
リフレクションのような仕組みはコードが自ずと複雑になるので、すべてを読み解こうとすると長い道のりになってしまいます。
ですので、細部には入りこまず、何をしているのか流れを理解するところまでを目標とします。
ゴールと、ゴールにいたるまでの道のりが見えてきたので、早速Laravelのソースコードを探検していきます。
入り口
まずはRoute::get()メソッドが定義されているところを探します。Routeクラスの定義へ飛んでみましょう。
1// Illuminate/Support/Facades/Route.php
2class Route extends Facade
3{
4 /*
5 * Get the registered name of the component.
6 *
7 * @return string
8 */
9 protected static function getFacadeAccessor()
10 {
11 return 'router';
12 }
13}
getFacadeAccessor()なるメソッドは定義されていましたが、肝心のRoute::get()が見当たりませんでした。
実はLaravelでは、Facadeと呼ばれるデザインパターンに従い、メソッド名(get)やインスタンス名(getFacadeAccessor()の戻り値 router)をもとに特定のクラスのメソッドを呼び出せる仕組みがあります。
これを実現するためには裏で色々なモジュールが動いているのですが、本筋から外れてしまうので割愛します。
結論だけ書くと、Route::get()という命令で実際に呼ばれているのは、Routerクラスのgetメソッドです。
Routerクラスを確認してみましょう。
1// Illuminate/Routing/Router.php
2
3namespace Illuminate\Routing;
4
5// 中略...
6
7class Router implements BindingRegistrar, RegistrarContract
8{
9 // 中略...
10
11 /*
12 * Register a new GET route with the router.
13 *
14 * @param string $uri
15 * @param array|string|callable|null $action
16 * @return \Illuminate\Routing\Route
17 */
18 public function get($uri, $action = null)
19 {
20 return $this->addRoute(['GET', 'HEAD'], $uri, $action);
21 }
22}
actionを参照しているところにたどり着きました。
ようやく入り口に足を踏み入れることができたので、actionが処理される仕組みを追っていきます。
action
ここからは、メソッドの中身に潜りこんでactionがどのように参照されるのか見ていきます。
Router -actionをもとにRouteオブジェクトをつくって登録
Router::get()で呼ばれていたRouter::addRoute()から始めます。
1/*
2 * Add a route to the underlying route collection.
3 *
4 * @param array|string $methods
5 * @param string $uri
6 * @param array|string|callable|null $action
7 * @return \Illuminate\Routing\Route
8 */
9public function addRoute($methods, $uri, $action)
10{
11 return $this->routes->add($this->createRoute($methods, $uri, $action));
12}
更にRouter::createRoute()へと進みます。
1/*
2 * Create a new route instance.
3 *
4 * @param array|string $methods
5 * @param string $uri
6 * @param mixed $action
7 * @return \Illuminate\Routing\Route
8 */
9protected function createRoute($methods, $uri, $action)
10{
11 // If the route is routing to a controller we will parse the route action into
12 // an acceptable array format before registering it and creating this route
13 // instance itself. We need to build the Closure that will call this out.
14 if ($this->actionReferencesController($action)) {
15 $action = $this->convertToControllerAction($action);
16 }
17
18 $route = $this->newRoute(
19 $methods, $this->prefix($uri), $action
20 );
21
22 // 中略...
23
24 return $route;
25}
必要な処理は、actionを受け取っているところだけなので、見るところを前半に絞っておきます。
action変換処理
1 // If the route is routing to a controller we will parse the route action into
2 // an acceptable array format before registering it and creating this route
3 // instance itself. We need to build the Closure that will call this out.
4 if ($this->actionReferencesController($action)) {
5 $action = $this->convertToControllerAction($action);
6 }
actionを加工していそうな処理が見つかりました。
コメントをざっと読んでみると、コントローラを対象としたactionを共通の形式の配列に加工している予感がします。中身を詳しく見ていきます。
1 /*
2 * Determine if the action is routing to a controller.
3 *
4 * @param mixed $action
5 * @return bool
6 */
7 protected function actionReferencesController($action)
8 {
9 if (! $action instanceof Closure) {
10 return is_string($action) || (isset($action['uses']) && is_string($action['uses']));
11 }
12
13 return false;
14 }
色々と条件が書かれているので、actionが文字列の場合、配列の場合で分けて考えてみます。
まず、どちらもClosureではないので、if文の中のreturn文が評価されます。
actionが文字列のときはis_string()からtrueが返ります。
一方、actionが配列のときは、usesキーを持たないのでfalseが返ります。
思っていた挙動ではなさそうですが、続けてconvertToControllerAction()へ進みます。
1 /*
2 * Add a controller based route action to the action array.
3 *
4 * @param array|string $action
5 * @return array
6 */
7 protected function convertToControllerAction($action)
8 {
9 if (is_string($action)) {
10 $action = ['uses' => $action];
11 }
12
13 // Here we'll merge any group "controller" and "uses" statements if necessary so that
14 // the action has the proper clause for this property. Then, we can simply set the
15 // name of this controller on the action plus return the action array for usage.
16 if ($this->hasGroupStack()) {
17 $action['uses'] = $this->prependGroupController($action['uses']);
18 $action['uses'] = $this->prependGroupNamespace($action['uses']);
19 }
20
21 // Here we will set this controller name on the action array just so we always
22 // have a copy of it for reference if we need it. This can be used while we
23 // search for a controller name or do some other type of fetch operation.
24 $action['controller'] = $action['uses'];
25
26 return $action;
27 }
どうやらこのメソッドでは、文字列形式のactionを以下のようなaction arrayと呼ばれる形式に変換しているようです。
コントローラ名を残しておくことで必要なときに参照するための処理であるようですが、今回の目的とは関係なさそうでした。
1$action = [
2 'uses' => 'Controller@method',
3 'controller' => 'Controller@method'
4];
ということで、元々見ていたRouter::createRoute()に戻ります。
1 $route = $this->newRoute(
2 $methods, $this->prefix($uri), $action
3 );
今度はRouter::newRouteへと進みます。
1 /*
2 * Create a new Route object.
3 *
4 * @param array|string $methods
5 * @param string $uri
6 * @param mixed $action
7 * @return \Illuminate\Routing\Route
8 */
9 public function newRoute($methods, $uri, $action)
10 {
11 return (new Route($methods, $uri, $action))
12 ->setRouter($this)
13 ->setContainer($this->container);
14 }
Routeオブジェクトをつくっているようです。
actionを参照している処理はここで最後のようなので、必ず配列や文字列を共通の形式へと加工している処理が潜んでいるはずです。
じっくりと読み解いていきましょう。
Route-ルート情報を表現するオブジェクト
Routeクラスのインスタンスがつくられていたので、コンストラクタから読み始めます。
1// Illuminate/Routing/Route.php
2
3namespace Illuminate\Routing;
4
5// 中略...
6
7class Route
8{
9 // 中略...
10
11 /*
12 * Create a new Route instance.
13 *
14 * @param array|string $methods
15 * @param string $uri
16 * @param \Closure|array $action
17 * @return void
18 */
19 public function __construct($methods, $uri, $action)
20 {
21 $this->uri = $uri;
22 $this->methods = (array) $methods;
23 $this->action = Arr::except($this->parseAction($action), ['prefix']);
24
25 if (in_array('GET', $this->methods) && ! in_array('HEAD', $this->methods)) {
26 $this->methods[] = 'HEAD';
27 }
28
29 $this->prefix(is_array($action) ? Arr::get($action, 'prefix') : '');
30 }
31 // 中略...
32}
ここで重要そうなのは、Route::parseAction()です。早速中身を覗いてみます。
1 /*
2 * Parse the route action into a standard array.
3 *
4 * @param callable|array|null $action
5 * @return array
6 *
7 * @throws \UnexpectedValueException
8 */
9 protected function parseAction($action)
10 {
11 return RouteAction::parse($this->uri, $action);
12 }
いかにもな名前のクラスのメソッドが呼ばれています。RouteAction::parse()へと移ります。
action加工処理
1// Illuminate/Routing/RouteAction.php
2
3namespace Illuminate\Routing;
4
5// 中略...
6
7class RouteAction
8{
9 /*
10 * Parse the given action into an array.
11 *
12 * @param string $uri
13 * @param mixed $action
14 * @return array
15 */
16 public static function parse($uri, $action)
17 {
18 // 中略...
19
20 // If the action is already a Closure instance, we will just set that instance
21 // as the "uses" property, because there is nothing else we need to do when
22 // it is available. Otherwise we will need to find it in the action list.
23 if (Reflector::isCallable($action, true)) {
24 return ! is_array($action) ? ['uses' => $action] : [
25 'uses' => $action[0].'@'.$action[1],
26 'controller' => $action[0].'@'.$action[1],
27 ];
28 }
29
30 // 中略...
31
32 return $action;
33 }
34 // 中略...
35}
長いメソッドなので、抜粋して探っていきます。
ぱっと見では配列はCallableじゃなさそうだし...と素通りしそうなところですが、念には念を入れて呼び出し先を確認しておきます。
1// Illuminate/Support/Reflector.php
2 /*
3 * This is a PHP 7.4 compatible implementation of is_callable.
4 *
5 * @param mixed $var
6 * @param bool $syntaxOnly
7 * @return bool
8 */
9 public static function isCallable($var, $syntaxOnly = false)
10 {
11 // 中略...
12
13 // ※引数syntaxOnlyにはtrueが渡されている
14 if ($syntaxOnly &&
15 (is_string($var[0]) || is_object($var[0])) &&
16 is_string($var[1])) {
17 return true;
18 }
19 // 中略...
20
21 }
if文の条件に注目します。配列形式のactionはコントローラ名・メソッド名をそれぞれ文字列で渡しています。要素はコード内の$var[0]・$var[1]と対応します。
よって、['Controller', 'method']のような配列は、Reflector::isCallable()にてtrueと評価されます。
やや直感と反しますが、これはPHPのis_callableの仕様を踏襲しているようです。
参考
配列がreturn文を評価する対象であることが明らかになりました。
ですので、もう一度return文を見直しておきます。
1return ! is_array($action) ? ['uses' => $action] : [
2 'uses' => $action[0].'@'.$action[1],
3 'controller' => $action[0].'@'.$action[1],
4];
配列から要素を取り出し、@で連結しています。いかにもな処理ですね。
これはつまり、['Controller' 'method']のような配列から、Controller@methodといった形式の文字列が組み立てられていることを表します。
まとめると、actionは文字列・配列で渡されても最終的には以下のような形になることが分かりました。
1$action = [
2 'uses' => 'Controller@method',
3 'controller' => 'Controller@method'
4];
最初の目的-actionがどのように参照されるか
actionを加工している処理を探すことで、なぜ文字列や配列をactionとして渡すことができるのか解き明かせました。
改めて言葉にすると、文字列も配列も、指定したコントローラのメソッドを呼び出せるよう、Controller名@メソッド名の形式の文字列となることが分かりました。
これでRoute::get()へ配列を渡すときも書式に迷わず書けそうです。
コントローラのメソッドはいかにして呼び出されるか
actionがどのような形となるかは理解できました。
更に理解を深めるために、actionを表す文字列からどのようにしてコントローラのメソッドが呼ばれるのか、流れを押さえておきます。
エントリーポイント
リクエストがどのように処理されるか、から始めると長くなってしまうので、ルーティング関連の処理の入り口までショートカットします。
リクエストから対応するコントローラを呼び出す処理は、Router::dispatch()を起点とします。
1// Illuminate/Routing/Router.php
2 /*
3 * Dispatch the request to the application.
4 *
5 * @param \Illuminate\Http\Request $request
6 * @return \Symfony\Component\HttpFoundation\Response
7 */
8 public function dispatch(Request $request)
9 {
10 $this->currentRequest = $request;
11
12 return $this->dispatchToRoute($request);
13 }
Router::dispatchToRoute()へと進みます。
1 /*
2 * Dispatch the request to a route and return the response.
3 *
4 * @param \Illuminate\Http\Request $request
5 * @return \Symfony\Component\HttpFoundation\Response
6 */
7 public function dispatchToRoute(Request $request)
8 {
9 return $this->runRoute($request, $this->findRoute($request));
10 }
Router::findRoute()は、リクエストのパス情報をもとに、対応するRouteオブジェクトを探し出す処理です。
Routeオブジェクトは、先ほど見た通り、パスやactionを持っています。
1// Illuminate/Routing/Route.php
2 /*
3 * Create a new Route instance.
4 *
5 * @param array|string $methods
6 * @param string $uri
7 * @param \Closure|array $action
8 * @return void
9 */
10 public function __construct($methods, $uri, $action)
11 {
12 $this->uri = $uri;
13 $this->methods = (array) $methods;
14 $this->action = Arr::except($this->parseAction($action), ['prefix']);
15
16 // 中略...
17
18 }
例えば、Route::get('/action/', ['Controller', 'method'])のような命令を書くとします。
すると、これをもとにuriプロパティが/action/・actionプロパティがController@methodのRouteオブジェクトがつくられます。
Router::findRoute()は、リクエストのパスがhttp://localhost/action/のような形式だったとき、前述のRouteオブジェクトを返します。
ひとまずここでは、Route::get()でつくられたオブジェクトが取り出されたんだな〜、ということを理解しておきましょう。
コントローラのメソッドを呼び出していそうなRouter::runRoute()を見てみます。
1 /*
2 * Return the response for the given route.
3 *
4 * @param \Illuminate\Http\Request $request
5 * @param \Illuminate\Routing\Route $route
6 * @return \Symfony\Component\HttpFoundation\Response
7 */
8 protected function runRoute(Request $request, Route $route)
9 {
10 $request->setRouteResolver(fn () => $route);
11
12 $this->events->dispatch(new RouteMatched($route, $request));
13
14 return $this->prepareResponse($request,
15 $this->runRouteWithinStack($route, $request)
16 );
17 }
色々と複雑そうな処理が書かれているので、流れだけ押さえておきます。Route::runRouteWithinStack()を契機に、Route::run()が呼び出されます。
1// Illuminate/Routing/Route.php
2 /*
3 * Run the route action and return the response.
4 *
5 * @return mixed
6 */
7 public function run()
8 {
9 $this->container = $this->container ?: new Container;
10
11 try {
12 if ($this->isControllerAction()) {
13 return $this->runController();
14 }
15
16 return $this->runCallable();
17 } catch (HttpResponseException $e) {
18 return $e->getResponse();
19 }
20 }
tryブロックに興味深そうな処理が書かれています。
Route::isControllerAction()は、actionに文字列や配列を渡していた場合、trueと評価されます。
1 /*
2 * Checks whether the route's action is a controller.
3 *
4 * @return bool
5 */
6 protected function isControllerAction()
7 {
8 return is_string($this->action['uses']) && ! $this->isSerializedClosure();
9 }
コントローラのメソッドを呼び出していそうなRoute::runController()へ進みます。
1 /*
2 * Run the route action and return the response.
3 *
4 * @return mixed
5 *
6 * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
7 */
8 protected function runController()
9 {
10 return $this->controllerDispatcher()->dispatch(
11 $this, $this->getController(), $this->getControllerMethod()
12 );
13 }
こういう処理は評価される順から見ていくのが定石です。
Route::getController()・Route::getControllerMethod()はいずれも似たような処理なので、あわせて見ていきます。
1 /*
2 * Get the controller instance for the route.
3 *
4 * @return mixed
5 */
6 public function getController()
7 {
8 if (! $this->controller) {
9 $class = $this->getControllerClass();
10
11 $this->controller = $this->container->make(ltrim($class, '\\'));
12 }
13
14 return $this->controller;
15 }
16
17 /**
18 * Get the controller class used for the route.
19 *
20 * @return string|null
21 */
22 public function getControllerClass()
23 {
24 return $this->isControllerAction() ? $this->parseControllerCallback()[0] : null;
25 }
26
27 /**
28 * Get the controller method used for the route.
29 *
30 * @return string
31 */
32 protected function getControllerMethod()
33 {
34 return $this->parseControllerCallback()[1];
35 }
Route::parseControllerCallback()にて、Controller@method形式の文字列からコントローラ名やメソッド名が抽出されていそうな予感がします。
中身を見てみましょう。
1 /*
2 * Parse the controller.
3 *
4 * @return array
5 */
6 protected function parseControllerCallback()
7 {
8 return Str::parseCallback($this->action['uses']);
9 }
1// Illuminate/Support/Str.php
2 /*
3 * Parse a Class[@]method style callback into class and method.
4 *
5 * @param string $callback
6 * @param string|null $default
7 * @return array
8 */
9 public static function parseCallback($callback, $default = null)
10 {
11 return static::contains($callback, '@') ? explode('@', $callback, 2) : [$callback, $default];
12 }
これは、Controller@methodのような文字列を['Controller', 'method']形式の配列へ変換します。
よって、コントローラ・メソッドを得る処理は、コントローラ名・メソッド名それぞれをactionから取り出すことができます。
これを受けて、Route::getController()をもう一度見てみます。
1 public function getController()
2 {
3 if (! $this->controller) {
4 $class = $this->getControllerClass();
5
6 $this->controller = $this->container->make(ltrim($class, '\\'));
7 }
8
9 return $this->controller;
10 }
Illuminate\Container::make()はコントローラのクラス名を表現する文字列から、クラスのインスタンスを出力してくれます。
内部では色々と複雑な処理が書かれているので、ここでは振る舞いを押さえておくにとどめます。
ということで、コントローラクラスのインスタンス・呼び出したいメソッド名がそろいました。
あとは実際にメソッドを呼び出しているところを確認できれば、今回の目標は達成です。
既にたくさんの処理を追ってきましたが、ゴールも見えてきたので、もうひと頑張りしてみます。
コントローラクラスのインスタンス・メソッド名を受け取るRoute::runController()へ戻ります。
1 /*
2 * Run the route action and return the response.
3 *
4 * @return mixed
5 *
6 * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
7 */
8 protected function runController()
9 {
10 return $this->controllerDispatcher()->dispatch(
11 $this, $this->getController(), $this->getControllerMethod()
12 );
13 }
ここで参照しているdispatcherは、Illuminate\Routing\ControllerDispatcherです。
実際に呼ばれているのは、ControllerDispatcher::dispatch()なので、どうやってコントローラのメソッドを呼び出すかだけ見ておきます。
1 /*
2 * Dispatch a request to a given controller and method.
3 *
4 * @param \Illuminate\Routing\Route $route
5 * @param mixed $controller
6 * @param string $method
7 * @return mixed
8 */
9 public function dispatch(Route $route, $controller, $method)
10 {
11 $parameters = $this->resolveParameters($route, $controller, $method);
12
13 if (method_exists($controller, 'callAction')) {
14 return $controller->callAction($method, $parameters);
15 }
16
17 return $controller->{$method}(...array_values($parameters));
18 }
return文に欲しかった答えが書かれています。
確かに、Controller@method形式の文字列と対応するコントローラクラスのメソッドが呼ばれていることが確認できました。
流れを振り返る
コントローラのメソッドが呼ばれるまでの概要をたどるだけでも、長い道のりでした。
理解したことを整理するために、ここで流れを復習しておきましょう。
- RouterがリクエストされたURL(パス)と対応するRouteオブジェクトを探索
- Routeオブジェクトのrunメソッドが呼ばれる
- Routeオブジェクトは、自身のaction(Controller@method形式の文字列)から、コントローラクラスのインスタンスとメソッド名を得る
- 得られたものをもとに、ControllerDispatcherを介してコントローラのメソッドを呼び出し
余談: フレームワークのコードを追う意義
振り返ってみると、概ね予測したものと合致していました。なんとなく推測できるようなものを時間をかけて読む意義はあったのでしょうか。
※ 以降に書いているのは、個人の主観です。
これは、コードを読むこと自体によきことがあると思っています。
例えば、コードを実際に眺めることで、URLとコントローラを対応づける処理はRouterが指令塔になっていて、処理自体はRouteで完結させているんだなぁ、ということが分かります。
構造が見えてくれば、LaravelはWebアプリケーションのよくある処理をどんな考え方で実現しているのか、思考がうっすら見えてきます。
そして何より、コードを読むのは楽しいことです。
楽しみながらLaravelの理解を深められるとあれば、やってみない手はないでしょう。
まとめ
LaravelのRoute::get()を入り口に、actionが動作する仕組みを見てきました。
分かってしまえば単純な話でしたが、コードを読む中でLaravelの理解が少しでも進めば幸いです。
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ
強くなりたい。