開発ブログ

株式会社Nextatのスタッフがお送りする技術コラムメインのブログ。

電話でのお問合わせ 075-744-6842 ([月]-[金] 10:00〜17:00)

  1. top >
  2. 開発ブログ >
  3. PHP >
  4. [PHP7] PSR-7 Middleware用の代替シグネチャ(HttpHandler / HttpContext Interface)
no-image

[PHP7] PSR-7 Middleware用の代替シグネチャ(HttpHandler / HttpContext Interface)

こんにちは、ナカエです。

本日はPSR-7 Http Messageに対応したHTTP Middlewareのポピュラーな実装への不満と代替案についての記事です。

PSR-7対応のMiddlewareの一般的なシグネチャ

use Psr\Http\Message\ServerResponseInterface;
use Psr\Http\Message\RequestInterface;

function (
    ServerRequestInterface $request, //HTTPリクエスト
    ResponseInterface $response, //HTTPレスポンス
    callable $next //次のMiddleware
) {
    // ...
}

有名どころのライブラリやフレームワークでもこの引数のシグネチャがよく採用されています。

※第一引数のHTTPリクエストについてはServerRequestIntefaceだったりRequestInterfaceだったりまちまち?

参考:

個人的には、callableだけでは返り値の型が保証しにくい点と、ルータライブラリとミドルウェアの合成用ライブラリが独自に乱立して相互運用性が期待していたほどではなくなりそうな点がイマイチだと思っています。

PHP7で返り値のタイプヒントが使えるようになったので、よりかっちり書くことが可能なはずと思い、違うシグネチャを探しました。

代替のシグネチャ

// Middleware
interface HttpHandlerInterface {
    public function __invoke(HttpContextInterface $context) : HttpContextInterface;
}

// Http Context (Request + Response + State)
interface HttpContextInterface {
    public function getRequest() : ServerRequestInterface;
    public function getResponse() : ResponseInterface;
    public function isTerminated(): bool;
    public function withRequest(ServerRequestInterface $request): HttpContextInterface;
    public function withResponse(ResponseInterface $response): HttpContextInterface;
    public function withIsTerminated(bool $isTerminated): HttpContextInterface;
    public function handledBy(HttpHandlerInterface): HttpContextInterface;
}

RequestとResponseを両方保持するHttpContextを介することで、Middlewareのメソッドの引数と返り値の型を同じにします。

.NET FrameworkのWeb.System.HttpContextが同じ発想だったので参考にさせていたきました。

用途をMiddlewareに限定する必要もないので、インスパイア元に倣いHttpHandlerと称しています。

基本的な使い方

HttpContextInterfaceを受け取ってHttpContextInterfaceを返すように__invoke()を実装します。

class Middleware implements HttpHandlerInterface {
    public function __invoke(HttpContextInterface $context) : HttpContextInterface
    {
        //なんらかの処理
        return $context;
    } 
}

$context = new HttpContext(\Zend\Diactoros\ServerRequestFactory::fromGlobals(), new \Zend\Diactoros\Response());
$middleware = new Middleware();
$newContext = $middleware($context);
$response = $newContext->getResponse();

ミドルウェア同士の合成(パイプラインなど)が容易

一般的に引数と返り値の型が一致する関数は合成が容易です。 直列のパイプラインは組み立て用のクラスを作らずとも容易に実現できます。

素朴な直列パイプライン

$context = new HttpContext(\Zend\Diactoros\ServerRequestFactory::fromGlobals(), new \Zend\Diactoros\Response());

$middlewareA = new MiddlewareA;
$middlewareB = new MiddlewareB;

$newContext = $middlewareB($middlewareA($context)); // A→Bの順で処理

// もしくは
$newContext = $context
     ->handledBy(new MiddlewareA)
     ->handledBy(new MiddlewareB);

パイプライン用のクラスによる直列パイプライン

ミドルウェアの合成結果に、再び合成前のミドルウェアと同じインターフェースを持たせることができるのもポイントです。

クラスを用いてパイプラインを構成することもできます。

use HttpHandlerInterface;
use HttpContextInterface;
/**
 * Class MiddlewarePipeline
 * Middleware Pipeline can be implemented as a HttpHandler.
 */
class MiddlewarePipeline implements HttpHandlerInterface {
    /**
     * @var HttpHandlerInterface[]
     */
    protected $handlers = [];
    /**
     * MiddlewarePipeline constructor.
     * @param HttpHandlerInterface[] $handlers
     */
    public function __construct(array $handlers = [])
    {
        foreach($handlers as $handler) {
            $this->append($handler);
        }
    }
    public function append(HttpHandlerInterface $handler) : void
    {
        $this->handlers[] = $handler;
    }

