Contactez-nous
-
Cloud Native

Développez vos CLI en Java avec Quarkus et Picocli dans le Cloud

Développez vos CLI en Java avec Quarkus et Picocli dans le Cloud

Sommaire

De plus en plus d’applications fournissent, en plus de leurs API (REST, gPRC, etc.), des utilitaires (Command Line Interface). Ceux-ci permettent un accès rapide via un terminal aux services proposés, sans avoir à se soucier techniquement de la communication. 

Dans un contexte Cloud Native, les CLI peuvent servir à fournir une interface supplémentaire pour l’utilisation de vos services ou encore des utilitaires permettant de faciliter l’administration, le monitoring ou le débogage.

Une CLI c’est quoi ?

Une ligne de commande se compose de plusieurs éléments :

  1. Le nom de l’exécutable : permet au système de localiser la commande ;
  2. Des sous-commandes optionnelles : permet de structurer les fonctionnalités ;
  3. Des options : pour configurer le comportement de la commande ;
  4. Des paramètres : les arguments de la commande.
docker container run --name containerName --rm imageName echo Hello WeScale

La CLI fournie par Docker permet par exemple un accès simplifié à l’API exposée par le serveur local : `docker container run <....>` enchaînera la création (point d’entrée  `/containers/create`) et le démarrage (point d’entrée `/container/{id}/start`) d’un conteneur.

La mise à disposition de ces utilitaires permet :

  • de simplifier l’accès aux services ;
  • de proposer des enchaînements de méthodes ;
  • de rendre accessible l’utilisation du service à tous les systèmes d'exploitations ;
  • de faciliter l’automatisation de tâches. 

Quel(s) langage(s) ?

Une CLI peut être codée dans n’importe quel langage de programmation, le choix est motivé par :

  • La performance de celui-ci avec comme critère principal le temps de démarrage. Il est compliqué de fournir une CLI mettant quelques secondes à démarrer pour exécuter un traitement de quelques millisecondes (ce qui place souvent Java en mauvaise posture 😇).
  • La facilité de développement : est-ce que le langage de programmation fournit une bibliothèque de fonctions pour gérer les options, les commandes, les paramètres ?
  • Les compétences des équipes de développement et de maintenance.

Et pour la distribution ?

Pour distribuer ces programmes, plusieurs méthodes sont possibles et dépendent du langage de programmation utilisé :

  • Les langages de programmation compilés (C, Go, etc.) offriront un binaire dépendant du système d’exploitation ;
  • Les langages de programmation interprétés in fine (Python, Java, etc.) offriront un livrable unique indépendant du système d’exploitation, mais nécessitent un environnement d’exécution dédié installé préalablement (interpréteur, jvm, …).

Une CLI en Java ?

L’idée semble folle et un mur se dessine à l’horizon en pensant à l’écosystème Java et sa complexité :

  • Une JVM est nécessaire pour exécuter l’application ;
  • Les fonctionnalités avancées requièrent des serveurs d’applications ;
  • De nombreuses bibliothèques existent mais impliquent la mise à disposition de plusieurs JARs complémentaires à celui de l’application ;
  • Les temps de démarrages peuvent être excessifs par rapport à la fonctionnalité fournie ;
  • L’impact mémoire peut rapidement grimper en fonction des dépendances du programme.

Depuis quelques années des solutions de scripting Java émergent,  qu’elles soient intégrées à Java (JShell), amenées par la communauté (JBang) ou rendues possibles grâce à de nouveaux JDK (GraalVM).

Quel environnement d’exécution choisir ?

Environnement d'exécution Avantages Inconvénients
JShell
  • Intégré à Java depuis la version 9
  • Distribution simple et portable (fichier java ou jsh)
  • Nécessite l’installation de Java
  • Pas de gestion de dépendances incluse
  • Démarre une JVM à l’exécution (consommation de ressources)
JBang
  • Fonctionne depuis Java 8
  • Téléchargement automatique de Java à l’exécution
  • Supporte la compilation native
  • Système de gestion de dépendances (sans Maven ou Gradle)
  • Nécessite l’installation de JBang
  • Démarre une JVM à l’exécution (consommation de ressources)
GraalVm
  • Support de Java (11+)
  • JVM performante et optimisée pour l’exécution
  • Supporte la compilation native
  • Démarrage rapide (compilation native)
  • Nécessite l’installation de Java (sauf compilation native)
  • Pas de gestion de dépendances incluse
  • Plusieurs binaires à fournir en fonction des architectures ciblées (compilation native)
  • Temps de compilation native

