はじめに
本記事は Kotlin Advent Calendar 2019の1日目の記事です。
今回は前回当該ブログでも紹介しました、https://github.com/michaelbull/kotlin-resultについて事例をつけてご紹介させていただければと思います。
michaelbull/kotlin-resultの概要
github.com
この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抜きで俯瞰して見たい方は以下のサイトにある動画を見ると非常にわかりやすいです。
fsharpforfunandprofit.com
一応、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> =
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 ->
bindEntityRepository.create(id)
.flatMap {
entityRepository.findById(id)
.flatMap { dao ->
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? =
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アプリで使った場合どうなるんだろう?」と思った方はぜひご覧になってみてください。
github.com