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
材料タグ
タグ名を持っています。

# 画像アップロード機能
## 状態変化
**ログイン画面（ログインする）→中略→カクテル作成画面（保存）→（アップロード実行→カクテル情報保存）→カクテル詳細表示画面**

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

## 機能説明していく

<img width="689" alt="スクリーンショット 2018-03-08 23.41.08.png" src="https://qiita-image-store.s3.amazonaws.com/0/200408/97cbf795-8054-ab36-f33f-2ef818db338c.png">

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

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

<img width="686" alt="スクリーンショット 2018-03-08 23.41.18.png" src="https://qiita-image-store.s3.amazonaws.com/0/200408/16e4828a-48e3-a1c9-7e99-14cf198dde53.png">

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

### 1.画面からmultipart/form-dataで画像を送信
* ビュー

```php
<?= $this->Form->file('img', ['class' => 'img']); ?>
```

* コントローラー 

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

* ロジッククラス

```php
        // 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'で送信したので連想配列として下記のように取得できます。

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

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

* ImgUploader#execute()

```php
    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へサムネイルと詳細表示用画像を生成

```php
     /**
     * サムネイルと表示用画像を作成する
     * @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. 生成した２つの画像をS3へ送信

* ImgUploader#upload()

```php
    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()

```php
    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はコンストラクタ内で生成しています。

```php
        $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()

```php
    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はここまでで次回は

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

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