Quarkus avec Panache

  1. Modèle de données
  2. Bootstrap des projets
    1. Quarkus
    2. Spring Boot
  3. Pattern pour la couche de persistence
    1. Repository Pattern
    2. Record Pattern
  4. Resource
  5. Comparaison
    1. Procédure
    2. Scénario 1
    3. Scénario 2
    4. Scénario 3
  6. Conclusion

Comme je vous l’avais partagé dans l’article Introduction à Quarkus, nous avions beaucoup apprécié la technologie et avions validé le fait de continuer à la tester. Cet article fera partie d’une série où nous présenterons des briques / modules de l’écosystème Quarkus.

Lors du test de découverte de Quarkus on avait testé la partie API Rest, dans cet article nous allons ajouter la couche d’accès aux données avec Panache.  Cette librairie spécifique à Quarkus permet de simplifier la couche de persistance basée sur Hibernate. Nous pouvons comparer cela à Spring-data dans le monde Spring.  L’objectif de cet article est de produire une même  API de manipulation de Film  avec les 2 stacks Quarkus/Panache et SpringBoot/Spring-data. Les sources associés à ce projet sont disponibles sur notre repo Github Aepsilon ici.

 

1. Modèle de données

Pour notre article, nous allons travailler avec  les entités suivantes :

  • Genre : Un genre est défini par un nom. Un genre est associé à un film et plusieurs films peuvent avoir le même genre;
  • Character : Un personnage est caractérisé par un nom, un prénom. 
  • Movie : Un film est décrit par un titre, une description. Il est composé d’une liste de personnage.

Je profile de cet article pour  tester une « nouveauté »  que propose github : Mermaid.  Cette techno permet de décrire des diagrammes UML en markdown dans le fichier Readme du projet github sans utiliser d’outils externe (type plantUml).  Vous pouvez retrouver le schéma ci-dessous à la racine du projet  dans le fichier README sous github.

MCD

 

2. Boostrap des projets

On va créer un projet maven constitué de 2 modules avec les 2 projets Quarkus et SpringBoot. On commence par créer un projet le plus simple possible et on va modifier un peu le pom.xml racine.

mvn archetype:generate -DgroupId=com.aepsilon -DartifactId=articlePanache -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false

Depuis le répertoire racine du projet  (dans mon cas ‘articlePanache’), je crée les 2 modules.

2.1 Quarkus

mvn io.quarkus:quarkus-maven-plugin:2.9.0.Final:create -DprojectGroupId=com.aepsilon -DprojectArtifactId=samplePanache -Dextensions="quarkus-hibernate-orm-panache,quarkus-jdbc-h2,quarkus-resteasy-jackson"

On initialise le projet avec 3 extensions:

  • quarkus-hibernate-orm-panache : Librairies pour Panache;
  • quarkus-jdbc-h2: Le driver qui va bien pour nos tests en local sur une BD H2 montée en mémoire;
  • quarkus-resteasy-jackson : Les librairies pour implémenter notre API Rest.

Je rajoute dans le fichier unifié de configuration application.propertie  du projet Quarkus les paramètres pour me connecter à la base de données H2. Dans cette configuration on précise à Hibernate de supprimer puis créer la base de données au démarrage de l’application  puis de lancer un script sql ‘data.sql’.  

quarkus.datasource.jdbc.url=jdbc:h2:mem:default
quarkus.datasource.db-kind=h2
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.sql-load-script=data.sql

2.2 Spring Boot

Pour SpringBoot, je n’ai pas trouvé d’archtetype Maven pour générer le projet alors je passe toujours pas Spring Initialzr

Spring Initializr

Pour activer la partie JPA dans une application springBoot il nous faut importer les dépendances spring-boot-starter et  spring-boot-starter-data-jpa. Dans notre cas le starter  »spring-boot-starter-web » importe lui même la dépendance spring-boot-starter. Ce dernier contient les élements nécessaires pour l’auto-configuration de Spring-data (spring-boot-autoconfigure) alors que spring-boot-starter-data-jpa importe toutes les dépendances comme hibernate-core, spring-boot-starter-jdbc.

Par défaut SpringBoot configure Hibernate comme implémentation par défaut de JPA. Il configure aussi le dataSource en fonction de la librairie trouvée dans le classPath (soit H2 pour notre exemple)

Fichier de configuration ‘application.propertie’ pour notre sous projet SpringBoot

# DATASOURCE
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.defer-datasource-initialization=true
spring.datasource.data=classpath:sampleData.sql

