Symfony Advent Calendar 2014 18日目の記事です。

ファイルのアップロードに VichUploaderBundle を使ってみる

Symfonyにおけるファイルのアップロードの基本的な実装はHow to Handle File Uploads with Doctrine (The Symfony CookBook)が参考になると思います。 しかし@ochi51 さんの Symfony Advent Calendar 2014 1日目 で栄えある 13位に輝いた VichUploaderBundle も便利そうです。使ってみましょう。

VichUploaderBundle てなに?

簡単に言ってしまえば「エンティティに紐づくファイルをいい感じに扱ってくれるバンドル」です。特徴は以下のような感じです。

  • エンティティ登録時にファイルを所望のディレクトリに保存してくれる。その際にファイルの命名規則やディレクトリの構成規則などを指定することができる。
  • エンティティ取得時にファイルインスタンス Symfony\Component\HttpFoundation\File\File がアタッチされる(設定に依る)
  • エンティティ削除時にファイルシステムからも削除してくれる
  • ファイルに対するURLを生成するヘルパがある
なお、エンティティのストレージライブラリはDoctrine ORMMongoDB ODMPHPCR ODMPropelが利用可能とのことです。また、ファイルのストレージとしてはローカルのファイルシステムはもちろんKnpGaufretteBundleを使用することで広範な実装に対応することができます。KnpGaufretteBundle については [Gaufrette - PHPで便利なファイルシステム抽象レイヤー QUARTETCOM TECH BLOG](http://tech.quartetcom.co.jp/2014/03/04/gaufrette/) に紹介記事がありますので参考まで。

実装してみた

当エントリではエンティティリポジトリとしてDoctrine ORM、ファイルのストレージとしてはローカルのファイルシステムを用いて商品の表示、登録、削除を行う基本的な機能を作成してみます。

1. インストールしてバンドルを有効化

$ composer require vich/uploader-bundle
<?php
// app/AppKernel.php
public function registerBundles()
{
    $bundles = array(
        // ...
        new Vich\UploaderBundle\VichUploaderBundle(),
        // ...
    );
}

2. データストレージの設定

ここではDoctrine ORMを指定します。

# app/config/config.yml
vich_uploader:
    db_driver: orm

3. ファイルストレージの設定

uri_prefix はファイルへのWebアクセス用URLを構成するPATH INFOの接頭語です。 以下の例のように設定することでURLはhttp://example.com/images/products/filename.jpgといった感じになります。 upload_destination はファイルを保存したい場所のパスですね。

# app/config/config.yml
vich_uploader:
    db_driver: orm

    mappings:
        product_image:
            uri_prefix:         /images/products
            upload_destination: %kernel.root_dir%/../web/images/products

4. エンティティクラスの作成

注意する点にコメントしておきました。

<?php

namespace VichUploaderDemoBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Annotation as Vich;

/**
 * @ORM\Entity
 * @Vich\Uploadable <== クラスに対するコメントにおいてアノテーションをつけます
 */
class Product
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\Column(type="string")
     */
    protected $title;

    /**
     * @Vich\UploadableField(mapping="product_image", fileNameProperty="imageName")
     *   `mapping` は app/config/config.yml で設定した vich_uploader.mappings 配下のラベル
     *   `fileNameProperty` は 当エンティティ内のファイル名を保持するプロパティ名です。
     */
    protected $imageFile;

    /**
     * @ORM\Column(type="string", length=255, name="image_name")
     */
    protected $imageName;  // <== これが $imageFile のアノテーションで指定されている

    public function getId()
    {
        return $this->id;
    }

    public function getTitle()
    {
        return $this->title;
    }

    public function setTitle($title)
    {
        $this->title = $title;

        return $this;
    }

    public function setImageFile(File $image = null)
    {
        $this->imageFile = $image;

        return $this;
    }

    public function getImageFile()
    {
        return $this->imageFile;
    }

    public function setImageName($imageName)
    {
        $this->imageName = $imageName;

        return $this;
    }

    public function getImageName()
    {
        return $this->imageName;
    }
}

5. DBテーブルの作成

$ php app/console doctrine:schema:update

以下のようなテーブルが出来ました。

mysql> desc Product;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int(11)      | NO   | PRI | NULL    | auto_increment |
| title      | varchar(255) | NO   |     | NULL    |                |
| image_name | varchar(255) | NO   |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+

6. フォームタイプの作成

<?php

namespace VichUploaderDemoBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

