Pour découvrir de nouveaux frameworks Java, j’ai réécrit un POC sur les requêtes HTTP partielles (range requests) : un serveur Spring y exposait des fichiers Parquet statiques, lus à distance par DuckDB. Il s’agissait de vérifier que :

  1. grâce aux requêtes partielles et à la structure par blocs du format Parquet, DuckDB peut lire une colonne entière sans télécharger tout le fichier ;
  2. Spring MVC gère correctement les requêtes partielles sur les fichiers statiques (ce qui m’a valu une petite contribution à sa documentation).

Cette fois, Vidocq remplace Spring et Hardwood remplace DuckDB. En chemin, un troisième outil s’est invité pour mesurer les données échangées pendant les tests : JFRUnit.

Courte présentation des frameworks et bibliothèques utilisés Link to heading

Vidocq Link to heading

Vidocq vise à implémenter Jakarta EE et MicroProfile sans aucune dépendance externe, sur une stack européenne et souveraine, indépendante de tout éditeur commercial. Encore en développement, il propose déjà des versions de démonstration.

Il coche les cases d’un framework Java moderne : Java 25, threads virtuels, code lié à la compilation (pas de réflexion), compatibilité AOT, jpackage et compilation native, architecture en petites briques indépendantes.

Il remet aussi au goût du jour les modules Java (JPMS, apparus en Java 9), omniprésents dans le JDK mais boudés par les grands frameworks.

Nous n’en utiliserons ici qu’une petite brique : Chappe, un serveur HTTP léger.

Hardwood Link to heading

Hardwood, apparue il y a moins d’un an, comble un manque : lire des fichiers Parquet en pur Java, sans cascade de dépendances (seules les bibliothèques de compression sont optionnelles). Ses développeurs soignent particulièrement les performances, benchmarks à l’appui. L’écriture arrive avec la version 1.1 (en bêta), et une CLI est aussi disponible.

JFRUnit Link to heading

Depuis Java 14, la classe RecordingStream permet de consommer un enregistrement JFR en continu depuis le code. On peut donc capturer des évènements pendant un test et s’en servir pour des assertions : performances, accès disque, trafic réseau…

JFRUnit prend en charge la plomberie JFR et fournit une API d’assertions. Ici, elle servira à mesurer les données reçues par un client HTTP.

Mise en œuvre Link to heading

Un simple test JUnit démarre un serveur Chappe et un client Hardwood qui communiquent en HTTP sur l’interface locale. Le client lit la colonne Age d’un fichier Parquet servi par Chappe et en calcule la moyenne ; en parallèle, un enregistrement JFR mesure le volume de données échangé.

Architecture logicielle de l’ensemble Link to heading

flowchart LR subgraph "JVM Test" direction TD subgraph Client Hardwood HttpInputFile end subgraph "Serveur Chappe (Vidocq)" RangeRequestHandler --- parquet(Fichier <code>static/parquet/titanic.parquet</code>) end subgraph Assertions JUnit JFRUnit["Assertions JFRUnit"] AssertJ["Assertions 'métier'"] end HttpInputFile --- JFR(("Enregistre les évènements JFR jdk.SocketRead")) JFR --> RangeRequestHandler JFRUnit --"Vérifie la quantité de données reçues" --> JFR AssertJ --"Vérifie l'exactitude des données reçues" --> HttpInputFile end

Extension Hardwood pour lire les fichiers via HTTP Link to heading

Le fichier titanic.parquet contient des données sur les passagers du Titanic. Avec DuckDB, une simple requête SQL suffisait grâce à son extension HTTP : select avg("Age") from 'http://localhost:8080/titanic.parquet'.

Hardwood, lui, ne fournit pas d’InputFile pour HTTP. Mais l’interface n’est pas scellée et ne compte que cinq méthodes :

  • open : le HttpClient du JDK gérant lui-même son pool de connexions, on ne peut pas vraiment « ouvrir » une connexion ici. On envoie plutôt une requête HEAD qui valide l’URL et la configuration réseau, amorce une connexion probablement réutilisée ensuite, et récupère la taille du fichier.
  • readRange : renvoie le corps d’une requête GET partielle dont les bornes sont celles passées en argument.
  • length : renvoie la taille de la ressource, obtenue lors de l’appel à open.
  • name : renvoie l’URL, à l’image de MappedInputFile.
  • close : ferme le HttpClient.

Rien d’étonnant à cette absence pour une bibliothèque aussi jeune, d’autant que son modèle s’y prête très bien : readRange et length correspondent directement aux requêtes partielles et aux requêtes HEAD.

