Servir des fichiers parquet avec Spring MVC Link to heading
Devant l’utilisation grandissante du format Parquet pour diffuser via http de gros volumes de données sous forme de tableas, les backends peuvent être amenés à servir des fichiers Parquet. L’objet de ce billet est de montrer comment un backend Java avec Spring MVC peut faire cela très simplement.
Le format de fichier Parquet Link to heading
Les fichiers au format parquet se font de plus en plus présents pour les gros volumes de données : le site data.gouv.fr propose maintenant ce format. Les fichiers au format Parquet sont en effet très adaptés pour mettre à disposition de grosses tables via http en minimisant les flux réseaux.
Les données y sont organisées en colonnes (format orienté colonne) et partitionnées en blocs et les positions des blocs sont disponibles dans les métadonnées en début de fichier. Avec la seule en-tête du fichier, il est possible d’en avoir la description (variables (les colonnes) avec type et nom), le nombre d’enregistrements. Cette structuration et ce partitionnement du fichier permet d’avoir rapidement, sans lire tout le fichier contrairement au CSV, la moyenne d’une variable : il suffit de lire les blocs relatifs à la variable en question. De nombreuses autres optimisations plus poussées expliquent le succès du format Parquet.
Diffuser correctement des fichiers Parquet via http implique prendre en charge les requêtes http partielles Link to heading
Le principe des requêtes http partielles est que si le client demande seulement une partie d’une ressource, le serveur, s’il sait traiter les requêtes partielles, ne renverra que la partie demandée :
- La demande de la resource se fait à partir d’une simple requête
GET /maRessource - La précision de la partie à demander se fait avec un en-tête
Rangesuivi d’une expression spécifiant la portion de la ressource à renvoyer - si le serveur sait traiter les requêtes partielles, il retourne uniquement la portion demandée avec un code retour
206 (partial content) - si le serveur ne sait pas traiter les requêtes partielles, il retourne l’intégralité de la ressource avec un code 200 (réponse à la requête http en ignorant l’en-tête
Range)
La page html référencée dans le titre donne des exemples concrets
Afin de bénéficier de la faculté des fichiers Parquet à découper les données en blocs, les outils qui lisent des fichiers Parquet distants via http utilisent des requêtes http partielles. En effet, les requêtes http partielles permettront de ne cibler que les blocs du fichier permettant de traiter la demande. Il est donc indispensable qu’un serveur qui sert des fichiers Parquet via http puisse traiter les requêtes partielles. Si le serveur ne sait pas traiter les requêtes partielles, il fera transiter la totalité du fichier sur le réseau et on perdra l’intérêt du format Parquet.
En pratique les intervalles des requêtes http partielles sont spécifiés en bytes : l’en-tête Range: bytes=0-9 désigne donc l’intervalle [0 ; 9] (bornes incluses) des octets du fichier. Le serveur devra renvoyer les 10 premiers octets du fichier (on indexe à partir de 0).
Spring MVC prend en charge les requêtes partielles Link to heading
Bien que cela ne soit pas explicitement précisé dans la documentation, Spring MVC prend en charge automatiquement les requêtes http partielles :
- pour les ressources servies depuis un contrôleur depuis la version 5.0.0.RC4
- pour les ressources statiques depuis la version 4.2.0-RC1
- pour les ressources services depuis un endpoint fonctionnel depuis la version 5.2.5
Autrement dit, si un fichier est servi par Spring, soit comme une ressource statique, soit par un contrôleur (ou bien un endpoint fonctionnel) en tant que Resource, si Spring MVC reçoit une requête http partielle ciblant la ressource en question, il honorera les intervalles (en-tête Range) spécifiés dans la requête sans servir l’intégralité du fichier.
Voici un exemple de contrôleur qui permettra de satisfaire les requêtes http partielles (à adapter aux cas réels en ajoutant des mesures de sécurité pour limiter les accès utilisateurs aux ressources autorisées) :
@Controller
static class HttpRangeCompliantController{
@GetMapping(value = "/{filename}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<Resource> getFileByRange(@PathVariable String filename) {
ClassPathResource classPathResource = new ClassPathResource(filename);
return classPathResource.exists() ?
ResponseEntity.ok(classPathResource) :
ResponseEntity.notFound().build();
}
}
NB : Conditions nécessaires pour que les requêtes partielles soient prises en charge automatiquement pour un contrôleur MVC :
- Le body de la réponse doit être non null et être un sous-type de
Resourcemais pasInputStreamResource- la requête doit comporter un header
Rangesyntaxiquement correct- Le code http de la réponse doit être égal à 200
Si l’une des conditions ci-dessus n’est pas vérifiée, Spring MVC répondra à la requête sans tenir compte de l’en-tête Range et retournera la totalité du fichier en réponse à la requête.
Test d’appel d’un fichier Parquet sur un serveur web Spring MVC par un client qui lit des fichiers Parquet (duckDB) Link to heading
DuckDB en java Link to heading
DuckDB est un SGBD in process capable de lire des fichiers Parquet accessibles à travers une URL : c’est un client très souvent utilisé pour lire les fichiers Parquet et qui va donc mobiliser les requêtes http partielles. DuckDB est conçu pour être utilisé comme un SGBD léger qu’on peut embarquer avec un applicatif et qui entend fournir un accès rapide aux données : c’est donc un client tout à fait adapté pour lire des fichiers Parquet depuis une application, y compris quand ils sont distants. La librairie se présente comme un moteur de SGBD portable associé à diverses façades :
- une CLI
- des API pour java, R, Python, Go, Node.js, Rust, …
Par la suite nous utiliserons l’API java pour accéder à travers http à un fichier Parquet servi par le contrôleur Spring ci-dessus. Le client java s’utilise comme un Driver Jdbc classique (il peut donc être associé à des outils de plus haut niveau pour la couche DAO comme JOOQ). Nous utiliserons ici directement le driver DuckDB pour notre test. Il nécessite d’ajouter la dépendance suivante au projet :
<dependency>
<groupId>org.duckdb</groupId>
<artifactId>duckdb_jdbc</artifactId>
<version>1.3.0.0</version>
</dependency>
Il se configure ensuite comme suit (exemple avec la configuration d’un proxy) :
private Connection getDuckDBConnection() throws ClassNotFoundException, SQLException {
Class.forName("org.duckdb.DuckDBDriver");
Properties props = new Properties();
// Exemple de configuration d'un proxy
props.setProperty("http_proxy", "http://proxy.company.com");
// exemple de configuration d'autres propriétés
props.setProperty("enable_object_cache", "true");
props.setProperty("default_block_size", "16384");
return DriverManager.getConnection("jdbc:duckdb:", props);
}
Enfin les appels se font sous forme de requêtes sql comme pour un SGBD classique. Ici l’exemple d’exécution d’une requête qui calcule la moyenne d’une seule variable d’un fichier en comportant plusieurs :
// parquetFileUrl est l'URL du fichier parquet sur le serveur Spring MVC
double requestAverageAgeWithDuckDB(String parquetFileUrl) throws ClassNotFoundException, SQLException {
Connection conn = getDuckDBConnection();
double averageAge = 0;
// 'Dans la vraie vie', utiliser un preparedStatement, ...
try (ResultSet rs = conn.createStatement().executeQuery("select avg(age) from '" + parquetFileUrl + "'")) {
while (rs.next()) {
averageAge = rs.getDouble(1);
}
}
return averageAge;
}
Vérifier que tout le fichier ne transite par sur le réseau Link to heading
Afin de vérifier que seuls les blocs nécessaires transitent sur le réseau lors de l’interaction entre DuckDB et le serveur Spring MVC qui sert le fichier Parquet, on peut jouer la requête précédente sur l’âge moyen sur le fichier titanic.parquet qui sera déposé sur le serveur Spring MVC de sorte qu’il puisse le servir. Le fichier comporte une colonne age et donc seuls les blocs relatifs à la colonne age devraient transiter sur le réseau.
Il s’agit d’exécuter la même requête sur la moyenne des âges dans un contexte de test où les requêtes http sont surveillées : en calculant la quantité totale d’octets qui vont transiter depuis le serveur Spring MVC vers DuckDB en réponse aux requêtes sur titanic.parquet, on pourra vérifier que tout le contenu du fichier n’a pas transité sur le réseau et donc que seuls les blocs nécessaires de données ont transité :
@Test
void should_return_parquet_by_range() throws IOException, ClassNotFoundException, SQLException, URISyntaxException {
String parquetFilename = "titanic.parquet";
URL parquetUrl = URI.create("http://localhost:" + port).resolve(parquetFilename).toURL();
double averageAge = requestAverageAgeWithDuckDB(parquetUrl.toString());
//check result's likelihood
assertThat(averageAge).isStrictlyBetween(29.0d, 30.0d);
assertThat(totalBytesReadByDuckDBFromRemoteTitanicParquetFile())
.isPositive()
.isLessThan(halfSizeOfRemoteTitanicParquetFile()); /* pour s'assurer que tout le fichier ne transite pas, on vérifie que moins de la moitié des octets du fichier ont été échangés*/
}
On ne peut pas intercepter les requêtes côté client car elles sont effectuées par le moteur de BDD de DuckDB qui est un binaire natif et qui n’est donc pas contrôlé par la JVM. Nous allons donc utiliser les access log de tomcat pour totaliser les octets échangés dans les requêtes relatives à titanic.parquet. Cela se fait avec avec la classe TomcatTestMonitor.WithAccessLog qui configure les access log de tocmat avec un pattern adéquat et pour être écrites dans un fichier surveillé. La classe enregistre également un bean TomcatTestMonitorWithAccessLog chargé de lire dans le fichier d’access log les requêtes http traitées par tomcat. En appelant ce bean, la méthode totalBytesReadByDuckDBFromRemoteTitanicParquetFile dans le test peut retrouver la quantité totale d’octets envoyés en réponse aux requêtes GET "/titanic.parquet et ainsi permettre d’écrire l’assertion sur la quantité totale d’octets transférés par ces requêtes.
private long totalBytesReadByDuckDBFromRemoteTitanicParquetFile() {
return this.tomcatTestMonitor.allRequests()
.filter(ProcessedRequest::isGET)
.filter(request -> request.uri().contains("/titanic.parquet"))
.mapToLong(ProcessedRequest::responseSize)
.sum();
}
tomcatTestMonitor est le bean de type TomcatTestMonitor déclaré par TomcatTestMonitor.WithAccessLog dans le contexte et injecté dans le test.
Références Link to heading
- Format des fichiers Parquet sur le site Apache : https://parquet.apache.org/docs/file-format/
- Plus d’informations sur les fichiers Parquet : https://www.icem7.fr/cartographie/parquet-devrait-remplacer-le-format-csv/
- Requêtes partielles http (ou http range requests) : https://developer.mozilla.org/fr/docs/Web/HTTP/Guides/Range_requests
- RFC sur les Range Requests : https://datatracker.ietf.org/doc/html/rfc9110#name-range-requests
- Dépôt avec le code des exemples au complet : https://framagit.org/FBibonne/poc-java/-/tree/spring-http-range
- Classe de Spring MVC qui gère les requêtes partielles pour les contrôleurs