Analisi dei parametri della riga di comando con JCommander

1. Panoramica

In questo tutorial, impareremo come utilizzare JCommander per analizzare i parametri della riga di comando. Esploreremo molte delle sue funzionalità mentre creiamo una semplice applicazione a riga di comando.

2. Perché JCommander?

"Perché la vita è troppo breve per analizzare i parametri della riga di comando" - Cédric Beust

JCommander, creato da Cédric Beust, è una libreria basata su annotazioni per l' analisi dei parametri della riga di comando . Può ridurre lo sforzo di creare applicazioni a riga di comando e aiutarci a fornire loro una buona esperienza utente.

Con JCommander, possiamo scaricare attività complesse come l'analisi, la convalida e le conversioni di tipo, per consentirci di concentrarci sulla logica dell'applicazione.

3. Configurazione di JCommander

3.1. Configurazione Maven

Cominciamo aggiungendo la dipendenza jcommander nel nostro pom.xml :

 com.beust jcommander 1.78 

3.2. Ciao mondo

Creiamo una semplice HelloWorldApp che accetta un singolo input chiamato name e stampa un saluto, "Hello" .

Poiché JCommander associa gli argomenti della riga di comando ai campi in una classe Java , definiremo prima una classe HelloWorldArgs con un nome di campo annotato con @Parameter :

class HelloWorldArgs { @Parameter( names = "--name", description = "User name", required = true ) private String name; }

Ora, usiamo la classe JCommander per analizzare gli argomenti della riga di comando e assegnare i campi nel nostro oggetto HelloWorldArgs :

HelloWorldArgs jArgs = new HelloWorldArgs(); JCommander helloCmd = JCommander.newBuilder()   .addObject(jArgs)   .build(); helloCmd.parse(args); System.out.println("Hello " + jArgs.getName());

Infine, richiamiamo la classe principale con gli stessi argomenti dalla console:

$ java HelloWorldApp --name JavaWorld Hello JavaWorld

4. Creazione di un'applicazione reale in JCommander