class ProductType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('title','text')
            ->add('imageFile','file') // <== 第1引数は Product エンティティにおいて UploadableField アノテーションが付与されているプロパティ
            ->add('save','submit', ['label' => '更新'])
        ;
    }

    public function getName()
    {
        return 'product_image';
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults([
            'data_class' => '\VichUploaderDemoBundle\Entity\Product',
        ]);
    }
}

7.コントローラの作成

<?php

namespace VichUploaderDemoBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use VichUploaderDemoBundle\Entity\Product;
use VichUploaderDemoBundle\Form\ProductType;

/**
 * Class ProductController
 *
 * @Route("/products")
 * @package VichUploaderDemoBundle\Controller
 */
class ProductController extends Controller
{
    /**
     * @Route(
     *      "/new",
     *      name="product_form"
     * )
     * @Method({"GET"})
     * @Template()
     * @return array
     */
    public function newAction()
    {
        $form = $this->createForm(new ProductType(), null, ['method' => 'POST']);

        return [
            'form' => $form->createView(),
        ];
    }

    /**
     * @Route(
     *      "",
     *      name="product_create"
     * )
     * @Method({"POST"})
     * @param Request $request
     * @return \Symfony\Component\HttpFoundation\RedirectResponse
     */
    public function createAction(Request $request)
    {
        $product = new Product();
        $form = $this->createForm(new ProductType(), $product);

        $form->handleRequest($request);
        if ($form->isValid()) {
            $em = $this->getDoctrine()->getManager();
            $em->persist($product);
            $em->flush();

            return $this->redirect($this->generateUrl('product_list'));
        }

        return $this->redirect($this->generateUrl('product_form'));
    }

    /**
     * @Route(
     *      "",
     *      name="product_list"
     * )
     * @Method({"GET"})
     * @Template()
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function listAction()
    {
        $products = $this->getDoctrine()->getManager()->getRepository('VichUploaderDemoBundle:Product')->findAll();

        return [
            'products' => $products,
        ];
    }

    /**
     * @Route(
     *      "/{id}",
     *      requirements = {"id" = "\d+"},
     *      name="product_delete"
     * )
     * @Method({"DELETE"})
     * @ParamConverter("product", class="VichUploaderDemoBundle:Product")
     * @param Product $product
     * @return \Symfony\Component\HttpFoundation\RedirectResponse
     */
    public function deleteAction(Product $product)
    {
        $em = $this->getDoctrine()->getManager();
        $em->remove($product);
        $em->flush();

        return $this->redirect($this->generateUrl('product_list'));
    }
}

8. ビューの作成

ファイルのパスを取得するのに次のように記述するところがポイントですね。 {{ vich_uploader_asset(product, 'imageFile') }} imageFile は Productエンティティにおいて UploadableField アノテーションが付与されているプロパティ名です。

