Les générateurs, c'est le bien ! Un cas d'usage.

22 janvier 2016php, generator
 Cet article a été rédigé il y a plusieurs années et peut ne plus être tout à fait à jour…

J’annonçais lundi le lancement de la « semaine des générateurs » : cette semaine, chaque jour, un article a été posté par un développeur différent, présentant un cas d’utilisation qu’il avait de générateurs. Ils sont tous référencés depuis mon post de lundi.

Aujourd’hui, pour finir cette semaine1, c’est mon tour !

Présentation de mon cas

En recherchant le mot-clef yield dans un de mes projets au boulot2, je l’ai trouvé neuf fois – plus quelques fois dans des commentaires ou noms de méthodes.

Une bonne partie de ces utilisations correspondent à des cas évidents d’utilisation de générateurs pour plusieurs de mes collègues : on parcourt une série de données – souvent depuis une base de données – et on souhaite effectuer un traitement sur chaque ligne, indépendante des autres. Aucune raison donc de tout charger en mémoire dans un tableau. Bien sûr, on souhaite tout de même séparer chargement / manipulation / affichage des données. Plusieurs exemples de ce type ont été montrés cette semaine et je ne partirai donc pas sur un de ces cas pour cet article.

Le cas dont j’ai choisi de parler va, en fait, dans le sens inverse : j’ai en entrée un fichier binaire constitué d’un nombre variable d’enregistrements, chacun de taille variable. J’ai besoin de lire ces enregistrements un par un, de les analyser, puis de les stocker en base de données.

Le format de mes fichiers

Chacun de mes fichiers est composé d’une suite d’enregistrements, indépendants les uns des autres. Chaque enregistrement, une suite de données binaires contenant une liste de livres, est composé des champs suivants :

  • 2 octets (entier) : la taille de l’enregistrement, ces deux octets étant exclus du compte
  • 2 octets (entier) : le nombre de livres dans l’enregistrement
  • Ensuite, pour chaque livre, une suite de données (titre, format, auteurs, …) – toujours les mêmes champs, certains étant de taille variable avec un délimiteur de fin.

Mon approche

J’ai commencé à bosser sur le parcours de ces fichiers il y a quelques mois, alors que notre plate-forme était déjà en PHP 5.6. Donc, je suis directement parti sur des générateurs, pour séparer analyse / parcours / utilisation des données et ne pas les charger intégralement en mémoire – et sans avoir à mettre en place un itérateur à la main.

Un flux, bien sûr !

Pour manipuler un fichier dont j’ai besoin de lire les données alors que j’avance dans le fichier, en partant du début de celui-ci et en allant jusqu’à la fin, je passe généralement par un flux – la conférence que j’ai donnée au Forum PHP, ce n’était pas un hasard ;-).

J’ai des chaînes C dans mon fichier. Elles sont mises en évidence par un octet nul à la fin de chacune (la façon « C » de faire). Pour me faciliter la recherche de ces \x00, je charge en mémoire un (et un seul) enregistrement entier (maximum 64KB, donc acceptable) avant de l’analyser, au lieu de parcourir le flux octet par octet.

Parsing et Generator !

Une grosse boucle (au sens « plein de code au même endroit ») avec toute la logique de lecture / analyse n’est pas envisageable : je souhaite découper mon code en composants testables unitairement, autant que faire se peut.

Pour les détails techniques, j’ai une classe Record qui correspond aux données d’un enregistrement (typiquement, cela correspond à une entité Doctrine – 0 code métier dedans, juste des propriétés, des accesseurs et des annotations) et une classe RecordParser qui lit les données3 depuis mon fichier pour les extraire vers un Record.

C’est au niveau de RecordParser que se trouve la logique métier d’extraction des données d’un enregistrement – pas de générateur à ce niveau, donc : il s’agit d’un traitement bien unitaire, séparé de l’aspect boucle.

La lecture enregistrement par enregistrement est orchestrée par FileParser::parseFile() qui appelle RecordParser::parseRecord() tant qu’on n’a pas atteint la fin du fichier. Bien sûr, c’est au niveau de mon FileParser::parseFile() que j’ai un générateur : chaque fois qu’un enregistrement est lu depuis le fichier, il est yieldé.