L’implémentation, volontairement simple (ni optimisation ni gestion d’erreurs poussée), se trouve dans poc.java.httprange.hardwood.HttpInputFile. Pratique pour un POC, le client HTTP du JDK n’est pas forcément adapté à la production : pas de cache, pas de compression, quelques bogues et soucis de performance.

Le cache de Hardwood : RangeBackedInputFile Link to heading

Le client HTTP du JDK ne gérant pas de cache, on encapsule HttpInputFile dans un dev.hardwood.internal.reader.RangeBackedInputFile, qui ajoute le cache interne de Hardwood. C’est ce que fait le test.

Extension Hardwood pour faciliter la lecture d’une colonne Link to heading

Pour simplifier la lecture d’une colonne, la méthode poc.java.httprange.hardwood.ParquetFileReaderExtension#streamColumn expose ses valeurs sous forme de DoubleStream, en gérant les valeurs manquantes, selon l’algorithme décrit dans la documentation. Là encore, étendre la bibliothèque est simple.

Extension de Vidocq/Chappe pour traiter les requêtes HTTP partielles Link to heading

Côté serveur, Chappe doit répondre aux requêtes partielles du client. Un serveur se déclare ainsi :

try (io.vidocq.chappe.api.Server server = Server.builder()
        .port(8080)
        .handler(handler)
        .build()) {
        server.start();
// The server handles requests...
} // stop() called automatically

handler est un io.vidocq.chappe.api.Handler, une interface fonctionnelle qui reçoit une requête et produit une réponse. Chappe fournit io.vidocq.chappe.api.StaticFileHandler, qui sert un dossier de ressources statiques, et un builder de io.vidocq.chappe.api.Router, qui associe des patterns de requêtes à des méthodes de traitement.

StaticFileHandler correspond presque à notre besoin, mais ignore les requêtes partielles. On l’enveloppe donc dans un Handler personnalisé (poc.java.httprange.vidocq.RangeRequestHandler) qui :

  1. délègue à StaticFileHandler pour obtenir la réponse complète ;
  2. si la requête porte un en-tête Range, n’en renvoie que la portion demandée, avec un statut 206 et un en-tête Content-Range ;
  3. sinon, renvoie la réponse complète telle quelle.

L’approche a deux limites. Le fichier est lu en entier au moins une fois, le cache de StaticFileHandler ne couvrant que les petits fichiers. Surtout, elle échoue si le client demande une réponse compressée (le cas le plus courant) : StaticFileHandler compresse lui-même le corps, ce qui fausse le découpage. Elle suffit néanmoins pour un POC et permet de découvrir l’API, notamment io.vidocq.chappe.api.Response.Builder.

Mise en œuvre de JFRUnit Link to heading

Pour vérifier que le fichier n’est pas transféré en entier, on mesure les octets lus depuis localhost:8080 (port d’écoute de Chappe) pendant le test, en surveillant l’évènement jdk.SocketRead avec JFRUnit (configuration décrite dans la documentation).

NB : dans la version actuelle de JFRUnit, la classe de test ainsi que l’attribut jfrEvents doivent être publics, car la bibliothèque y accède par réflexion.

L’API de JFRUnit simplifie ensuite le calcul, et ses constantes pour les noms d’évènements et d’attributs évitent les fautes de frappe :

private long totalBytesReadByHardwoodFromRemoteTitanicParquetFile() {
            // ne conserver que les évènements relatifs à localhost:8080
    return jfrEvents.filter(SocketRead.INSTANCE.withHost("localhost").withPort(8080))
            // récupérer la quantité de données reçues pour chaque évènement
            .mapToLong(socketReadEvent -> socketReadEvent.getLong(SocketRead.BYTES_READ.getName()))
            .sum();
}

On peut ensuite écrire une assertion dans le test qui vérifie que la quantité totale de données lues est bien positive mais inférieure à la moitié de la taille du fichier (il est difficile de donner un seuil précis sans analyser la structure interne du fichier Parquet, donc on prend la moitié du fichier pour s’assurer qu’on a un gain substantiel) :

assertThat(totalBytesReadByHardwoodFromRemoteTitanicParquetFile())
        .isPositive()
        .isLessThan(Files.size(Path.of(PARQUET_FILE)) / 2);

L’implémentation complète se trouve dans la classe CheckBytesTransfered.

Conclusion Link to heading

Dans le POC précédent, mesurer les données échangées était plus compliqué : DuckDB passe par un binaire natif dont le trafic échappe à la JVM. Avec Hardwood, on reste dans la JVM et on profite de JFR dans les tests, une technique qui mériterait d’être plus connue. JFR pourrait d’ailleurs servir à comparer les performances de différentes approches.