Lire un gros fichier ligne par ligne en PHP (Array, Itérateur & Générateur)
Tags: php, performance, french, beginners
Published: 2021-04-02 13:38:00

*Sorry English readers, this post goes to my French followers. Let me know if you would like me to translate this one. In the meantime, you can Google translate it.*

Dans une carrière de développeur, nous sommes souvent amenés à travailler avec des fichiers. Que ce soit pour importer des données dans un système ou exporter des données dans un fichier il faudra ouvrir le fichier et effectuer des opérations sur chaque ligne du fichier. Il existe plusieurs façons de lire un fichier ligne par ligne en PHP mais certaines peuvent très vite devenir problématique lorsqu'il s'agit de lire des gros fichiers.

Array
-----
Il existe plusieurs façons de lire un fichier dans un tableau PHP, mais voici celle que je vois le plus souvent dans des projets :

```php
<?php
require_once('utils.php');

$rows = $this->read('input.txt');
foreach($rows as $row) {
    // traiter les données
}
```

```php
<?php
// utils.php
public function read(string $filename): array
{
    $handle = fopen($filename, "r");

    $rows = [];
    while(!feof($handle)) {
        $rows[] = fgets($handle);
    }

    fclose($handle);

    return $rows;
}
```

### Problèmes
#### Le temps d'exécution est trop long
Plus le fichier est volumineux, plus le temps d'exécution sera long. Cela est logique car PHP aura besoin de stocker un grand nombre d'éléments et donc générer un très grand tableau.

#### La limite de mémoire est atteinte 
Lorsque le fichier est léger, il n'y a pas de question à se poser, mais lorsque celui-ci contient un gros volume de données, c'est là que ce message d'erreur apparaît :

```
Fatal Error: Allowed memory size of XXXXX bytes exhausted. 
```

En effet, dans l'exemple ci-dessus, **PHP stocke toutes les données du tableau en mémoire**. La taille de votre tableau sera donc limitée par la quantité de mémoire allouée au script définie par la variable `memory_limit` de PHP. Il m'arrive de voir la valeur de cette variable égale à `memory_limit=-1` en production, ce qui signifie que PHP utilisera toute la mémoire disponible sur le serveur ! Je ne vous recommande donc pas cette pratique mais vous propose plutôt d'utiliser un Itérateur ou un Générateur.

Itérateur
---------

> Un Itérateur est un patron de conception comportemental qui permet de parcourir les éléments d'une collection de façon séquentielle. Chaque itérateur doit indiquer la façon dont il doit être traversé, et quelles valeurs seront disponibles à chaque itération.

Depuis la version 5, PHP fournit une interface `Iterator` qui peut être utilisée pour créer des itérateurs personnalisées :

```php
Iterator extends Traversable {
    /* Méthodes */
    abstract public current () : mixed
    abstract public key () : scalar
    abstract public next () : void
    abstract public rewind () : void
    abstract public valid () : bool
}
```

L'interface déclare les opérations nécessaires au parcours d'une collection :
- `current()` : donne la position actuelle
- `key()` : donne la clé de l'élement actuel
- `next()` :  donne le prochain élément de la collection
- `rewind()` : recommence la boucle depuis le début
- `valid()` : vérifie si la position actuelle est valide.

Il est donc possible de créer un itérateur qui permet de parcourir un fichier ligne par ligne :

```php
<?php

class FileIterator implements \Iterator
{
    protected $fileHandle;
    protected $row;
    protected $lineNumber;

    public function __construct(string $fileName)
    {
        if (!$this->fileHandle = fopen($fileName, 'r')) {
            throw new \RuntimeException('Unable to open file "' . $fileName . '" ');
        }
    }

    /**
     * Revient au début du fichier.
     */
    public function rewind()
    {
        fseek($this->fileHandle, 0);
        $this->row = fgets($this->fileHandle);
        $this->lineNumber = 0;
    }

    /**
     * Vérifie que la fin du fichier n'est pas atteinte.
     */
    public function valid()
    {
        return !feof($this->fileHandle);
    }

    /**
     * Retourne la ligne actuelle.
     */
    public function current()
    {
        return $this->row;
    }

    /**
     * Retourne le numéro de la ligne actuel.
     */
    public function key()
    {
        return $this->lineNumber;
    }

    /**
     * Déplace le pointeur à la ligne suivante.
     */
    public function next()
    {
        if ($this->valid()) {
            $this->row = fgets($this->fileHandle);
            $this->lineNumber++;
        }
    }

    public function __destruct()
    {
        fclose($this->fileHandle);
    }
}
```

La fonction `read()` peut donc être simplifiée avec l'utilisation de cet itérateur :

```php
public function read(string $filename): FileIterator
{
    return new FileIterator($filename);
}
```

Vous pouvez ensuite parcourir l'itérateur avec l'utilisation de `foreach` :

```php
$rows = $this->read('input.txt');
foreach($rows as $row) {
    // traiter les données
}
```

Voici l'ordre des opérations exécutées au moment où vous bouclez sur l'itérateur :
1. Avant la première itération de la boucle, la méthod `rewind()` est appelée.
2. Avant chaque itération de la boucle, la méthode `valid()` est appelée : 
 - Si la méthode `valid()` retourne false, la boucle est terminée.
 - Si la méthode `valid()` retourne true, les méthodes `current()` et `key()` sont appelées.
