This article is also available in English

Mit Kommandozeilenanwendungen im Allgemeinen haben wir uns in dieser Kolumne bereits vor vier Jahren beschäftigt. Die dort beschriebenen Grundlagen gelten auch heute noch. Und auch an der Verbreitung von mit Java geschriebenen Kommandozeilenanwendungen hat sich seitdem nicht viel geändert.

Trotzdem gibt es Gründe, diese Anwendungen auch mit Java zu implementieren. Auf diese Art und Weise kann ein Team das bereits erarbeitete Wissen weiterverwenden und auf bereits bekannte Bibliotheken zurückgreifen. Zudem kann der Nachteil einer längeren Startzeit der JVM heutzutage beispielsweise durch den Einsatz des nativen Kompilierens mit der GraalVM vermieden werden.

Doch egal welchen Grund es für die Entscheidung gibt, eine Kommandozeilenanwendung mit Java zu schreiben, diese unterscheiden sich im Wesentlichen durch das Auslesen und Verarbeiten der übergebenen Argumente und Optionen von anderen Anwendungstypen. Deshalb wollen wir uns in diesem Artikel genau damit beschäftigen. Nach einer kurzen generellen Einführung in Argumente und Optionen werden wir uns dazu vier Bibliotheken anschauen, die wir einsetzen können, um uns die Arbeit deutlich zu erleichtern.

Konfiguration von Kommandozeilenanwendungen

Um den Ablauf einer Kommandozeilenanwendung zu konfigurieren, gibt es grundsätzlich drei Möglichkeiten.

Die erste Möglichkeit besteht darin, dass die Anwendung Konfigurationswerte ausliest. Hierzu werden in der Regel Dateien, die sich an bestimmten Stellen im Dateisystem befinden müssen, oder definierte Systemumgebungsvariablen ausgelesen. Diese Art der Konfiguration ist statisch und bietet sich deswegen eher für globale und längerfristige Einstellungen an. Beispiele hierfür sind die Datei ~/.gitconfig für Git oder ~/.m2/settings.xml für Maven.

Daneben ist es möglich, dass die Anwendung selbst über die Standardeingabe Werte abfragt. Diese Art der Abfrage hat vor allem den Vorteil, dass die Eingabe nicht in der Historie der Kommandozeile gespeichert wird. Vor allem für die Eingabe von Passwörtern oder anderen Geheimnissen ist dieser Weg also sehr geeignet. Der Nachteil daran ist jedoch, dass sich dieser Ansatz weniger zum Schreiben von Skripten eignet.

Als dritte Möglichkeit bleibt noch die Übergabe von Argumenten und Optionen beim Aufruf der Anwendung. Diese werden hinter den Aufruf der Anwendung geschrieben. So besteht der Aufruf curl --request get -v https://innoq.com aus den zwei Optionen --request mit dem Wert get und -v ohne Wert, sowie dem Argument https:// innoq.com.

Argumente und Optionen

Argumente werden vor allem dann genutzt, wenn deren Angabe für die Hauptaufgabe der Anwendung zwingend notwendig ist. So erwartet beispielsweise der Befehl git add die Angabe von Dateien oder Verzeichnissen, die hinzugefügt werden sollen. Argumente müssen hierzu jedoch nicht immer zwingend auch angegeben werden, sondern können durch sinnvolle Defaults vorbelegt werden. So nutzt das Tool ls um Dateien anzuzeigen den aktuellen Ordner, wenn kein Argument übergeben wird.

Benötigt eine Anwendung mehrere Argumente, wie beispielsweise mv um Dateien zu verschieben, sind diese stets in der von der Anwendung definierten Reihenfolge anzugeben. Je nach Anwendung ist es sogar zwingend notwendig, dass alle Argumente erst nach den Optionen folgen dürfen.

Optionen hingegen dienen der optionalen Konfiguration und sind deswegen selten zwingend notwendig. Eine Option besteht dabei immer aus einem Namen und einem Wert. Für den Namen hat sich die Nutzung einer Kurz- und Langform eingebürgert. In der Kurzform wird die Option dabei mit nur einem Minuszeichen - und nur einem einzelnen Buchstaben angegeben. Die Langform beginnt hingegen mit -- und besteht aus mehreren Buchstaben, meistens einem ganzen Wort. Der Wert einer Option ist entweder das auf den Optionsnamen folgende Argument oder wird durch ein Trennzeichen, meistens das Gleichheitszeichen =, direkt an den Optionsnamen geschrieben. Für Wahrheitswerte kann häufig auf die Angabe des Werts verzichtet werden. Der Wert wird dann bei Angabe vom Optionsnamen auf true gesetzt.

