Closed12

[php] Slim4 とその周辺についてのコードリーディング

snowindysnowindy

Prerequisite

PHPでのAPIサーバー実装チョットワカル人向け。

Overview

Slim, a micro framework for PHP

https://www.slimframework.com/
https://github.com/slimphp/Slim

slimphp では、Slim 以外にも Slim-Psr7 など周辺のコンポーネントの実装も行われている。
Middleware (PSR-15) は独自実装、
HTTP Message (PSR-7)Slim-Psr7,
Dependency InjectionPHP-DI,
Routernikic/FastRoute
をデフォルトとしている。

snowindysnowindy

Lifecycle

Slim さん優しい。とりあえずこれを見るべし。
https://www.slimframework.com/docs/v4/concepts/life-cycle.html
曰く、Slim のライフサイクルは

  1. Slim App のインスタンス化。$app = new App();
    a. (追記)DI Container の set などもここで行う
  2. Route の登録。$app->get('hello', Hello::class);
    a. (追記)実際はここで $app->add($middleware); などして、Middlewareを追加する
  3. 実行。 $app->run()
    a. Middleware を stack に積む。tip (先端) から実行(後述)
    b. Dispatch された Route の API Handler を実行する
    c. Middleware の stack を遡っていく。ここはコード見た方が理解が早いので後述
    d. Response の送信。

という流れである。それはそうなのだが 3 あたりで急に情報の密度が上がり置いていかれるので順に説明する。

snowindysnowindy

1_new App()

PSR-17: HTTP Factories 準拠のため、 AppFactory::create() でApp が作成される。App とは Slim Framework の User Interface と思ってもらえれば。
Slim の特徴として、コンストラクタで周辺コンポーネントオブジェクトの初期化を行っているので、__construct() までしっかり読む必要あり。
余談だが、Slim ユーザーとしては Slim の実装でも DI を使いたいものだと思った。
AppFactory::create(), App::__construct() あたりを見ると、Slim がどんな周辺とでも接続(Glue)できるように、GlueCode がたくさんあることがわかる。

AppFactory.php
    public static function create(
        ?ResponseFactoryInterface $responseFactory = null,
        ?ContainerInterface $container = null,
        ?CallableResolverInterface $callableResolver = null,
        ?RouteCollectorInterface $routeCollector = null,
        ?RouteResolverInterface $routeResolver = null,
        ?MiddlewareDispatcherInterface $middlewareDispatcher = null
    ): App {
        static::$responseFactory = $responseFactory ?? static::$responseFactory;
        return new App(
            self::determineResponseFactory(),
            $container ?? static::$container,
            $callableResolver ?? static::$callableResolver,
            $routeCollector ?? static::$routeCollector,
            $routeResolver ?? static::$routeResolver,
            $middlewareDispatcher ?? static::$middlewareDispatcher
        );
    }
App.php
    public function __construct(
        ResponseFactoryInterface $responseFactory,
        ?ContainerInterface $container = null,
        ?CallableResolverInterface $callableResolver = null,
        ?RouteCollectorInterface $routeCollector = null,
        ?RouteResolverInterface $routeResolver = null,
        ?MiddlewareDispatcherInterface $middlewareDispatcher = null
    ) {
        parent::__construct(
            $responseFactory,
            $callableResolver ?? new CallableResolver($container),
            $container,
            $routeCollector
        );

        $this->routeResolver = $routeResolver ?? new RouteResolver($this->routeCollector);
        $routeRunner = new RouteRunner($this->routeResolver, $this->routeCollector->getRouteParser(), $this);

        if (!$middlewareDispatcher) {
            $middlewareDispatcher = new MiddlewareDispatcher($routeRunner, $this->callableResolver, $container);
        } else {
            $middlewareDispatcher->seedMiddlewareStack($routeRunner);
        }

        $this->middlewareDispatcher = $middlewareDispatcher;
    }

上記のように、Container から Middleware まで、依存関係を自由自在に選べるようになっている。
使用者側からすると組み込み易く有難いと思う反面、コードをややこしくしている原因でもある。

AppFactory::setContainer($container); などすると、それが App 構築時のロジックに反映されていく。

snowindysnowindy

2_Register route

