over 2 years ago

Translated from "JCommander: A Better Way to Parse Command Lines - An easy-to-use library that exploits annotations to parse the most-complex command lines" by CÉDRIC BEUST, Java Magazine, November/December 2015, page 13. Copyright Oracle Corporation.

JCommander:解析命令列有更好的方法

利用annotations解析複雜命令列的簡易函式庫

幾年前,我發現我須寫一個能在命令列中使用的應用程式,那是極具挑戰的專案,須解析複雜的命令列參數。因此,很自然地,我第一個直覺是尋找是否有函式庫能讓我描述應用程式所需的命令列語法又保持彈性。就在我找到一些符合目的的函式庫時,它們全因太過老舊讓我深受打擊,它們使用甚至比Java更早之前的概念與做法,此外,它們並沒有善用Java新功能的優點。

因此,我開始嘗試一些想法,然後我知道我要做的下一件事是完全拋棄我的初始想法,取而代之,我創建了JCommander:一個能輕易分析命令列參數的現代化開放原碼函式庫,並盡可能涵蓋各種風格的參數語法。不限於字串,參數不僅可以是數字或命令,也可以是lists、密碼或任何Java物件。讓我們開始瞧瞧吧!

快速總覽

設計JCommander時,我完成的第一個實現是當所有命令列上的選項都被解析後,結果最終會是一個Java物件。一般來說,結果是一個非常單純的物件,或稱作POJO (Plain Old Java Object),通常是一個沒有任何邏輯函式的容器,只有成員變數(fields)與對應的getters和setters。

我們開始迅速寫一個可以解析如下命令列的“hello world”程式:

tool --name Cedric --verbose

下列的類別可以捕捉解析後的資訊:

class Args {
    boolean verbose;
    String name;
}

為了做到這點,我們需要用告訴JCommander如何初始化此類別的物件。

class Args {
    @Parameter(names = "--verbose")
    boolean verbose;

    @Parameter(names = "--name")
    String name;
}

現在,我們只需以這類別物件建立並初始化JCommander,接著將命令列參數交由JCommander解析,當解析完成,所有解析的結果會被正確地指派給該物件中對應的成員變數:

public static void main(String[] argv) {
    Args args = new Args();
    new JCommander(args).parse(argv);
    System.out.println("Hello " + args.name + ", verbose is: " + args.verbose)
}

幾個原因讓使用annotation是非常恰當的方式:語法能相當清晰地標註在參數類別中,且閱讀原始碼時就能明白程式可以接受的參數,還有其他幾項優勢我很快就會解釋。

Annotation的威力

JCommander的方式中最顯著的方面是使用annotation,我是Java annotation的愛用者,但或許我有點偏袒它,因為我是設計它的委員會成員之一。甚至十年後的今日,我仍認為它開啟Java語言更具表達力的一種撰寫風格。

記住一個重點,annotation是一種十分合適用在替類別、成員變數或函式等Java元素添加額外意義的方式。任何無法明確與Java元素聯繫的資訊像package資訊、主機名稱或連接埠名稱都應外部描述,當有這一個簡單的規則,JCommander使用annotation很明顯是個正確的選擇。

此外,annotation可擁有多項屬性,讓您精煉添加到Java元素的中介資料(metadata),剛剛的範例程式中,我只使用一個屬性names,但還可以給予其他幾個屬性:

@Parameter(names = { "--output", "-o" }, required = true, description = "The output file")
String file;

由於我指定required屬性,所以當該參數被省略時,JCommander會拋出例外表示錯誤:

Exception in thread "main" com.beust.jcommander.ParameterException:
The following option is required:
--output

注意到names是複數型:您可以指定多個名字,這也表示以下二個命令列效果是相同的:

tool --out file
tool -o file

這項能力解決了使用者描述命令列選項不同風格偏好的常見問題。

JCommander的方法中最顯著的方面是使用annotation,它開啟Java語言更具表達力的一種撰寫風格。

使用說明書

我剛提到description屬性,因為JCommander會特別對待它:不論哪個參數有此屬性,它都會被自動收集並用來呈現完整的語法描述,如果您曾想在使用者打錯指令時顯示些訊息幫助他們,您只需要呼叫JCommander的usage()就可以看到下列資訊被顯示在畫面上:

Usage: <main class> [options]
  Options:
    --debug Debug mode (default: false)
  * --groups Group names to be run
    --log, -verbose Level of verbosity (default: 1)
    --long A long number (default: 0)

這描述幾乎包含所有JCommander能從選項的annotation中收集到的所有語法資訊:選項名稱、選項是否為必填(*符號表示為必填)、選項的預設值,當然還有他們的說明。這功能同時表達另一個寫程式的重要原則:不做重複的事 (don’t repeat yourself),如果您在參數類別中描述語法,您不應該為了顯示協助訊息再做一次相同的事,JCommander自動替您留意這件事。

型別

JCommander預設能處理許多型別,所有型別都被帶向元數(arity)的概念,元數定義一個參數需要幾個值,例如:

  • 一個布林(boolean)參數不需要任何值:當它呈現時即為true,若被忽略就是false
  • 一個純量參數(int, long, string, and so on)需要一個數值,就像--logLevel 3
  • 一個list參數需要多個值

JCommander能根據參數的型別自動推論元數的型別,此外,您若不滿意預設的元數,您可以自己定義,這允許使用其他形式的語法,例如,您可以定義一個元數為1的布林參數,因此可支援類似tool --verbose true的語法。JCommander也支援可變的元數,所以一個參數可以接受多個值,例如:--files file1 file2 file3

有時候,預設型別是不足夠的,您的應用程式需要更複雜的選項。例如,我先前用字串型別描述輸出檔案,如果JCommander能提供一個真正的java.io.File物件而不是一個字串,這不是不方便嗎?這是型別轉換器方便的地方。我們開始修改先前的例子,用真的Java File取代字串:

@Parameter(names = "-file", converter = FileConverter.class)
File file;

要注意多一個額外的converter屬性,我們需要實作它

public class FileConverter implements IStringConverter<File> {

    @Override
    public File convert(String value) {
        return new File(value);
    }
}

您可以指定任何轉換器,JCommander根據屬性自動使用對應的轉換器,就是這麼簡單。

JCommander可以支援複雜的程式與語法風格,許多功能協助您組織出簡潔的程式。

語法的彈性

為了盡可能支援許多種語法風格,JCommander允許您使用空白以外的分隔符號,例如可以透過指定separators屬性,用java Main -log=3java Main -log:3取代java Main -log 3

@Parameters(separators = "=")
public class SeparatorEqual {
    @Parameter(names = "-level")
    private Integer level = 2;
}

驗證

當您的命令漸漸變得複雜,判斷一個給定的命令列是否是有效會變得難以處理,因為多個選項會以各式各樣的方式與其他選項互相影響。JCommander可以提供一些協助讓驗證參數變簡單,語法相當接近剛剛提到的轉換器:

@Parameter(names = "-age", validateWith = PositiveInteger.class)
private Integer age;

下列程式一個驗證器的實作:

public class PositiveInteger implements IParameterValidator {
    public void validate(String name, String value) throws ParameterException {
        int n = Integer.parseInt(value);
        if (n < 0) {
            throw new ParameterException("Parameter " + name + " should be positive" + (found " + value +")");
        }
    }
}

複雜的命令

您可能很習慣一些工具使用子命令表達複雜的調用語法,例如,當子命令可以擁有自己的語法時,git提供如此類型的語法:當git add能接受-i參數,您也可以在呼叫git commit時,指定如--author–amend等參數,用JCommander能輕易實現這樣的語法。

首先,您定義您的命令類別,下面範例是commit命令:

@Parameters(separators = "=", commandDescription = "Record changes")
private class CommandCommit {

    @Parameter(description = "The list of files")
    private List<String> files;

    @Parameter(names = "--amend", description = "Amend")
    private Boolean amend = false;

    @Parameter(names = "--author")
    private String author;
}

接著是add命令:

@Parameters(commandDescription = "Add file to the index")
public class CommandAdd {

    @Parameter(description = "File patterns for the index")
    private List<String> patterns;

