PSR-7とPSR-15を使ったWebアプリケーション開発

はじめに

PSR-7(HTTP Message)が承認されてからしばらく経ちますが、現在はこれを使った様々なライブラリ・フレームワークが登場しています。 これによって特定のライブラリ・フレームワークにロックインされずに、Webアプリケーションを実装できる道程が見えてきました。

しかし、PSR-7はあくまでHTTPメッセージのインターフェイスを提供するもので、リクエストを受け取ってレスポンスを返す流れを抽象化するものではありません。 これはHTTPミドルウェアと呼称されますが、そのインターフェイスはそれぞれの実装でまちまちです。 そこで、これを抽象化するPSR-15(HTTP Middleware)が提案されています。

ミドルウェアは大まかにダブルパスのミドルウェアと、シングパスのミドルウェアに分けることができます。 PSR-15は現在の所シングルパスのシグネチャを採用しています。

シングルパスのミドルウェアシグネチャ

function (ServerRequestInterface $request, callable $next): ResponseInterface;

ダブルパスのミドルウェアシグネチャ

function (ServerRequestInterface $request, ResponseInterface $response, callable $next): ResponseInterface;

PSR-15のインターフェイスを提供するhttp-interop/http-middlewareにシングルパスを採用する理由があります。 ダブルパスのミドルウェアには事前にレスポンスを与える必要がありますが、そのレスポンスが利用可能なものかどうかを保証できないことが問題だと判断し、シングルパスを採用したようです。

The most severe is that passing an empty response has no guarantees that the response is in a usable state. This is further exacerbated by the fact that a middleware may modify the response before passing it for further dispatching.

残念ながら、この2つのインターフェイスには相互運用性がないので、アダプターを介してシグネチャを統一しても同じ動作を保証できません。 しかしこれは過渡期の問題で、恐らくはPSR-15の承認とともにどちらかに統一されることで解決に向かうはずです。

実装方針

本稿ではPSR-15を利用して簡単なサンプルとしてのWebアプリケーションを実装することを目的とします。 アプリケーションの設計については様々な手法がありますが、今回はDDD(Domain-driven design)を採用することを念頭に置いて進めていきます(サンプルなのでドメインと言っても実体があまりないですが)。

実装にあたっては既存のフレームワークを使うという選択肢もありますが、ここでは単機能なライブラリを組合わせて疎結合な独自のマイクロフレームワークを構築し、その上でアプリケーションを実装します。 これはあえてフルスタックフレームワークを避けたということでもあります。

フルスタックフレームワークは機能が豊富で便利なのは確かですが、注意深く設計をしないとドメインが特定のインフラストラクチャに依存する形になってしまいがちです。 さらには、複雑なドメインとアプリケーションの要件次第では、フレームワークの機能も十分に生かすことができず、かえって足枷になってしまうこともあります(フレームワークに汚いハックをして無理矢理要件を満たすなど)。 これはフレームワークが汎用のものである所以です。

ドメインは特定のインフラストラクチャから完全に分離されており、それ単体で動作するのが理想的です。 そうすると、ドメインへの影響は一切なしに、フレームワークを乗り換えることだってできます。 フレームワークもアプリケーションの要件に完全に合致したものがあればミスマッチもありません。

そこで、クリーンアーキテクチャを採用した、独自のマイクロフレームワークを作成するという道を選択しました。 幸いにして、そのための部品(ライブラリ)は揃いつつあります(揃えました)。 クリーンアーキテクチャではフレームワークに依存するレイヤーはこの図にある「Interface Adapters」と「Frameworks & Drivers」の部分だけです。

f:id:emonkak:20161209010829j:plain

https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html

仕様

作成するのはアカウント登録、ログイン、ログアウトがあるだけのごく単純なアプリケーションです。

  • ユーザーはサービスを利用するためのアカウントを作成(サインアップ)しなければならない
  • アカウント作成にはメールアドレスとパスワードが必要
  • アカウントが作成されると自動的にログインされる
  • ログインに成功すると「Hello World!」と表示される
  • ログインしたアカウントはログアウトすることができる
  • ログインするにはアカウント作成時に入力したメールアドレスとパスワードが必要

実装

今回実装したアプリケーションのソースは以下にあります。

https://github.com/emonkak/php-http-app-skeleton

使用ライブラリ

いくつか自作のライブラリも含まれますが、それぞれ詳細については追って記事にしたいと思います。

ディレクトリ構造

Laravelの構成を参考にしています。

パッケージ構成

  • App\Adapters

    HTTP、コンソール(CLI)、データベースなどの外部インターフェイスへのアダプターが格納されます。 本来であればデータベースにアクセスするリポジトリの実装はこちらに配置するのが望ましいですが、今回はインターフェイス定義を省略して簡略化するために App\Domain に配置しています。

  • App\UseCases

    アプリケーションのユースケースが格納されます。 今回の場合はアカウントの認証と作成のためのサービスが格納されています。

  • App\Domain

    アプリケーションが対象とする問題領域であるドメインの実装が格納されます。 パッケージ直下には実装は格納せずに、集約ルート(ルートエンティティ)ごとにサブパッケージを定義します。 今回はアカウントのサブパッケージのみが格納されています。

  • App\Supports

    他の各レイヤーの実装を支援するためのユーティリティが格納されます。

