🚰

【Java】Stream APIを使ってグループごとの合計を求める

2024/06/19に公開

はじめに

JavaのStream APICollectorsを使用し、データを指定の属性でグループ化し、各グループの合計を求める方法を説明します
初めてのJavaの実装で手こずったので記事に残しました

前提

商品データ

以下は例として使用する商品データです

カテゴリ ブランド ID 商品名 価格 原価
トップス Lunaire T001 シルクブラウス 49.99 25.00
トップス Lunaire T002 レースタンクトップ 29.99 15.00
トップス Lunaire T004 カシミアセーター 79.99 50.00
ジャケット Alpine グレー J001 ダウンジャケット 149.99 90.00
パンツ Axis カーキ P001 チノパンツ 59.99 35.00
パンツ Axis カーキ P002 スリムフィットジーンズ 69.99 40.00
パンツ Eclipse ネイビー P004 ジョガーパンツ 49.99 30.00

今回はこの商品データを「カテゴリ、ブランド、色」でグループ化を行い、以下のように合計を算出します。

カテゴリ ブランド ID 商品名 価格 原価
トップス Lunaire 159.97 90.00
ジャケット Alpine グレー 149.99 90.00
パンツ Axis カーキ 129.98 75.00
パンツ Eclipse ネイビー 49.99 30.00

成果物

クラス定義

商品データとグループ毎の集計結果を扱うためのクラスを定義します

商品ごとのレコードクラス
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE)
private static class ProductRecord {
        private String productId;
        private String category;
        @Nullable
        private String brand;
        @Nullable
        private String color;
        private String name;
        private Integer price;
        private Integer cost;
}
グループ毎の集計用クラス
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE)
private static class Stat {
        private Double price;
        private Double cost;
}

商品レコードのリストを作成

final List<ProductRecord> recordList = Arrays.asList(
    new ProductRecord("トップス", "Lunaire", "白", "T001", "シルクブラウス", 49.99, 25.00),
    new ProductRecord("トップス", "Lunaire", "白", "T002", "レースタンクトップ", 29.99, 15.00),
    new ProductRecord("トップス", "Lunaire", "白", "T004", "カシミアセーター", 79.99, 50.00),
    new ProductRecord("ジャケット", "Alpine", "グレー", "J001", "ダウンジャケット", 149.99, 90.00),
    new ProductRecord("パンツ", "Axis", "カーキ", "P001", "チノパンツ", 59.99, 35.00),
    new ProductRecord("パンツ", "Axis", "カーキ", "P002", "スリムフィットジーンズ", 69.99, 40.00),
    new ProductRecord("パンツ", "Eclipse", "ネイビー", "P004", "ジョガーパンツ", 49.99, 30.00)
)

グループ毎の集計

商品レコードを「カテゴリ、ブランド、色」でグループ化し、各グループの「価格」と「原価」の合計を求めます。

final Map<String, Map<String, Map<String, Stat>>> groupedMap = recordList.stream().collect(Collectors.groupingBy(
        ProductRecord::getCategory,
        Collectors.groupingBy(
                record -> Optional.ofNullable(record.getBrand()).orElse(""),
                Collectors.groupingBy(
                        record -> Optional.ofNullable(record.getColor()).orElse(""),
                        Collectors.reducing(
                                new Stat(),
                                record -> new Stat(record.getPrice(), record.getCost()),
                                (record, nextRecord) -> new Stat(
                                    record.getPrice() + nextRecord.getPrice(),
                                    record.getCost() + nextRecord.getCost()
                                )
                        )
                )
        )
));

集計結果のレコードを作成

グループ毎の合計を元に商品レコードを作成します

final List<ProductRecord> groupedRecordList = groupedMap.entrySet().stream()
        .flatMap(categoryEntry -> categoryEntry.getValue().entrySet().stream()
                .flatMap(brandEntry -> brandEntry.getValue().entrySet().stream()
                        .map(colorEntry -> ProductRecord.builder()
                                .productId(null)
                                .category(categoryEntry.getKey())
                                .brand(brandEntry.getKey())
                                .color(colorEntry.getKey())
                                .name(null)
                                .price(colorEntry.getValue().getPrice())
                                .cost(colorEntry.getValue().getCost())
                                .build()
                        )
                )
        ).toList();

処理の流れ

  1. 商品レコードを「カテゴリ、ブランド、色」でグループ化
  2. グループ毎に「価格、原価」を合計
  3. グループ毎の合計を元に商品レコードを作成

Stream APIとは

Java 8で導入された機能で、コレクションや配列などの要素を高階関数的に扱うことが出来るものです

Stream APIの概要
Streamを利用することで、一連のデータ処理を簡潔かつ直感的に記述できます。

Stream APIは、以下の3つの主要な操作で構成されます:

  1. ソース: コレクションや配列からStreamを生成
  2. 中間操作: フィルタリングやマッピングなどの操作を行い、新しいStreamを返す
  3. 終端操作: 結果を生成する操作で、Streamの操作を終了する

List.stream().collect
collect()メソッドは、ストリームの終端操作であり、ストリームの要素を収集してリストやマップなどの別のコレクションに変換します。

List<ProductRecord> productList = // ... 商品レコードのリスト
Stream<ProductRecord> productStream = productList.stream(); // リストをストリームに変換
List<ProductRecord> collectedList = productStream.collect(Collectors.toList()); // ストリームをリストに変換

Collectors.groupingBy
Collectors.groupingByは、ストリームの要素を指定したキーでグループ化するためのメソッドです。このメソッドは、グループ化の基準となる関数を受け取り、その基準に基づいて要素をマップに分割します。

Map<String, List<ProductRecord>> groupedByCategory = productList.stream()
    .collect(Collectors.groupingBy(ProductRecord::getCategory));

