2019.01.12

Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう (11) 写真一覧取得API


UPDATED:2020.01.05
PHP 7.4 および Laravel 6 に対応しました 🎉

この連載記事では、フロントエンドに Vue.js + Vue Router + Vuex とサーバーサイドに Laravel を使用したシングルページ Web アプリケーションの開発方法を紹介します。実際に写真共有アプリを開発する手順を通して SPA 開発のエッセンスを学ぶことができます。

今回のチュートリアルで扱うツールなどのバージョンは以下の通りです。

Node npm Vue.js Vue Router Vuex PHP Laravel
10.15 6.4 2.6 3.1 3.1 7.4 6.9

この章では写真の一覧を取得する Web API を実装します。
また最後に、API ではありませんが写真ダウンロード用の URL も実装します。

JSON レスポンス

まず写真一覧表示 API がどのような JSON データをレスポンスすべきかについて考えましょう。フロントエンドではこの API の返却値を使って画面を作るため、フロントエンドでどのような情報が必要かという観点で設計します。

写真一覧ページ(トップページ)はこのようなページでした。

トップページ

それぞれの写真にマウスオーバーすると以下のように「いいねボタン」「ダウンロードボタン」「投稿者名」が表示されます。

トップページ

「いいね」機能については後の章で実装しますのでいったん置いておくとして、写真を表示させるためには以下のデータが必要そうです。

  • 写真ID(各写真は詳細ページへのリンクなのでリンクURLに写真IDを使う)
  • 写真のURL
  • 投稿者名

そこで、ページ送りの機能も考慮して、以下のフォーマットの JSON を返却することとします。

{
  "total": 50,
  "per_page": 15,
  "current_page": 1,
  "last_page": 4,
  "first_page_url": "http://laravel.app?page=1",
  "last_page_url": "http://laravel.app?page=4",
  "next_page_url": "http://laravel.app?page=2",
  "prev_page_url": null,
  "path": "http://laravel.app",
  "from": 1,
  "to": 15,
  "data": [
    {
      "id": "abcd1234EFGH",
      "url": "https://s3-ap-northeast-1.amazonaws.com/backet-name/abcd1234EFGH.jpeg",
      "owner": {
        "name": "John Lennon"
      }
    },
    {
      "id": "ijkl5678MNOP",
      "url": "https://s3-ap-northeast-1.amazonaws.com/backet-name/ijkl5678MNOP.jpeg",
      "owner": {
        "name": "Paul McCartney"
      }
    }
  ]
}

ただし data の中身以外は Laravel のページネーション機能で自動的に付与されます。data の中の各要素の項目のみを気にすれば OK です。

さらにもう一つの仕様として、写真の一覧は作成日の降順で並ぶものとします。

テストコード

テストコードから書いていきます。

ファクトリ

まずはテストデータ作成のためのファクトリを作成します。

$ php artisan make:factory PhotoFactory

雛形 database/factories/PhotoFactory.php を以下の内容で編集してください。

PhotoFactory.php
<?php

/** @var \Illuminate\Database\Eloquent\Factory $factory */

use Faker\Generator as Faker;
use Illuminate\Support\Str;

$factory->define(App\Photo::class, function (Faker $faker) {
    return [
        'id' => Str::random(12),
        'user_id' => fn() => factory(App\User::class)->create()->id,
        'filename' => Str::random(12) . '.jpg',
        'created_at' => $faker->dateTime(),
        'updated_at' => $faker->dateTime(),
    ];
});

ファクトリについて詳しくはマニュアル(🇺🇸 公式 / 🇯🇵 日本語)を参照してください。

テストケース

次にテストを作成します。

$ php artisan make:test PhotoListApiTest

tests/Feature/PhotoListApiTest.php を以下の内容で編集してください。

PhotoListApiTest.php
<?php

namespace Tests\Feature;

use App\Photo;
use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;

class PhotoListApiTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @test
     */
    public function should_正しい構造のJSONを返却する()
    {
        // 5つの写真データを生成する
        factory(Photo::class, 5)->create();

        $response = $this->json('GET', route('photo.index'));

        // 生成した写真データを作成日降順で取得
        $photos = Photo::with(['owner'])->orderBy('created_at', 'desc')->get();

        // data項目の期待値
        $expected_data = $photos->map(function ($photo) {
            return [
                'id' => $photo->id,
                'url' => $photo->url,
                'owner' => [
                    'name' => $photo->owner->name,
                ],
            ];
        })
        ->all();

        $response->assertStatus(200)
            // レスポンスJSONのdata項目に含まれる要素が5つであること
            ->assertJsonCount(5, 'data')
            // レスポンスJSONのdata項目が期待値と合致すること
            ->assertJsonFragment([
                "data" => $expected_data,
            ]);
    }
}

Photo::with()with メソッドについては後述します。

API の実装

ルーティング

テストが書けたので実装に入ります。

まずは routes/api.php にルート定義を追加しましょう。

api.php
// 写真一覧
Route::get('/photos', 'PhotoController@index')->name('photo.index');

Photo モデル

app/Photo.php を編集します。

