PHP
Heroku
S3
cakephp3
画像アップロード

CakePHP3で自作アプリを作ってみた part5 〜画像アップロードの実装〜

7回に渡って記載していきます。
CakePHP3で自作アプリを作ってみた シリーズ
後半戦です!

今回part5では

  • 画像アップロード

を解説したいと思います。

ブツはこちら公開済み。
https://cocktail-com.herokuapp.com/

データ登録が間に合わなかったので悪しからず。。。
少しづつ登録します。

今までの記事
CakePHP3で自作アプリを作ってみた part1 〜イントロ〜
https://qiita.com/m-hatano/items/61392c33fdbd49376747

CakePHP3で自作アプリを作ってみた part2 〜herokuでHello World!!まで〜
https://qiita.com/m-hatano/items/79480fa380ebc49c0209

CakePHP3で自作アプリを作ってみた part3 〜検索機能の実装〜
https://qiita.com/m-hatano/items/e85b8aa8fcaa0c3410f0

CakePHP3で自作アプリを作ってみた part4 〜登録・編集機能の実装〜
https://qiita.com/m-hatano/items/86657cf8b69fcb542d66


エンティティ説明

復習です。
cocktailsにアップロードした画像のurlを保存しておきます。
画像を表示するときはこのurlをimgタグに当て込んで表示します。

  • cocktails
    カクテルの情報が入っています
    画像URLもここ ←今日はコレ

  • cocktails_elements
    カクテルに紐づく材料(ウォッカとかソーダとか...)
    cocktailsとは1:nになります。
    elementsのidと必要な量を持っています。

  • cocktails_tags
    カクテルに紐づくタグ
    cocktailsとは1:nになります。
    tagsのidを持っています。

  • elements
    材料マスタ
    名前とカテゴリを持っています。

  • tags
    材料タグ
    タグ名を持っています。

画像アップロード機能

状態変化

ログイン画面(ログインする)→中略→カクテル作成画面(保存)→(アップロード実行→カクテル情報保存)→カクテル詳細表示画面

カクテルを保存すると同時にアップロードを実施し、カクテル情報も保存します。
画像のアップロードのステータスに関わらず、カクテル情報自体は保存できるようにエラーハンドリングしています。

機能説明していく

スクリーンショット 2018-03-08 23.41.08.png

ざっくりと説明すると、
1. 画面からmultipart/form-dataで画像を送信
2. アプリで保持された一時ファイルからherokuの/tmpへサムネイルと詳細表示用画像を生成
3. 生成した2つの画像をS3へ送信
4. 3.が成功したら、/tmp配下の画像を削除して、S3の配置先urlを返却
5. 4.で返却されたS3の配置先urlをカクテル情報と一緒にDBへ保存

という感じです。
最終的には下記のような形になります。

スクリーンショット 2018-03-08 23.41.18.png

プログラムを見ていきます。

1.画面からmultipart/form-dataで画像を送信

  • ビュー
<?= $this->Form->file('img', ['class' => 'img']); ?>
  • コントローラー

ロジッククラスにデータを渡しているだけなので省略

  • ロジッククラス
        // S3にアップロードしてアップロード先のURLをセットする
        if(!empty($this->params['img']['name'])){
            $uploader = new ImgUploader($this->params);
            try{
                $img_url = $uploader->execute();
            } catch (FileUploadException $e) {
                $this->logger->log('[ERROR] uploaded image is failed img_url:[ ' . $img_url . ']', LOG_ERR);
            }
            $this->logger->log('uploaded image: ' . $img_url);
        }

保存ボタン押下し、multipart/form-dataでアプリに送信された画像は、プログラムで一時ファイルとして保持されます。
class='img'で送信したので連想配列として下記のように取得できます。

['img' => ['name' => {image_name}, 'error' => {error}, 'tmp_name' => {tmp_name}]]

ImgUploaderにパラメータを渡してexecute()を実行してS3へ送信します。

  • ImgUploader#execute()
    public function execute()
    {
        try {
            if(!file_exists($this->tmp_img_dir) && !mkdir($this->tmp_img_dir, 0700)){
                throw new RuntimeException('Not Found or Not Make tmp_img_dir.');
            }
            // 未定義、複数ファイル、破損攻撃のいずれかの場合は無効処理
            if (!isset($this->params['img']['error']) || !is_int($this->params['img']['error'])){
                throw new RuntimeException('Invalid parameters.');
            }
            // ファイル拡張子を取得
            if(!mime_content_type($this->params['img']['tmp_name'])){
                throw new RuntimeException('Invalid file format.');
            }
            $this->ext = pathinfo($this->params['img']['name'], PATHINFO_EXTENSION);
            // ファイル名を組み立て
            $this->to_file_name = $this->upload_img_prefix . '_' . date( "YmdHis", time()) . '.' . $this->ext;
            // サムネイルと表示用画像を作成
            $this->createDispAndThumb();

            return $this->upload();

        } catch (\Exception $e){
            throw new FileUploadException($e);
        }
    }

一時ディレクトリの存在チェック、攻撃のチェック、拡張子の確認と取得を前半で行います。
その後、配置用のファイル名を組み立て、サムネイルと表示用画像を作成します。

