Open30

「Laravelのサービスコンテナをコードリーディングする会」を主催しました

meijinmeijin

以下のイベントで調査する予定のスクラップ
https://connpass.com/event/205279/

イベントのハードルが上がってしまっているとアレなので、イベント企画者自身の理解度を赤裸々に残しておきます。
正直分からないことだらけです。

Laravelのバージョン

僕の手元で動かせるリポジトリのバージョンが"laravel/framework": "7.11.0"だったので、7.11.0で。

サービスコンテナとサービスプロバイダの違いが分からない

コンテナはクラス名とその実体をマッピングして保存している場所で、プロバイダはコンテナへの登録を受け持っているクラスのこと・・・?

bind()されたInterfaceと実装クラスの関係は何に保存されている?

以下のコードがあるとして、

// MySQLHogeRepositoryのインスタンスが取得できる
$this->app->make(HogeRepositoryInterface::class)

HogeRepositoryInterface::classとMySQLHogeRepository::classはAppServiceProvider等のbind()メソッドで紐付けを行っているわけですが、その紐付けは実体としては大きな連想配列とかに保存されている感じ?

Controllerクラスでは当たり前のようにメソッドインジェクションやコンストラクタインジェクションが行えるのはなぜ?

Controllerはapp->make()などしているわけではなくroutes/hoge.phpで呼び出しているだけなのに各種インジェクションが行えるのはどうして?これはindex.php->route系の処理を追っていけば分かるのかな。

ちなみにCommandを拡張したartisan command用のクラスではhandleメソッドでメソッドインジェクションができるが、コンストラクタインジェクションは失敗する。これはどうして?

メソッドインジェクションやコンストラクタインジェクションではどうして順不同で指定のクラスのバインド元が引っ張ってこれるの?

ControllerのアクションメソッドではRequest型にタイプヒントしたらRequest型の値が入る。これはどういう判断ロジック?
同様にコンストラクタインジェクションも。

public function index(Request $request, int $id) {
    // ...
}

func_get_argsしてそれぞれの引数に対してget_classとかしてる?

各種インジェクションはなぜ再帰的に利用できるの?

ControllerからUseCaseをインジェクションしたら、そのUseCaseがコンストラクタインジェクションしているとそのインジェクションも無事に完了する。このように再帰的に?インジェクションが実行できているのはどうして?引数がプリミティブな値になるまで依存解決するための関数を再帰的に回してる?

bind()とinstance()の違いとは

細かいけどたまにinstance()を使わないとテストコードが動作しないとかあった記憶がある。どういう違い?

meijinmeijin

memo

今Artisanコマンドを叩いたら、メソッドインジェクションしか対応していなかったようなのでそこをちょっと追ってみたメモ

src/vendor/laravel/framework/src/Illuminate/Contracts/Container/Container.php

src/vendor/laravel/framework/src/Illuminate/Container/Container.php
@call

src/vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php
@addDependencyForCallParameter

meijinmeijin

Controllerクラスでは当たり前のようにメソッドインジェクションやコンストラクタインジェクションが行えるのはなぜ?

src/public/index.php
から追っていく

meijinmeijin
$response = $kernel->handle(
    $request = Illuminate\Http\Request::capture()
);

ここを追っていけばControllerに着きそう

meijinmeijin

src/bootstrap/app.php

$app->singleton(
    Illuminate\Contracts\Http\Kernel::class,
    App\Http\Kernel::class
);
meijinmeijin

src/app/Http/Kernel.php

use Illuminate\Foundation\Http\Kernel as HttpKernel;

class Kernel extends HttpKernel

laravel/framework/src/Illuminate/Foundation/Providers
こっちのhandleメソッドを追ってみる

meijinmeijin
    public function handle($request)
    {
        try {
            $request->enableHttpMethodParameterOverride();

            $response = $this->sendRequestThroughRouter($request);
        } catch (Throwable $e) {
            $this->reportException($e);

            $response = $this->renderException($request, $e);
        }

        $this->app['events']->dispatch(
            new RequestHandled($request, $response)
        );

        return $response;
    }
            $response = $this->sendRequestThroughRouter($request);
meijinmeijin

handleの第1引数に渡ってきているのが

$request = Illuminate\Http\Request::capture()

で、

これが

$this->app->instance('request', $request);

でrequestという文字列に対して紐付いている