ルーティングの登録。
Slim でどのように登録できるかは以下で把握しておいてください。
https://www.slimframework.com/docs/v4/objects/routing.html

$app->post('/books', Book::class); (第二引数は callable であればなんでも良い)
こういった登録はもちろん、

$app->group('/users/{id:[0-9]+}', function (RouteCollectorProxy $group) {
    $group->get('/reset-password', ResetPassword::class);
    $group->get('/verify-email', VerifyEmail::class);
    ...

グループ化もできる。
これは App それ自身も RouteCollectorProxy であり、それらは配下に RouteGroup を付けられるからである。

App.php
class App extends RouteCollectorProxy implements RequestHandlerInterface
RouteCollectorProxy.php
    public function group(string $pattern, $callable): RouteGroupInterface
    {
        $pattern = $this->groupPattern . $pattern;

        return $this->routeCollector->group($pattern, $callable);
    }
RouteCollector.php
    public function group(string $pattern, $callable): RouteGroupInterface
    {
        $routeGroup = $this->createGroup($pattern, $callable);
        $this->routeGroups[] = $routeGroup;

        $routeGroup->collectRoutes();
        array_pop($this->routeGroups);

        return $routeGroup;
    }

Middleware の追加もここで行う。
Middleware 自体の説明は 3a で行うので先にそちらを見ても良い。
App にも、RouteGroup にも、Route にも追加できる。

$app->group('/foo', function (RouteCollectorProxy $group) {
    $group->get('/bar', $callable)
        ->add(new RouteMiddleware());
})->add(new GroupMiddleware());
$app->add(new AppMiddleware());

Middleware は何にせよ、App のプロパティ MiddlewareDispatcher によって収集される。
(以下は App での実装。 RouteGroup 等でも最終的に $this->middlewareDispatcher->add($middleware); している)

App.php
    public function add($middleware): self
    {
        $this->middlewareDispatcher->add($middleware);
        return $this;
    }

Middleware についても、callable であればなんでも良い。Slim は懐が深い。

MiddlewareDispatcher.php
    public function add($middleware): MiddlewareDispatcherInterface
    {
        if ($middleware instanceof MiddlewareInterface) {
            return $this->addMiddleware($middleware);
        }

        if (is_string($middleware)) {
            return $this->addDeferred($middleware);
        }

        if (is_callable($middleware)) {
            return $this->addCallable($middleware);
        }
...


この後、3bで Route() について扱うのでここで触れておく。

new Route()されるタイミングは、$app->post('hello', Hello::class); などを実行したタイミング。App extends RouteCollectorProxy なので、App::postは以下となる。

RouteCollectorProxy.php
    public function post(string $pattern, $callable): RouteInterface
    {
        return $this->map(['POST'], $pattern, $callable);
    }

$this->map() は内部で $this->routeCollector->map()を叩く。それが以下。

RouteCollector.php
    public function map(array $methods, string $pattern, $handler): RouteInterface
    {
        $route = $this->createRoute($methods, $pattern, $handler);
        $this->routes[$route->getIdentifier()] = $route;

        $routeName = $route->getName();
        if ($routeName !== null && !isset($this->routesByName[$routeName])) {
            $this->routesByName[$routeName] = $route;
        }

        $this->routeCounter++;

        return $route;
    }

    /**
     * @param string[] $methods
     * @param callable|array{class-string, string}|string $callable
     */
    protected function createRoute(array $methods, string $pattern, $callable): RouteInterface
    {
        return new Route(
            $methods,
            $pattern,
            $callable,
            $this->responseFactory,
            $this->callableResolver,
            $this->container,
            $this->defaultInvocationStrategy,
            $this->routeGroups,
            $this->routeCounter
        );
    }

ここで new Route() してRouteCollectorが保管している。

snowindysnowindy

3_App::run(); Create ServerRequest

実際はライフサイクルはもう少し細かい。
$app->run() 内で、まずは PSR-7: HTTP message interfaces に準拠して ServerRequestInterface(サーバーがハンドリングする用の HTTP Request Object)が作られる。

余談:この辺りも Glue Code によって非常に読みづらくなっている

App.php
    public function run(?ServerRequestInterface $request = null): void
    {
        if (!$request) {
            $serverRequestCreator = ServerRequestCreatorFactory::create();
            $request = $serverRequestCreator->createServerRequestFromGlobals();
        }

        $response = $this->handle($request);
        $responseEmitter = new ResponseEmitter();
        $responseEmitter->emit($response);
    }

デフォルトの Slim-Psr7 の実装であれば、最終的に ServerRequestFactory::createFromGlobals() にて Slim\Psr7\Request implements ServerRequestInterface オブジェクトが作成される。

ちなみに、デフォルトのSlim-Psr7実装で使用されるクラス・メソッドは以下。参照されたし

SlimPsr17Factory.php
class SlimPsr17Factory extends Psr17Factory
{
    protected static string $responseFactoryClass = 'Slim\Psr7\Factory\ResponseFactory';
    protected static string $streamFactoryClass = 'Slim\Psr7\Factory\StreamFactory';
    protected static string $serverRequestCreatorClass = 'Slim\Psr7\Factory\ServerRequestFactory';
    protected static string $serverRequestCreatorMethod = 'createFromGlobals';
}

個人的にこの辺りは低レイヤな HTTP 実装が多く、勉強になった

ServerRequestFactory.php
    public static function createFromGlobals(): Request
    {
        /** @var string $method */
        $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
        $uri = (new UriFactory())->createFromGlobals($_SERVER);

        $headers = Headers::createFromGlobals();
        $cookies = Cookies::parseHeader($headers->getHeader('Cookie', []));

        // Cache the php://input stream as it cannot be re-read
        $cacheResource = fopen('php://temp', 'wb+');
        $cache = $cacheResource ? new Stream($cacheResource) : null;

        $body = (new StreamFactory())->createStreamFromFile('php://input', 'r', $cache);
        $uploadedFiles = UploadedFile::createFromGlobals($_SERVER);

        $request = new Request($method, $uri, $headers, $cookies, $_SERVER, $body, $uploadedFiles);
        $contentTypes = $request->getHeader('Content-Type');

        $parsedContentType = '';
        foreach ($contentTypes as $contentType) {
            $fragments = explode(';', $contentType);
            $parsedContentType = current($fragments);
        }

        $contentTypesWithParsedBodies = ['application/x-www-form-urlencoded', 'multipart/form-data'];
        if ($method === 'POST' && in_array($parsedContentType, $contentTypesWithParsedBodies)) {
            return $request->withParsedBody($_POST);
        }

        return $request;
    }
snowindysnowindy

3a_Middleware

ここまできてようやく Middleware の話ができる。
Middleware は PSR-15: HTTP Server Request Handlers 準拠の実装であり、以下のドキュメントが非常にわかり易くまとまっている。
https://www.slimframework.com/docs/v4/concepts/middleware.html

この同心円の実装は先ほども少し登場した MiddlewareDispatcher 周りのコードを追っていくとよくわかる。

まず、$app->run() 内で、Request が作られた後に $this->middlewareDispatcher->handle() が呼び出される。

MiddlewareDispatcher.php
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        return $this->tip->handle($request);
    }

これだけ見ると ? なのだが、tip とは Middleware stack の "先端" ということだ。それを念頭に addMiddleware を見ていただくと良い。

MiddlewareDispatcher.php
    public function __construct(
        RequestHandlerInterface $kernel,
        ?CallableResolverInterface $callableResolver = null,
        ?ContainerInterface $container = null
    ) {
        $this->seedMiddlewareStack($kernel);
        $this->callableResolver = $callableResolver;
        $this->container = $container;
    }

    public function seedMiddlewareStack(RequestHandlerInterface $kernel): void
    {
        $this->tip = $kernel;
    }

    public function addMiddleware(MiddlewareInterface $middleware): MiddlewareDispatcherInterface
    {
        $next = $this->tip;
        $this->tip = new class ($middleware, $next) implements RequestHandlerInterface {
            private MiddlewareInterface $middleware;

            private RequestHandlerInterface $next;

            public function __construct(MiddlewareInterface $middleware, RequestHandlerInterface $next)
            {
                $this->middleware = $middleware;
                $this->next = $next;
            }

            public function handle(ServerRequestInterface $request): ResponseInterface
            {
                return $this->middleware->process($request, $this->next);
            }
        };

        return $this;
    }

$kernel をスタックの一つ目の要素として、一番最後に追加(add($middleware))された Middleware が $tip に、その一つ前に追加された Middleware が $next に入れられていることがわかる。

Middleware は クラスの場合一般的に以下のような実装になっている。

ContentLengthMiddleware.php
class ContentLengthMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request);

        // Add Content-Length header if not already added
        $size = $response->getBody()->getSize();
        if ($size !== null && !$response->hasHeader('Content-Length')) {
            $response = $response->withHeader('Content-Length', (string) $size);
        }

        return $response;
    }
}