2. アプリで保持された一時ファイルからherokuの/tmpへサムネイルと詳細表示用画像を生成

     /**
     * サムネイルと表示用画像を作成する
     * @param $original_file
     * @param $to_file_name
     */
    private function createDispAndThumb()
    {
        // 生成する画像のパスを生成
        $this->tmp_thumbnail_path = $this->tmp_img_dir . '/thumbnail_' . $this->to_file_name;
        $this->tmp_disp_img_path = $this->tmp_img_dir . '/' . $this->to_file_name;
        // サムネイルと表示用画像を作成する
        $this->resizeImg($this->params['img']["tmp_name"], $this->tmp_thumbnail_path, self::THUMBNAIL_WIDTH);
        $this->resizeImg($this->params['img']["tmp_name"], $this->tmp_disp_img_path, self::DISP_IMG_WIDTH);
    }

    /**
     * リサイズ画像を作成する
     * @param $original_file
     * @param $to_file_path
     * @param $width
     */
    private function resizeImg($original_file, $to_file_path, $width)
    {
        list($original_width, $original_height) = getimagesize($original_file);
        // 縦横比はそのままで空の画像を作成
        $height = round( $original_height * $width / $original_width );
        $image = imagecreatetruecolor($width, $height);
        // オリジナルコピー画像を空画像にマージ
        if($this->ext === 'jpg' || $this->ext === 'jpeg') $original_image = imagecreatefromjpeg($original_file);
        if($this->ext === 'png') $original_image = imagecreatefrompng($original_file);
        if($this->ext === 'gif') $original_image = imagecreatefromgif($original_file);
        imagecopyresized($image, $original_image, 0, 0, 0, 0,
            $width, $height, $original_width, $original_height);
        // ディレクトリに画像を保存
        if($this->ext === 'jpg' || $this->ext === 'jpeg') imagejpeg($image, $to_file_path);
        if($this->ext === 'png') imagepng($image, $to_file_path);
        if($this->ext === 'gif') imagegif($image, $to_file_path);
    }

createDispAndThumbは画像のサイズとパスを指定してresizeImgを呼び出しています。
実際に画像を生成するのはresizeImgの方です。
画像操作は初なのですが、ポイントは

  1. 画像の枠のようなものを作成(imagecreatetruecolor)
  2. オリジナル画像のコピーからコピーを作成する(imagecreatefromjpegなど)
  3. 2.で作成したオリジナル画像のコピーを1.にかぶせる(imagecopyresized)
  4. パスを指定して3.を保存(imagejpegなど)

の流れです。
。。。。引数が多すぎて目がチカチカする。

ここらへんの記事を参考にさせていただきました。
https://qiita.com/yuwaita/items/22a296bdf1898acbd70f
https://webkaru.net/php/function-imagecopyresized/

3. 生成した2つの画像をS3へ送信

  • ImgUploader#upload()
    private function upload()
    {
        $this->logger->log('file_name: ' . $this->to_file_name);
        //画像のアップロード
        $this->s3PutObject($this->tmp_thumbnail_path, 'thumbnail_' . $this->to_file_name);
        $img_url = $this->s3PutObject($this->tmp_disp_img_path, $this->to_file_name);
        // 成功したらディレクトリ配下を削除
        unlink($this->tmp_thumbnail_path);
        unlink($this->tmp_disp_img_path);
        //読み取り用のパスを返す
        return $img_url;
    }
  • ImgUploader#s3PutObject()
    private function s3PutObject($source_file, $to_file_name)
    {
        $result = $this->s3client->putObject([
                    'Bucket' => self::S3_BUCKET_NAME,
                    'Key' => $this->env_dir . '/' . $to_file_name,
                    'ContentType' => mime_content_type($source_file),
                    'SourceFile' => $source_file,
                    'ACL' => 'public-read',
                ]);

        $statusCode = $result['@metadata']['statusCode'];
        if ($statusCode !== 200) {
            throw new \Exception('response error. [statusCode: '.$statusCode.']');
        }
        return $result['ObjectURL'];
    }

s3clientを使用して、S3バケットへ画像を送信します。
返ってきた結果が200以外の場合はExceptionを発生させます。
s3clientはコンストラクタ内で生成しています。

        $this->s3client = new S3Client([
            'credentials' => new Credentials(env('AWS_ACCESS_KEY_ID'), env('AWS_SECRET_ACCESS_KEY')),
            'region' => self::REGION,
            'version' => 'latest',
        ]);

コンストラクタはこんな感じです。
heroku環境変数からAWSキーなどを取得しています。

AWSはドキュメントが充実しています
https://aws.amazon.com/jp/sdk-for-php/

4. 3.が成功したら、/tmp配下の画像を削除して、S3の配置先urlを返却

  • ImgUploader#upload()
    private function upload()
    {
        $this->logger->log('file_name: ' . $this->to_file_name);
        //画像のアップロード
        $this->s3PutObject($this->tmp_thumbnail_path, 'thumbnail_' . $this->to_file_name);
        $img_url = $this->s3PutObject($this->tmp_disp_img_path, $this->to_file_name);
        // 成功したらディレクトリ配下を削除
        unlink($this->tmp_thumbnail_path);
        unlink($this->tmp_disp_img_path);
        //読み取り用のパスを返す
        return $img_url;
    }

最後はそのままです。
unlinkでherokuの/tmpに保存しておいた画像を削除します。
最後にreturnされたurlを返却します。

5. 4.で返却されたS3の配置先urlをカクテル情報と一緒にDBへ保存

そして、part4の保存にurlも乗っけます。
part4はこちら
https://qiita.com/m-hatano/items/86657cf8b69fcb542d66


以上が画像アップロードです。
画像のサムネイル生成などはapache mod_small_lightで動的に生成できますが、今回は画像を生成する方向で実装してみました。

part5はここまでで次回は

  • 削除機能
  • ログイン
  • 認証、認可

を解説します。
最後までお付き合いありがとうございました!