Je vous préviens d'avance, là, ça va être du lourd.
Le contexte
Il va d'abord falloir poser le contexte. Il y a quelques temps, j'ai commencé le développement d'un module custom basé sur la librairie GSAP. GSAP, c'est une lib qui permet de créer des animations via javascript. C'est un truc assez ouf, avec plein de plugins : tween, scrollTrigger, splitText, timeline, etc... Du balèze pour ajouter des animations dans un site web.
Et bon, à force de me lire, vous savez sûrement que je ne fais jamais les choses à moitié. J'ai donc pris la décision de mettre en place un module assez complexe, qui doit permettre de créer des animations (Tween), de les ajouter à des séquences, lesquelles sont embarquées dans des "bindings", ces derniers étant ajoutés à des "mappings".
Donc en gros :
- Binding : détermine où les séquences sont embarquées. ça bosse avec le système de visibilité de Drupal, le "ConditionManager". C'est cette partie de formulaire qu'on trouve sur la gestion des blocs, qui permet de déterminer sur quel(s) page(s) l'élément doit apparaitre, sur quel(s) type(s) de contenu, quel(s) rôle(s) ont accès, etc...
- Mapping : le mapping, c'est l'entité qui permet d'indiquer les séquences à utiliser. On regroupe donc les séquences ensemble, même si elles concernent différents éléments
- Séquence : la séquence, c'est l'orchestrateur d'animations. Elle indique comment un preset va être utilisé, ses propriétés spécifiques.
- Preset : le preset enfin, c'est la "brique atomique" réutilisable. On déclare un preset en indiquant ses propriétés de base (type de tween, durée, délai). Le preset détermine l'animation dans sa plus simple expression, puis pourra être utilisé dans une séquence qui va décider de comment le preset va se comporter.
Architecture
ça, c'est le contexte. Maintenant, pour mettre tout ça en place, il a fallu pas mal de réflexion. L'idée, c'était d'avoir un système avec des briques réutilisables, pour ne pas devoir déclarer chaque animation pour chaque utilisation. De plus, il fallait que le module principal définir le "Core" du système, avec la base pour les tweens, puis chaque "plugin GSAP" devait être implémenté dans un sous-module. Le but étant que n'importe quel développeur puisse implémenter son plugin spécifique, ses propres définitions de propriétés, de groupe. Bref... modularité à l'extrême, possibilité de se "mapper" sur le système, etc...
Les définitions de propriétés
Dans ce système, j'ai mis en place des définitions de propriétés. J'ai différents types de propriétés, qui sont regroupées dans des groupes spécifiques, exemple :
Groupe : Tween
Propriétés :
- offset X
- offset Y
- offset Z
- easing
- stagger
- opacity
- transform origin
Ces propriétés permettent de générer un système de formulaire pour que l'utilisateur puisse remplir les propriétés associées à un preset. Là, je parle de presets, mais j'ai des propriétés pour chaque type d'entité (preset, sequence, mapping, binding).
J'avais besoin d'un "propertyManager" qui allait permettre de gérer ces définitions de propriétés, les récupérer selon les besoins, etc...
La véritable réflexion
C'est là que les questionnements ont commencé. Comment j'allais pouvoir implémenter ces définitions de propriétés dans Drupal en faisant en sorte que le système soit viable sur du long terme. Dans la logique que j'ai utilisée, j'ai la structure suivante :
src
- Annotation
-- AbstractGsapAnnotation.php
-- GsapBinding.php
-- GsapMapping.php
-- GsapPreset.php
-- GsapSequence.php
- Gsap
-- Core
--- (toutes les classes du noyau)
-- Definition
--- Adapter
--- GroupDefinition
--- Interfaces
--- Normalizer
--- Plugin
--- PropertyDefinition
--- PropertyInjector
- Plugin
-- Gsap
--- Form
---- (les formulaires de construction des plugins)
--- Plugin
---- (les plugins gsap finaux)Avec cette structure, n'importe quel autre développeur peut créer son propre sous-module, déclarer les répertoires nécessaires avec ses propres définitions et ses propres plugins.
Mais... Car il y a un mais, forcément.
Comment faire en sorte que chaque développeur puisse "exposer" ses implémentations au noyau principal ? En particulier pour les définitions de propriétés / groupes.
Ces définitions sont assez basiques, et proposent des méthodes statiques. Elles sont ensuite utilisées par les plugins GSAP de mon système pour indiquer qu'elles sont utilisées ou non pour un plugin particulier. C'est le propertyDefinitionManager qui s'occupe de les charger et de les proposer.
Ces définitions sont donc :
- statiques
- déclaratives
- n'ont pas besoin d'être instanciées
- n'ont pas besoin d'être injectées
Elles ne sont donc pas des vrais services au sens strict du terme. J'avais dans l'idée à la base d'en faire des services tagués pour permettre au manager de les retrouver, mais ça me gênait à cause des différents points précédents. Déclarer des définitions dans le fichier services.yml me semblait "too much", tout ça juste pour pouvoir y accéder. ça créait une contrainte pour les potentiels développeurs qui voudraient travailler sur ce module GSAP et je n'arrivais pas à trouver de justification à le faire de cette manière. Annotations, plugins, services tagués, hooks... Tout ça ne correspondait pas à ce que je désirais.
- couche d'abstraction inutile
- coût cognitif pour les développeurs
- complexité d'intégration supplémentaire
L'API Plugin de Drupal aurait été disproportionnée par rapport au réel besoin de l'architecture. Tous les problèmes ne nécessitent pas des annotations, des managers ou encore des mécanismes de discovery pour être résolus. Parfois, une simple collecte à la compilation suffit, et c'est précisément ce que permet le conteneur symfony.
Compiler pass
Je vous l'avoue sans détour, ChatGPT m'a bien aidé sur ce coup-là, pour trouver une solution élégante et viable. Après lui avoir exposé ma problématique, c'est lui qui m'a orienté vers une architecture intéressante. Cette solution passe par ce qu'on appelle un "CompilerPass".
Le compiler pass, c'est un point d'extension du conteneur de services. ça permet de rajouter des fonctionnalités à un service. Dans mon cas, mon "compiler pass" a été implémenté dans une logique un peu spéciale :
Rechercher un répertoire src/Gsap/Definition/PropertyDefinition et inclure toutes les classes qui y sont trouvées. L'intérêt ici, c'est de pouvoir les inclure à la compilation initiale du conteneur de services, plutôt que de les charger au runtime à chaque fois.
- Pas de YAML
- Pas de tags
- Pas d'annotation
Juste des classes placées dans le bon répertoire, avec une logique "auto-discovery" quand on vide les caches.
- Coût au runtime : 0
- Couplage YAML : aucun
- Extensibilité : forte
- Friction pour les modules tiers : minimale
- Performance : maximale
La complexité est déplacée au moment de la compilation, et les classes sont disponibles sans charge supplémentaire.
Voilà à quoi ressemble mon compilerPass :
<?php
namespace Drupal\gsap_animations\Compiler;
use Drupal\gsap_animations\Gsap\Definition\Interfaces\GsapGroupDefinitionInterface;
use Drupal\gsap_animations\Gsap\Definition\Interfaces\GsapPropertyDefinitionInterface;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Finder\Finder;
/**
* Defines a gsap property definition compiler pass.
*/
class GsapPropertyDefinitionPass implements CompilerPassInterface {
private const PATHS = [
DRUPAL_ROOT . '/modules/custom',
DRUPAL_ROOT . '/modules/contrib',
];
/**
* Process compiler pass.
*
* @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
* The container builder.
*/
public function process(ContainerBuilder $container) {
if ($container->has('gsap_animations.property_definition_manager')) {
$manager = $container->findDefinition('gsap_animations.property_definition_manager');
$this->getDefinitions($manager, 'Property');
}
if ($container->has('gsap_animations.group_definition_manager')) {
$manager = $container->findDefinition('gsap_animations.group_definition_manager');
$this->getDefinitions($manager, 'Group');
}
}
/**
* Get definitions method.
*
* Iterate over the whole directory tree from modules/contrib and
* modules/custom to find GsapDefinitionInterface classes and retrieve
* definitions to store into the definition manager.
*
* @param \Symfony\Component\DependencyInjection\Definition $manager
* The manager to store definitions.
* @param string $type
* The type of definitions to search / store.
*/
private function getDefinitions(Definition $manager, $type) {
$definitions = [];
foreach (self::PATHS as $base_path) {
if (!is_dir($base_path)) {
continue;
}
$modules = scandir($base_path);
foreach ($modules as $module_dir) {
if ($module_dir === '.' || $module_dir === '..') {
continue;
}
$module_path = $base_path . '/' . $module_dir;
$definition_path = $module_path . '/src/Gsap/Definition/' . $type . 'Definition';
if (!is_dir($definition_path)) {
continue;
}
$finder = new Finder();
$finder->files()->name('*.php')->in($definition_path);
foreach ($finder as $file) {
$class = 'Drupal\\' . $module_dir . '\\Gsap\\Definition\\' . $type . 'Definition\\' . $file->getBasename('.php');
if (class_exists($class) && is_subclass_of($class, GsapPropertyDefinitionInterface::class)) {
$class_type = $class::getType();
if (!isset($definitions[$class_type])) {
$definitions[$class_type] = [];
}
$definitions[$class_type][] = $class;
}
elseif (class_exists($class) && is_subclass_of($class, GsapGroupDefinitionInterface::class)) {
$definitions[] = $class;
}
}
}
}
$manager->addMethodCall('setDefinitions', [$definitions]);
}
}Pour déclarer un compiler pass, on doit utiliser un namespace spécifique : Drupal\{module}\Compiler
On déclare une méthode "process()" dont le seul paramètre est le containerBuilder symfony. Dans mon cas, j'utilise cette méthode pour instancier mes managers et exécuter la méthode de récupération des définitions (groupes et propriétés).
La méthode de récupération va itérer sur tous les modules présent dans la hiérarchie (contrib / custom) et rechercher si un répertoire src/GSap/Definition/{type}Definition existe. {type} peut être "Group" ou "Property". Si des classes sont trouvées, elles sont ajoutées au manager via sa méthode "setDefinitions".
Le manager, quant à lui, est un service défini dans le fichier services.yml et implémenté comme suivant :
<?php
namespace Drupal\gsap_animations\Service\DefinitionManager;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Extension\ModuleHandlerInterface;
/**
* Defines a property definition manager.
*/
class PropertyDefinitionManager {
/**
* Property definition manager constructor.
*
* @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
* The module handler.
*/
public function __construct(protected ModuleHandlerInterface $moduleHandler) {}
/**
* The definitions array.
*
* @var array
*/
private array $definitions = [];
/**
* Add a definition to the manager.
*
* @param string $type
* The definition type.
* @param string $class
* The class name.
*/
public function addDefinition(string $type, string $class): void {
if (!isset($this->definitions[$type])) {
$this->definitions[$type] = [];
}
$this->definitions[$type][] = $class;
}
/**
* Set all definitions in one time.
*
* @param array $definitions
* The definitions to set.
*/
public function setDefinitions(array $definitions): void {
$this->definitions = $definitions;
}
/**
* Check if a property definition exists.
*
* @param string $type
* A property definition type.
*
* @return bool
* TRUE if the property definition is found, FALSE otherwise.
*/
public function hasDefinition(string $type) {
if (!isset($this->definitions[$type])) {
return FALSE;
}
return TRUE;
}
/**
* Get a definition from the manager.
*
* @param string $type
* The type of the definition.
*
* @return array
* The definition.
*/
public function getDefinition(string $type): array {
if (!$this->hasDefinition($type)) {
throw new \InvalidArgumentException("No definition for type $type.");
}
$definition = [];
// Vérifie si le module du namespace est actif.
$classes = $this->definitions[$type];
foreach ($classes as $class) {
if (preg_match('/^Drupal\\\\([a-z0-9_]+)\\\\/', $class, $matches)) {
$module = $matches[1];
if (!$this->moduleHandler->moduleExists($module)) {
throw new \InvalidArgumentException("Definition type $type belongs to disabled module $module.");
}
}
$class = ltrim($class, '\\');
if (!class_exists($class)) {
throw new \RuntimeException("Class $class does not exist.");
}
$definition = NestedArray::mergeDeep($class::getDefinition(), $definition);
//$definition = $class::getDefinition();
}
return $definition;
}
/**
* Return all classes.
*
* @return string[]
* An array of classes.
*/
public function getAllClasses(): array {
return $this->definitions;
}
/**
* Get all definitions.
*
* @return \Drupal\gsap_animations\Gsap\Definition\Interfaces\GsapPropertyDefinitionInterface[]
* An array of definitions.
*/
public function getAll(): array {
$classes = $this->getAllClasses();
$store = [];
foreach (array_keys($classes) as $type) {
$store[$type] = $this->getDefinition($type);
}
return $store;
}
}On y retrouve la méthode "setDefinitions" appelée par le compilerPass. Le manager fait les choses suivantes :
- Vérifie que le module du namespace est actif
- Fusionne les définitions
- Gère les collisions
- Permet du polymorphisme multi-classes
C'est propre, c'est efficace, et ça demande un minimum d'effort pour créer de nouvelles définitions. Exactement ce que je recherchais pour que le système soit viable.
Inconvénients
Alors oui, tout n'est pas "parfait".
- Le compiler pass doit scanner l'arborescence de fichiers
- C'est couplé au namespace \Drupal\{module}\Gsap\Definition\PropertyDefinition
- Il n'y a pas de système de priorité explicite comme ça pourrait être le cas sur des services tagués
Mais à l'inverse, ça ne nécessite ni fichiers de configuration, ni déclaration sous forme de services.
Conclusion
Comprendre la différence entre compile-time et runtime dans Drupal change complètement la manière dont on conçoit une architecture modulaire.
Ce choix architectural reflète une logique assez simple... Tout n'est pas service. Avec Drupal, on a souvent tendance à vouloir implémenter des Plugin, des annotations, des services tagués, qui sont tous d'excellents outils. Néanmoins, ils ne sont pas toujours adaptés. Le cas de mes définitions de propriété en est une belle preuve. De par leur nature, les transformer en services aurait ajouté une complexité inutile et de la friction pour d'éventuels développeurs tiers.
Le compiler pass m'a permis de déplacer cette complexité là ou elle devait être, à savoir lors de la compilation du conteneur. Le runtime reste propre, léger, déterministe.
Ce n'est pas une solution "classique" dans la logique "drupalesque", mais elle a le mérite d'être cohérente avec le problème rencontré.
Et au fond, c'est toujours la visée : trouver l'outil / l'architecture adapté au besoin réel, et ne pas s'arrêter à ceux qui sont très souvent mis en avant dans la documentation officielle. Il y a des solutions très intéressantes lorsqu'on sort des "sentiers battus".