L’Object Storage dans une application Symfony

Introduction

Les modes de déploiement et d’hébergement de nos applications évoluent et par conséquent la façon que nous avons de les développer change aussi. Si vous envisagez de déployer vos applications dans un environnement “cloud” il va falloir rendre vos applications “stateless”.

Concrètement, aujourd’hui vos applications Symfony utilise par défaut le système de fichier de votre poste ou de votre serveur pour écrire des fichiers, je pense aux fichiers de Logs, aux sessions, mais aussi à d’éventuels imports que permettrait votre application.

L’objectif, vous l’avez compris, est d’externaliser en dehors de votre application tout ce qui nécessite d’être persisté ou stocké d’une manière ou d’une autre.

Pour les logs il existe des solutions, par exemple Sentry. Pour la gestion des sessions, vous pouvez regarder du côté de Redis, mais aujourd’hui nous allons nous intéresser au stockage dit “Object Storage”, popularisé par AWS avec S3.

Infrastructure du POC

Afin de réaliser notre petit POC (Proof Of Concept) de l’utilisation de l’Object Storage au sein d’une application Symfony, nous allons mettre en place une petite infrastructure avec l’aide de notre ami Docker 😉

Création de notre projet Symfony

Créons un nouveau projet Symfony “ObjectStorage”.

symfony new ObjectStorage --full
cd ObjectStorage

Créons ensuite notre base de donnée MySQL avec l’aide de Docker.

symfony console make:docker:database
 Which database service will you be creating?:
  [0] MySQL
  [1] MariaDB
  [2] Postgres
 > 0

 What version would you like to use? [latest]:
 > lastest

 created: docker-compose.yaml
  Success! 

Et démarrons notre environnement Docker et le serveur Symfony.

docker-compose up -d
symfony serve -d

Créons notre seul et unique entité : “Fichier”, avec un seul et unique champ “nom” (pour le moment).

symfony console make:entity Fichier

 created: src/Entity/Fichier.php
 created: src/Repository/FichierRepository.php
 
 New property name (press <return> to stop adding fields):
 > nom  

 Field type (enter ? to see all types) [string]:
 > string

 Field length [255]:
 > 255

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/Fichier.php

  Success!

Créons et appliquons nos migrations.

symfony console make:migration
symfony console d:m:m

Un back office en 30 secondes avec EasyAdmin

Mettons en place un back office avec EasyAdmin, pour cela on installe la librairie.

 composer require easycorp/easyadmin-bundle

Créons notre tableau de bord d’administration

symfony console make:admin:dashboard

 Which class name do you prefer for your Dashboard controller? [DashboardController]:
 > DashboardController

 In which directory of your project do you want to generate "DashboardController"? [src/Controller/Admin/]:
 > src/Controller/Admin/
                                                                                                              
 [OK] Your dashboard class has been successfully generated.

Puis notre premier CRUD sur notre entité “Fichier”.

symfony console make:admin:crud

 Which Doctrine entity are you going to manage with this CRUD controller?:
  [0] App\Entity\Fichier
 > 0

 Which directory do you want to generate the CRUD controller in? [src/Controller/Admin/]:
 > src/Controller/Admin/

 Namespace of the generated CRUD controller [App\Controller\Admin]:
 > App\Controller\Admin
                                                                                                                      
 [OK] Your CRUD controller class has been successfully generated.

Enfin, branchons notre CRUD dans notre Dashoard 😉

#src/Controller/Admin/DashboardController.php

<?php

namespace App\Controller\Admin;

use App\Entity\Fichier;
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class DashboardController extends AbstractDashboardController
{
    /**
     * @Route("/admin", name="admin")
     */
    public function index(): Response
    {
        return parent::index();
    }

    public function configureDashboard(): Dashboard
    {
        return Dashboard::new()
            ->setTitle('ObjectStorage');
    }

    public function configureMenuItems(): iterable
    {
        yield MenuItem::linktoDashboard('Dashboard', 'fa fa-home');
        yield MenuItem::linkToCrud('Fichier', 'fas fa-list', Fichier::class);
    }
}

Et consultons le résultat dans un navigateur web https://127.0.0.1:8000/admin

Upload de fichier avec VichUploaderBundle

Il est temps de mettre en place un upload de fichier dans notre application Symfony. Pour cela nous allons utiliser l’ultra-classique VichUploaderBundle.

composer require vich/uploader-bundle

