はじめに
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」の部分だけです。
https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html
仕様
作成するのはアカウント登録、ログイン、ログアウトがあるだけのごく単純なアプリケーションです。
- ユーザーはサービスを利用するためのアカウントを作成(サインアップ)しなければならない
- アカウント作成にはメールアドレスとパスワードが必要
- アカウントが作成されると自動的にログインされる
- ログインに成功すると「Hello World!」と表示される
- ログインしたアカウントはログアウトすることができる
- ログインするにはアカウント作成時に入力したメールアドレスとパスワードが必要
実装
今回実装したアプリケーションのソースは以下にあります。
https://github.com/emonkak/php-http-app-skeleton
使用ライブラリ
いくつか自作のライブラリも含まれますが、それぞれ詳細については追って記事にしたいと思います。
PSR-3を実装するロガー。
PSR-7を実装するHTTPメッセージライブラリ。
自作のシンプルなORM。 JavaのPOJOのPHP版にあたるPOPO(Plain Old PHP Object)を使ってエンティティを表現することができます。
自作のジェネレータを利用したPHPにおけるLINQ to Objectsの実装。 EnumerableExtensionsトレイトを利用することで任意のイテレーターオブジェクトに、LINQのクエリメソッドをミックスインすることができます。
自作のPSR-15ミドルウェアと、ミドルウェアのエントリーポイントとしてのアプリケーションの実装を提供するライブラリ。 エラーハンドリングを提供する独自のミドルウェアのインターフェイスも提供します。
symfony/http-kernelに含まれるHTTPのステータスコードに応じたエラーを表す例外を切り出したバッケージ。
パスのルーティングを提供する自作の汎用ルーター。 トライ木による実装と、最適化された正規表現による実装が提供されます。
シンプルなマイグレーションツール。
エラー発生時のデバッグ画面を表示するライブラリ。
.envファイルによる環境変数の設定を提供するライブラリ。
ディレクトリ構造
Laravelの構成を参考にしています。
bootstrap/
アプリケーションの初期化をするためのスクリプトを格納するディレクトリ。 ここに格納されたスクリプトはアプリケーションの設定ファイルとしても機能します。
database/migrations/
public/
サーバーのドキュメントルートとなるディレクトリ。
resources/templates/
src/
storage/
ログやキャッシュなどのファイルを書き込むディレクトリ。
パッケージ構成
App\Adapters
HTTP、コンソール(CLI)、データベースなどの外部インターフェイスへのアダプターが格納されます。 本来であればデータベースにアクセスするリポジトリの実装はこちらに配置するのが望ましいですが、今回はインターフェイス定義を省略して簡略化するために
App\Domain
に配置しています。App\UseCases
アプリケーションのユースケースが格納されます。 今回の場合はアカウントの認証と作成のためのサービスが格納されています。
App\Domain
アプリケーションが対象とする問題領域であるドメインの実装が格納されます。 パッケージ直下には実装は格納せずに、集約ルート(ルートエンティティ)ごとにサブパッケージを定義します。 今回はアカウントのサブパッケージのみが格納されています。
App\Supports
他の各レイヤーの実装を支援するためのユーティリティが格納されます。
エントリーポイント
まずはサーバーのエントリーポイントとなるスクリプトを見てみます。
最初に、リクエストからレスポンスを生成するための Application
クラスのインスタンスを別ファイルから require
しています。
この Application
のインスタンスにリクエストを与えて、生成されたレスポンスを送信するというのが全体の流れです。
<?php// public/index.phpuse 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の内容です:
<?phprequire__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.phpを require
してPSR-11の ContainerInterface
の実装が生成されます。
要求されるのは ContainerInterface
のインスタンスなので、好きなDIコンテナの実装を利用することができます。
今回は自作の emonkak/di を使っています。
このコンテナにはアプリケーションに必要な各種インスタンスが登録されます。 環境の違いによって異なる設定を利用したい場合は、.env によって設定される環境変数を利用して切り替えます。
以下はDBのコネクションをコンテナに登録している部分のソースです:
<?php$container->factory(PDOInterface::class, function(){returnnewPDO(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文で分岐するというのが重複も発生せずシンプルでいいと思ってます。
<?phpif(getenv('APP_ENV')==='production'){// production settings}else{// other settings}
ルーターの生成
ルーターはURLのパスに応じて、リクエストをハンドリングするクラス(ハンドラー)を決定するためのものです。
ルーターの生成は、パスに応じた処理を実行するための Dispatcher
ミドルウェアの登録時に bootstrap/router.phpを require
することで行われます。
ここで要求されるのは emonkak/router で定義されたインターフェイスである RouterInterface
の実装です。
以下は bootstrap/router.phpの内容です:
<?phpuse 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();
これは単純にルーティングの内容を書き下しているだけですが、ハンドラーの命名に一定の法則を設けることで、クラスの一覧を取得して自動的にルーティングを定義するという方法も考えられます。 さらに生成したルーティングの内容をシリアライズした上でキャッシュすることで、性能的にも有利になります。 ルーティング数の多い大規模アプリケーションであれば、このような方法も検討もするといいかもしれません。
実装のイメージとしては以下のようなものです:
<?phpif(file_exsits(__DIR__.'/../storage/router.cache.php')){returnunserialize(file_get_contents('/../storage/router.cache.php')); }$builder=new TrieRouterBuilder(); foreach(RoutingResolver::resolve(__DIR__.'/../src/Adapters/Http/Handlers')aslist($method, $path, $handler)){$builder->route($method, $path, $handler); }file_put_contents(__DIR__.'/../storage/router.cache.php', serialize($builder->build())); return$router;
ハンドラーの実装
リクエストをハンドリングしてレスポンスを返すクラスをハンドラーと呼んでいます。 あらゆるPSR-15のミドルウェアはハンドラーとして利用できます。
その他、1つのクラスで複数のエンドポイントを取り扱う、コントローラースタイルのクラスをハンドラーとして登録することもできます。
コントローラーを登録する時はルーターにクラス名とメソッド名のペアを指定します。
指定されたクラスのメソッドは ServerMiddlewareInterface
の process()
メソッドと同じシグネチャであることを期待されます。
コントローラーの例:
<?phpclass ExampleController {publicfunction index(ServerRequestInterface $request, DelegateInterface $delegate){...}publicfunction 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
トレイトを作成しました。
<?phptrait Respondable {publicfunction html($html, $statusCode=200, array$headers=[]){returnnew HtmlResponse($html, $statusCode, $headers); }publicfunction json($data, $statusCode=200, array$headers=[]){returnnew JsonResponse($data, $statusCode, $headers); }publicfunction redirect($uri, $statusCode=302){returnnew RedirectResponse($uri, $statusCode); }...}
これらのメソッドをハンドラーにミックスインして呼び出すことで、直接特定のレスポンスの実装に依存することはなくなりました。
おわりに
PSR-7とPSR-15を使ってマイクロフレームワークを作成して、簡単なサンプルアプリケーションという試みをしてみました。 機能としては不足している点はありますが、この成果は現実のアプリケーションに十分に適用できるものだと思います。
実は、このような試みをしたのは2度目で、その時の成果として emonkak/wafというライブラリがあります。 これは前職で関わったとあるプロダクトで利用されています。 この時はアプリケーションの要件がかなり特殊で既存のフレームワークとは適合しなかったので、独自のフレームワークを作成する必要がありました。
本稿では説明不足な点も多々ありますが、ソースはシンプルで読み易いはずなので、興味があれば読んで頂ければと思います。