咱们都知道,软件的用户界面无非分为 GUI (图形用户界面)和 CLI (命令行用户界面)。对于咱们常用 Linux 的人来讲,命令行界面必定很是熟悉。不管是 Shell 里输入命令的界面,仍是如 GDB 等软件的内部交互界面,都是命令行界面。而当咱们开发本身的软件,要写认真写一个 CLI 的时候,却发现要手写作出一个好用的命令行界面其实很是困难。由于一个好的命令行界面,在输入/输出以外,还要支持一些常见的命令行功能。java
对我而言,一个合格的命令行软件界面应该支持这三个功能:git
熟悉 Linux 的人会发现,上面这三个功能都是 GNU Readline 的功能。咱们不须要在软件中手写这几个功能,只要用这样一个库就能够了。实际上,GNU/Linux 中使用 GNU Readline 库的软件很是多,这使得 GNU Readline 同时也成为了一个事实上的命令行交互标准。GNU Readline 是 C 语言的库。咱们用其余语言的时候,就要找对应功能的库(这每每是封装了底层的 GNU Readline 的库)。对 Java 语言来讲, JLine 就是这样一个帮助你搭建一个命令行交互界面的库。github
本文是想经过一个例子介绍 JLine3 的基本用法。JLine3 并无一个 “Hello, world!” 的例子,它的 wiki 也写得很是简略。虽然有一个示例的程序 Example.java ,但这个示例比较复杂,难以理解。但愿本文的内容能对你理解 JLine3 的用法有所帮助。框架
咱们尝试为软件 Fog 设计一个命令行用户界面。用户能够输入四种命令:ide
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) 框架:oop
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) 时,程序会退出。ui
即便咱们只写了一个框架,但此时程序已经拥有了 JLine3 默认提供的命令历史和行编辑功能。此时按上/下方向键时,会显示上一条/下一条命令,也可使用 Ctrl+A、Ctrl+E 等 Emacs 快捷键进行行内编辑。this
因为命令补全和程序的命令格式密切相关,因此咱们必须本身定义补全的方式。根据 wiki 中所写,JLine3 中定义命令补全的方式是:建立一个 Completer
类的实例,将其传入 LineReader
。JLine3 内置了多个 completer,其中最多见的是 FileNameCompleter
(补全文件名)和 StringsCompleter
(根据预约义的几个字符串进行补全,用于命令名或参数名)。例如,Fog 程序的四个命令分别以 CREATE, OPEN, WRITE, CLOSE 开头,那么咱们可使用一个 StringsCompleter
来对命令的第一个单词进行补全:spa
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();
这里有两点须要注意的地方:
AggregateCompleter
组合起来。 AggregateCompleter
是另外一种复合 completer,将多种可能的补全方式组合到了一块儿。打比方来讲, ArgumentCompleter
至关于串联电路,而 AggregateCompleter
至关于并联电路。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 的源代码也比较容易理解,遇到困难时,能够本身阅读源代码来寻找线索。