かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Picocli+Spring Boot でコマンドラインアプリケーションを作成してみる

概要

記事一覧はこちらです。

Twitter を見ていたところ picocli というライブラリの 4.0 GA release のツイートを見かけました。picocli のことを知らなかったので調べてみたところ、

  • Java で command line application を作成するための framework。考えられるものはほとんど実装されているのではないだろうか、というぐらい機能が充実している。マニュアルも分かりやすい。
  • picocli-spring-boot-starter が提供されている。
  • bashzsh 限定だが、コマンドラインから実行する時に TAB で自動補完が効くコマンドを作成できる。
  • GraalVM に対応しているらしい。GraalVM を全然理解していないので自分ではどう対応されているのか全く分かりませんでしたが。。。

調べたことをメモして残し、サンプルを作成してみます。作成したサンプルは以下の URL の場所に置いてあります。
https://github.com/ksby/ksbysample-boot-miscellaneous/tree/master/picocli-boot-cmdapp

参照したサイト・書籍

  1. picocli - a mighty tiny command line interface
    https://picocli.info/

  2. Quick Guide
    https://picocli.info/quick-guide.html

  3. remkop/picocli
    https://github.com/remkop/picocli

  4. picocli/picocli-spring-boot-starter/
    https://github.com/remkop/picocli/tree/master/picocli-spring-boot-starter

  5. Autocomplete for Java Command Line Applications
    https://picocli.info/autocomplete.html

  6. Create a Java Command Line Program with Picocli
    https://www.baeldung.com/java-picocli-create-command-line-program

  7. Spring Boot Exit Codes
    https://www.baeldung.com/spring-boot-exit-codes

  8. Including subprojects using a wildcard in a Gradle settings file
    https://stackoverflow.com/questions/2297032/including-subprojects-using-a-wildcard-in-a-gradle-settings-file

目次

  1. Spring Boot ベースのコマンドラインアプリケーションのサンプルを作成する(Subcommand なし)
  2. Spring Boot ベースのコマンドラインアプリケーションのサンプルを作成する(Subcommand あり)
  3. --version(-V)オプション指定時に build.gradle に記述した build.version を表示する
  4. TAB キー押下時に subcommand, option の候補の表示や自動補完が行われるようにする

手順

Spring Boot ベースのコマンドラインアプリケーションのサンプルを作成する(Subcommand なし)

Subcommand なしと Subcommand ありの2つのサンプルを Gradle Multi-project の中に作成します。

  • D:\project-springboot\ksbysample-boot-miscellaneous の下に picocli-boot-cmdapp ディレクトリを作成する。
  • 別のプロジェクトから Gradle Wrapper のファイルをコピーする(コピーしたのは 5.4.1)。
  • Gradle を最新バージョン(5.5.1)にする。
  • gradlew init を実行する。
  • settings.gradle を以下の内容に変更する(Including subprojects using a wildcard in a Gradle settings file 参照)。これで build.gradle があるサブプロジェクトは include 分を記述しなくても自動的に Multi-project に認識されるようになる。
rootProject.name = 'picocli-boot-cmdapp'
rootDir.eachFileRecurse { f ->
    if ( f.name == "build.gradle" ) {
        String relativePath = f.parentFile.absolutePath - rootDir.absolutePath
        String projectName = relativePath.replaceAll("[\\\\\\/]", ":")
        include projectName
    }
}

Multi-project のベースが出来ました。次に Spring Boot ベースのコマンドラインアプリケーション(Subcommand なし)の Project を作成します。以下の仕様のコマンドを作成します。

  • filetools --create <ファイル> <ファイル> ...(--create は -c も可) で指定されたファイル名の空ファイルを作成する。
  • filetools --delete <ファイル> <ファイル> ...(--delete は -d も可) で指定されたファイルを削除する。
  • --create--delete のオプションはいずれか一方が必須。どちらも指定しない、あるいはどちらも指定した場合にはエラーになる。

まず D:\project-springboot\ksbysample-boot-miscellaneous\picocli-boot-cmdapp の下に Spring Initializr で nosubcmd-cmdapp プロジェクトを作成した後、build.gradle を以下のように変更します。