Gerade für Optionen sollten wir Defaults hinterlegen, auf die zurückgegriffen wird, wenn die Option nicht angegeben wird.

In Java können wir über das als Argument in der main-Methode deklarierte Stringarray auf die der Anwendung übergebenen Argumente zugreifen. Allerdings kennt Java nicht das Konzept von Optionen. Im Fall des oben beschriebenen curl-Aufrufs würde uns ein Array mit den vier Strings --request, get, -v und https://innoq.com übergeben werden. Es ist also Aufgabe der Anwendung, diese nun passend zu interpretieren. Da dies schnell aufwendig werden kann, bietet sich hier der Einsatz einer passenden Bibliothek an.

Commons CLI

Eine der ältesten mir bekannten Bibliotheken zum Umgang mit Argumenten und Optionen ist Apache Commons CLI. Die Implementierung nutzt hier ein rein programmatisches Programmiermodell, das logisch in die drei Phasen:

  • Definieren der Optionen,
  • Verarbeiten der Kommandozeile sowie
  • Auswertung der gesetzten Optionen

unterteilt werden kann. Listing 1 zeigt hierzu ein sehr simples Beispiel, wie der dazu notwendige Code aussieht.

class ApacheExample {

    public static void main(String[] args) {
        args = new String[]{ "foo", "--verbose", "bar", "-d=|", "baz" };

        var options = new Options()
            .addOption("v", "verbose", false, "Verbose")
            .addOption(Option.builder("d")
                .longOpt("delimiter")
                .hasArg(true)
                .desc("The delimiter to use")
                .argName("delimiter")
                .build());

        var parser = new DefaultParser();

        try {
            var cmdLine = parser.parse(options, args);
            if (cmdLine.hasOption('v')) {
                System.err.println("Running in verbose mode");
            }

            var delimiter = cmdLine.getOptionValue('d', ",");
            var result = String.join(delimiter, cmdLine.getArgs());
            System.out.println(result);
        } catch (ParseException e) {
            e.printStackTrace();
            new HelpFormatter().printHelp("apache args...", options);
        }
    }
}
Listing 1: Implementierung mit Commons CLI

Für das Definieren der Optionen nutzen wir die Klasse Options mit den dort vorhandenen und mehrfach überladenen addOption-Methoden. Neben der Hauptvariante, die eine Instanz der Klasse Option entgegennimmt, bieten uns die überladenen Methoden kürzere Varianten zur Erzeugung einer solchen Option an. Im Beispiel wird dies für die verbose-Option genutzt.

Eine Option besteht aus einem Kurz- oder einem Langnamen. Zudem können wir spezifizieren, ob für diese Option ein Wert angegeben werden muss oder ob es ausreicht herauszufinden, dass die Option angegeben wurde. Zusätzlich lässt sich mit argName und description auch die Generierung des Hilfetexts noch beeinflussen. Um so eine Option dann zu erzeugen, nutzen wir den zur Verfügung gestellten Builder.

Nach der Definition der Optionen nutzen wir den DefaultParser, um die auf der Kommandozeile angegeben Optionen mit den von uns definierten zu verarbeiten. Die parse-Methode wirft dabei im Fehlerfall, beispielsweise wenn eine nicht definierte Option angegeben wurde, eine ParseException.

Wenn das Verarbeiten erfolgreich war, können wir anschließend auf der von parse zurückgegebenen CommandLine die angegebenen Optionen auswerten. Hierzu nutzen wir die Methoden hasOption und getOptionValue. An die übergebenen Argumente gelangen wir über getArgs. Commons CLI verzichtet dabei auf eine Konvertierung der Optionswerte in spezifische Datentypen und gibt nur einen String zurück. Wir können zwar bei der Definition unserer Optionen einen type angeben und anschließend mit getOptionObject den in diesen Typen konvertierten Wert abfragen. Hier werden allerdings nur eine Handvoll Typen aus dem JDK, wie URL, File und Number, unterstützt. Es gibt keine Möglichkeit, diesen Mechanismus für eigene Typen zu erweitern.

Zuletzt bietet uns Commons CLI noch die Möglichkeit an, mit der Klasse HelpFormatter einen Hilfetext auszugeben, in dem alle möglichen Optionen ausgegeben werden. Typischerweise geschieht dies im Fehlerfall oder wenn eine bestimmte help-Option gesetzt wurde.

