Est-il possible d'éviter l'écueil de la configuration à rallonge de VichUploaderBundle ?
Nous pensons que oui.
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.
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;
}
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...
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).
VichUploaderBundle possède 2 composants pour vous donner la main sur le nommage et l'emplacement du fichier :
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 unAUTO_INCREMENT
(ou uneSEQUENCE
) 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
.
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
etdirectory_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).
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;
}
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
(ouSEQUENCE
), 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
│ └── ...
└── ...