buildscript {
    ext {
        group "ksby.ksbysample-boot-miscellaneous.picocli-boot-cmdapp"
        version "1.0.0-RELEASE"
    }
    repositories {
        mavenCentral()
        maven { url "https://repo.spring.io/release/" }
        maven { url "https://plugins.gradle.org/m2/" }
    }
}

plugins {
    id 'org.springframework.boot' version '2.1.6.RELEASE'
    id "io.spring.dependency-management" version "1.0.8.RELEASE"
    id 'java'
    id 'idea'
}

sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11

idea {
    module {
        inheritOutputDirs = false
        outputDir = file("$buildDir/classes/main/")
    }
}

repositories {
    mavenCentral()
}

dependencyManagement {
    imports {
        mavenBom(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)
    }
}

dependencies {
    def picocliVersion = "4.0.0"

    implementation("org.springframework.boot:spring-boot-starter")
    testImplementation("org.springframework.boot:spring-boot-starter-test")

    // picocli
    implementation("info.picocli:picocli-spring-boot-starter:${picocliVersion}")
}
  • dependencies block に Picocli の Spring Boot Starter である picocli-spring-boot-starter を追加します。

src/main/java/ksbysample/cmdapp/nosubcmd の下に FileToolsCommand.java を新規作成して、以下の内容を記述します。

package ksbysample.cmdapp.nosubcmd;

import org.springframework.stereotype.Component;
import picocli.CommandLine.*;

import java.io.File;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileSystemException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.concurrent.Callable;

@Component
@Command(name = "filetools", mixinStandardHelpOptions = true,
        version = "1.0.0",
        description = "create/delete file(s) command")
public class FileToolsCommand implements Callable<Integer>, IExitCodeExceptionMapper {

    // --create オプションと --delete オプションはいずれか一方しか指定できないようにする
    @ArgGroup(exclusive = true, multiplicity = "1")
    private Exclusive exclusive;

    static class Exclusive {

        @Option(names = {"-c", "--create"}, description = "create file(s)")
        private boolean isCreate;

        @Option(names = {"-d", "--delete"}, description = "delete file(s)")
        private boolean isDelete;

    }

    @Parameters(paramLabel = "ファイル", description = "作成あるいは削除するファイル")
    private File[] files;

    @Override
    public Integer call() {
        Arrays.asList(this.files).forEach(f -> {
            try {
                if (exclusive.isCreate) {
                    Files.createFile(Paths.get(f.getName()));
                    System.out.println(f.getName() + " is created.");
                } else if (exclusive.isDelete) {
                    Files.deleteIfExists(Paths.get(f.getName()));
                    System.out.println(f.getName() + " is deleted.");
                }
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        });

        return ExitCode.OK;
    }

    @Override
    public int getExitCode(Throwable exception) {
        Throwable cause = exception.getCause();
        if (cause instanceof FileAlreadyExistsException) {
            // 既に存在するファイルを作成しようとしている
            return 12;
        } else if (cause instanceof FileSystemException) {
            // 削除しようとしたファイルが別のプロセスでオープンされている等
            return 13;
        }
        return 11;
    }

}

src/main/java/ksbysample/cmdapp/nosubcmd/Application.java を以下のように変更します。

package ksbysample.cmdapp.nosubcmd;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.ExitCodeGenerator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import picocli.CommandLine;
import picocli.CommandLine.IFactory;

@SpringBootApplication
public class Application implements CommandLineRunner, ExitCodeGenerator {

    private int exitCode;

    private final FileToolsCommand fileToolsCommand;

    private final IFactory factory;

    public Application(FileToolsCommand fileToolsCommand,
                       IFactory factory) {
        this.fileToolsCommand = fileToolsCommand;
        this.factory = factory;
    }

    public static void main(String[] args) {
        System.exit(SpringApplication.exit(SpringApplication.run(Application.class, args)));
    }

    @Override
    public void run(String... args) {
        exitCode = new CommandLine(fileToolsCommand, factory)
                .setExitCodeExceptionMapper(fileToolsCommand)
                .execute(args);
    }