# JPA / HIBERNATE
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.show-sql=false
spring.jpa.hibernate.ddl-auto=create-drop

 

! Pensez à bien setter à true la propertie ‘spring.jpa.defer-datasource-initialization’ qui permet de lancer le script sql d’init de data une fois la creation du schema faite par Hibernate.

3. Pattern pour la couche de persistence

Panache implémente les 2 pattern ‘Record Pattern‘ et ‘Repository Pattern‘.  Ces 2 pattern offrent une manière standardisée de Créer/Lire/Mettre à jour et Supprimer une entité.  Pour cet article nous utiliserons le ‘record pattern’ pour le sous projet Quarkus    et   ‘Repository Pattern‘ pour SpringBoot.

3.1 Repository Pattern

Le repository implemente toute la logique de gestion de lecture/ecriture/mise à jour ou suppression d’une entité. C’est la manière standard de faire dans un projet SpringBoot avec Spring Data.

Pour les entités, on les définit comme des entités « standard » JPA. Il suffit d’annoter nos classes avec  @Entity et qu’elles ne soient pas final.

Voici un exemple avec la classe Movie:

package com.aepsilon.sampleSpringData.domain;

import javax.persistence.*;
import java.util.Set;

@Entity
public class Movie {
    @Id
    @GeneratedValue(strategy= GenerationType.AUTO)
    private Long id;

    private String title;
    private String description;

    @ManyToOne
    private Genre genre;

    @OneToMany(mappedBy="movie")
    private Set<Character> characters;

    public Movie(){}
    public Movie(Long id){this.id=id;}

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public Genre getGenre() {
        return genre;
    }

    public void setGenre(Genre genre) {
        this.genre = genre;
    }

    public Set<Character> getCharacters() {
        return characters;
    }

    public void setCharacters(Set<Character> characters) {
        this.characters = characters;
    }

    @Override
    public String toString() {
        return "Movie{" +
                "id=" + id +
                ", title='" + title + '\'' +
                ", description='" + description + '\'' +
                ", genre=" + genre +
                '}';
    }
}

 

Concernant le repository , il suffit de faire étendre l’interface avec JpaRepository.  Cette classe étend elle-même PagingAndSortingRepository  (qui étend CrudRepository).  Ainsi  on a accès à toutes les méthodes de CRUD, de pagination… Voici une liste non exhaustive:

  • List<T> findAll();
  • List<T> findAll(Sort sort);
  • List<T> findAllById(Iterable<ID> ids);
  • Optional<T> findById(ID id);
  • boolean existsById(ID id);
  • long count();void deleteById(ID id);
  • void delete(T entity);
  • <S extends T> S save(S entity);

3.2 Record Pattern 

Le Record Pattern intègre toute la logique de creation/lecture/mise à jour et suppression dans l’entité elle-même. Cela a pour but de simplifier la lecture du code en supprimant une couche, d’être plus intuitif. 

! Cet article n’a pas pour but de discuter du meilleur pattern, juste de le présenter.  (Panache le proposant par défaut, autant le tester.)

Pour les entités, il faut toujours les annoter avec @Entity mais il faut aussi les faire étendre soit de PanacheEntity ou PanacheEntityBase. PanacheEntity apporte un attribut ‘id’ de type Long par défaut qui est utilisé comme clé primaire (Si vous voulez des clés composites, alors étendez PanacheEntityBase et gérez vous même la PK). Tous les attributs doivent être public, les getter/setter ne sont pas nécessaires (générés au build time). 

La classe Movie

package com.aepsilon.domain;

import io.quarkus.hibernate.orm.panache.PanacheEntity;

import javax.persistence.*;
import java.util.Set;

@Entity
public class Movie extends PanacheEntity {
    public String title;
    public String description;

    @ManyToOne
    public Genre genre;

    @OneToMany(mappedBy="movie")
    public Set<Characters> characters;


    public Movie(){}
    public Movie(Long id){this.id=id;}


}

Ensuite on peut manipuler directement la classe entité directement dans le service. Par exemple:

package com.aepsilon.service;

import com.aepsilon.domain.Genre;
import com.aepsilon.domain.Movie;
import com.aepsilon.domain.Characters;
import com.aepsilon.dto.CharacterLight;
import com.aepsilon.dto.MovieDetail;
import com.aepsilon.dto.MovieLight;

import javax.enterprise.context.ApplicationScoped;
import javax.transaction.Transactional;
import java.util.List;
import java.util.Optional;

@ApplicationScoped
@Transactional
public class MovieService {