Pour résumer, il n’y a pas de bonnes ou de mauvaises solutions, on notera une grande facilité de distribution via l’utilisation de JBang et des performances optimisées via l’utilisation de GraalVM Native Image au détriment du temps de compilation.

Mais alors pourquoi utiliser Java pour coder une CLI ?

En effet la question est légitime, pourquoi utiliser Java pour des scripts ayant une durée de vie courte alors qu’il excelle dans la gestion d’applications ayant une durée de vie longue ?

Tout simplement parce que maintenant c’est possible, viable et performant ! Avec l’arrivée de la compilation native, un programme Java peut être extrêmement performant dès son démarrage et permet de bénéficier de son écosystème riche : 

  • une multitude de librairies et frameworks de développements ;
  • une grande communauté ;
  • de multiples exemples ou tutoriels disponibles ;
  • des bonnes pratiques de développement et de test connues et partagées.

Bien entendu, des développeurs back ayant principalement des compétences en Java seront immédiatement efficaces pour développer des CLI.

La compilation native : qu’est ce que c’est ?

Sans entrer dans les détails, la compilation native Java est amenée par le projet native-image porté par GraalVM. Il permet de générer un binaire contenant les classes Java de l’application et des dépendances ainsi que le code natif (du moins une partie) du runtime Java.

Le binaire généré ne tourne pas dans une JVM préinstallée mais est composé de parties de la JVM ce qui le rend extrêmement portable et rapide mais sensible au système d’exploitation cible.

Évidemment, la compilation native ne présente pas que des avantages :

  • Le temps de compilation est fortement augmenté.
  • Les fonctionnalités dynamiques de Java (Reflection API, Class Loading, Sérialisation, etc.) doivent être configurées pour que le compilateur puisse produire le binaire.
  • Le binaire produit est dépendant de l’architecture de la plateforme de compilation (Windows, Linux x64, Linux ARM, etc.).

Quel framework de développement choisir ?

Quel que soit l’environnement d’exécution, Java offre une multitude de frameworks facilitant le développement d’applications. Tous ne sont pas compatibles avec la compilation native de GraalVM mais une rapide recherche permet d’obtenir une liste plutôt prometteuse (non exhaustive) :

  • Spring et Spring Boot (via Spring Native)
  • Micronaut
  • Helidon
  • Quarkus

Peu importe ses préférences, il est possible de trouver son bonheur dans les frameworks proposés. La suite de cet article se base sur l’utilisation de Quarkus pour sa simplicité d’utilisation et son environnement de développement.

Une CLI avec Quarkus ?

Quarkus est un framework de développement d'applications Cloud Native Java basé sur Jakarta EE et Microprofile. Reposant sur un système d’extensions, l’application générée ne contient que les spécifications utiles pour votre application. Par exemple, la spécification JAX-RS ne sera pas packagée si votre application fournit un service gRPC.

Il offre un cadre de développement concentré sur la satisfaction des développeurs avec des fonctionnalités avancées comme le Live Coding, les Test Containers en encore la DevUI permettant de visualiser les composants de l’application.

Mode ligne de commande

Un mode ‘Command Line Application’ permet de concevoir une application de type script définissant une méthode en point d’entrée. Bien entendu, toutes les fonctionnalités avancées de Quarkus sont accessibles dès lors que l’extension est configurée.

@QuarkusMain
public class HelloWorldMain implements QuarkusApplication {
   @Override
   public int run(String... args) {
       if (args.length == 0) {
           return 1;
       }

       System.out.println("Hello " + args[0]);
       return 0;
   }
}

Voilà, vous avez écrit votre première application en ligne de commande ! Dans les coulisses, Quarkus détecte l’annotation @QuarkusMain et cherche :

  • la méthode run si la classe implémente l’interface @QuarkusApplication ;
  • la méthode statique main java sinon.

Test d’applications simples

Quarkus utilise un système de test basé sur Junit et sur l’annotation @QuarkusTest. Il permet d’injecter dans la classe de test toutes sortes d’objets CDI supportés par Quarkus (Context and Dependency Injection) comme des beans, des alternatives ou des mocks.

Dans le cas du mode ligne de commande, Quarkus met à disposition deux nouvelles annotations : @QuarkusMainTest et @QuarkusMainIntegrationTest. La première est utilisée sur la phase de test unitaire et dans une JVM alors que la deuxième est utilisée sur la phase de test d'intégration et contre le package produit (binaire ou JAR). Contrairement aux annotations test classiques de Quarkus, @QuarkusMainTest et @QuarkusMainIntegrationTest ne permettent pas l’injection CDI.

@QuarkusMainTest
class HelloWorldMainTest {

