この記事はZOZO Advent Calendar 2024 シリーズ8の10日目の記事です。
はじめに
2024年も終盤ですね。ちなみに今年のマイベスト技術書は関数型ドメインモデリングでした。「日本語で読みたいなあ」と思っていた矢先の出版、あまりに僥倖でした。1
この記事では、関数型ドメインモデリングでも紹介されていた、鉄道指向プログラミング(Railway Oriented Programming)
をJavaで実践する方法について、難しい言葉は使わずに説明していきます。
関数型という言葉だけでちょっとハードルが上がる気持ち、わかります。ですが安心してください。この記事は気楽に読み進めていただけると思います。2
Vavr
本題へと入る前に、Javaで関数型プログラミングを行うためのライブラリであるVavrについて、簡単に説明します。
この記事では主にEither
を利用します。
Either
はLeftとRightどちらかの値を返す型です。それぞれが成功と失敗を表すような使い方をする場合も多く、その場合はRightを成功として扱うのが(駄洒落による)慣習です。
下の例のようにEither
を導入することで、Javaでも例外機構を利用せず戻り値で成功と失敗を伝播することが容易になります。
import io.vavr.control.Either;
public class EitherExample {
record InputValue(Integer value) {}
record ValidatedValue(Integer value) {}
/**
* 例外で伝播する
*/
ValidatedValue validateUsingException(final InputValue inputValue) {
// 偶数はNG
if (inputValue.value() % 2 == 0)
throw new IllegalArgumentException("偶数はNGです");
return new ValidatedValue(inputValue.value());
}
/**
* 戻り値で伝播する(良い例ではないが失敗の場合はその理由を文字列で返す)
*/
Either<String, ValidatedValue> validateUsingEither(final InputValue inputValue) {
// 偶数はNG
if (inputValue.value() % 2 == 0)
return Either.left("偶数はNGです");
return Either.right(new ValidatedValue(inputValue.value()));
}
}
Vavrの詳しい利用方法はユーザーガイドをご覧ください。
鉄道指向プログラミング(Railway Oriented Programming)
鉄道指向プログラミング(Railway Oriented Programming)(以後、ROPと表記)は、関数型ドメインモデリングの著者でもあるScott Wlaschinさんが提唱したプログラミング手法です。
この後に実践しながら詳細を説明するので、ここでは超絶意訳で概要をまとめると、
ROPは、例外機構を使わずにエラー(いわゆる異常系)を伝播させるプログラミング手法においてノイズになりがちなエラーハンドリング処理をスマートに行うプログラミング手法です。
鉄道のレールを連結するかのようなアプローチを取るため鉄道指向プログラミングと呼んでいます。
もちろんROPについて、正確に詳細を知りたい方は上の原典を読んでいただくのが吉です。
JavaでROPを実践する
それでは実践していきます。
題材
原典に従って
- 検証(Validate) -> 更新(Update) -> 送信(Send) のステップを連続して行う
- いずれも失敗する可能性があり、失敗した場合には次のステップには進まない
- 全て成功した場合が正常系(Success)、それ以外は異常系(Failure)を戻り値として返す
といった典型的なユースケースを考えます。
素朴な実装
まずはROPを実践せずに、Vavrを用いて素朴に実装すると以下のようになります。
public class NaiveImplUseCase {
Either<FailedReason, Success> execute(final InputValue inputValue) {
// 検証ステップ
final Either<String, ValidatedValue> validateResult = validate(inputValue);
if (validateResult.isLeft())
return Either.left(FailedReason.VALIDATE_FAILED);
// 更新ステップ
final Either<String, Success> updateResult = update(validateResult.get());
if (updateResult.isLeft())
return Either.left(FailedReason.UPDATE_FAILED);
// 送信ステップ
final Either<String, Success> sendResult = send(validateResult.get());
if (sendResult.isLeft())
return Either.left(FailedReason.SEND_FAILED);
return Either.right(new Success());
}
record InputValue(Integer value) {}
record ValidatedValue(Integer value) {}
record Success(){}
enum FailedReason {
VALIDATE_FAILED,
UPDATE_FAILED,
SEND_FAILED
}
Either<String ,ValidatedValue> validate(final InputValue inputValue) {
// 省略
return Either.right(new ValidatedValue(inputValue.value()));
}
Either<String, Success> update(final ValidatedValue validatedValue) {
// 省略
return Either.right(new Success());
}
Either<String, Success> send(final ValidatedValue validatedValue) {
// 省略
return Either.right(new Success());
}
}
ROPの説明で課題に挙げた通り、execute
は各ステップの失敗(Left)のハンドリングにコードの半数ほどを費やしていることがわかります。
ROPを適用した実装
同様の題材でROPを実践してこのハンドリングをスマートに行います。
おまたせしました。やっと鉄道の話です。
題材にしたユースケースにおいて、各ステップはEither
でない引数を取り、Either
の戻り値(成功・失敗)を返していました。これを2つに分岐する鉄道のレールのように考えます。成功(Right)が緑で失敗(Left)は赤で表現されています。3
素朴な実装では、失敗を都度ハンドリングして早期リターンを記述していました。
「全て以下のルール従っているだけなので毎回ハンドリングしなくとも、スマートに記述できるのでは?」というのがROPのアイデアです。
- 成功した場合は次に進む
- 失敗した場合はそのまま戻り値を返す
視覚にはレールを連結するイメージです。
この連結したレールそのものがユースケース4となり、戻り値を返す処理は最後の一箇所のみ。つまり素朴に実装した際のような煩雑な失敗のハンドリングは不要となります。
イメージは簡単ですが、これを実際にソースコードに反映させるにはいくつかの工夫が必要です。それらはこの後じっくり説明していきますので、まずは完成形のソースコードをご覧ください。
import io.vavr.control.Either;
public class ROPUseCase {
Either<FailedReason, Success> execute(final InputValue inputValue) {
return validateWrapper(inputValue)
.flatMap(this::updateWrapper)
.flatMap(this::sendWrapper);
}
record InputValue(Integer value) {}
record ValidatedValue(Integer value) {}
record Success(){}
enum FailedReason {
VALIDATE_FAILED,
UPDATE_FAILED,
SEND_FAILED
}
Either<FailedReason, ValidatedValue> validateWrapper(final InputValue inputValue) {
return validate(inputValue).fold(
error -> Either.left(FailedReason.VALIDATE_FAILED),
Either::right
);
}
Either<FailedReason, ValidatedValue> updateWrapper(final ValidatedValue validatedValue) {
return update(validatedValue).fold(
error -> Either.left(FailedReason.UPDATE_FAILED),
success -> Either.right(validatedValue)
);
}
Either<FailedReason, Success> sendWrapper(final ValidatedValue validatedValue) {
return send(validatedValue).fold(
error -> Either.left(FailedReason.SEND_FAILED),
Either::right
);
}
Either<String ,ValidatedValue> validate(final InputValue inputValue) {
// 省略
return Either.right(new ValidatedValue(inputValue.value()));
}
Either<String, Success> update(final ValidatedValue validatedValue) {
// 省略
return Either.right(new Success());
}
Either<String, Success> send(final ValidatedValue validatedValue) {
// 省略
return Either.right(new Success());
}
}
この実装では、execute
は煩雑な失敗のハンドリングから解放されて圧倒的に簡潔になったことがわかります。ValidateとUpdateとSendという2分岐のレールが鉄道のように直列で連結している。 という視覚的なイメージがそのままソースコードに反映できています。
実装の解説
ここからはROPを実践したコードについてポイントごとに解説していきます。それぞれ関連があり、単体の説明だけでは理解が難しいかもしれません。完全に理解できなくても最後まで読み進めていただき、必要に応じて再度読んでいただけると理解しやすいと思います。5
①失敗レールの型を統一する
ROPを実践する上でのポイント一つ目は、失敗レール(赤い下側)の型を統一することです。
メソッドチェーンで連結したユースケースの中間の型は全てEitherかつLeftは常に同じ型
にすることが重要です。
コード例では、失敗の型(Left)をユースケースの失敗理由をまとめていたFailedReason
に統一しています。6
/**
* ユースケースの失敗理由
*/
enum FailedReason {
VALIDATE_FAILED,
UPDATE_FAILED,
SEND_FAILED
}
/**
* このユースケース固有の処理
*/
Either<FailedReason, ValidatedValue> validateWrapper(final InputValue inputValue) {
return validate(inputValue).fold(
error -> Either.left(FailedReason.VALIDATE_FAILED),
Either::right
);
}
/**
* 複数のユースケースで共有する処理
*/
Either<String ,ValidatedValue> validate(final InputValue inputValue) {
// 省略
return Either.right(new ValidatedValue(inputValue.value()));
}
②必要に応じてバケツリレーする
2つ目のポイントはバケツリレーです。
ROPで各ステップが引数として受け取ることができるのは、前のステップの戻り値、または最初のステップ以前に存在した値
のみです。そのため、必要に応じて値のバケツリレーが必要です。
先の例においてsend
ではValidatValue
が必要ですが、ROPに変更したことでsend
が受け取ることのできる値はInputValueとupdateの戻り値
のみです。
そこでupdate
がValidatValue
をバケツリレーして戻り値として戻すようにラッパー関数を用意します。
/**
* このユースケース固有の処理
*/
Either<FailedReason, ValidatedValue> updateWrapper(final ValidatedValue validatedValue) {
return update(validatedValue).fold(
error -> Either.left(FailedReason.UPDATE_FAILED),
success -> Either.right(validatedValue)
);
}
/**
* 複数のユースケースで共有する処理
*/
Either<String, Success> update(final ValidatedValue validatedValue) {
// 省略
return Either.right(new Success());
}
③flatMapで連結する
①②のポイントを押さえた上で、flatMap
で各ステップを連結します。
flatMapはMap処理を実行したのちその結果を平坦(flat)にします。
例えばListを平坦にするとはList<List<Hoge>> -> List<Hoge>
ということです。
Eitherの場合には、Either<Fuga, Either<Fuga, Hoge>> -> Either<Fuga, Hoge>
です。
validateWrapper(inputValue)
.flatMap(this::updateWrapper)
flatMap
で連結したこのソースコードの挙動について詳しく説明します。
validateWrapper(inputValue)
の戻り値の型はEither<FailedReason, ValidatedValue>
です。これに対してflatMap
を呼び出すと、まずは以下のいずれかの処理がなされます(ここまではmap
と同じ)。
- Left(
FailedReason
)の場合は何もしない - Right(
ValidatedValue
)の場合はupateWrapper
を呼び出す
upateWrapper
はEither<FailedReason, ValidatedValue>
を返します。そのため実行後の型はEither<FailedReason, Either<FailedReason, ValidatedValue>>
になります。
次にこれを平坦にする処理がなされます。(map
の場合はしない)
その結果Either<FailedReason, ValidatedValue>
です。
失敗(Left)の型を統一した意味がわかったと思います。型が異なる場合にはflatMap
の呼び出しはできずコンパイルエラーが発生します。
これらのポイントを理解した上で改めてソースコードを見ていただけるとROPの全体像が掴めるはずです。
まとめ
この記事では、入力値を検証して更新して通知する。という典型的なユースケースを題材に、JavaでVavrを用いてROPを実践する方法について解説しました。
自分の所属する開発チームではROPを実際に取り入れています。
miroなどを用いて視覚的に設計し作図した後にコーディングを行うことが多いため、図と実装がわかりやすく対応する点も気に入っています。
この記事では紹介していませんが、業務レベルで実践するうえでは部分適用などのテクニックも必須になってくるのでこの記事に反響があれば紹介します。
最後にひとつ。このやり方を覚えるとガンガンROPを適用したくなるのですが(経験談)、どうやら提唱者本人がAgainst Railway-Oriented Programming内でROPをどこでも使えばいいってものではないよ。と注意喚起しているようなので適材適所で使っていきましょう(自戒)。
-
t-wadaさんの尽力もあったらしいですね、本当に関係者各位ありがとうございます。 ↩
-
この記事の主題である鉄道指向プログラミングを提唱した本人もAbstraction, intuition, and the “monad tutorial fallacy”にて、具体的なことから始めて、抽象なものへ進むという教育的アプローチを支持しているようです。この記事でも抽象的な説明はせず具体例について説明してきます。(筆者が関数型プログラミングあまりわかっていないので出来ないだけ) ↩
-
引用元の都合で、UpdateがUpdateDb、SendがSendEmailに変化していますが、そこは気にしないでください。 ↩
-
関数型ドメインモデリングではワークフローと呼んでいます。 ↩
-
一重に筆者の構成能力不足です。すいません。 ↩
-
この記事では各ステップをユースケースクラスの内部に実装しています。ただ実際には、複数のユースケースから共通して利用される処理として独立した別のクラスに実装されている可能性も高いです。そのためステップのシグネチャを変更するのではなく、ラッパーしたメソッド作成しています。また同じクラスにある都合上Wrapperを末尾につけた少々ダサい命名となっていますが別のクラスに書かれていればこのような命名にする必要はないです。 ↩