Java
java8
StreamAPI
OriginalTech FunDay 20

いまさら始めるJava8 ~StreamAPI~

前回:いまさら始めるJava8 ~forEachとラムダ式~

続いてはStreamAPI編です。

StreamAPIとは

Java8から導入された、配列やCollectionに対する処理(集約操作)を記述することができるAPIです。
要素の集合に対して以下の手順で操作します。

  1. 生成:配列、Collection、ファイル等からStreamを生成する。
  2. 中間操作:Streamに対して、条件を指定しての抽出、ソート、値の加工などを行う。
  3. 終端操作:StreamからCollectionへの変換、集計、最大値/最小値の取得などを行う。終端操作は1つのStreamに対して1回のみ実行できる。

Java7までの書き方とはがらっと変わってしまうので、例題を使ってまとめてみます。

例題

  • 注文ファイルには、商品コードと注文数量がある
  • 商品マスタファイルには、商品コードと商品名がある
  • ある注文ファイルを読み取り、商品コード、商品マスタから抽出した商品名、注文数量をファイルに出力するプログラムを作成する
  • 商品マスタに商品コードのレコードがない場合、出力ファイルの商品名は空欄とする
  • 商品コードの昇順にソートされていること

入力ファイル仕様

共通

  • CSV形式とする
  • ヘッダ行は含めない

注文ファイル

  • ファイル項目は商品コード,注文数量とする
order.csv
BBJ001,300
AES010,20
BBJ005,100
BBJ001,50
DIH999,10
AES010,150

商品名マスタ

  • ファイル項目は商品コード,商品名とする
item.csv
BBJ001,ボールペン黒
BBJ005,ボールペン赤
AES010,消しゴム

出力ファイル仕様

  • CSV形式とする
  • ヘッダ行は含めない
  • ファイル項目は商品コード,商品名,数量とする
order_collected.csv
DIH999,,10
BBJ005,ボールペン赤,100
BBJ001,ボールペン黒,300
BBJ001,ボールペン黒,50
AES010,消しゴム,20
AES010,消しゴム,150

手順

この例題をコードに落とすならば、このような手順になるかとおもいます。

  1. 商品名マスタを読み込む
  2. 1行を商品コード、商品名に分解する
  3. key:商品コード,value:商品名のMapにする
  4. 注文ファイルを読み込む
  5. 注文ファイルを商品コードでソートする
  6. 商品名マスタのMapから、注文ファイルの商品名を埋める
  7. ファイルに出力する

StreamAPIでの実装

手順1. 商品名マスタを読み込む

Stream<String> java.nio.file.Files#lines(Path) で、ファイル内のすべての行をもつStreamを生成します。
Java7以前はReader等をいちいち生成して読み込んでたものが

Java7版ファイル読込
BufferedReader reader = new BufferedReader(new FileReader("files/stream/item.csv"));
String line = null;
while ((line = reader.readLine()) != null) {
    // 1行分の処理いろいろ
}

こうなりました。

StreamAPI版ファイル読込
Stream<String> itemStream = Files.lines(Paths.get("files/stream/item.csv"));

手順2. 1行を商品コード、商品名に分解する

前手順で作成したStreamの各要素を分解します。

Stream<String[]> itemStream2 = itemStream .map(line -> line.split(","));

<R> Stream<R> map(Function<? super T,? extends R> mapper)

中間操作mapは、Streamの各要素に対して引数のラムダ式を適用した結果のStreamを返します。
今回はラムダ式 line -> line.split(",")を渡しています。

Stream<String> itemStream の1つの要素 line ごとに line.split(",") の処理をして Stream<String[]> 型のStreamを生成しました。

BBJ001,ボールペン黒   // ⇒ [BBJ001,ボールペン黒]
BBJ005,ボールペン赤   // ⇒ [BBJ005,ボールペン赤]
・・・

重要なのは、Streamの中間操作はStreamを返すことです。中間操作の結果に対して中間操作、終端操作ができます。

手順3. key:商品コード,value:商品名のMapにする(商品マップ)

