PHP Slim4の解読、ルートグループ編

Slim4のグループを深く掘り下げていきます。
先にルート編とミドルウェア編を読んでください。

group()メソッドを使うことで階層化出来る。

use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use Slim\Factory\AppFactory;
use Slim\Routing\RouteCollectorProxy;

$app = AppFactory::create();

$app->setBasePath('/slim-public');

$app->addRoutingMiddleware();
$app->addErrorMiddleware(true, true, true);

$app->group('/aaa',  function(RouteCollectorProxy $group){
    $group->group('/bbb', function(RouteCollectorProxy $group){
        $group->group('/ccc',  function(RouteCollectorProxy $group){
            $group->group('/ddd',  function(RouteCollectorProxy $group){

                $group->get('/eee',  function(Request $request, Response $response, array $arg){
                    $response->getBody()->write('Hello World');
                    return $response;
                });

            });
        });
    });
});

$app->run();

このプログラムで https://your-domain/slim-public/aaa/bbb/ccc/ddd/eee とアクセスすることが出来ます。
ルートがファイルだとすればグループはディレクトリのようなものになってます。

結果はHello Worldと表示される。

group()メソッドはRouteGroupInterface(実体はRouteGroup)インスタンスを返す。

use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use Slim\Factory\AppFactory;
use Slim\Routing\RouteCollectorProxy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

$app = AppFactory::create();

$app->setBasePath('/slim-public');

$app->addRoutingMiddleware();
$app->addErrorMiddleware(true, true, true);

$app->group('/aaa',  function(RouteCollectorProxy $group){
    $group->group('/bbb', function(RouteCollectorProxy $group){

        // group()の戻り値を取得
        $cRouteGroup = $group->group('/ccc',  function(RouteCollectorProxy $group){
            $group->group('/ddd',  function(RouteCollectorProxy $group){

                $route = $group->get('/eee',  function(Request $request, Response $response, array $arg){
                    $response->getBody()->write('Hello World');
                    return $response;
                });

            });
        });

        // RouteGroupのパターンを取得
        $groupPattern = $cRouteGroup->getPattern();

        // group()メソッドはRouteGroupインスタンスを返す。
        // このインスタンスにはミドルウェアを追加出来る。
        $cRouteGroup->add(function(ServerRequestInterface $request, RequestHandlerInterface $handler) use($groupPattern)
        {
            $response = $handler->handle($request);
            $response->getBody()->write("<br />I am ccc RouteGroup!<br />Pattern = $groupPattern");
            return $response;
        });

    });
});

$app->run();
Hello World
I am ccc RouteGroup!
Pattern = /aaa/bbb/ccc

RouteCollectorProxyのgroup()はグループをプッシュした後RouteGroupインスタンスを返します。
このインスタンスはミドルウェアを追加出来るようになってます。
$app->run()を実行すると、Appのミドルウェア、グループのミドルウェア(上から下へ)、Routeのミドルウェアの順で実行されていきます。

Routeは作成された時点でRouteGroupの先祖一覧を持っている

use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use Slim\Factory\AppFactory;
use Slim\Routing\Route;
use Slim\Routing\RouteCollectorProxy;

$app = AppFactory::create();

$app->setBasePath('/slim-public');

$app->addRoutingMiddleware();
$app->addErrorMiddleware(true, true, true);

$app->group('/aaa',  function(RouteCollectorProxy $group){
    $group->group('/bbb', function(RouteCollectorProxy $group){
        $group->group('/ccc',  function(RouteCollectorProxy $group){
            $group->group('/ddd',  function(RouteCollectorProxy $group){

                $route = $group->get('/eee',  function(Request $request, Response $response, array $arg){
                    $response->getBody()->write('Hello World');
                    return $response;
                });

                // PHPの構文上この段階でRouteGroupは取得できないが、
                // 再帰の設計上実は取得出来ないことは無い、ただおすすめはしない。

                // protectedなので実行できない
                //$group->getRouteCollector()->routeGroups;

                // 先祖のRouteGroupは実はルートに組み込まれている。

                /** @var Route $route */
                foreach($route->getGroups() as $routeGroup) echo $routeGroup->getPattern() . '<br />';

            });
        });
    });
});

$app->run();
/aaa
/aaa/bbb
/aaa/bbb/ccc
/aaa/bbb/ccc/ddd
Hello World

分かりやすく言うとこういうこと。

use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use Slim\Factory\AppFactory;
use Slim\Routing\Route;
use Slim\Routing\RouteCollectorProxy;

