s平面の左側

左側なので安定してます(制御工学の話は出てきません)

Laravel で API サーバを実現する際の構成案

背景

SPA (Single Page Application)を作るにあたって、サーバサイドは純粋に JSON API だけを提供するということもある。

これを Laravel で実現する際の構成について考え・検証したものを GitHub にあげた。

github.com

この記事ではその内容を(ほぼ)コミット単位で解説していく。

リポジトリについて

もともと 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 とは無関係)。

speakerdeck.com

github.com

レスポンスは 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ようにしてみた。

assertion 違反時のエラー画面
JSON形式でないレスポンスを返そうとするとエラーになる

表明についてはこちらがとても参考になる。

独自の例外を作成・エラー時の挙動カスタマイズ

コミット: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\Handlerreport() および 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 を使って必要な機能を選択・追加していく、というようなアプローチのほうが良かったりするのだろうか。

このあたりも気になるところ。

*1:実際にはアプリケーションの名前を使うイメージ

*2:設定に準ずる修正は行う

*3:けど、おそらく9割以上のプロジェクトで作成される

*4:API サーバ実現の検証が目的なので

*5:しかし「リクエストヘッダを書き換える」という操作はやっていいのだろうか?という疑問が残る

*6:php.ini の設定が必要