Commons CLI ist somit sehr hilfreich und nimmt uns bereits eine Menge Arbeit ab. Jedoch fehlt an der einen oder anderen Stelle noch etwas, wie der fehlende Support für die Konvertierung in eigene Typen. Außerdem können wir zwar an die Argumente gelangen, diese tauchen jedoch nicht im Hilfetext mit auf.

Betrachten wir also mit args4j einen weiteren Kandidaten.

args4j

Im Prinzip funktioniert args4j genau wie Commons CLI. Wir definieren die Optionen, und hier auch die Argumente, stoßen anschließend die Verarbeitung an und können dann mit den Argumenten und Optionen arbeiten.

Im Gegensatz zu Commons CLI nutzt args4j jedoch einen auf Annotationen basierenden Mechanismus für die Definition. Hierzu werden die in einer Klasse definierten Felder annotiert. Listing 2 zeigt die Implementierung mit identischer Funktionalität zum vorherigen Commons CLI-Beispiel.

class Args4jExample {

    static class Options {

        @Option(name = "-v", aliases = "--verbose",
                usage = "Verbose")
        public boolean verbose;

        @Option(name = "-d", aliases = "--delimiter",
                usage = "The delimiter to use", metaVar = "delimiter")
        public String delimiter = ",";

        @Argument(required = true,
                usage = "Words to join", metaVar = "words")
        public List<String> words;
    }

    public static void main(String[] args) {
        args = new String[]{ "foo", "--verbose", "bar", "-da=|", "baz" };

        var options = new Options();

        var parser = new CmdLineParser(options);

        try {
            parser.parseArgument(args);
            if (options.verbose) {
                System.err.println("Running in verbose mode");
            }

            var delimiter = options.delimiter;
            var result = String.join(delimiter, options.words);
            System.out.println(result);
        } catch (CmdLineException e) {
            e.printStackTrace();
            System.out.print("args4j");
            parser.printSingleLineUsage(System.out);
            System.out.println();
            parser.printUsage(System.out);
        }
    }
}
Listing 2: Implementierung mit args4j

Nach der Definition nutzen wir den CmdLineParser, um die angegebenen Optionen zu verarbeiten. Diesem wird bei Erzeugung eine Instanz der annotierten Klasse übergeben. Wird nun parseArgument aufgerufen, bindet der Parser die Werte der übergebenen Argumente und Optionen an die in der Klasse definierten Felder. Im Fehlerfall wird auch hier eine Exception geworfen. Auch hier wird die Ausgabe eines Hilfetexts unterstützt. Dazu nutzen wir direkt die erzeugte Instanz des Parsers und die Methoden printSingleLineUsage und printUsage.

Im Gegensatz zu Commons CLI bietet uns args4j die Möglichkeit, jeden beliebigen Datentyp zu nutzen. Hierzu müssen wir die Klasse OptionHandler implementieren und vor dem Parsen in der OptionHandlerRegistry registrieren.

JCommander

Eine weitere, args4j ähnliche Implementierung ist JCommander. Auch hier werden Optionen und Argumente per Annotationen definiert und auch die Unterstützung von eigenen Typen ist vorhanden.

JCommander bietet uns jedoch noch weitere Features an. So können wir beispielsweise eine Option explizit als Passwort markieren. Wird diese Option nun ohne Wert angegeben, wird dieser durch eine interaktive Eingabe nach Start der Anwendung abgefragt (s. Listing 3).

class JCommanderPasswordExample {

    static class Options {

        @Parameter(names = "--password", password = true, required = true,
                description = "The password to use")
        public String password;
    }

    public static void main(String[] args) {
        args = new String[] { "--password" };
        //args = new String[] { "--password", "Geheim!" };

        var opts = new Options();

        var jc = JCommander.newBuilder()
            .addObject(opts)
            .programName("jcommander")
            .build();

        try {
            jc.parse(args);
            System.out.println("Das Passwort ist: " + opts.password);
        } catch (ParameterException e) {
            e.printStackTrace();
            jc.usage();
        }
    }
}
Listing 3: Passwortoptionen mit JCommander

Daneben bietet uns JCommander auch die Möglichkeit, eigene Validierungen für Optionen zu implementieren. Somit könnten wir dafür sorgen, dass das eingegebene Passwort mindestens acht Buchstaben lang sein muss (s. Listing 4).

