S'amuser avec l'itérateur tagué de Symfony
Publié le 27/08/2024 • Actualisé le 27/08/2024
Dans cet article, nous voyons comment utiliser l'itérateur tagué de Symfony pour récupérer et itérer sur des services du même type. Pour sûr, c'est l'une des fonctionnalités de Symfony les plus utiles. C'est parti ! 😎
» Publié dans "Une semaine Symfonique 922" (du 26 août au 1 septembre 2024).
Prérequis
Je présumerai que vous avez les connaissances élémentaires de Symfony et que vous savez ce qu'est un service.
Configuration
- PHP 8.3
- Symfony 6.4.10
Introduction
Quand j'ai développé la version Symfony du projet is-php-dead.ovh (l'avez-vous essayé ? 🤔), j'avais une spécification en tête : j'éviterai d'utiliser les tableaux dans tout le projet.
But
Nous allons voir comment utiliser l'attribut TaggedIterator
pour récupérer des services et quelques astuces relatives.
Questions d'architecture
Le site is-php-dead.ovh est un hommage à PHP, mais également un jeu de type "easter egg" où vous devez trouver des pages ou effectuer certaines actions pour découvrir ces œufs de Pâques. Une page affiche les accomplissements dans le jeu ; lorsqu'aucune découverte n'a été faite, elle s'affiche ainsi :
Chaque ligne représente un easter egg ; la description est masquée s'il n'est pas trouvé. Un easter egg possède un niveau de difficulté et un ordre. Les easter eggs faciles sont affichés en premier, puis les normaux et enfin les difficiles. Cette difficulté étant bien sûr subjective. Comme il est possible de jouer une fois par jour, il est essentiel d'afficher les éléments dans le même ordre lorsqu'on revient sur le site.
Alors, comment pouvons-nous concevoir cela ? Chaque easter egg peut être un service implémentant une classe générique EasterEgg
avec des attributs génériques :
<?php
declare(strict_types=1);
use App\Enum\EasterEggDifficulty;
/**
* This is the base class for all EasterEgg instances.
*/
class EasterEgg
{
public function __construct(
public readonly EasterEggDifficulty $difficulty, // subjective difficulty to find the egg
public readonly string $description, // a brief description
public bool $found = false, // if it was already found or not
public ?\DateTimeImmutable $foundAt = null // when it was found, null otherwise
) {
}
/**
* Mark the egg as found.
*/
public function markAsfound(): void
{
$this->found = true;
$this->foundAt = new \DateTimeImmutable();
}
Nous avons donc plusieurs attributs, le niveau de difficulté qui utilise une énumération PHP :
<?php
declare(strict_types=1);
namespace App\Enum;
enum EasterEggDifficulty
{
case easy;
case medium;
case hard;
}
Il y a aussi une description, un indicateur pour savoir s'il a été trouvé, et la date associée. Chaque easter egg hérite de cette classe ; voyons un exemple avec un easter egg factice :
<?php
declare(strict_types=1);
use App\EasterEgg\EasterEgg;
use App\EasterEgg\EasterEggInterface;
use App\Enum\EasterEggDifficulty;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
/**
* @see https://symfony.is-php-dead.ovh/xxx
*/
#[AsTaggedItem(priority: -1)]
final class EasterEggDummy extends EasterEgg implements EasterEggInterface
{
public function __construct()
{
parent::__construct(
EasterEggDifficulty::easy,
'You haved clicked on the "xxx" hidden link of the "xxx" page.',
);
}
}
Comme prévu, cet easter egg étend la classe de base des easter eggs et implémente l'interface EasterEggInterface
:
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/**
* Marker interface for all instanciated easter eggs.
*/
#[AutoconfigureTag]
interface EasterEggInterface
{
}
L'interface EasterEggInterface
est vide, (c'est le design pattern Marker Interface) mais nous pouvons voir qu'elle possède un attribut PHP #[AutoconfigureTag]
. Grâce à cet attribut, nous indiquons à Symfony d'associer le tag EasterEggInterface
à toutes les classes implémentant cette interface. Ainsi, la classe EasterEggDummy
va être associée à ce tag. Vérifions le :
$ bin/console debug:container App\EasterEgg\Egg\EasterEggDummy
Information for Service "App\EasterEgg\Egg\EasterEggDummy"
==========================================================
---------------- ----------------------------------
Option Value
---------------- ----------------------------------
Service ID App\EasterEgg\Egg\EasterEggDummy
Class App\EasterEgg\Egg\EasterEggDummy
Tags App\EasterEgg\EasterEggInterface
Public no
Synthetic no
Lazy no
Shared yes
Abstract no
Autowired yes
Autoconfigured yes
Usages none
---------------- ----------------------------------
Comme prévu, le service App\EasterEgg\EasterEggInterface
hérite bien de ce tag. Maintenant, retournons au service EasterEggDummy
.
Récupérer et ordonner les services
Si nous examinons le service EasterEggDummy
, nous pouvons remarquer qu'il possède un attribut PHP #[AsTaggedItem(priority: -1)]
. Comme c'est le premier easter egg que nous voulons afficher, nous lui attribuons la valeur -1
(nous verrons pourquoi plus tard). Le second easter egg reçoit la valeur -2
, et ainsi de suite. -1
est plus grand que -2
, il a donc une plus "grande" priorité.
Nous pouvons utiliser l'attribut Symfony TaggedIterator
pour récupérer tous les easter eggs. Ceux-ci sont stockés dans la propriété $easterEggs
, classés par la priorité que nous avons définie dans l'attribut AsTaggedItem
que nous avons vu précédemment.
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
/**
* This is just a service to gather EasterEggs services.
*/
final class EasterEggManager
{
/**
* @param iterable<EasterEgg>&\IteratorAggregate $easterEggs
*/
public function __construct(
#[TaggedIterator(\App\EasterEgg\EasterEggInterface::class, indexAttribute: 'index')]
public readonly iterable $easterEggs,
) {
}
}
Nous voulons stocker cela en session pour sauvegarder l'état de chaque easter egg et pour chaque joueur : a-t-il été trouvé ? Quand ? L'itérateur ne peut pas être stocké en session, mais nous pouvons le convertir en un tableau classique grâce à la fonction iterator_to_array()
(c'est la seule utilisation d'un tableau classique dans ce projet).
<?php
declare(strict_types=1);
namespace App\EasterEgg;
final class EasterEggSessionManager
{
/**
* @param iterable<EasterEgg>&\IteratorAggregate $iterable
*/
public static function fromTaggedIterator(iterable $iterable): self
{
/** @var array<EasterEgg> $easterEggs */
$easterEggs = iterator_to_array($iterable, preserve_keys: true);
return new self($easterEggs);
}
// ...
}
On veut conserver les clés puisqu'elles représentent la FQCN de chaque easter egg. Au final, le tableau ressemble à ceci :
array:8 [▼
"App\EasterEgg\Egg\EasterEggDummy" => App\EasterEgg\Egg\EasterEggDummy {#154 ▼
+difficulty: App\Enum\EasterEggDifficulty {#158 ▶}
+description: "You have clicked on the "xxx" hidden link of the xxx page."
+found: false
+foundAt: null
},
"App\EasterEgg\Egg\EasterEggOtherDummy" => App\EasterEgg\Egg\EasterEggOtherDummy {#155 ▼
...
Et bien sûr, ils sont dans le bon ordre grâce à l'attribut de priorité que nous avons attribué à chaque easter egg. Maintenant, pour récupérer un de ces services, cela peut être fait avec :
$egg = $this->easterEggs[EasterEggDummy::class];
Je ne vais pas montrer tout le code, mais vous avez compris ; il est maintenant facile de récupérer un easter egg donné et pour modifier son statut (trouvé ou non) ; nous pouvons exécuter le code suivant :
$egg = $this->easterEggManager->getEasterEggSessionManager()->getByClass(EasterEggDummy::class);
$this->easterEggManager->checkFound($egg);
La méthode checkFound()
récupère l'easter egg dans la session et vérifie s'il a été trouvé. Si ce n'est pas le cas, elle le marque comme trouvé, sauvegarde les nouvelles données dans la session et crée deux messages flash pour confirmer aux utilisateurs qu'ils ont bien découvert quelque chose. Si l'easter egg a déjà été trouvé, alors rien n'est modifié.
Affichage des résultats
Pour afficher les résultats, on peut ajouter une petite extension Twig qui va rendre disponible le service EasterEggSessionManager
dans les templates Twig :
public function getFunctions(): array
{
return [
new TwigFunction('eesm', $this->getEasterEggSessionManager(...)),
];
}
Puis, dans les templates, on peut itérer sur les easter eggs :
{% for egg in eesm().easterEggs %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ egg.found ? egg.description : '****************************************' }}</td>
<td>{{ egg.difficulty.name }}</td>
<td>{{ egg.found ? '✅' : '❌' }}</td>
<td>{{ egg.found ? (egg.foundAt|date(constant('\DateTime::RFC850'))) : '' }}</td>
</tr>
{% endfor %}
Nous pouvons utiliser la variable Twig loop.index
, car les services sont dans le bon ordre. Mais, avons-nous vraiment besoin d'utiliser cette variable spéciale de Twig ? Appuyons-nous uniquement sur les données du jeu. Nous avons vu précédemment que les services sont triés par priorité, nous pouvons donc utiliser cette priorité pour afficher ce numéro de rang. C'est en fait la valeur absolue de la priorité. Dans la classe EasterEgg
, ajoutons une fonction pour accéder à cette valeur :
/**
* This is a little trick to get the order from the priority argument. This allow
* avoiding to declare a property for this or using the Twig loop variable.
*/
public function getOrder(): int
{
$refl = new \ReflectionClass($this::class);
$attributes = $refl->getAttributes();
/** @var int $priority */
$priority = $attributes[0]->getArguments()['priority'];
return abs($priority);
}
}
On utilise la réflexion pour extraire la priorité de l'objet, puis on retourne sa valeur absolue. On peut désormais utiliser cette nouvelle fonction au lieu de la variable Twig.
{{ egg.getOrder() }} {# or just "egg.order" thanks to Twig #}
Conclusion
L'itérateur tagué est l'une des fonctionnalités les plus utiles de Symfony et permet de récupérer et d'itérer sur des services avec beaucoup de facilités. J'ai pris beaucoup de plaisir à construire ce site web. Bien sûr, il existe de nombreuses implémentations possibles, et celle-ci n'est qu'une de celles qui fonctionnent. Ce qui est puissant ici, c'est que si on a besoin d'ajouter un easter egg, il suffit de créer une nouvelle classe EasterEgg
et d'ajouter le code de détection correspondant. Réaliser un projet sans (presque) utiliser de tableaux était également intéressant. N'hésitez pas à m'envoyer un message sur Symfony Slack (COil) si vous avez d'autres idées pour des easter eggs ou si vous les avez tous trouvés. Ils n'ont pas encore tous été découverts ! 😁
Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À tantôt ! COil. 😊
Lire la doc Plus sur le web Plus sur le web Plus sur Stackoverflow
A vous de jouer !
Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )
- Me remonter des erreurs ou typos.
- Me remonter des choses qui pourraient être améliorées.
- Aimez et retweetez !
- Suivez moi sur Twitter Suivez moi sur Twitter
- Inscrivez-vous au flux RSS.
- Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.
Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉
[🇫🇷] 2ᵉ article de l'année : « S'amuser avec l'itérateur tagué de Symfony » https://t.co/zJrEAwB0sm Relectures, retours, likes et retweets sont les bienvenus ! 😉 Objectif annuel : 2 / 6 #symfony #php
— COil #OnEstLaTech ✊ (@C0il) September 3, 2024