Ora che siamo attivi e funzionanti, consideriamo un caso d'uso più complesso: un client API della riga di comando che interagisce con un'applicazione di fatturazione come Stripe, in particolare lo scenario di fatturazione a consumo (o basato sull'utilizzo). Questo servizio di fatturazione di terze parti gestisce i nostri abbonamenti e fatturazione.

Immaginiamo di gestire un'attività SaaS, in cui i nostri clienti acquistano abbonamenti ai nostri servizi e vengono fatturati per il numero di chiamate API ai nostri servizi al mese. Eseguiremo due operazioni nel nostro client:

  • submit : invia quantità e prezzo unitario di utilizzo per un cliente rispetto a un determinato abbonamento
  • fetch : recupera gli addebiti per un cliente in base al consumo di alcuni o tutti i suoi abbonamenti nel mese corrente: possiamo ottenere questi addebiti aggregati su tutti gli abbonamenti o dettagliati per ogni abbonamento

Creeremo il client API mentre esaminiamo le funzionalità della libreria.

Cominciamo!

5. Definizione di un parametro

Cominciamo definendo i parametri che la nostra applicazione può utilizzare.

5.1. L' annotazione @Parameter

L'annotazione di un campo con @Parameter indica a JCommander di associare ad esso un argomento della riga di comando corrispondente . @Parameter ha attributi per descrivere il parametro principale, come:

  • nomi: uno o più nomi dell'opzione, ad esempio "–name" o "-n"
  • descrizione : il significato dietro l'opzione, per aiutare l'utente finale
  • richiesto : se l'opzione è obbligatoria, il valore predefinito è false
  • arity : numero di parametri aggiuntivi consumati dall'opzione

Configuriamo un parametro customerId nel nostro scenario di fatturazione a consumo:

@Parameter( names = { "--customer", "-C" }, description = "Id of the Customer who's using the services", arity = 1, required = true ) String customerId; 

Ora, eseguiamo il nostro comando con il nuovo parametro "–customer":

$ java App --customer cust0000001A Read CustomerId: cust0000001A. 

Allo stesso modo, possiamo utilizzare il parametro "-C" più breve per ottenere lo stesso effetto:

$ java App -C cust0000001A Read CustomerId: cust0000001A. 

5.2. Parametri obbligatori

Quando un parametro è obbligatorio, l'applicazione esce generando un'eccezione ParameterException se l'utente non lo specifica:

$ java App Exception in thread "main" com.beust.jcommander.ParameterException: The following option is required: [--customer | -C]

Dobbiamo notare che, in generale, qualsiasi errore nell'analisi dei parametri genera un'eccezione ParameterException in JCommander.

6. Tipi incorporati

6.1. Interfaccia IStringConverter

JCommander esegue la conversione del tipo dall'input della stringa della riga di comando nei tipi Java nelle nostre classi di parametri. L' interfaccia IStringConverter gestisce la conversione del tipo di un parametro da String a qualsiasi tipo arbitrario. Quindi, tutti i convertitori integrati di JCommander implementano questa interfaccia.

JCommander viene fornito con il supporto per tipi di dati comuni come String , Integer , Boolean , BigDecimal ed Enum .

6.2. Tipi single-arity

Arity relates to the number of additional parameters an option consumes. JCommander's built-in parameter types have a default arity of one, except for Boolean and List. Therefore, common types such as String, Integer, BigDecimal, Long, and Enum, are single-arity types.

6.3. Boolean Type

Fields of type boolean or Boolean don't need any additional parameter – these options have an arity of zero.

Let's look at an example. Perhaps we want to fetch the charges for a customer, itemized by subscription. We can add a boolean field itemized, which is false by default:

@Parameter( names = { "--itemized" } ) private boolean itemized; 

Our application would return aggregated charges with itemized set to false. When we invoke the command line with the itemized parameter, we set the field to true:

$ java App --itemized Read flag itemized: true. 

This works well unless we have a use case where we always want itemized charges, unless specified otherwise. We could change the parameter to be notItemized, but it might be clearer to be able to provide false as the value of itemized.

Let's introduce this behavior by using a default value true for the field, and setting its arity as one:

@Parameter( names = { "--itemized" }, arity = 1 ) private boolean itemized = true; 

Now, when we specify the option, the value will be set to false:

$ java App --itemized false Read flag itemized: false. 

7. List Types

JCommander provides a few ways of binding arguments to List fields.

7.1. Specifying the Parameter Multiple Times

Let's assume we want to fetch the charges of only a subset of a customer's subscriptions:

@Parameter( names = { "--subscription", "-S" } ) private List subscriptionIds; 

The field is not mandatory, and the application would fetch the charges across all the subscriptions if the parameter is not supplied. However, we can specify multiple subscriptions by using the parameter name multiple times:

$ java App -S subscriptionA001 -S subscriptionA002 -S subscriptionA003 Read Subscriptions: [subscriptionA001, subscriptionA002, subscriptionA003]. 

7.2. Binding Lists Using the Splitter

Instead of specifying the option multiple times, let's try to bind the list by passing a comma-separated String:

$ java App -S subscriptionA001,subscriptionA002,subscriptionA003 Read Subscriptions: [subscriptionA001, subscriptionA002, subscriptionA003]. 

This uses a single parameter value (arity = 1) to represent a list. JCommander will use the class CommaParameterSplitter to bind the comma-separated String to our List.

7.3. Binding Lists Using a Custom Splitter

We can override the default splitter by implementing the IParameterSplitter interface:

class ColonParameterSplitter implements IParameterSplitter { @Override public List split(String value) { return asList(value.split(":")); } }

And then mapping the implementation to the splitter attribute in @Parameter:

@Parameter( names = { "--subscription", "-S" }, splitter = ColonParameterSplitter.class ) private List subscriptionIds; 

Let's try it out:

$ java App -S "subscriptionA001:subscriptionA002:subscriptionA003" Read Subscriptions: [subscriptionA001, subscriptionA002, subscriptionA003]. 

7.4. Variable Arity Lists

Variable arity allows us to declarelists that can take indefinite parameters, up to the next option. We can set the attribute variableArity as true to specify this behavior.

Let's try this to parse subscriptions:

@Parameter( names = { "--subscription", "-S" }, variableArity = true ) private List subscriptionIds; 

And when we run our command:

$ java App -S subscriptionA001 subscriptionA002 subscriptionA003 --itemized Read Subscriptions: [subscriptionA001, subscriptionA002, subscriptionA003]. 

JCommander binds all input arguments following the option “-S” to the list field, until the next option or the end of the command.

7.5. Fixed Arity Lists

So far we've seen unbounded lists, where we can pass as many list items as we wish. Sometimes, we may want to limit the number of items passed to a List field. To do this, we can specify an integer arity value for a List fieldto make it bounded:

@Parameter( names = { "--subscription", "-S" }, arity = 2 ) private List subscriptionIds; 

Fixed arity forces a check on the number of parameters passed to a List option and throws a ParameterException in case of a violation:

$ java App -S subscriptionA001 subscriptionA002 subscriptionA003 Was passed main parameter 'subscriptionA003' but no main parameter was defined in your arg class 

The error message suggests that since JCommander expected only two arguments, it tried to parse the extra input parameter “subscriptionA003” as the next option.

8. Custom Types

We can also bind parameters by writing custom converters. Like built-in converters, custom converters must implement the IStringConverter interface.

Let's write a converter for parsing an ISO8601 timestamp:

class ISO8601TimestampConverter implements IStringConverter { private static final DateTimeFormatter TS_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss"); @Override public Instant convert(String value) { try { return LocalDateTime .parse(value, TS_FORMATTER) .atOffset(ZoneOffset.UTC) .toInstant(); } catch (DateTimeParseException e) { throw new ParameterException("Invalid timestamp"); } } } 

This code will parse the input String and return an Instant, throwing a ParameterException if there's a conversion error. We can use this converter by binding it to a field of type Instant using the converter attribute in @Parameter:

@Parameter( names = { "--timestamp" }, converter = ISO8601TimestampConverter.class ) private Instant timestamp; 

Let's see it in action:

$ java App --timestamp 2019-10-03T10:58:00 Read timestamp: 2019-10-03T10:58:00Z.

9. Validating Parameters

JCommander provides a few default validations:

  • whether required parameters are supplied
  • if the number of parameters specified matches the arity of a field
  • whether each String parameter can be converted into the corresponding field's type

In addition, we may wish to add custom validations. For instance, let's assume that the customer IDs must be UUIDs.

We can write a validator for the customer field that implements the interface IParameterValidator:

class UUIDValidator implements IParameterValidator { private static final String UUID_REGEX = "[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}"; @Override public void validate(String name, String value) throws ParameterException { if (!isValidUUID(value)) { throw new ParameterException( "String parameter " + value + " is not a valid UUID."); } } private boolean isValidUUID(String value) { return Pattern.compile(UUID_REGEX) .matcher(value) .matches(); } } 

Then, we can hook it up with the validateWith attribute of the parameter:

@Parameter( names = { "--customer", "-C" }, validateWith = UUIDValidator.class ) private String customerId; 

If we invoke the command with a non-UUID customer Id, the application exits with a validation failure message:

$ java App --C customer001 String parameter customer001 is not a valid UUID. 

10. Sub-Commands

Now that we've learned about parameter binding, let's pull everything together to build our commands.

In JCommander, we can support multiple commands, called sub-commands, each with a distinct set of options.

10.1. @Parameters Annotation

We can use @Parameters to define sub-commands. @Parameters contains the attribute commandNames to identify a command.

Let's model submit and fetch as sub-commands:

@Parameters( commandNames = { "submit" }, commandDescription = "Submit usage for a given customer and subscription, " + "accepts one usage item" ) class SubmitUsageCommand { //... } @Parameters( commandNames = { "fetch" }, commandDescription = "Fetch charges for a customer in the current month, " + "can be itemized or aggregated" ) class FetchCurrentChargesCommand { //... } 

JCommander uses the attributes in @Parameters to configure the sub-commands, such as:

  • commandNames – name of the sub-command; binds the command-line arguments to the class annotated with @Parameters
  • commandDescription – documents the purpose of the sub-command

10.2. Adding Sub-Commands to JCommander

We add the sub-commands to JCommander with the addCommand method:

SubmitUsageCommand submitUsageCmd = new SubmitUsageCommand(); FetchCurrentChargesCommand fetchChargesCmd = new FetchCurrentChargesCommand(); JCommander jc = JCommander.newBuilder() .addCommand(submitUsageCmd) .addCommand(fetchChargesCmd) .build(); 

The addCommand method registers the sub-commands with their respective names as specified in the commandNames attribute of @Parameters annotation.

10.3. Parsing Sub-Commands

To access the user's choice of command, we must first parse the arguments:

jc.parse(args); 

Next, we can extract the sub-command with getParsedCommand:

String parsedCmdStr = jc.getParsedCommand(); 

In addition to identifying the command, JCommander binds the rest of the command-line parameters to their fields in the sub-command. Now, we just have to call the command we want to use:

switch (parsedCmdStr) { case "submit": submitUsageCmd.submit(); break; case "fetch": fetchChargesCmd.fetch(); break; default: System.err.println("Invalid command: " + parsedCmdStr); } 

11. JCommander Usage Help

We can invoke usage to render a usage guide. This is a summary of all the options that our application consumes. In our application, we can invoke usage on the main command, or alternatively, on each of the two commands “submit” and “fetch” separately.

A usage display can help us in a couple of ways: showing help options and during error handling.

11.1. Showing Help Options

We can bind a help option in our commands using a boolean parameter along with the attribute help set to true:

@Parameter(names = "--help", help = true) private boolean help; 

Then, we can detect if “–help” has been passed in the arguments, and call usage:

if (cmd.help) { jc.usage(); } 

Let's see the help output for our “submit” sub-command:

$ java App submit --help Usage: submit [options] Options: * --customer, -C Id of the Customer who's using the services * --subscription, -S Id of the Subscription that was purchased * --quantity Used quantity; reported quantity is added over the billing period * --pricing-type, -P Pricing type of the usage reported (values: [PRE_RATED, UNRATED]) * --timestamp Timestamp of the usage event, must lie in the current billing period --price If PRE_RATED, unit price to be applied per unit of usage quantity reported 

The usage method uses the @Parameter attributes such as description to display a helpful summary. Parameters marked with an asterisk (*) are mandatory.

11.2. Error Handling

Possiamo rilevare ParameterException e chiamare l' utilizzo per aiutare l'utente a capire perché il loro input non era corretto. ParameterException contiene l' istanza JCommander per visualizzare la guida:

try { jc.parse(args); } catch (ParameterException e) { System.err.println(e.getLocalizedMessage()); jc.usage(); } 

12. Conclusione

In questo tutorial, abbiamo utilizzato JCommander per creare un'applicazione a riga di comando. Sebbene abbiamo coperto molte delle principali funzionalità, c'è di più nella documentazione ufficiale.

Come al solito, il codice sorgente di tutti gli esempi è disponibile su GitHub.