Dieser Artikel wurde automatisch aus dem Englischen übersetzt.

Dieses Tutorial befasst sich mit dem Einsatz von Subcommands in picocli-Programmen. Mit Subcommands lässt sich mehr Funktionalität über eine einzige Kommandozeilenanwendung auf benutzerfreundliche Weise bereitstellen. Wer noch nicht mit picocli vertraut ist, sollte zunächst die picocli-Einführung lesen.

Die Unix-Philosophie beinhaltet den Grundsatz “Do One Thing and Do It Well”. Das ist generell ein guter Ratschlag, dem die meisten grundlegenden Unix-Programme folgen. Einige fortgeschrittenere Programme weichen davon ab und bündeln verschiedene Funktionen in einem einzigen Executable.

Beispiele solcher Programme sind git und docker. Der git-Befehl enthält alle Funktionen zur Interaktion mit Git-Repositories, und docker bietet eine Vielzahl von Funktionen rund um Docker-Umgebungen (Verwaltung von Images, Containern und Ressourcen wie Volumes und Netzwerken).

Der Ansatz dabei ist der Einsatz von Subcommands. So erstellt beispielsweise git clone ein lokales Repository als Klon eines entfernten Repositories. Es gibt zahlreiche Subcommands für git, wie git commit, git branch und viele mehr. Jeder Subcommand hat dabei seine eigenen Optionen und Parameter. Das ist ein Fall von vielfältiger Funktionalität rund um gemeinsame Daten (Git-Repositories).

Subcommands in picocli

Aus Benutzersicht ähneln Subcommands in picocli-Anwendungen der Arbeit mit git und docker. Der Subcommand wird als Parameter an die Hauptanwendung übergeben. Argumente, die vor dem Subcommand-Namen angegeben werden, gehören zum Hauptbefehl. Argumente nach dem Subcommand-Namen gehören zum Subcommand.

Für Anwendungsentwickler gibt es zwei primäre Implementierungsmöglichkeiten. Die erste Option ist die Verwendung von Methoden, die mit @Command annotiert sind, in einer einzigen Klasse. Die andere Option ist die Verwendung separater Klassen, die mit @Command annotiert sind, für jeden Subcommand. Welcher Ansatz gewählt wird, hängt von der Anwendung und dem bevorzugten Design-Stil ab.

Für die folgenden Beispiele stellen wir ein Programm mit den drei Subcommands prepare, process und status bereit. Das ist ein vereinfachter Blick auf die grundlegendste Funktionalität solcher Programme. Der Einfachheit halber gibt die Anwendung lediglich eine Meldung auf der Konsole aus. In der Praxis sind die Implementierungen stark anwendungsabhängig und beinhalten typischerweise Dateiverarbeitung und/oder Verbindungen zu externen Systemen (Datenbankserver, Online-Ressourcen, Messaging-Systeme usw.).

Subcommands mit Methoden

Der vielleicht einfachste Weg, Subcommands hinzuzufügen, ist die Verwendung von Methoden in der bestehenden Command-Klasse. Das folgende Beispiel enthält eine main-Methode sowie die @Command-Annotation auf Klassenebene. Drei Methoden der Klasse sind ebenfalls mit @Command annotiert – das sind die drei Subcommands prepare, process und status.

import java.util.concurrent.Callable;

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

@Command(name = "subcommands")
public class SubcommandsMethods implements Callable<Integer> {

  @Command(name = "prepare")
  public Integer prepare() {
    System.out.println("Preparing data files.");
    return 0;
  }

  @Command(name = "process")
  public Integer process() {
    System.out.println("Processing data files.");
    return 0;
  }

  @Command(name = "status")
  public void status() {
    System.out.println("Show status of data files.");
  }

  @Override
  public Integer call() {
    System.out.println("Subcommand needed: 'prepare', 'process' or 'status'");
    return 0;
  }

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

}

