2019.01.12

Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう (9) 写真投稿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 を実装します。

AWS S3

写真は AWS S3 にアップロードして保管します。
ここで S3 のバケットと開発用の IAM ユーザーを作成しておきましょう。

S3 バケット

「バケットを作成する」をクリックするとウィザードが開きます。

S3 Management Console Step 1

リージョンは「アジアパシフィック(東京)」を選択すればよいでしょう。

S3 Management Console Step 2

「アクセス許可の設定」では、4つのチェックボックスを外してください。
ここにチェックが付いていると写真の公開ができません。

S3 Management Console Step 3

IAM ユーザー

サービスから IAM を選択し、さらに「ユーザー」を選択します。

「ユーザーを追加」をクリックすると作成フォームに移動します。

IAM Management Console

Laravel アプリケーションから S3 にアクセスするユーザーを作成するので「AWS アクセスの種類を選択」では「プログラムによるアクセス」にチェックを付けます。

IAM Management Console

「アクセス許可の設定」では「既存のポリシーを直接アタッチ」をクリックし、「AmazonS3FullAccess」ポリシーにチェックを付けます。

IAM Management Console

タグは入力しなくて構いません。

IAM Management Console

次の確認ページで確認して問題なければ「ユーザーの作成」をクリックします。

ユーザーが作成できると以下のページが表示されます。

IAM Management Console

ここに表示される「アクセスキーID」と「シークレットアクセスキー」をメモしておくか「.csvのダウンロード」から CSV ファイルをダウンロードしておきましょう。この接続情報を使用してアプリケーションから S3 にアクセスすることになります。

テストコード

写真投稿 API もまずはテストコードで動作確認を行います。

$ php artisan make:test PhotoSubmitApiTest

tests/Feature/PhotoSubmitApiTest.php を以下の内容で編集します。

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 バケットの接続情報を記述します。

.env
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 テーブル

XXXX_XX_XX_XXXXXX_create_photos_table.php
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 テーブル

XXXX_XX_XX_XXXXXX_create_likes_table.php
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 テーブル

XXXX_XX_XX_XXXXXX_create_comments_table.php
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桁のランダムな文字列にしますのでそのためのコードを追加します。

Photo.php
<?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

ユーザーモデルにも変更を加えます。以下のメソッドを追記してください。

User.php
/**
 * リレーションシップ - photosテーブル
 * @return \Illuminate\Database\Eloquent\Relations\HasMany
 */
public function photos()
{
    return $this->hasMany('App\Photo');
}

写真モデルとのリレーションを表現しています。

ルーティング

API のルートを定義します。
routes/api.php に以下の記述を追加してください。

api.php
// 写真投稿
Route::post('/photos', 'PhotoController@create')->name('photo.create');

フォームリクエスト

コントローラーの前にフォームリクエストクラスを作成してしまいましょう。

$ php artisan make:request StorePhoto

app/Http/Requests/StorePhoto.php を以下の内容で編集します。

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 を以下の内容で編集してください。

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.phpcloud の設定にしたがって使用されるストレージが決まります。

filesystems.php
'cloud' => env('FILESYSTEM_CLOUD', 's3'),

Azure や GCP など他のクラウドサービスを使う場合は .envFILESYSTEM_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で写真共有アプリを作ろう

その他