kotlin-resultで改善するKotlinの例外ハンドリング

はじめに

こんにちは。LINEヤフー株式会社で、出前館のサーバーサイド開発を担当している松崎です。

出前館は、日本最大級のデリバリーサービスを展開している会社です。

開発においてはマイクロサービスアーキテクチャを採用しており、私たちが担当しているマイクロサービスはSpring BootとKotlinで実装しています。

Motivation

皆さんは例外処理で次のような苦労をした経験はありませんか?

  • 意図せずthrowされた例外が共通例外ハンドラで処理されたが、発生箇所の特定が難しい
  • 呼び出す関数がthrowする例外を全て把握できないので、仕方なく Exception を catch してしまったり、catch漏れが発生している
  • 依存ライブラリの実行時エラーと業務エラーをどちらも例外として扱っているので、例外ハンドリング処理が煩雑になっている

私たちのチームではそれらの問題を解決するために kotlin-result ライブラリを導入し、例外ではなくResult型でエラーを表現する手法を採用しました。

その結果、関数で発生しうるエラーが明確化され、エラーハンドリング漏れをコンパイル時に検出できるようになりました。

Kotlinにおける例外ハンドリングの難しさ

Kotlinでは全ての例外が非検査例外(Unchecked Exceptions)として扱われるため、呼び出す関数がどのような例外をスローする可能性があるのかをメソッドシグネチャから知ることができません。

(Throwsアノテーションは存在しますが、Kotlin同士の呼び出しでは例外ハンドリングの実装は強制されません)

関数の実装を読めば例外の種類は分かりますが、関数呼び出し階層が深くなるとすべての潜在的な例外を正しく把握することは困難です。

その結果、例外ハンドリングの実装漏れが発生し、本番環境で予期しない実行時エラーが発生してしまいます。

(これはKotlinに限らず、処理のエラーを例外で表現するプログラミング言語全般に言える課題かもしれません)

解決アプローチの選択肢

以下の2つが満たせればエラーを適切にハンドリングできると思います。

  • 呼ばれる関数: 「関数実行が成功したか失敗したか、失敗した場合はどのエラーが発生したのか」を関数の呼び出し元に伝える
  • 関数を呼ぶ側: 呼び出した関数が返す可能性があるエラーを網羅的にハンドリングする

それらを実現するための手段として、例えばScalaにおける Either や、 Rustにおける Result があり、Kotlinでよく使われてるものは以下があります。

私達のチームでは、kotlin-resultを選択しました。

  • Kotlin標準のResult
    • Pros
      • 標準ライブラリ
        • runCatchingとの統合
          e.g. fun toInt(s: String): Result<Int> = runCatching { s.toInt() }
    • Cons
      • エラーをThrowableでしか表現できない
      • 例えば、戻り値がResultという関数があるとして、その関数が返すエラーがわからない(関数を使う側は、その関数が「成功時にIntを返す」ことと「失敗時に何らかの例外を返す」ことしかわからない)ので、関数を呼び出す側で網羅的なエラーハンドリングが難しい
  • ArrowのEither
  • kotlin-result
    • Pros
      • (ArrowのEitherと比較すると)Resultだけなので導入しやすい
      • エラーを任意の型で表現できる (Throwableを継承する必要はない)
      • 関数の戻り値にエラーの型が明示されるので、関数を呼び出す側で網羅的なエラーハンドリングが実装しやすい


FYI: KotlinにRich Errorsというエラーのための共用体型(Error Union Types)」を導入しようというプロポーザルがあります。

将来的にRich Errorsが実装されたら有効な選択肢になるかもしれません。

kotlin-resultによる解決アプローチ

kotlin-resultは、先ほど述べた「例外ハンドリングの難しさ」を型安全性によって解決します。

関数がエラーを返す可能性がある場合、戻り値をResult<T, E>型にすることで、メソッドシグネチャ自体が「この関数はエラーが発生する可能性がある」ことを明示します。

また、呼び出し元は成功時と失敗時の両方のケースを処理する必要があり、エラーハンドリングの実装漏れを防ぐことができます。

シンプルなコード例

import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Result

// 従来の例外処理
fun divide(a: Int, b: Int): Int {
    if (b == 0) throw IllegalArgumentException("ゼロ除算エラー")
    return a / b
}

// kotlin-resultを使用
fun divideWithResult(a: Int, b: Int): Result<Int, String> {
    return if (b == 0) {
        Err("ゼロ除算エラー")
    } else {
        Ok(a / b)
    }
}

Resultを使ったコード例

もう少し具体的なコード例を見てみましょう。

ユーザを登録するUseCaseとModelの実装例です。