Configurons sommairement VichUploader

# config/packages/vich_uploader.yaml

vich_uploader:
    db_driver: orm

    mappings:
        fichier:
            uri_prefix: /fichier
            upload_destination: '%kernel.project_dir%/public/fichier'

Et on modifie notre entité pour prendre en charge VichUploader, et y ajouter les champs nécessaires.

#src/Entity/Fichier.php

<?php

namespace App\Entity;

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

/**
 * @ORM\Entity(repositoryClass=FichierRepository::class)
 * @Vich\Uploadable
 */
class Fichier
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $nom;

    /**
     * NOTE: This is not a mapped field of entity metadata, just a simple property.
     * 
     * @Vich\UploadableField(mapping="fichier", fileNameProperty="imageName")
     * 
     * @var File|null
     */
    private $imageFile;

    /**
     * @ORM\Column(type="string")
     *
     * @var string|null
     */
    private $imageName;

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

    public function getNom(): ?string
    {
        return $this->nom;
    }

    public function setNom(string $nom): self
    {
        $this->nom = $nom;

        return $this;
    }

    /**
     * If manually uploading a file (i.e. not using Symfony Form) ensure an instance
     * of 'UploadedFile' is injected into this setter to trigger the update. If this
     * bundle's configuration parameter 'inject_on_load' is set to 'true' this setter
     * must be able to accept an instance of 'File' as the bundle will inject one here
     * during Doctrine hydration.
     *
     * @param File|\Symfony\Component\HttpFoundation\File\UploadedFile|null $imageFile
     */
    public function setImageFile(?File $imageFile = null): void
    {
        $this->imageFile = $imageFile;
    }

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

    public function setImageName(?string $imageName): void
    {
        $this->imageName = $imageName;
    }

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

Générons et appliquons notre migration.

symfony console make:migration
symfony console d:m:m

Enfin, nous adaptons notre CrudController pour expliquer à EasyAdmin comment gérer l’upload et l’affichage du fichier.

#src/Controller/Admin/FichierCrudController.php

<?php

namespace App\Controller\Admin;

use App\Entity\Fichier;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use Vich\UploaderBundle\Form\Type\VichImageType;

class FichierCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return Fichier::class;
    }

    
    public function configureFields(string $pageName): iterable
    {
        return [
            TextField::new('nom'),
            TextField::new('imageFile', 'Upload')
                ->setFormType(VichImageType::class)
                ->onlyOnForms(),
            ImageField::new('imageName', 'Fichier')
                ->setBasePath('/fichier')
                ->hideOnForm()
        ];
    }
   
}

Testons sans attendre d’uploader quelques fichiers.

Utilisation de Flysystem (avec le flysystem-bundle)

Avant de mettre en place le stockage en mode Object Storage, nous allons mettre en place une couche d’abstraction entre notre upload (ici avec Vich Uploader) et le stockage en tant que tel. De cette manière Vich Uploader n’a pas connaissance du type de stockage, et cela permet de découpler les choses.

Installons le bundle.

composer require league/flysystem-bundle

Paramétrons rapidement flysystem.

# config/packages/flysystem.yaml

# Read the documentation at https://github.com/thephpleague/flysystem-bundle/blob/master/docs/1-getting-started.md
flysystem:
    storages:
        default.storage:
            adapter: 'local'
            options:
                directory: '%kernel.project_dir%/public/fichier'

Et modifions le comportement de VichUploader afin de lui indiquer d’utiliser notre “abstraction” Flysystem.

# config/packages/vich_uploader.yaml

vich_uploader:
    db_driver: orm
    storage: flysystem

    mappings:
        fichier:
            uri_prefix: /fichier
            upload_destination: default.storage

Si nous faisons un nouvel essai d’upload, rien ne change en apparence, les fichiers sont bien upploader dans le même repertoire, mais nous disposons désormais d’une couche d’abstraction, que nous allons maintenant utiliser pour mettre en place l’Object Storage !

Mettre en place un serveur d’Object Storage compatible S3 avec Docker et MinIO

Afin que toutes et tous vous puissiez tester le principe d’ObjectStorage sans avoir besoin d’ouvrir un compte AWS ou autre provider fournissant du stockage compatible S3, nous allons utiliser MinIO avec Docker pour mettre en place localement u serveur d’ObjectStorage compatible S3.

Modifions notre docker-compose.yaml pour y ajouter MinIO.

