この連載記事では、フロントエンドに 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 を実装します。
AWS S3
写真は AWS S3 にアップロードして保管します。
ここで S3 のバケットと開発用の IAM ユーザーを作成しておきましょう。
S3 バケット
「バケットを作成する」をクリックするとウィザードが開きます。
リージョンは「アジアパシフィック(東京)」を選択すればよいでしょう。
「アクセス許可の設定」では、4つのチェックボックスを外してください。
ここにチェックが付いていると写真の公開ができません。
IAM ユーザー
サービスから IAM を選択し、さらに「ユーザー」を選択します。
「ユーザーを追加」をクリックすると作成フォームに移動します。
Laravel アプリケーションから S3 にアクセスするユーザーを作成するので「AWS アクセスの種類を選択」では「プログラムによるアクセス」にチェックを付けます。
「アクセス許可の設定」では「既存のポリシーを直接アタッチ」をクリックし、「AmazonS3FullAccess」ポリシーにチェックを付けます。
タグは入力しなくて構いません。
次の確認ページで確認して問題なければ「ユーザーの作成」をクリックします。
ユーザーが作成できると以下のページが表示されます。
ここに表示される「アクセスキーID」と「シークレットアクセスキー」をメモしておくか「.csvのダウンロード」から CSV ファイルをダウンロードしておきましょう。この接続情報を使用してアプリケーションから S3 にアクセスすることになります。
テストコード
写真投稿 API もまずはテストコードで動作確認を行います。
$ php artisan make:test PhotoSubmitApiTest
tests/Feature/PhotoSubmitApiTest.php
を以下の内容で編集します。
<?php
namespace Tests\Feature;
use App\Photo;
use App\User;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class PhotoSubmitApiTest extends TestCase
{
use RefreshDatabase;
public function setUp(): void
{
parent::setUp();
$this->user = factory(User::class)->create();
}
/**
* @test
*/
public function should_ファイルをアップロードできる()
{
// S3ではなくテスト用のストレージを使用する
// → storage/framework/testing
Storage::fake('s3');
$response = $this->actingAs($this->user)
->json('POST', route('photo.create'), [
// ダミーファイルを作成して送信している
'photo' => UploadedFile::fake()->image('photo.jpg'),
]);
// レスポンスが201(CREATED)であること
$response->assertStatus(201);
$photo = Photo::first();
// 写真のIDが12桁のランダムな文字列であること
$this->assertRegExp('/^[0-9a-zA-Z-_]{12}$/', $photo->id);
// DBに挿入されたファイル名のファイルがストレージに保存されていること
Storage::cloud()->assertExists($photo->filename);
}
/**
* @test
*/
public function should_データベースエラーの場合はファイルを保存しない()
{
// 乱暴だがこれでDBエラーを起こす
Schema::drop('photos');
Storage::fake('s3');
$response = $this->actingAs($this->user)
->json('POST', route('photo.create'), [
'photo' => UploadedFile::fake()->image('photo.jpg'),
]);
// レスポンスが500(INTERNAL SERVER ERROR)であること
$response->assertStatus(500);
// ストレージにファイルが保存されていないこと
$this->assertEquals(0, count(Storage::cloud()->files()));
}
/**
* @test
*/
public function should_ファイル保存エラーの場合はDBへの挿入はしない()
{
// ストレージをモックして保存時にエラーを起こさせる
Storage::shouldReceive('cloud')
->once()
->andReturnNull();
$response = $this->actingAs($this->user)
->json('POST', route('photo.create'), [
'photo' => UploadedFile::fake()->image('photo.jpg'),
]);
// レスポンスが500(INTERNAL SERVER ERROR)であること
$response->assertStatus(500);
// データベースに何も挿入されていないこと
$this->assertEmpty(Photo::all());
}
}
今回はファイルは S3 に保存してファイル名をデータベースに登録しておくという設計です。そのため、S3 にファイルはあるけどデータベースにファイル名が登録されていない、またはその逆のような不整合な状態を防ぐ必要があります。
そこで、2つめのテストケースはデータベースとの通信でエラーが発生したときにファイルが保存されないこと、3つめのテストケースはファイル保存でエラーが発生したときにデータが登録されないことを確認しています。
Laravel では Storage::fake()
や UploadedFile::fake()
を使って手軽にファイルアップロードのテストができますね。Storage::fake('s3')
を呼び出すとストレージの設定が切り替わり、アプリケーションコード中で Storage::disk('s3')
からファイル保存をしても S3 ではなくテスト用のローカルディレクトリにファイルが保存されます(テストケースごとに削除される)。
API の実装
テストが書けたので API を実装していきます。
.env
先ほど作成した IAM ユーザーおよび S3 バケットの接続情報を記述します。
AWS_ACCESS_KEY_ID=アクセスキーID
AWS_SECRET_ACCESS_KEY=シークレットアクセスキー
AWS_DEFAULT_REGION=ap-northeast-1
AWS_BUCKET=バケット名
AWS_URL=https://s3-ap-northeast-1.amazonaws.com/バケット名/
上述の通りテスト中は S3 へのアップロードは発生しませんが、いま設定してしまいましょう。
マイグレーション
第2章での設計通り、写真テーブルのマイグレーションを作成します。いいねテーブルとコメントテーブルについても作成しておきます。
$ php artisan make:migration create_photos_table --create=photos
$ php artisan make:migration create_likes_table --create=likes
$ php artisan make:migration create_comments_table --create=comments
それぞれの up
メソッドの内容は以下の通りです。
photos テーブル
public function up()
{
Schema::create('photos', function (Blueprint $table) {
$table->string('id')->primary();
$table->unsignedInteger('user_id');
$table->string('filename');
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users');
});
}
likes テーブル
public function up()
{
Schema::create('likes', function (Blueprint $table) {
$table->increments('id');
$table->string('photo_id');
$table->unsignedInteger('user_id');
$table->timestamps();
$table->foreign('photo_id')->references('id')->on('photos');
$table->foreign('user_id')->references('id')->on('users');
});
}
comments テーブル
public function up()
{
Schema::create('comments', function (Blueprint $table) {
$table->increments('id');
$table->string('photo_id');
$table->unsignedInteger('user_id');
$table->text('content');
$table->timestamps();
$table->foreign('photo_id')->references('id')->on('photos');
$table->foreign('user_id')->references('id')->on('users');
});
}
マイグレーション実行
マイグレーションファイルが書けたらテーブルを作成します。
$ php artisan migrate
モデル
次にモデルクラスの実装です。
Photo
写真モデルを作成します。
$ php artisan make:model Photo
今回は写真の ID は12桁のランダムな文字列にしますのでそのためのコードを追加します。
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
class Photo extends Model
{
/** プライマリキーの型 */
protected $keyType = 'string';
/** IDの桁数 */
const ID_LENGTH = 12;
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
if (! Arr::get($this->attributes, 'id')) {
$this->setId();
}
}
/**
* ランダムなID値をid属性に代入する
*/
private function setId()
{
$this->attributes['id'] = $this->getRandomId();
}
/**
* ランダムなID値を生成する
* @return string
*/
private function getRandomId()
{
$characters = array_merge(
range(0, 9), range('a', 'z'),
range('A', 'Z'), ['-', '_']
);
$length = count($characters);
$id = "";
for ($i = 0; $i < self::ID_LENGTH; $i++) {
$id .= $characters[random_int(0, $length - 1)];
}
return $id;
}
}
- プライマリキーの値を初期設定(
int
)から変更したい場合は$keyType
を上書きする。 Photo
作成時に忘れずにsetId
を呼ばなくてはいけないのはデフォルトのルールと違っていて分かりにくい。そのためコンストラクタで自動的にsetId
を呼び出している。
User
ユーザーモデルにも変更を加えます。以下のメソッドを追記してください。
/**
* リレーションシップ - photosテーブル
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function photos()
{
return $this->hasMany('App\Photo');
}
写真モデルとのリレーションを表現しています。
ルーティング
API のルートを定義します。
routes/api.php
に以下の記述を追加してください。
// 写真投稿
Route::post('/photos', 'PhotoController@create')->name('photo.create');
フォームリクエスト
コントローラーの前にフォームリクエストクラスを作成してしまいましょう。
$ php artisan make:request StorePhoto
app/Http/Requests/StorePhoto.php
を以下の内容で編集します。
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePhoto extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'photo' => 'required|file|mimes:jpg,jpeg,png,gif'
];
}
}
写真投稿 API は写真ファイルのみを受け取ります。必須入力であることのほか、今回はファイルであることとファイルタイプが jpg,jpeg,png,gif
であることをルールとして定義しています。
ファイルサイズの制限などもあったらいいかもしれませんね。ただ今回はこれで。
コントローラー
ここまでの準備を踏まえてコントローラーでメインロジックを実装します。
$ php artisan make:controller PhotoController
app/Http/Controllers/PhotoController.php
を以下の内容で編集してください。
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StorePhoto;
use App\Photo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class PhotoController extends Controller
{
public function __construct()
{
// 認証が必要
$this->middleware('auth');
}
/**
* 写真投稿
* @param StorePhoto $request
* @return \Illuminate\Http\Response
*/
public function create(StorePhoto $request)
{
// 投稿写真の拡張子を取得する
$extension = $request->photo->extension();
$photo = new Photo();
// インスタンス生成時に割り振られたランダムなID値と
// 本来の拡張子を組み合わせてファイル名とする
$photo->filename = $photo->id . '.' . $extension;
// S3にファイルを保存する
// 第三引数の'public'はファイルを公開状態で保存するため
Storage::cloud()
->putFileAs('', $request->photo, $photo->filename, 'public');
// データベースエラー時にファイル削除を行うため
// トランザクションを利用する
DB::beginTransaction();
try {
Auth::user()->photos()->save($photo);
DB::commit();
} catch (\Exception $exception) {
DB::rollBack();
// DBとの不整合を避けるためアップロードしたファイルを削除
Storage::cloud()->delete($photo->filename);
throw $exception;
}
// リソースの新規作成なので
// レスポンスコードは201(CREATED)を返却する
return response($photo, 201);
}
}
ポイントはコメントの通りです。
一点ここで説明したいのは Storage::cloud()
についてです。
cloud()
を呼んだ場合は config/filesystems.php
の cloud
の設定にしたがって使用されるストレージが決まります。
'cloud' => env('FILESYSTEM_CLOUD', 's3'),
Azure や GCP など他のクラウドサービスを使う場合は .env
で FILESYSTEM_CLOUD
の値を設定すれば OK です。今回はデフォルト値の S3 を使うので設定しませんでした。
テストの実行
API が書けたのでテストで期待通りに動くことを確認しましょう。
$ ./vendor/bin/phpunit --testdox
S3 操作ライブラリ
最後になりましたが、S3 にアクセスするために必要なライブラリをインストールします。
$ composer require league/flysystem-aws-s3-v3
テストの段階ではダミーの(ローカルの)ストレージを使ったので問題ありませんでしたが、次章では API をフロントエンドと組み合わせて実際に S3 にアップロードするので、ここでインストールしておいてください。
この章はこれでおしまいです。
本章までのソースコードはリポジトリの ch-9 ブランチに置いてあります。
次の章では写真投稿のユーザーインターフェースを実装します。
関連記事
連載記事(全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