Aller au contenu
BackJavaquarkusCLI

Le commis de course : créer une CLI interactive avec Quarkus

Découvrez comment créer des applications en ligne de commande avec Quarkus et Picoli.

Le commis par en course

La Taverne "The Falling Whale" n'a jamais manqué de bras. Il y a le serveur au comptoir, celui qui reste debout du matin jusqu'au soir à encaisser les commandes des clients (un certain endpoint REST, que les habitués connaissent bien depuis l'épisode précédent). Il y a le brasseur de nuit, qui descend à la cave dès que la dernière chandelle s'éteint et remonte à l'aube avec les tonneaux du lendemain (ce brave Jakarta Batch, increvable, mais lent à se mettre en route).

Deux profils, deux rythmes. Sauf qu'un beau soir, le tavernier a eu besoin de tout autre chose : quelqu'un pour courir jusqu'au marché relever un stock de houblon en urgence, et personne pour s'en charger. Le serveur ne quitte jamais son comptoir. Le brasseur ne se réveille que la nuit. Il manquait un troisième métier.

Il manquait un commis de course.

Mais un commis qu'on doit rappeler du marché à chaque nouvelle mission, ce n'est plus un commis, c'est un chat qu'on va chercher toutes les cinq minutes. Le tavernier voulait un vrai poste de garde : quelqu'un qui reste dans la cour, une lanterne à la main, prêt à recevoir un ordre après l'autre, toute la soirée, sans qu'on ait à le rappeler à chaque fois. Voyons comment on lui a taillé ce poste sur mesure.

Les dépendances nécessaires

Avant même de savoir courir, un commis a besoin d'un équipement. Chez nous, cet équipement se déclare dans le pom.xml, et il tient en deux pièces principales, au milieu des dépendances Quarkus habituelles.

<dependencies>
    <!-- Le coeur du commis : l'integration picocli, qui transforme
         Quarkus en executable de ligne de commande. -->
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-picocli</artifactId>
    </dependency>

    <!-- Gestion du terminal en mode "raw" : auto-completion. -->
    <dependency>
        <groupId>org.jline</groupId>
        <artifactId>jline</artifactId>
        <version>3.26.3</version>
    </dependency>

    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-arc</artifactId>
    </dependency>

</dependencies>

extrait du pom.xml

La première pièce, quarkus-picocli, c'est l'uniforme de la guilde. Elle transforme le démarrage de l'application Quarkus, habituellement pensé pour ouvrir un port HTTP, en point d'entrée en ligne de commande. Elle apporte avec elle la bibliothèque Picocli, qui sait transformer une classe Java en commande complète : options, arguments, aide générée automatiquement.

La seconde pièce, jline, c'est la lanterne. Sans elle, notre commis ne saurait qu'attendre bêtement qu'on lui souffle un ordre complet dans l'oreille, ligne par ligne, sans jamais deviner la suite ni se souvenir de ce qu'on lui a demandé plus tôt. Avec elle, il gère le terminal en mode dit "raw" : il peut compléter un mot avant qu'on ait fini de le taper, retrouver une ancienne commande d'un coup de flèche vers le haut, et gérer proprement les interruptions et les signaux du terminal (comme un Ctrl+C malencontreux), plutôt que de s'effondrer à la première touche imprévue.

Un simple Scanner sur System.in, la solution "à la main" qu'on est tenté d'utiliser par réflexe, ne sait faire aucune de ces trois choses. C'est un commis à moitié sourd, qui n'entend qu'une ligne à la fois et qui a la mémoire courte. Deux dépendances, donc, mais qui suffisent à transformer un simple coursier en véritable poste de garde.

Le reste de l'équipement du commis tient dans une poignée de lignes de configuration, dans application.properties :

# Le commis travaille seul et de nuit : pas besoin de vitrine.
quarkus.banner.enabled=false

# Pas de top-command ici : le point d'entree reel est Main / CommisShellApplication, qui garde le commis eveille dans une boucle au lieu de l'eteindre apres une seule commande.

# Niveau de log sobre : le rapport de mission se lit dans la console, pas dans un roman fleuve.
quarkus.log.level=INFO
quarkus.log.console.format=%d{HH:mm:ss} %-5p [commis] %s%n

Rien d'exotique : on coupe la bannière Quarkus, qui n'a aucun sens pour un commis qui n'affiche jamais de page d'accueil, et on garde des logs sobres, pensés pour un rapport de mission plutôt que pour un journal de bord verbeux.

Application web contre ligne de commande : deux métiers dans la même taverne

Le serveur au comptoir et le commis de course travaillent tous les deux pour la même taverne, mais leurs journées ne se ressemblent en rien.

Le serveur au comptoir (application web) Le commis de course (ligne de commande)
Public N'importe quel client qui pousse la porte Un seul interlocuteur à la fois, le tavernier lui-même
Canal Le comptoir, ouvert à tous, via des requêtes qui vont et viennent Le terminal, une conversation directe et privée
Rythme Reste debout en permanence, prêt à répondre à tout moment Démarre sur demande, puis reste en poste jusqu'à la fin de la session
Mémoire d'une requête à l'autre Chaque commande est traitée sans se souvenir forcément de la précédente Le commis garde le fil de la conversation tant qu'il reste en poste
Ce qui compte La disponibilité et la rapidité de réponse La clarté du dialogue et la justesse de l'exécution

Ce tableau explique pourquoi on ne construit pas un commis de course comme on construit un serveur au comptoir. Un endpoint REST n'a pas besoin de deviner ce que le client va demander ensuite : il répond, point final, et oublie.

Notre commis, lui, reste en conversation. Il a fallu lui donner une vraie boucle d'écoute plutôt qu'un simple aller-retour, et une lanterne (JLine3) plutôt qu'une simple oreille tendue une fois (un Scanner).

C'est là toute la différence entre héler quelqu'un à travers une fenêtre et avoir une vraie discussion avec lui, dans la même pièce, où il se souvient de ce qu'on vient de dire.

Explication des commandes

Rôle

Tout commence par la boucle qui garde le commis éveillé. C'est elle qui fait toute la différence avec un simple script qu'on lance et qu'on oublie :

@QuarkusMain
public class CommisShellApplication implements QuarkusApplication {

    private static final List<String> MOTS_DE_CONGE = List.of("quitter", "exit", "bonne-nuit");

    CommandLine.IFactory factory;

    @Override
    public int run(String... args) throws Exception {
        CommandLine commandLine = new CommandLine(new CommisDeCourseCommand(), factory);

        try (Terminal terminal = TerminalBuilder.builder().system(true).build()) {
            LineReader lineReader = LineReaderBuilder.builder()
                    .terminal(terminal)
                    .completer(new MissionCompleter(commandLine))
                    .build();

            afficherAccueil();

            while (true) {
                String ligne;
                try {
                    ligne = lineReader.readLine("commis> ").trim();
                } catch (UserInterruptException interruption) {
                    continue;
                } catch (EndOfFileException finDeFichier) {
                    break;
                }

                if (ligne.isEmpty()) continue;
                if (MOTS_DE_CONGE.contains(ligne.toLowerCase())) break;
                if (ligne.equalsIgnoreCase("help")) {
                    commandLine.usage(System.out);
                    continue;
                }

                commandLine.execute(decouper(ligne));
            }
        }

        System.out.println("Le commis repart se coucher. A bientot pour une prochaine course.");
        return 0;
    }
}

Le try (Terminal terminal = ...) ouvre la lanterne pour toute la durée du service, et la referme proprement quand le commis rentre se coucher. Le LineReader, construit une seule fois, est ensuite réutilisé à chaque tour de boucle pour lire une nouvelle ligne : c'est lui qui porte l'invite commis>, la complétion et l'historique. Et c'est ce while (true) qui distingue vraiment ce commis d'une simple commande one-shot : tant qu'on ne prononce pas un mot de congé (quitter, exit, bonne-nuit), il reste dans la cour, prêt pour la mission suivante.

Le tableau de service affiché dans la cour de la taverne liste ensuite les missions disponibles. C'est le rôle de la commande racine, celle que la boucle interroge à chaque ligne tapée :

@Command(
        name = "commis",
        mixinStandardHelpOptions = true,
        version = "commis-de-course 1.0.0",
        description = "Le commis de course de la taverne : reveille pour une mission, puis reparti se coucher.",
        subcommands = {
                ExporterDettesCommand.class,
                ReassortUrgentCommand.class,
                PurgerGrimoireCommand.class
        }
)
public class CommisDeCourseCommand implements Runnable {

    @Override
    public void run() {
        System.out.println("Le commis attend une mission. Utilisez --help pour voir la liste des courses disponibles.");
    }
}

Chaque mission listée dans subcommands est une course à part entière, avec son propre nom, sa propre description et ses propres arguments. Le tavernier tape le nom de la mission qu'il souhaite confier, comme il désignerait une course précise sur le tableau de service, et le commis sait exactement où aller chercher les instructions correspondantes.

La sortie de la commande --help, générée automatiquement par Picocli à partir du tableau de service, listant les trois missions disponibles avec leur description

Les missions et leur service

Une mission ne devrait jamais faire deux métiers à la fois : porter un message et tenir les comptes. C'est pour ça que le registre des dettes vit dans son propre service, séparé de la mission qui l'exploite.

Le domaine, d'abord, un simple record :

public record DetteAventurier(String nomAventurier, BigDecimal montant, int joursDeRetard) {

    public boolean estCritique() {
        return joursDeRetard > 30;
    }
}

Le grand livre de comptes, ensuite, tenu par le tavernier et confié au commis à chaque course :

@ApplicationScoped
public class RegistreDettesService {

    public List<DetteAventurier> listerDettes() {
        return List.of(
                new DetteAventurier("Aragorn", new BigDecimal("42.50"), 12),
                new DetteAventurier("Boromir", new BigDecimal("128.00"), 45),
                new DetteAventurier("Gimli", new BigDecimal("7.30"), 90)
        );
    }
}

Et la mission elle-même, qui reste volontairement mince : elle se contente de demander le registre, de le filtrer selon ce qu'on lui a demandé et d'afficher un rapport.

@Command(
        name = "exporter-dettes",
        description = "Dresse la liste des dettes des aventuriers de la taverne."
)
public class ExporterDettesCommand implements Callable<Integer> {

    RegistreDettesService registreDettesService;

    @Option(
            names = {"-c", "--critiques-seulement"},
            description = "N'affiche que les dettes critiques (retard superieur a 30 jours)."
    )
    boolean critiquesSeulement;

    @Override
    public Integer call() {
        List<DetteAventurier> dettes = registreDettesService.listerDettes().stream()
                .filter(dette -> !critiquesSeulement || dette.estCritique())
                .toList();

        if (dettes.isEmpty()) {
            System.out.println("Aucune dette a rapporter. La taverne est en paix.");
            return 0;
        }

        dettes.forEach(dette -> System.out.printf(
                "%-12s %8.2f po (%d jours de retard)%s%n",
                dette.nomAventurier(),
                dette.montant(),
                dette.joursDeRetard(),
                dette.estCritique() ? "  [CRITIQUE]" : ""
        ));

        System.out.printf("%nMission accomplie : %d dette(s) rapportee(s).%n", dettes.size());
        return 0;
    }
}

Les missions non encore développées suivent le même principe, avec des arguments positionnels plutôt que des options facultatives, pour montrer les deux façons de recevoir des instructions :

@Command(
        name = "reassort-urgent",
        description = "Passe une commande de reassort d'urgence au marche."
)
public class ReassortUrgentCommand implements Callable<Integer> {

    @Parameters(index = "0", description = "Nom de la denree a reapprovisionner.")
    String denree;

    @Parameters(index = "1", description = "Quantite a commander.")
    int quantite;

    @Override
    public Integer call() {
        System.out.printf("Commande d'urgence enregistree : %d unite(s) de %s.%n", quantite, denree);
        return 0;
    }
}
Le résultat de exporter-dettes --critiques-seulement dans le terminal, avec les dettes critiques mises en évidence, pour montrer un vrai rapport de mission plutôt qu'une simple liste de code.

La complétion

Le meilleur commis de course n'est pas celui qui attend un ordre complet et bien articulé. C'est celui qui, en entendant "exporter-de...", propose déjà "...ttes" avant qu'on ait fini de le taper.

public class MissionCompleter implements Completer {

    private static final List<String> MOTS_CLES = List.of("help", "quitter", "exit", "bonne-nuit");

    private final CommandLine commandeRacine;

    public MissionCompleter(CommandLine commandeRacine) {
        this.commandeRacine = commandeRacine;
    }

    @Override
    public void complete(LineReader reader, ParsedLine line, List<Candidate> candidates) {
        String motCourant = line.word();

        if (line.wordIndex() == 0) {
            completerPremierMot(motCourant, candidates);
            return;
        }

        completerOptionsDeLaMission(line.words().get(0), motCourant, candidates);
    }

    private void completerPremierMot(String prefixe, List<Candidate> candidates) {
        for (String nomMission : commandeRacine.getSubcommands().keySet()) {
            if (nomMission.startsWith(prefixe)) {
                candidates.add(new Candidate(nomMission));
            }
        }
        for (String motCle : MOTS_CLES) {
            if (motCle.startsWith(prefixe)) {
                candidates.add(new Candidate(motCle));
            }
        }
    }

    private void completerOptionsDeLaMission(String nomMission, String prefixe, List<Candidate> candidates) {
        CommandLine mission = commandeRacine.getSubcommands().get(nomMission);
        if (mission == null) {
            return;
        }

        for (OptionSpec option : mission.getCommandSpec().options()) {
            for (String nomOption : option.names()) {
                if (nomOption.startsWith(prefixe)) {
                    candidates.add(new Candidate(nomOption));
                }
            }
        }
    }
}

Le détail qui compte : cette complétion ne maintient aucune liste écrite à la main quelque part dans un coin du code. Elle lit directement commandeRacine.getSubcommands(), c'est-à-dire le tableau de service tenu par CommisDeCourseCommand lui-même. Le jour où une quatrième mission rejoint la guilde, le commis apprend à la reconnaître sans qu'on ait à le lui enseigner une seconde fois : il suffit de l'ajouter dans subcommands, et la complétion suit toute seule.

Mais cette complétion n'est pas automatique pour autant : les annotations Picocli décrivent le tableau de service, mais il faut expressément brancher ce modèle sur la lanterne JLine3 pour qu'elle sache s'en servir. C'est tout le rôle de MissionCompleter, écrit pour l'occasion : sans lui, le tableau de service resterait affiché dans la cour sans que le commis pense jamais à le consulter avant de répondre.

Annotations

L'annotation @Command est celle que Picocli utilise pour reconnaître une classe Java comme une commande complète : un nom, une description, et éventuellement une liste de sous-commandes. C'est elle qui fait le lien entre ce qu'on tape dans le terminal et le code qui s'exécute. mixinStandardHelpOptions = true ajoute automatiquement les options --help et --version, sans qu'on ait à les écrire soi-même : le tavernier n'a pas à réapprendre chaque fois comment demander de l'aide au commis.

Chaque mission peut ensuite déclarer ses propres arguments, mais l'essentiel à retenir pour la suite de cet article, c'est que ces arguments sont décrits directement dans la classe de la mission : pas de fichier de configuration séparé, pas de documentation à maintenir à part, l'aide en ligne de commande (--help) reste toujours synchronisée avec le code réel.

Point d'attention sur le mode de lancement

Un point de vigilance, et pas des moindres : ce commis attend un vrai terminal, pas la console de rechargement à chaud de Quarkus.

En mode quarkus:dev, la console interne de Quarkus capte elle-même certaines touches du clavier pour son propre usage (relancer les tests, forcer un rechargement, etc.). Elle se retrouve alors en concurrence directe avec la lanterne de notre commis, qui s'attend elle aussi à recevoir chaque touche frappée.
Résultat : la saisie peut se bloquer, se comporter de façon erratique, ou tout simplement ne jamais afficher l'invite commis> correctement.

Autrement dit : pour une vraie session avec le commis, mieux vaut ne pas le réveiller par la porte du chantier (quarkus:dev), mais par celle du service, une fois le travail terminé et emballé :

./mvnw package
java -jar target/quarkus-app/quarkus-run.jar

C'est la seule façon de garantir que le terminal appartient entièrement au commis, sans personne d'autre pour lui souffler dans l'oreille en même temps.

Conclusion

Ce qui est intéressant, avec cet exercice, ce n'est pas tant le code, une poignée de classes en tout, rien de sorcier, que la question qu'il pose : à partir de quand un simple script cesse d'être un script pour devenir une application à part entière ? Le jour où il a besoin de retenir ce qu'on lui a déjà demandé. Le jour où il a besoin de deviner ce qu'on va lui demander ensuite. Le jour où on prend soin de la porte par laquelle on le réveille.

Le commis de course, autrefois relégué au rang de simple utilitaire, mérite finalement la même attention que le serveur au comptoir ou le brasseur de nuit. Il ne tient simplement pas le même poste : un seul interlocuteur, une conversation qui dure, et une porte qu'il faut savoir pousser au bon endroit.


Si l'envie de tester vous démange, tout le code peut être consulté ici :

GitHub - ErwanLT/quarkus-demo: Demo project for quarkus possibility
Demo project for quarkus possibility. Contribute to ErwanLT/quarkus-demo development by creating an account on GitHub.

Dernier