$app = AppFactory::create();

$app->setBasePath('/slim-public');

$app->addRoutingMiddleware();
$app->addErrorMiddleware(true, true, true);

$GLOBALS['ga'] = $app->group('/aaa',  function(RouteCollectorProxy $group){
    $GLOBALS['gb'] = $group->group('/bbb', function(RouteCollectorProxy $group){
        $GLOBALS['gc'] = $group->group('/ccc',  function(RouteCollectorProxy $group){
            $GLOBALS['gd'] =  $group->group('/ddd',  function(RouteCollectorProxy $group){

                $route = $group->get('/eee',  function(Request $request, Response $response, array $arg){
                    $response->getBody()->write('Hello World');
                    return $response;
                });

                /** @var Route $route */
                $GLOBALS['routeGroups'] = $route->getGroups();

            });
        });
    });
});

$ancestors = [$ga, $gb, $gc, $gd];

foreach($ancestors as $g) echo 'A:' . $g->getPattern() . '<br />';
foreach($routeGroups as $g) echo 'B:' . $g->getPattern() . '<br />';

$app->run();
A:/aaa
A:/aaa/bbb
A:/aaa/bbb/ccc
A:/aaa/bbb/ccc/ddd
B:/aaa
B:/aaa/bbb
B:/aaa/bbb/ccc
B:/aaa/bbb/ccc/ddd
Hello World

グループの仕組みは再帰処理

RouteCollectorクラスにはルートを追加するmap()メソッドとグループをプッシュするgroup()メソッドを持っています。
このメソッドは直接実行するよりはRouteCollectorProxyというプロキシ経由で呼び出します。

RouteCollectorProxy()にはget(), post(), put(), any()等のルートを追加するメソッドがありますが、
これらはプロキシが内部に持つRouteCollectorのmap()を呼び出しています。
RouteCollectorProxyのgroup()はRouteCollectorのgroup()を呼び出します。

AppはRouteCollectorProxyを継承しており、$app->get()$app->group()などと呼び出すことが出来ます。

map()メソッドの戻り値はRouteInterface(実体はRoute)で、group()メソッドの戻り値はRouteGroupInterface(実体はRouteGroup)です。

では本題のグループですが、グループはネスト出来ます。
まずはルーティング設定側のコードを見ます。

$app->group('/aaa',  function(RouteCollectorProxy $group){
    $group->group('/bbb', function(RouteCollectorProxy $group){
        $group->group('/ccc',  function(RouteCollectorProxy $group){
            $group->group('/ddd',  function(RouteCollectorProxy $group){

                $route = $group->get('/eee',  function(Request $request, Response $response, array $arg){
                    $response->getBody()->write('Hello World');
                    return $response;
                });

                /** @var Route $route */
                foreach($route->getGroups() as $routeGroup) echo $routeGroup->getPattern() . '<br />';

            });
        });
    });
});

そのネストの仕組みは再帰処理で実現されてます。
RouteCollectorのgroup()メソッドがこちらです。

public function group(string $pattern, $callable): RouteGroupInterface
{
    $routeCollectorProxy = new RouteCollectorProxy(
        $this->responseFactory,
        $this->callableResolver,
        $this->container,
        $this,
        $pattern
    );

    $routeGroup = new RouteGroup($pattern, $callable, $this->callableResolver, $routeCollectorProxy);

    $this->routeGroups[] = $routeGroup;
    $routeGroup->collectRoutes();
    array_pop($this->routeGroups);

    return $routeGroup;
}

$routeCollectorProxyRouteCollectorインスタンスのプロキシです。
プロキシは再帰の都度生成されていることが分かります。
プロキシが持つ対象はRouteCollectorProxyのコンストラクタの4番目の引数で、自分自身(RouteCollector)だと分かります。
つまりRouteCollectorインスタンスは$app以下の階層のプロキシで共通(Appインスタンス作成時に生成されるのみ)です。

$routeGroupは$app->group()の戻り値になるもので、RouteGroupInterface(実体はRouteGroup)のインスタンスです。

つまりコールバックに渡すプロキシとgroup()メソッドの戻り値を作成してます。