meijinmeijin
    protected function sendRequestThroughRouter($request)
    {
        $this->app->instance('request', $request);

instanceメソッドが登場!

meijinmeijin

第1引数は文字列だったらなんでもいい。

文字列をキーとして登録しているだけ。

meijinmeijin

ユニークなIDとして、instance()メソッドの第1引数にはHogeClass::classとやることがあるが、これは任意の文字列でよい

meijinmeijin

Facade::clearResolvedInstance('request');

これはスルー!

meijinmeijin

$this->bootstrap();

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

    protected $bootstrappers = [
        \Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class,
        \Illuminate\Foundation\Bootstrap\LoadConfiguration::class,
        \Illuminate\Foundation\Bootstrap\HandleExceptions::class,
        \Illuminate\Foundation\Bootstrap\RegisterFacades::class,
        \Illuminate\Foundation\Bootstrap\RegisterProviders::class,
        \Illuminate\Foundation\Bootstrap\BootProviders::class,
    ];

LoadConfigurationをしているから、Configが読み取れるようになっている!

meijinmeijin
        return (new Pipeline($this->app))
                    ->send($request)
                    ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
                    ->then($this->dispatchToRouter());
meijinmeijin
    public function send($passable)
    {
        $this->passable = $passable;

        return $this;
    }

passableは、ずっと追っているRequestのこと。

meijinmeijin
    public function through($pipes)
    {
        $this->pipes = is_array($pipes) ? $pipes : func_get_args();

        return $this;
    }
->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)

ここの3項演算子の最後に来ている、$this->middlewareは

src/app/Http/Kernel.php

こっちで登録しているmiddlewareを指している

    protected $middleware = [
        \App\Http\Middleware\TrustProxies::class,
        \Fruitcake\Cors\HandleCors::class,
        \App\Http\Middleware\CheckForMaintenanceMode::class,
        \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
        \App\Http\Middleware\TrimStrings::class,
        \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
    ];
meijinmeijin
    public function then(Closure $destination)
    {
        $pipeline = array_reduce(
            array_reverse($this->pipes()), $this->carry(), $this->prepareDestination($destination)
        );

        return $pipeline($this->passable);
    }

これはすごいのが出てきた

pipesに渡ってきているClass名と、prepareDestinationで用意されたものに対してReduceを掛けているので、第2引数のcarry関数が、順に呼ばれている(と思われる)

meijinmeijin
    protected function parsePipeString($pipe)
    {
        [$name, $parameters] = array_pad(explode(':', $pipe, 2), 2, []);

        if (is_string($parameters)) {
            $parameters = explode(',', $parameters);
        }

        return [$name, $parameters];
    }
>>> array_pad(explode(':', 'App\Http\Middleware\TrustProxyes', 2), 2, [])
=> [
     "App\Http\Middleware\TrustProxyes",
     [],
   ]
>>> 

普通は、parametersには何も入らないのだが、

'throttle:60,1'

といった形式で書くときの、コロン以降がparametersに入るようになっている

meijinmeijin

src/vendor/laravel/framework/src/Illuminate/Auth/Middleware/Authenticate.php

public function handle(request, Closure $next, ...guards)

Middlewareでよく見る、handleメソッド(このMiddlewareでやるべきことをやって最終的にnextを呼ぶもの)が、

carryメソッド中の

function (\Clusure $stack, string $pipe) {
                $carry = function (Request $passable) use ($stack, $pipe) {
                    try {
                        if (is_callable($pipe)) {
                            // If the pipe is a callable, then we will call it directly, but otherwise we
                            // will resolve the pipes out of the dependency container and call it with
                            // the appropriate method and arguments, returning the results back out.
                            return $pipe($passable, $stack);
                        }

                        if (! is_object($pipe)) {
                            [$name, $parameters] = $this->parsePipeString($pipe);

                            // If the pipe is a string we will parse the string and resolve the class out
                            // of the dependency injection container. We can then build a callable and
                            // execute the pipe function giving in the parameters that are required.
                            $pipe = $this->getContainer()->make($name);

                            $parameters = array_merge([$passable, $stack], $parameters);
                        } else {
                            // If the pipe is already an object we'll just make a callable and pass it to
                            // the pipe as-is. There is no need to do any extra parsing and formatting
                            // since the object we're given was already a fully instantiated object.
                            $parameters = [$passable, $stack];
                        }

                        $carry = method_exists($pipe, $this->method)
                            ? $pipe->{$this->method}(...$parameters)
                            : $pipe(...$parameters);

                        return $this->handleCarry($carry);
                    } catch (Throwable $e) {
                        return $this->handleException($passable, $e);
                    }
                };
 $parameters = array_merge([$passable, $stack], $parameters);

の部分が、Middlewareの引数の仕様に合致している
だからMiddlewareはhandleメソッドの仕様がそのようになっている(もはやDSL・・・)

meijinmeijin
                        $carry = method_exists($pipe, $this->method)
                            ? $pipe->{$this->method}(...$parameters)
                            : $pipe(...$parameters);
    /**
     * The method to call on each pipe.
     *
     * @var string
     */
    protected $method = 'handle';
meijinmeijin

ここまでで、Middlewareを順番に全部通ってくることは分かった(middlewareGroupは除いて)

最後にdispatchToRouterを見る

meijinmeijin

array_reverseしているのは、ControllerをReduceのInitialにしておきながらも、Middleware→Controllerの順で呼ばせるためな気がした

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

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

        return $this->prepareResponse($request,
            $this->runRouteWithinStack($route, $request)
        );
    }
meijinmeijin

マッチしたRouteを呼ぶことが出来ているので、最後に以下の関数が呼ばれている

    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()
                            );
                        });
    }
meijinmeijin

$route->runを見る

    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();
        }
    }
meijinmeijin

runRouteはrunしているだけだったようなので、Findのほうを見て、メソッドインジェクションなどの正体を探る

    protected function findRoute($request)
    {
        $this->current = $route = $this->routes->match($request);

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

        return $route;
    }
    public function match(Request $request)
    {
        $routes = $this->get($request->getMethod());

        // First, we will see if we can find a matching route for this current request
        // method. If we can, great, we can just return it so that it can be called
        // by the consumer. Otherwise we will check for routes with another verb.
        $route = $this->matchAgainstRoutes($routes, $request);

        return $this->handleMatchedRoute($request, $route);
    }
meijinmeijin
    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();
        }
    }

    protected function runController()
    {
        return $this->controllerDispatcher()->dispatch(
            $this, $this->getController(), $this->getControllerMethod()
        );
    }

    public function dispatch(Route $route, $controller, $method)
    {
        $parameters = $this->resolveClassMethodDependencies(
            $route->parametersWithoutNulls(), $controller, $method
        );

        if (method_exists($controller, 'callAction')) {
            return $controller->callAction($method, $parameters);
        }

        return $controller->{$method}(...array_values($parameters));
    }

ここのresolveClassMethodDependencies、およびresolveMethodDependenciesを用いてメソッドインジェクションが行われているようだ

meijinmeijin

結果

index.phpからControllerを呼ぶまでを追いかけるので必死で、Containerクラスそのものの動作原理には到達できなかった。
しかし、Middlewareの呼び出し順の周りの生の処理を追えたのが大変勉強になった。


追記

以下で、パラメータのそれぞれの値に対してgetClassして、makeしている処理を発見した。

    protected function transformDependency(ReflectionParameter $parameter, $parameters, $skippableValue)
    {
        $class = $parameter->getClass();

        // If the parameter has a type-hinted class, we will check to see if it is already in
        // the list of parameters. If it is we will just skip it as it is probably a model
        // binding and we do not want to mess with those; otherwise, we resolve it here.
        if ($class && ! $this->alreadyInParameters($class->name, $parameters)) {
            return $parameter->isDefaultValueAvailable()
                        ? null
                        : $this->container->make($class->name);
        }

        return $skippableValue;
    }
meijinmeijin

Route.phpのconstructerでparseActionメソッドを発見。

   public function __construct($methods, $uri, $action)
    {
        $this->uri = $uri;
        $this->methods = (array) $methods;
        $this->action = Arr::except($this->parseAction($action), ['prefix']);

        if (in_array('GET', $this->methods) && ! in_array('HEAD', $this->methods)) {
            $this->methods[] = 'HEAD';
        }

        $this->prefix(is_array($action) ? Arr::get($action, 'prefix') : '');
    }

laravel/framework/src/Illuminate/Routing/RouteAction.php

__invokeメソッドの動作理由は分かった

        elseif (! isset($action['uses'])) {
            $action['uses'] = static::findCallable($action);
        }

        if (is_string($action['uses']) && ! Str::contains($action['uses'], '@')) {
            $action['uses'] = static::makeInvokable($action['uses']);
        }
    protected static function makeInvokable($action)
    {
        if (! method_exists($action, '__invoke')) {
            throw new UnexpectedValueException("Invalid route action: [{$action}].");
        }

        return $action.'@__invoke';
    }