エントリーポイント

まずはサーバーのエントリーポイントとなるスクリプトを見てみます。

最初に、リクエストからレスポンスを生成するための Application クラスのインスタンスを別ファイルから require しています。 この Applicationインスタンスにリクエストを与えて、生成されたレスポンスを送信するというのが全体の流れです。

<?php
// public/index.php

use Zend\Diactoros\Response\SapiEmitter;
use Zend\Diactoros\ServerRequestFactory;

// アプリケーションのインスタンスを読み込む
$app = require __DIR__ . '/../bootstrap/http.php';

// グローバル変数からリクエストを生成
$request = ServerRequestFactory::fromGlobals();

// リクエストからレスポンスを生成
$response = $app->handle($request);

// レスポンスを送信する
(new SapiEmitter())->emit($response);

アプリケーションの初期化

Application の初期化は bootstrap/http.php で行なわれます。 ここではオートローダーを読み込み環境変数の設定をした上で、各種ミドルウェアの登録を行っています。

以下は bootstrap/http.php の内容です:

<?php

require __DIR__ . '/../vendor/autoload.php';

// .envから環境変数を読み込み
(new Dotenv\Dotenv(__DIR__ . '/../'))->load();

if (getenv('APP_DEBUG')) {
    // デバッグモードならsymfony/debugによるエラー画面を表示する
    Symfony\Component\Debug\Debug::enable();
}

$app = new App\Adapters\Http\Application(realpath(__DIR__ . '/../'));

// 以降ミドルウェアの設定
// ミドルウェアは追加された順番通りに実行される

// POST時に_methodパラメータをPOSTすることでメソッドをオーバーライドする
$app->pipe(
    (new Middlewares\MethodOverride())
        ->post(['PATCH', 'PUT', 'DELETE'])
        ->parsedBodyParameter('_method')
);

// セッションを開始する
$app->register(App\Adapters\Http\Middlewares\SessionStarter::class);

// アカウントを認証する認証
$app->register(App\Adapters\Http\Middlewares\Authenticator::class);

// テンプレートに設定される共通の変数($request, $uri, $session, $flashes)を設定する
$app->register(App\Adapters\Http\Middlewares\ViewSharedVariables::class);

// ルーティングの結果から処理を設定されたハンドラに移譲する
$app->registerDispatcher();

// エラーのロギングを実行する
$app->registerErrorHandler();

if (!getenv('APP_DEBUG')) {
    // Dispatcherが処理できなかった時に最後に実行されるミドルウェア
    // 404ぺージが表示される
    $app->register(App\Adapters\Http\Middlewares\ErrorPage::class);

    // 同様の実装をエラー発生時にも実行されるように登録する
    // catchされない例外があればここでハンドリングされてエラーページが作成される
    $app->registerOnError(App\Adapters\Http\Middlewares\ErrorPage::class);
}

return $app;

DIコンテナの生成

Applicationインスタンスが生成される時に、コンストラクターでは bootstrap/container.phprequire してPSR-11の ContainerInterface の実装が生成されます。 要求されるのは ContainerInterfaceインスタンスなので、好きなDIコンテナの実装を利用することができます。 今回は自作の emonkak/di を使っています。

このコンテナにはアプリケーションに必要な各種インスタンスが登録されます。 環境の違いによって異なる設定を利用したい場合は、.env によって設定される環境変数を利用して切り替えます。

以下はDBのコネクションをコンテナに登録している部分のソースです:

<?php

$container->factory(PDOInterface::class, function() {
    return new PDO(
        sprintf(
            '%s:host=%s;port=%d;dbname=%s',
            getenv('DB_CONNECTION'),
            getenv('DB_HOST'),
            getenv('DB_PORT'),
            getenv('DB_DATABASE')
        ),
        getenv('DB_USERNAME'),
        getenv('DB_PASSWORD'),
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        ]
    );
})->in(SingletonScope::getInstance());

本サンプルでは設定値を管理するための専用の仕組みは得に設けていませんが、ほとんどの場合この環境変数を使う方法で事足りるはずです。

仮に環境によって特別な設定をしたい場合についても、以下のように if で分岐してしまえば対応できます。 多くの設定は環境によらず共通のはずで(環境ごとの差異が大きすぎるのは問題)、異なる部分だけをif文で分岐するというのが重複も発生せずシンプルでいいと思ってます。

<?php

if (getenv('APP_ENV') === 'production') {
    // production settings
} else {
    // other settings
}

ルーターの生成

