Les passions sont un moteur, essences de la vie..

Logo flux syndication RSS

Bundle Symfony 2 pour la gestion de vos uploads avec Doctrine 2

28 novembre 2011
Symfony2

Après plusieurs mois d’absence, j’ai souhaité reprendre du service et vous offrir un premier article traitant de Symfony 2. Pendant cette période, j’ai été pas mal occupé avec la conception et le développement de plusieurs solutions et notamment d’un extranet exploitant les frameworks Symfony 2 et ExtJS.

J’étais super heureux de pouvoir enfin tester la nouvelle version du framework de Sensio Labs sur un projet de plus grande envergure (j’avais déjà essayé par curiosité ^^). Et mes premières impressions sur cette nouvelle monture… fantastiques ! La simplicité de prise en main est déconcertante, l’organisation du code est devenue plus naturelle, l’injecteur de dépendances est magique,… Je pourrais continuer à faire l’éloge du travail réalisé par les développeurs de Sensio mais ce n’est pas le but de cet article.

Tout d’abord, je souhaite signaler que ce billet est destiné aux personnes connaissant déjà Symfony 2. Si vous êtes débutant, je vous laisse consulter le Quick Tour sur le site de Symfony et les excellents tutoriels de Jérôme Place.

Gestion des chargements de fichiers avec Doctrine

Habituellement le chargement des fichiers est opéré directement depuis une classe d’entité (cookbook file upload). Si votre modèle de données est constitué de plusieurs entités devant gérer cette fonctionnalité, l’implémentation devient vite laborieuse. C’est pourquoi nous allons développer un bundle offrant la possibilité de charger des fichiers sans ajouter une seule ligne de code à votre entité (enfin presque).

Dans un premier temps, nous allons créer un bunble pour tester JFSFUploadBundle. Cette première partie vous permettra d’appréhender le fonctionnement du bundle. Et par la suite, je détaillerai le mécanisme de JFSFUploadBundle.

Télécharger – Code source JFSFUploadBundle

JFSFUploadTestsBundle

Pour notre exemple nous allons créer une table « user », représentée par le schéma suivant :

Comme vous pouvez le voir, nous avons deux champs « photo » et « thumb » qui contiendront le nom des fichiers. Une fois votre base de données créée et les paramètres de connexion configurés, nous allons pouvoir générer le bundle et le controller qui permettront  la gestion des utilisateurs.

app/console generate:bundle
...
app/console doctrine:mapping:import JFSFUploadTestsBundle
app/console doctrine:generate:entities JFSFUploadTestsBundle
app/console doctrine:generate:crud
...

Vous devez désormais disposer d’un bundle permettant l’ajout, la modification et la suppression d’un utilisateur.

Ouvrez le fichier User.php créé lors de la génération des entités et ajoutez les propriétés « filePhoto » et « fileThumb » avec un accès public (les quelques lignes de code dont je vous ai parlé).

namespace JFSF\Bundle\UploadTestsBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * JFSF\Bundle\UploadTestsBundle\Entity\User
 */
class User
{
    public $filePhoto;

    public $fileThumb;

    ...
}

Il faudra également modifier le formulaire pour configurer les champs.

namespace JFSF\Bundle\UploadTestsBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

class UserType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder
            ->add('firstname')
            ->add('lastname')
            ->add('filePhoto', 'file')
            ->add('fileThumb', 'file')
        ;
    }

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

A ce stade, il n’y a plus besoin de coder. Il vous suffira de configurer le fichier services.yml pour gérer vos uploads.

parameters:
    jfsf_upload.configuration.class: JFSF\Bundle\UploadBundle\Config\UploaderConfiguration
    jfsf_upload.uploader.class: JFSF\Bundle\UploadBundle\Uploader\Uploader
    jfsf_upload.listener.class: JFSF\Bundle\UploadBundle\Listener\UploaderListener