$group = $app->group('/aaa', function(RouteCollectorProxy $group);

$routeGroup = $app->group('/aaa', function(RouteCollectorProxy $routeCollectorProxy){ ... });

と読み替えることが出来ます。

再帰の部分です。

$this->routeGroups[] = $routeGroup;
$routeGroup->collectRoutes();
array_pop($this->routeGroups);

$routeGroup->collectRoutes();が再帰をしているところ、つまりルートコールバックを呼び出しているところです。
では$this->routeGroupsは何をやってるのかという話ですが、階層が下がるたびにpushし、上がるたびにpopされてます。
再帰処理に慣れた方なら階層の先祖をスタックに記録している場面だとわかります。

先ほどいったようにRouteCollectorのインスタンスは$app以下の階層で共通です。
このスタックが使用されるタイミングはルートが作成される時です。

$app->group('/aaa',  function(RouteCollectorProxy $group){
    $group->group('/bbb', function(RouteCollectorProxy $group){
        $group->group('/ccc',  function(RouteCollectorProxy $group){
            $group->group('/ddd',  function(RouteCollectorProxy $group){

                // new Route(...); とRouteインスタンス作成の際このスタックがRouteコンストラクタに渡されます。
                $route = $group->get('/eee',  function(Request $request, Response $response, array $arg){
                    $response->getBody()->write('Hello World');
                    return $response;
                });

                /** @var Route $route */
                foreach($route->getGroups() as $routeGroup) echo $routeGroup->getPattern() . '<br />';

            });
        });
    });
});

\$app->get()などルート作成系のメソッドが実行されたとき、Routeインスタンスは作成されます。
Routeの第八引数にその段階のスタックが渡されます。
取得はgetGroups()で出来ます(インスタンスがRoute型だと保障出来れば)。

ネストされたグループとミドルウェア

まず大方のルーティングの動きを見ていきます。

use Psr\Http\Message\RequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Factory\AppFactory;
use Slim\Routing\Route;
use Slim\Routing\RouteCollectorProxy;

$app = AppFactory::create();

$app->setBasePath('/slim-public');

$app->addRoutingMiddleware();
$app->addErrorMiddleware(true, true, true);

/** @return MiddlewareInterface */
function createMiddleware(string $message)
{
    return new class($message) implements MiddlewareInterface
    {
        public function __construct(private string $message) { }

        public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): Response
        {
            echo "($this->message)";
            $response = $handler->handle($request);
            echo "($this->message)";
            return $response;
        }
    };
}

$app->add(createMiddleware('App-A'))->add(createMiddleware('App-B'))->add(createMiddleware('App-C'));

$app->group('/aaa',  function(RouteCollectorProxy $group){
    $group->group('/bbb', function(RouteCollectorProxy $group){
        $group->group('/ccc',  function(RouteCollectorProxy $group){
            $group->group('/ddd',  function(RouteCollectorProxy $group){

                $group->get('/eee',  function(Request $request, Response $response, array $arg){
                    echo "<br />Hello World<br />";
                    return $response;
                })->add(createMiddleware('Route-A'))->add(createMiddleware('Route-B'))->add(createMiddleware('Route-C'));

            })->add(createMiddleware('DDD-A'))->add(createMiddleware('DDD-B'))->add(createMiddleware('DDD-C'));
        })->add(createMiddleware('CCC-A'))->add(createMiddleware('CCC-B'))->add(createMiddleware('CCC-C'));
    })->add(createMiddleware('BBB-A'))->add(createMiddleware('BBB-B'))->add(createMiddleware('BBB-C'));
})->add(createMiddleware('AAA-A'))->add(createMiddleware('AAA-B'))->add(createMiddleware('AAA-C'));

$app->run();

結果

(App-C)(App-B)(App-A)(AAA-C)(AAA-B)(AAA-A)(BBB-C)(BBB-B)(BBB-A)(CCC-C)(CCC-B)(CCC-A)(DDD-C)(DDD-B)(DDD-A)(Route-C)(Route-B)(Route-A)
Hello World
(Route-A)(Route-B)(Route-C)(DDD-A)(DDD-B)(DDD-C)(CCC-A)(CCC-B)(CCC-C)(BBB-A)(BBB-B)(BBB-C)(AAA-A)(AAA-B)(AAA-C)(App-A)(App-B)(App-C)

App及びRouteには独自のMiddlewareDispacherインスタンスがあります。
RouteCollectorProxyのgroup()メソッドはRouteGroupInterface(実体はRouteGroup)を返します。

RouteGroupはミドルウェアを追加出来るようになっています。
内部では配列でミドルウェアを持っているものの、取得は出来ないようになってます。

グループのミドルウェアのみを抽出

