この連載記事では、フロントエンドに 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
を以下の内容で編集してください。
<?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
を以下の内容で編集してください。
<?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
にルート定義を追加しましょう。
// 写真一覧
Route::get('/photos', 'PhotoController@index')->name('photo.index');
Photo モデル
app/Photo.php
を編集します。
リレーションシップ
まずは User
モデルとのリレーションを定義します。
以下の owner
メソッドを追加してください。
/**
* リレーションシップ - 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
メソッドを追加します。
/**
* アクセサ - url
* @return string
*/
public function getUrlAttribute()
{
return Storage::cloud()->url($this->attributes['filename']);
}
クラウドストレージの url
メソッドは S3 上のファイルの公開 URL を返却します。具体的には .env
で定義した AWS_URL
と引数のファイル名を結合した値になります。
アクセサは定義しただけではモデルの JSON 表現には現れません。ユーザー定義のアクセサを JSON 表現に含めるためには、明示的に $appends
プロパティに登録する必要があります。
/** 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
プロパティで表現すると以下のようになります。
/** JSONに含めない属性 */
protected $hidden = [
'user_id', 'filename',
self::CREATED_AT, self::UPDATED_AT,
];
一方、$visible
プロパティで表現すると以下のようになります。
/** JSONに含める属性 */
protected $visible = [
'id', 'owner', 'url',
];
どちらを使うかはユースケースによります。ここでは記述がよりシンプルで意図が分かりやすくなっている $visible
プロパティを採用します(一般的にどちらがいいという話ではなく、あくまで今回のケースに限った判断です)。
User モデル
続いて app/User.php
を編集します。
余計な項目を表示させない
Photo
と同じく User
についても JSON で表示しない項目は隠します。
今回は User
の情報は name
だけで足ります。最初から $hidden
プロパティによって password
と remember_token
は隠されるようになっていたはずですが、今回で言えば特に email
は隠しておくべきでしょう。写真の投稿者のメールアドレスは晒す必要がありません。
app/User.php
に $visible
プロパティを追加してください。
protected $visible = [
'name',
];
また、最初からあった $hidden
プロパティは削除してください。
ちなみに同じことを $hidden
プロパティで表現すると以下のようになります。
protected $hidden = [
'id', 'email', 'email_verified_at', 'password', 'remember_token',
self::CREATED_AT, self::UPDATED_AT,
];
このケースに関しても $visible
プロパティを使ったほうがすっきりしていますね。
コントローラー
最後にコントローラーにメインロジックを記述します。
と言ってもモデルクラスを JSON に変換する処理もページ送りも Laravel が面倒を見てくれるので、シンプルなロジックになるでしょう。
PhotoController.php
に index
メソッドを追加してください。
/**
* 写真一覧
*/
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
メソッドを追記します。
public function __construct()
{
// 認証が必要
$this->middleware('auth')->except(['index']);
}
テスト実行
API が完成したらテストを実行しましょう。
$ ./vendor/bin/phpunit --testdox
ダウンロードリンク
ルート定義
routes/web.php
にルート定義を追加します。
// 写真ダウンロード
Route::get('/photos/{photo}/download', 'PhotoController@download');
この URL は API ではないので api.php
ではなく web.php
に記述します。また index テンプレートを返すルートよりも上に記述してください。Laravel のルート定義は上から順番にマッチしたルートに制御が渡されるので、任意の URL を受け入れる index テンプレート返却ルートの方が上にあると必ず先にそちらがマッチしてしまうからです。
コントローラー
PhotoController
を編集します。
まずダウンロードリンクは認証不要なので、コンストラクタの認証ミドルウェア設定にて、except
の引数に download
を追記します。
public function __construct()
{
// 認証が必要
$this->middleware('auth')->except(['index', 'download']);
}
次に download
メソッドを追加してください。
/**
* 写真ダウンロード
* @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-Disposition
に attachment
および filename
を指定することで、レスポンスの内容(S3 から取得した画像ファイル)を Web ページとして表示するのではなく、ダウンロードさせるために保存ダイアログを開くようにブラウザに指示します。
これでダウンロードリンクは完成です。
/photos/DBに存在する写真ID/download
にアクセスして、保存ダイアログが表示されるか、そして写真をダウンロードできるか、確かめてみましょう。
この章はこれでおしまいです。
本章までのソースコードはリポジトリの ch-11 ブランチに置いてあります。
次の章では、写真一覧ページのフロントエンドを実装します。
関連記事
連載記事(全16回)
Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう
- (1) イントロダクション
- (2) アプリケーションの設計
- (3) SPA開発環境とVue Router
- (4) 認証API
- (5) 認証ページ
- (6) 認証機能とVuex
- (7) 認証機能とVuex Part.2
- (8) エラーハンドリング
- (9) 写真投稿API
- (10) 写真投稿フォーム
- (11) 写真一覧取得API
- (12) 写真一覧ページ
- (13) 写真詳細ページ
- (14) コメント投稿機能
- (15) いいね機能
- (16) エラーハンドリング Part.2