CakePHP3で画像投稿機能付き掲示板作成 ~第三回 画像投稿機能の実装~

[第二回](http://qiita.com/banaba0207/items/b05f0942d39792c412c5)に引き続き、掲示板の作成を行っていきます。
今回は
 - 画像投稿機能の実装
を行っていきます。

#目次
[~第一回 CRUDの実装~](http://qiita.com/banaba0207/items/3610f3f24f4f03e3b93f)
[~第二回 paginate, ナビゲーションバーの実装~](http://qiita.com/banaba0207/items/b05f0942d39792c412c5)
[~第三回 画像投稿機能の実装~](http://qiita.com/banaba0207/items/6cee4cd74b9231ca52c4)
[~第四回 ドラッグアンドドロップ(DnD)での画像添付 ~](http://qiita.com/banaba0207/items/ec5dd68413534eef938a)

#方針
 - inputフォームから画像をアップロードする
    - 形式は ipg, png, gifのみ（最大サイズ 10M）
 - DBには以下の項目を格納する
    - img_name (ユニークな名前)
    - img_ext (ファイルの拡張子)
    - img_size (ファイルの容量)
- ファイルはユニークな名前をつけてローカル（webroot/img以下）に保存する
    - その際、サムネイルも生成する
    - webroot/img/mini以下に保存
- 表示する際はサムネイルで表示し、画像がクリックされた際にlightbox形式で表示する

#アップロードフォームの実装
前回作成したformエレメントを少し編集します。

```php.src/Template/Element/form.ctp
<?= $this->Form->create($post, array(
    'url' => array('action' => $action),
    'type' => 'file',                     //add
    )) ?>
<fieldset>
<?php
        echo $this->Form->hidden('postId');
        echo $this->Form->hidden('resId');
        echo $this->Form->input('title');
        echo $this->Form->input('name');
        echo $this->Form->input('content');
        echo $this->Form->file('img');     //add
        echo "<br>";
    ?>
</fieldset>
<?= $this->Form->button(__('投稿する')) ?>
<?= $this->Form->end() ?>
```

以上でアップロードフォームが生成されます。

#画像の保存
ブサイクなやり方ですが、ご容赦下さい。

- モデルの修正
    - jpg, png, gif以外の拡張子の場合、バリデーションエラーを返すように修正
    - ファイルサイズが10M以上の場合、バリデーションエラーを返すように修正

```php.src/Model/Table/PostsTable.php
~~
        $validator
            ->allowEmpty('img_ext')
            ->add('img_ext', ['list' => [
                'rule' => ['inList', ['jpg', 'png', 'gif']],
                'message' => 'jpg, png, gif のみアップロード可能です.',
            ]]);

        $validator
            ->integer('img_size')
            ->allowEmpty('img_size')
            ->add('img_size', 'comparison', [
                'rule' => ['comparison', '<', 10485760],
                'message' => 'ファイルサイズが超過しています(MaxSize:10M)',
            ]);
~~
```

- コンポーネントの作成
    - add, edit アクションの両方で用いるので、コンポーネント化し、使いまわせるようにします
    - コントローラーからsaveメソッドを呼び出すことで以下のことを行います
        - $postに img_name, img_size, img_extを設定
            - md5(uniqid(rand(), 1))により、ユニークな文字列を生成し、nameに設定しています
        - 画像をローカルに保存
    - ※サムネイル生成のところがややこしすぎるので、より良い方法を思案中

```php.src/Controller/Component/ImgProcessComponent.php
<?php
namespace App\Controller\Component;

use Cake\Controller\Component;
use Cake\Controller\ComponentRegistry;

/**
 * ImgProcess component
 */
class ImgProcessComponent extends Component
{
    function initialize(array $config) {
        $this->controller = $this->_registry->getController();
    }
    
    //validation適用のため、rquestに img_name, img_ext, img_sizeを詰める
    function save($request) {
        $img = $request->data['img'];
        $ext =  pathinfo($img['name'], PATHINFO_EXTENSION);
        $name = md5(uniqid(rand(), 1)).'.'.$ext;
        $request->data['img_ext'] = $ext;
        $request->data['img_size'] = $img['size'];
        $request->data['img_name'] = $name;
    }

    //オリジナルとサムネイル作成
    function generate($tmp_name, $post) {
        move_uploaded_file($tmp_name, 'img/'.$post->img_name);
        $original_file = 'img/'.$post->img_name;;
        list($original_width, $original_height) = getimagesize($original_file);
        $thumb_width = 300;
        $thumb_height = round( $original_height * $thumb_width / $original_width );
        if($post->img_ext === 'jpg') $original_image = imagecreatefromjpeg($original_file);
        if($post->img_ext === 'png') $original_image = imagecreatefrompng($original_file);
        if($post->img_ext === 'gif') $original_image = imagecreatefromgif($original_file);
        $thumb_image = imagecreatetruecolor($thumb_width, $thumb_height);
        imagecopyresized($thumb_image, $original_image, 0, 0, 0, 0,
            $thumb_width, $thumb_height,
            $original_width, $original_height);
        if($post->img_ext === 'jpg') imagejpeg($thumb_image, 'img/mini/'.$post->img_name);
        if($post->img_ext === 'png') imagepng($thumb_image, 'img/mini/'.$post->img_name);
        if($post->img_ext === 'gif') imagegif($thumb_image, 'img/mini/'.$post->img_name);
    }
}
```

- controllerの修正

```php.src/Controller/PostsController.php
~~
    public $components = array(
        'ImgProcess' => array(),
    );
~~
    public function add()
    {
        $post = $this->Posts->newEntity();
        if ($this->request->is('post')) {
            //追加（validation適用のため、requestに色々詰める処理）--------------
            if(!empty($this->request->data['img']['name'])) {
                $this->ImgProcess->save($this->request);
            }
            //------------------------------------------------------------
            $post = $this->Posts->patchEntity($post, $this->request->data);
            if ($post->postId == -1) {
                $post->postId = $this->Posts->find()
                    ->order(['postId' => 'desc'])
                    ->select(['postId'])
                    ->first()['postId'] + 1;
                $post->resId = 0;
            }
            if ($post->name === '') $post->name = '名無しさん';
            if ($this->Posts->save($post)) {
               //追加(ローカルに保存＆サムネイル生成)-------------------------
                if(!empty($this->request->data['img']['name'])) {
                    $this->ImgProcess->generate(
                        $this->request->data['img']['tmp_name'], $post);
                }
               //-------------------------------------------------------
                $this->Flash->success(__('投稿されました.'));
                if ($post->resId ===0) return $this->redirect(['action' => 'index']);
                else                   return $this->redirect($this->referer());
            } else {
                //validation errorの表示準備--------------------------------------------
                if($post->errors()['img_ext'])
                    $this->Flash->error(__($post->errors()['img_ext']['list']));
                if($post->errors()['img_size'])
                    $this->Flash->error(__($post->errors()['img_size']['comparison']));
                //--------------------------------------------------------------------
                $this->Flash->error(__('投稿できませんでした. 再度お試し下さい.'));
                return $this->redirect($this->referer());
            }
        }
        $this->set(compact('post'));
        $this->set('_serialize', ['post']);
    }
~~
}
```
以上で、アップロードされたファイルをローカルに保存することができました。

#画像の表示

- lightboxの導入
    - 英語ですが、コマンドを追っていけば組み込むことができました
        - http://lokeshdhakar.com/projects/lightbox2/

- templeteの編集
    - one_articleエレメントを編集します
        - $this->request->webroot でwebrootのディレクトリを参照するURLを生成できます
        - lightboxを使うため、タグ内を`<a href="<?= $this->request->webroot ?>img/<?= $post->img_name ?>" data-lightbox='image-1'>`のようにしています

```php.src/Template/Element/one_article.ctp
~~
    <div class='panel-boby'>
        <div style="padding:10px">
            <?= h($post->content) ?>
        </div>
        <div style="padding:10px">
            <?php if (!empty($post->img_name)): ?>
                <a href="<?= $this->request->webroot ?>img/<?= $post->img_name ?>" data-lightbox='image-1'>
                <img src="<?= $this->request->webroot ?>img/mini/<?= $post->img_name ?>"
                   alt="<?= $post->img_name ?>" height="200" width="200"/>
                </a>
            <?php endif; ?>
        </div>
    </div>
~~
```

以上により、lightbox形式でローカルにある画像を表示することができました。
![スクリーンショット 2016-03-21 19.14.28.png](https://qiita-image-store.s3.amazonaws.com/0/111756/8fe4f91e-c159-3fe0-7d39-e79e53789959.png)

#削除機能
投稿が削除された際に、画像も一緒に削除するようにdeleteアクションを編集します。
例によってブサイクなコードですが、以下解説です。
 1. 削除対象が返信投稿なのか大元の投稿なのかを判別します
 2. resID=0の場合、すべての返信投稿を削除するためにpostIdが一致する投稿の画像の名前を取得し、配列に保存しておきます
 3. foreachでまわしていき、オリジナルとサムネイルファイルを削除していきます
 4. resId が0 でない場合、`$del_post->img_name`と一致するファイルを削除します


```php.src/Controller/PostsController.php
~~
//画像削除のため
use Cake\Filesystem\Folder;
use Cake\Filesystem\File;
~~
    public function delete($id = null)
    {
        $this->request->allowMethod(['post', 'delete']);
        $del_post = $this->Posts->get($id);
        if($del_post->resId === 0) {                                          //1
            //ファイル削除用に削除するファイル名を詰めた del_imgs を作成　　　　　　　 //2
            $query = $this->Posts->find()
                ->where(['postId =' => $del_post->postId])
                ->select(['img_name'])
                ;
            $del_imgs = [];
            foreach($query as $q) array_push($del_imgs, $q->img_name);
            //-----------------------------------------------------------

            if ($this->Posts->deleteAll(array('postId' => $del_post->postId))) {
                //ファイル削除-----　　　　　　　　　　　　　　　　　　　　　　　　//3
                foreach($del_imgs as $q) {
                    $file = new File(WWW_ROOT.'img/'.$q);
                    $file->delete();
                    $file = new File(WWW_ROOT.'img/mini/'.$q);
                    $file->delete();
                }
                //-----------------
                $this->Flash->success(__('投稿が削除されました.'));
            } else {
                $this->Flash->error(__('投稿が削除されませんでした. もう一度お試し下さい.'));
            }
            return $this->redirect(['action' => 'index']);
        }
        else if ($this->Posts->delete($del_post)) {
            //ファイル削除-----　　　　　　　　　　　　　　　　　　　　　　　　　　//4
            $file = new File(WWW_ROOT.'img/'.$del_post->img_name);
            $file->delete();
            $file = new File(WWW_ROOT.'img/mini/'.$del_post->img_name);
            $file->delete();
            //-----------------
            $this->Flash->success(__('投稿が削除されました.'));
        } else {
            $this->Flash->error(__('投稿が削除されませんでした. もう一度お試し下さい.'));
        }
        return $this->redirect($this->referer());
    }
~~
```
似たような処理がいくつか散らばっているので、上手くリファクタリングしたいです。
とりあえず、以上で投稿を削除した際に、画像ファイルも一緒に削除できるようになりました。

#まとめ
とりあえず実装できましたが、
- サムネイル生成の処理がブサイク
- 削除アクションがブサイク

等問題があるので、何かアドバイスがありましたらコメント欄にてお願いいたしますm(__)m