はじめに
本記事は Kotlin Advent Calendar 2019の1日目の記事です。 今回は前回当該ブログでも紹介しました、https://github.com/michaelbull/kotlin-resultについて事例をつけてご紹介させていただければと思います。
michaelbull/kotlin-resultの概要
このgithubのイントロダクションが全てなのですが、
The Result monad has two subtypes, Ok
representing success and containing a value, and Err , representing failure and containing an error. Scott Wlaschin's article on Railway Oriented Programming is a great introduction to the benefits of modelling operations using the Result type.
Mappings are available on the wiki to assist those with experience using the Result type in other languages:
書いてある通りで、kotlinに実装されてないResultモナドをライブラリによって実装し、安全かつ強力に「結果」を取り扱おうというライブラリです。
Result は特に Rust Scala Haskell といった言語を扱った人がいれば、その強力さたるや実感できると思います。
ちなみ「どう強力なのか?」をKotlin抜きで俯瞰して見たい方は以下のサイトにある動画を見ると非常にわかりやすいです。
一応、kotlinにも Result Class は存在するのですが、これは例外を処理するためのResultで戻り値の型として利用できません。
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-result/index.html
このため、今回紹介する michaelbull/kotlin-result は便宜上、 Return Result として題名には書かせていただきました。
どんなものなの
fun returnResult(): Result<String, Error> = TODO() fun basicResult() = returnResult() .mapBoth( success = { str -> println(str) }, failure = { err -> println(err) } )
上記に書いたちょっぴり抽象的な概念とは思えぬ、非常にシンプルな動きをします。
つまり、 Result というのは ジェネリクスClass ってだけで、Ok と Err の2種類の data class を返すだけです。
この記事ではこのライブラリが特に強力な「エラーハンドルのスマート化」に注目し、
結果を返す動作 を michaelbull/kotlin-result で記述する事で以下の事が達成できます。
- 結果(OkかErr)によって処理をスマートに変える
- nullableな結果に対するエラーハンドリングもスマートに
- エラーに共通処理を実装してコードを簡素化に
- 例えthorwableな結果も安全に受け取ってエラーハンドルできる
さて、それぞれのユースケースをコードと共に見ていきましょう。
結果(OkかErr)によって処理をスマートに変える
最大の特徴な訳ですが、例えばwebサーバ(今回はサーバにktorを使ったとします)で Result クラスに生えている mapBoth() を使って、
処理が成功したら 200 OK、失敗したら500 Internal Server Errorを返す例です.
(おそらく本当に作る場合は EntityList, Error は何かレスポンス用にJSONとかに変換したりの処理が入るでしょうが、一旦略します。 )
fun dbAction(): Result<EntityList, Error> = // DBの処理 fun Route.exampleController() { route("/example") { get { dbAction() .mapBoth( success = { entityList -> call.respond(HttpStatusCode.OK, EntityList) }, failure = { error -> call.respond(HttpStatusCode.InternalServerError, error.toString()) } ) } } }
このように、非常にシンプルで可読性の高く、安全なコードを書くことができます。
mapBoth() は、
inline fun <V, E, U> Result<V, E>.mapBoth(success: (V) -> U, failure: (E) -> U): U { return when (this) { is Ok -> success(value) is Err -> failure(error) } }
のようなインライン関数で、レシーバの Result が Ok か Err かどちらかの data class かで処理を分けます。
見ていただければわかりますが、 dbActionが成功したら何をするか、失敗したら何をするかが1発でわかりますね。
これにより、例えば 成功する動作の場合にentityListがnullかどうか や 失敗する動作の方で謝ってentityListを参照してしまう みたいなことが起きなくなります(nullable型だらけで全部チェックしなきゃ、みたいな事も無くなります)。
また、この Result はfunctional programmingの必殺奥義 flatmapを使うことが可能です。
fun createEntity(value: String): Result<Entity, Error> = entityRepository.insert(value) .flatMap { id -> // entityRepository.insert(value)が戻り値 `Ok` なら次に進む bindEntityRepository.create(id) .flatMap { // bindEntityRepository.create(id)が戻り値 `Ok` なら次に進む entityRepository.findById(id) .flatMap { dao -> // entityRepository.findById(id)が戻り値 `Ok` なら次に進む Ok(Entity( dao.id, dao.name, dao.created, )) } } }
上記のように、いくつかの処理があった時、 成功した時だけ次の処理に言って、失敗したらearlyReturnして失敗値を返したい というニーズを満たすことができ、いわゆる コールバック地獄 を回避してスマートに処理を書くことができます。
余談ですが、おそらく michaelbull/kotlin-result の作者さんはRustが好きらしく、
inline infix fun <V, E, U> Result<V, E>.flatMap(transform: (V) -> Result<U, E>): Result<U, E> { return andThen(transform) } inline infix fun <V, E, U> Result<V, E>.andThen(transform: (V) -> Result<U, E>): Result<U, E> { return when (this) { is Ok -> transform(value) is Err -> this } }
Rustで使われる andThen も flatMap も同じ処理を行いますし、なんなら flatMap は andThen のコールバックみたいです 😆
nullableな結果に対するエラーハンドリングもスマートに
michaelbull/kotlin-result には toResultOr というResultクラスがもちメソッドがあり、このメソッドは kotlinの特徴的実装である、 nullable に対応します。
inline infix fun <V, E> V?.toResultOr(error: () -> E): Result<V, E> { return when (this) { null -> Err(error()) else -> Ok(this) } }
処理の見て通りで、nullならErrorを返すラムダを書いて、そうでなければそのまま Ok クラスで包んで返す処理とします。
fun dbAction(): Entity? = // DBの処理 fun findById(key: Int): Result<Count, EnumError> = dbAction() .toResultOr { EnumError.NotFoundEntityFailure.withLog() } .flatMap { Ok(Entity) }
これにより、kotlinならではの nullableな戻り値も Result 型と表現でき、 1つ前の項目で言ったような flatmap のコンビネータに kotlinのnullableを組むこむことができます。
エラーに共通処理を実装してコードを簡素化に
こちらはどちらかというとライブラリを応用したテクニックになります。
当然? Kotlin はGoのような error 型のようなものは無いので、以下のように自分で作る必要があります。
なるべく typealiasより自分で data class や enum class を作った方が型の制約的な意味で安全です。
data class SimpleError( val reason: String, ) enum class EnumError { ArrayIndexOutOfBoundsFailure, ParseParamaterFailure, ParseRequestFailure, MismatchDataStoreFailure, CouldNotCreateEntityFailure, NotFoundEntityFailure; }
加えて、例えば このエラーが発生したらロギングしたい 通知したい などのニーズがあった場合、
メソッドを生やしてしまいます。
僕がある現場で使ってるところは、以下のように生やして、スタックトレースを繋げて出すようにしています。
fun EnumError.withLog(reason: String = this.name): EnumError { var stackTrace = "" Thread.currentThread().stackTrace.forEach { stackTrace += it } return this.also { KotlinLogging.logger {}.error("$reason - $stackTrace") } }
これが何かいいというと、アプリ全体のコードを表現した際、必ず例外処理に michaelbull/kotlin-result で結果を表現するときにエラーを定義することになります。なので、エラーの定義部分で忘れずにロギングすることができるというわけです。
fun findById(key: Int): Result<Count, EnumError> = RedisContext.zscore(globalJedisPool.resource, CountListKey, key.toString()) .toResultOr { EnumError.NotFoundEntityFailure.withLog() } .flatMap { Ok(Count( CountRow( key.toString(), it ) )) }
もちろん、「え、絶対ロギングするし、そんな毎回 .withLog() したくないんだが」って場合は、
Err と表現するクラスのコンストラクタ処理のところにロギングの処理を書いていただければいいと思います。
例えthorwableな結果も安全に受け取ってエラーハンドルできる
さて最後、普通にkotlinを書いていても、AWSなどのJava SDKを利用したときに、どうしても 例外(JavaのException) が帰ってくる可能性があります。せっかくここまで細かく例外処理を 型 として処理できるようにしたのに、こいつが混ざると台無しです。
もちろん、こいつにも対応できます、でもこれは kotlin標準のResult で、ですが(笑)
fun createSubList(rowList: List<Int>): Result<List<Int>, EnumError> = runCatching { rowList.subList(0, 10) }.fold( onSuccess = { Ok(it) }, onFailure = { Err(EnumError.ArrayIndexOutOfBoundsFailure.withLog(it.toString())) } )
まず Listクラスについているような、 subList モジュールは、正しく無い引数を入れると境界線例外を起こすことはご存知かと思います。
kotlinの場合、runCatching .fold を使うことによって、例外をおこしたときに安全に例外を取り出すことができます。
public inline fun <T, R> T.runCatching(block: T.() -> R): Result<R> { return try { Result.success(block()) } catch (e: Throwable) { Result.failure(e) } }
public inline fun <R, T> Result<T>.fold( onSuccess: (value: T) -> R, onFailure: (exception: Throwable) -> R ): R { contract { callsInPlace(onSuccess, InvocationKind.AT_MOST_ONCE) callsInPlace(onFailure, InvocationKind.AT_MOST_ONCE) } return when (val exception = exceptionOrNull()) { null -> onSuccess(value as T) else -> onFailure(exception) } }
この上記の Result はkotlin謹製の Result なので注意を・・・。
これらを使えば、あとは例外だったとき、ではなかったときに分けて Ok Err それぞれの data class に包めば例外も型の世界で処理していくことができます。
おわりに
僕の運営している勉強会の方で使用しているアプリで、全面的に michaelbull/kotlin-result を使用しているので、
「実際webアプリで使った場合どうなるんだろう?」と思った方はぜひご覧になってみてください。