Accueil /  Blog / Symfony / VichUploaderBundle DRY configuration

VichUploaderBundle DRY configuration

Publié le jeudi 4 janvier 2018

Est-il possible d'éviter l'écueil de la configuration à rallonge de VichUploaderBundle ?
Nous pensons que oui.

Présentation du bundle

VichUploaderBundle est devenu un incontournable de l'écosystème Symfony. Il propose une mécanique efficace pour gérer l'upload de fichiers.

L'objectif n'est pas de revenir en détail sur comment configurer ce bundle (la documentation est à priori suffisante), mais de proposer un retour d'expérience sur sa mise en place et sur de "bonnes pratiques" qui découlent de l'expérience.

Cas d'usage

Prenons un cas relativement simple. 2 entités avec chacune une (ou plusieurs) propriété(s) permettant l'upload de fichier :

L'entité User gérant un avatar :

<?php

use Doctrine\ORM\Mapping as ORM;
use Vich\UploaderBundle\Mapping\Annotation as Vich;

class User
{
    /**
     * @var string|null
     *
     * @ORM\Column(type="text", nullable=true)
     */
    private $avatar;

    /**
     * @Vich\UploadableField(mapping="...", fileNameProperty="avatar")
     */
    private $avatarFile;
}

L'entité Product gérant une picture et des instructions :

<?php

use Doctrine\ORM\Mapping as ORM;
use Vich\UploaderBundle\Mapping\Annotation as Vich;

class Product
{
    /**
     * @var string|null
     *
     * @ORM\Column(type="text", nullable=true)
     */
    private $picture;

    /**
     * @Vich\UploadableField(mapping="...", fileNameProperty="picture")
     */
    private $pictureFile;

    /**
     * @var string|null
     *
     * @ORM\Column(type="text", nullable=true)
     */
    private $instructions;

    /**
     * @Vich\UploadableField(mapping="...", fileNameProperty="instructions")
     */
    private $instructionsFile;
}

Problématique

En suivant "bêtement" la documentation du bundle, on aboutit rapidement à ce genre de configuration :

vich_uploader:
    mappings:
        user_avatar:
            uri_prefix: /uploads/user/avatar
            upload_destination: '%kernel.root_dir%/../web/uploads/user/avatar'
        product_picture:
            uri_prefix: /uploads/product/picture
            upload_destination: '%kernel.root_dir%/../web/uploads/product/picture'
        product_instructions:
            uri_prefix: /uploads/user/instructions
            upload_destination: '%kernel.root_dir%/../web/uploads/product/instructions'

Ce n'est pas "mal" à proprement parler, mais cela nuit à la lisibilité de la configuration et engendre beaucoup trop de duplication. D'autant que, pour rappel, le mapping doit également être indiqué dans la configuration de votre entité :

Dans l'entité User, on utilise user_avatar sur la propriété avatarFile :

<?php

class User
{
    /**
     * @Vich\UploadableField(mapping="user_avatar", ...)
     */
    private $avatarFile;
}

Dans l'entité Product, on utilise product_picture sur la propriété pictureFile et product_instructions sur instructionsFile :

<?php

class Product
{
    /**
     * @Vich\UploadableField(mapping="product_picture", ...)
     */
    private $pictureFile;

    /**
     * @Vich\UploadableField(mapping="product_instructions", ...)
     */
    private $instructionsFile;
}

Vous avez compris le principe, c'est pénible...

Mapping par convention

