我們都知道,軟體的使用者介面無非分為 GUI (圖形使用者介面)和 CLI (命令列使用者介面)。對於我們經常使用 Linux 的人來說,命令列介面一定非常熟悉。無論是 Shell 裡輸入命令的介面,還是如 GDB 等軟體的內部互動介面,都是命令列介面。而當我們開發自己的軟體,要寫認真寫一個 CLI 的時候,卻發現要手寫做出一個好用的命令列介面其實非常困難。因為一個好的命令列介面,在輸入/輸出之外,還要支援一些常見的命令列功能。
對我而言,一個合格的命令列軟體介面應該支援這三個功能:
- 自動補全:當按下 TAB 鍵時,在當前游標處進行內容補全。根據上下文資訊,補全可能是對命令的補全,也可能是對檔案路徑的補全。
- 命令歷史:當按上/下方向鍵時,可以顯示上一條/下一條命令。
- 行編輯 (line editing):可以使用 Emacs 快捷鍵進行行內的編輯功能,例如 Ctrl+A 移動游標至行首,Ctrl+E 移動游標至行尾。
熟悉 Linux 的人會發現,上面這三個功能都是 GNU Readline 的功能。我們不需要在軟體中手寫這幾個功能,只要用這樣一個庫就可以了。實際上,GNU/Linux 中使用 GNU Readline 庫的軟體非常多,這使得 GNU Readline 同時也成為了一個事實上的命令列互動標準。GNU Readline 是 C 語言的庫。我們用其他語言的時候,就要找對應功能的庫(這往往是封裝了底層的 GNU Readline 的庫)。對 Java 語言來說,JLine 就是這樣一個幫助你搭建一個命令列互動介面的庫。
本文是想通過一個例子介紹 JLine3 的基本用法。JLine3 並沒有一個 "Hello, world!" 的例子,它的 wiki 也寫得非常簡略。雖然有一個示例的程式 Example.java,但這個示例比較複雜,難以理解。希望本文的內容能對你理解 JLine3 的用法有所幫助。
基本框架
我們嘗試為軟體 Fog 設計一個命令列使用者介面。使用者可以輸入四種命令:
CREATE [FILE_NAME]
OPEN [FILE_NAME] AS [FILE_VAR]
WRITE TIME|DATE|LOCATION TO [FILE_VAR]
CLOSE [FILE_VAR]
複製程式碼
下面我們將一步步地寫出 Fog 軟體的命令列介面。首先,用 JLine3 搭建一個最基礎的 REPL (Read-Eval-Print Loop) 框架:
Terminal terminal = TerminalBuilder.builder()
.system(true)
.build();
LineReader lineReader = LineReaderBuilder.builder()
.terminal(terminal)
.build();
String prompt = "fog> ";
while (true) {
String line;
try {
line = lineReader.readLine(prompt);
System.out.println(line);
} catch (UserInterruptException e) {
// Do nothing
} catch (EndOfFileException e) {
System.out.println("\nBye.");
return;
}
}
複製程式碼
這裡除了設定命令提示符 (prompt),沒有進行任何特殊的設定。命令列會將使用者輸入的一行原樣列印出來。當使用者輸入 Ctrl+D (End of line) 時,程式會退出。
即使我們只寫了一個框架,但此時程式已經擁有了 JLine3 預設提供的命令歷史和行編輯功能。此時按上/下方向鍵時,會顯示上一條/下一條命令,也可以使用 Ctrl+A、Ctrl+E 等 Emacs 快捷鍵進行行內編輯。
命令補全
簡單補全與複合補全
由於命令補全和程式的命令格式密切相關,所以我們必須自己定義補全的方式。根據 wiki 中所寫,JLine3 中定義命令補全的方式是:建立一個 Completer
類的例項,將其傳入 LineReader
。JLine3 內建了多個 completer,其中最常見的是 FileNameCompleter
(補全檔名)和 StringsCompleter
(根據預定義的幾個字串進行補全,用於命令名或引數名)。例如,Fog 程式的四個命令分別以 CREATE, OPEN, WRITE, CLOSE 開頭,那麼我們可以使用一個 StringsCompleter
來對命令的第一個單詞進行補全:
Completer commandCompleter = new StringsCompleter("CREATE", "OPEN", "WRITE", "CLOSE");
LineReader lineReader = LineReaderBuilder.builder()
.terminal(terminal)
.completer(commandCompleter)
.build();
複製程式碼
然而,這種補全方式只能支援每個命令的第一個單詞,我們想要在命令的各種可能的地方都進行補全該怎麼辦呢?這時候就需要將 completer 進行組合,形成 複合 completer 。一般情況下,StringsCompleter
這樣的 簡單 completer 只能負責一個單詞的補全,而要想實現整條命令的補全,就需要將幾個不同的 completer 組合起來使用。ArgumentCompleter
就是用來補全整條命令的複合 completer。它可以將若干個 completer 組合在一起,每個 completer 負責補全命令中的第 i 個單詞。以 CREATE 命令為例,這條命令共有兩個單詞,第一個單詞需要字串補全,第二個單詞需要檔名補全。於是我們使用 ArgumentCompleter
將 StringsCompleter
和 FileNameCompleter
組合起來:
Completer createCompleter = new ArgumentCompleter(
new StringsCompleter("CREATE"),
new Completers.FileNameCompleter()
);
LineReader lineReader = LineReaderBuilder.builder()
.terminal(terminal)
.completer(createCompleter)
.build();
複製程式碼
根據 ArgumentCompleter
的兩個引數,在輸入第一個單詞的時候會補全 CREATE,輸入第二個單詞的時候會補全檔名。但實測時會發現一個問題:當你已經輸入了 CREATE 和檔名後,再試圖進行補全,在第三個單詞處試圖補全,還是會出現檔名的補全。這是因為,ArgumentCompleter
在你已經“用完了”所有的 completers 之後(即第三個單詞開始),會預設使用最後一個 completer。這並不是我們想要的效果。為了解決這個問題,我們可以在最後新增一個 NullCompleter
:
Completer createCompleter = new ArgumentCompleter(
new StringsCompleter("CREATE"),
new Completers.FileNameCompleter(),
NullCompleter.INSTANCE
);
LineReader lineReader = LineReaderBuilder.builder()
.terminal(terminal)
.completer(createCompleter)
.build();
複製程式碼
NullCompleter
即不進行任何補全。這樣,從第三個單詞開始,都不會進行任何多餘的補全。
類似地,我們再加入 OPEN 命令補全的定義:
Completer createCompleter = new ArgumentCompleter(
new StringsCompleter("CREATE"),
new Completers.FileNameCompleter(),
NullCompleter.INSTANCE
);
Completer openCompleter = new ArgumentCompleter(
new StringsCompleter("OPEN"),
new Completers.FileNameCompleter(),
new StringsCompleter("AS"),
NullCompleter.INSTANCE
);
Completer fogCompleter = new AggregateCompleter(
createCompleter,
openCompleter
);
LineReader lineReader = LineReaderBuilder.builder()
.terminal(terminal)
.completer(fogCompleter)
.build();
複製程式碼
這裡有兩點需要注意的地方:
- CREATE 命令和 OPEN 命令分別定義了 completer,再用
AggregateCompleter
組合起來。AggregateCompleter
是另一種複合 completer,將多種可能的補全方式組合到了一起。打比方來說,ArgumentCompleter
相當於串聯電路,而AggregateCompleter
相當於並聯電路。 - OPEN 命令的
ArgumentCompleter
中只定義了前三個單詞的補全方式。這是因為第四個單詞是使用者定義了檔案變數,使用者可能輸入任何的名字,因此無法進行補全。
動態補全
WRITE 命令的補全與前兩個稍有不同。根據程式語義,只有使用者在 OPEN 命令中定義了的檔案變數才能在 WRITE 命令中使用。那麼,在補全的時候也應該考慮這一點。我們需要在執行時動態地調整補全候選詞:每當使用者使用 OPEN 命令開啟一個檔案後,都調整 completer,將新的檔案變數納入補全候選詞。我們需要知道如何動態地修改 completer。雖然 completer 的建立和傳遞給 LineReader
的過程是靜態的,但在程式執行時,是通過呼叫 Completer.complete()
來獲取補全的候選詞的。那麼,我們可以繼承 Completer
並重寫 complete()
方法來實現動態的候選詞調整。
public class FileVarsCompleter implements Completer {
Completer completer;
public FileVarsCompleter() {
this.completer = new StringsCompleter();
}
@Override
public void complete(LineReader reader, ParsedLine line, List<Candidate> candidates) {
completer.complete(reader, line, candidates);
}
public void setFileVars(List<String> fileVars) {
this.completer = new StringsCompleter(fileVars);
}
}
複製程式碼
當呼叫 setFileVars()
時,會重新建立一個新的 StringsCompleter
,從而擴充候選詞。而在 REPL 中,只需要在使用者輸入 OPEN 命令後,呼叫 setFileVars()
即可。
public class Fog {
private static List<String> fileVars = new ArrayList<>();
private static FileVarsCompleter fileVarsCompleter = new FileVarsCompleter();
public static void main(String[] args) throws IOException {
// ...
Completer writeCompleter = new ArgumentCompleter(
new StringsCompleter("WRITE"),
new StringsCompleter("TIME", "DATE", "LOCATION"),
new StringsCompleter("TO"),
fileVarsCompleter,
NullCompleter.INSTANCE
);
Completer fogCompleter = new AggregateCompleter(
createCompleter,
openCompleter,
writeCompleter
);
// ...
String prompt = "fog> ";
while (true) {
String line;
try {
line = lineReader.readLine(prompt);
System.out.println(line);
if (line.startsWith("OPEN")) {
fileVars.add(line.split(" ")[3]);
fileVarsCompleter.setFileVars(fileVars);
}
} catch (UserInterruptException e) {
// Do nothing
} catch (EndOfFileException e) {
System.out.println("\nBye.");
return;
}
}
}
}
複製程式碼
命令歷史
前面已經過說,在預設情況下,JLine3 已經支援命令歷史查詢。不過我們想加上一個特殊的功能:使用者輸入的註釋(以 # 開頭)不會進入命令歷史,從而在命令歷史查詢時不受註釋內容的干擾。
JLine3 中,History
負責控制歷史記錄的行為,其預設實現為 DefaultHistory
。檢視原始碼,我們發現 add()
方法是其核心行為。使用者輸入的一行命令,會通過 add()
方法加入命令歷史中。
@Override
public void add(Instant time, String line) {
Objects.requireNonNull(time);
Objects.requireNonNull(line);
if (getBoolean(reader, LineReader.DISABLE_HISTORY, false)) {
return;
}
// ...
internalAdd(time, line);
// ...
}
複製程式碼
同樣地,我們可以通過繼承並重寫 add()
方法,將註釋內容過濾掉,不加入命令歷史:
public final class FogHistory extends DefaultHistory {
private static boolean isComment(String line) {
return line.startsWith("#");
}
@Override
public void add(Instant time, String line) {
if (isComment(line)) {
return;
}
super.add(time, line);
}
}
複製程式碼
然後我們這樣設定 LineReader
:
LineReader lineReader = LineReaderBuilder.builder()
.terminal(terminal)
.completer(fogCompleter)
.history(new FogHistory())
.build();
複製程式碼
總結
我們發現,JLine3 的各個功能設計得比較清晰,有其對應的介面和預設實現。如果我們想自定義一些特性,一般通過繼承並重寫的方式可以做到。JLine3 的原始碼也比較容易理解,遇到困難時,可以自己閱讀原始碼來尋找線索。
本文中示例程式的完整程式碼參見 jline3-demo。