Laravelの基本はこちらから学べます

Laravel でスレッド掲示板を作成(中級)。10 章画像投稿 AWS S3




今回は前回に続けて開発を進めていきましょう。
前章をまだご覧になっていない方は以下から開発を始めましょう。

10 章:S3で画像投稿機能

では今回は AWS の S3 を使用して画像投稿機能を作成しましょう。

AWS アカウントの作成

ではまず、アカウントを作成しましょう。

AWS はドキュメントが豊富で日本語にも対応(一部はカタコトな日本語ですが。)しているので、まずは、AWS ドキュメントにあたるようにしましょう。

AWS アカウント作成の流れ
https://aws.amazon.com/jp/register-flow/

AWS アカウントを作成すると、Amazon EC2 や Amazon S3 をはじめ 60 以上の製品を 1 年間無料でお試しいただけます。

また、AWS クラウドの世界中のリージョンで提供されるすべてのサービスを始めることができます。

https://aws.amazon.com/jp/register-flow/

root ユーザーに MFA を設定しておこう

それでは、アカウント作成が完了しましたら実際に AWS マネジメントコンソールにログインしてみましょう。
IAM ユーザーはまだ作成していないと思いますので、ルートユーザでログインします。

まずはセキュリティを高める為に MFA をルートユーザーに設定していきます。

仮想 Multi-Factor Authentication (MFA) デバイスの有効化 (コンソール)
https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html

マイセキュリティ資格情報から変更を行います。

MFA の有効化をクリック。
「仮想 MFA デバイス」を選択して、続行を押しましょう。

Google Authenticator スマホアプリです。
https://apps.apple.com/jp/app/google-authenticator/id388497605

表示の通りに、QRコードを読み込み、連続されるコードを2回入力しましょう。
MFA の割り当てをクリックして成功すれば OK です。

今後、ログインする際には Google Authenticator での認証も必要になりアカウントのセキュリティが高まりました。

ルートユーザーは、あなたの AWS アカウントへの全てのアクセス権限があります。
MFA 設定をしておきましょう。

S3 に画像アップロードするには

では S3 のアップロードの為に以下の作業をしていきます。

  1. AWS S3 にバケットを作成する
  2. 上記のバケットにアクセスできる、IAM ユーザーを作る
  3. Laravel にアップロード処理を記入する
  4. 実際にアップロードする。

S3 にバケットを作成する

検索窓から S3 を検索、S3のコンソールにいきましょう。

「バケットを作成」からバケットを作成していきます。
バケット名は 「2ch」リージョンは 「東京」に設定しました。
他の箇所は変更せずにそのまま作成をします。

では S3 のページに戻ると、「2ch」のバケットが作成済みになっているはずです。

バケット用の IAM ユーザーを作成する

今回は IAM について扱います。
もし、IAM とは?なんぞ?と言う方がいらっしゃいましたら、下記のドキュメントを見て知見を深めて見ましょう。

AWS Identity and Access Management (IAM)
https://aws.amazon.com/jp/iam/

油そば

現在ログインしているユーザーがあるのに、もう一人作成するのか。
バケット用というと、先ほど作成した S3 バケットのアクセス権しかない IAM を作成すると言うこと?

そうですね。もちろんルートユーザーでも可能なのですが、セキュリティの観点から、先ほどのバケットへのアクセスのみを許可した IAM ユーザーを作成し運用していきましょう。

まずは検索窓から IAM を検索し、コンソールにアクセスしましょう。

では IAM コンソールでユーザーをクリックしましょう。

ユーザーを作成から、作成画面に遷移。

「既存のポリシーを直接アタッチ」タブにいき、
ポリシーの作成をクリックしてください。

ポリシーの作成画面で JSON を選択しましょう。
公式を参考に、「2ch」のバケットへのアクセス権限のポリシーを作成します。

Amazon S3: S3 バケットのオブジェクトへの読み取りおよび書き込みアクセスを許可するhttps://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/reference_policies_examples_s3_rw-bucket.html

確定ボタンをクリック(タグは不要)してください。
ポリシーの確認画面で、「s3-2ch-access」を名前としてポリシーを作成しましょう。