リレーションシップ

まずは User モデルとのリレーションを定義します。

以下の owner メソッドを追加してください。

Photo.php
/**
 * リレーションシップ - usersテーブル
 * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
 */
public function owner()
{
    return $this->belongsTo('App\User', 'user_id', 'id', 'users');
}

リレーションのメソッド名は任意の値を定義できます。User モデルとのリレーションだからといって user というメソッド名でなくてはいけないというルールはありません。意味を考えて分かりやすい名前にしましょう。

ただし今回のようにリレーション先のモデル名と関係のない名前を付ける場合は belongsTo などのメソッドの引数は省略せずに記述する必要があります。

そしてモデルクラスがコントローラーからレスポンスされて JSON に変換されるとき、このリレーション名 "owner" が反映されます。

url アクセサ

続いて url アクセサを定義するために getUrlAttribute メソッドを追加します。

Photo.php
/**
 * アクセサ - url
 * @return string
 */
public function getUrlAttribute()
{
    return Storage::cloud()->url($this->attributes['filename']);
}

クラウドストレージの url メソッドは S3 上のファイルの公開 URL を返却します。具体的には .env で定義した AWS_URL と引数のファイル名を結合した値になります。

アクセサは定義しただけではモデルの JSON 表現には現れません。ユーザー定義のアクセサを JSON 表現に含めるためには、明示的に $appends プロパティに登録する必要があります。

Photo.php
/** JSONに含める属性 */
protected $appends = [
    'url',
];

余計な項目を表示させない

今後は逆に JSON に表示させる必要のない項目を隠すための記述を追加します。

例えば下記のようにコントローラーからモデルクラスのインスタンスを返却すると、自動的にモデルのデータが JSON に変換されます。

class SampleController extends Controller
{
    public function sample()
    {
        $item = Item::first();

        return $item;
    }
}

このとき、モデルが持つ情報の種類によって JSON データに含まれるかどうかが決まります。

モデルの項目 JSON に含まれるか?
元からある属性 含まれる
ユーザー定義のアクセサ デフォルトでは含まれない
$appends などで明示的に追加すれば含まれる
リレーション デフォルトでは含まれない
with でロードされていれば含まれる(with については後述)

そして上記の基本ルールをカスタマイズする方法が2つあります。
モデルクラスの $hidden プロパティと $visible プロパティです。

プロパティ 役割
$hidden 登録項目は JSON に含めない。それ以外は基本ルールに従う。
$visible 登録項目だけを JSON に含める。それ以外は含めない。

今回のケースでは、Photo の情報としては id url owner さえあれば OK です。
これを $hidden プロパティで表現すると以下のようになります。

Photo.php
/** JSONに含めない属性 */
protected $hidden = [
    'user_id', 'filename',
    self::CREATED_AT, self::UPDATED_AT,
];

一方、$visible プロパティで表現すると以下のようになります。

Photo.php
/** JSONに含める属性 */
protected $visible = [
    'id', 'owner', 'url',
];

どちらを使うかはユースケースによります。ここでは記述がよりシンプルで意図が分かりやすくなっている $visible プロパティを採用します(一般的にどちらがいいという話ではなく、あくまで今回のケースに限った判断です)。

User モデル

続いて app/User.php を編集します。

余計な項目を表示させない

Photo と同じく User についても JSON で表示しない項目は隠します。

今回は User の情報は name だけで足ります。最初から $hidden プロパティによって passwordremember_token は隠されるようになっていたはずですが、今回で言えば特に email は隠しておくべきでしょう。写真の投稿者のメールアドレスは晒す必要がありません。

app/User.php$visible プロパティを追加してください。

User.php
protected $visible = [
    'name',
];

また、最初からあった $hidden プロパティは削除してください。

ちなみに同じことを $hidden プロパティで表現すると以下のようになります。

User.php
protected $hidden = [
    'id', 'email', 'email_verified_at', 'password', 'remember_token',
    self::CREATED_AT, self::UPDATED_AT,
];

このケースに関しても $visible プロパティを使ったほうがすっきりしていますね。

コントローラー

最後にコントローラーにメインロジックを記述します。
と言ってもモデルクラスを JSON に変換する処理もページ送りも Laravel が面倒を見てくれるので、シンプルなロジックになるでしょう。

PhotoController.phpindex メソッドを追加してください。

PhotoController.php
/**
 * 写真一覧
 */
public function index()
{
    $photos = Photo::with(['owner'])
        ->orderBy(Photo::CREATED_AT, 'desc')->paginate();

    return $photos;
}

ではポイントをいくつか説明します。

with メソッド

with メソッドは、リレーションを事前にロードしておくメソッドです。

どういうことかというと、例えば以下のような photos テーブルがあったとします。

id user_id filename
2zcDhnAZqS7A 1 2zcDhnAZqS7A.jpeg
9ksBoulfARra 2 9ksBoulfARra.png
VggvSwRX3LEb 3 VggvSwRX3LEb.jpg

そして以下のコードを実行すると...

$photos = Photo::all();
foreach ($photos as $photo) {
    var_dump($photo->owner->name);
}