services:
    jfsf_upload.configuration:
        class: %jfsf_upload.configuration.class%
        arguments:
            configuration:
                JFSF\Bundle\UploadTestsBundle\Entity\User:
                    photo: { file:filePhoto, destination: %kernel.root_dir%/../web/user/photos } #public, setter, getter
                    thumb: { file:fileThumb, destination: %kernel.root_dir%/../web/user/thumbs } #public, setter, getter
    jfsf_upload.uploader:
        class: %jfsf_upload.uploader.class%
        arguments:
            configuration: @jfsf_upload.configuration
            options:
                unique_filename: true
    jfsf_upload.listener:
        class: %jfsf_upload.listener.class%
        arguments:
            uploader: @jfsf_upload.uploader
        tags:
            - { name: doctrine.event_listener, event: prePersist }
            - { name: doctrine.event_listener, event: preUpdate }
            - { name: doctrine.event_listener, event: postPersist }
            - { name: doctrine.event_listener, event: postUpdate }

Sympa non ? Mais peut-être que quelques explications s’imposent.

jfsf_upload.configuration

Le service jfsf_upload.configuration permet de définir les classes et les propriétés de vos entités pour lesquelles un fichier pourra être chargé. Plusieurs paramètres peuvent être définis pour une propriété.

  • file (obligatoire) : le nom du champ permettant de lier un objet UploadedFile à l’entité
  • destination (obligatoire) : le répertoire où sera envoyé le fichier
  • public (facultatif, défaut = false) : permet de définir la visibilité de la propriété
  • getter(facultatif) : permet de définir l’accessor à utiliser pour accéder à la propriété
  • setter (facultatif) : permet de définir le mutator à utiliser pour modifier la propriété

jfsf_upload.upload

Le service jfsf_upload.upload permet la manipulation des entités et la gestion des fichiers chargés. Comme vous pouvez le voir, le constructeur de la classe Uploader attend une instance de UploaderConfiguration. Certaines options peuvent également être spécifiées pour ce service.

  • unique_filename(facultatif, défaut = true) : un nom de fichier unique est généré automatiquement

jfsf_upload.listener

Pour terminer, le service jfsf_upload.listener permet d’écouter les événements envoyés par Doctrine. Le constructeur de la classe UploaderListener attend l’instance de Uploader et les méthodes de l’uploader seront appelées lors de l’insertion ou d’une mise à jour d’une de vos entités.  Si vous utilisez MongoDB, il vous suffira de configurer le listener pour écouter ses événements.

 jfsf_upload.listener:
        class: %jfsf_upload.listener.class%
        arguments:
            uploader: @jfsf_upload.uploader
        tags:
            - { name: doctrine.odm.mongodb.default_event_listener, event: prePersist }
            - { name: doctrine.odm.mongodb.default_event_listener, event: preUpdate }
            - { name: doctrine.odm.mongodb.default_event_listener, event: postPersist }
            - { name: doctrine.odm.mongodb.default_event_listener, event: postUpdate }

Ok, mais je souhaite gérer le nom des fichiers, comment je fais ?

Pas de problème, il suffit de créer votre listener héritant de la classe JFSF\Bundle\UploadBundle\Listener\UploaderListener et de surcharger les méthodes « prePersit » ou/et « preUpdate ».

namespace JFSF\Bundle\UploadTestsBundle\Listener;

use Doctrine\Common\EventArgs;

use JFSF\Bundle\UploadTestsBundle\Entity\User;

use JFSF\Bundle\UploadBundle\Listener\UploaderListener;

class CustomUploaderListener extends UploaderListener
{
    public function prePersist(EventArgs $eventArgs)
    {
        if($eventArgs->getEntity() instanceof User)
        {
            $this->uploader->preUpload($eventArgs->getEntity(), array(
                'photo' => array('filename' => 'name-photo.jpg'),
                'thumb' => array('filename' => 'name-thumb.jpg'),
            ));
        }
    }
}

Pour terminer, il faudra simplement modifier la classe à utiliser dans le fichier services.yml.

parameters:
    jfsf_upload.configuration.class: JFSF\Bundle\UploadBundle\Config\UploaderConfiguration
    jfsf_upload.uploader.class: JFSF\Bundle\UploadBundle\Uploader\Uploader
    jfsf_upload.listener.class: JFSF\Bundle\UploadTestsBundle\Listener\CustomUploaderListener

JFSFUploadBundle

