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.
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.
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).
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).
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.
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.
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.
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.