   @Test
   @Launch("World")
   public void testLaunchCommand(LaunchResult result) {
       Assertions.assertEquals("Hello World", result.getOutput());
   }

   @Test
   @Launch(value = {}, exitCode = 1)
   public void testLaunchCommandFailed() {}

   @Test
   public void testManualLaunch(QuarkusMainLauncher launcher) {
       LaunchResult result = launcher.launch("Everyone");
       Assertions.assertEquals(0, result.exitCode());
       Assertions.assertEquals("Hello Everyone", result.getOutput());
   }
}

La classe de test ci-dessus est détectée via l’annotation @QuarkusMainTest et exécute trois tests :

  • testLaunchCommand : méthode la plus simple pour lancer un test de ligne de commande. L’annotation @Launch permet de spécifier les paramètres de la ligne commandes. L’argument result contiendra le résultat de la commande si celle-ci se termine en succès.
  • testLaunchCommandFailed : Ce test vérifie une erreur lors de l’exécution. Dans le cas présent, aucun paramètre n’est fourni. Le programme se termine avec le code de retour 1. Valeur qui est vérifiée automatiquement par Quarkus via le paramètre exitStatus de l’annotation @Launch
  • testManualLaunch : Méthode de test programmatique. Le développeur doit spécifier tous les paramètres manuellement et vérifier les sorties via l’utilisation du paramètre QuarkusMainLauncher.

Pour les tests d’intégration, la mécanique est la même. L’exemple de base définit une classe annotée @QuarkusMainIntegrationTest et héritant de la classe de test précédente : 

@QuarkusMainIntegrationTest
class HelloWorldMainIT extends HelloWorldMainTest { }

Ce mécanisme d’héritage est un confort d’utilisation permettant l’exécution des tests joués lors de la phase de test unitaire (en mode JVM) sur le livrable final produit par Quarkus (JAR exécutable ou binaire). 

C’est une facilité proposée par la documentation officielle de Quarkus qui n'est en aucun cas obligatoire. Libre à vous d’implémenter des tests d’intégration spécifiques en fonction de vos usages en utilisant les annotations @Launch et @Test.

Une extension pour les CLI

Maintenant que vous êtes un(e) expert(e) du mode ligne de commande Quarkus, vous êtes heureux(se), mais vous ne voyez pas comment son utilisation sera plus pratique pour développer une CLI par rapport à Java SE. C’est là qu’intervient l’intégration de Picocli dans l’écosystème de Quarkus.

Picocli est une librairie Java gérant pour vous l’analyse des arguments de votre ligne de commande. Son API est riche et permet rapidement de gérer :

  • des sous commandes ;
  • des options (avec des valeurs par défaut, répétables) ;
  • des messages d’aide (--help) ;
  • des scripts de complétion pour les terminaux Bash ou Zsh.
@Command(name = "greeting", mixinStandardHelpOptions = true)
public class GreetingCommand implements Runnable {


   @Option(names = {"-s", "--secret"},
           description = "Secret greeting message",
           interactive = true)
   String secret;

   @Parameters(defaultValue = "picocli", description = "Your name.")
   String name;

   @Override
   public void run() {
       System.out.println("Hello " + name + "!");
       ofNullable(secret).ifPresent(s -> System.out.println(
               "You have a secret greeting message: " + s
       ));
   }
}

L’activation de l’extension amène quelques changements sur notre code :

  • plus besoin de l’annotation @QuarkusMain, l’extension fournit une classe par défaut ;
  • une commande doit être annotée avec @Command pour que Picocli la détecte ;
  • la génération de l’aide est automatique si l’option mixinStandardHelpOptions est activée ;
  • les paramètres sont annotés avec @Parameters ;
  • les options sont annotées avec @Options.

Une multitude de fonctionnalités sont disponibles pour la gestion des paramètres et options. Ici, l’option secret est interactive : Picocli se chargera de proposer à l’utilisateur de saisir sa valeur (sans l’afficher sur le terminal) avant de lancer l’exécution de la commande. Ci-dessous, vous trouverez la compilation et l’exécution de ce code en version JVM puis en version native.

Compilation et exécution d’un JAR :

Compilation native et exécution :

Des performances au rendez-vous

Sans réaliser de véritables benchmarks nous pouvons noter deux points importants :

  • Les temps de compilation maven sont grandement augmentés dès lors que la compilation native est demandée, passant de 3 secondes à 42 secondes pour une application se contentant d’écrire sur la console ;
  • Les temps d'exécution sont eux considérablement réduits, passant de 460 ms pour la version Java à 10 ms pour la version native.

Une CLI dans le Cloud ?