Nous allons maintenant décortiquer le bundle JFSFUploadBundle dans le but de simplifier sa compréhension. Les classes de configuration ne seront pas détaillées, pas vraiment utile pour comprendre le fonctionnement du système.

Vous avez pu voir dans la première partie de l’article qu’une instance de la classe UploaderConfiguration était passée au constructeur de la classe Uploader. Une interface UploaderConfigurationInterface sera donc définie et devra être implémentée par la classe UploaderConfiguration.

namespace JFSF\Bundle\UploadBundle\Config;

interface UploaderConfigurationInterface
{
    function getEntityConfiguration($entity);
}

La classe Uploader devra également respecter un certain contrat en implémentant l’interface UploaderInterface.

namespace JFSF\Bundle\UploadBundle\Uploader;

interface UploaderInterface
{
    function preUpload($entity, array $options);

    function upload($entity);
}
namespace JFSF\Bundle\UploadBundle\Uploader;

use JFSF\Bundle\UploadBundle\Config\UploaderConfigurationInterface;

class Uploader implements UploaderInterface
{
    protected $configuration;

    protected $options = array(
        'unique_filename' => true,
    );

    public function setOptions(array $options)
    {
        $this->options = array_merge($this->options, $options);
    }

    public function getOptions()
    {
        return $this->options;
    }

    public function setUniqueFilename($value)
    {
        if(is_bool($value)){
            $this->options['unique_filename'] = $value;
        }
    }

    public function getUniqueFilename()
    {
        return $this->options['unique_filename'];
    }

    protected $isPreUpload = false;

    protected $entityUploader;

    public function __construct(UploaderConfigurationInterface $configuration, array $options)
    {
        $this->configuration = $configuration;

        $this->setOptions($options);
    }

    public function preUpload($entity, array $options = array())
    {
        $this->isPreUpload = true;

        $entityConfiguration = $this->configuration->getEntityConfiguration($entity);

        if(null !== $entityConfiguration)
        {
            $this->entityUploader = new EntityUploader($this, $entity, $entityConfiguration);

            foreach ($this->entityUploader as $propertyUploader)
            {
                $propertyUploader->preUpload($options);
            }
        }
    }

    public function upload($entity)
    {
        if(false === $this->isPreUpload) {
            throw new UploaderException('Uploading impossible, the method "preUpload" must be called.');
        }

        $this->isPreUpload = false;

        if(null !== $this->entityUploader)
        {
            foreach ($this->entityUploader as $propertyUploader)
            {
                $propertyUploader->upload();
            }

            $this->entityUploader = null;
        }
    }
}

Lors de l’appel à la méthode « preUpload », vous remarquerez l’invocation de la méthode « getEntityConfiguration » qui permet de retourner la configuration pour un type d’entité. Si le type d’entité n’a pas été configuré, une exception InvalidConfigurationException sera levée. Une fois la configuration retournée, un objet EntityUploader est instancié. Cette classe implémente l’interface Iterator permettant de récupérer les objets PropertyUploader à l’aide d’une instruction foreach.

namespace JFSF\Bundle\UploadBundle\Uploader;

use JFSF\Bundle\UploadBundle\Config\EntityConfigurationInterface;

class EntityUploader implements \Iterator
{
    protected $uploader;

    public function getUploader()
    {
        return $this->uploader;
    }

    protected $entity;

    public function getEntity()
    {
        return $this->entity;
    }

    protected $entityConfiguration;

    public function getEntityConfiguration()
    {
        return $this->entityConfiguration;
    }

    private $index = 0;

    private $properties = array();  

    public function __construct(Uploader $uploader, $entity, EntityConfigurationInterface $entityConfiguration)
    {
        $this->uploader = $uploader;
        $this->entity = $entity;
        $this->entityConfiguration = $entityConfiguration;

        foreach ($this->entityConfiguration as $propertyConfiguration)
        {
            $uploadedFile = $this->entity->{ $propertyConfiguration->getFile() };

            if(null !== $uploadedFile) {
                $this->properties[] = new PropertyUploader($this, $propertyConfiguration->getProperty(), $propertyConfiguration, $uploadedFile);
            }
        }
    }

    public function rewind()
    {
        $this->index = 0;
    }