4. Le corps de la boucle est évalué.
5. Après chaque itération de la boucle, la méthode `next()` est appelée et nous répétons les opérations à partir de l'étape 2 ci-dessus.

À la différence d'un tableau, **l'itérateur consommera très peu de mémoire (<1 Mo)** car PHP aura besoin de stocker uniquement la position du pointeur et la ligne actuelle du fichier. Les précédentes lignes ne seront pas gardées en mémoire.

### Problème

Les itérateurs peuvent être complexes à créer en fonction du type de fichier. Bien qu'il s'agit d'une optimisation au niveau de la mémoire utilisée, le code est plus conséquent pour une simple opération de lecture d'un fichier. Voyons une façon plus simple de créer des itérateurs à l'aide des générateurs PHP.

Genérateur
----------
Depuis la version PHP 5.5, il est aussi possible d'économiser de la mémoire en utilisant des générateurs PHP : 

> Un générateur permet de parcourir les éléments d'une collection sans avoir à construire un tableau en mémoire pouvant conduire à dépasser la limite de la mémoire ou nécessiter un temps important pour sa génération.

En réalité, un générateur est un objet PHP qui implémente l'interface `Iterator`. **Il permet de simplifier la création des itérateurs** en écrivant une seule fonction chargée de construire le tableau, sans se soucier de toutes les autres fonctions de l'itérateur permettant d’obtenir l’entrée courante du tableau ou de savoir si le tableau contient une autre entrée pour continuer son parcours par exemple. Il sera capable de le gérer tout seul.

Pour créer un générateur, il suffit de créer une fonction qui à la place de retourner un tableau, utilisera autant de fois que nécessaire le mot-clé `yield` pour indiquer à la foncction générateur, les éléments à parcourir au moment où vous bouclerez dessus :

```php
<?php
public function read(string $filename): \Generator
{
    $handle = fopen($filename, "r");

    // $rows = [];
    while(!feof($handle)) {
        // $rows[] = fgets($handle);
        yield fgets($handle);
    }

    fclose($handle);

    // return $rows;
}
```

Comme vous pouvez le voir, on parcourt toujours les lignes du fichier mais plutôt que de renvoyer le tableau avec toutes les lignes, on utilise le mot-clé `yield` pour indiquer à PHP les lignes du fichier que le générateur devra parcourir.

Lorsque la fonction `read` est appelée, elle retourne un objet `Generator` que l'on peut parcourir avec `foreach` :

```php
$rows = $this->read('input.txt');
foreach($rows as $row) {
    // traiter les données
}
```

Lorsque vous parcourez cet objet, PHP appellera les méthodes d'itération de l'objet (`rewind()`, `valid()`, `key()`, `current()`) chaque fois qu'il a besoin d'une valeur. Ensuite, il sauvegardera le statut du générateur en appelant la méthode `next()` de l'itérateur au moment où il génère une valeur. Lorsqu'il n'y a plus de valeur à fournir (`valid()` retourne false), la fonction générateur peut simplement sortir, et le code appelant continuera comme si un tableau n'avait plus de valeur.

**Le générateur consommera autant de mémoire qu'un itérateur**, c'est à dire très peu.

### Problème
À la différence des itérateurs, **le générateur est capable d'aller uniquement vers l'avant**. Il ne peut donc pas être ré-initialisé une fois que le parcours a commencé. Cela signifie que le même générateur ne peut pas être utilisé à plusieurs reprises : il devra être reconstruit en appelant une nouvelle fois la fonction générateur.

```php
$rows = $this->read('input.txt');
foreach($rows as $row) {
    // traiter les données
}

foreach($rows as $row) {
    // vous ne passerez jamais ici car le générateur a déjà été consommé.
}
```

Conclusion
----------
**La lecture d'un fichier ligne par ligne avec un tableau PHP est à bannir si vous travailler avec des gros fichiers.**

Le générateur ne vous permettra pas de lire plusieurs fois le fichier, vous devrez appeler une nouvelle fois la fonction générateur à chaque fois que vous voulez revenir au début du fichier. Si vous devez parcourir une seule fois le fichier, je vous recommande d'utiliser les générateurs qui sont plus simple à créer par rapport à des itérateurs.

L'itérateur est la solution à retenir si vous avez besoin de traiter plusieurs fois les données du fichier. Vous serez capable de revenir au début du fichier à n'importe quel moment.

Ressources
----------

- Lire un fichier dans un tableau avec la fonction [`file()`](https://www.php.net/manual/fr/function.file.php) ou [`fgets()`](https://www.php.net/manual/fr/function.fgets.php)
- [Générateur PHP](https://www.php.net/manual/fr/language.generators.overview.php)
- Interface [Iterator](https://www.php.net/manual/fr/class.iterator.php)
- [Comparaison des générateurs avec les objets Itérateurs](https://www.php.net/manual/fr/language.generators.comparison.php)
- Patron de conception [Itérateur](https://refactoring.guru/fr/design-patterns/iterator)

Credits
-------
Image de [Jordi Koalitic](https://www.instagram.com/p/B1gdCr5IIPf)