Doctrine en dehors des sentiers battus

Doctrine en dehors des sentiers battus

L'ORM Doctrine offre beaucoup plus de flexibilité qu'il n'y paraît. Dans cette présentation, nous allons nous intéresser à son fonctionnement interne et à ses fonctionnalités moins connues, pour découvrir comment mieux l'utiliser. Au programme, évènements et listeners, filtres, tracking policy, mais aussi des astuces sur des architectures possibles pour son code !

Présenté le 29 mars SymfonyLive Paris 2019

0d02cc7698020597fdff97397baf799b?s=128

Romaric Drigon

March 29, 2019
Tweet

Transcript

  1. Doctrine en dehors des sentiers battus

  2. Romaric Drigon, @romaricdrigon Software engineer chez à netinfluence !

  3. ❗ = à utiliser avec précaution

  4. SELECT Que se passe-t-il lorsque je sélectionne une entité ?

  5. Une requête basique $blog = $entityManager->getRepository(Blog::class)->find(1);

  6. Une requête basique $blog = $entityManager->getRepository(Blog::class)->find(1); — Doctrine ORM regarde

    dans le mapping (ClassMetadata) quel est l'identifiant (ID) de notre entité
  7. Une requête basique $blog = $entityManager->getRepository(Blog::class)->find(1); — Doctrine ORM regarde

    dans le mapping (ClassMetadata) quel est l'identifiant (ID) de notre entité — dans le cas d'une requête par ID, il regarde dans l'identityMap si l'entité n'a pas déjà été chargée
  8. Une requête basique $blog = $entityManager->getRepository(Blog::class)->find(1); — Doctrine ORM regarde

    dans le mapping (ClassMetadata) quel est l'identifiant (ID) de notre entité — dans le cas d'une requête par ID, il regarde dans l'identityMap si l'entité n'a pas déjà été chargée — si non, Doctrine va générer une requête SQL
  9. Une requête basique $blog = $entityManager->getRepository(Blog::class)->find(1); — Doctrine ORM regarde

    dans le mapping (ClassMetadata) quel est l'identifiant (ID) de notre entité — dans le cas d'une requête par ID, il regarde dans l'identityMap si l'entité n'a pas déjà été chargée — si non, Doctrine va générer une requête SQL — Doctrine DBAL va l'exécuter
  10. Une requête basique $blog = $entityManager->getRepository(Blog::class)->find(1); — Doctrine ORM regarde

    dans le mapping (ClassMetadata) quel est l'identifiant (ID) de notre entité — dans le cas d'une requête par ID, il regarde dans l'identityMap si l'entité n'a pas déjà été chargée — si non, Doctrine va générer une requête SQL — Doctrine DBAL va l'exécuter — Doctrine ORM va construire un objet à partir du résultat (hydratation)
  11. Une requête basique $blog = $entityManager->getRepository(Blog::class)->find(1); — Doctrine ORM regarde

    dans le mapping (ClassMetadata) quel est l'identifiant (ID) de notre entité — dans le cas d'une requête par ID, il regarde dans l'identityMap si l'entité n'a pas déjà été chargée — si non, Doctrine va générer une requête SQL — Doctrine DBAL va l'exécuter — Doctrine ORM va construire un objet à partir du résultat (hydratation) — elle sera ajoutée à l'identityMap, puis retournée
  12. DQL : une abstraction de SQL // class UserRepository $query

    = $this->createQueryBuilder('u') ->where('u.name = :name') ->getQuery(); // SELECT u FROM UserBundle\Entity\User u WHERE u.name = :name $query->getDQL(); // SELECT u0_.id AS id_0, u0_. u.name AS u.name_0 ... FROM user u0_ WHERE u0_.name = ? $query->getSQL(); $query->setParameter('name', $name); // User[] $users = query->getResult();
  13. DQL : une abstraction de SQL // class UserRepository $query

    = $this->createQueryBuilder('u') ->where('u.name = :name') ->getQuery(); // SELECT u FROM UserBundle\Entity\User u WHERE u.name = :name $query->getDQL(); // SELECT u0_.id AS id_0, u0_. u.name AS u.name_0 ... FROM user u0_ WHERE u0_.name = ? $query->getSQL(); $query->setParameter('name', $name); // User[] $users = query->getResult();
  14. DQL : une abstraction de SQL // class UserRepository $query

    = $this->createQueryBuilder('u') ->where('u.name = :name') ->getQuery(); // SELECT u FROM UserBundle\Entity\User u WHERE u.name = :name $query->getDQL(); // SELECT u0_.id AS id_0, u0_. u.name AS u.name_0 ... FROM user u0_ WHERE u0_.name = ? $query->getSQL(); $query->setParameter('name', $name); // User[] $users = query->getResult();
  15. DQL : une abstraction de SQL // class UserRepository $query

    = $this->createQueryBuilder('u') ->where('u.name = :name') ->getQuery(); // SELECT u FROM UserBundle\Entity\User u WHERE u.name = :name $query->getDQL(); // SELECT u0_.id AS id_0, u0_. u.name AS u.name_0 ... FROM user u0_ WHERE u0_.name = ? $query->getSQL(); $query->setParameter('name', $name); // User[] $users = query->getResult();
  16. DQL : fonctions // Exemple d'utilisation de fonction DQL $query

    = $this->createQueryBuilder('u') ->where('u.name = :name') ->andWhere('u.expiresAt < DATE_ADD(CURRENT_DATE(), 1, \'day\')') ->getQuery(); // On peut aussi tout écrire en DQL $dql = <<<'EOT' SELECT CONCAT(u.firstname, ' ', u.lastname) FROM UserBundle\Entity\User u WHERE LOWER(u.company) = :company AND u.expiresAt BETWEEN DATE_SUB(CURRENT_DATE(), 1, 'day') AND DATE_ADD(CURRENT_DATE(), 1, 'day') EOT; $query = $this->getEntityManager()->createQuery($dql);
  17. Changer l'hydratation Plusieurs modes sont disponibles : $result = query->getResult(Query::HYDRATE_OBJECT);

    // Blog, objet construit par Reflection $result = query->getResult(Query::HYDRATE_ARRAY); // tableau associatif avec id, name... $result = query->getResult(Query::HYDRATE_SCALAR); // tableaux avec b_id, b_name... (non-dédupliqué !) $result = query->getResult(Query::HYDRATE_SINGLE_SCALAR); // non supporté ici $result = query->getResult(Query::HYDRATE_SIMPLEOBJECT); // Blog mais sans objets joints (1-to-1) (!) Pourquoi? Principalement pour des raisons de performance, l'hydratation en objet, surtout avec des objets associés, est très lourde. ❗ pour HYDRATE_SIMPLEOBJECT, surtout si vos getters sont typés
  18. Une autre optimisation : partial object // Dans BlogRepository $query

    = $this->createQueryBuilder('b') ->select('PARTIAL b.{id, name}') ->where('b.name = :name') ->setParameter('name', 'Romaric') ->getQuery(); $blogs = query->getResult(); dump($blogs[0] instanceof Blog); // true dump($blogs[0]->getId()); // 1 dump($blogs[0]->getName()); // Romaric dump($blogs[0]->getDescription()); // null ❗ Tous les autres champs seront null, les associations et collections auront des proxys non initialisés / vides.
  19. Ou encore : les Partial References Lorsqu'on veut une entité

    que pour son ID, il est possible d'utiliser un autre type d'objet partiel, une référence : $blog1 = $entityManager->getPartialReference(Blog::class, 1); dump($blog1 instanceof Blog); // true dump($blog1->getId()); // 1 $articles = $entityManager->getRepository(Article::class) ->findBy(['blog' => $blog1]);
  20. Relations / associations : base Dans une relation, il y

    a l'owning side (requis), et l'inverse side. /** @ORM\Entity */ class Article { /** * @ORM\ManyToOne(targetEntity="Blog", inversedBy="articles") */ private $blog; } /** @ORM\Entity */ class Blog { /** * @ORM\OneToMany(targetEntity="Article", mappedBy="blog", * cascade={"remove"}, * onDelete="CASCADE", * orphanRemoval=true) */ private $articles; }
  21. Relations / associations : base Dans une relation, il y

    a l'owning side (requis), et l'inverse side. /** @ORM\Entity */ class Article { /** * @ORM\ManyToOne(targetEntity="Blog", inversedBy="articles") */ private $blog; } /** @ORM\Entity */ class Blog { /** * @ORM\OneToMany(targetEntity="Article", mappedBy="blog", * cascade={"remove"}, * onDelete="CASCADE", * orphanRemoval=true) */ private $articles; }
  22. Relations / associations : base Dans une relation, il y

    a l'owning side (requis), et l'inverse side. /** @ORM\Entity */ class Article { /** * @ORM\ManyToOne(targetEntity="Blog", inversedBy="articles") */ private $blog; } /** @ORM\Entity */ class Blog { /** * @ORM\OneToMany(targetEntity="Article", mappedBy="blog", * cascade={"remove"}, * onDelete="CASCADE", * orphanRemoval=true) */ private $articles; }
  23. Relations / associations : base Dans une relation, il y

    a l'owning side (requis), et l'inverse side. /** @ORM\Entity */ class Article { /** * @ORM\ManyToOne(targetEntity="Blog", inversedBy="articles") */ private $blog; } /** @ORM\Entity */ class Blog { /** * @ORM\OneToMany(targetEntity="Article", mappedBy="blog", * cascade={"remove"}, * onDelete="CASCADE", * orphanRemoval=true) */ private $articles; }
  24. Relations et proxy Dans la mesure du possible, Doctrine propose

    du lazy loading, c'est-à-dire de mettre soit un proxy soit une PersistentCollection à la place de(s) entité(s) jointe(s). Type Côté Peut être proxy ? One-to-One Owning Oui (ou null) One-to-One Inverse Jamais ❗ Many-to-One Owning Oui (ou null) Many-to-One (One-to-Many) Inverse Oui, Collection Many-to-Many Owning Oui, Collection Many-to-Many Inverse Oui, Collection
  25. Lazy-loading... $blogs = $entityManager->getRepository(Blog::class)->findAll(); // Chaque Blog a des articles

    (One-to-Many), // et des contributeurs/authors (One-to-Many) foreach ($blogs as $blog) { foreach ($blog->getArticles() as $article) { $title = $article->getTitle(); // ... } foreach ($blog->getAuthors() as $author) { $name = $author->getName(); // ... } }
  26. ...et kaboom, problème du N+1

  27. N+1 : une possible solution On peut demander à Doctrine

    de récupérer en même temps les articles et les contributeurs : $blogs = $this->createQueryBuilder('blog') ->addSelect('article, author') ->join('blog.articles', 'article') ->join('blog.authors', 'author') ->getQuery() ->getResult(); ❗ on garde le problème du coût de l'hydratation (coût en O(n*m*q) ici, avec n blogs, m articles et q contributeurs).
  28. Solution2 : multi-step hydratation $blogs = $entityManager->createQuery(' SELECT blog, article

    FROM Blog blog LEFT JOIN blog.articles article ') ->getResult(); $entityManager->createQuery(' SELECT PARTIAL blog.{id}, author FROM Blog blog LEFT JOIN blog.authors author ') ->getResult(); // Résultat inutile $blogs[0]->getArticles()->first()->getTitle(); // Ne déclenche pas de requête 2 Plus de détails sur le blog de Marco Pivetta (Ocramius), exemples sur Github ici
  29. Solution3 ! // On va aggréger côté BDD, ici avec

    PostgreSQL $sql = <<<'EOT' SELECT c.id, c.title, json_agg( json_build_object('id', o.id, 'title', o.product_title) ) AS offerings FROM lms__course AS c JOIN lms__course_offering AS o ON o.course_id = c.id WHERE c.is_completed = TRUE AND c.is_active = TRUE GROUP BY c.id EOT; $result = $databaseConnection->executeQuery($sql, $parameters); $courses = []; while ($row = $result->fetch(\PDO::FETCH_ASSOC)) { $offerings = json_decode($row['offerings'], true); $row['offerings'] = $offerings; $courses[] = $row; }
  30. ❗ Note sur les proxies // Un Blog a un

    Logo (One-To-One, owning side) $logo = $blog->getLogo(); // Les proxies supportent mal la sérialisation $str = serialize($logo); $logo2 = unserialize($str); // ! Error at offset 0... // À la place, soit charger le proxy if ($logo instanceof Doctrine\ORM\Proxy\Proxy) { $logo->__load(); } $logo2 = unserialize(serialize($logo)); // // Soit utiliser l'identité $logoId = unserialize(serialize($logo->getId()));
  31. Requêtage, astuce : les Criteria class Blog { public function

    getDraftArticles(): Collection { $criteria = Criteria::create() ->where(Criteria::expr()->eq('status', 'draft')) ->orderBy(['position' => Criteria::ASC]); return $this->articles->matching($criteria); } } Si la collection n'est pas chargée, une requête SQL avec un WHERE sera générée, sinon le filtrage aura lieu sur les éléments en mémoire.
  32. Astuce 2 : appliquer un filtre à toutes les requêtes

    class NoDraftFilter extends SQLFilter { public function addFilterConstraint(ClassMetadata $entityMetadata, $alias) { if (Article::class !== $entityMetadata->reflClass->getName()) { return ''; } return $alias.'.status != \'draft\''; // DQL injecté dans le WHERE } } $publishedArticles = $entityManager->getRepository(Article::class)->findAll(); $entityManager->getFilters()->disable('draft_filter'); $allArticles = $entityManager->getRepository(Article::class)->findAll(); # config/packages/doctrine.yaml orm: entity_managers: default: filters: draft_filter: class: App\Filter\NoDraftFilter enabled: true
  33. Astuce 3 : organiser ses repositories... class ArticleRepository { public

    function findOnlineArticlesIWroteOnBlog(User $user, Blog $blog) { $queryBuilder = $this->createQueryBuilder('a'); self::withIsOnline($queryBuilder, 'a'); self::withIWrote($queryBuilder, 'a', $user); self::withFromBlog($queryBuilder, 'a', $blog); return $queryBuilder->getQuery()->getResult(); } private static function withIsOnline(QueryBuilder $queryBuilder, string $alias) { $queryBuilder ->andWhere($alias.'.status != \'draft\'') ->andWhere($alias.'.publishOn >= CURRENT_TIMESTAMP()') ; } // withIWrote(), withFromBlog(), etc }
  34. ...ou construire des requêtes complexes class ArticleQueryBuilderBuilder { private $queryBuilder;

    private $tableAlias; public function __construct(QueryBuilder $queryBuilder, string $tableAlias) { $this->queryBuilder = $queryBuilder; $this->tableAlias = $tableAlias; } public function withIsOnline() { $this->queryBuilder ->andWhere($this->tableAlias.'.status != \'draft\'') ->andWhere($this->tableAlias.'.publishOn >= CURRENT_TIMESTAMP()'); } // withIWrote(), withFromBlog(), etc } $articles = $entityManager->getRepository(Article::class) ->getArticleQueryBuilderBuilder() // À ajouter dans ArticleRepository ->withIsOnline() ->withIWrote($user) ->withFromBlog($blog) ->getQueryBuilder()->getQuery()->getResult();
  35. None
  36. INSERT Que se passe-t-il quand j'insère une nouvelle entité ?

  37. Cycle de Vie d'un persist() $article = new Article(); //

    Doctrine détecte qu'il ne connaît pas l'entité, // il va l'ajouter dans UnitOfWork::entityInsertions $entityManager->persist($article); // Maintenant, Doctrine va synchroniser l'UnitOfWork avec la BDD : // il regarde s'il y a de nouvelles entités, // ouvre une transaction, // génère et exécute un INSERT SQL, // puis commit et l'UnitOfWork se "nettoie" $entityManager->flush();
  38. Cycle de Vie d'un persist() $article = new Article(); //

    Doctrine détecte qu'il ne connaît pas l'entité, // il va l'ajouter dans UnitOfWork::entityInsertions $entityManager->persist($article); // Maintenant, Doctrine va synchroniser l'UnitOfWork avec la BDD : // il regarde s'il y a de nouvelles entités, // ouvre une transaction, // génère et exécute un INSERT SQL, // puis commit et l'UnitOfWork se "nettoie" $entityManager->flush();
  39. Cycle de Vie d'un persist() $article = new Article(); //

    Doctrine détecte qu'il ne connaît pas l'entité, // il va l'ajouter dans UnitOfWork::entityInsertions $entityManager->persist($article); // Maintenant, Doctrine va synchroniser l'UnitOfWork avec la BDD : // il regarde s'il y a de nouvelles entités, // ouvre une transaction, // génère et exécute un INSERT SQL, // puis commit et l'UnitOfWork se "nettoie" $entityManager->flush();
  40. Évènements Action Quand ? Évènements Nouvelle entité EM:flush() prePersist et

    postPersist Mise à jour EM:flush() preUpdate et postUpdate Suppression EM:flush() preRemove et postRemove Toujours EM:flush() preFlush, onFlush et postFlush Lecture de la BDD find()... postLoad Première opération find(), EM:persist()... loadClassMetadata Nettoyage EM:clear() onClear
  41. Lifecycle callbacks (1/3) use Doctrine\Common\Persistence\Event\PreUpdateEventArgs; /** * @ORM\Entity * @ORM\HasLifecycleCallbacks

    */ class Blog { /** @ORM\PreUpdate */ public function onPreUpdate(PreUpdateEventArgs $event) { $this->updatedAt = new \DateTimeImmutable(); } }
  42. Events listeners/subscribers (2/3) use Doctrine\ORM\Events; use Doctrine\Common\EventSubscriber; use Doctrine\Common\Persistence\Event\LifecycleEventArgs; class

    LocalizationPersister implements EventSubscriber { public function getSubscribedEvents() { return [Events::prePersist]; } public function onPrePersist(LifecycleEventArgs $args) { if (!$args->getObject() instanceof Article) { return; } $args->getObject()->setLocale('fr'); // ou injectée... } }
  43. Entity listeners3 ❤ (3/3) use Doctrine\Common\Persistence\Event\LifecycleEventArgs; class BlogLogoListener { public

    function preRemove(Logo $logo, LifecycleEventArgs $args) { unlink($logo->getPath()); } } services: blog_logo_listener: class: App\Listener\BlogLogoListener tags: - { name: doctrine.orm.entity_listener, event: preRemove, entity: App\Entity\Logo } 3 Syntaxe avec Doctrine 2.5+. ❗ La documentation n'est pas très claire pour l'instant.
  44. UPDATE Et lors de la mise à jour d'une entité

    ?
  45. Lors du flush() $article->setTitle('Retex SymfonyLive 2019'); $entityManager->flush($article); // Ou flush()

    tout court Par défaut, pas besoin de persist() ! Doctrine va regarder si chaque champ de chaque entité a été modifié. Il est possible de changer cela, c'est-à-dire la tracking policy.
  46. Les tracking policies Deferred Implicit : stratégie par défaut. Doctrine

    garde en mémoire les valeurs récupérées de la BDD, et lors du flush() va comparer chaque champ des entités qu'il connaît pour voir s'il a été modifié. ❗ Peut utiliser beaucoup de ressource. Deferred Explicit : seules les entités explicitement persistées ($entityManager->persist($article)) ont leurs champs comparés. ❗ Utilise moins de ressources, mais attention aux cascades. Notify : chaque entité doit signaler ses modifications à un listener. Le plus optimisé, mais lourd à mettre en place.
  47. Exemple avec Deferred explicit /** * @ORM\Entity * @ORM\ChangeTrackingPolicy("DEFERRED_EXPLICIT") */

    class Article { // ... } $article->setTitle('Hello World'); $entityManager->persist($article); $entityManager->flush(); ❗ Il faut appeler exactement $entityManager->persist() sur chaque entité, cascade: {"persist"} dans les annotations ne suffit pas. Donc il faut pouvoir/vouloir accéder à chaque entité depuis son contrôleur...
  48. Listeners : particularité de l'update use Doctrine\Common\Persistence\Event\PreUpdateEventArgs; class BlogListener {

    public function preUpdate(Blog $blog, PreUpdateEventArgs $args) { if ($eventArgs->hasChangedField('name') && $eventArgs->getNewValue('name')) { // Attention de cette manière seuls les champs déjà modifiés peuvent être remodifiés $eventArgs->setNewValue('name', 'Nouveau nom: '.$eventArgs->getNewValue()); $blog->setSlug(canonicalize($blog->getName())); // Si on souhaite modifier une autre propriété, il faut lancer une recomparaison $classMetadata = $args->getEntityManager()->getClassMetadata(Blog::class); $args->getEntityManager()->getUnitOfWork()->recomputeSingleEntityChangeSet($classMetadata, $blog); } } } ❗ On ne peut pas créer de nouvelles entités ici.
  49. Listeners : créer de nouvelles entités class BlogSubscriber implements EventSubscriber

    { public function getSubscribedEvents() { return [Events::onFlush]; } public function onFlush(OnFlushEventArgs $args) { $entityManager = $args->getEntityManager(); $unitOfWork = $entityManager->getUnitOfWork(); foreach ($unitOfWork->getScheduledEntityUpdates() as $entity) { if ($entity instanceof Blog) { $changeset = $unitOfWork->getEntityChangeSet($entity); if (isset($changeset['name'])) { $notification = new Notification(); // etc $entityManager->persist($notification); // On ne peut pas déclencher de flush() - on doit recompute "à la main" $classMetadata = $entityManager->getClassMetadata(Notification::class); $unitOfWork->computeChangeSet($classMetadata, $notification); } } } } }
  50. Un piège : suivi des objets Par défaut, Doctrine compare

    les valeurs des propriétés pour détecter si elles ont été modifiées. ❗ Cela ne marche pas avec les objets: UploadedFile, DateTime... Il faut alors soit modifier la valeur d'une autre propriété, d'un type primitif, soit utiliser des objets immutables. class Article { /** * @var \DateTimeImmutable * @ORM\Column(type="datetime_immutable") */ private $updatedAt; }
  51. None
  52. MAPPING & configuration & dernières astuces

  53. Embeddables /** @ORM\Embeddable */ class Address { /** @ORM\Column() */

    private $street; /** @ORM\Column() */ private $city; } /** @ORM\Entity */ class Blog { /** @ORM\Embedded(class="Address") */ private $address; public function __construct() { $this->address = new Address(); } } // Pour requêter en DQL, on pourra écrire : // SELECT b FROM Blog b WHERE b.address.city = ...
  54. Ajouter un type Doctrine namespace App\Type; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\ConversionException;

    use Doctrine\DBAL\Types\Type; class DateTimeUtcType extends Type { public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform) { return $platform->getDateTimeTypeDeclarationSQL($fieldDeclaration); } public function convertToDatabaseValue($value, AbstractPlatform $platform) {} public function convertToPHPValue($value, AbstractPlatform $platform) {} public function getName() { return 'datetime_utc'; } } # config/packages/doctrine.yaml doctrine: dbal: types: datetime_utc: App\Type\DateTimeUtcType Code complet sur https://git.io/fjfTP et explications https://bit.ly/2HZXnfh
  55. Héritage Cas typique : définir dans un bundle une entité

    qui sera étendue. /** @ORM\MappedSuperclass */ /** @ORM\Entity */ abstract class AbstractUser class User extends AbstractUser { { /** @ORM\Column() */ /** @ORM\Id @ORM\Column(type="integer") */ protected $email; private $id; /** @ORM\Column() */ /** @ORM\Column() */ protected $username; private $myfield; } } ❗ aux limites (associations...) Doctrine supporte les Traits, cela est généralement plus simple. ❗ ❗ ❗ pour Single Table Inheritance et Multiple Table Inheritance
  56. Caches doctrine: orm: # Ces 2 caches sont INDISPENSABLES en

    "prod" : # (et activés par Symfony Flex par défaut) metadata_cache_driver: apcu # cache les annotations query_cache_driver: apcu # cache le DQL généré # Optionnel et manuel : result_cache_driver: apcu Le Result cache devra être rempli manuellement : $query = $em->createQuery('SELECT b FROM App\Entity\Blog b'); $query->useResultCache(true, 3600); // 1 heure
  57. Second-level cache1 doctrine: orm: entity_managers: default: second_level_cache: enabled: true region_cache_driver:

    apcu regions: my_entity_region: type: apcu /** * @ORM\Entity * @ORM\Cache(usage="READ_ONLY", region="my_entity_region") */ class Blog { // ... } 1 Il y a un nombre important d'options, voir la documentation
  58. Étendre Doctrine Le plus connu, les Doctrine extensions : Sortable,

    SoftDeleteable, Sluggable... ramsey/uuid-doctrine pour les UUID ⭐ ⭐ steevanb/DoctrineStatsBundle ⭐ ⭐ Des fonctions DQL supplémentaires : - spécifiques à chaque plate-forme - pour requêter/manipuler du JSON - pour les types spatiaux Des utilitaires pour les batchs
  59. Conclusion & questions @romaricdrigon / romaric@netinfluence.ch