    @Parameter(names = "-i")
    private Boolean interactive = false;
}

然後,您將這些命令加入JCommaner,在下列程式碼中,我在結尾加了一些驗證來展示這些命令如何運作:

JCommander jc = new JCommander();
CommandAdd add = new CommandAdd();
jc.addCommand("add", add);
CommandCommit commit = new CommandCommit();
jc.addCommand("commit", commit);
jc.parse("-v", "commit", "--amend", "--author=cbeust", "A.java", "B.java");
Assert.assertTrue(cm.verbose);
Assert.assertEquals(jc.getParsedCommand(), "commit");
Assert.assertTrue(commit.amend);
Assert.assertEquals(commit.author, "cbeust");
Assert.assertEquals(commit.files, Arrays.asList("A.java", "B.java"));

如您從驗證中所見,程式正確解析命令列並將參數放到預期的變數中。

架構

JCommander可以支援複雜的程式與語法風格,許多功能協助您組織出簡潔的程式。多參數物件,隨著您的語法演進,您可能自己發現有一個巨大且有點難以維護的參數類別,JCommander能讓您將這個類別拆開成多個類別,因此您可以用直覺的方式組織這些選項:

CommandRead argRead = new CommandRead();
CommandWrite argWrite = new CommandWrite()
JCommander jc = new JCommander(argRead, argWrite);
jc.parse(argv);
// argRead and argWrite are now both initialized

參數代理人,您自己可能發現想要重複利用既有的參數類別,您可以使用參數代理人處理這情況,簡言之,參數代理人是一個可以指向其他類別的指標。在前面的例子,我決定建立二個不同的參數類別並直接宣告在JCommander中,但相反地,我可能想委託給它們,這可以用@ParameterDelegate annotation達成:

class MainParams {

    @Parameter(names = "-v")
    private boolean verbose;

    @ParametersDelegate
    private ArgRead argRead = new ArgRead();

    @ParametersDelegate
    private ArgWrite argWrite = new ArgWrite();
}

如此宣告,我需要宣告一個MainParams參數類別,它會包含並聚合ArgReadArgWrite兩者。

支援多種多語言

感謝JVM能支援多種語言的能力,其他JVM支援的語言也能使用JCommander,我正在Kotlin專案中使用它:

class Args {

    @Parameter(names = arrayOf("--buildFile"))
    var buildFile: String? = null

    @Parameter(names = arrayOf("--tasks"))
    var tasks: Boolean = false
}

fun main(argv: Array<String>) {
    val args = Args()
    JCommander(args).parse(*argv)
    println("Args: ${args}"")
}

這是另一個Groovy的例子:

import com.beust.jcommander.*
class Args {
  @Parameter(names = ["-f", "--file"], description = "File to load.")
  List<String> file
}
new Args().with {
  new JCommander(it, args)
  file.each {
    println "file: ${new File(it).name}"
  }
}

然後這是使用Scala的相同例子:

import java.io.File
import com.beust.jcommander.JCommander
import com.beust.jcommander.Parameter
import collection.JavaConversions._

object Main {
  object Args {
    @Parameter(names = Array("-f", "--file"), description = "File to load.")
    var file: java.util.List[String] = null
  }

  def main(args: Array[String]): Unit = {
    new JCommander(Args, args.toArray: _*)
    for (filename <- Args.file) {
      val f = new File(filename)
      printf("file: %s\n", f.getName)
    }
  }
}

結論

JCommander還有其他許多功能,包含:

  • 國際化,支援在地化的描述文字
  • 參數隱藏
  • 允許選項縮寫
  • 可選擇是否忽略大小寫
  • 預設值與預設值工廠
  • 動態參數 (能解析在編譯時還不知道的參數)

整體來說,JCommander是一個彈性的命令列解析函式庫,協助讓您的解析程式易於維護與演進。

LEARN MORE
JCommander on GitHub
JCommander discussion group
JCommander example file

譯者的告白
翻譯時常在想:翻譯時能帶入譯者的風格嗎?像是標題「JCommander: A Better Way to Parse Command Lines」,是一個很單純的名詞說明,可以翻成「JCommander:一個解析命令列更好的方法」,或是「JCommander:解析命令列有更好的方法」,我知道就精確性來說前者較佳,但我選擇後者,因為比較像中文。又例如,文章第二段結尾「Let’s have a look.」,我想了一下,決定用比較俏皮一點的翻譯「讓我們開始瞧瞧吧!」,讀者覺得呢?

← 使用TestFX測試JavaFX應用程式 用Byte Buddy於執行期生成程式碼 →