version: '3.7'
services:
    database:
        image: 'mysql:latest'
        environment:
            MYSQL_ROOT_PASSWORD: password
            MYSQL_DATABASE: main
        ports:
            # To allow the host machine to access the ports below, modify the lines below.
            # For example, to allow the host to connect to port 3306 on the container, you would change
            # "3306" to "3306:3306". Where the first port is exposed to the host and the second is the container port.
            # See https://docs.docker.com/compose/compose-file/#ports for more information.
            - '3306'

    minio:
        image: minio/minio
        environment:
            MINIO_ROOT_USER: access1234
            MINIO_ROOT_PASSWORD: secret1234
        volumes:
            - ./data/minio:/data
        command: server /data --console-address ":9001"
        ports:
            - 9000:9000
            - 9001:9001

Et démarrons le conteneur MinIO.

docker-compose up -d

Connectons-nous à l’interface d’administration http://127.0.0.1:9001/login avec les identifiants spécifiés dans le docker-compose.yaml.

Et constatons que tout est OK.

Créons un bucket “Fichier” depuis le menu Admin>Buckets>Create Bucket.

Pour ne pas nous compliquer la suite de ce POC, modifions le bucket pour le rendre “Public”.

Mettre en place l’utilisation de l’Object Storage dans notre application

Nous y voilà enfin ! Bravo !

Pour que notre flysystem soit en mesure de discuter avec notre serveur MinIO S3, nous devons ajouter une “extension” pour prendre en charge S3.

composer require league/flysystem-aws-s3-v3

Paramétrons notre “client” AWS S3 avec les informations de notre serveur MinIO.

# config/services.yaml

# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.

# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:

services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

    # makes classes in src/ available to be used as services
    # this creates a service per class whose id is the fully-qualified class name
    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'
            - '../src/Tests/'

    # add more service definitions when explicit configuration is needed
    # please note that last definitions always *replace* previous ones
    Aws\S3\S3Client:
        arguments:
            - version: 'latest'
              region: 'eu-east-1'
              endpoint: '127.0.0.1:9000'
              credentials:
                key: 'access1234'
                secret: 'secret1234'

Créons ensuite un nouveau type de stockage dans notre configuration flysystem.

# config/packages/flysystem.yaml

# Read the documentation at https://github.com/thephpleague/flysystem-bundle/blob/master/docs/1-getting-started.md
flysystem:
    storages:
        default.storage:
            adapter: 'local'
            options:
                directory: '%kernel.project_dir%/public/fichier'
        aws.storage:
            adapter: 'aws'
            options:
                client: Aws\S3\S3Client
                bucket: 'fichier'

Et enfin, indiquons à VichUploader d’utiliser ce nouveau type de stockage.

# config/packages/vich_uploader.yaml

vich_uploader:
    db_driver: orm
    storage: flysystem

    mappings:
        fichier:
            uri_prefix: /fichier
            upload_destination: aws.storage

Dernière petite modification, pensons à adapter notre CrudController pour lui indiquer le chemin des images (dans la vraie vie, utilisez des variables d’environnement pour ce genre de chose ou utiliser les fichiers .env).

# src/Controller/Admin/FichierCrudController.php

<?php

namespace App\Controller\Admin;

use App\Entity\Fichier;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use Vich\UploaderBundle\Form\Type\VichImageType;

class FichierCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return Fichier::class;
    }

    
    public function configureFields(string $pageName): iterable
    {
        return [
            TextField::new('nom'),
            TextField::new('imageFile', 'Upload')
                ->setFormType(VichImageType::class)
                ->onlyOnForms(),
            ImageField::new('imageName', 'Fichier')
                ->setBasePath('http://127.0.0.1:9000/fichier/')
                ->hideOnForm()
        ];
    }
   
}

Faite un essai d’upload depuis votre application, et magie, votre fichier est bien uploadé sur le serveur MinIO !

Conclusion

Nous venons de voir que la mise en place du stockage objet avec Symfony et avec l’aide précieuse de quelques Bundles n’est finalement pas si compliqué que cela !

Nous avons profité pour découvrir Flysystem qui nous offre une abstraction bien agréable pour la gestion de nos fichiers, n’hésitez pas à creuser le sujet.

Vous avez ou apercevoir la nouvelle version de EasyAdmin, voir comment Docker et un outil trés précieux et pratique pour tester des technologies, et peut être, allez-vous avoir envie d’expérimenter vous aussi MinIO !

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.