2018-06-22

Pagination chaînée et Generator pour les APIs HTTP

TL;DR : Pour récupérer et traiter efficacement une quantité massive de données via une API HTTP, utilisez de la pagination chaînée et le design pattern Generator.

Dans un système composé de différents services interconnectés par des API HTTP, il arrive que certains traitements nécessitent le transfert d'un nombre important de données d'un service à l'autre. Lorsque le volume de données est important (ou inconnu), il est indispensable d'utiliser des techniques permettant d'optimiser les ressources système lors du traitement.

Un cas simple : requête SQL

Prenons un exemple classique : une requête SQL initiée par un batch PHP, afin de traiter toutes les lignes d'une table de plusieurs millions d'entrées.

Approche naïve

L'approche naïve consiste à :

// Exemple avec PDO
$sth = $dbh->prepare("SELECT * FROM BigTable");
$sth->execute();
$result = $sth->fetchAll();
foreach ($result as $row) {
    doSomethingWith($row);
}

Approche naïve

Cette approche convient pour un faible volume de données et a l'avantage de la simplicité. Mais dès que le volume devient important, les problèmes suivants apparaissent :

A moins d'avoir à disposition une quantité de RAM infinie, on comprend que cette approche rencontre vite ses limites.

Approche par itérateur

La plupart des drivers SGBD (tous ?) fournissent des APIs permettant d'utiliser des itérateurs, par exemple avec PDO :

$sql = 'SELECT * FROM BigTable';
$stmt = $pdo->prepare($sql);
$stmt->execute();
foreach ($stmt as $row) {
    doSomethingWith($row);
}

Dans ce cas de figure, le déroulement est le suivant :

Itérateur

Cette approche permet de ne pas avoir à stocker toutes les lignes en RAM côté PHP, et peut également en théorie permettre d'optimiser le temps de transfert des lignes (en le parallèlisant avec le traitement de la lignes précédente, si le driver le permet).

On constate ici que la construction foreach de PHP permet de boucler sur une structure de donnée qui n'est pas une liste (array), mais un itérateur (Iterator).

Plus complexe : une API HTTP

Les APIs HTTP présentent des contraintes particulières :

Approche naïve

L'approche naïve consiste à n'exécuter qu'un seul appel à l'API, qui va récupérer toutes les lignes.

// Côté client
$json = file_get_contents('http://example.com/lots-of-data');
$rows = json_decode($json);

// Côté serveur
$json = json_encode($bigList);
echo $json;

Approche naïve API

Dans ce cas de figure, les performances globales du systèmes sont encore plus mauvaises que dans le cas du SGBD. En effet, la sérialisation et la déserialisation utilisent des algorithmes relativement gourmands en ressources. Une grande quantité de RAM sera donc consommée côté serveur dans le seul but de sérialiser les données, puis ensuite côté client pour la désérialisation, tout cela avant même de commencer à traiter les données côté client.

Multiplier les appels à l'API

Pagination numérotée

Pour éviter une trop grande consommation de RAM côté API et client, il est possible de découper la récupération de l'ensemble des lignes en plusieurs appels, en utilisant de la pagination.

L'approche la plus "intuitive" serait donc d'utiliser une limite (par exemple, 500 lignes par page) et un numéro de page, puis de récupérer les pages les unes après les autres, en incrémentant le numéro de page.

Pagination simple

Cette approche a un problème majeur dû au fait que les requêtes HTTP se sont pas transactionnelles.

En effet, considérons le scénario suivant : entre la récupération de deux pages successives par le client, un autre client a provoqué la suppression d'une ligne.

Suppression intermédiaire

Dans ce cas, la deuxième page récupérée par le client commencera non pas à la ligne 500 comme prévu, mais à la ligne 501. La ligne 500 ne sera donc jamais récupérée et traitée par le client, alors qu'elle n'a aucun rapport avec le ligne qui a été effectivement supprimée.

Le problème inverse peut se produire si le Client2 insère une ligne qui aurait dû se situer dans la page 1 : la ligne 499 passe alors en 500e position, et sera présente sur la page 1 ET sur la page 2.

Pour éviter ces problèmes, il est possible d'utiliser un autre mode de pagination, qui ressemble à une liste chaînée.

Pagination chaînée

Le principe de ce mode de pagination est le suivant : plutôt que de se baser sur un numéro de page que l'on incrémente d'une page à l'autre, on se base sur l'identifiant du dernier élément de la page que l'on vient de récupérer pour la page suivante.

Exemple :

// Côté client
$after = -1;
do {
    $json = file_get_contents("http://example.com/lots-of-data?limit=500&after=$after");
    $rows = json_decode($json);    

    foreach($rows as $row) {
        doSomething($row);
    }

    $after = $rows[count($rows)-1]->id;
} while (count($rows) > 0);

// Côté serveur
$sth = $dbh->prepare("SELECT * FROM BigTable WHERE id > :after ORDER BY id LIMIT :limit");
$sth->execute([
    ':after' => $_GET['after'],
    ':limit' => $_GET['limit'],
]);
$rows = $sth->fetchAll();
$json = json_encode($rows);
echo $json;

De cette manière, si une ligne est supprimée ou ajoutée entre deux appels à l'API, les resultats resteront cohérents.

Abstraction de la couche client API grâce à un générateur

Si l'on observe le code client ci-dessus, on constate qu'il s'agit principalement de code technique dont la place se situerait dans les couches "techniques" de l'application (par opposition aux couches de logique métier). La logique métier est représentée ici par la fonction doSomething(), dont l'appel est donc embarqué par du code dont la responsabilité est de récupérer des données via une API. Ce code enfreint donc le principe de Responsabilité unique : le client d'API devrait retourner les données, et non déclencher des traitements.

Pour résoudre ce problème structurel, il existe un pattern permettant d'utiliser un flux de données arbitraires de longueur inconnue comme un itérateur : le pattern Generator.

PHP fournit un mot-clé yield permettant la mise en oeuvre facile de ce pattern. Reprenons le code de notre client :

// Côté client

// Couche technique client d'API
function getAllTheData(): \Generator
{
    $after = -1;
    do {
        $json = file_get_contents("http://example.com/lots-of-data?limit=500&after=$after");
        $rows = json_decode($json);    

        foreach($rows as $row) {
            yield $row;
        }

        $after = $rows[count($rows)-1]->id;
    } while (count($rows) > 0);
}

// Dans une autre couche de l'application
$rows = getAllTheData();
foreach($rows as $row) {
    doSomething($row);
}

L'appel à la fonction getAllTheData() retourne une valeur de type Generator, qui peut être utilisé comme un Iterator. On peut donc utiliser cet objet comme une liste contenant la totalité des millions de lignes fournies par l'API.

Il est à noter que :