/**
 * @return \Generator|void
 */
public function parseFile()
{
    while (true) {
        try {
            $record = new Record();
            $this->recordParser->parseRecord($record, $this->file);

            yield $record;
        } catch (\OutOfBoundsException $e) {
            // Utiliser une exception pour le contrôle de la boucle (pour la quitter en fin de fichier),
            // ce n'est pas très élégant...
            // Mais elle était levée (on vérifie, en lisant des données, qu'on n'est pas en fin du fichier)
            // et c'était pratique de la réutiliser pour ça (même si "peu élégant", le but est atteint)
            return;
        }
    }
}

Note : avec PHP < 5.5, j’aurais peut-être envisagé de mettre en place un Iterator moi-même, sans passer par le raccourci qu’est un Generator… Mais rien que de penser aux cinq méthodes qu’il m’aurait fallu écrire, j’en frissonne !

Et l’utilisation

Une fois que j’ai ce générateur qui yielde des données, il ne reste plus qu’à en faire ce que je veux, comme les enregistrer en base de données, les afficher, les convertir vers un autre format, …

Dans les grandes lignes et sans reproduire ici la gestion d’erreur, ça se résume globalement à six lignes de code :

$file = new SplFileObject('data/several-books.dat', 'r');
$recordParser = new Demo\FileParser($file);

foreach ($recordParser->parseFile() as $record) {
    $this->app['db.orm.em']->persist($record);
}

$this->app['db.orm.em']->flush();
$this->app['db.orm.em']->clear();

Ajoutez autour, pour mon cas réel, une gestion d’exceptions, quelques logs et un archivage du fichier après son traitement – qui ne sont pas spécifiques à l’utilisation d’un générateur. Et je flushe toutes les quelques dizaines d’enregistrements et pas uniquement une seule fois à la fin.

Générateur sur le parcours des fichiers

Ce dont j’ai parlé jusqu’à présent couvrait l’analyse d’un fichier et le traitement de ses données. En pratique, j’ai souvent plusieurs fichiers : soit quelques gros fichiers peu nombreux, soit plein de petit fichiers. Là aussi, je souhaite séparer ma recherche de fichiers de leur traitement (et, tant qu’à faire, ne pas charger en mémoire les données d’un nombre inconnu de fichiers). Donc, vous l’autez deviné : générateur !

Pour cela, je passe par un GlobIterator pour itérer sur tous les fichiers susceptibles de m’intéresser. Et j’applique un peu de logique de filtrage avant de yielder les fichiers retenus :

/**
 * @param string $directory
 *
 * @return \Generator
 */
private function files($directory)
{
    $minimumAcceptableDatetime = new \DateTimeImmutable('-5 minutes');

    $iterator = new \GlobIterator(sprintf("%s/*", $directory), \FilesystemIterator::SKIP_DOTS);
    /* @var \SplFileInfo $file */
    foreach ($iterator as $file) {
        if (!$file->isFile()) {
            continue;
        }

        if (!$file->isReadable()) {
            // Ici : éventuellement, log d'un avertissement
            continue;
        }

        $datetimeFile = \DateTimeImmutable::createFromFormat('U', $file->getMTime());
        if ($datetimeFile > $minimumAcceptableDatetime) {
            // Fichier modifié trop récemment (en cours d'écriture ?) => ignoré pour l'instant
            continue;
        }

        yield $file;
    }
}

En y réfléchissant quelques mois après : j’aurais également pu passer par un FilterIterator, ça ne m’aurait pas coûté grand chose, si ce n’est devoir définir une classe de plus… Mais, avec les classes anonymes en PHP 7, ça ne serait même pas un vrai critère à prendre en compte !

Bref, il ne reste plus qu’à boucler sur le générateur fourni par cette méthode, en appelant mon FileParser vu plus haut pour le parsing et en l’accompagnant de l’enregistrement des données.

Quelques tests automatisés ?

Bien sûr, j’ai des tests automatisés sur tout ça ;-)

