はじめに
プロジェクトで簡単にCLIツールを作って業務を改善したいケースがありました。プロジェクトメンバーの得意言語がJavaであるため、メンテナンスのことも考えJavaでCLIツールを作る方法を調べました。以下、仮のツールを作成しながら利用法を紹介します。
作ったもの
お題
CSVを読み込んで標準出力するツールを作ります。取り出したいカラムを指定する機能やtsvやmarkdownのテーブル形式など他のフォーマットで出力するという機能を作ります。
利用するライブラリ
Javaでコマンドライン引数を解析するなどCLIツールに必要な機能を実現するライブラリとして今回は picocliを利用しました。
実際に作る
CSVを表示する機能まで
全体としては以下にまとまっています。
プロジェクトセットアップ
gradle initで作成したひな形にライブラリなどを追加します。本稿の主眼ではないので詳細はGithub上のコードを見てください。
plugins {
// Apply the application plugin to add support for building a CLI application in Java.
id 'application'
id 'com.github.johnrengelman.shadow' version '8.1.1'
}
// 中略
dependencies {
// 前略
// https://mvnrepository.com/artifact/info.picocli/picocli
implementation 'info.picocli:picocli:4.7.6'
// https://mvnrepository.com/artifact/com.opencsv/opencsv
implementation 'com.opencsv:opencsv:5.10'
}
// 後略
依存関係全部入りのfatJarを作るためにプラグインとしてshadow
を追加しました。
また、CLIの機能のためにpicocli
、CSV解析のためにopencsv
を入れています。
コマンドを実装する
picocli
では java.util.concurrent.Callable
を継承したクラスにコマンドのオプションや処理内容を実装するという形でコマンドを実装出来ます。
まず、クラスにアノテーションをつけることでコマンド名や説明などを定義します。
import picocli.CommandLine.Command;
@Command(name = "csv-tool", version = "1.0", description = "CSV file processing tool", mixinStandardHelpOptions = true)
public class CsvTool implements Callable<Integer> {
@Override
public Integer call() throws Exception {
// TODO: 実装
return null;
}
}
例えば、この場合、csv-toolというコマンド名、バージョンは1.0
、説明としてはCSV file processing tool
となります。mixinStandardHelpOptionsは--help
や--version
などの標準のオプションをつけるかというフラグです。
続いて各種オプションを実装します。個別のオプションについてはクラスのメンバ変数として定義します。
例えば、CSVファイルのパスを指定する必要があるため、Pathを扱うオプションを定義します。
import java.nio.file.Path;
import picocli.CommandLine.Command;
import picocli.CommandLine.Parameters;
@Command(name = "csv-tool", version = "1.0", description = "CSV file processing tool", mixinStandardHelpOptions = true)
public class CsvTool implements Callable<Integer> {
@Parameters(index = "0", description = "Input CSV file", paramLabel = "FILE")
private Path csvFile;
@Override
public Integer call() throws Exception {
// TODO: 実装
return null;
}
}
この場合、CLIの0番目の引数に指定した値がcsvFile
という変数に代入されます。
メンバ変数の型を変えることもできます。java.io.File
を利用してファイル実体を扱うこともできますし、Stringとして受け取ることで文字列として扱うこともできます。
また、-a
というように指定した名前でパラメータを受け取りたい場合は以下のように指定します。
@Option(names = {"-a", "--algorithm"}, description = "MD5, SHA-1, SHA-256, ...")
private String algorithm = "SHA-256";
その他、よくCLIツールで見るパラメータ指定方法はおおむねできるようです。詳しくはドキュメントを参照してください。
最後にこのコマンドの処理を実装します。call
メソッドが実際にコマンドが呼び出された際に実行される処理となります。
package org.example.command;
import java.io.FileReader;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.Callable;
import com.opencsv.CSVReader;
import com.opencsv.exceptions.CsvException;
import picocli.CommandLine.Command;
import picocli.CommandLine.Parameters;
@Command(name = "csv-tool", version = "1.0", description = "CSV file processing tool", mixinStandardHelpOptions = true)
public class CsvTool implements Callable<Integer> {
@Parameters(index = "0", description = "Input CSV file", paramLabel = "FILE")
private Path csvFile;
@Override
public Integer call() throws Exception {
try {
// CSVファイルを読み込む
List<String[]> records = readCsvFile(csvFile.toString());
// ヘッダー行を取得して表示
String[] headers = records.get(0);
printRow(headers);
System.out.println("-".repeat(80)); // 区切り線
// データ行を表示
records.stream()
.skip(1) // ヘッダー行をスキップ
.forEach(this::printRow);
return 0; // 正常終了
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
return 1; // エラー終了
}
}
private List<String[]> readCsvFile(String filename) throws IOException, CsvException {
try (CSVReader reader = new CSVReader(new FileReader(filename))) {
return reader.readAll();
}
}
private void printRow(String[] row) {
System.out.println(String.join(",", row));
}
}
mainメソッドからコマンドを呼び出す
以下のようにmainメソッドからコマンドを呼び出すようにします。
/*
* This source file was generated by the Gradle 'init' task
*/
package org.example;
import org.example.command.CsvTool;
import picocli.CommandLine;
public class App {
public static void main(String[] args) {
int exitCode = new CommandLine(new CsvTool()).execute(args);
System.exit(exitCode);
}
}
exitCode
は call
の戻り値です。
実行してみる
ビルドします。
./gradlew shadowJar
app/build/libs/app-all.jar
というjarが生成される。
$ java -jar app/build/libs/app-all.jar --help
Usage: csv-tool [-hV] FILE
CSV file processing tool
FILE Input CSV file
-h, --help Show this help message and exit.
-V, --version Print version information and exit.
例えば、以下のようなCSVを渡すと
date,store,product,quantity,unit_price,total
2024-01-01,Store A,Product 1,5,1000,5000
2024-01-01,Store B,Product 2,3,1500,4500
$ java -jar app/build/libs/app-all.jar examples/sample.csv
date,store,product,quantity,unit_price,total
--------------------------------------------------------------------------------
2024-01-01,Store A,Product 1,5,1000,5000
2024-01-01,Store B,Product 2,3,1500,4500
というようにCSVを表示するCLIツールができました。
機能を追加する(カラムの指定機能/出力フォーマットの指定)
全体としてはこちらになります。
オプションを追加する
カラムを指定するオプション、出力フォーマットを指定するオプションを追加します。
@Option(names = "--columns", description = "Column names to display (comma-separated)", split = ",")
private List<String> columnNames;
@Option(names = "-c", description = "Column indexes to display (comma-separated, 0-based)", split = ",")
private List<Integer> columnIndexes;
@Option(names = "--format", description = "Output format (csv, tsv, table)", defaultValue = "csv")
private OutputFormat outputFormat = OutputFormat.csv;
private enum OutputFormat {
csv, tsv, table
}
callメソッドの実装を修正する
実装の詳細は本質ではないので割愛します。
@Override
public Integer call() throws Exception {
try {
// CSVファイルを読み込む
List<String[]> records = readCsvFile(csvFile.toString());
if (records.isEmpty()) {
System.err.println("Empty CSV file");
return 1;
}
// ヘッダー行を取得
String[] headers = records.get(0);
// カラムインデックスを解決
int[] targetColumns = resolveTargetColumns(headers);
if (targetColumns.length == 0) {
System.err.println("No valid columns specified");
return 1;
}
// 選択されたヘッダーを表示
String[] selectedHeaders = selectColumns(headers, targetColumns);
printFormattedRow(selectedHeaders);
printSeparator(selectedHeaders);
// データ行を表示
records.stream()
.skip(1) // ヘッダー行をスキップ
.map(row -> selectColumns(row, targetColumns))
.forEach(this::printFormattedRow);
return 0;
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
return 1;
}
}
private int[] resolveTargetColumns(String[] headers) {
if (columnNames != null && !columnNames.isEmpty()) {
// カラム名でフィルタリング
return columnNames.stream()
.mapToInt(name -> findColumnIndex(headers, name))
.filter(index -> index >= 0)
.toArray();
} else if (columnIndexes != null && !columnIndexes.isEmpty()) {
// カラムインデックスでフィルタリング
return columnIndexes.stream()
.mapToInt(Integer::intValue)
.filter(i -> i >= 0 && i < headers.length)
.toArray();
} else {
// 全カラムを返す
return IntStream.range(0, headers.length).toArray();
}
}
private int findColumnIndex(String[] headers, String columnName) {
return IntStream.range(0, headers.length)
.filter(i -> headers[i].equalsIgnoreCase(columnName))
.findFirst()
.orElse(-1);
}
private String[] selectColumns(String[] row, int[] targetColumns) {
return Arrays.stream(targetColumns)
.mapToObj(i -> i < row.length ? row[i] : "")
.toArray(String[]::new);
}
private void printFormattedRow(String[] row) {
switch (outputFormat) {
case csv:
System.out.println(String.join(",", row));
break;
case tsv:
System.out.println(String.join("\t", row));
break;
case table:
System.out.println("| " + String.join(" | ", row) + " |");
break;
}
}
private void printSeparator(String[] headers) {
if (outputFormat == OutputFormat.table) {
String separator = Arrays.stream(headers)
.map(h -> "-".repeat(h.length()))
.collect(java.util.stream.Collectors.joining(" | ", "| ", " |"));
System.out.println(separator);
}
}
private List<String[]> readCsvFile(String filename) throws IOException, CsvException {
try (CSVReader reader = new CSVReader(new FileReader(filename))) {
return reader.readAll();
}
}
実際に呼んでみる
ビルドします。
./gradlew shadowJar
app/build/libs/app-all.jar
が生成されているはず。
$ java -jar app/build/libs/app-all.jar --help
Usage: csv-tool [-hV] [--format=<outputFormat>] [-c=<columnIndexes>[,
<columnIndexes>...]]... [--columns=<columnNames>[,
<columnNames>...]]... FILE
CSV file processing tool
FILE Input CSV file
-c=<columnIndexes>[,<columnIndexes>...]
Column indexes to display (comma-separated, 0-based)
--columns=<columnNames>[,<columnNames>...]
Column names to display (comma-separated)
--format=<outputFormat>
Output format (csv, tsv, table)
-h, --help Show this help message and exit.
-V, --version Print version information and exit.
先ほど追加したオプションが増えていますね。
列番号指定で抽出
$ java -jar app/build/libs/app-all.jar examples/sample.csv -c 1,4,5
store,unit_price,total
Store A,1000,5000
Store B,1500,4500
列名指定で抽出。
$ java -jar app/build/libs/app-all.jar examples/sample.csv --columns date,product,total
date,product,total
2024-01-01,Product 1,5000
2024-01-01,Product 2,4500
マークダウンのテーブル形式で出力。
$ java -jar app/build/libs/app-all.jar examples/sample.csv --format table
| date | store | product | quantity | unit_price | total |
| ---- | ----- | ------- | -------- | ---------- | ----- |
| 2024-01-01 | Store A | Product 1 | 5 | 1000 | 5000 |
| 2024-01-01 | Store B | Product 2 | 3 | 1500 | 4500 |
組み合わせる
$ java -jar app/build/libs/app-all.jar examples/sample.csv --columns date,product,total --format table
| date | product | total |
| ---- | ------- | ----- |
| 2024-01-01 | Product 1 | 5000 |
| 2024-01-01 | Product 2 | 4500 |
上記のように機能追加ができました。
その他の機能
サブコマンド
多機能なCLIツールを作る際にサブコマンドへ分けることができます。
@Command(name = "myapp", subcommands = {MySubcommand.class, MySubcommand2.class})
class MyCommand implements Runnable {
// ...
}
色や太字など
SpringBoot統合