上記のコード群より、

  1. $this->tip->handle(); が呼び出されると、
  2. $this->middleware->process($request, $this->next); が呼び出される。
  3. process 内では $handler->handle()、つまり $this->next->handle(); が呼び出されるので、
  4. 次の Middleware に渡される、

という仕組みとなる。これが Middleware の数だけ繰り返され、$this->next$kernel までたどり着くと、関数のコールスタックを辿ってまた戻ってくる。
この流れが同心円状のアーキテクチャとして例示されているのだ。

では MiddlewareDispatcher::__construct() の第一引数である $kernel はどこからきたのか?というと、new App() 内の new RouteRunner() である。 ここから

3b. Dispatch された Route の API Handler を実行する

へ移行するのだ。

App.php
    public function __construct(
        ResponseFactoryInterface $responseFactory,
        ?ContainerInterface $container = null,
        ?CallableResolverInterface $callableResolver = null,
        ?RouteCollectorInterface $routeCollector = null,
        ?RouteResolverInterface $routeResolver = null,
        ?MiddlewareDispatcherInterface $middlewareDispatcher = null
    ) {
        parent::__construct(
            $responseFactory,
            $callableResolver ?? new CallableResolver($container),
            $container,
            $routeCollector
        );

        $this->routeResolver = $routeResolver ?? new RouteResolver($this->routeCollector);
        $routeRunner = new RouteRunner($this->routeResolver, $this->routeCollector->getRouteParser(), $this);

        if (!$middlewareDispatcher) {
            $middlewareDispatcher = new MiddlewareDispatcher($routeRunner, $this->callableResolver, $container);
        } else {
            $middlewareDispatcher->seedMiddlewareStack($routeRunner);
        }

        $this->middlewareDispatcher = $middlewareDispatcher;
    }
