JSON Parser

Streaming & Parsing de JSON en php

A la fois par défi et par manque de lib moderne (php 8.2) sur le sujet, je me suis lancé dans la réalisation de cette lib.

L'objectif est de lire (et donc parser) à la volée du JSON avec un modèle objet ouvert.

On peut également écrire du JSON en stream, mais cette tâche était de loin la plus basique.

Evidemment, retour aux bases et donc je suis parti de la RFC 8259 qui décrit tout ce qu'il faut pour cela.

La grammaire du JSON est vraiment très simple et pour parser, j'avais 2 choix :

  1. "classique" : tokenisation suivi d'analyse grammaticale.
  2. "naïve" : directement faire matcher l'analyse via la structure du code

Dans le cas "classique" 1, le problème c'est la syntaxe récursive du json qui nécessite d'utiliser par exemple une machine à état récursive et on augmente très vite la complexité du code.

J'ai choisi la solution "naïve" 2. En effet, la grammaire étant très simple, j'ai préféré pour l'approche KISS (Keep it simple, stupid). Le point où c'est "simple stupid", c'est que je fais porter la responsabilité de l'analyse récursive du JSON par la call-stack de php. L'inconvénient c'est la limite de la "call stack" et le risque de stack overflow.

Cependant, de manière pratique, il est absolument exceptionnel voire improbable de rencontrer du json dépassant quelques niveaux d'imbrications : je n'ai jamais rien rencontré qui dépasse une dizaine de niveaux. En PHP, les problèmes pourraient commencer à surgir à partir de plusieurs dizaines voire centaines. Nous sommes donc pragmatiquement très à l'abri en considérant cette manière de faire.

Un autre avantage de cette approche c'est la lisibilité du code qui peut être difficilement neilleure. Par lisibilité, on parle aussi de maintenance et de fiabilité du code. D'autant plus que la base de code est petite. Economie de code = moins d'instructions machine = performance = facilité de maintenance. Bref beaucoup d'avantages.

Exemple de code

    private function fetchValue(): void
    {
        switch ($this->readChar()) {
            case Rfc8259::STRUCTURE_BEGIN_OBJECT:
                $this->pushKey();
                $this->fetchStructure(Rfc8259::STRUCTURE_BEGIN_OBJECT);
                $this->visitor->beginNode(new ObjectNode($this->parents, ++$this->depth));
                $this->fetchWhitespaces();

                if (Rfc8259::STRUCTURE_END_OBJECT !== $this->readChar()) {
                    $this->fetchObjectMember();
                    $this->fetchWhitespaces();
                }

                while (Rfc8259::STRUCTURE_VALUE_SEPARATOR === $this->readChar()) {
                    $this->fetchStructure(Rfc8259::STRUCTURE_VALUE_SEPARATOR);
                    $this->fetchWhitespaces();
                    $this->fetchObjectMember();
                    $this->fetchWhitespaces();
                }

                $this->visitor->endNode(new ObjectNode($this->parents, --$this->depth));
                $this->fetchStructure(Rfc8259::STRUCTURE_END_OBJECT);
                $this->popKey();
                break;

            case Rfc8259::STRUCTURE_BEGIN_ARRAY:
            // ...

            case Rfc8259::STRING_QUOTATION_MARK:
                $this->fetchString(true);
                break;

            case Rfc8259::LITERAL_FALSE[0]:
                $this->fetchLiteral(Rfc8259::LITERAL_FALSE);
                break;

            // ...

            default:
                $this->fetchNumber();
                break;
        }
    }

Découplage

En entrée, on dépend d'une interface d'Input. Vous pouvez utiliser les quelques implémentations prévues (file, stream, ...) ou coder les votres : le parser s'en fiche.

En sortie, on dépend d'une interface de Visitor (je tord légèrement le principe car on est à la limite d'un Listener). Mais vu qu'on est dans un mécanisme de parsing de syntaxe (AST, ...), il me paraissait plus adapté de partir sur le visitor. Ce visitor reçoit les noeuds du JSON, porteurs d'informations : à vous d'implémenter ce que vous voulez derrière (stockage en bdd, filtrage, écriture à l'aide d'un Output, etc ...).

Pour des raisons de facilité, j'ai exposé un cas d'utilisation des Fibers en php. Il permet de transformer un Visitor en Iterator en mettant en suspens le visitor pour rendre la main à l'itérateur et ainsi itérer simplement sur les noeuds.

Cela requiert l'utilisation de visitors dédiés, j'en ai codé 2 : un pour parcourir les feuilles (avec un maxDepth) de notre JSON, et un autre pour décoder à la volée des noeuds sélectionnés (avec un callback).

Le passage d'un visiteur à un iterateur avec les "fibres" se paie au prix d'une légère perte de perte (-10%) qui est la conséquence du swap de contexte d'exécution des "fibres". A vous de juger la pertinence ou pas d'utiliser ces itérateurs dans votre code.

Considérations sur les perfs

Faire du JSON parsing en stream en php : déjà rien qu'à l'ennoncé, vous devez théoriquement mettre un frein car php n'est clairement pas le langage le plus adapté.

Mais de manière pragmatique, parfois on n'a pas trop le choix quand des stacks techniques historiques existent et qu'on doit faire avec.

Les "performances" c'est très relatif et ici ce n'est pas trop les valeurs absolues qui nous intéressent mais les valeurs relatives pour comparaison.

Json de 100 MB Temps Mémoire Débit
json_decode 1 sec 300 MB 100 MB/s
arnapou/json-parser 15 sec 3 MB 6.7 MB/s
salsify/jsonstreamingparser 17 sec 3 MB 5.8 MB/s
tokenizer byte par byte qui ne fait rien 2.5 sec 3 MB 40 MB/s
tokenizer byte par byte qui fait son travail 8.5 sec 3 MB 12 MB/s

La partie tokenizer est un premier jet que j'avais tenté dans l'approche "classique". On voit que rien que le parcours byte à byte sans rien faire est très couteux en php (-60% de perf) par rapport au json_decode natif. En php, on ne peut pas faire de pointeur char * sur un buffer d'octet (string). Récupérer un caractère est une opération couteuse en effectuant des échanges dans les data structures php.

Finalement, en comparant avec salsify/jsonstreamingparser qui est une libre analogue de streaming json la plus utilisée de packagist, on est pas si mal... Cette dernière gère plusieurs choses que je ne gère pas (UTF-16, ...) mais qui sont des besoins marginaux qui pourront faire l'objet de développement le cas échéant. Par contre mon approche avec des interfaces est beaucoup plus découplée et libre dans les utilisations possibles.

Enfin, en considérant la partie infrastructure réseau avec les débits disponibles des machines sur le web, le débit que sait gérer arnapou/json-parser me paraît très acceptable.

Je vois actuellement deux axes possibles d'amélioration de perf : JIT, et FFI que j'explorerai plus tard si j'ai l'envie/le temps.

Exemples

Arnapou\Json\JsonStreamUtils::pretty(
    input: new Arnapou\Json\Input\FileInput($input_filename),
    output: new Arnapou\Json\Output\FileOutput($output_filename)
);

Voir plus de code sur Gitlab.

Détails

Pour plus de détails techniques, allez voir le README sur Gitlab.

Liens