アプリケーションレイヤー

import com.github.michaelbull.result.Result
import com.github.michaelbull.result.mapError
import com.github.michaelbull.result.zip
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

// UseCase
@Service
class UserUseCase(
    private val transactionHelper: TransactionHelper,
    private val userRepository: UserRepository,
) {
    @Transactional
    fun create(
        request: UserCreateRequestDto
    ): Result<UserResponseDto, CreateError> = zip(
        // enumに変換
        {
            Position.of(request.position)
                .mapError {
                    // Position.of関数がErrを返した場合、create関数の戻り値に変換する
                    CreateError.EnumConvertError(it.message)
                }
        },
        {
            MailAddress.of(request.mailAddress)
                .mapError {
                    // MailAddress.of関数がErrを返した場合、create関数の戻り値に変換する
                    CreateError.InvalidMailAddressError
                }
        }
    ) { position, mailAddress ->
        // 上記のzip関数内で呼ぶ関数がErrを返さなければ、このブロックが実行される
        val user = User.create(
            name = request.name,
            position = position,
            mailAddress = mailAddress
        )

        userRepository.create(user)

        UserResponseDto(
            id = user.id.toString(),
            name = user.name,
            position = user.position.toString(),
            mailAddress = user.mailAddress.value
        )
    }
}

// UseCaseのエラー
sealed interface CreateError {
    data class ExceptionOccurredError(val exception: Exception) : CreateError
    data class EnumConvertError(val message: String) : CreateError
    data object InvalidMailAddressError : CreateError
}

ドメインレイヤー

import com.github.michaelbull.result.Result
import com.github.michaelbull.result.toResultOr

// Position (ValueObject)
enum class Position {
    ENGINEER,
    SENIOR_ENGINEER,
    STAFF_ENGINEER,
    MANAGER,
    ...

    companion object {
        private val map = entries.associateBy { it.toString() }

        fun of(s: String): Result<Position, EnumConvertError> {
            map[s].toResultOr { EnumConvertError("unknown position: $s") }
        }
    }
}

// Positionのエラー
data class EnumConvertError(val message: String)

// ---------------------------------------------------------------------------------

// MailAddress (ValueObject)
class MailAddress private constructor(
    val value: String
) {
    companion object {
        fun of(
            value: String
        ): Result<MailAddress, InvalidMailAddressError> = validate(value)
            .map {
                MailAddress(value)
            }

        private fun validate(value: String): Result<Unit, InvalidMailAddressError> {
            val isValid = EmailValidator.getInstance()
                .isValid(value)
            return if (isValid) {
                Ok(Unit)
            } else {
                Err(InvalidMailAddressError)
            }
        }
    }
}

// MailAddressのError
data object InvalidMailAddressError

Resultを返す関数の呼び出しをbindingとbindを使って改善する

Resultを返す関数を複数呼ぶ場合、上記のUserUseCase#createの様にzip関数を使ってそれらの関数をまとめる必要があります。

関数呼び出しが多いと実装しにくい・読みにくいと感じるかもしれません。

その場合、bindingとbindを使うとスッキリします。

bind() は Result が Ok の場合は値を取り出し、Err の場合はその時点で binding ブロック全体を Err として終了させます。

import com.github.michaelbull.result.Result
import com.github.michaelbull.result.binding
import com.github.michaelbull.result.mapError

@Transactional
fun create(
    request: UserCreateRequestDto
): Result<UserResponseDto, CreateError> = binding {
    val position = Position.of(request.position)
        .mapError {
            CreateError.EnumConvertError(it.message)
        }
        .bind()

    val mailAddress = MailAddress.of(request.mailAddress)
        .mapError {
            CreateError.InvalidMailAddressError
        }
        .bind()

    val user = User.create(
        name = request.name,
        position = position,
        mailAddress = mailAddress
    )

    userRepository.create(user)

    UserResponseDto(
        id = user.id.toString(),
        name = user.name,
        position = user.position.toString(),
        mailAddress = user.mailAddress.value
    )
}

Spring Bootとの組み合わせて使う場合

関数の戻り値としてResultを使う場合、その関数では例外をThrowする事は避けた方が良いです。

関数を呼び出す側がResultと例外を両方ハンドリングしなければいけなくなるためです。

とは言え、Springでは以下のような実装を行う事が多いため、kotlin-resultを導入するとしても例外を使った実装を完全に無くす事は難しいです。


TransactionalアノテーションとResultを組み合わせで実装する場合、Transactionalアノテーションを付けた関数ではResultから例外に変換する。

もしくは、

Transactionalアノテーションを使わずに、例外やエラー発生のロールバックをPlatformTransactionManagerやTransactionTemplateを使って自分で実装する。
という方法が考えられます。