    public List<Genre> getGenres(){
        return Genre.listAll();
    } 

    public List<CharacterLight> getMovieCharacter(Long idMovie){
        return Character.find("movie.id",idMovie).project(CharacterLight.class).list();
    }

   public void test(){
        List<Movie> movies = Movie.listAll();

        Long nbMovie  = Movie.count();

        Movie newMovie = new Movie();
        newMovie.title="titre";
        newMovie.description="desc film";
        newMovie.persist();

        newMovie.delete();

    }
}

Avec l’heritage de la classe PanacheEntity, on a accès à ce genre de méthode (liste encore une fois non exhaustive):

  • public void persist();
  • public void delete();
  • public boolean isPersistent();
  • public static <T extends PanacheEntityBase> T findById(Object id);
  • public static <T extends PanacheEntityBase> Optional<T> findByIdOptional(Object id);
  • public static <T extends PanacheEntityBase> PanacheQuery<T> find(String query, Parameters params) ;
  • public static <T extends PanacheEntityBase> Stream<T> stream(String query, Sort sort, Object… params);
  • ….

Pour finir cette initiation à Panache, je voulais juste attirer votre attention sur les méthodes  getMovieCharacter ou getAllMovie du service MovieService.  Dans ces méthodes on a utilisé une projection pour ne sélectionner que certains champs de l’entité et les injecter dans un Dto (quand on ne veut pas récupérer toutes les données d’une entité). Par contre je n’ai pas réussi à faire la même chose avec des Dtos imbriqués (Movie avec liste de Character), j’ai du revenir à un mapper ‘maison’ (Mais sachez que vous pouvez utiliser du MapStruct par exemple).   

4. Resource

Pour la partie API, les endpoints sont exactement les mêmes sur les 2 projets.  Pour vous présenter la doc API, j’ai juste ajouter une extension à mon projet Quarkus (quarkus-smallrye-openapi) et magie j’ai un swagger UI de dispo fonctionnel en local !

swagger

Vous pouvez aussi utiliser un curl pour tester votre api:

curl http://localhost:8080/api/genre 
curl http://localhost:8080/api/movie 
curl -X POST http://localhost:8080/api/movie -H "Content-Type: application/json" -d '{"title": "movieTitle", "description": "movieDesc", "genre":{"id":"2"}}' 
curl http://localhost:8080/api/movie/48965 curl -X POST http://localhost:8080/api/movie/48965/character -H "Content-Type: application/json" -d '{"firstname": "prenom", "lastname": "nom"}'

5. Comparaison

5.1 Procédure

J’ai voulu faire quelques tests de charges pour voir comment se comportait la mémoire et la CPU de ma machine avec chacun des 2 projets. Je ne vous détail pas les caractéristiques de ma machine car ce n’est pas le sujet. Cet article a pour seul objectif de donner des ordres de grandeurs. Comme lors du premier article, j’ai commencé par regardé la consommation mémoire et le temps de démarrage.

Pour rappel, la commande de build & run:

    • Quarkus :
      • mvn -pl samplePanache clean package
      • java -jar samplePanache/target/quarkus-app/quarkus-run.jar
    • Quarkus Native :
      • mvn -pl samplePanache clean package -Pnative
      • ./samplePanache/target/samplePanache-1.0.0-SNAPSHOT-runner
    • SpringBoot: 
      • mvn -pl sampleSpringData clean package
      • java -jar sampleSpringData/target/sampleSpringData-0.0.1-SNAPSHOT.jar

 

Indicateurs au démarrage de l’application

  SpringBoot Quarkus – JVM Quarkus – Natif
Démarrage (sec)

5,374

2,46

0,4

Taille Artefact (Mo) 36,5 N/A 66,7
Mémoire – RSS (Mo) 423 162 34 

 

! Pour faire ces tests, j’ai basculé sur une BD mariadb car  je trouvais que l’empreinte mémoire n’avait grand sens avec une BD en mémoire et aussi parce que H2 tombait en erreur avec la trop forte montée en charge. (! J’ai modifié  pour le test de charge le nom de l’entité Character en Characters car c’est un mot réservé par mariadb)

Le test de charge est fait sous JMeter  (fichier dispo. sur le repo dans le répertoire JMeter). Le scénario est celui-ci:

  • Récupération liste des genres de film
  • Récupération liste des films 
  • Création d’un film
  • Ajout d’un personnage au film crée à l’étape d’avant
  • Récupération Détail du film créé 2 étapes avant