    @Override
    public int getExitCode() {
        return exitCode;
    }

}

gradle の build タスクを実行して build/libs の下に nosubcmd-cmdapp-1.0.0-RELEASE.jar を生成します。

Git for Windowsbash を起動してから D:\project-springboot\ksbysample-boot-miscellaneous\picocli-boot-cmdapp\nosubcmd-cmdapp\build\libs\ の下に移動し、alias filetools='java -jar nosubcmd-cmdapp-1.0.0-RELEASE.jar' コマンドを実行して filetools だけでコマンドを実行できるようにします。

filetools コマンドを実行してみると、

f:id:ksby:20190718064717p:plain

Spring Boot のロゴと INFO ログが邪魔でした。。。 今回は表示されないようにします。

src/main/resources/application.properties に以下の内容を記載します。

spring.main.banner-mode=off
logging.level.root=OFF

build し直してから filetools コマンドを実行すると -c, -d のどちらのオプションを指定していないというエラーメッセージ(Error: Missing required argument (specify one of these): ([-c] | [-d]))とヘルプが表示されました。あれだけの記述なのにヘルプは見やすいし、オプションのところに色が付いているのがいいですね。

f:id:ksby:20190718065132p:plain

-c, -d のオプションをどちらも指定すると Error: --create, --delete are mutually exclusive (specify only one) というエラーメッセージが表示されます。

f:id:ksby:20190718065540p:plain

filetools -h コマンドを実行するとヘルプだけが表示され、filetools -V コマンドを実行するとバージョン番号だけが表示されます。

f:id:ksby:20190718070714p:plain

ファイルの作成、削除を試してみます。ディレクトリ内に jar 以外のファイルがない状態で、

f:id:ksby:20190718070004p:plain

filetools -c 1.txt 2.txt 3.txt コマンドを実行すると、

f:id:ksby:20190718065816p:plain

ディレクトリ内にファイルが作成されます。

f:id:ksby:20190718070102p:plain

filetools -d 1.txt 2.txt 3.txt コマンドを実行すると、

f:id:ksby:20190718070209p:plain

ファイルが削除されます。

f:id:ksby:20190718070252p:plain

filetools -c a.txt a.txt コマンドを実行して同じファイルを2度作成しようとすると、コマンドの戻り値が 0 ではなく 12 になります。

f:id:ksby:20190718225317p:plain

Spring Boot ベースのコマンドラインアプリケーションのサンプルを作成する(Subcommand あり)

今度は Subcommand ありのコマンドラインアプリケーションを作成してみます。Git コマンドでの git commit ...git branch ... のように commit, branch が Subcommand にあたります。

以下の仕様のコマンドを作成します。

  • cal add 数値 数値 ... で指定された数値を全て加算した結果を表示する。--avg('-a' でも可)オプションを付けると数値の個数で割った平均値を表示する。
  • cal multi 数値 数値 ... で指定された数値を全て乗算した結果を表示する。--compare 数値 オプションを付けると計算結果と数値を比較して、計算結果 < 数値なら -1、計算結果 = 数値なら 0、計算結果 > 数値なら 1 を返す。

まず D:\project-springboot\ksbysample-boot-miscellaneous\picocli-boot-cmdapp の下に Spring Initializr で subcmd-cmdapp プロジェクトを作成した後、nosubcmd-cmdapp プロジェクトの build.gradle をコピーします。

src/main/java/ksbysample/cmdapp/subcmd の下に CalCommand.java を新規作成して、以下の内容を記載します。

package ksbysample.cmdapp.subcmd;

import org.springframework.stereotype.Component;
import picocli.CommandLine.*;

import java.math.BigDecimal;
import java.util.Arrays;
import java.util.Optional;
import java.util.concurrent.Callable;

@Component
@Command(name = "cal", mixinStandardHelpOptions = true,
        versionProvider = CalCommand.class,
        description = "渡された数値の加算・乗算を行うツール",
        subcommands = {
                CalCommand.AddCommand.class,
                CalCommand.MultiCommand.class
        })
public class CalCommand implements Callable<Integer>, IExitCodeExceptionMapper, IVersionProvider {

    @Override
    public Integer call() {
        return ExitCode.OK;
    }

    @Override
    public int getExitCode(Throwable exception) {
        Throwable cause = exception.getCause();
        if (cause instanceof NumberFormatException) {
            // 数値パラメータに数値以外の文字が指定された
            return 12;
        }

        return 11;
    }