    public function current()
    {
        return $this->properties[$this->index];
    }

    public function key()
    {
        return $this->index;
    }

    public function next()
    {
        $this->index ++;
    }

    public function valid()
    {
        return isset($this->properties[$this->index]);
    }
}

Pour chaque objet PropertyUploader la méthode « preUpload » sera appelée pour modifier la propriété de l’entité avec le nom du fichier. Par la suite, la méthode « upload » devra être invoquée pour déplacer le fichier chargé à la bonne destination.

namespace JFSF\Bundle\UploadBundle\Uploader;

use JFSF\Bundle\UploadBundle\Config\PropertyConfigurationInterface;

use Symfony\Component\HttpFoundation\File\UploadedFile;

class PropertyUploader
{
    protected $entityUploader;

    public function getEntityUploader()
    {
        return $this->entityUploader;
    }

    protected $property;

    public function getProperty()
    {
        return $this->property;
    }

    protected $propertyConfiguration;

    public function getPropertyConfiguration()
    {
        return $this->propertyConfiguration;
    }

    protected $uplodedFile;

    public function getUplodedFile()
    {
        return $this->uplodedFile;
    }

    public function __construct(EntityUploader $entityUploader, $property, PropertyConfigurationInterface $propertyConfiguration, UploadedFile $uplodedFile)
    {
        $this->entityUploader = $entityUploader;
        $this->property = $property;
        $this->propertyConfiguration = $propertyConfiguration;
        $this->uplodedFile = $uplodedFile;
    }

    public function preUpload(array $options)
    {
        $filename = empty($options[$this->property]['filename']) ? $this->getFilename() : $options[$this->property]['filename'];

        if($this->propertyConfiguration->isPublic()){
            $this->entityUploader->getEntity()->{$this->property} = $filename;
        }
        else {
            $setter = $this->propertyConfiguration->getSetter();
            $this->entityUploader->getEntity()->{$setter}( $filename );
        }
    }

    public function upload()
    {
        if($this->propertyConfiguration->isPublic()){
            $filename = $this->entityUploader->getEntity()->{$this->property};
        }
        else {
            $getter = $this->propertyConfiguration->getGetter();
            $filename = $this->entityUploader->getEntity()->{$getter}();
        }

        $this->uplodedFile->move( $this->propertyConfiguration->getDestination() , $filename );

        unset($this->entityUploader->getEntity()->{ $this->propertyConfiguration->getFile() });
    }

    protected function getFilename()
    {
        if($this->entityUploader->getUploader()->getUniqueFilename()) {
            return uniqid().'.'. pathinfo($this->uplodedFile->getClientOriginalName(), PATHINFO_EXTENSION);
        }
        else {
            return $this->uplodedFile->getClientOriginalName();
        }
    }
}

A quel moment les méthodes de l’uploader sont appelées ? Tout simplement lors de l’insertion ou d’une mise à jour d’une de vos entités depuis la classe UploaderListener.

namespace JFSF\Bundle\UploadBundle\Listener;

use JFSF\Bundle\UploadBundle\Uploader\UploaderInterface;

use Doctrine\Common\EventArgs;

class UploaderListener implements UploaderListenerInterface
{
    protected $uploader;

    public function __construct( UploaderInterface $uploader )
    {
        $this->uploader = $uploader;
    }

    public function prePersist(EventArgs $eventArgs)
    {
        $this->uploader->preUpload($eventArgs->getEntity());
    }

    public function preUpdate(EventArgs $eventArgs)
    {
        $this->uploader->preUpload($eventArgs->getEntity());
    }

    public function postPersist(EventArgs $eventArgs)
    {
        $this->uploader->upload($eventArgs->getEntity());
    }

    public function postUpdate(EventArgs $eventArgs)
    {
        $this->uploader->upload($eventArgs->getEntity());
    }
}

Vous disposez désormais d’un bundle modulable pour la gestion de vos uploads. En espérant que cet article inspirera quelques personnes ^^ Voici une liste de fonctionnalités que vous pouvez vous amuser à coder :

  • suppression des anciens fichiers lors d’un UPDATE
  • suppression du fichier lors d’un DELETE,…
Logo flux syndication RSS

Laisser un commentaire