Beim Aufruf der main-Methode dieser Klasse wird eine neue CommandLine-Instanz erstellt und eine Instanz unserer Klasse übergeben. Auf Basis dieser Instanz kann picocli die unterstützten Optionen und Parameter der Anwendung ermitteln. Anschliessend wird der Hauptbefehl mit den Kommandozeilenargumenten ausgeführt. Da unsere Hauptbefehlsklasse Callable<Integer> implementiert, ruft picocli standardmässig die Methode Integer call() auf.

Da wir ausserdem drei Subcommands haben, prüft picocli die Kommandozeilenargumente auf Parameter, die einem der Subcommand-Namen entsprechen. Wird eine Übereinstimmung gefunden, wird die entsprechende Methode anstelle der Standard-Methode call() ausgeführt.

In der main()-Methode wird der Rückgabewert von picocli als Exit-Code verwendet. Der Rückgabewert des Hauptbefehls stammt aus dem Rückgabewert der call()-Methode, die ein Integer zurückgibt. Für die Subcommands geben prepare und process ebenfalls ein Integer zurück – dieser Wert wird als Exit-Code verwendet. Die Methode status hat den Rückgabetyp void; in diesem Fall wird der Standard-Exit-Code 0 zurückgegeben. Wirft eine Subcommand-Methode eine Exception, ist der Standard-Exit-Code 1.

Subcommand-Methoden mit Optionen und Parametern

Wir haben die Rückgabecodes für Subcommands mit Methoden behandelt – aber wie werden Optionen und Parameter gehandhabt? Ähnlich wie die @Command-Annotation von der Klassen- auf die Methodenebene verschoben wurde, können die Annotationen @Option und @Parameters für Methodenparameter verwendet werden. Auf diese Weise können Optionen und Parameter des Subcommands über annotierte Parameter angegeben werden.

Das folgende Beispiel fügt dem Subcommand process die Option -n hinzu und liest ausserdem einen einzelnen Parameter namens INPUTDIR ein:

@Command(name = "process")
public Integer process(
    @Option(names = {"-n"}, defaultValue = "-1") int maxEntries,
    @Parameters(arity = "1", paramLabel = "INPUTDIR") Path inputDir) {
  System.out.println("Processing data files.");
  return 0;
}

Zugriff auf Argumente des Hauptbefehls

Es kann Situationen geben, in denen ein Subcommand auf Optionen zugreifen möchte, die dem Hauptbefehl übergeben wurden. Ein Beispiel für eine Option, die dem Hauptbefehl übergeben wird, aber das Verhalten der Subcommands beeinflusst, ist die Option --git-dir bei git. Das Standardverhalten von git ist es, das Git-Repository im aktuellen Arbeitsverzeichnis zu suchen. Die Option --git-dir überschreibt dieses Verhalten, sodass das angegebene Verzeichnis als Git-Repository verwendet wird. Da alle git-Subcommands ein Repository benötigen, ist die Option dem Hauptbefehl zugeordnet, anstatt sie jedem Subcommand einzeln hinzuzufügen.

Ein weiteres typisches Beispiel ist eine globale Option für ausführliche Ausgaben. Wenn aktiviert, gibt die Anwendung mehr Details zur Verarbeitung aus. Üblicherweise wird das mit einem Flag -v (Kurzform) oder --verbose (Langform) aktiviert.

Die Option wird als neues Feld zur Klasse SubcommandsMethods hinzugefügt. Da es ein Flag ist, verwenden wir den Typ boolean mit dem Standardwert false:

@Option(names = { "-v", "--verbose" }, defaultValue = "false")
private boolean verbose = false;

Da die Subcommands als Methoden in derselben Klasse wie der Hauptbefehl implementiert sind, kann auf das Feld direkt zugegriffen werden. Unten sieht man die erweiterte process-Methode, die bei aktiviertem Verbose-Flag mehr Informationen ausgibt:

@Command(name = "process")
public Integer process(@Option(names = { "-n" }, defaultValue = "-1") int maxEntries,
    @Parameters(arity = "1", paramLabel = "INPUTDIR") Path inputDir) {
  System.out.println("Processing data files.");
  if (verbose) {
    System.out.printf("Processing files in %s%n", inputDir);
    if (maxEntries > 0) {
      System.out.printf("Max entries: %d%n", maxEntries);
    }
  }
  return 0;
}

