【Laravel】 ルーティングサービスを継承する

**【2021/10/15 追記】現在の自分の思想としてはこのアーキテクチャを推奨しておりません！**

# はじめに

[【Laravel】Route Model Binding と Policy だけで認可処理を完結させるための Resourceful ルーティングの工夫 - Qiita](https://qiita.com/mpyw/items/13b04f80f6355b30215a#comment-9031cb1ad0797616f4d0)

せっかくここで面白いアイデアを思いついたのに，Laravelの微妙な仕様のせいで実現できずにこのまま諦める…というのは悔しかったので，ルーティングサービス一式全部継承してやろうじゃないか！というところに落ち着きました。

今回継承が必要なのは以下の4クラスです。

- [`Illuminate\Routing\Route`](https://github.com/laravel/framework/blob/5.5/src/Illuminate/Routing/Route.php) … ルート1つ1つを表現するクラス
- [`Illuminate\Routing\Router`](https://github.com/laravel/framework/blob/5.5/src/Illuminate/Routing/Router.php) … ルートの定義とアクションの実行を行うクラス
- [`Illuminate\Routing\ControllerDispatcher`](https://github.com/laravel/framework/blob/5.5/src/Illuminate/Routing/ControllerDispatcher.php) … コントローラに関するアクションの実行を行うクラス
- [`Illuminate\Routing\RoutingServiceProvider`](https://github.com/laravel/framework/blob/5.5/src/Illuminate/Routing/RoutingServiceProvider.php) … 上記のクラス群を登録するサービスプロバイダ

# ベースを作る

## サービス定義

```php:app/Services/Routing/Route.php
<?php

declare(strict_types=1);

namespace App\Services\Routing;

use Illuminate\Routing\Route as BaseRoute;

class Route extends BaseRoute
{
}
```

```php:app/Services/Routing/ControllerDispatcher.php
<?php

declare(strict_types=1);

namespace App\Services\Routing;

use Illuminate\Routing\ControllerDispatcher as BaseControllerDispatcher;

class ControllerDispatcher extends BaseControllerDispatcher
{
}
```

```php:app/Services/Routing/Router.php
<?php

declare(strict_types=1);

namespace App\Services\Routing;

use Illuminate\Routing\Router as BaseRouter;

class Router extends BaseRouter
{
    /**
     * Create a new Route object.
     *
     * @param  array|string $methods
     * @param  string       $uri
     * @param  mixed        $action
     * @return Route
     */
    protected function newRoute($methods, $uri, $action): Route
    {
        return (new Route($methods, $uri, $action))
            ->setRouter($this)
            ->setContainer($this->container);
    }
}
```

- `Router` および `ControllerDispatcher` はコンテナ経由でインスタンスが生成されるので，サービスプロバイダで登録するだけで済みます。
- `Route` はクラス名が `Router` の中でハードコーディングされているので， `Router::newRoute()` のオーバーライドが必要です。

## サービスプロバイダ定義

```php:app/Providers/RoutingServiceProvider.php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Services\Routing\ControllerDispatcher;
use App\Services\Routing\Router;
use Illuminate\Routing\Contracts\ControllerDispatcher as ControllerDispatcherContract;
use Illuminate\Routing\RoutingServiceProvider as ServiceProvider;

class RoutingServiceProvider extends ServiceProvider
{
    /**
     * Register the router instance.
     */
    protected function registerRouter(): void
    {
        $this->app->singleton('router', function ($app) {
            return new Router($app['events'], $app);
        });
    }

    /**
     * Register the controller dispatcher.
     */
    protected function registerControllerDispatcher(): void
    {
        $this->app->singleton(ControllerDispatcherContract::class, function ($app) {
            return new ControllerDispatcher($app);
        });
    }
}
```

```php
<?php

return [

    // ...

    /*
    |--------------------------------------------------------------------------
    | Autoloaded Service Providers
    |--------------------------------------------------------------------------
    |
    | The service providers listed here will be automatically loaded on the
    | request to your application. Feel free to add your own services to
    | this array to grant expanded functionality to your applications.
    |
    */

    'providers' => [

        // ...

        /*
         * Application Service Providers...
         */
        App\Providers\RoutingServiceProvider::class,

        // ...
    ],

];
```

## HTTPカーネルでのミドルウェアリロード

```php:app\Http\Kernel.php
<?php

declare(strict_types=1);

namespace App\Http;

use Illuminate\Foundation\Http\Kernel as HttpKernel;

class Kernel extends HttpKernel
{
    // ...

    /**
     * Router をオーバーライドしているため，dispatch する前にミドルウェアを再設定します。
     *
     * @return \Closure
     */
    protected function dispatchToRouter(): \Closure
    {
        $this->router = $this->app['router'];

        $this->router->middlewarePriority = $this->middlewarePriority;

        foreach ($this->middlewareGroups as $key => $middleware) {
            $this->router->middlewareGroup($key, $middleware);
        }

        foreach ($this->routeMiddleware as $key => $middleware) {
            $this->router->aliasMiddleware($key, $middleware);
        }

        return parent::dispatchToRouter();
    }
}
```

↑これが超大事！ `Router` の初期化は早い段階で行われているため，差し替えたあとには再度ミドルウェアをリロードする必要があります。

# 拡張する

## `RouteDependencyResolverTrait` にパッチを当てるトレイト

```php:app/Services/Routing/Concerns/AcceptsNullableDependencies.php
<?php

declare(strict_types=1);

namespace App\Services\Routing\Concerns;

use Illuminate\Contracts\Container\Container;
use Illuminate\Routing\RouteDependencyResolverTrait;
use ReflectionFunctionAbstract;
use ReflectionParameter;

/**
 * Trait AcceptsNullableDependencies
 *
 * https://github.com/laravel/framework/pull/22488
 *
 * @property Container $container
 * @mixin RouteDependencyResolverTrait
 */
trait AcceptsNullableDependencies
{
    /**
     * Resolve the given method's type-hinted dependencies.
     *
     * @param  array                      $parameters
     * @param  ReflectionFunctionAbstract $reflector
     * @return array
     */
    public function resolveMethodDependencies(array $parameters, ReflectionFunctionAbstract $reflector)
    {
        $instanceCount = 0;

        $values = array_values($parameters);

        foreach ($reflector->getParameters() as $key => $parameter) {
            $instance = $this->transformDependency(
                $parameter, $parameters
            );

            if ($instance !== '__UNDEFINED__') {
                ++$instanceCount;

                $this->spliceIntoParameters($parameters, $key, $instance);
            } elseif (!isset($values[$key - $instanceCount]) &&
                $parameter->isDefaultValueAvailable()) {
                $this->spliceIntoParameters($parameters, $key, $parameter->getDefaultValue());
            }
        }

        return $parameters;
    }

    /**
     * Attempt to transform the given parameter into a class instance.
     *
     * @param  ReflectionParameter $parameter
     * @param  array               $parameters
     * @return mixed
     */
    protected function transformDependency(ReflectionParameter $parameter, $parameters)
    {
        $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()
                ? $parameter->getDefaultValue()
                : $this->container->make($class->name);
        }

        return '__UNDEFINED__';
    }
}
```

もとの実装と違って，未解決を `'__UNDEFINED__'` という文字列で表現することで， `null` と区別できるようにしています。後はこれを `Route` と `ControllerDispatcher` に当てるだけ。

```diff:app/Services/Routing/Route.php
 <?php
 
 declare(strict_types=1);
 
 namespace App\Services\Routing;
 
+use App\Services\Routing\Concerns\AcceptsNullableDependencies;
 use Illuminate\Routing\Route as BaseRoute;
 
 class Route extends BaseRoute
 {
+    use AcceptsNullableDependencies;
 }
```

```diff:app/Services/Routing/ControllerDispatcher.php
 <?php
 
 declare(strict_types=1);
 
 namespace App\Services\Routing;
 
+use App\Services\Routing\Concerns\AcceptsNullableDependencies;
 use Illuminate\Routing\ControllerDispatcher as BaseControllerDispatcher;
 
 class ControllerDispatcher extends BaseControllerDispatcher
 {
+    use AcceptsNullableDependencies;
 }
```

これで記事で書かれた内容も動くようになります。