    @Override
    public String[] getVersion() {
        return new String[]{"1.0.0"};
    }

    @Component
    @Command(name = "add", mixinStandardHelpOptions = true,
            versionProvider = CalCommand.class,
            description = "渡された数値を加算する")
    static class AddCommand implements Callable<Integer> {

        @Option(names = {"-a", "--avg"}, description = "平均値を算出する")
        private boolean optAvg;

        @Parameters(paramLabel = "数値", arity = "1..*", description = "加算する数値")
        private BigDecimal[] nums;

        @Override
        public Integer call() {
            BigDecimal sum =
                    Arrays.asList(nums).stream()
                            .reduce(new BigDecimal("0"), (a, v) -> a.add(v));
            Optional<BigDecimal> avg = optAvg
                    ? Optional.of(sum.divide(BigDecimal.valueOf(nums.length)))
                    : Optional.empty();
            System.out.println(avg.orElse(sum));
            return ExitCode.OK;
        }

    }

    @Component
    @Command(name = "multi", mixinStandardHelpOptions = true,
            versionProvider = CalCommand.class,
            description = "渡された数値を乗算する")
    static class MultiCommand implements Callable<Integer> {

        @Parameters(paramLabel = "数値", arity = "1..*", description = "乗算する数値")
        private BigDecimal[] nums;

        @Option(names = {"-c", "--compare"},
                description = "計算結果と比較して、計算結果 < 数値なら -1、計算結果 = 数値なら 0、計算結果 > 数値なら 1 を返す")
        private BigDecimal compareNum;

        @Override
        public Integer call() {
            BigDecimal result =
                    Arrays.asList(nums).stream()
                            .reduce(new BigDecimal("1"), (a, v) -> a.multiply(v));
            Optional<Integer> compareResult = (compareNum == null)
                    ? Optional.empty()
                    : Optional.of(result.compareTo(compareNum));
            System.out.println(compareResult.isPresent() ? compareResult.get() : result);
            return ExitCode.OK;
        }

    }

}

src/main/java/ksbysample/cmdapp/subcmd/Application.java を以下のように変更します。

package ksbysample.cmdapp.subcmd;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.ExitCodeGenerator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import picocli.CommandLine;
import picocli.CommandLine.ExitCode;
import picocli.CommandLine.IFactory;
import picocli.CommandLine.ParameterException;
import picocli.CommandLine.ParseResult;

@SpringBootApplication
public class Application implements CommandLineRunner, ExitCodeGenerator {

    private int exitCode;

    private final CalCommand calCommand;

    private final IFactory factory;

    public Application(CalCommand calCommand
            , IFactory factory) {
        this.calCommand = calCommand;
        this.factory = factory;
    }

    public static void main(String[] args) {
        System.exit(SpringApplication.exit(SpringApplication.run(Application.class, args)));
    }

    @Override
    public void run(String... args) {
        CommandLine commandLine = new CommandLine(calCommand, factory);

        // subcommand が指定されていない場合にはエラーメッセージと usage を表示する
        try {
            ParseResult parsed = commandLine.parseArgs(args);
            if (parsed.subcommand() == null &&
                    !parsed.isUsageHelpRequested() &&
                    !parsed.isVersionHelpRequested()) {
                System.err.println("Error: at least 1 command and 1 subcommand found.");
                commandLine.usage(System.out);
                exitCode = ExitCode.USAGE;
                return;
            }
        } catch (ParameterException ignored) {
            // CommandLine#parseArgs で ParameterException が throw されても
            // CommandLine#execute を実行しないと subcommand の usage が表示されないので
            // ここでは何もしない
        }

        exitCode = commandLine
                .setExitCodeExceptionMapper(calCommand)
                .execute(args);
    }

    @Override
    public int getExitCode() {
        return exitCode;
    }

}

src/main/resources/application.properties を以下のように変更します。

spring.main.banner-mode=off
logging.level.root=OFF

bashD:\project-springboot\ksbysample-boot-miscellaneous\picocli-boot-cmdapp\subcmd-cmdapp\build\libs\ の下に移動し、alias cal='java -jar subcmd-cmdapp-1.0.0-RELEASE.jar' コマンドを実行して cal だけでコマンドを実行できるようにします。

cal コマンドだけを実行すると Subcommand が指定されていないので Error: at least 1 command and 1 subcommand found. のエラーメッセージと usage が表示されます。

f:id:ksby:20190720005515p:plain

cal add コマンドを実行すると、

f:id:ksby:20190720070032p:plain

cal multi コマンドを実行すると、

f:id:ksby:20190720070247p:plain

--version(-V)オプション指定時に build.gradle に記述した build.version を表示する

build.gradle に version を記述しているので、cal -V 実行時にこの文字列を出力するようにしてみます。

buildscript {
    ext {
        group "ksby.ksbysample-boot-miscellaneous.picocli-boot-cmdapp"
        version "1.0.0-RELEASE"
    }

まず build.gradle に springBoot { buildInfo() } を追加します。 `

idea {
    module {
        inheritOutputDirs = false
        outputDir = file("$buildDir/classes/main/")
    }
}

springBoot {
    buildInfo()
}

repositories {
    mavenCentral()
}

src/main/java/ksbysample/cmdapp/subcmd/CalCommand.java を以下のように変更します。

public class CalCommand implements Callable<Integer>, IExitCodeExceptionMapper, IVersionProvider {

    // picocli.AutoComplete で generate Completion Script をする時に引数なしのコンストラクタが必要になる
    // のでコンストラクタインジェクションは使用しないこと
    // https://picocli.info/autocomplete.html 参照
    @Autowired
    private BuildProperties buildProperties;

    ..........

    @Override
    public String[] getVersion() {
        return new String[]{buildProperties.getVersion()};
    }

    ..........
  • private final BuildProperties buildProperties; を追加します。
  • getVersion メソッド内で "1.0.0"buildProperties.getVersion() に変更します。

build して jar ファイルを作成し直してから cal -V コマンドを実行すると build.gradle の version に記述した文字列が表示されます。

f:id:ksby:20190720080754p:plain

TAB キー押下時に subcommand, option の候補の表示や自動補完が行われるようにする

Autocomplete for Java Command Line Applications のマニュアルに従い、cal コマンドの subcommand, option の候補の表示や自動補完が行わえるようにしてみます。

subcmd-cmdapp-1.0.0-RELEASE.jar を zip 解凍可能なツール(今回は Explzh を使用)で開いた後、BOOT-INF/lib の下にある spring-boot-2.1.6.RELEASE.jar と picocli-4.0.0.jar を取り出します。

f:id:ksby:20190720083531p:plain f:id:ksby:20190720083724p:plain

java -cp "picocli-4.0.0.jar;subcmd-cmdapp-1.0.0-RELEASE.jar" picocli.AutoComplete -n cal ksbysample.cmdapp.subcmd.CalCommand を実行しても ClassNotFoundException が発生して動作しなかったのですが、

f:id:ksby:20190720082703p:plain

class ファイルは subcmd-cmdapp/build/classes/java/main の下に生成されているので、

f:id:ksby:20190720082830p:plain

java -cp "picocli-4.0.0.jar;spring-boot-2.1.6.RELEASE.jar;../classes/java/main" picocli.AutoComplete -n cal ksbysample.cmdapp.subcmd.CalCommand を実行します。

f:id:ksby:20190720084700p:plain

cal_completion というファイルが生成されます。

f:id:ksby:20190720084852p:plain

何もしていない時には bash 上で cal と入力してから TAB キーを2回押すととディレクトリ内のファイル一覧が表示されますが、

f:id:ksby:20190720085425p:plain

. cal_completion コマンドを実行してから cal を入力+TAB キーを2回押すと subcommand の候補が表示されます。

f:id:ksby:20190720085704p:plain

cal m とだけ入力して TABキーを1回押すと、

f:id:ksby:20190720085950p:plain

multi の文字列が自動補完されます。

f:id:ksby:20190720090039p:plain

cal multi - と入力してから TAB キーを2回押すと指定可能なオプションが表示されますし、

f:id:ksby:20190720090641p:plain

cal multi --c と入力してから TAB キーを1回押すと

f:id:ksby:20190720090930p:plain

--compare のオプションが自動補完されます。

f:id:ksby:20190720091017p:plain

履歴

2019/07/20
初版発行。