Subcommands als separate Klassen

Die andere Möglichkeit zur Implementierung von Subcommands ist die Verwendung separater Klassen. Der Hauptgrund für die Verwendung von Klassen anstelle von Methoden ist die Erhöhung der Kohäsion (das klassische Software-Design-Prinzip der hohen Kohäsion und niedrigen Kopplung). Je mehr Argumente zu den Subcommands hinzukommen und je komplexer die Verarbeitung wird (neue Geschäftsregeln, Sonderfälle, Fehlerbehandlung usw.), desto grösser und schwerer wartbar werden Klassen, die alle Subcommands als Methoden enthalten.

Die Subcommands prepare, process und status werden nun als separate Top-Level-Klassen implementiert. Es wäre auch möglich, statische innere Klassen zu verwenden, aber im Sinne von hoher Kohäsion und guter Wartbarkeit empfehlen sich in den meisten Fällen Top-Level-Klassen.

Zunächst erstellen wir eine neue Klasse für den Subcommand prepare:

import java.util.concurrent.Callable;

import picocli.CommandLine.Command;

@Command(name = "prepare")
public class SubcommandPrepare implements Callable<Integer> {

  @Override
  public Integer call() {
    System.out.println("Preparing data files.");
    return 0;
  }

}

Die neue Klasse SubcommandPrepare implementiert Callable<Integer>, und die Funktionalität, die sich zuvor in der Methode public Integer prepare() befand, ist nun in der Methode public Integer call(). Die Annotation @Command wurde von der Methodenebene auf die Klassenebene verschoben.

Als nächstes implementieren wir den Subcommand process – zunächst in der einfacheren Version ohne Zugriff auf die Option des Hauptbefehls:

import java.nio.file.Path;
import java.util.concurrent.Callable;

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

@Command(name = "process")
public class SubcommandProcess implements Callable<Integer> {

  @Option(names = { "-n" }, defaultValue = "-1")
  private int maxEntries;

  @Parameters(arity = "1", paramLabel = "INPUTDIR")
  private Path inputDir;

  @Override
  public Integer call() {
    System.out.println("Processing data files.");
    return 0;
  }

}

Der Subcommand process akzeptiert die Option -n und erfordert einen Parameter für das Eingabeverzeichnis. Bei Subcommands als Klassen funktionieren die Annotationen @Option und @Parameters genau wie bei Hauptbefehlen.

Der letzte Subcommand ist status, der sich von den beiden vorherigen durch das Fehlen eines Rückgabewerts unterscheidet:

import java.util.concurrent.Callable;

public class SubcommandStatus implements Callable<Void>{

  @Override
  public Void call() {
    System.out.println("Show status of data files.");
    return null;
  }

}

Hier wird weiterhin Callable implementiert, aber der Typparameter wurde von Integer auf Void (java.lang.Void) geändert. Der Typ Void ist etwas Besonderes, da er keine Instanzen zulässt. Da kein Wert zurückgegeben werden soll, wird null zurückgegeben.

Da wir void zurückgeben, könnten wir auch Runnable anstelle von Callable<Void> implementieren:

public class SubcommandStatus implements Runnable {

  @Override
  public void run() {
    System.out.println("Show status of data files.");
  }

}

Jetzt, wo wir separate Klassen für alle Subcommands haben, ist es Zeit, den Hauptbefehl zu erstellen. Der Hauptbefehl befindet sich ebenfalls in einer separaten Klasse und ist kaum von einem Befehl ohne Subcommands zu unterscheiden:

import java.util.concurrent.Callable;

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

@Command(name = "subcommands", subcommands = { SubcommandPrepare.class,
    SubcommandProcess.class, SubcommandStatus.class })
public class SubcommandsClasses implements Callable<Integer> {

  @Override
  public Integer call() {
    System.out.println("Subcommand needed: 'prepare', 'process' or 'status'");
    return 0;
  }

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

}