Même si ce sont les deux classes qui sont le plus testées, je ne parlerai pas de ceux sur la classe RecordParser ni sur mon utilitaire DataContainer : il s’agit de classes PHP, avec des méthodes publiques et privées bien découpées, qui travaillent pour l’une sur un flux et l’autre sur une chaîne binaire. Bref, elles sont bien testées unitairement, mais rien à voir avec les générateurs.

En revanche, la question qui se pose un peu plus souvent porte sur le générateur : comment est-il testé ?

Tester le générateur ?

Pour rappel, mon générateur, c’est une classe avec un constructeur qui reçoit un \SplFileObject et une méthode qui délégue tout le travail au RecordParser avant de yielder l’objet Record qu’il a rempli. Autrement dit, la méthode génératice ne fait quasiment rien ! En fait, tout ce que j’attend d’elle, c’est :

  • Qu’elle ne fasse rien si le fichier est vide.
  • Qu’elle retourne un Iterator. Note : que ce soit par le biais d’un Generator est uniquement un détail d’implémentation : ce qui m’intéresse réellement, c’est de pouvoir itérer sur les données !
  • Et que cet itérateur me permette de parcourir des Record.
  • Je pourrais également vérifier que toute exception levée lors du parsing et qui n’indique pas la fin du fichier soit re-transmise ; je ne l’ai pas fait ici (j’ai légérement simplifié mon cas réel, oui)

Voici ce que donneraient les trois premiers tests (sur ce projet, les tests unitaires utilisent atoum) :

public function testParseFileReturnsAnIterator()
{
    $this->object($parser = new TestedClass($this->getEmptySplFileObject()));

    // Je veux pouvoir itérer sur ce que me renvoie parseFile.
    // => j'ai besoin que ce soit un Iterator.
    // Et je n'ai pas besoin que ce soit spécifiquement un Generator (qui extends Iterator)
    $this->object($iterator = $parser->parseFile())->isInstanceOf(\Iterator::class);
}

public function testParseFileIteratorIteratesOverRecords()
{
    $this->object($parser = new TestedClass($this->getSplFileObjectWithSeveralRecords()));
    $this->object($iterator = $parser->parseFile());

    // On a un premier enregistrement
    $this->object($iterator->current())
        ->isInstanceOf(\Demo\Record::class);

    // Puis un second
    $this->variable($iterator->next())
        ->object($iterator->current())
        ->isInstanceOf(\Demo\Record::class);
}

public function ParseFileIteratorIteratesUntilTheEndOfTheFile()
{
    $this->object($parser = new TestedClass($this->getSplFileObjectWithSeveralRecords()));
    $this->object($iterator = $parser->parseFile());

    // On boucle sur toutes les données de l'itérateur ; pas de Fatal, pas d'exception, pas de boucle infinie.
    // Et toutes les données de l'itérateur sont des Record
    foreach ($iterator as $record) {
        $this->object($record)->isInstanceOf(\Demo\Record::class);
    }
}

Le code de mon FileParser, le code qui correspond à ma méthode génératrice, est extrêment simple et elle ne fait elle-même pas grand chose : tous les vrais traitement sont dans des classes faites spécialement pour. Et le parcours des données est décorélé de leur analyse. Les tests sur le générateur, ils n’ont donc pas grand chose à vérifier.

Tests, fichiers et flux

D’aucun diraient que dépendre directement de \SplFileObject au niveau de mon FileParser, « c’est mal ».

Moué, OK, et ? Ca me permet de manipuler des fichiers locaux (ça répond à mon besoin). Si un jour j’ai besoin de lire des fichiers compressés, ça me permettra de le faire en ne changeant rien (et ça répondra à un besoin que je n’ai pas). Si un autre jour j’ai besoin de lire des fichiers distant, je pourrai aussi, sans rien changer (et ça répondra à un autre besoin que je n’ai pas). Et si je souhaite mettre en place des tests automatisés sans créer de fichier temporaire qui pollue le disque, je peux également le faire (et ça répond à mon besoin) !

Et oui : \SplFileObject, ça passe par la couche de flux de PHP \o/. Et je vous renvoie à nouveau vers ma conf au Forum PHP ;-)