snowindysnowindy

3b_RouteRunner

new RouteCollector されるまでにも結構色々なオブジェクトがnew されるので以下に収集。
null だったら new、というように書かれているが、基本外部から依存関係を入れない限りはそのまま new されると思っておいて良い。

$this->routeCollector = $routeCollector ?? new RouteCollector($responseFactory, $callableResolver, $container);
$this->routeParser = $routeParser ?? new RouteParser($this);
$this->routeResolver = $routeResolver ?? new RouteResolver($this->routeCollector);
$routeRunner = new RouteRunner($this->routeResolver, $this->routeCollector->getRouteParser(), $this);

$kernel->handle() の実装を見る。

RouteRunner.php
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        // If routing hasn't been done, then do it now so we can dispatch
        if ($request->getAttribute(RouteContext::ROUTING_RESULTS) === null) {
            $routingMiddleware = new RoutingMiddleware($this->routeResolver, $this->routeParser);
            $request = $routingMiddleware->performRouting($request);
        }

        if ($this->routeCollectorProxy !== null) {
            $request = $request->withAttribute(
                RouteContext::BASE_PATH,
                $this->routeCollectorProxy->getBasePath()
            );
        }

        /** @var Route<\Psr\Container\ContainerInterface|null> $route */
        $route = $request->getAttribute(RouteContext::ROUTE);
        return $route->run($request);
    }

やってることは、

  1. $routingMiddleware->performRouting(); でルーティング
  2. 決定した route に対して、`route->run();`

だけどここも複雑なんだよね
次の章から説明します。

snowindysnowindy

3b-1_performRouting()