L’utilisation d’une CLI dans un environnement cloud peut être motivée par de multiples raisons. Ici, deux illustrations vous sont proposées en partant d’une application “Greeting” tournant dans un cluster Kubernetes.

 

  • L’application GreetingService expose une API REST publique et une privée :
    • /greet/’ permettant une salutation distinguée 
    • ‘/admin/greeter/’ permettant de changer le message de salutation.
  • L’application Greetings Admin CLI permet d’accéder à l’API publique de GreetingService mais aussi de l’administrer en changeant la salutation via un accès à l’API privée.

Souvent, les images déployées sont minimales et ne disposent que des paquets systèmes nécessaires (donc pas d’accès à curl ou bash) et l’équipe de développement n’a pas eu le temps de développer une interface d’administration web (toute ressemblance à une situation réelle est fortuite).

Exécution de tâches

Une solution relativement simple pour effectuer nos tâches d’administration consiste à utiliser la CLI d’administration dans un Job Kubernetes. Ils permettent d’exécuter des tâches en soumettant un manifeste Kubernetes et possèdent plusieurs avantages :

  • L’action d’administration est versionnable et intégrable dans votre CI/CD
  • Un multitude d’options s’offrent à vous pour l’exécution de la tâche (réessai, ménage, etc.)
  • Une revue de l’action peut être faite avant sa mise en production.

Avec une image Docker contenant notre CLI, un job Kubernetes se définit comme le code suivant :

apiVersion: batch/v1
kind: Job
metadata:
 name: blog-quarkus-cli-greetings-cli
spec:
 template:
   spec:
     containers:
       - name: blog-quarkus-cli-greetings-cli
         image:
ktoublanc/blog-quarkus-cli-greetings-cli:1.0.0-SNAPSHOT-native
         command: [ "./application", "admin", "set", "Hey" ]
     restartPolicy: Never

Son exécution crée un Pod (objet portant les conteneurs dans Kubernetes) qui utilise l’image Docker spécifiée et lance la commande d’administration, changeant ainsi le message de salutation par “Hey”.

Débogage avec les conteneurs ephémères

Prenons maintenant un second exemple, notre conteneur tourne et nous voulons faire une administration mais sans créer un job Kubernetes. Depuis la version 1.25 de l’API Kubernetes, les conteneurs éphémères sont stables. Ils sont conçus pour permettre le débogage de Pod lorsque “kubectl exec” est insuffisant et permettent d’obtenir toutes les informations du processus lancé dans le conteneur “nominal”.

Dans notre cas, l’utilisation d’un conteneur éphémère permet :

  • de se connecter au pod GreetingsService ;
  • d’accéder à l’API REST en utilisant l’URL locale du conteneur (localhost) ;
  • de spécifier une image de base avec les outils nécessaires pour le débogage ou l’administration.

Kubernetes expose une API dédiée pour la gestion des conteneurs éphémères, elle est accessible via la command “kubectl debug” :

kubectl debug -it \
        --image <BASE IMAGE AND TAG> \
        --target <CONTAINER NAME FROM THE POD DESCRIPTION> \
        <YOUR POD IDENTIFIER>

Plutôt que d’exposer les commandes nécessaires, l'enregistrement du terminal suivant vous guidera dans les étapes.

Pour aller plus loin

Vous trouverez toutes les ressources (code source, exemple de CI, manifestes Kubernetes et images Docker) de cet article disponible sur GitLab : 

https://gitlab.com/ktoublanc-ws/quarkus-cli-blog

Conclusion

Grâce à l'ensemble des évolutions de la JVM, il est maintenant possible de réaliser des CLI performantes en Java en bénéficiant de son écosystème riche, permettant ainsi de gagner en rapidité et qualité sans devoir apprendre un nouveau langage sur le tas !

Si une compilation native est choisie, les temps de compilation seront drastiquement augmentés mais les temps d’exécution seront considérablement diminués et la distribution dans un conteneur Docker sera simplifiée.

Références

  1. Guide de référence Quarkus pour le développement des application en ligne de commande
    https://quarkus.io/guides/command-mode-reference

  2. Guide de référence de l’extension Quarkus Picocli
    https://quarkus.io/guides/picocli

  3. Guide de référence de la compilation native Quarkus
    https://quarkus.io/guides/building-native-image

  4. Limitation de la compilation native Java
    https://www.graalvm.org/22.1/reference-manual/native-image/Limitations/

  5. Kube Jobs
    https://kubernetes.io/docs/concepts/workloads/controllers/job/

  6. Kube Ephemeral Containers
    https://kubernetes.io/docs/concepts/workloads/pods/ephemeral-containers/