6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

プロジェクトで簡単に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);
    }
}

exitCodecall の戻り値です。

実行してみる

ビルドします。

./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統合

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?