背景
SPA (Single Page Application)を作るにあたって、サーバサイドは純粋に JSON API だけを提供するということもある。
これを Laravel で実現する際の構成について考え・検証したものを GitHub にあげた。
この記事ではその内容を(ほぼ)コミット単位で解説していく。
リポジトリについて
もともと docker-compose を使って Laravel の開発環境を手軽に作るためのものとして作ったリポジトリである。
今回は、そのリポジトリから skelton-api
というブランチを切って検証を行った。
リポジトリルート直下にある .env.example
をコピーして .env
ファイルを作成・編集して任意の値をセットしたのち、 make setup
を叩くことでローカルで動作させることができる。
Laravel で実装している部分のソースコードは src
下にある。
解説
不要なファイル・ディレクトリを削除
コミット:Remove unnecessary files · okashoi/laravel5.6-sandbox@6f9694a · GitHub
基本的にテンプレートエンジンやリダイレクト処理などを考慮する必要がなくなるので、大幅に機能を削ぐことができる。
これらを残したままでも別に問題はないが、チームメンバーの混乱を少しでも抑える目的で不要なファイル・ディレクトリは削除してしまう。
最初は Console
ディレクトリも不要かと思ったが、デプロイや運用保守の際に使う Command を作成するかもしれないので残しておいた。
名前空間の整理および WEB の無効化
コミット:Fix namespace and Disable web · okashoi/laravel5.6-sandbox@d17f62f · GitHub
次の節で説明するが、デフォルトの \App
という名前空間を使わずに、独自の \MyApp
*1 という名前空間下に実装していく形をとる。
これにあたって、実装を行わない *2 意図を明確にするために、\App
という名前空間を \Base
にリネームしておく。
なお \MyApp
下には本来 Laravel には存在しない *3 Models
というディレクトリが作成されているが、今回は深く考えず*4に Eloquent ORM を置くディレクトリとしている。
さらに RouteServiceProvider
を修正することで web の部分を無効化 + api のルーティングに /api/
というprefix がつかないようにした。
今思えばディレクトリ名も app
から base
等にしたほうが良かったような気がする。
必要なディレクトリを追加
コミット:Add necessary directories · okashoi/laravel5.6-sandbox@7a213ad · GitHub
\MyApp
という名前空間に結びついた packages
というディレクトリの下に、Laravel の機能と対応した、使いそうなディレクトリを一通り作成。
目的に応じて階層を増やして整理してもいいかもしれない。
ちなみに、この packages
というディレクトリを作るというやり方は @shin1x1 さんの受け売りである(ただし 今回は DDD とは無関係)。
レスポンスは JSON であることを表明
コミット:Add expectation response must be application/json · okashoi/laravel5.6-sandbox@3742099 · GitHub
エラー時でも常に JSON レスポンスを返したいので \Base\Exceptions\Handler::render()
内でリクエストヘッダに Accept: application/json
をセットする処理を追加。
こうすることで、後段の処理(parent::render()
)で例外をよしなに JSON レスポンスに変換してくれるようになる。*5
また、開発の段階で JSON レスポンス以外のレスポンスを返そうとしたらエラーになるようにもした。
Middleware でチェックをするのでも良かったが、public/index.php
内で 表明(assertion) を使うことで本番環境での動作に影響を与えない*6ようにしてみた。
表明についてはこちらがとても参考になる。
独自の例外を作成・エラー時の挙動カスタマイズ
コミット:Add original exception · okashoi/laravel5.6-sandbox@4a1d8f1 · GitHub
\MyApp\Exceptions\MyAppException\
という独自の例外を作成したうえで、\Base\Exceptions\Handler
を書き換えてエラー時の挙動をカスタマイズした。
独自の例外を作成
独自の例外を作成するのは、エラー時に「ユーザが目にするメッセージ」「ログに出力されるメッセージ」を出し分けることに動機がある。
このあたりはこちらのスライドを参考にしている。
たとえば、独自の例外を次のようにして使うことができる。
<?php use MyApp\Exceptions\MyAppException; //--------------------------------------------------- // ログには「コーヒーがリクエストされました」というメッセージとともに、そのサイズと種類を出力 // ユーザにはステータスコード 418 で「私はティーポットです。コーヒーは提供できません。」というメッセージを表示 // --------------------------------------------------- throw new MyAppException( 'コーヒーがリクエストされました', ['size' => 'tall', 'type' => 'espresso'], '私はティーポットです。コーヒーは提供できません。', 418 );
エラー時の挙動カスタマイズ
エラー時の挙動のカスタマイズは主に \Base\Exceptions\Handler
の report()
および render()
メソッドの書き換えによって行われる。
前者は
- 独自の例外に
$context
というプロパティを用意しておき、ログ出力時にメッセージ以外の情報を任意で与えられるようしたい
という要件を満たすためのものである。
こちらは副作用のある「ログ出力」に関する部分なので、書き換えの際に parent::report()
を活用することができず、継承元のクラスの実装をそのまま持ってきてたうえで書き換える必要があった。
具体的には、以下に示した $additionalContext
という変数に関する処理を追加している。
<?php // 独自の例外から $context を取得 $additionalContext = []; if ($e instanceof MyAppException) { $additionalContext = array_merge($additionalContext, $e->getContext()); } $logger->error( $e->getMessage(); array_merge($this->context(), $additionalContext, ['exception' => $e]); // array_merge() の引数に $additionalContext を追加 );
後者、 render()
の書き換えは次の要件を満たすように行われている。
- 開発環境下では、先述の表明(assertion)のチェックを行いたい
- 一方で、開発環境下でのエラー発生時には、デバッグに役に立つ Laravel のエラー画面(Whoops)を出したい
- ただし、未認証・バリデーションエラー等のよるリダイレクトは発生させたくない
- 本番環境下では、いかなるエラーも JSON レスポンスとして返したい
これたを満たすために ini_set('assert.active', false)
をするなど、少し気持ち悪い感じになってしまった。
なお、ここで独自の例外を HttpException
に変換しているのは、自前で render()
の処理を書くことなく既存の仕組みを利用することができるためである。
例として echo API を実装 + Http Test
コミット:Add echo api · okashoi/laravel5.6-sandbox@87283c4 · GitHub
コミット:Add Http test for echo api · okashoi/laravel5.6-sandbox@9c6964f · GitHub
ここまで構築したものが動くかどうかの確認のため、 受け取った message
をそのまま返す echo という API を実装。
基本的な書き方が変わるわけではないので、説明は割愛。
Http テストによって API が想定どおりの動作をしていることが確認できた。
$ docker-compose exec php-cli ./vendor/bin/phpunit PHPUnit 7.2.6 by Sebastian Bergmann and contributors. .. 2 / 2 (100%) Time: 1.87 seconds, Memory: 14.00MB OK (2 tests, 4 assertions)
今後の展望
とりあえず、ここまでで基礎的な部分はできあがった。
あとは実際の用途に合わせて更なるカスタマイズをしていくことになる。
例えば
- Laravel Passport を利用した認証機構
- Eloquent ORM を設置している Model を Domain 層の Entity/ValueObject・Infrastructure 層の Repository/Eloquent等 に分割
などが考えられる。
今回は Laravel から不要な機能を削っていくというアプローチだったが、逆に Lumen を使って必要な機能を選択・追加していく、というようなアプローチのほうが良かったりするのだろうか。
このあたりも気になるところ。