Les résultats de mon premier test de charge (scénario 1 dans la suite de l’article) m’ont un peu étonné alors j’ai décidé d’en faire 3 avec des configurations différentes :

  • scénario 1: Test de charge tourne en boucle pendant 5 minutes avec un maximum de 200 utilisateurs en parallèle et un temps de montée en charge de 2’30;
  • scénario 2: Test de charge tourne en boucle pendant 5 minutes avec un maximum de 20 utilisateurs en parallèle et un temps de montée en charge de 2’30;
  • scénario 3: Test de charge de 100 itérations avec un unique utilisateur.

 

Jmeter - configuration
Configuration JMeter pour le scénario 1

 

Pour apprécier les temps de réponses, le througput, je me suis appuyé sur « l’Aggregate Graph » proposé par JMeter. Par contre pour la consommation mémoire et le temps cpu utilisé, je me suis fait un petit script qui exporte dans un fichier texte les datas de la commande ps (j’ai pas trouvé d’outils clé en main qui fasse ça aussi pour l’application native)

! Le % de cpu renvoyé par la commande ps  de mon mac est le % d’utilisation de la CPU par le process (Extrait de man ps: The CPU utilization of the process; this is a decaying average over up to a minute of previous (real) time. Because the time base over which this is computed varies (some processes may be very young), it is possible for the sum of all %cpu fields to exceed 100%.). Vous trouverez ci-dessous ce script:

#!/bin/bash

PID="$1"
LOG_FILE="$2"

while true ; do
    echo "$(date +%D' '%T);$(ps -p ${PID} -o %cpu,rss | tail -1 | awk '{print $1";"$2}')" >> $LOG_FILE
    sleep 2
done

Dans les paragraphes suivants, je vous présente les résultats que j’ai obtenu pour les 3 scénarios. Et voici une explication des colonnes de l’aggregate Graph qui pourront vous aider à mieux apprécier les résultats détaillés: 

  • Label: name of the request,
  • # Samples: total number of executions,
  • Average: Average Elapsed Time in milliseconds,
  • Median: The Median is the value separating the higher half of a data sample, a population, or a probability distribution, from the lower half. For a data set, it may be thought of as the “middle” value,
  • 90% Line: 90% Percentile, A percentile (or a centile) is a measure used in statistics indicating the value below which a given percentage of observations in a group of observations fall,
  • 95% Line: 95% Percentile,
  • 99% Line: 99% Percentile,
  • Min: Minimum Elapsed Time,
  • Max: Maximum Elapsed Time,
  • Errors %: Percentage of errors (errors / (errors + samples) * 100),
  • Throughput: Number of samples per second,
  • KB/sec: Network Throughput in KiloBytes/sec.

L’ensemble des résultats sont dans le répertoire « data » du projet github.

5.2 Scénario 1 

Test de charge tourne en boucle pendant 5 minutes avec un maximum de 200 utilisateurs en parallèle et un temps de montée en charge de 2’30

5.2.1 Résumé

  SpringBoot Quarkus – JVM Quarkus – Natif
Temps réponse moyen (msec)

2855

1513

1571

Temps réponse Max (msec)

21553

18444

14216

#Sample

15896

30464

29097