En bon développeur, on repère rapidement un schéma entre tous ces mappings : uploads/{entity type}/{property name}. Et qui dit schéma, dit automatisation possible (après tout, c'est le cœur même de notre métier).

File & Directory Namers

VichUploaderBundle possède 2 composants pour vous donner la main sur le nommage et l'emplacement du fichier :

  • FileNamer qui permet d'agir sur le nom du fichier. Le bundle est livré avec.
  • DirectoryNamer qui permet d'agir sur le nom du sous-dossier dans lequel le fichier sera déposé.

note le bundle est d'ailleurs livré avec plusieurs namers : voir la documentation

En écrivant nous-mêmes 2 classes, on peut facilement prendre la main sur le stockage de ce fichier et factoriser notre configuration.

DirectoryNamer par convention

<?php

namespace AppBundle\Upload\Namer;

use Doctrine\Common\Persistence\ManagerRegistry;
use Vich\UploaderBundle\Mapping\PropertyMapping;
use Vich\UploaderBundle\Naming\DirectoryNamerInterface;
use Vich\UploaderBundle\Util\Transliterator;

class ConventionedDirectoryNamer implements DirectoryNamerInterface
{
    /**
     * @var ManagerRegistry
     */
    private $doctrine;

    /**
     * @param ManagerRegistry $doctrine
     */
    public function __construct(ManagerRegistry $doctrine)
    {
        $this->doctrine = $doctrine;
    }

    public function directoryName($object, PropertyMapping $mapping)
    {
        return sprintf('%s/%s', $this->getShortClassName($object), $this->getIdentifier($object));
    }

    /**
     * Get short class name of given object :
     *  - AppBundle\Entity\Product : product
     *  - AppBundle\Entity\User : user
     *
     * @param object $object
     *
     * @return string
     */
    private function getShortClassName($object)
    {
        $fqcn = get_class($object);
        $classParts = explode('\\', $fqcn);

        return Transliterator::transliterate(array_pop($classParts));
    }

    /**
     * Get identifier given object.
     * Use Doctrine metadata as a generic method.
     *
     * @param object $object
     *
     * @return string
     */
    private function getIdentifier($object)
    {
        $fqcn = get_class($object);
        $identifiers = $this->doctrine->getManagerForClass($fqcn)
            ->getClassMetadata($fqcn)
            ->getIdentifierValues($object);

        return Transliterator::transliterate(reset($identifiers));
    }
}

Il va générer ce genre de chemin : user/{id}, product/{id}.

warning VichUploaderBundle interroge les namers lors des événements prePersist & preUpdate de Doctrine. Si vos IDs sont gérés via un AUTO_INCREMENT (ou une SEQUENCE) vos entités n'auront alors pas encore d'ID, l'identifiant ne peut alors pas faire partie du chemin (ou du nom) du fichier. Vous pouvez utiliser des UUID pour contourner ce problème : ramsey/uuid-doctrine

FileNamer par convention

<?php

namespace AppBundle\Upload\Namer;

use Vich\UploaderBundle\Mapping\PropertyMapping;
use Vich\UploaderBundle\Naming\NamerInterface;
use Vich\UploaderBundle\Naming\Polyfill\FileExtensionTrait;
use Vich\UploaderBundle\Util\Transliterator;

class ConventionedFileNamer implements NamerInterface
{
    use FileExtensionTrait;

    public function name($object, PropertyMapping $mapping)
    {
        $file = $mapping->getFile($object);
        $name = Transliterator::transliterate($mapping->getFileNamePropertyName());

        // append the file extension if there is one
        if ($extension = $this->getExtension($file)) {
            $name = sprintf('%s.%s', $name, $extension);
        }

        return uniqid() . '_' . $name;
    }
}

Il va générer ce genre de nom de fichier : {uniqid}_avatar.png, {uniqid}_picture.jpeg, {uniqid}_instructions.pdf.

Utilisation des namers dans la configuration

Dans la configuration, on n'a plus besoin de conserver qu'un seul mapping (default) auquel on attache nos namers :

vich_uploader:
    mappings:
        default:
            namer: AppBundle\Upload\Namer\ConventionedFileNamer
            directory_namer: AppBundle\Upload\Namer\ConventionedDirectoryNamer
            uri_prefix: /uploads
            upload_destination: '%kernel.root_dir%/../web/uploads'

note c'est bien l'ID du service que vous devez écrire dans namer et directory_namer. Cet article ayant été préparé avec Symfony 3.3, la classe du namer est aussi son ID de service (quoi qu'il en soit, il est nécessaire que le service en question soit public).

Utilisation du mapping unique dans les entités

Il suffit de tout rediriger sur le mapping default.

Dans l'entité User :

<?php

class User
{
    /**
     * @Vich\UploadableField(mapping="default", ...)
     */
    private $avatarFile;
}

Dans l'entité Product :

<?php

class Product
{
    /**
     * @Vich\UploadableField(mapping="default", ...)
     */
    private $pictureFile;

    /**
     * @Vich\UploadableField(mapping="default", ...)
     */
    private $instructionsFile;
}

Conclusion

Avec cette configuration, on obtient une façon assez simple et efficace pour gérer l'upload de nos objets. Les chemins finaux seront donc de la forme suivante : %kernel.root_dir%/../web/uploads/{entity type}/{id}/{uniqid}_{property}.{extension}

web/
└── uploads/
    ├── product/
    │   ├── 6e8fb2c2-5fe6-41b1-9238-3f499b5aabca/
    │   │   ├── 5982cd1b131ab_picture.jpg
    │   │   └── 5982cd1b1519b_instructions.pdf
    │   ├── d81d4d8d-80b6-41b4-a061-1a0a8ccb54f0/
    │   │   └── 5982e22cf3ad5_picture.jpg
    │   └── ...
    ├── user/
    │   ├── 1311bfee-aaef-4e7d-83f7-0f92c5db16b5/
    │   │   └── 5982e21cd23de_avatar.jpg
    │   ├── d3b85796-e1b8-4d2f-be51-acf6c1b382a6/
    │   │   └── 5982cd012df44_avatar.png
    │   └── ...
    └── ...

note Si vous êtes "contraints" par l'utilisation d'identifiants en AUTO_INCREMENT (ou SEQUENCE), l'arbre sera légèrement différent :

web/
└── uploads/
    ├── product/
    │   ├── 5982e22cf3ad5_picture.jpg
    │   ├── 5982cd1b131ab_picture.jpg
    │   ├── 5982cd1b1519b_instructions.pdf
    │   └── ...
    ├── user/
    │   ├── 5982cd012df44_avatar.png
    │   ├── 5982e21cd23de_avatar.jpg
    │   └── ...
    └── ...
Suivez notre actualité en avant première. Pas plus d’une newsletter par mois.