RoutingMiddleware.php
    public function performRouting(ServerRequestInterface $request): ServerRequestInterface
    {
        $request = $request->withAttribute(RouteContext::ROUTE_PARSER, $this->routeParser);

        $routingResults = $this->resolveRoutingResultsFromRequest($request);
        ...()
    }

    /**
     * Resolves the route from the given request
     */
    protected function resolveRoutingResultsFromRequest(ServerRequestInterface $request): RoutingResults
    {
        return $this->routeResolver->computeRoutingResults(
            $request->getUri()->getPath(),
            $request->getMethod()
        );
    }
RouteResolver.php
    public function computeRoutingResults(string $uri, string $method): RoutingResults
    {
        $uri = rawurldecode($uri);
        if ($uri === '' || $uri[0] !== '/') {
            $uri = '/' . $uri;
        }
        return $this->dispatcher->dispatch($method, $uri);
    }
Dispatcher.php
    public function dispatch(string $method, string $uri): RoutingResults
    {
        $dispatcher = $this->createDispatcher();
        $results = $dispatcher->dispatch($method, $uri);
        return new RoutingResults($this, $method, $uri, $results[0], $results[1], $results[2]);
    }

これ以下はデフォルトで nikic/FastRoute が使用される。ここだけで記事一つ書けてしまうので、以下の記事を紹介するに留める。

Blog post explaining how the implementation works and why it is fast.

とりあえず、$results の中身はこうなっている
[FastRoute\Dispatcher::FOUND, 'handler0', ['name' => 'nikic', 'id' => '42']]
見つかったかどうか、どのhandlerを叩けばいいか、プレースホルダーで捕捉された変数は何か。
ここまできたらこの handler を引数付きで叩くだけですね!
$routingResults = $this->resolveRoutingResultsFromRequest($request);RouteResults が帰ってくるので、その後を見ましょう。

RoutingMiddleware.php
    public function performRouting(ServerRequestInterface $request): ServerRequestInterface
    {
        $request = $request->withAttribute(RouteContext::ROUTE_PARSER, $this->routeParser);

        $routingResults = $this->resolveRoutingResultsFromRequest($request);
        $routeStatus = $routingResults->getRouteStatus();

        $request = $request->withAttribute(RouteContext::ROUTING_RESULTS, $routingResults);

        switch ($routeStatus) {
            case RoutingResults::FOUND:
                $routeArguments = $routingResults->getRouteArguments();
                $routeIdentifier = $routingResults->getRouteIdentifier() ?? '';
                $route = $this->routeResolver
                    ->resolveRoute($routeIdentifier)
                    ->prepare($routeArguments);
                return $request->withAttribute(RouteContext::ROUTE, $route);

            case RoutingResults::NOT_FOUND:
                throw new HttpNotFoundException($request);

            case RoutingResults::METHOD_NOT_ALLOWED:
                $exception = new HttpMethodNotAllowedException($request);
                $exception->setAllowedMethods($routingResults->getAllowedMethods());
                throw $exception;

            default:
                throw new RuntimeException('An unexpected error occurred while performing routing.');
        }
    }

Found の場合は、RouteCollectorからRouteResolverが選び取ったRoute()(2で触れましたね)に 引数がくっついてAttributeに生やす感じですね。

その後、

  1. 決定した route に対して、`route->run();`

これに進みます。

snowindysnowindy

3b-2_route->run()

Route.php
    public function run(ServerRequestInterface $request): ResponseInterface
    {
        if (!$this->groupMiddlewareAppended) {
            $this->appendGroupMiddlewareToRoute();
        }

        return $this->middlewareDispatcher->handle($request);
    }

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

        foreach (array_reverse($this->groups) as $group) {
            $group->appendMiddlewareToDispatcher($this->middlewareDispatcher);
        }

        $this->groupMiddlewareAppended = true;
    }

勘の鋭い方はここで気づくかもですが、また$this->middlewareDispatcher->handle()されてますね。
別にMiddlewareDispatcherはシングルトンではないのがミソで、Route::__construct()でnewされたものである。

Route.php
$this->middlewareDispatcher = new MiddlewareDispatcher($this, $callableResolver, $container);

つまり、全体像を俯瞰すると、App()全体の$kernelであるRouteRunner()の中で、Route()がさらに$kernelとなり、RouteやRouteGroup専用のMiddlewareが先に$this->tip->handle()されて〜という、3-aで話した流れに帰着するというわけだ。こうしないと入れ子構造つくれないよね。うーんよくできている。