Collectors.reducing
Collectors.reducingは、ストリームの要素を集約するためのメソッドです。このメソッドは、初期値、集約関数、結合関数を受け取り、ストリームの要素を1つの結果にまとめます。

Map<String, Integer> totalPriceByCategory = productList.stream()
    .collect(Collectors.groupingBy(
        ProductRecord::getCategory,
        Collectors.reducing(
            0, //初期値
            ProductRecord::getPrice, //集約関数
            Integer::sum //結合関数
        )
    ));

実装

ここからは、具体的な処理の流れに従って実装を行います

1. 商品レコードを「カテゴリ、ブランド、色」でグループ化

この部分では、商品データを「カテゴリ」「ブランド」「色」の3つの属性でネストされたマップにグループ化しています。

final Map<String, Map<String, Map<String, List<ProductRecord>>>> groupedMap = recordList.stream().collect(Collectors.groupingBy(
    ProductRecord::getCategory,
    Collectors.groupingBy(
        record -> Optional.ofNullable(record.getBrand()).orElse(""),
        Collectors.groupingBy(
            record -> Optional.ofNullable(record.getColor()).orElse("")
        )
    )
));

1. ストリームの生成:
recordList.stream()は、商品レコードのリストからStreamを作成します。
2. カテゴリでグループ化:
Collectors.groupingBy(ProductRecord::getCategory)は、ストリームの要素をカテゴリ(getCategory)でグループ化します。

3. ブランドでネストされたグループ化:
Collectors.groupingBy(record -> Optional.ofNullable(record.getBrand()).orElse(""))は、各カテゴリの中でさらにブランドでグループ化します。ブランドがnullの場合は空文字列に置き換えます。

4. 色でさらにネストされたグループ化:
Collectors.groupingBy(record -> Optional.ofNullable(record.getColor()).orElse(""))は、各ブランドの中でさらに色でグループ化します。色がnullの場合は空文字列に置き換えます。
この結果、データはカテゴリ、ブランド、色の順にネストされたマップとして整理されます

2. グループ毎で「価格、原価」を合計

1で作成したものに、グループごとに価格と原価を合計する処理を追加します

- final Map<String, Map<String, Map<String, List<ProductRecord>>>> groupedMap = recordList.stream().collect(Collectors.groupingBy(
+ final Map<String, Map<String, Map<String, Stat>>> groupedMap = recordList.stream().collect(Collectors.groupingBy(
        ProductRecord::getCategory,
        Collectors.groupingBy(
                record -> Optional.ofNullable(record.getBrand()).orElse(""),
                Collectors.groupingBy(
                        record -> Optional.ofNullable(record.getColor()).orElse(""),
+                       Collectors.reducing(
+                               new Stat(),
+                               record ->
+                                   StatResult.builder()
+                                           .price(record.getPrice())
+                                           .cost(record.getCost())
+                                           .build(),
+                               (record, nextRecord) ->
+                                   new StatResult(
+                                           record.getPrice() + nextRecord.getPrice(),
+                                           record.getCost() + nextRecord.getCost(),
+                                   )
+                       )
                )
        )
));

価格と原価の合計:

  • Collectors.reducingは、グループごとの要素を集約するために使用します。
  • new Stat(0, 0)は、初期値として価格と原価が0のStatオブジェクトを設定します。
  • record -> new Stat(record.getPrice(), record.getCost())は、各商品レコードをStatオブジェクトに変換します。
  • (stat1, stat2) -> new Stat(stat1.getPrice() + stat2.getPrice(), stat1.getCost() + stat2.getCost())は、Statオブジェクトを合計します。

この結果、各グループごとに価格と原価の合計を持つStatオブジェクトが作成されます。

3. グループ毎の合計を元に商品レコードを作成

この部分では、グループごとの合計を使って新しい商品レコードのリストを作成します。

final List<ProductRecord> groupedRecordList = groupedMap.entrySet().stream()
    .flatMap(categoryEntry -> categoryEntry.getValue().entrySet().stream()
        .flatMap(brandEntry -> brandEntry.getValue().entrySet().stream()
            .map(colorEntry -> ProductRecord.builder()
                .productId(null)
                .category(categoryEntry.getKey())
                .brand(brandEntry.getKey())
                .color(colorEntry.getKey())
                .name(null)
                .price(colorEntry.getValue().getPrice())
                .cost(colorEntry.getValue().getCost())
                .build()
            )
        )
    ).collect(Collectors.toList());

1. 外側のエントリセットをストリームに変換:

  • groupedMap.entrySet().stream()は、カテゴリごとのエントリセットをストリームに変換します。
  • .entrySet()は、groupedMapの各エントリをセット(キーと値のペア)として取得します。

2. 中間のエントリセットをストリームに変換:
categoryEntry.getValue().entrySet().stream()は、各カテゴリごとのブランドエントリセットをストリームに変換します。

3. 内側のエントリセットをストリームに変換:
brandEntry.getValue().entrySet().stream()は、各ブランドごとの色エントリセットをストリームに変換します。

4. 新しい商品レコードの作成:

  • ProductRecord.builder()を使用して、新しい商品レコードを作成します。
  • categoryEntry.getKey(), brandEntry.getKey(), colorEntry.getKey()は、それぞれのカテゴリ、ブランド、色のキーを取得します。
  • colorEntry.getValue().getPrice(), colorEntry.getValue().getCost()は、それぞれのグループの価格と原価の合計を取得します。

5. ストリームの収集:
.collect(Collectors.toList())は、最終的に新しい商品レコードのリストにストリームの要素を収集します。

この結果、グループごとの合計を持つ新しい商品レコードのリストが作成されます。

参考文献

Discussion