Générateurs et quelques autres idées

Arrivé ici, je vous ai montré un cas réel où j’ai utilisé des générateurs ; je ne l’ai que légérement simplifié pour le rendre plus facile à saisir (et ce que j’ai enlevé ne porte pas au niveau du générateur). Mais les générateurs, ça peut permettre d’autres choses !

Aller jusqu’au template ?

En parcourant les autres utilisations qui sont faites de générateurs sur ce projet, j’en vois une qui attire mon attention. Ce projet est une application qui utilise Silex et Twig, avec une couche de requêtage de données.

En général, on charge les données depuis une DB MySQL vers un tableau et on passe ce tableau de données au template Twig qui va boucler dessus pour l’afficher. Sur quelques cas, on passe directement un \PDOStatement au template.

Mais, sur un cas en particulier, on a une méthode dans une couche service en PHP, qui parcourt un \PDOStatement et, pour chaque série de quelques lignes, effectue des calculs et yielde un objet. Et, oh ! Le générateur est transmis à Twig qui itère dessus pour affichage.

Après tout : un Generator est un Iterator qui est un Traversable. On peut donc directement l’utiliser en tant que tel ;-).

Ce que je n’utilise pas (pour l’instant !)

Bien sûr, je n’utilise pas encore toutes les fonctionnalités des générateurs. En particulier :

  • On est encore en PHP 5.6 sur nos serveurs de production (même si la moitié de nos projets fonctionnent sans problème en PHP 7 – pour l’autre moitié, on n’a pas encore testé ^^). Je ne peux donc pas encore utiliser deux nouveautés arrivées avec PHP 7.0 :
    • La syntaxe yield from : j’ai hâte de passer à PHP 7 : j’en aurai l’usage, de la délégation ! Sébastien en a parlé dans son post de lundi : cas d’utilisation de yield en PHP
    • La possibilité de retourner une valeur depuis un générateur (en PHP 5.x, cela cause une erreur). Je n’ai pas encore tellement réfléchi à des cas pratiques, mais ça me servira un jour ou l’autre, aucun doute là-dessus !
  • Generator->send() : je n’en ai pas encore eu besoin. Pour plus d’informations, ce post de Nikita est intéressant : Cooperative multitasking using coroutines

Et donc, en conclusion…

Pour finir, je vais répêter ce que je dis souvent depuis la sortie de PHP 5.5 – et désolé pour ceux qui m’ont déjà entendu le dire un paquet de fois !

Les générateurs, c’est bien !

Pour résumer, les générateurs, c’est :

  • Séparation des rôles : analyse de données, parcours, utilisation des données, …
  • Ecriture simplifiée (surtout par rapport à un Itérateur) et intuitive (en général, une simple boucle).
  • Consommation mémoire maitrisée, facilement

Et donc : à vous de jouer, maintenant !


Si vous cherchez une lecture de plus : j’avais déjà écrit sur le sujet des générateurs en novembre 2012, lorsque la sortie de PHP 5.5 approchait. Ce post était assez théorique (mais il montrait la différence entre un Générateur et un Itérateur, notamment en termes de nombre de lignes de code !).



  1. Si vous souhaitez vous aussi partager un retour d’expérience, autour de l’utilisation de générateurs, ou autour de quelque autre point que ce soit, en rapport avec PHP ou non : lancez-vous ! Vous avez très probablement quelque chose à dire ;-) ↩︎

  2. Celui ou nous avons tendance à effectuer une partie de nos expérimentations ; celui qui est passé le premier en PHP 5.5, puis 5.6 ; et donc, celui qui a probablement le plus d’utilisations de générateurs ; et aussi, le premier à avoir été validé sous PHP 7.0, accessoirement ^^. ↩︎

  3. Pour séparer un peu la logique « lecture d’informations depuis le fichier » et la logique « connaissance des informations à lire », mon RecordParser passe par une classe que j’ai appelée DataContainer, qui se charge de la lecture de chaînes, d’entiers longs, d’entiers courts, … Cette seconde classe peut ainsi être unit-testée et pourra éventuellement un jour être réutilisée ailleurs. ↩︎