よって、$this->middlewareDispatcher->handle() 内のコールスタックの終着点は Route::handle()であることはお分かりだと思うのでそちらを見る。

Route.php
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        if ($this->callableResolver instanceof AdvancedCallableResolverInterface) {
            $callable = $this->callableResolver->resolveRoute($this->callable);
        } else {
            $callable = $this->callableResolver->resolve($this->callable);
        }
        $strategy = $this->invocationStrategy;

        $strategyImplements = class_implements($strategy);

        if (
            is_array($callable)
            && $callable[0] instanceof RequestHandlerInterface
            && !in_array(RequestHandlerInvocationStrategyInterface::class, $strategyImplements)
        ) {
            $strategy = new RequestHandler();
        }

        $response = $this->responseFactory->createResponse();
        return $strategy($callable, $request, $response, $this->arguments);
    }

ついに、Responseという文字が...。

こちらも順を追って説明する。

resolveRoute() の中身

この段落は本筋から逸れるので見なくても良い。

final class CallableResolver implements AdvancedCallableResolverInterface なので、$this->callableResolver->resolveRoute($this->callable); を見れば良い。中身は、$this->resolveByPredicate($this->callable, [$this->callableResolver, 'isRoute'], 'handle'); で、さらにその先は

CallableResolver.php
    private function resolveByPredicate($toResolve, callable $predicate, string $defaultMethod): callable
    {
        $toResolve = $this->prepareToResolve($toResolve);
        if (is_callable($toResolve)) {
            return $this->bindToContainer($toResolve);
        }
        $resolved = $toResolve;
        if ($predicate($toResolve)) {
            $resolved = [$toResolve, $defaultMethod];
        }
        if (is_string($toResolve)) {
            [$instance, $method] = $this->resolveSlimNotation($toResolve);
            if ($method === null && $predicate($instance)) {
                $method = $defaultMethod;
            }
            $resolved = [$instance, $method ?? '__invoke'];
        }
        $callable = $this->assertCallable($resolved, $toResolve);
        return $this->bindToContainer($callable);
    }

こんな感じで、まあ $callable を本当に callable な形にして(ややこしい)、coutainer に bindTo している。$this->get('hoge') とかを handler closure 内で行ったときに、$thisがcontainerを指したいがために、Slim3以前で主に使っていたからのようだ。ふーん。
ex.

$app->get('/hello/{name}', function ($request, $response, $args) {
    $service = $this->get('MyService');
    return $response->write($service->sayHello($args['name']));
});

strategy

ここで、strategyは Route ハンドラをどのような形で呼ぶかを決めている。

  1. $handler($request, $response, $routeArgs) → 初期の $this->invocationStrategy := new RequestResponse()
  2. $handler->handle($request)new RequestHandler()

後者の方が PSR-15 準拠なので、順当な実装であれば後者となるだろう。
それぞれのstrategyの中身も easy-to-understand なのでここに貼り付けておく。

RequestResponse.php
    public function __invoke(
        callable $callable,
        ServerRequestInterface $request,
        ResponseInterface $response,
        array $routeArguments
    ): ResponseInterface {
        foreach ($routeArguments as $k => $v) {
            $request = $request->withAttribute($k, $v);
        }

        /** @var ResponseInterface */
        return $callable($request, $response, $routeArguments);
    }
RequestHandler.php
    public function __invoke(
        callable $callable,
        ServerRequestInterface $request,
        ResponseInterface $response,
        array $routeArguments
    ): ResponseInterface {
        if ($this->appendRouteArgumentsToRequestAttributes) {
            foreach ($routeArguments as $k => $v) {
                $request = $request->withAttribute($k, $v);
            }
        }

        /** @var ResponseInterface */
        return $callable($request);
    }

Strategyが決まったら、さっくりとResponseの雛形を作って、

ResponseFactory.php
class ResponseFactory implements ResponseFactoryInterface
{
    /**
     * {@inheritdoc}
     */
    public function createResponse(
        int $code = StatusCodeInterface::STATUS_OK,
        string $reasonPhrase = ''
    ): ResponseInterface {
        $res = new Response($code);

        if ($reasonPhrase !== '') {
            $res = $res->withStatus($code, $reasonPhrase);
        }

        return $res;
    }
}