Troughput (#Sample/Sec)

52,4

95,7

93,7

Nb Films crées

3181

6102

5829

Nb Personnages crées

3141

6046

5779

%CPU moyen

24

34

32,8

%CPU Max

119

134

93,1

Max Mem (MB)

507,8

398,3

586,308

Moyenne Mem (MB)

464,4

446,6

386,774

5.2.2 SpringBoot

SpringBoot , graph cpu, scenario 1

SpringBoot ,tableau aggregate Jmeter, scenario 1

5.2.3 Quarkus – JVM

Quarkus JVM , graph cpu, scenario 1

Quarkus JVM ,tableau aggregate Jmeter, scenario 1

5.2.4 Quarkus – Natif

Quarkus Natif , graph cpu, scenario 1

Quarkus Natif ,tableau aggregate Jmeter, scenario 1

 

 

 

 

 

5.3 Scénario 2 

Test de charge tourne en boucle pendant 5 minutes avec un maximum de 20 utilisateurs en parallèle et un temps de montée en charge de 2’30;

5.3.1 Résumé

  SpringBoot Quarkus – JVM Quarkus – Natif
Temps réponse moyen (msec)

367

178

178

Temps réponse Max (msec) 4075 3776 3743
#Sample 12467 25593 25657
Troughput (#Sample/Sec) 41,4 85,3 85,5
NB Film crée 2496 5122 5128
Nb Personnage crée 2490 5117 5126
%CPU moyen 20,1 28,6 26,2
%CPU Max 90,9 106 76,2
Max Mem (MB)

461,3

351,1 308,9
Moyenne Mem (MB) 425,5 310,4 534

5.3.2 SpringBoot

SpringBoot , graph cpu, scenario 2

 

SpringBoot ,tableau aggregate Jmeter, scenario 2

5.3.3 Quarkus – JVM

Quarkus JVM , graph cpu, scenario 2

Quarkus JVM ,tableau aggregate Jmeter, scenario 2

5.3.4 Quarkus – Natif

Quarkus Natif , graph cpu, scenario 2

Quarkus Natif ,tableau aggregate Jmeter, scenario 2

 

 

5.4 Scénario 3 

Test de charge de 100 itérations avec un unique utilisateur.

5.4.1 Résumé

  SpringBoot Quarkus – JVM Quarkus – Natif
Temps réponse moyen (msec) 166 161 149
Temps réponse Max (msec) 3341 3149 900
#Sample 500 500 500
Troughput (#Sample/Sec) 6,0 6,2 6,7
Durée éxécution(Sec) 83 80 74
%CPU moyen 10,8 10,4 1,1
%CPU Max 68,8 104 1,9
Max Mem (MB) 450,4 226,4 76,8
Moyenne Mem (MB) 445,5 208,6 60,8

5.4.2 SpringBoot

SpringBoot , graph cpu, scenario 3

SpringBoot ,tableau aggregate Jmeter, scenario 3

5.4.3 Quarkus – JVM

Quarkus JVM , graph cpu, scenario 3

Quarkus JVM ,tableau aggregate Jmeter, scenario 3

5.4.4 Quarkus – Natif

Quarkus Natif , graph cpu, scenario 3

Quarkus Natif ,tableau aggregate Jmeter, scenario 3

 

Conclusion

Comme je le disais durant l’article, je n’ai pas obtenu les résultats que j’imaginais sur le premier scénario. On n’avait pas d’effet « ouahou » sur la partie mémoire de la version native, en revanche on a un troughput presque 2 fois supérieur entre les versions SpringBoot et Quarkus (même en version JVM).  Si je désire « encaisser » la charge gérée avec le socle Quarkus mais en SpringBoot, il me faudra 2 machines comme la mienne. Autrement dit avec une même machine, je peux faire tourner plus de conteneurs et rendre plus de services avec un socle Quarkus. C’est la notion de « densification » des infrastructures qui est mis en valeur ici.

Le second scénario (dans lequel j’ai réduit le nombre de users en parallèle) confirme l’intérêt de rester sur un socle Quarkus basé sur la JVM  quand on un applicatif qui a un peu de charge et qui a une durée de vie assez longue. Clairement, on bénéficie de la puissance de la JVM et des optimisations du garbage collector (beaucoup moins mature sur la version native). Le troughput est toujours environ 2 fois supérieur dans les.versions Quarkus.

Enfin le troisième scénario amène l’effet « ouahou » que j’attendais : une consommation mémoire et CPU qui sont vraiment impressionnantes, beaucoup plus basse qu’avec le socle SpringBoot (%CPU: 1,1 vs 10,8 et Memoire 76 vs 450). La partie througput est moins flagrante mais sur ce scénario avec 1 unique user  cela ne semble pas pertinent de le retenir. Il faut aussi noter la très bonne conso. mémoire pour la version Quarkus Java.  Avec ce scénario, on se rapproche   d’un contexte « serverless » que  Quarkus voulait justement adresser : on peut dire « Job done ! ».

Au final, les promesses de Quarkus se confirment avec les tests que j’ai réalisés. On a réellement un gain  de consommation mémoire, de CPU et de performance. La solution « Always Native » n’est clairement pas tout le temps la meilleure solution, il est nécessaire de « bencher ». Comme tout projet informatique, il ne faut pas être dogmatique et savoir s’adapter au contexte, ce qu’offre l’écosystème Quarkus.

Ce second article ne fait que conforter notre intérêt pour Quarkus, répondant complètement à nos préoccupations de sobriété numérique pour la réalisation de nos solutions digitales.    

Guettez nos RS, un prochaine article sur le sujet est dans les cartons !

 

    Partager cet article

    Partager sur Facebook Partager sur Linkedin Partager sur Twitter

    Consulter d'autres publications

    Nous contacter
    Postuler