15
10

More than 3 years have passed since last update.

【Java】【Stream API】オレオレCollectorによるヘッダ・明細Bean生成で学ぶ独自Collectorの実装方法

Last updated at Posted at 2019-12-15

この記事は、Java Advent Calendar 2019 16日目の記事です。
※この記事のソースはJava11を前提にしています。


2014年にリリースされたJava8より、Stream APIが追加されて、関数型言語のパラダイムが入り、「その気になれば」モダンなプログラミングスタイルでコレクションを扱うことができるようになりました。

Java8リリース当時、Stream APIもラムダ式も禁止だとか話題になったことがあったような気もしますが、現在はどうでしょうか:sweat_smile:

Java8のリリースから5年経ち、ここ数年で私の関わるエンタープライズな現場でStream APIを導入していると、終端操作でStream#collect(toList())Listにした後に、ゴニョゴニョしていたり、Stream#forEachのラムダ式が数十行もある実装を見かけ、それが至るところに存在しているなんてこともあったので、独自にCollectorを作って解決できないか・・・と思ったのが、独自Collectorを作ろうと思ったきっかけで、そのときの思考プロセスを含めて、書いていきたいと思います。

JDKビルトインのStream APIで物足りないとき・・・

すでにJavaは半年サイクルでメジャーバージョンアップするライフサイクルになっており、進化のスピードが早くなっています。

Stream APIも進化を続けており、例えばJava10でイミュータブルなコレクションを作るtoUnmodifiableListtoUnmodifiableMapなんかが追加されていたり、Java12で2つのCollectorを適用&マージして返すteeingが追加されていたりしますが、痒いところに手が届かなかったり、ドメイン固有なロジックなんかは、そもそも難しいですよね。

Stream APIを拡張しようと考えたときに、アプローチとしては大きく二つになるかと思います。

①Stream自体を拡張したクラスを作成する

java.util.stream.BaseStreamを拡張して、独自Streamクラスを作ってしまう方法です。

例えば、下記ライブラリのEntryStreamを使うアプローチですね。

amaembo/streamex: Enhancing Java 8 Streams
https://github.com/amaembo/streamex

このアプローチだと、filterしてfindFirstするのが冗長なんだよな・・・みたいな不満も、findAny(Predicate)のようなメソッドを作ることで解決することができます。

👍メリット

  • 中間操作、終端操作のどちらも拡張が可能

👎デメリット

  • 利用しているライブラリが標準Streamを返すAPIを持っている場合、独自Streamへと変換が必要

②独自Collectorを作って、Stream#collectに渡す

java.util.stream.Collectorsのように、java.util.stream.Collectorインスタンスを返す静的メソッドをクラスを作り、Stream#collectに渡す方法です。

上記のstreamexでは、MoreCollectorsを使うアプローチです。
Eclipse CollectionsのCollectors2も同様ですね。

👍メリット

  • 利用しているライブラリを選ばずに利用が可能

👎デメリット

  • 中間操作を拡張することができない
  • 終端操作のcollectでしか利用できない

考察

前述の通り、今後のJavaのバージョンアップと共にStream APIも進化していくと思いますし、
①独自Streamクラスは、ソースの移植性に不安もあるかなということで、
今回は②独自Collectorの実装方法について書きたいと思います。

Collectorとは

Collectorは、Stream APIにおける終端操作の代表格であるcollectメソッドに引数として渡して利用されるオブジェクトです。

JDKビルトインとして、java.util.stream.Collectorsクラスが静的メソッドとして、汎用的なものを提供しており、例えばListに変換する場合は、
Collectors.toList()を利用します。

Stream#collectの一つ目のオーバーロードですね。

修飾子と型 メソッド 説明
<R,A> R collect(Collector<? super T,A,R> collector) Collectorを使ってこのストリームの要素に対する可変リダクション操作を実行します。
<R> R collect(Supplier<R> supplier, BiConsumer<R,? super T> accumulator, BiConsumer<R,R> combiner) このストリームの要素に対して可変リダクション操作を実行します。
Stream.of("Tanaka", "Yamada", "Takahashi").collect(Collectors.toList());
// ==> [Tanaka, Yamada, Takahashi]