route handler が実行される!

Route.php
return $strategy($callable, $request, $response, $this->arguments);

いやーーーーー。長かった。
ここまでの流れが、ユーザーからすると AppFactory::create(), $app->post(), $app->add(), $app->run()` のみに隠蔽されているのはおぞましいですね。
もうエンディングに突入したい気分をグッとこらえて、Responseへの道を進みましょうか。

snowindysnowindy

3c_Reverse Middleware stack

ここまで完全に理解している方は飛ばしていただいていいと思います。

3aで言っていたことをそのまま書きます。

ContentLengthMiddleware.php
class ContentLengthMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request);

        // Add Content-Length header if not already added
        $size = $response->getBody()->getSize();
        if ($size !== null && !$response->hasHeader('Content-Length')) {
            $response = $response->withHeader('Content-Length', (string) $size);
        }

        return $response;
    }
}

$this->tip->handle(); が呼び出されると、
$this->middleware->process($request, $this->next); が呼び出される。
process 内では $handler->handle()、つまり $this->next->handle(); が呼び出されるので、
次の Middleware に渡される、
という仕組みとなる。これが Middleware の数だけ繰り返され、$this->next が $kernel までたどり着くと、関数のコールスタックを辿ってまた戻ってくる。
この流れが同心円状のアーキテクチャとして例示されているのだ。

あえて冗長に流れを可視化すると

1. App()->run()(App()->handle())
    2. middleware2→process()
        3.  middleware1→process()
            4.  kernel->handle()
        5.  return
    6. return
7. return
App.php
    public function run(?ServerRequestInterface $request = null): void
    {
        if (!$request) {
            $serverRequestCreator = ServerRequestCreatorFactory::create();
            $request = $serverRequestCreator->createServerRequestFromGlobals();
        }

        $response = $this->handle($request);
        $responseEmitter = new ResponseEmitter();
        $responseEmitter->emit($response);
    }

とすげー久しぶりにApp()->run() まで帰ってきたということだ。以上

snowindysnowindy

3d_emit()

ということで、App()->run();まで帰ってきました!めでたい!
App()->run()を再掲しておく。

App.php
    public function run(?ServerRequestInterface $request = null): void
    {
        if (!$request) {
            $serverRequestCreator = ServerRequestCreatorFactory::create();
            $request = $serverRequestCreator->createServerRequestFromGlobals();
        }

        $response = $this->handle($request);
        $responseEmitter = new ResponseEmitter();
        $responseEmitter->emit($response);
    }

App()->handle()から帰ってきた$responseをemitすれば長い旅路は終わりを告げる。

ResponseEmitter.php
    public function emit(ResponseInterface $response): void
    {
        $isEmpty = $this->isResponseEmpty($response);
        if (headers_sent() === false) {
            $this->emitHeaders($response);

            // Set the status _after_ the headers, because of PHP's "helpful" behavior with location headers.
            // See https://github.com/slimphp/Slim/issues/1730

            $this->emitStatusLine($response);
        }

        if (!$isEmpty) {
            $this->emitBody($response);
        }
    }

ここはHTTP通信やファイル操作が絡むので、低レイヤなコードになっている。

ResponseEmitter.php
    public function isResponseEmpty(ResponseInterface $response): bool
    {
        if (in_array($response->getStatusCode(), [204, 205, 304], true)) {
            return true;
        }
        $stream = $response->getBody();
        $seekable = $stream->isSeekable();
        if ($seekable) {
            $stream->rewind();
        }
        return $seekable ? $stream->read(1) === '' : $stream->eof();
    }

HTTP Status Code が

  • 204 No Content
  • 205 Reset Content
  • 304 Not Modified
    なら空のbodyとみなす。
    もしくは、StreamInterface である $response->getBody() が空であればbodyは空である。

Response Body について

ここで補足。
レスポンスボディはSlim/Psr7 実装を使用している場合、Slim\Psr7\Stream が使用される。
実態はphpがメモリ上に用意する一時ファイルである。 cf. https://qiita.com/mpyw/items/f24d3764fe3eedf132ff

/** class Slim\Psr7\Response */
    $this->body = $body ?: (new StreamFactory())->createStream();

/** class Slim\Psr7\Factory\StreamFactory */
    public function createStream(string $content = ''): StreamInterface
    {
        $resource = fopen('php://temp', 'rw+');

        if (!is_resource($resource)) {
            throw new RuntimeException('StreamFactory::createStream() could not open temporary file stream.');
        }

        fwrite($resource, $content);
        rewind($resource);

        return $this->createStreamFromResource($resource);
    }

fopen / fwrite はなじみ深いかもしれないが
rewind / fseek などはなじみ浅いかもしれないので軽く説明。

php://tempという一時ファイル内のカーソルポインタを、

rewind: ファイルの先頭まで戻す
fseek: ファイルの特定の位置に移動する

というイメージ。seekableかどうかは、php://temp はすでに seekable streamなので無視してOK。


本題に戻ると、次はheaders_sent() で、ヘッダーが送出済みか判定している。
どうやってヘッダーが送出済みか判定しているの?と筆者も疑問に思ったが
PHPコアのSAPIの話になるのでここでは触れない。
php-srcの SAPI.c SAPI_API int sapi_send_headers(void) あたりを見ると良いだろう。
headerをwriteする直前にSAPI_globalsのフラグをTRUEにしているようだ。

headerが未送出であることが確認できたら、header()で実際に送出する(ために出力バッファに積む)。

ResponseEmitter.php
    private function emitHeaders(ResponseInterface $response): void
    {
        foreach ($response->getHeaders() as $name => $values) {
            $first = strtolower($name) !== 'set-cookie';
            foreach ($values as $value) {
                $header = sprintf('%s: %s', $name, $value);
                header($header, $first);
                $first = false;
            }
        }
    }

Set-Cookie のみ複数行の送出が許可されているのでその実装が入っている。

続けて、StatusLineの送出を行う。

ResponseEmitter.php
    private function emitStatusLine(ResponseInterface $response): void
    {
        $statusLine = sprintf(
            'HTTP/%s %s %s',
            $response->getProtocolVersion(),
            $response->getStatusCode(),
            $response->getReasonPhrase()
        );
        header($statusLine, replace: true, $response->getStatusCode());
    }

Statusが最初じゃなくていいの?と思いますよね。
結論大丈夫で、header()でOutput Buffer (出力バッファ、OB)に積んだ後、SAPI層でStatusをHeaderと分離するためです。
じゃあなんでこんな奇妙な実装なのかというと、
PHPの歴史的経緯により逆転させたそうです 😪
https://github.com/slimphp/Slim/issues/1730
公式docsにも書いてます。
https://www.php.net/manual/ja/function.header.php

最後に、Bodyを送出します。

ResponseEmitter.php
    private function emitBody(ResponseInterface $response): void
    {
        $body = $response->getBody();
        if ($body->isSeekable()) {
            $body->rewind();
        }

        $amountToRead = (int) $response->getHeaderLine('Content-Length');
        if (!$amountToRead) {
            $amountToRead = $body->getSize();
        }

        if ($amountToRead) {
            while ($amountToRead > 0 && !$body->eof()) {
                $length = min($this->responseChunkSize, $amountToRead);
                $data = $body->read($length);
                echo $data;

                $amountToRead -= strlen($data);

                if (connection_status() !== CONNECTION_NORMAL) {
                    break;
                }
            }
        } else {
            while (!$body->eof()) {
                echo $body->read($this->responseChunkSize);
                if (connection_status() !== CONNECTION_NORMAL) {
                    break;
                }
            }
        }
    }

やってることはシンプルですね。Content-Length 分、echo します。
ここにきて echo が帰ってくるのはアツいですね。
StreamがEOFに到達すると、コードは終了します。

お疲れ様でした!!

snowindysnowindy

終わりに

Slim の実装を一通りさらってみて、色々勉強になりました。
追いかけたのはSlimの標準実装を使った場合のみですが、他の繋ぎ込み部分もおおよそ似た実装になっているので全部理解できるのではないかと思います。
PHPの低レイヤ部分(特にHTTP/ファイル操作周り/PSR)への理解が深まりました。

次は PHP-DI / nikic\FastRoute / Slim\Psr7 どれにしようかな..

このスクラップは10日前にクローズされました