{# new.html.twig #}

{{ form_start(form, {action: path('product_create')}) }}
    {{ form_rest(form) }}
{{ form_end(form) }}
{# list.html.twig #}

<ul>
    {% for product in products %}
    <li>
        {{ product.title }}
        <img src="{{ vich_uploader_asset(product, 'imageFile') }}" alt="{{ product.imageName }}" />
        <form action="{{ path('product_delete', {id: product.id}) }}" method="POST">
            <input type="hidden" name="_method" value="DELETE"/>
            <input type="submit" value="削除"/>
        </form>
    </li>
    {% endfor %}
</ul>

アプリを使ってみた

これで商品に紐づく画像の登録、表示、削除が行えるようになりました。 ためしに手元にある桜の画像を登録してみます。

mysql> select * from Product;
+----+--------------+------------+
| id | title        | image_name |
+----+--------------+------------+
|  1 |            | .jpg     |
+----+--------------+------------+

ちゃんと登録されていますね。

$ tree web/images/products
web/images/products
└── 花.jpg

ファイルもバッチリです。 削除した場合もDBからレコードが、ファイルシステムからファイルが削除されているのが確認できると思います。 このように振る舞いを記述することなく設定だけでファイルを簡単に扱う事ができるようになりました。

命名機能

試しにさらにチューリップの画像を登録してみます。なんとこれは偶然にも「花.jpg」という同じファイル名でした。

+----+--------------+------------+
| id | title        | image_name |
+----+--------------+------------+
|  1 |            | .jpg     |
|  2 | チューリップ | .jpg     |
+----+--------------+------------+

DBには2つのレコードが登録されています。

$ tree web/images/products
web/images/products
└── 花.jpg

しかし、ファイルは1つしかありません。これでは困ります。ファイルにユニークな名前をつけてやることでこの問題を回避したいと考えました。そこで使用するのが Namer という命名機能です。 Namerはファイル名向けとディレクトリ名向けがあり、どちらも独自のカスタムネイマーを作成することができます。 またファイル名向けには2つのプリセットがあります。

  • vich_uploader.namer_uniqid
  • vich_uploader.namer_origname

プリセットのネイマーを使ってみる

試しにvich_uploader.namer_uniqidを使ってみましょう。

# app/confog/config.yml
vich_uploader:
    db_driver: orm

    mappings:
        product_image:
            uri_prefix:         /images/products
            upload_destination: %kernel.root_dir%/../web/images/products
            namer:              vich_uploader.namer_uniqid

namer プロパティとして設定することで使用可能になります。

$ tree web/images/products
web/images/products
├── 花.jpg
└── 5490ea1499f80.jpg

できたファイルはこちら。ユニークなIDをファイル名として生成されたのがわかります。なお vich_uploader.namer_origname にした場合は5490ea572ce61_花.jpgといった感じになります。オリジナルのファイル名にユニークIDが接頭辞として付け加えられているのがわかります。

カスタムネイマーを作ってみる

さらにディレクトリ名向けのカスタムネイマーを作成してみましょう。ちょっと落とし穴があるので注意です。 作成と設定自体は簡単です。

<?php

namespace VichUploaderDemoBundle\Service;

use Symfony\Component\Validator\Constraints\DateTime;
use Vich\UploaderBundle\Mapping\PropertyMapping;
use Vich\UploaderBundle\Naming\DirectoryNamerInterface;

class ProductDirectoryNamer implements DirectoryNamerInterface
{
    public function directoryName($object, PropertyMapping $mapping)
    {
        return 'flower'
    }
}
# app/config/config.yml

services:
    vich_uploader_demo.my_directory_namer.product:
        class: VichUploaderDemoBundle\Service\ProductDirectoryNamer

vich_uploader:
    db_driver: orm

    mappings:
        product_image:
            uri_prefix:         /images/products
            upload_destination: %kernel.root_dir%/../web/images/products
            namer:              vich_uploader.namer_uniqid
            directory_namer:    vich_uploader_demo.my_directory_namer.product

ネイマーサービスを作成して設定してやるだけです。ファイルを登録してみましょう。

$ tree web/images/products
web/images
└── products
    └── flower
        └── 549121a8db438.jpg

このようにflowerディレクトリが切られました。しかしハードコードされた名前のディレクトリが切られるだけでは旨味がありませんね。ためしに今日の年月日でディレクトリを切ってみることにします。

    public function directoryName($object, PropertyMapping $mapping)
    {
-        return 'flower'
+        return (new \DateTime())->format('Y/m/d');
    }

サービスを変更してみました。ファイルを登録してみましょう。

\VichUploaderDemoBundle\Entity\Product
$ tree web/images/products
web/images/products
└── 2014
    └── 12
        └── 18
            └── 549104acb104f.jpg

希望通りにディレクトリが切られました。いい感じです。 しかし、ここで注意が必要です。先ほどビューの実装において{{ vich_uploader_asset(product, 'imageFile') }}と記述していた部分です。ここでURLを作る際にこのネイマーを使用しています。よって日が変わると生成されるURLが変わってしまうのです。今日はhttp://example.com/images/product/2014/12/18/549104acb104f.jpgと生成してくれていますが明日にはhttp://example.com/images/product/2014/12/19/549104acb104f.jpgとなってしまいます。

VichUploaderBundle/namers.md at master · dustin10/VichUploaderBundle

Directory namers are called when a file is uploaded but also later, when you want to retrieve the path or URL of an already uploaded file. That's why directory namers MUST be stateless and rely only on the data provided by the mapping or the entity itself to determine the directory.

公式のドキュメントにも返却値がステートレスになるようにとの指示があります。気をつけたいですね。 (ファイルパスをネイマーを使用せずに生成することが前提なら特に問題にはならないでしょう。)

最後に

公式のドキュメントに毛が生えただけな上に散漫なものになってしまいましたが、実際に自分がハマったところが落とし込めたので少しでも皆さんのお役に立てれば幸いです。おかしなところなどあればつっこみくださいませ。