ちなみに、静的インポートを利用して、下記のように実装しているのも、よく見かけると思います。

import static java.util.stream.Collectors.*;

Stream.of("Tanaka", "Yamada", "Takahashi").collect(toList());
// ==> [Tanaka, Yamada, Takahashi]

二つ目のオーバーロードはCollectorの内部ロジックとも言うべき、supplieraccumulatorcombinerをラムダ式で渡すことができますが、CollectorインタフェースのofメソッドでCollectorのインスタンスを作成したほうが再利用性が高いため、局所的な場合に限り使う形かと思います。

参考:リダクション操作と可変リダクション操作

リダクション操作とは、一連の入力要素を受け取り、結合操作を繰り返し適用することでそれらを結合し、単一のサマリー結果を出力する操作です(Javadocより)。

JavaのStreamにおけるリダクション操作は、reduceメソッドを使って実現します。

// 1から5までを合算する例
Stream.of(1, 2, 3, 4, 5).reduce(0, (x,y) -> x+y);
// ==> 15

上述の(x,y) -> x+yが、アキュムレータ関数(累積関数、蓄積関数)と呼ばれています。

次に、可変リダクション操作とは、ストリーム内の要素を処理する際に、可変結果コンテナ(CollectionやStringBuilderなど)に入力要素を蓄積し、その結果コンテナを返す操作です。

JavaのStreamにおける可変リダクション操作は、collectメソッドを使って実現します。
collectする際に適用するロジックをまとめたものが、この後作成するCollectorということになります。

独自Collectorの作成

独自にCollectorを作成する場合、java.util.stream.Collectorimplementsしたクラスを作成するか、上述のCollector#ofを利用するかの二つの方法がありますが、今回は記述量も少なくて済む後述のCollector#ofを利用する方法で実装します。

今回はお題として、Stream<Map<String, Object>>をヘッダ・明細型のBeanにマッピングして返す」としました。

往々にして、エンタープライズの世界では、ActiveRecordパターンで済ませようとすると性能上問題になることが多く、割と複雑なSQLで実現することも多かったりするのですが、そういった場合、List<Map<String, Object>>で結果を受けて、2次元の表として保持はしつつも、UIはコンポジットな構造を求めているみたいなケースがあります。

例えば、ヘッダ・明細型(ヘッダが1件に対し、明細がN件。JPAだとOneToManyのケース)の場合、ActiveRecordではヘッダを取得するSQL、明細を複数件取得するSQLの2つのSQLを発行することになりますが、性能的な問題やER構造上の問題から1SQLで取得した場合、今度はマッピングが問題になります(いわゆるインピーダンスミスマッチ)。

それでは、前置きはこのくらいにして、実装に入っていきます。

ヘッダ(Header)と明細(Detail)のBean

Header.java
public class Header {
    private int id;

    private String description;

    private List<Detail> details = new ArrayList<>();

    public int getId() {
        return this.id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getDescription() {
        return this.description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public List<Detail> getDetails() {
        return this.details;
    }

    public void setDetails(List<Detail> details) {
        this.details = details;
    }
}
Detail.java
public class Detail {
    private int id;

    private int price;

    public int getId() {
        return this.id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public int getPrice() {
        return this.price;
    }

    public void setPrice(int price) {
        this.price = price;
    }
}

独自Collectorを使わないパターン

まずは、独自Collectorを作らずに実装した場合にどうなるか?から見ていきましょう。

独自Collectorを使わないパターン
// 変換元のリスト
var list = List.of(
    Map.of("id", 1, "description", "desc1", "detailId", 101, "price", 1000),
    Map.of("id", 1, "description", "desc1", "detailId", 102, "price", 1500),
    Map.of("id", 1, "description", "desc1", "detailId", 103, "price", 2000)
);

// ヘッダのマッピング
var header = new Header();
header.setId((int)list.get(0).get("id"));
header.setDescription((String)list.get(0).get("description"));

// 明細のマッピング
list.forEach(e -> {
    var detail = new Detail();
    detail.setId((int)e.get("detailId"));
    detail.setPrice((int)e.get("price"));
    header.getDetails().add(detail);
});

// header ==>
// Header[id=1,description=desc1,
//    details=java.util.ArrayList{
//      Detail[id=101,price=1000],
//      Detail[id=102,price=1500],
//      Detail[id=103,price=2000]}]

愚直に書くと、こんな感じになりますね。。。
List#forEachの部分がラムダ式になっているとはいえ、昔ながらのJavaです。

独自Collectorを使うパターン

いよいよ本題の独自Collectorです。
型引数だらけで、初見殺しなフィーリングがありますが、後ほど、解説します。

CustomCollectors.java
import java.util.Map;
import java.util.Objects;
import java.util.function.BiConsumer;
import java.util.function.Supplier;
import java.util.stream.Collector;

import org.apache.commons.beanutils.BeanUtils;

public class CustomCollectors {

    /**
     * Mapを要素とするStreamの内容をヘッダー・明細型のBeanに変換します.
     *
     * @param <T> Mapの型引数
     * @param <H> ヘッダーの型引数
     * @param <D> 明細の型引数
     * @param headerSupplier ヘッダーのインスタンスを返す{@link Supplier}
     * @param detailSupplier 明細のインスタンスを返す{@link Supplier}
     * @param headerMapper ヘッダーとMapのマッピングを行う{@link BiConsumer}、利用しない場合は{@link #biNoop()}が利用できます
     * @param detailMapper 明細とMapのマッピングを行う{@link BiConsumer}、利用しない場合は{@link #biNoop()}が利用できます
     * @param headerDetailMapper ヘッダーと明細のマッピングを行う{@link BiConsumer}
     * @return {@link Collector}
     */
    public static <T extends Map<String, ?>, H, D> Collector<T, H, H> headerDetails(
            final Supplier<H> headerSupplier,
            final Supplier<D> detailSupplier,
            final BiConsumer<H, T> headerMapper,
            final BiConsumer<D, T> detailMapper,
            final BiConsumer<H, D> headerDetailMapper) {
        final var counter = new int[] { 0 };
        return Collector.of(
                Objects.requireNonNull(headerSupplier),
                (header, map) -> {
                    // Streamの最初の要素のみヘッダーのマッピングを行う
                    if (counter[0]++ == 0) {
                        BeanUtils.populate(map, header);
                        if (Objects.nonNull(headerMapper)) {
                            headerMapper.accept(header, map);
                        }
                    }
                    var detail = Objects.requireNonNull(detailSupplier).get();
                    BeanUtils.populate(map, detail);
                    if (Objects.nonNull(detailMapper)) {
                        detailMapper.accept(detail, map);
                    }
                    if (Objects.nonNull(headerDetailMapper)) {
                        headerDetailMapper.accept(header, detail);
                    }
                },
                (accume, header) -> {
                    return accume;
                },
                accume -> {
                    return counter[0] == 0 ? null : accume;
                });
    }

    /**
     * BiConsumerで何もしない場合に利用します.
     *
     * @param <A> 1番目の型引数
     * @param <B> 2番目の型引数
     * @return {@link BiConsumer}
     */
    public static <A, B> BiConsumer<A, B> biNoop() {
        return (a, b) -> {};
    }
}
独自Collectorを使うパターン
// import staticでCustomCollectors.*を静的インポートしておく

// 変換元のリスト
var list = List.of(
    Map.of("id", 1, "description", "desc1", "detailId", 101, "price", 1000),
    Map.of("id", 1, "description", "desc1", "detailId", 102, "price", 1500),
    Map.of("id", 1, "description", "desc1", "detailId", 103, "price", 2000)
);

var header = list.stream()
    .collect(headerDetails(Header::new, Detail::new, biNoop(),
        (detail, map) -> {
            detail.setId((int)map.get("detailId"));
        },
        (header, detail) -> {
            header.getDetails().add(detail);
        }));

// header ==>
// Header[id=1,description=desc1,
//    details=java.util.ArrayList{
//      Detail[id=101,price=1000],
//      Detail[id=102,price=1500],
//      Detail[id=103,price=2000]}]

というわけで、Stream API流れを断ち切ることなく、目的のHeaderを生成することができました。
ブラックボックス感が増した気もしますが:sunglasses:

ちなみに、実際に現場で作ったものは、割り切りでもう少しドメイン固有なロジックを入れて、シンプルに記述できるようにはしています。

独自Collectorの解説

これから独自Collectorで何をやっているかを見ていきます。

CustomCollectors#headerDetails

まずはシグネチャです。

<T extends Map<String, ?>, H, D> Collector<T, H, H> headerDetails(
            final Supplier<H> headerSupplier,
            final Supplier<D> detailSupplier,
            final BiConsumer<H, T> headerMapper,
            final BiConsumer<D, T> detailMapper,
            final BiConsumer<H, D> headerDetailMapper)

型引数は下記を示しています。

  • T - Mapの型引数(Streamの要素 = Map<String, ?>
  • H - ヘッダーの型引数(Headerクラス)
  • D - 明細の型引数(Detailクラス`)

Collector<T, H, H>は後述しますが、今回のヘッダ・明細型のBeanの生成においては、TMapを入力として、Headerを返す処理で、中間蓄積と最終結果が同じHとなるため、Collector<T, H, H>となります。

Supplier<H> headerSupplier

ヘッダーのインスタンスを返すSupplierです。
呼び出し側で、Header::newのコンストラクタ参照を指定しています。

Supplier<D> detailSupplier

明細のインスタンスを返すSupplierです。
呼び出し側で、Detail::newのコンストラクタ参照を指定しています。

BiConsumer<H, T> headerMapper

ヘッダーとMapのマッピングを行うBiConsumerです。
基本的には、BeanUtils#populateで、Mapのキー名とBeanのフィールド名が合致すれば、値がコピーされているのですが、名前が合致しない場合はこの関数で実施する想定にしています。

今回のサンプルでは、名前が合致しているため、CustomCollectorsに用意した、何もしないBiConsumerであるbiNoopを呼び出し側で指定しています。

BiConsumer<D, T> detailMapper

明細とMapのマッピングを行うBiConsumerです。
ヘッダと同じく、基本的には値がコピーされているのですが、名前が合致しない場合はこの関数で実施する想定にしています。

今回のサンプルでは、HeaderにもDetailにもidフィールドが存在しており、MapではDetailiddetailIdというキーで格納されているため、呼び出し側で(detail, map) -> { detail.setId((int)map.get("detailId")); } というラムダ式を指定して、不一致のフィールドの値をコピーしています。

BiConsumer<H, D> headerDetailMapper

ヘッダーと明細のマッピングを行うBiConsumerです。
呼び出し側で、(header, detail) -> { header.getDetails().add(detail); }というラムダ式を指定して、Header#detailsリストに追加しています。


java.util.stream.Collector#ofの部分の実装

headerDetailsの内部はjava.util.stream.Collector#ofによって、Collectorのインスタンスを返しているだけです。

Collector自体については、先人の記事「Java8 Streamのリダクション操作について」で数式と共に詳しく解説されていますので、ここでは簡潔に最低限の説明に留めます。

ポイントとなるjava.util.stream.Collector#ofですが、下記の通り、2つのオーバーロードがあります。
第4引数のfinisherがあるかどうか、またfinisher有無によって型引数が<T,A,R>なのか<T,R,R>なのかが異なります。

修飾子と型 メソッド 説明
static <T,A,R> Collector<T,A,R> of(Supplier<A> supplier, BiConsumer<A,T> accumulator, BinaryOperator<A> combiner, Function<A,R> finisher, Collector.Characteristics... characteristics) 指定されたsupplier、accumulator、combiner、およびfinisher関数で記述される新しいCollectorを返します。
static <T,R> Collector<T,R,R> of(Supplier<R> supplier, BiConsumer<R,T> accumulator, BinaryOperator<R> combiner, Collector.Characteristics... characteristics) 指定されたsupplier、accumulator、およびcombiner関数で記述される新しいCollectorを返します。

今回の独自Collectorでは、finisherがあるオーバーロードを使用しています。

<T,A,R> - 型引数

まず、Collectorの型引数である<T,A,R>を理解する必要があります。

  • T - 入力要素の型
  • A - 中間蓄積の型
  • R - 最終結果の型

finisherがなければ、A中間蓄積の型とR最終結果の型が同一のため、<T,R,R>になるってわけですね。

supplier - サプライヤ(生成)関数

サプライヤー関数は、A中間蓄積の型を生成する関数を指定します。
finisherがなければ、R`最終結果の型になります。

この関数はStreamが0件の場合でも、必ず呼び出されることに注意する必要があります。

独自Collectorでは、Objects.requireNonNull(headerSupplier)の実装が相当し、
呼出側でHeader::newのコンストラクタ参照を指定している箇所になります。

accumulator - アキュームレータ(累積、蓄積)関数

前述の参考:リダクション操作と可変リダクション操作で触れた関数です。

この関数は、第1引数にA中間蓄積の型のインスタンス、第2引数にT入力要素の型のインスタンスが渡されて呼び出されます。
第1引数は、サプライヤ関数で生成したインスタンス、第2引数はStreamの入力要素です。

第2引数の入力要素を第1引数に蓄積していく処理になるため、基本的に第1引数はコレクションをはじめとする結果コンテナになるというわけです。

独自Collectorでは、入力要素であるMap<String, Object>Header,Detailにそれぞれのクラスへとマッピングして、Header#detailsのリストに追加していく処理が相当します。

Collector#ofのスコープ外で、final var counter = new int[] { 0 };を宣言しておき、Streamの1件目のときだけ、MapからHeaderへの値のコピーやheaderMapperの呼出を行うようにしています。

ちなみに、counterを配列にしているのは、ラムダ式内でスコープ外の変数の代入は許可されていないからですね。

combiner - コンバイナ(結合)関数

この関数は、第1引数、第2引数ともにA中間蓄積の型のインスタンスが渡されます。
この記事では詳細は触れませんが、並列ストリームを使って、結果コンテナが複数存在する場合、その結果コンテナ同士を結合するための処理を行います。

独自Collectorでは、並列ストリームをサポートしていないので、(accume, header) -> { return accume; }と実装しており、何もしていません。

finisher - フィニッシャ(最終変換)関数

この関数は、A中間蓄積の型のインスタンスを引数に受けて、R最終結果の型のインスタンスを返します。
引数で渡されるのは、アキュームレータ関数で蓄積したインスタンスですので、これを最終変換するための関数です。

独自Collectorでは、A中間蓄積の型もR最終結果の型もHeaderクラスであり、同一のため変換は不要なのですが、上述の通り、サプライヤ関数がHeaderのインスタンスを0件でも生成してしまうため、0件の場合にnewしただけのHeaderが返ってきてしまうと、呼び出し側は使いづらいため、0件の場合には、nullを返す処理を入れています。
(もちろん、ここでOptionalにするという手もあります)

最後に

今回のサンプルでは、あまりメリットを伝えきれなかったかなと思いますが、自分が独自Collectorを作るには、そもそもどうしたら?などを悩んだとき、日本語リソースが少なかったので、なるべく思考プロセスと共に振り返りながら記事にしてみました。

とっつきにくい印象のあるCollectorの自作ですが、Collector#ofを理解してしまえば、比較的少ない記述量にて実装できることがわかって頂けたのなら幸いです。

Javaの進化と共に、さらにStream APIが便利になっていくことを祈って:pray:、結びとしたいと思います。

15
10
1

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
15
10