では、先ほどのユーザー作成画面からポリシーをアタッチしましょう。

それでは、確認画面までいきましたら「ユーザーの作成」をクリックし、作成しましょう。

では作成が完了しました。
この際に、アクセスキーID、シークレットアクセスキーをメモしておきましょう。

アクセスキーID、シークレットアクセスキーがメモできましたら完了です。

Laravel で画像投稿機能を実装する

.env の変更

では、まずは先ほどの「アクセスキーID」「シークレットアクセスキー」を設定 Poject に登録していきましょう。

.env

AWS_ACCESS_KEY_ID=あなたのアクセスキーID
AWS_SECRET_ACCESS_KEY=あなたのシークレットアクセスキー
AWS_DEFAULT_REGION=ap-northeast-1
AWS_BUCKET=2ch

これで OK です。

S3用のパッケージインストール

では次に Laravel のドキュメントに記載されているパッケージを使用しましょう。

ファイルストレージ Amazon S3: league/flysystem-aws-s3-v3 ~1.0
https://readouble.com/laravel/6.x/ja/filesystem.html

laradock % docker-compose exec workspace bash

/var/www# composer require league/flysystem-aws-s3-v3 ~1.0 

Form のコンポーネント化

画像投稿処理を記述していきます。
その前に、レスを投稿する Form はいくつありますでしょうか。また、その各 Form は同一のものでしょうか。

油そば

Form は同一のものを、index.blade.php と show.blade.php に使っているな。
画像投稿の input を 2 箇所に実装するのは少し面倒だな。

そうですよね。また二箇所あることによって片方を変更したのにもう一方は変更してなかった、と言うエラーの元になりかねません。
DRY原則 と呼んだりします。

ではコンポーネント化していきましょう。

views\components\message-create.blade.php

<form method="POST" action="{{ route('messages.store', $thread->id) }}" class="mb-4" enctype="multipart/form-data">
                            @csrf
                            <div class="form-group">
                                <label for="thread-first-content">内容</label>
                                <textarea name="body" class="form-control" id="thread-first-content" rows="3"
                                    required></textarea>
                            </div>
                            <button type="submit" class="btn btn-primary">書き込む</button>
                        </form>

重複している箇所を取り出し、components 配下に新しい Blade ファイルを作成しました。
また、画像投稿を行うにあたり、enctype=”multipart/form-data” を追加しておきました。

では index.blade.php show.blade.php のこの箇所を以下に書き換えましょう

// <form> 略 <form/> までを以下に置き換えましょう。

 @include('components.message-create', compact('thread'))

今回は、Blade(コンポーネント)に変数が必要なので、compact 関数で渡してあげました。

油そば

これで、画像投稿の処理を記入する箇所が、message-create.blade.php のみになって、楽になったな。
また、間違いもこれから減りそうで、よかった。

実際に http://localhost/threads にアクセスし、メッセージの投稿が正常にできるかどうかを確認しておきましょう。

画像投稿フォームの作成

では画像投稿画面を作成していきましょう

message-create.blade.php

// 略
<div class="form-group">
        <label for="message-images">画像</label>
        <input type="file" class="form-control-file" id="message-images" name="images[]" multiple>
    </div>

この Form は Bootstrap から拝借しました。
https://getbootstrap.com/docs/4.1/components/forms/

また、複数投稿に対応する為に

  1. multiple 属性
  2. name を 配列で指定 ( images[] )

しています。

画像を複数選択できる

バリデーション

では、投稿した際のバリデーションを設定していきましょう。

MessageRequest.php

// 略

/**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'body'     => 'required',
            'images.*' => 'image|mimes:jpg,png,jpeg,gif,svg',
        ];
    }

    public function messages()
    {
        return [
            'body.required' => trans('validation.required'),
            'images.image'  => trans('validation.image'),
            'images.mimes'  => trans('validation.mimes'),
        ];
    }

バリデーションでは、image であるか、拡張子は指定のものかをバリデーションするように追加しました。

画像の保存処理

ではまず、MessageController.php に画像の保存処理を記入していきましょう。
実装にあたり、以下のドキュメントを参考にします。

ファイルストレージ
https://readouble.com/laravel/6.x/ja/filesystem.html

まずは ImageModel を作成します。

Image.php

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Image extends Model
{
    /**
     * The table associated with the model.
     *
     * @var string
     */
    protected $table = 'images';

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'message_id', 's3_file_path'
    ];
}