Streamのままでは後の手順の商品名割り当てで扱いにくいので、Mapに変換します。

Map<String, String> items = itemStream2.collect(Collectors.toMap(item -> item[0], item -> item[1]));

<R,A> R collect(Collector<? super T,A,R> collector)

終端操作 collect はStreamの各要素に対してあるルール(Collector)を適用した結果を返します。
よく使うルールはCollectorsに用意されています。

ここではMapを返すCollector toMap(<MapのKeyを作るラムダ式>, <MapのValueを作るラムダ式>) を利用します。

itemStream2はString[]のStreamなので、配列の0番目の要素をKey、1番目の要素をValueとするMapを生成しました。

商品名マスタまとめ

Streamの中間操作はStreamを返すため、Streamの生成からMapへの変換までをチェインで書くことができます

// 商品名ファイル読み込み
Map<String, String> items = Files.lines(Paths.get("files/stream/item.csv") // Stream<String>
    // ファイルの1行を配列に変換(Mapへの下準備)
    .map(line -> line.split(","))                                      // Stream<String[]>
    // Mapに変換
    .collect(Collectors.toMap(item -> item[0], item -> item[1]));      // Map<String, String>

手順4. 注文ファイルを読み込む(生成)

続いて注文ファイルの処理に入ります。
1行分のデータを分解するところまでは商品名マスタと同様です。

Stream<String> orderStream = Files.lines(Paths.get("files/stream/order.csv"))
Stream<OrderDto> orderStream2 = orderStream
                // ファイルの1行を配列に変換(Dtoへの下準備)
                .map(line -> line.split(","))
                // 配列を納品Dtoに変換
                .map(array -> makeOrder(array));

","で分割したのち、makeOrder(String[])でOrderDtoにマッピングしました。

private static OrderDto makeOrder(String[] array) {
    OrderDto order = new OrderDto();
    int idx = 0;
    order.setItemCode(array[idx++]);
    order.setOrderNum(Integer.parseInt(array[idx++]));
    return order;
}

手順5. 注文ファイルを商品コードでソートする(中間操作)

商品コードの昇順にソートします。

Stream<OrderDto> orderStream3 = orderStream2.sorted((item1, item2) -> item1.getItemCode().compareTo(item2.getItemCode()));

Stream<T> sorted(Comparator<? super T> comparator)

Comparatorはオブジェクト同士の比較ルールを定義する関数型インタフェースです。
インタフェースComparableを実装していれば、引数をもたないsorted()を使うこともできます。

インタフェースComparableでデフォルトのソート順を決めておき、必要なときにはComparatorを使いラムダ式で簡潔に再定義することができます。

手順6. 商品名マスタのMapから、注文ファイルの商品名を埋める(中間操作)

商品マップを商品コードで検索し、ヒットしたらその商品名を、ヒットしなければ空文字を埋めます。

Stream<OrderDto> orderStream4 = orderStream2.map(order -> {
                    order.setItemName(Optional.ofNullable(items.get(order.getItemCode())).orElse(""));
                    return order;
                });

再度mapを使います。
Optionalについてはここでは詳しい解説はしませんが、Nullのときのデフォルト値を決める機能として使っています。

手順7. ファイルに出力する(終端操作)

データが揃ったのでファイルに出力します。

try (BufferedWriter writer = new BufferedWriter(new FileWriter("files/stream/order_collected.csv")) ) {
    orderList.forEach(order -> {
        try {
            writer.write(makeLine(order));
            writer.newLine();
        } catch (IOException e) {e.printStackTrace();return;}
    });
}

void forEach(Consumer<? super T> action)

前回ラムダ式の解説に使った終端操作forEachは、各要素に対して引数で指定した操作を実行します。

完成

public class StreamMain {

    public static void main(String[] args) {
        try (Stream<String> orderStream = Files.lines(Paths.get("files/stream/order.csv"));
                Stream<String> itemStream = Files.lines(Paths.get("files/stream/item.csv"))){

            // 注文ファイル読み込み
            Map<String, String> items = itemStream
                    // ファイルの1行を配列に変換(Mapへの下準備)
                    .map(line -> line.split(","))
                    // Mapに変換
                    .collect(Collectors.toMap(item -> item[0], item -> item[1]));

            // 商品名ファイル読み込み
            Stream<OrderDto> orderList = orderStream
                // ファイルの1行を配列に変換(Dtoへの下準備)
                .map(line -> line.split(","))
                // 配列を納品Dtoに変換
                .map(array -> makeOrder(array))
                // ソート
                .sorted((item1, item2) -> item1.getItemCode().compareTo(item2.getItemCode()))
                // マッチング
                .map(order -> {
                    order.setItemName(Optional.ofNullable(items.get(order.getItemCode())).orElse(""));
                    return order;
                });

            // 出力
            try (BufferedWriter writer = new BufferedWriter(new FileWriter("files/stream/order_collected.csv")) ) {
                orderList.forEach(order -> {
                    try {
                        writer.write(makeLine(order));
                        writer.newLine();
                    } catch (IOException e) {e.printStackTrace();return;}
                });
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private static String makeLine(OrderDto order) {
        StringBuilder line = new StringBuilder();
        line.append(order.getItemCode());
        line.append(',');
        line.append(order.getItemName());
        line.append(',');
        line.append(order.getOrderNum());
        return line.toString();
    }

    private static OrderDto makeOrder(String[] array) {
        OrderDto order = new OrderDto();
        int idx = 0;
        order.setItemCode(array[idx++]);
        order.setOrderNum(Integer.parseInt(array[idx++]));
        return order;
    }
}

Re:StreamAPIとは

例題を解くにあたって最初に手順を整理しました。

  1. 商品名マスタを読み込む
  2. 1行を商品コード、商品名に分解する
  3. key:商品コード,value:商品名のMapにする

おそらく、Java7以前のコードでは商品名マスタを読み込むforループときにMapの生成までしてしまうとおもいます。

Java7
for (/*要素の集合*/) {
    // 要素1個分への処理いろいろ
}

ところで、StreamAPIは、要素の集合に対して操作を行います。
代表的な操作は、map(要素を別の要素に変換する)、filter(要素の中から条件にあったものを抽出する/今回未使用)、sorted(要素を並び替える) などです。
さらに中間操作の戻り値はStreamであるので、最初に要素の集合を作っておき、連続して操作していくという使い方が向いています。

Java8
Stream obj = // 要素の集合をStreamに変換
  // objのなかの要素を変換したり
  .map(/*変換ルール*/)
  // フィルタリングしたり
  .filter(/*抽出ルール*/);

StreamAPIを使う際には要素への単純な操作の組み合わせになるよう手順を考えるとよいようです。

下記の記事がとても参考になりました。
Java8でJava8っぽいコードを書く

おまけ

商品コードごとの注文数量の合計も求めるようにしたかったのですが、上手い書き方が調べきれなかったのであきらめました。
無理矢理やったバージョンの供養コードを残していくので何か良い手がありましたらご教示ください。

// 商品名ファイル読み込み
Stream<OrderDto> orderList = orderStream
    // ファイルの1行を配列に変換(Dtoへの下準備)
    .map(line -> line.split(","))
    // 配列を納品Dtoに変換
    .map(array -> makeOrder(array));

// 集計のために商品コードごとにグループ化
Map<String, List<OrderDto>> grouping = orderList.collect(Collectors.groupingBy(order->order.getItemCode()));
// グループ化した要素ごとに注文数量を合計
orderList = grouping.values()
        .stream()
        .map(orders->{
            // 要素のうちどれか1つをピックアップ
            OrderDto order = orders.stream().findAny().get();
            // 注文数量に要素全ての合計数を格納
            order.setOrderNum(orders.stream().mapToInt(oo ->oo.getOrderNum()).sum());
            return order;
        })
        // ソート
        .sorted((item1, item2) -> item1.getItemCode().compareTo(item2.getItemCode()))
        // 商品名を設定
        .map(order -> {
            order.setItemName(Optional.ofNullable(items.get(order.getItemCode())).orElse(""));
            return order;
        });