ルーターはURLのパスに応じて、リクエストをハンドリングするクラス(ハンドラー)を決定するためのものです。

ルーターの生成は、パスに応じた処理を実行するための Dispatcher ミドルウェアの登録時に bootstrap/router.phprequire することで行われます。 ここで要求されるのは emonkak/router で定義されたインターフェイスである RouterInterface の実装です。

以下は bootstrap/router.php の内容です:

<?php

use App\Adapters\Http\Handlers;
use Emonkak\Router\TrieRouterBuilder;

return (new TrieRouterBuilder())
    ->get('/', Handlers\Index::class)
    ->get('/api/ping', Handlers\Api\Ping::class)
    ->get('/accounts/sign_up', Handlers\Accounts\SignUp::class)
    ->post('/accounts', Handlers\Accounts\Create::class)
    ->get('/sessions/login', Handlers\Sessions\Login::class)
    ->post('/sessions', Handlers\Sessions\Create::class)
    ->delete('/sessions', Handlers\Sessions\Delete::class)
    ->build();

これは単純にルーティングの内容を書き下しているだけですが、ハンドラーの命名に一定の法則を設けることで、クラスの一覧を取得して自動的にルーティングを定義するという方法も考えられます。 さらに生成したルーティングの内容をシリアライズした上でキャッシュすることで、性能的にも有利になります。 ルーティング数の多い大規模アプリケーションであれば、このような方法も検討もするといいかもしれません。

実装のイメージとしては以下のようなものです:

<?php

if (file_exsits(__DIR__ . '/../storage/router.cache.php')) {
    return unserialize(file_get_contents('/../storage/router.cache.php'));
}

$builder = new TrieRouterBuilder();

foreach (RoutingResolver::resolve(__DIR__ . '/../src/Adapters/Http/Handlers') as list($method, $path, $handler)) {
    $builder->route($method, $path, $handler);
}

file_put_contents(__DIR__ . '/../storage/router.cache.php', serialize($builder->build()));

return $router;

ハンドラーの実装

リクエストをハンドリングしてレスポンスを返すクラスをハンドラーと呼んでいます。 あらゆるPSR-15のミドルウェアはハンドラーとして利用できます。

その他、1つのクラスで複数のエンドポイントを取り扱う、コントローラースタイルのクラスをハンドラーとして登録することもできます。 コントローラーを登録する時はルーターにクラス名とメソッド名のペアを指定します。 指定されたクラスのメソッドは ServerMiddlewareInterfaceprocess() メソッドと同じシグネチャであることを期待されます。

コントローラーの例:

<?php

class ExampleController
{
    public function index(ServerRequestInterface $request, DelegateInterface $delegate)
    {
        ...
    }

    public function create(ServerRequestInterface $request, DelegateInterface $delegate)
    {
        ...
    }

    ...
}

http-interop/http-middleware に言及がありますが、PSR-15のシングルパスのミドルウェアはレスポンスの生成をミドルウェア自身が行うので、レスポンスの実装に対する依存が起きてしまいます。

Some have argued that passing the response helps ensure dependency inversion. While it is true that it helps avoid depending on a specific implementation of HTTP messages, the problem can also be resolved by injecting factories into the middleware to create HTTP message objects, or by injecting empty message instances. With the creation of HTTP Factories in PSR-17, a standard approach to handling dependency inversion is possible.

確かにPSR-17を使うことで特定の実装に依存することは回避できるのですが、ちょっと使い勝手が良くありません。 例えばJSONのレスポンスを返す時にContent-Typeを適切に設定してデータを json_encode() を設定しなければならないのはなかなかに面倒です。

そこでもっと簡単にレスポンスオブジェクトを生成できる Respondable トレイトを作成しました。

<?php

trait Respondable
{
    public function html($html, $statusCode = 200, array $headers = [])
    {
        return new HtmlResponse($html, $statusCode, $headers);
    }

    public function json($data, $statusCode = 200, array $headers = [])
    {
        return new JsonResponse($data, $statusCode, $headers);
    }

    public function redirect($uri, $statusCode = 302)
    {
        return new RedirectResponse($uri, $statusCode);
    }

    ...
}

これらのメソッドをハンドラーにミックスインして呼び出すことで、直接特定のレスポンスの実装に依存することはなくなりました。

おわりに

PSR-7とPSR-15を使ってマイクロフレームワークを作成して、簡単なサンプルアプリケーションという試みをしてみました。 機能としては不足している点はありますが、この成果は現実のアプリケーションに十分に適用できるものだと思います。

実は、このような試みをしたのは2度目で、その時の成果として emonkak/wafというライブラリがあります。 これは前職で関わったとあるプロダクトで利用されています。 この時はアプリケーションの要件がかなり特殊で既存のフレームワークとは適合しなかったので、独自のフレームワークを作成する必要がありました。

本稿では説明不足な点も多々ありますが、ソースはシンプルで読み易いはずなので、興味があれば読んで頂ければと思います。