TransactionTemplateを使う方が良いのではないかと思っています。

例えば、以下のようなHelperを実装し、トランザクション境界となる関数でそのHelperを使ってトランザクション処理を行えば、コントローラーでのServiceクラスの関数呼び出し時のエラーハンドリングはResultとして扱いつつトランザクション処理も行えます。

import com.github.michaelbull.result.BindingScope
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.onFailure
import org.springframework.stereotype.Component
import org.springframework.transaction.support.TransactionTemplate

@Component
class TransactionHelper(
    private val transactionTemplate: TransactionTemplate,
) {
    fun <V, E> binding(
        onException: (Exception) -> E,
        block: BindingScope<E>.() -> V
    ): Result<V, E> = try {
        transactionTemplate.execute { status ->
            com.github.michaelbull.result.binding(block)
                .onFailure { status.setRollbackOnly() }
        }
    } catch (e: Exception) {
        Err(onException(e))
    }
}

アプリケーションレイヤー

// UseCase
@Service
class UserUseCase(
    private val transactionHelper: TransactionHelper,
    private val userRepository: UserRepository,
) {
    fun create(
        request: UserCreateRequestDto
    ): Result<UserResponseDto, CreateError> = transactionHelper.binding(
        // 例外が発生した場合に、例外をErrorに変換する処理
        onException = CreateError::fromException
    ) {
        // このブロックがトランザクション処理される
        // 例外が発生するか、実行した関数の戻り値がErrの場合はロールバック
        val position = Position.of(request.position)
            .mapError { CreateError.EnumConvertError(it.message) }
            .bind()

        val mailAddress = MailAddress.of(request.mailAddress)
            .mapError { CreateError.InvalidMailAddressError }
            .bind()

        val user = User.create(
            name = request.name,
            position = position,
            mailAddress = mailAddress
        )

        userRepository.create(user)

        UserResponseDto(
            id = user.id.toString(),
            name = user.name,
            position = user.position.toString(),
            mailAddress = user.mailAddress.value
        )
    }
}

// UserUseCase#createのエラー
sealed interface CreateError {
    companion object {
        fun fromException(e: Exception): CreateError = ExceptionOccurredError(e)
    }
    data class ExceptionOccurredError(val exception: Exception) : CreateError
    data class EnumConvertError(val message: String) : CreateError
    data object InvalidMailAddressError : CreateError
}

プレゼンテーションレイヤー

import com.github.michaelbull.result.getOrThrow

// Controller
@RestController
@RequestMapping("/user")
class UserController(
    private val userUseCase: UserUseCase
) {
    @PostMapping
    fun create(
        @RequestBody request: UserCreateRequestDto
    ): ResponseEntity<UserResponseDto> = userUseCase.create(
        request
    ).getOrThrow { err ->
        // UserUseCase#createで発生しうるエラーを網羅的にハンドリング
        // この例ではRestControllerAdviceで共通的にエラー処理したいのでErrを例外に変換
        when (err) {
            is CreateError.EnumConvertError -> BadRequestException(err.message)
            is CreateError.InvalidMailAddressError -> BadRequestException("Invalid address")
            is CreateError.ExceptionOccurredError -> err.exception
        }
    }.let {
        ResponseEntity(it, HttpStatus.CREATED)
    }
}

// RestControllerAdviceで共通的な例外処理を行うための例外クラス
class BadRequestException(override val message: String) : RuntimeException(message)
class NotFoundException(override val message: String) : RuntimeException(message)
@RestControllerAdvice
class CommonExceptionHandler {
    @ExceptionHandler(BadRequestException::class)
    fun handleException(e: BadRequestException): ResponseEntity<String> {
        return ResponseEntity(e.message, HttpStatus.BAD_REQUEST)
    }

    @ExceptionHandler(NotFoundException::class)
    fun handleException(e: NotFoundException): ResponseEntity<String> {
        return ResponseEntity(e.message, HttpStatus.NOT_FOUND)
    }

    @ExceptionHandler(Exception::class)
    fun handleException(e: Exception): ResponseEntity<String> {
        return ResponseEntity("internal server error", HttpStatus.INTERNAL_SERVER_ERROR)
    }
}

おわりに

私達のチームにおけるkotlin-resultを使ったエラーハンドリングについてご紹介しました。

例外ハンドリングの難しさを感じている場合には有効な選択肢だと思います。

採用情報

出前館ではサーバーサイドエンジニアを募集しています。

Kotlinでのサーバーサイド開発にご興味を持っていただけましたら、ぜひ以下のページもお読みください。

Mission Vision Value:

採用ページ: