Dieser Artikel wurde automatisch aus dem Englischen übersetzt.

Dies ist eine Einführung in die Entwicklung benutzerfreundlicher Command-Line-Interfaces für Java-Anwendungen mit dem picocli-Framework.

Die Welt der Anwendungen scheint von aufwendigen Webanwendungen und mobilen Apps dominiert zu werden, doch der Bedarf, Anwendungen direkt über die Kommandozeile auszuführen, besteht nach wie vor. Kommandozeilenanwendungen sind generell nicht für ihre Benutzerfreundlichkeit bekannt – und bei Java-Anwendungen scheint das noch ausgeprägter zu sein als anderswo.

Das muss aber nicht so sein. Das Apache Commons CLI-Projekt ist seit Jahren recht bekannt, und es gibt verschiedene weitere Alternativen, um Kommandozeilen-Interfaces für Java-Anwendungen zu erstellen. In letzter Zeit ist ein neuer Kandidat aufgetaucht: picocli. Leichtgewichtig und dennoch mit einer leistungsstarken und flexiblen API ausgestattet – zusammen mit einigen weiteren interessanten Features – entwickelt sich picocli zum bevorzugten Framework für moderne, benutzerfreundliche Kommandozeilen-Interfaces in Java.

picocli zur Anwendung hinzufügen

Zunächst muss picocli zur Anwendung hinzugefügt werden. Es gibt mehrere Möglichkeiten; in diesem Tutorial wird die Maven-Abhängigkeit gezeigt.

Füge die folgende Abhängigkeit zur pom.xml hinzu:

<dependency>
  <groupId>info.picocli</groupId>
  <artifactId>picocli</artifactId>
  <version>4.7.1</version>
</dependency>

Wer Gradle oder ein anderes Dependency-Management-System verwendet, das das Maven Central Repository unterstützt, kann die Abhängigkeit entsprechend anpassen. Es ist auch möglich, picocli direkt als einzelne Java-Quelldatei in das eigene Programm einzubinden. Das ist in der Online-Dokumentation beschrieben.

Das Hauptprogramm ausführen

Wie es sich für eine gute Einführung gehört, beginnen wir mit einem Hello-World-Programm. Dieses Programm gibt einfach Hello, Picocli auf der Konsole aus.

import picocli.CommandLine;
import picocli.CommandLine.Command;

@Command
public class HelloPicocli implements Runnable {

  public void run() {
    System.out.println("Hello, Picocli");
  }

  public static void main(String[] args) {
    new CommandLine(new HelloPicocli()).execute(args);
  }

}

Das ist ein sehr einfaches Beispiel, enthält aber bereits mehrere wichtige Bestandteile.

Die Klasse implementiert – wie üblich – die Methode public static void main(String[]) als Einstiegspunkt. Die main-Methode ruft das picocli-Framework über die Klasse picocli.CommandLine auf.

Die Klasse implementiert das Interface java.lang.Runnable und die erforderliche Methode public void run(). In dieser Methode befindet sich die eigentliche Anwendungslogik. Die Klasse ist ausserdem mit der Annotation picocli.CommandLine.Command (@Command) versehen.

Die main-Methode erstellt eine neue Instanz von picocli.CommandLine und übergibt eine neue Instanz unserer Anwendungsklasse an den Konstruktor. Anschliessend wird die Methode execute auf der CommandLine-Instanz aufgerufen und die Kommandozeilenargumente übergeben. picocli verarbeitet diese und ruft dann die Methode run() auf unserer Anwendungsinstanz auf.

Parameter und Optionen entgegennehmen

Die grosse Mehrheit der Kommandozeilenanwendungen nimmt einen oder mehrere Parameter und/oder Optionen entgegen, um anzugeben, womit gearbeitet werden soll, und um die Verarbeitung zu steuern. Bevor wir weitermachen, sollten wir kurz den Unterschied zwischen Parametern und Optionen klären.

Parameter werden häufig verwendet, um die Daten anzugeben (z. B. Dateien oder Verzeichnisse), die verarbeitet werden sollen. Optionen hingegen verändern das Verhalten der Anwendung und beeinflussen, wie die Daten verarbeitet werden.

Optionen sind in der Regel entweder binär (ihre Anwesenheit aktiviert oder deaktiviert ein Verhalten) oder sie fungieren als Schlüssel-Wert-Paar. Im letzteren Fall verändert sowohl die Anwesenheit als auch der zugewiesene Wert das Verhalten des Programms.

Zum Beispiel: Der Befehl ls -l /var/log hat die Option -l, die ls anweist, die „lange Listenansicht” zu verwenden (mit mehr Informationen pro Eintrag). Der letzte Teil, /var/log, ist ein Parameter und gibt an, für welches Verzeichnis oder welche Datei die Informationen angezeigt werden sollen.

Das folgende Programm erweitert das HelloPicocli-Beispiel so, dass es einen oder mehrere Parameter mit Nachrichten entgegennimmt, die anstelle der Nachricht Hello, Picocli angezeigt werden sollen.

import java.util.List;
import picocli.CommandLine;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

public class HelloCustomMessage implements Runnable {

  @Parameters
  private List<String> messages;

  @Option(names = { "-p", "--prefix" })
  private String prefix;

  public void run() {
    if (messages == null || messages.size() < 1) {
      System.out.printf("%sHello, Picocli%n", prefix == null ? "" : prefix);
    } else {
      for (String msg : messages) {
        System.out.printf("%s%s%n", prefix == null ? "" : prefix, msg);
      }
    }
  }

  public static void main(String[] args) {
    new CommandLine(new HelloCustomMessage()).execute(args);
  }

}

Die Annotation @Parameters gibt die Variable an, in der die als Parameter übergebenen Nachrichten gespeichert werden. Hier verwenden wir java.util.List<String>, um die Parameter zu halten.

Die Annotation @Option gibt an, dass der Wert, der mit -p (Kurzform) oder --prefix (Langform) übergeben wird, in der Variable prefix gespeichert werden soll.

Wenn es zwingend erforderlich sein soll, dass mindestens ein Parameter angegeben wird, kann der arity-Parameter an die @Parameters-Annotation übergeben werden:

  @Parameters(arity="1..*")

Damit prüft picocli, ob mindestens ein Parameter angegeben wurde. Als Folge davon kann das Programm vereinfacht werden, sodass nur der Fall mit Parametern behandelt werden muss. Wird das Programm ohne Parameter aufgerufen, wird die Methode run() nicht ausgeführt, und stattdessen wird eine Meldung ähnlich der folgenden angezeigt:

Missing required parameter: <messages>
Usage: <main class> [-p=<prefix>] <messages>...
      <messages>...
  -p, --prefix=<prefix>

Andere Typen von Parametern und Optionen

Bisher wurden Parameter und Optionen als Strings übergeben. Was, wenn eine Option eine ganze Zahl sein oder auf eine Datei verweisen soll? Kommandozeilenparameter werden als Strings an das Java-Programm übergeben, und man müsste sie in den gewünschten Datentyp konvertieren. Zum Glück enthält picocli bereits viel Funktionalität, um das automatisch zu erledigen.

Beginnen wir damit, ein boolesches Flag hinzuzufügen, das einen standardmässigen Begrüssungstext vor den Nachrichten einblendet:

  @Option(names = { "-g", "--greeting"})
  private boolean displayGreeting;

Und in der run()-Methode fügen wir Folgendes hinzu:

  public void run() {
    if (displayGreeting) {
      System.out.println("Welcome! Here are your messages:");
    }
    .....
  }

Ausserdem könnten wir einen Parameter hinzufügen, der erlaubt, jede Nachricht eine bestimmte Anzahl von Malen zu wiederholen. Wird der Parameter nicht angegeben, soll jede Nachricht standardmässig einmal ausgegeben werden:

  @Option(names = { "-r", "--repeat"})
  private int repeat = 1;

Wenn geprüft werden soll, ob der Parameter angegeben wurde oder nicht, können Wrapper-Klassen verwendet werden – in diesem Beispiel java.lang.Integer:

  @Option(names = { "-r", "--repeat"})
  private Integer repeat;

Mit dieser Änderung kann geprüft werden, ob repeat null ist – was der Fall wäre, wenn der Parameter nicht angegeben wurde.

Als letztes Beispiel für Optionstypen fügen wir eine Option hinzu, mit der Nachrichten aus einer Textdatei eingelesen werden können:

  @Option(names = { "-i", "--input"})
  private Path messagesFile;

Damit kann mit messagesFile als java.nio.file.Path gearbeitet werden. Zu beachten ist, dass es sich lediglich um ein Path-Objekt handelt – es gibt keine Garantie, dass der Pfad auf eine gültige Datei zeigt. Für solche Prüfungen bietet sich die Funktionalität von java.nio.file.Files an.

Das sind nur einige der grundlegenden Konvertierungen, die picocli beherrscht. Weitere Informationen sind im Abschnitt Built-in Types der picocli-Dokumentation zu finden.

Fehlerbehandlung und Rückgabewert

Das Programm kann direkt in einem interaktiven Terminal aufgerufen werden, aber viele Kommandozeilenanwendungen sind dafür gedacht, aus Skripten heraus aufgerufen zu werden. Das können einfache Shell-Skripte sein oder Bestandteile von Job-Scheduling- und Workload-Management-Systemen wie Slurm.

Daher ist es wichtig, dass das Programm einen Rückgabewert liefert. Bei normalem Ablauf sollte das Programm mit dem Exit-Code 0 beenden – dieser signalisiert üblicherweise Erfolg. Bei Fehlerszenarien sollte ein Wert ungleich 0 zurückgegeben werden. Alle Rückgabewerte sind programmabhängig.

Es wird dringend empfohlen, alle Rückgabecodes an einer einzigen Stelle zu verwalten, zum Beispiel in einem enum oder als static final-Felder einer einzelnen Klasse. Die Rückgabecodes sollten sauber dokumentiert sein – inklusive ihrer Bedeutung. Zu berücksichtigen ist dabei, ob es sich um eine Warnung oder einen Fehler handelt, ob die Situation dauerhaft ist (Datei nicht lesbar) oder beim nächsten Versuch möglicherweise erfolgreich sein könnte (Netzwerkfehler). Falls Teile der Verarbeitung nicht rückgängig gemacht werden konnten, sollte das ebenfalls klar kommuniziert werden. Gegebenenfalls kann es sinnvoll sein, Rückgabecodes in Bereiche zu segmentieren – zum Beispiel 1–20 für Warnungen und über 20 für Fehler.

Wie bereits gesagt: Alle möglichen Rückgabecodes sollten dokumentiert werden. Aus eigener Erfahrung (besonders mit bestimmten kommerziellen Closed-Source-Produkten) ist es äusserst frustrierend, wenn ein Programm ohne aussagekräftige Fehlermeldung abbricht und einen undokumentierten Rückgabecode liefert.

Zurück zur Anwendung: Um einfach einen Rückgabewert liefern zu können, ändern wir sie so, dass sie java.util.concurrent.Callable<Integer> anstelle von java.lang.Runnable implementiert. Als Konsequenz muss auch die Methode public void run() durch public Integer call() ersetzt werden.

Der Rückgabewert von call() wird dann zum Rückgabewert von picocli.CommandLine.execute(String...) in der main-Methode. Zu beachten ist, dass picocli diesen Wert nicht automatisch als Exit-Code des Java-Prozesses an den Aufrufer zurückgibt. Dafür muss explizit java.lang.System.exit(int) mit dem Rückgabecode aufgerufen werden. Es empfiehlt sich, System.exit nur in der main-Methode aufzurufen, da es die JVM beendet. Die meisten statischen Code-Analysewerkzeuge warnen ausserdem, wenn System.exit ausserhalb von main verwendet wird.

Fertige Anwendung

Nun ein Blick auf die fertige Anwendung. Diese implementiert den Rückgabewert: Bei Erfolg wird 0 zurückgegeben. Tritt ein Fehler auf – in diesem Fall kann die optionale Datei mit Nachrichten nicht gelesen werden – wird stattdessen 1 zurückgegeben.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Callable;

import picocli.CommandLine;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

public class HelloFinal implements Callable<Integer> {

  @Parameters
  private List<String> messages;

  @Option(names = { "-p", "--prefix" })
  private String prefix;

  @Option(names = { "-g", "--greeting" })
  private boolean displayGreeting;

  @Option(names = { "-r", "--repeat" })
  private int repeat = 1;

  @Option(names = { "-i", "--input" })
  private Path messagesFile;

  public Integer call() {
    if (displayGreeting) {
      System.out.println("Welcome! Here are your messages:");
    }

    if (messagesFile != null) {
      try {
        if (messages == null) {
          messages = new ArrayList<>();
        }
        messages.addAll(Files.readAllLines(messagesFile));
      } catch (IOException e) {
        System.err.printf("Error reading file %s%n", messagesFile);
        return 1;
      }
    }

    if (messages == null || messages.size() < 1) {
      messages = Collections.singletonList("Hello, Picocli");
    }

    for (String msg : messages) {
      for (int i = 0; i < repeat; i++) {
        System.out.printf("%s%s%n", prefix == null ? "" : prefix, msg);
      }
    }

    return 0;
  }

  public static void main(String[] args) {
    int rc = new CommandLine(new HelloFinal()).execute(args);
    System.exit(rc);
  }

}

Standardoptionen

Welche Optionen und Parameter bereitgestellt werden, hängt stark von der jeweiligen Anwendung ab. Es gibt jedoch einige Standardparameter, die bei Linux/GNU-Befehlen häufig verwendet werden. Zum Beispiel -h oder --help, um Hinweise zur Verwendung des Befehls anzuzeigen. Ein weiterer häufiger Parameter ist -v oder --verbose, um mehr Protokollausgaben zu erzeugen. Manche Anwendungen unterstützen auch -vv und --very-verbose, um die Ausgabe noch weiter zu erhöhen. Bei Verwendung eines Logging-Frameworks könnte -v den Logger auf debug und -vv auf trace setzen.

Einige Standards für Kommandozeilen-Interfaces: