こんにちは、かつコーチです。
今回はここまで学んだSOLID原則・デザインパターン・不変オブジェクトの知識を活かして、実際にCLIツール(コマンドラインで動作するツール)を作ります。
コマンドライン引数の受け取り方、Stream API、例外処理は理解している前提で進めます。
作るツールの仕様を決める
「CSVを集計するCLIツール」を題材にする
題材として、CSVファイルを読み込んで数値列を集計するcsv-sumというツールを作ります。
$ java -jar csv-sum.jar --file sales.csv --column amount --op sum
合計: 125000
要件は以下の3点です。
--fileでCSVファイルパスを指定する--columnで集計したい列名を指定する--opで集計方法(sum・avg・max)を指定する
責任を分割した設計
いきなり1つのmainメソッドに全処理を書くのではなく、SOLID原則のSRP(単一責任の原則)に沿って役割を分けます。
CliArguments:コマンドライン引数の解析結果を保持する不変オブジェクトCsvReader:CSVファイルを読み込みデータに変換するAggregator:集計処理を担当する(Strategyパターンで集計方法を切り替える)Main:全体の流れを組み立てるエントリーポイント
引数解析とCSV読み込みを実装する
recordで引数を不変オブジェクトとして表現する
record CliArguments(String file, String column, String operation) {
static CliArguments parse(String[] args) {
Map<String, String> options = new HashMap<>();
for (int i = 0; i < args.length - 1; i++) {
if (args[i].startsWith("--")) {
options.put(args[i].substring(2), args[i + 1]);
}
}
String file = options.get("file");
String column = options.get("column");
String operation = options.getOrDefault("op", "sum");
if (file == null || column == null) {
throw new IllegalArgumentException("--file と --column は必須です");
}
return new CliArguments(file, column, operation);
}
}
recordのparse静的メソッドをFactoryとして使い、生成時点で必須項目のバリデーションを済ませています。
不変オブジェクトなので、一度生成したCliArgumentsが処理の途中で書き換わる心配がありません。
CSV読み込みの実装
class CsvReader {
List<Map<String, String>> read(Path path) throws IOException {
List<String> lines = Files.readAllLines(path);
if (lines.isEmpty()) {
return List.of();
}
String[] headers = lines.get(0).split(",");
return lines.stream()
.skip(1)
.map(line -> toRow(headers, line.split(",")))
.toList();
}
private Map<String, String> toRow(String[] headers, String[] values) {
Map<String, String> row = new LinkedHashMap<>();
for (int i = 0; i < headers.length; i++) {
row.put(headers[i].trim(), values[i].trim());
}
return row;
}
}
java.nio.file.Files.readAllLinesでファイルを読み込み、Stream APIのskip(1)でヘッダー行を除外しています。
実装で詰まったポイント:文字コードの不一致
Excelで作成したCSVを読み込んだところ、日本語列名が文字化けしました。
java.nio.charset.MalformedInputException: Input length = 1
原因はExcel側がCSVをShift_JIS(正確にはCP932)で保存しており、Files.readAllLinesがデフォルトのUTF-8として読み込んでいたことでした。Files.readAllLines(path, Charset.forName("MS932"))のように明示的に文字コードを指定することで解決しました。
業務でExcel由来のCSVを扱う場合は、文字コードを決め打ちにせず、設定で切り替えられるようにしておくと安全です。
Strategyパターンで集計方法を切り替える
Aggregatorの設計
集計方法(合計・平均・最大)は将来増える可能性が高いため、デザインパターンの記事で扱ったStrategyパターンを適用します。
interface Aggregator {
double aggregate(List<Double> values);
}
class SumAggregator implements Aggregator {
public double aggregate(List<Double> values) {
return values.stream().mapToDouble(Double::doubleValue).sum();
}
}
class AverageAggregator implements Aggregator {
public double aggregate(List<Double> values) {
return values.stream().mapToDouble(Double::doubleValue).average().orElse(0);
}
}
class MaxAggregator implements Aggregator {
public double aggregate(List<Double> values) {
return values.stream().mapToDouble(Double::doubleValue).max().orElse(0);
}
}
Factoryで生成を集約する
class AggregatorFactory {
private static final Map<String, Supplier<Aggregator>> REGISTRY = Map.of(
"sum", SumAggregator::new,
"avg", AverageAggregator::new,
"max", MaxAggregator::new
);
static Aggregator create(String operation) {
Supplier<Aggregator> supplier = REGISTRY.get(operation);
if (supplier == null) {
throw new IllegalArgumentException("未対応の集計方法: " + operation);
}
return supplier.get();
}
}
デザインパターンの記事で紹介した「MapベースのFactory」をそのまま流用しています。
新しい集計方法(中央値など)を追加する際は、Aggregator実装クラスとREGISTRYへの1行追加だけで済み、既存コードには手を入れません。
全体を組み立てて動かす
Mainクラスで各部品を接続する
public class Main {
public static void main(String[] args) {
try {
CliArguments cli = CliArguments.parse(args);
CsvReader reader = new CsvReader();
List<Map<String, String>> rows = reader.read(Path.of(cli.file()));
List<Double> values = rows.stream()
.map(row -> row.get(cli.column()))
.filter(Objects::nonNull)
.map(Double::parseDouble)
.toList();
Aggregator aggregator = AggregatorFactory.create(cli.operation());
double result = aggregator.aggregate(values);
System.out.printf("%s: %.2f%n", cli.operation(), result);
} catch (IOException e) {
System.err.println("ファイル読み込みエラー: " + e.getMessage());
System.exit(1);
} catch (IllegalArgumentException e) {
System.err.println("引数エラー: " + e.getMessage());
System.exit(1);
}
}
}
Mainは「引数解析→CSV読み込み→集計→出力」という流れを組み立てるだけで、各処理の詳細は各クラスに委譲されています。
例外は種類ごとにキャッチして、ユーザーに分かりやすいメッセージと共に終了コード(System.exit(1))を返しています。
Mavenでjarファイルにパッケージングする
Maven基礎編の記事で扱ったmaven-shade-pluginを使うと、依存関係を含めた実行可能jarを作成できます。
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
mvn packageを実行すれば、java -jar csv-sum.jar --file sales.csv --column amount --op sumのように単体で動くjarが生成されます。
まとめ
この記事のポイント
- CLIツールもSRPに沿って引数解析・データ読み込み・集計・出力を別クラスに分割すると保守しやすい
- 引数はrecordで不変オブジェクトとして表現し、生成時点でバリデーションする
- 集計処理のように将来増える可能性がある処理はStrategy+Factoryの組み合わせで拡張しやすくする
- CSVの文字コード不一致のように、実データを扱うと初めて気づく落とし穴がある
次に読むべき記事
- SOLID原則入門の記事へ
- 代表的デザインパターン(Singleton・Factory・Strategy)の記事へ
タグ: Java, 上級者向け, 実践プロジェクト