この記事は、Java Advent Calendar 2019 16日目の記事です。
※この記事のソースはJava11を前提にしています。
2014年にリリースされたJava8より、Stream API
が追加されて、関数型言語のパラダイムが入り、「その気になれば」モダンなプログラミングスタイルでコレクションを扱うことができるようになりました。
Java8リリース当時、Stream API
もラムダ式も禁止だとか話題になったことがあったような気もしますが、現在はどうでしょうか
Java8のリリースから5年経ち、ここ数年で私の関わるエンタープライズな現場でStream API
を導入していると、終端操作でStream#collect(toList())
でList
にした後に、ゴニョゴニョしていたり、Stream#forEach
のラムダ式が数十行もある実装を見かけ、それが至るところに存在しているなんてこともあったので、独自にCollectorを作って解決できないか・・・と思ったのが、独自Collectorを作ろうと思ったきっかけで、そのときの思考プロセスを含めて、書いていきたいと思います。
JDKビルトインのStream APIで物足りないとき・・・
すでにJavaは半年サイクルでメジャーバージョンアップするライフサイクルになっており、進化のスピードが早くなっています。
Stream API
も進化を続けており、例えばJava10でイミュータブルなコレクションを作るtoUnmodifiableList
やtoUnmodifiableMap
なんかが追加されていたり、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の内部ロジックとも言うべき、supplier
、accumulator
、combiner
をラムダ式で渡すことができますが、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.Collector
をimplements
したクラスを作成するか、上述の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
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;
}
}
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を作らずに実装した場合にどうなるか?から見ていきましょう。
// 変換元のリスト
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です。
型引数だらけで、初見殺しなフィーリングがありますが、後ほど、解説します。
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) -> {};
}
}
// 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
を生成することができました。
ブラックボックス感が増した気もしますが
ちなみに、実際に現場で作ったものは、割り切りでもう少しドメイン固有なロジックを入れて、シンプルに記述できるようにはしています。
独自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の生成においては、T
のMap
を入力として、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ではDetail
のid
がdetailId
というキーで格納されているため、呼び出し側で(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
が便利になっていくことを祈って、結びとしたいと思います。