このような SQL が発行されます。

SELECT * FROM `photos`;
SELECT * FROM `users` WHERE `id` = 1;
SELECT * FROM `users` WHERE `id` = 2;
SELECT * FROM `users` WHERE `id` = 3;

owner リレーションを解決するために users テーブルにも問い合わせが必要なのですが、ループのたびに SQL を発行しています。そのため、写真データが100行あったら101回 SQL が発行されてしまいます。

このようにN行のデータに対してN+1回 SQL が発行される現象を「N+1問題」と呼びます。発行する SQL が多くなるほどデータベースへの通信回数が多くなるので、端的に言ってアプリが遅くなります。そのため「問題」と言われているわけです。

SQL だけを見ると、id ごとに users への SELECT 文を発行しなくても、IN 句などを使って「id が1または2または3のデータ」というふうに取ってくれば一度の SQL 発行で済みそうです。

with メソッドを使うと、引数で渡したリレーションが定義されたテーブルの情報を先にまとめて取得することで「N+1」問題を回避できます。

つまり以下のコードを実行すると...

$photos = Photo::with(['owner'])->get();
foreach ($photos as $photo) {
    var_dump($photo->owner->name);
}

このような SQL が発行されます。

SELECT * FROM `photos`;
SELECT * FROM `users` WHERE `id` IN (1, 2, 3);

foreach ループの内部では SQL は発行されていません。with で先に取得されたユーザーデータが参照されます。

複数行のデータについてさらにリレーションを参照するような場合にはこの with メソッドを活用すれば SQL の発行回数を抑えることができるでしょう。

paginate メソッド

paginate はページ送り機能を実現します。get の代わりに paginate を使うことで、JSON レスポンスでも示した total(総ページ数)や current_page(現在のページ)といった情報が自動的に追加されます。

2ページ目以降の写真一覧を取得したい場合は API の URL にクエリパラメータ page を付与すれば OK です。

/api/photos/?page=2

クエリパラメータを取得するようなコードは記述していませんが、paginate が呼ばれれば Laravel が勝手にページを適用してくれます。便利ですね。

JSON への変換

コントローラーからモデルクラスのインスタンスなどを return すると、自動的に JSON に変換されてレスポンスが生成されます。

JSON への変換の際、with であらかじめロードされているリレーションは自動的に解決されますがアクセサは項目に含まれません。そのため JSON として返したいアクセサに関してはモデルの $appends プロパティに登録する必要があります。

認証を外す

最後に、写真一覧取得 API は認証していなくてもアクセスできる仕様にしたいので、コンストラクタで設定した認証ミドルウェアの適用対象から index メソッドを外します。

具体的には、以下の通り except メソッドを追記します。

PhotoController.php
public function __construct()
{
    // 認証が必要
    $this->middleware('auth')->except(['index']);
}

テスト実行

API が完成したらテストを実行しましょう。

$ ./vendor/bin/phpunit --testdox

ダウンロードリンク

ルート定義

routes/web.php にルート定義を追加します。

web.php
// 写真ダウンロード
Route::get('/photos/{photo}/download', 'PhotoController@download');

この URL は API ではないので api.php ではなく web.php に記述します。また index テンプレートを返すルートよりも上に記述してください。Laravel のルート定義は上から順番にマッチしたルートに制御が渡されるので、任意の URL を受け入れる index テンプレート返却ルートの方が上にあると必ず先にそちらがマッチしてしまうからです。

コントローラー

PhotoController を編集します。

まずダウンロードリンクは認証不要なので、コンストラクタの認証ミドルウェア設定にて、except の引数に download を追記します。

PhotoController.php
public function __construct()
{
    // 認証が必要
    $this->middleware('auth')->except(['index', 'download']);
}

次に download メソッドを追加してください。

PhotoController.php
/**
 * 写真ダウンロード
 * @param Photo $photo
 * @return \Illuminate\Http\Response
 */
public function download(Photo $photo)
{
    // 写真の存在チェック
    if (! Storage::cloud()->exists($photo->filename)) {
        abort(404);
    }

    $disposition = 'attachment; filename="' . $photo->filename . '"';
    $headers = [
        'Content-Type' => 'application/octet-stream',
        'Content-Disposition' => $disposition,
    ];

    return response(Storage::cloud()->get($photo->filename), 200, $headers);
}

レスポンスヘッダ Content-Dispositionattachment および filename を指定することで、レスポンスの内容(S3 から取得した画像ファイル)を Web ページとして表示するのではなく、ダウンロードさせるために保存ダイアログを開くようにブラウザに指示します。

参考:Content-Disposition | MDN

これでダウンロードリンクは完成です。
/photos/DBに存在する写真ID/download にアクセスして、保存ダイアログが表示されるか、そして写真をダウンロードできるか、確かめてみましょう。

👾 👾 👾

この章はこれでおしまいです。
本章までのソースコードはリポジトリの ch-11 ブランチに置いてあります。

次の章では、写真一覧ページのフロントエンドを実装します。

関連記事

連載記事(全16回)

Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう

その他