class JCommanderValidatorExample {

    static class Options {

        @Parameter(names = "--password", password = true, required = true,
                description = "The password to use",
                validateValueWith = PasswordLengthValidator.class)
        public String password;
    }

    public static class PasswordLengthValidator
            implements IValueValidator<String> {

        @Override
        public void validate(String name, String value)
                throws ParameterException {
            if (value.length() < 8) {
                throw new ParameterException(
                    "Parameter " + name + " must have at least 8 characters");
            }
        }
    }

...
}
Listing 4: Validierung eines Optionswertes mit JCommander

Ein weiteres, mächtiges Feature, besteht darin, dass uns JCommander auch für Kommandozeilenanwendungen unterstützt, die aus mehreren Kommandos, ähnlich wie Git, bestehen. Hierzu registrieren wir, wie in Listing 5 zu sehen, mit addCommand unsere Kommandos unter einem bestimmten Namen und können anschließend mit getParsedCommand herausfinden, welches Kommando angegeben wurde.

class JCommanderCommandExample {

    static class GlobalOptions {

        @Parameter(names = "--verbose")
        public boolean verbose;
    }

    @Parameters(commandDescription = "Adds some files")
    static class AddOptions {

        @Parameter(names = "-i")
        public boolean interactive;

        @Parameter
        public List<String> files;
    }

    @Parameters(commandDescription = "Removes some files")
    static class RmOptions {

        @Parameter(names = "-r")
        public boolean recursive;

        @Parameter
        public List<String> files;
    }

    public static void main(String[] args) {
        args = new String[] { "--verbose", "add", "-i", "foo", "bar" };

        var opts = new GlobalOptions();
        var addOpts = new AddOptions();
        var rmOpts = new RmOptions();

        var jc = JCommander.newBuilder()
            .addObject(opts)
            .programName("jcommander")
            .addCommand("add", addOpts)
            .addCommand("rm", rmOpts)
            .build();

        try {
            jc.parse(args);
            switch (jc.getParsedCommand()) {
                case "add" -> {
                    System.out.println("add " + addOpts.files);
                    System.out.println(" Verbose: " + opts.verbose);
                    System.out.println(" Interactive: " + addOpts.interactive);
                }
                case "rm" -> {
                    System.out.println("rm " + rmOpts.files);
                    System.out.println(" Verbose: " + opts.verbose);
                    System.out.println(" Recursive: " + rmOpts.recursive);
                }
            }
        } catch (ParameterException e) {
            e.printStackTrace();
            jc.usage();
        }
    }
}
Listing 5: Unterstützung für Kommandos von JCommander

Als letzte Erweiterung bietet uns JCommander noch die Möglichkeit, alle Optionen und Argumente, separiert durch Zeilenumbrüche, in eine Datei zu schreiben und anschließend beim Aufruf unserer Anwendung diese Datei als einziges Argument mit einem @ als Präfix anzugeben. JCommander erkennt ein solches @-Argument und liest die Optionen und Argumente nun aus der angegebenen Datei aus.

Auch wenn es scheint, als könnte JCommander bereits alles, was wir uns wünschen, gibt es mit picocli noch eine Bibliothek, die den Umfang von JCommander noch übertrifft.

picocli

picocli nutzt wie args4j und JCommander auch das deklarative auf Annotationen basierende Programmiermodell. Dabei unterstützt es alle bisher gezeigten Funktionalitäten und noch weitere.

Beispielsweise unterstützt es -- als Argument um anzuzeigen, dass alle folgenden Werte Argumente sind, auch wenn eines dieser Argumente einem Optionsnamen gleicht. So wird in Listing 6, obwohl --recursive angegebenx wurde, die Option recurisve auf false bleiben und --recursive in den Argumenten auftauchen.

@Command(name = "picocli")
class PicocliDashDashExample implements Runnable {

    @Option(names = "recursive")
    private boolean recursive;

    @Parameters
    private List<String> parameters;

    @Override
    public void run() {
        System.out.println("Recursive: " + recursive);
        System.out.println("Parameters: " + parameters);
    }

    public static void main(String[] args) {
        args = new String[] { "--", "--recursive", "foo" };

        new CommandLine(new PicocliDashDashExample()).execute(args);
    }
}
Listing 6: -- Argument in picocli