それでは次に、コントローラに画像保存の処理を追加していきましょう。

MessageController.php

// 略

public function store(MessageRequest $request, int $id)
    {
        try {
            $data = $request->validated();
            $data['user_id'] = Auth::id();
            $message = $this->message_service->createNewMessage($data, $id);

            $images = $request->file('images'); // 投稿された画像を $images に代入
            if ($images) { // $images が存在するか(画像投稿されたかどうか)
                foreach ($images as $image) { // 複数投稿の可能性があるので、反復処理
                    $path = Storage::disk('s3')->put('/', $image); // S3 にアップロード
                    $image = new Image(); // Image を DB に保存していく。
                    $image->s3_file_path = $path;
                    $image->message_id = $message->id;
                    $image->save();
                }
            }
        } catch (Exception $error) {
油そば

Controller が少し Fat だな。これからサービスと、リポジトリに分解していく感じかな。

そうですね。では一旦ここまでで、画像が投稿できるようになっているかを確認してみましょう。

2枚選んで、書き込む!

投稿をしたら、AWS のマネジメントコンソールから S3 -> 2ch のバケットを確認してみましょう。

しっかり2枚アップロードされているね

もしも、AWS から 403 エラーが返ってきたら、キャッシュをクリアして再度試してみましょう。
.env に記載の アクセスキー等が正常に読み込まれていない可能性があります。
「Laravel キャッシュ クリア」で検索いただくと、`php artisan config:clear`等のコマンドを見つけることができます。

また、php コマンドは Laradock の workspace で入力するようにしましょう。

サービス・リポジトリに分解

それでは、サービスクラス、リポジトリクラスに分解して Fat コントローラを解消していきましょう。

ではまずはリポジトリクラスに DB の操作を切り出しましょう。

Repositories\ImageRepository.php

<?php

namespace App\Repositories;

use App\Image;

class ImageRepository
{
    /**
     * @var Image
     */
    protected $image;

    /**
     * ImageRepository constructor.
     *
     * @param Image $Image
     */
    public function __construct(Image $image)
    {
        $this->image = $image;
    }

    /**
     * Create new Image.
     *
     * @param array $data
     * @return Image $image
     */
    public function create(array $data)
    {
        return $this->image->create($data);
    }
}

こちらで、保存処理をリポジトリにまとめることができました。
では、サービスクラスにビジネスロジックを切り出していきましょう。

ImageService.php

<?php

namespace App\Services;

use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Repositories\ImageRepository;
use Illuminate\Support\Facades\Storage;

class ImageService
{
    /**
     * @var ImageRepository
     */
    protected $image_repository;

    /**
     * ImageService constructor.
     *
     * @param ImageRepository $image_repository
     */
    public function __construct(
        ImageRepository $image_repository
    ) {
        $this->image_repository = $image_repository;
    }
    
    /**
     * Create new image and put s3
     *
     * @param array $images
     * @param int $message_id
     *
     * @return Image $image
     */
    public function createNewImages(array $images, int $message_id)
    {
        DB::beginTransaction();
        try {
            foreach ($images as $image) {
                $path = Storage::disk('s3')->put('/', $image);
                $data = [
                    's3_file_path' => $path,
                    'message_id' => $message_id
                ];
                $this->image_repository->create($data);
            }
        } catch (Exception $error) {
            DB::rollBack();
            Log::error($error->getMessage());
            throw new Exception($error->getMessage());
        }
        DB::commit();

        return $image;
    }
}

いかがでしょうか。コントローラに記載していた、ロジックを移し変えました。
また、先ほど作成したリポジトリクラスを使用して保存するように変更しています。

では、コントローラにてこのサービスクラスを使用するようにコードを変更しましょう。

MessageController.php

class MessageController extends Controller
{
    /**
     * The MessageService implementation.
     *
     * @var MessageService
     */
    protected $message_service;

    /**
     * The ImageService implementation.
     *
     * @var ImageService
     */
    protected $image_service;

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct(
        MessageService $message_service,
        ImageService $image_service
    ) {
        $this->middleware('auth');
        $this->message_service = $message_service;
        $this->image_service = $image_service;
    }

    // 略

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function store(MessageRequest $request, int $id)
    {
        try {
            $data = $request->validated();
            $data['user_id'] = Auth::id();
            $message = $this->message_service->createNewMessage($data, $id);

            $images = $request->file('images');
            if ($images) {
                $this->image_service->createNewImages($images, $message->id);
            }
        } catch (Exception $error) {
            return redirect()->route('threads.show', $id)->with('error', 'メッセージの投稿ができませんでした。');
        }

        return redirect()->route('threads.show', $id)->with('success', 'メッセージを投稿しました');
    }

ここまでできましたら、実際に画像投稿をして、動作確認をしましょう。
また、画像が投稿できましたら、AWS コンソールから S3 のバケットに画像が保存されているかを確認しましょう。

S3 に保存した画像を表示する

では画像投稿機能の最後として、保存した画像を表示できるようにしましょう。

まずは、リレーションで画像を表示するために Message.php にメソッドを追加します。

Message.php

// 略
public function images()
    {
        return $this->hasMany('App\Image');
    }
}

それでは、N+1 問題を避けるためにイーガーロードをしましょう。

ThreadController.php

// 略
public function index()
    {
        $threads = $this->thread_service->getThreads(3);
        $threads->load('messages.user', 'messages.images');
        return view('threads.index', compact('threads'));
    }

// 略
public function show($id)
    {
        $thread = $this->thread_repository->findById($id);
        $thread->load('messages.user', 'messages.images');
        return view('threads.show', compact('thread'));
    }
// 略

次に、現在保存している S3 のパスを画像を表示するための URL に変換するメソッドを作成していきましょう。

ImageService.php

// 略
    /**
     * Create temporary url from path
     *
     * @param String $s3_file_path
     * @return String
     */
    public function createTemporaryUrl(String $s3_file_path)
    {
        return Storage::disk('s3')->temporaryUrl($s3_file_path, Carbon::now()->addDay());
    }
油そば

url メソッドもあると思うが、今回は temporaryUrl メソッドを使用するんだな。

そうです。と言うのも、今回の S3 のバケットは「非公開のバケットとオブジェクト」に設定してあり、普通の URL ではアクセスできないようにしています(セキュリティの為)。

なので、Laravel で .env に記載した IAM で temporary(一時的)な URL を発行して、その URL で画像を表示しています。

「非公開のバケットとオブジェクト」を公開設定にすれば、url メソッドでも実装できますが、URL が漏れてしまうと困る(セキュリティ的に)ので、おすすめしません

では、実際に Blade に表示をしていきましょう。

show.blade.php
@inject('image_service', 'App\Services\ImageService')

// 略
<div class="card-body">
                    <p>{{ $loop->iteration }} {{ $message->user->name }} {{ $message->created_at }}</p>
                    <p class="mb-0">{!! $message_service->convertUrl($message->body) !!}</p>
                    <div class="row">
                        @if (!$message->images->isEmpty())
                        @foreach ($message->images as $image)
                            <div class="col-md-3">
                                <img src="{{ $image_service->createTemporaryUrl($image->s3_file_path) }}" class="img-thumbnail" alt="">
                            </div>
                            @endforeach
                        @endif
                    </div>
                </div>

上記のように記入しました。
ポイントは以下です。

  1. if 文。画像がある時のみ、foreach を実行する
  2. $image_service->createTemporaryUrl で一時的な URL を生成し、画像を表示。
やってみよう

index.blade.php でも画像投稿があった際に表示できるようにしてみよう。

では、ここまで実装をすると以下のような形になります。

いい感じ

10 章の変更点は以下からご確認いただけます。
https://github.com/t-aburasoba/thread-board/pull/8/files




コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です