use Psr\Http\Message\RequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Factory\AppFactory;
use Slim\Factory\ServerRequestCreatorFactory;
use Slim\MiddlewareDispatcher;
use Slim\ResponseEmitter;
use Slim\Routing\Route;
use Slim\Routing\RouteCollectorProxy;

$app = AppFactory::create();

$app->setBasePath('/slim-public');

$app->addRoutingMiddleware();
$app->addErrorMiddleware(true, true, true);

/** @return MiddlewareInterface */
function createMiddleware(string $message)
{
    return new class($message) implements MiddlewareInterface
    {
        public function __construct(private string $message) { }

        public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): Response
        {
            echo "($this->message)";
            $response = $handler->handle($request);
            echo "($this->message)";
            return $response;
        }
    };
}

$app->add(createMiddleware('App-A'))->add(createMiddleware('App-B'))->add(createMiddleware('App-C'));

$app->group('/aaa',  function(RouteCollectorProxy $group){
    $group->group('/bbb', function(RouteCollectorProxy $group){
        $group->group('/ccc',  function(RouteCollectorProxy $group){
            $group->group('/ddd',  function(RouteCollectorProxy $group){

                // このルートはグローバルで使用します。
                global $route;

                $route = $group->get('/eee',  function(Request $request, Response $response, array $arg){
                    echo "<br />Hello World<br />";
                    return $response;
                })->add(createMiddleware('Route-A'))->add(createMiddleware('Route-B'))->add(createMiddleware('Route-C'));

            })->add(createMiddleware('DDD-A'))->add(createMiddleware('DDD-B'))->add(createMiddleware('DDD-C'));
        })->add(createMiddleware('CCC-A'))->add(createMiddleware('CCC-B'))->add(createMiddleware('CCC-C'));
    })->add(createMiddleware('BBB-A'))->add(createMiddleware('BBB-B'))->add(createMiddleware('BBB-C'));
})->add(createMiddleware('AAA-A'))->add(createMiddleware('AAA-B'))->add(createMiddleware('AAA-C'));

// 自作のMiddlewareDispacherを作る
$middlewareDispacher = new MiddlewareDispatcher(
    new class implements RequestHandlerInterface
    {
        function handle(ServerRequestInterface $request): Response
        {
            global $app;
            echo "<br />Hello Group Middlewares!<br />";
            return $app->getResponseFactory()->createResponse();
        }
    }
);

// ターゲットのRouteからグループを取り出し、各グループのミドルウェアの配列をMiddlewareDispacherに追加する
/** @var Route $route */
foreach($route->getGroups() as $group)
{
    // $group->middlewareはミドルウェアの配列ですが、protectedなため直接アクセスできません。
    // このメソッドでMiddlewareDispacherにミドルウェアを追加してもらいます。
    $group->appendMiddlewareToDispatcher($middlewareDispacher);
}

// リクエストの作成
$request = ServerRequestCreatorFactory::create()->createServerRequestFromGlobals();

// ミドルウェアを実行
$response = $middlewareDispacher->handle($request);

// レスポンスの出力(ただし今回はechoで直接出力しているので意味はありません。)
(new ResponseEmitter())->emit($response);

当然ですがこのコードではApp及びRoute側のミドルウェアは処理されていません。
appendMiddlewareToDispatcher()はこのようになってます。

public function appendMiddlewareToDispatcher(MiddlewareDispatcher $dispatcher): RouteGroupInterface
{
    foreach ($this->middleware as $middleware) {
        $dispatcher->add($middleware);
    }

    return $this;
}

Routeのrun()は自身のMiddlewareDispatcherの実行時にこのような感じでグループのミドルウェアを実行してます。

protected function appendGroupMiddlewareToRoute(): void
{
    $inner = $this->middlewareDispatcher;
    $this->middlewareDispatcher = new MiddlewareDispatcher($inner, $this->callableResolver, $this->container);

    /** @var RouteGroupInterface $group */
    foreach (array_reverse($this->groups) as $group) {
        $group->appendMiddlewareToDispatcher($this->middlewareDispatcher);
    }

    $this->groupMiddlewareAppended = true;
}

自身のMiddlewareDispatcherを$kernelにして、先祖グループを逆から追加しています。
実行時は浅い層から深い層へ向かってミドルウェアは実行されるようになります。

certificate Docker Gutenberg Hyper-V openssl PHP React ReduxToolkit REST ubuntu WordPress オレオレ認証局 フレームワーク