    public function __invoke(HttpContextInterface $context) : HttpContextInterface
    {
        foreach($this->handlers as $handler) {
            $context = $handler->__invoke($context);
            if($context->isTerminated()) {
                return $context;
            }
        }
        return $context;
    }
}

/// 利用
$context = new HttpContext(\Zend\Diactoros\ServerRequestFactory::fromGlobals(), new \Zend\Diactoros\Response());
$pipeline = new MiddlewarePipeline([
    new MiddlewareA(), // implements HttpHandlerInterface
    new MiddlewareB(), // implements HttpHandlerInterface
    new MiddlewareC(), // implements HttpHandlerInterface
]);

// 無名クラスを利用してインラインで追加も可
$pipeline->append(new class implements HttpHandlerInterface {
    public function __invoke(HttpContextInterface $context) :HttpContextInterface
    {
        // なんらかの処理
        return $context;
    }
});

$response = $pipeline($context)->getResponse();

玉ねぎ型の合成

callableを利用するシグネチャと比べると若干面倒ですが、アプリケーションをMiddlewareの前処理と後処理で包み込んでいく玉ねぎ型の構造も素直に実装できます。


use HttpHandlerInterface;
use HttpContextInterface;
class OnionMiddleware implements HttpHandlerInterface {
    /**
     * @var HttpHandlerInterface
     */
    private $wrapped;

    public function wrap(HttpHandlerInterface $wrapped) : void
    {
        $this->wrapped = $wrapped;
    }
    public function __invoke(HttpContextInterface $context) : HttpContextInterface
    {
        //前処理

        $newContext = $this->wrapped->__invoke($context);

        //後処理

        return $newContext;
    }
}

// 利用
$onion = new OnionMiddleware();
$coreApp = new MyCoreApp(); //implements HttpHandlerInterface
$onion->wrap($coreApp);
$context = new HttpContext(Zend\Diactoros\ServerRequestFactory::fromGlobals(), new \Zend\Diactoros\Response());
$response = $onion($context)->getResponse();

HttpHandlerInterfaceの汎用性

用途は特にMiddlewareには限らないところもポイントです。

MVCのControllerのActionメソッドにあたるものもHttpHandlerInterfaceで表現できます。 __invoke()の処理の中身を何らかのパターンで切り分けるなど、好きに書くと良いでしょう。

先日紹介したRadarの真似をして切り分けてみる例

class MyAction implements HttpHandlerInterface {
    public function __construct(
    	InputConverter $input, // convert http request to domain input
    	DomainService $domain,  // execute  buisiness logic
    	Responder $responder // convert domain output to http response
    ) {
        $this->input =  $input;
        $this->domain = $domain,
        $this->responder = $responder;
    }
    
    public function __invoke(HttpContextInterface $context) : HttpContextInterface
    {
    	    $domainInput = $input->__invoke($context->getRequest());
    	    $domainPayload = $domain->_invoke($domainInput);
    	    $httpResponse = $responder->_invoke($context->getRequest(), $context->getResponse(), $domainPayload);

    	    return $context->withResponse($httpResponse);
    }    
}

オブジェクトを組み合わせて型を保証しつつあたかも高階関数のように使えるところが__invoke()のいいところですよね。

Requestに応じたHttpHandlerに処理を振り分けるルーターをHttpHandlerとして実装し、 必要なMiddlewareと合成することで、WebアプリケーションそのものもHttpHandlerInterfaceの実装として構成可能です。

デメリット

試した範囲で感じたデメリットは

  • 間接層が1つ増えるためにコードの記述量とコストが増える
  • 無名関数によるMiddleware定義の際に、関数からHttpHandlerInterfaceを実装したオブジェクトへの変換が必要

の2点です。HttpContextの初見での分かりにくさはcallableを使う"Next"型のMiddlewareといい勝負だと思います。

まとめ

  • PHP7が使えるならMiddlewareの型をよりかっちり書ける
  • 処理の組み合わせ、合成が容易
  • ActionやApplicationも同じ型で表現できる
  • 無名クラスも活躍可

今後の展望

  • そのうち GitHubにリポジトリを作る予定
  • 試しにマイクロフレームワークを作ってみる
  • HttpContextに保持する状態(エラーの蓄積やルーティングのパラメータなど)についての考察
TOPに戻る