🌸 Java CLI设计(二)——picocli(下)

在软件构造Lab3中,我尝试用picocli搭建了一个命令行交互客户端(Command-Line Interface,CLI)。不同于Common CLI,picocli的设计更为现代化,功能更为强大,文档也十分丰富。

上篇中主要介绍CLI应用的基本特征、基本概念和picocli的基本用法等内容。下篇将讲述利用picocli开发面向Lab3需求的实际应用的过程。

可执行命令

第一步是解析命令行参数。一个健壮的实际应用程序需要处理许多场景:

picocli 4.0引入了execute方法,可以在一行代码中处理上述所有场景。例如:

new CommandLine(new MyApp()).execute(args);

execute方法,可以用相当紧凑的应用代码运行程序:

@Command(name = "myapp", mixinStandardHelpOptions = true, version = "1.0")
class MyApp implements Callable<Integer> {

    @Option(names = "-x") int x;

    @Override
    public Integer call() { // business logic
        System.out.printf("x=%s%n", x);
        return 123; // exit code
    }

    public static void main(String... args) { // bootstrap the application
        System.exit(new CommandLine(new MyApp()).execute(args));
    }
}

注意到MyApp类实现了Callable接口,并重写了call方法。要通过execute方法运行的命令类,必须实现Callable(或Runable)接口。用execute方法运行命令时,就是用运行其call方法(或run方法)。

Root

Lab3中需要实现三个应用。因此,在整个程序的入口处(即在shell中用java -jar命令运行的)应该让用户「选择运行哪个应用」。我把这项功能放在Root类中。

Root接受三个不同的选项,分别代表三个应用。用户从三个应用中选择一个。

@Command(name = "Lab3-1183710106")
public class Root implements Callable<Integer> {

    @ArgGroup(exclusive = true, multiplicity = "1")
    Exclusive exclusive;

    // 三个应用选项互相冲突
    static class Exclusive {
        // 航班应用
        @Option(names = {"-f", "--fsa"},
                paramLabel = "FLIGHT",
                description = "run FlightScheduleApp.")
        private boolean flight;

        // 高铁车次应用
        @Option(names = {"-t", "--tsa"},
                paramLabel = "TRAIN",
                description = "run TrainScheduleApp.")
        private boolean train;

        // 学习活动应用
        @Option(names = {"-a", "--aca"},
                paramLabel = "ACTIVITY",
                description = "run ActivityCalendarApp.")
        private boolean activity;
    }

    @Option(names = {"-h", "--help"}, usageHelp = true, description = "display this help message.")
    private boolean helpRequested = false;

    @Override
    public Integer call() throws Exception {
        // call方法中,根据用户的选择分别运行不同应用的main方法
        if (exclusive.flight) {
            System.out.println("Executing Flight-Schedule-App...");
            FlightScheduleApp.main(null);
        }
        else if (exclusive.train) {
            System.out.println("Executing Train-Schedule-App...");
            TrainScheduleApp.main(null);
        }
        else if (exclusive.activity) {
            System.out.println("Executing Activity-Calender-App...");
            ActivityCalenderApp.main(null);
        }
        return 0;
    }

    public static void main(String[] args) {
        // 如果参数列表为空,则显示帮助信息
        if (args.length == 0) {
            new CommandLine(new Root()).execute("-h");
        }
        else {
            new CommandLine(new Root()).execute(args);
        }
    }
}

这样,在shell中,只需输入指定的选项,就能选择应用运行了。例如,输入命令

$ java -jar Lab3-1183710106 -fsa

就能运行航班管理应用了。

三个实际应用类

通用设计

与一般CLI应用不同的是,Lab3的使用场景不是执行「单行命令式的任务」,而是需要用户连续地输入命令。因此,在各个App的main方法中,需要用while循环不断地接受用户输入,手动地把输入字符串分离成参数列表,然后扔给execute方法运行。这部分代码如下:

public static void runCliApp(Scanner scanner, CommandLine cmd, String reminder) {
    // 注册构造器
    cmd.registerConverter(Timeslot.class, Timeslot::new);
    cmd.registerConverter(EntryLabel.class, EntryLabel::new);
    cmd.registerConverter(TimeDate.class, TimeDate::new);
    // 指定运行策略:只运行最后一个命令
    cmd.setExecutionStrategy(new CommandLine.RunLast());
	// 当有下一行时,不断读取下一行输入
    while (scanner.hasNextLine()) {
        // 从scanner读取下一行
        String line = scanner.nextLine().trim();
        // 如果本行为空,忽略
        if (line.length() == 0) {
            System.out.print(reminder);
            continue;
        }
        // 用String.split方法分离参数列表,分隔符为空格和制表符\t
        String[] arguments = line.split("(( +)|(\t+))+");
        // 执行命令
        cmd.execute(arguments);

        // 每行命令执行结束后暂停100毫秒,以免出现输入输出混乱
        try {
            TimeUnit.MILLISECONDS.sleep(100);
        }
        catch (InterruptedException e) {
            System.exit(1);
        }
        // 输出提示符,如" > fsa "(fsa是FlightScheduleApp的缩写)
        System.out.print(reminder);
    }
}

在具体应用中,为了存储信息,需要在主命令类(如FlightScheduleApp)中设置一个面向应用的PlanningEntryCollection成员(如FlightEntryCollection)。例如,FlightScheduleApp的代码可以这样编写:

// 标明命令名和子命令
@Command(name = "fsa", subcommands = {
        AddEntry.class,
    	ReadFile.class,
        ManagePlane.class,
        ManageAirport.class,
        Allocate.class,
        Cancel.class,
        Run.class,
        End.class,
        Show.class
})
public class FlightScheduleApp {

    // PlanningEntryCollection成员
    private final FlightEntryCollection collection = new FlightEntryCollection();
    
    // 提示符
    private final static String reminder = " > fsa ";


    @Option(names = {"-h", "--help"}, usageHelp = true, description = "display this help message.")
    private boolean helpRequested = false;

    // protected权限的getter方法,子命令可以通过它获取collection
    protected FlightEntryCollection getCollection() {
        return collection;
    }

    public static void main(String[] args) {
        System.out.print(reminder);
        Scanner scanner = new Scanner(System.in);
        CommandLine cmd = new CommandLine(new FlightScheduleApp());
        cmd.registerConverter(FlightNumber.class, FlightNumber::new);
        // 调用runCliApp方法运行应用
        runCliApp(scanner, cmd, reminder);
    }

}

而子命令则需要用@ParentCommand标识指明父命令类,以获得其信息。例如,AddEntry命令的框架是这样的:

@Command(name = "add-entry", aliases = "ae", description = "Add flight planning entries.")
public class AddEntry implements Callable<Integer> {

    // 指明父命令
    @ParentCommand
    private FlightScheduleApp app;

    ...

    @Option(names = {"-h", "--help"}, usageHelp = true, description = "display this help message.")
    private boolean helpRequested = false;

    @Override
    public Integer call() {
        // 获取父命令类实例的FlightEntryCollection
        FlightEntryCollection collection = app.getCollection();
        try {
            ...
            // 执行命令动作
        }
        catch (IllegalArgumentException e) {
            ...
            // 捕获异常,异常处理
        }
        
        return 0;
    }
}

但这就出现一个问题:前一次执行命令时存入子命令类中的数据是不会清空的,而是直接带入后一次命令的执行中。因此,更好的方法是,把PlanningEntryCollection中的数据用一个临时变量储存和更新,每次执行命令前,用前一次的PlanningEntryCollection新建CommandLine类,命令执行完后,再更新到PlanningEntryCollection中。改进后的FlightScheduleAppmain方法如下所示:

public static void main(String[] args) {
    System.out.print(reminder);
    Scanner scanner = new Scanner(System.in);
    // FlightEntryCollection临时变量
    FlightEntryCollection collection = new FlightEntryCollection();
    while (scanner.hasNextLine()) {
        String line = scanner.nextLine().trim();
        if (line.length() == 0) {
            System.out.print(reminder);
            continue;
        }
        String[] arguments = line.split("(( +)|(\t+))+");
        
        // 用前一次FlightEntryCollection新建FlightScheduleApp和对应的CommandLine
        FlightScheduleApp app = new FlightScheduleApp(collection);
        CommandLine cmd = new CommandLine(app);
        
        // 注册自动构造器,设置运行策略
        cmd.registerConverter(Timeslot.class, Timeslot::new);
        cmd.registerConverter(EntryLabel.class, EntryLabel::new);
        cmd.registerConverter(TimeDate.class, TimeDate::new);
        cmd.registerConverter(FlightNumber.class, FlightNumber::new);
        cmd.setExecutionStrategy(new CommandLine.RunLast());

        // 运行命令
        cmd.execute(arguments);
        // 更新FlightEntryCollection数据
        collection = app.getCollection();

        try {
            TimeUnit.MILLISECONDS.sleep(100);
        }
        catch (InterruptedException e) {
            System.exit(1);
        }
        System.out.print(reminder);
    }
}