Tu me dis, j'oublie. Tu m'enseignes, je me souviens. Tu m'impliques, j'apprends..
Benjamin Franklin

Cette citation de Benjamin Franklin illustre parfaitement mes sentiments. Chaque jour, je cherche à progresser et acquérir de nouvelles connaissances. Cet enseignement se traduit par l'échange et la remise en question, alors n'hésitez pas à partager votre analyse en postant un commentaire.

*
*
*

Vous pouvez utiliser ces balises et attributs HTML : <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

12 Réponse(s) pour Bundle Symfony 2 pour la gestion de vos uploads avec Doctrine 2

    Cléo - Répondre
    25 décembre 2012 à 21 h 45 min

    Salut,

    J'ai suivi le tuto à la lettre et tout fonctionne.

    Par contre, quand j'essaie d'implémenter le code dans une nouvelle Entité, lors de la validation du formulaire, j'obtiens une erreur lorsque je soumets mon formulaire.
    Avec un var_dump sur mon entité, voici le code :

    ["error":"Symfony\Component\HttpFoundation\File\UploadedFile":private]=> int(0) ["pathName":"SplFileInfo":private]=> string(36) "/Applications/MAMP/tmp/php/phpctAgfD"

    Après divers tests, si je crée un bundle de zéro puis en implémentant le tuto, cela fonctionne. En tentant de créer une nouvelle entité dans un bundle existant, ça ne fonctionne plus et j'obtiens l'erreur ci-dessus.

    Il existe peut-être un problème avec le services.yml.
    Si quelqu'un a déjà été confronté à ce problème... Merci !

    fareh - Répondre
    28 juin 2012 à 19 h 59 min

    Voilà aussi un bon tutoriel qui explique très bien l'upload des fichiers et images avec doctorine :

    http://www.dev-skills.com/upload-file-with-doctrine-in-symfony2/

    Ce tuto met chaque image dans le dossier portant l'id de la page .

    Ahmed - Répondre
    19 avril 2012 à 10 h 16 min

    Salut,
    j'ai toujours le path temporaire du fichier qui s'insère dans la base au lieux du nom généré
    Merci.

    movrack - Répondre
    31 décembre 2011 à 1 h 44 min

    Yop, encore moi.
    Je suis arriver à l'étape, comment supprimer le fichier lors de la suppression de l'entité.

    J'ai vu que dans la class Uploader, la méthode remove ce charge de la suppression du fichier sur le disque. Pourtant quand je teste, cela ne fonctionne pas.
    J'ai également tenté de la ré implémenté dans mon listener mais cela ne fonctionne pas plus.

    public function postRemove(EventArgs $eventArgs) {
    $entity = $eventArgs->getEntity();

    if ($entity instanceof EpisodeVersion) {
    if ($entity->fileMp3 != null) {
    unlink($entity->fileMp3->getAbsolutePathOgg());
    }
    if ($entity->fileOgg != null) {
    unlink($entity->fileOgg->getAbsolutePathOgg());
    }
    }

    $this->uploader->remove($eventArgs->getEntity());
    }

    Est-ce possible que ce soit parce que la suppressions ce fait via un cascade de doctrine?
    J'ai une entité Episode qui contient des EpisodeVersion (qui contiennent les mp3 et ogg)

    Sinon une remarque pour ton tuto car j'ai un peu chercher mais j'y suis arriver. Pour renommer le fichier lors de l'upload:
    $eventArgs->getEntity()->photo->getClientOriginalName()
    permettra de retrouver le nom original. C'est bête mais ça va en éviter certain qui commence avec sf à chercher comment on fait ;)

      Jérôme Fath - Répondre
      31 décembre 2011 à 14 h 59 min

      Yo, movrack merci pour le commentaire. Concernant la classe d'uploader, je n'ai jamais implémenté la méthode remove().


      interface UploaderInterface
      {
      function preUpload($entity, array $options);

      function upload($entity);
      }

      Je sais que bat a réalisé un fork du bundle, tu parles peut être de son code.

      Pour automatiser la suppression des fichiers, l'implémentation du code devrait être réalisé dans les classes Uploader, EntityUploader et PropertyUploader et non depuis le Listener.

    movrack - Répondre
    30 décembre 2011 à 15 h 29 min

    Pas très constructif par rapport au post, mais cela fait tjs plaisir à entendre.
    Merci pour ce bundle. Très pratique.

    Jérôme Fath - Répondre
    20 décembre 2011 à 18 h 04 min

    Désolé bat, je me suis mal exprimé. L'upload des fichiers fonctionne de manière transparente grâce à cette configuration :


    jfsf_upload.listener:
    class: JFSF\Bundle\UploadBundle\Listener\UploaderListener
    arguments:
    uploader: @jfsf_upload.uploader
    tags:
    - { name: doctrine.event_listener, event: prePersist }
    - { name: doctrine.event_listener, event: preUpdate }
    - { name: doctrine.event_listener, event: postPersist }
    - { name: doctrine.event_listener, event: postUpdate }

    A chaque fois qu'une entité est ajoutée, doctrine diffuse les événements prePersist et postPersit et les méthodes prePersit() et postPersist() de la classe UploaderListener sont appelées.


    public function prePersist(EventArgs $eventArgs)
    {
    $this->uploader->preUpload($eventArgs->getEntity());
    }

    public function preUpdate(EventArgs $eventArgs)
    {
    $this->uploader->preUpload($eventArgs->getEntity());
    }

    Ce qui permet le chargement des fichiers sans une seule ligne de code, c'est justement l'appel aux méthodes du listener lors de l'insertion ou la maj des différentes entités. L'uploader vérifie ensuite la configuration des entités, si l'entité est configurée alors le processus est lancé. Il ne doit en aucun cas lever une exception si l'entité n'est pas configurée.

    Tu peux également te passer du listener et gérer tes uploads depuis ton controller mais il faut coder ^^

    public function createAction()
    {
    $entity = new User();
    $request = $this->getRequest();
    $form = $this->createForm(new UserType(), $entity);
    $form->bindRequest($request);

    if ($form->isValid()) {

    $this->container->get('jfsf_upload.uploader')->preUpload($entity);

    $em = $this->getDoctrine()->getEntityManager();
    $em->persist($entity);
    $em->flush();

    $this->container->get('jfsf_upload.uploader')->upload($entity);

    return $this->redirect($this->generateUrl('user_show', array('id' => $entity->getId())));

    }

    return $this->render('JFSFUploadTestsBundle:User:new.html.twig', array(
    'entity' => $entity,
    'form' => $form->createView()
    ));
    }

    bat - Répondre
    20 décembre 2011 à 10 h 26 min

    Merci beaucoup pour ce bel exemple.
    Par contre je n'arrive pas à enregistrer le nom de l'image sur un update (le fichier est bien créé).
    Sur une création tout fonctionne correctement.

      bat - Répondre
      20 décembre 2011 à 12 h 12 min

      Autre problème : les évènements sont déclenchés pour toutes mes entités.
      J'ai donc maintenant partout des "The entity class "%s" is not configured for uploader.".

        Jérôme Fath - Répondre
        20 décembre 2011 à 15 h 03 min

        Hello bat,

        Merci pour la remarque, je viens de mettre à jour l'article et le code source du Bundle. Désormais aucune exception ne sera levée lorsque l'entité n'est pas configurée.

        Concernant le problème du nom de fichier qui n'est pas mis à jour lors d'un update. Je n'ai pas le temps de regarder pour le moment mais dès que l'occasion se présente, je me penche dessus. Si tu trouves la solution n'hésite pas à la partager.

          bat - Répondre
          20 décembre 2011 à 16 h 49 min

          Hélas je ne pense pas qu'enlever l'exception soit suffisant : je pense qu'il faut pour chaque callback vérifier que l'entité fait partie des entités déclarées dans les paramètres du service (configuration), et si ce n'est pas le cas, ne pas lancer l'update.

          J'en arrive alors à avoir des doutes quant à la pertinence de cette solution.
          Corrige-moi si je me trompe, mais si tu as 100 entités, et seulement une dizaine qui implémentent l'upload de fichier, les callbacks seront appelées pour rien dans 90% des cas (je simplifie bien sûr), ce qui n'est pas trop optimal...