Die Methoden main() und call() ähneln denen in der Variante mit Subcommands als Methoden. Was sich geändert hat: Die Subcommand-Methoden sind verschwunden und stattdessen wurde der Parameter subcommands zur @Command-Annotation hinzugefügt. Der Wert dieses Parameters ist ein Array mit den Subcommand-Klassen.

SubcommandsMethods und SubcommandsClasses sind aus Benutzersicht nun fast funktional gleichwertig. Lediglich die Option für ausführliche Ausgaben im Hauptbefehl fehlt noch.

Zugriff auf den Hauptbefehl

Die Subcommands befinden sich nun in Klassen getrennt vom Hauptbefehl. Wie kann also auf die dem Hauptbefehl übergebenen Argumente zugegriffen werden? Bei der Variante mit Methoden war es einfach der Zugriff auf das Feld in der Klasse. Da sich Haupt- und Subcommands jetzt in separaten Klassen befinden, ist kein direkter Zugriff mehr möglich. picocli löst das durch die Annotation @ParentCommand. Wird diese auf ein Feld in der Subcommand-Klasse angewendet, erhält man Zugriff auf den Hauptbefehl (Parent).

Zunächst wird die Option --verbose zur Klasse SubcommandsClasses hinzugefügt:

@Option(names = { "-v", "--verbose" }, defaultValue = "false")
private boolean verbose = false;

public boolean isVerbose() {
  return verbose;
}

Das Feld verbose samt @Option-Annotation entspricht dem, was wir zur Klasse SubcommandsMethods hinzugefügt haben. Die Methode isVerbose() wird hinzugefügt, damit auf das Feld von ausserhalb der Klasse zugegriffen werden kann.

Um auf die Option in der Subcommand-Klasse zugreifen zu können, brauchen wir eine Referenz auf die Instanz des Hauptbefehls. Die aktualisierte Klasse SubcommandProcess sieht so aus:

import java.nio.file.Path;
import java.util.concurrent.Callable;

import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.ParentCommand;

@Command(name = "process")
public class SubcommandProcess implements Callable<Integer> {

  @Option(names = { "-n" }, defaultValue = "-1")
  private int maxEntries;

  @Parameters(arity = "1", paramLabel = "INPUTDIR")
  private Path inputDir;

  @ParentCommand
  private SubcommandsClasses mainCmd;

  @Override
  public Integer call() {
    System.out.println("Processing data files.");
    if (mainCmd.isVerbose()) {
      System.out.printf("Processing files in %s%n", inputDir);
      if (maxEntries > 0) {
        System.out.printf("Max entries: %d%n", maxEntries);
      }
    }
    return 0;
  }

}

Das neue Feld mainCmd vom Typ SubcommandsClasses ist mit @ParentCommand annotiert. Mit dieser Annotation initialisiert picocli das Feld mit einer Referenz auf die SubcommandsClasses-Instanz. Da nun eine Methode zum Auslesen des Optionswerts (isVerbose()) vorhanden ist, kann über mainCmd der Wert des --verbose-Parameters des Hauptbefehls abgefragt werden.

Subcommands verschachteln

Es ist auch möglich, Subcommands in anderen Subcommands zu verschachteln. Verschachtelte Subcommands kennt man von docker, wo der erste Subcommand oft den Ressourcentyp (image, network, volume) und der zweite die Operation auf der Ressource angibt. Beispiele sind docker volume create (ein neues Docker-Volume anlegen) und docker volume ls (vorhandene Docker-Volumes auflisten).

In picocli lassen sich verschachtelte Befehle mit der Annotation @Command erstellen. Subcommands zur Klasse SubcommandProcess könnten über den Parameter subcommands der Annotation (ähnlich wie bei SubcommandsClasses) oder durch Annotieren von Methoden in der Klasse mit @Command erstellt werden.

Weitere Möglichkeiten mit Subcommands

Die Unterstützung für Subcommands in picocli geht über das hier Beschriebene hinaus. Weitere Features, die interessant sein könnten, umfassen unter anderem versteckte Subcommands, vererbte Optionen sowie abgekürzte Optionen und Subcommands. In der offiziellen Dokumentation sind diese Themen ausführlich beschrieben.