Dadurch, dass picocli nicht nur Optionen und Argumente verarbeitet, sondern wir Kommandos definieren, die dann von picocli auch noch ausgeführt werden, ist auch die Implementierung von mehreren Kommandos in einer Anwendung noch einfacher als mit JCommander. Wie in Listing 7 zu sehen ist, definieren wir an unserer Anwendung die zur Verfügung stehenden Kommandos und implementieren diese in ihrer eigenen mit @Command annotierten Klasse. Um an globale Optionen zu gelangen, können wir uns mit @ParentCommand die Anwendungsklasse injizieren lassen und diese abfragen.

@Command(name = "picocli", subcommands = {
        PicocliCommandExample.AddCommand.class,
        PicocliCommandExample.RmCommand.class})
public class PicocliCommandExample {

    @Option(names = "--verbose")
    public boolean verbose;

    @Command(name = "add")
    static class AddCommand implements Runnable {

        @ParentCommand
        PicocliCommandExample parent;

        @Spec
        CommandSpec spec;

        @Option(names = "-i")
        public boolean interactive;

        @Parameters(arity = "1..*")
        public List<String> files;

        @Override
        public void run() {
            var out = spec.commandLine().getOut();
            out.println("add " + files);
            out.println(" Verbose: " + parent.verbose);
            out.println(" Interactive: " + interactive);
        }
    }

    @Command(name = "rm")
    static class RmCommand implements Runnable {

        @ParentCommand
        PicocliCommandExample parent;

        @Spec
        CommandSpec spec;

        @Option(names = "-r")
        public boolean recursive;

        @Parameters(arity = "1..*")
        public List<String> files;

        @Override
        public void run() {
            var out = spec.commandLine().getOut();
            out.println("rm " + files);
            out.println(" Verbose: " + parent.verbose);
            out.println(" Recursive: " + recursive);
        }
    }

    public static void main(String[] args) {
        args = new String[] { "--verbose", "add", "-i", "foo", "bar" };

        new CommandLine(new PicocliCommandExample()).execute(args);
    }
}
Listing 7: Kommandos mit picocli

Listing 7 zeigt auch, dass wir anstatt direkt über System.out.println unser Resultat auszugeben, eine Indirektion über die Klasse CommandSpec nutzen. Diese ermöglicht es uns, in Tests den hierüber erlangten PrintWriter auszutauschen und somit auch die Ausgaben unserer Anwendung zu testen.

Ein weiteres nettes Feature besteht darin, dass picolci für uns bei Booleschen Optionen auch eine negierte Variante erzeugen kann. Erweitern wir dazu die Annotation für interactive um den Wert negatable=true, wird anschließend auch --no-verbose geparst und verarbeitet.

Neben diesen vier Features bietet uns picocli noch viele, sehr weitreichende Annehmlichkeiten an. So ist es möglich, man pages für unsere Anwendung generieren zu lassen, und auch eine Autocompletion mit Tab für bash und zsh ist möglich. Zudem können wir über einen Annotationsprozessor die definierten Kommandos und Optionen zur Kompilierungszeit auf Korrektheit prüfen und gleichzeitig die für GraalVM native Images benötigten Metadaten generieren lassen.

Zuletzt gibt es für picocli auch noch fertige Integrationen in die gängigen Dependency Injection basierten Frameworks wie Guice, Spring, Micronaut und Quarkus.

Titelfoto von Joel Mbugua auf Unsplash.

Fazit

In diesem Artikel haben wir mit Commons CLI, args4j, JCommander und picocli vier Bibliotheken kennengelernt, die uns bei der Implementierung von Kommandozeilenanwendungen mit Java unterstützen.

Neben dem Programmiermodell, programmatisch per Code oder deklarativ per Annotationen, unterscheiden sich diese vor allem in der Menge der zur Verfügung stehenden Features, wie Typkonvertierungen, Kommandos oder erweiterter Validierung. Je nach Anwendungsfall können wir hier mit dem reduzierten Umfang von Commons CLI oder args4j auskommen. Bei einem anderen benötigen wir den größeren Umfang von JCommander oder sogar die noch weiter reichenden Möglichkeiten von picocli.

Neben diesen vieren gibt es, natürlich, noch weitere Bibliotheken, wie Airline oder CREST. Bei einer konkreten Evaluierung sollten auch diese in Betracht gezogen werden.

Den in diesem Artikel gezeigten Code gibt es, zum Nachvollziehen oder Herumprobieren, auf GitHub unter https://github.com/mvitz/javaspektrum-cli.

TAGS