明确了上述框架之后,三个实际应用只需要往里面塞入具体的内容即可。

航班管理应用FlightScheduleApp

我为航班管理应用FlightScheduleApp(简写为fsa)设置了9个子命令:

子命令类名 子命令 功能
AddEntry add-entry, ae 添加计划项
ReadFile read-file, rf 从文件读入航班计划项
ManageAirport manage-airport, ma 管理机场(增加、删除)
ManagePlane manage-plane, mp 管理飞机(增加、删除)
Allocate allocate, al 为某计划项分配飞机资源
Run depart 某一航班计划项从始发机场起飞(master
某一航班计划项从当前机场起飞(314change
End arrivemaster 某一航班抵达到达机场(master
Cancel cancel 取消某一航班
Block arrive314change 某一航班抵达下一机场(314change
Show show, s 展示当前计划项集合信息

需要注意的几点:

下面提供一组测试示例(master分支,#开头的是注释行,请勿输入):

# 增加若干机场
manage-airport -l Beijing -la 15.78 -lo 23.56
ma -l Nanjing -la 15.78 -lo 23.56
ma -l Hefei -la 15.78 -lo 23.56
ma -l Harbin -la 15.78 -lo 23.56

# 增加若干飞机
manage-plane -l SunXiaochuan -a 10.5 -t B787 -s 300
mp -l SunChuan -a 10.6 -t B737 -s 300
mp -l SunXiao -a 10.7 -t B747 -s 300
mp -l Sun -a 10.8 -t B777 -s 300

# 查看现有的机场列表
show --airports

# 查看现有的飞机列表
show --planes

# 增加一条航班计划项(程序会检测唯一性/地点冲突)
add-entry --arrival-airport Beijing --departure-airport Nanjing --label MU5387 --timeslot (2020-05-02.11:50,2020-05-02.11:55)
# 为该航班计划项分配飞机资源(程序会检测资源冲突)
allocate --label MU5387 -t 2020-05-02 --plane Sun

# 查看现有的计划项列表
show -entries

# 尝试删除飞机Sun,预期结果为失败(已被航班使用)
mp --delete -l Sun
# 尝试删除飞机SunChuan,预期结果为成功
mp -d -l SunChuan

# 指定航班出发
depart -l MU5387 -t 2020-05-02

# 指定航班到达
arrive -l MU5387 -t 2020-05-02

# 再增加一个航班,并分配飞机资源
ae -aa Hefei -da Nanjing -l MU5379 -t (2020-05-02.11:56,2020-05-02.11:59)
al -l MU5389 -t 2020-05-02 -p SunXiao

# 显示南京机场的信息板(仅显示计划起飞/到达时间在当前系统时间前后一小时之内的)
show -b Nanjing

# 取消航班
cancel -l MU5389 -t 2020-05-02

# 读取文件中的航班计划项
read-file -f test/apps/flight/FlightSchedule_2.txt

对于314change分支,改变命令是add-entry:可以通过--middle-airport(缩写-ma)选项指定一个经停机场。注意,如果指定经停机场,就必须要输入两个时间段(不能相交,后一个必须晚于前一个),例如:

# 增加带经停机场的航班计划项
ae -aa Harbin -ma Beijing -da Hefei -l MU5378 -t (2020-05-02.11:56,2020-05-02.11:59) (2020-05-02.12:56,2020-05-02.12:59)

高铁车次管理应用TrainScheduleApp

我为高铁车次管理应用TrainScheduleApp(简写为tsa)设置了8个子命令:

子命令类名 子命令 功能
AddEntry add-entry, ae 添加计划项
ManageStation manage-station, ms 管理车站(增加、删除)
ManageCarriage manage-carriage, mc 管理车厢(增加、删除)
Allocate allocate, al 为某计划项分配车厢资源
Run depart 某一高铁车次计划项从当前车站出发
Cancel cancel 取消某一车次
Block arrive 某一高铁车次计划项抵达下一车站
Show show, s 展示当前计划项集合信息

需要注意的几点:

下面提供一组测试示例(master分支,#开头的是注释行,请勿输入):

# 增加若干车站
manage-station -l Beijing -la 15.78 -lo 23.56
ms -l Nanjing -la 15.78 -lo 23.56
ms -l Hefei -la 15.78 -lo 23.56
ms -l Harbin -la 15.78 -lo 23.56

# 增加若干车厢
manage-carriage -l 001 -r 2011 -t 1 -c 300
mc -l 002 -r 2012 -t 2 -c 300
mc -l 003 -r 2013 -t 3 -c 300
mc -l 004 -r 2014 -t 4 -c 300

# 当前车站列表
show --stations

# 当前车厢列表
show --carriages

# 增加一条高铁计划项,注意车站数量需要比时间段数量恰好多1
add-entry --label G1024 --stations Beijing Nanjing Harbin Hefei --timeslots (2020-05-07.20:00,2020-05-07.20:01) (2020-05-07.20:02,2020-05-07.20:03) (2020-05-07.20:04,2020-05-07.20:05)

# 显示南京站的信息板(仅显示计划从本站出发/到达本站的时间在当前系统时间前后一小时之内的)
show --board Nanjing
# 显示合肥站的信息板
show -b Hefei

# 再增加一条高铁计划项
ae -l G1025 -s Harbin Nanjing Beijing Hefei -t (2020-05-07.20:00,2020-05-07.20:01) (2020-05-07.20:02,2020-05-07.20:03) (2020-05-07.20:04,2020-05-07.20:05)

# 取消G1024
cancel -l G1024 -t 2020-05-07

# 为G1025分配两节车厢001和002
allocate -l G1025 -t 2020-05-07 -c 001 002

# 显示当前所有计划项
show --entries

# 从起始站出发
depart -l G1025 -t 2020-05-07
# 到达第二站(状态为BLOCKED)
arrive -l G1025 -t 2020-05-07
# 从第二站出发
depart -l G1025 -t 2020-05-07
# 到达第三站(状态为BLOCKED)
arrive -l G1025 -t 2020-05-07
# 从第三站出发
depart -l G1025 -t 2020-05-07
# 到达第四站(也是终点站,状态为ENDED)
arrive -l G1025 -t 2020-05-07

学习活动管理应用ActivityCalendarApp

我为学习活动管理应用ActivityCalendarApp(简写为aca)设置了8个子命令:

子命令类名 子命令 功能
AddEntry add-entry, ae 添加计划项
ManageRoom manage-room, mr 管理会议室(增加、删除)
Allocate allocate, al 为某计划项分配材料资源
Run run 某一活动开始
End end 某一活动结束
Cancel cancel 取消某一活动
Block block 某一活动暂停(仅限314change
Show show, s 展示当前计划项集合信息

需要注意的几点:

下面提供一组测试示例(master分支,#开头的是注释行,请勿输入):

# 添加若干会议室
manage-room -l EG001 -la 15.78 -lo 23.56
mr -l WS002 -la 15.78 -lo 23.56
mr -l MD003 -la 15.78 -lo 23.56
mr -l ZX004 -la 15.78 -lo 23.56

# 当前所有会议室列表
show --rooms

# 添加若干学习活动计划项
add-entry -l aaaa -r EG001 -t (2020-05-08.17:00,2020-05-08.17:01)
ae -l bbbb -r EG001 -t (2020-05-08.17:01,2020-05-08.17:02)
ae -l dddd -r EG001 -t (2020-05-08.16:51,2020-05-08.16:52)

# 展示会议室EG001的信息板
show --board EG001

# 为aaaa分配两份材料
al -l aaaa -t 2020-05-08 -m m01 -d d01 -r 2020-04-01 -m m02 -d d02 -r 2020-04-01

# 开始活动
run -l aaaa -t 2020-05-08

# 结束活动
end -l aaaa -t 2020-05-08

# 取消另一项活动
cancel -l bbbb -t 2020-05-08

# 展示所有计划项信息
show --entries

参考资料

Powered by Jekyll and Theme by solid