Accéder au contenu principal

7min.

JSON Streaming et AutoMapper 10.3

Symfony propose depuis peu le composant symfony/json-streamer, permettant d’encoder et décoder du JSON sous forme de flux. Le but : traiter de très gros volumes sans charger l’intégralité du document en mémoire.

Dans AutoMapper, notre solution pour accélérer tous vos mappings de données dans vos projets (sérialisation, object mapping, etc.), nous supportons déjà 2 composants de Symfony en proposant une implémentation alternative :

Ajouter la gestion des flux JSON de la même façon (être une implémentation de l’interface) nous a paru naturel, tous ces composants font exactement la même chose : passer de la donnée de A vers B avec une représentation ou un format légèrement différent.

Nous supportons déjà depuis quelque temps le fait d’avoir un mapping Lazy, a.k.a le mapping s’effectue uniquement au moment où on a besoin du champ (si jamais on ne touche à rien : rien n’est mappé). Le fait de pouvoir passer d’un objet à un flux json fut donc très facile à réaliser.

Mais en travaillant sur l’autre sens : lire un flux pour le transformer en objet, l’implémentation nous a paru beaucoup plus complexe, il fallait revoir toute la partie où on lit la donnée dans la source (le flux JSON).

Section intitulée lazy-jsonLazy JSON

AutoMapper possède déjà toute la connaissance nécessaire pour transformer un tableau vers un objet.

L’idée était donc de lui fournir quelque chose qui se comporte comme un tableau PHP, sans réellement décoder tout le JSON.

C’est cette idée qui a donné naissance à une nouvelle librairie.

Section intitulée code-json-stream-polyfill-code-une-premiere-implementation-en-phpjson-stream-polyfill : une première implémentation en PHP

joelwurtz/json-stream-polyfill est la première implémentation, entièrement écrite en PHP.

L’objectif était d’avoir une API aussi simple que json_decode() :

$data = json_stream_decode($json);

avec une différence importante : on peut également lui passer directement un flux.

$data = json_stream_decode(
    fopen('huge.json', 'r')
);
echo $data['user']['name'];

Le résultat est un JsonStream\Document, qui se comporte comme un tableau PHP classique grâce notamment à ArrayAccess, IteratorAggregate et Countable, mais dont les données sont résolues de manière lazy.

L’appel à json_stream_decode() est quasiment instantané. Le JSON est parsé au fur et à mesure des accès : demander user.name fait avancer le flux jusqu’à cette donnée et seules les valeurs nécessaires sont matérialisées en PHP. Un champ a au final trois états :

  • Non lu : on ne l’a pas encore vu dans le flux
  • Bufferisé : on est passé dessus mais on garde toujours sa représentation en JSON (chaîne de caractères)
  • Parsé : le champ a été lu, parsé et transformé en valeur PHP

Pour AutoMapper, c’est particulièrement intéressant : son code généré peut continuer à lire la donnée comme il le fait déjà, tandis que le parsing JSON reste complètement lazy.

Le polyfill introduit également JSON_STREAM_TRANSIENT :

$data = json_stream_decode(
    $stream,
    JSON_STREAM_TRANSIENT
);

Dans ce mode, les données déjà parcourues peuvent être libérées. On peut donc lire un document arbitrairement gros avec une consommation mémoire stable.

La contrepartie est importante : la lecture devient forward-only. Une donnée déjà dépassée peut être définitivement perdue. Ce mode est donc surtout intéressant lorsque l’on parcourt tout le document dans son ordre naturel, typiquement une grosse collection JSON.

Cette première implémentation fonctionne bien et valide l’API, mais elle possède une limite assez évidente : scanner un énorme JSON en pur PHP reste lent, surtout lorsqu’on finit par lire l’intégralité du document.

Il fallait donc aller plus bas.

Section intitulée code-json-stream-code-la-meme-chose-mais-en-extension-phpjson-stream : la même chose, mais en extension PHP

joelwurtz/json-stream expose exactement la même API :

$data = json_stream_decode($json);

mais directement sous forme d’extension PHP, installable avec pie via pie install joelwurtz/json-stream.

L’intérêt n’est pas uniquement de déplacer le code en natif. L’extension utilise également SIMD pour scanner rapidement la structure du JSON, avec différentes implémentations suivant le CPU : AVX2, SSSE3 ou SSE2 sur x86–64, NEON/PMULL sur ARM64, ainsi qu’un fallback scalaire.

Le fonctionnement reste le même : le document est lazy et seules les valeurs effectivement demandées sont matérialisées.

C’est important parce que le parsing n’est finalement qu’une partie du coût d’un json_decode(). Une fois le JSON compris, PHP doit encore créer les zval, strings, arrays et HashTables correspondants.

JSON → parsing → zval / arrays / strings → objets

SIMD permet d’accélérer fortement la première étape, mais pas de faire disparaître les suivantes.

C’est pourquoi json-stream peut être plus rapide que json_decode() dans certains cas, notamment lorsque seule une partie du document est utilisée, sans pour autant être magiquement dix fois plus rapide lorsque tout doit être matérialisé.

Et comme le polyfill et l’extension partagent la même API, le code ne change pas selon l’environnement : le polyfill fonctionne partout et l’extension peut être installée lorsque les performances deviennent importantes.

Section intitulée automapper-10–3AutoMapper 10.3

Tout cela nous ramène à AutoMapper.

Avec la 10.3, AutoMapper intègre le support de symfony/json-streamer. Le bundle peut remplacer l’implémentation de Symfony et utiliser le moteur de mapping généré d’AutoMapper.

automapper:
    json_streamer:
        enabled: true

On continue ensuite à utiliser l’API JsonStreamer :

$users = $reader->read($stream, $type);

Le mapping passe par AutoMapper et bénéficie de toute la configuration : ObjectMapper, Serializer et JsonStreamer utilisent désormais le même moteur de mapping. Les attributs et fonctionnalités supportés par AutoMapper fonctionnent donc quelle que soit la porte d’entrée utilisée. Cela inclut également les attributs Symfony Serializer supportés par AutoMapper : il n’est plus nécessaire de maintenir une configuration ou avoir des objets différents simplement parce que l’on passe au streaming.

Section intitulée benchmarksBenchmarks

Alt text

Pour la release de cette nouvelle version nous avons aussi mis en place des benchmarks que vous pouvez exécuter vous-même si besoin.

AutoMapper propose de nombreuses options pour optimiser le code généré. La désactivation des checks sur les attributs en est un bon exemple : ils ne servent que si vous faites du mapping dynamique, typiquement un appel d’API qui choisit lui-même les champs à retourner. Par souci de cohérence avec le comportement par défaut du serializer de Symfony, ces checks restent malgré tout activés par défaut, et les benchmarks permettent justement de mesurer ce qu’ils coûtent.

Plutôt que de dérouler tous les scénarios, voici les quatre comparaisons qui correspondent aux portes d’entrée de Symfony. Les trois premières lignes mappent un objet Person unique ; les trois dernières lisent ou écrivent une collection de 20 000 Person, en mode streamé des deux côtés (STREAM pour AutoMapper, iterable / getIterator() pour Symfony), chaque objet étant entièrement hydraté à la lecture.

Scénario AutoMapper AutoMapper opti. Symfony Gain / optimisé
array → objet — vs Serializer 4.77 µs 1.96 µs 78.92 µs 16.5× / 40×
objet → array — vs Serializer 5.24 µs 1.71 µs 39.53 µs 7.5× / 23×
objet → objet — vs ObjectMapper 3.07 µs 1.01 µs 27.52 µs 9.0× / 27×
JSON → objets — vs JsonStreamer 1.112 s 990.3 ms 2.447 s 2.2× / 2.5×
JSON → objets — vs JsonStreamer, avec l’extension 152.6 ms 87.3 ms 2.496 s 16.4× / 28.6×
objets → JSON — vs JsonStreamer 169.5 ms 95.1 ms 68.1 ms 0.40× / 0.72×

L’écriture ne décode rien : elle est strictement insensible à l’extension, et c’est le seul scénario où Symfony reste devant — de 2.5× par défaut, de 1.4× une fois l’attribute checking désactivé. C’est logique : son writer compilé lit les champs directement sur l’objet, là où AutoMapper fait passer chaque élément par le pipeline de mapping complet — renommages, transformers, #[MapTo], propriétés privées, discriminateurs… C’est le prix de la cohérence entre les trois portes d’entrée, et il nous paraît raisonnable.

La lecture, en revanche, passe de 1.112 s à 152.6 ms avec l’extension, soit 7.3× de gain : c’est elle qui fait basculer le streamer d’AutoMapper de « à peine plus rapide » à « un ordre de grandeur plus rapide » que celui de Symfony. Dès que vous lisez du JSON avec le streamer, installez-la.

Côté mémoire, les deux streamers gardent un pic plat quelle que soit la taille de la collection — environ 10 MB pour Symfony, 21 MB pour AutoMapper avec l’extension en lecture, 7.2 MB pour les deux en écriture — c’est tout l’intérêt. Encore faut-il demander le bon type à Symfony : avec Type::list(...) au lieu de Type::iterable(...), le reader matérialise tout et le pic monte à 682 MB sur les mêmes 20 000 objets.

Enfin, la colonne « opti. (sans attribute checking) » parle d’elle-même : c’est de loin le levier le plus rentable côté AutoMapper, avec 2 à 3× sur les scénarios unitaires, 1.7× en lecture streamée et 1.8× en écriture streamée. Et c’est une fonctionnalité rarement utilisée.

La fonctionnalité est encore jeune : testez-la sur vos gros JSON et faites-nous vos retours !

Commentaires et discussions

Nos articles sur le même sujet

Ces clients ont profité de notre expertise