📝

検査例外にさよなら!KotlinのRailway Oriented Programming by kotlin-result

2024/06/21に公開

はじめに

惜しくも(?) Kotlin Fest 2024で採択とならなかったセッションの供養を行います。とはいえ、全ての内容を網羅することはせず、かいつまんで話したかった内容を書いていきます。

https://fortee.jp/kotlin-fest-2024

Railway Oriented Programmingとは?

Railway Oriented ProgrammingとはScott Wlaschin氏によって提唱された設計です。
詳細は全て無料でこちらから見れるのでぜひチェックしてみてください。

https://fsharpforfunandprofit.com/rop/

簡単にいうとRailway Oriented Programmingとは正常ケースと異常ケースの2つのレールを型で表現しながら設計をする手法です。

関数型プログラミングでは、RustのResultやScalaのEitherに代表される成功値かエラー値かのどちらか一方の値を持ったデータ構造を使ってエラーハンドリングを行います。以下はRustのResultの例です。

fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err("Cannot divide by zero".to_string())
    } else {
        Ok(a / b)
    }
}

fn main() {
    match divide(10, 2) {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error: {}", e),
    }

    match divide(10, 0) {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error: {}", e),
    }
}

https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html?highlight=Result#recoverable-errors-with-result

しかし、これをこのままログラスのサーバサイドKotlin + オニオンアーキテクチャの中に組み込むには難がありました。
今回はRailway Oriented Programmingの基本概念、そもそもResult、Eitherなどの例外を使わないエラーハンドリング、そして関数型プログラミング言語で作られていないプロジェクト(今回はKotlin)の上手い塩梅の導入方法を紹介します。

Kotlinの例外エラーハンドリング

Kotlinは例外機構をサポートしています。

https://kotlinlang.org/docs/exceptions.html

しかしこの例外機構は以下二つの問題があります。

  1. エラーハンドリングが呼び元で強制されない
  2. どのエラーをハンドリングするべきかわからない

KotlinはJavaのような検査例外をサポートしていません。
検査例外は以下のように記述することで関数の呼び元で例外のハンドリングを強制させることです。
検査例外があることで上記の1,2の問題を解消できます。

public class FileReadingExample {

    // ファイルを読み込むメソッドで、IOExceptionをスローする
    public void readFile(String filePath) throws IOException {
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new FileReader(filePath));
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } finally {
            if (reader != null) {
                reader.close();
            }
        }
    }

    public static void main(String[] args) {
        FileReadingExample example = new FileReadingExample();
        String filePath = "example.txt"; // 読み込むファイルのパス

        try {
            example.readFile(filePath);
        } catch (IOException e) {
            System.out.println("An error occurred while reading the file: " + e.getMessage());
        }
    }
}

検査例外をサポートしていない例外機構によって何が起こるこというと、例外の検査漏れが発生します。
さらに、ある関数がどの例外を投げてくるかわからず、ハンドリングしようにもそのメソッドの中身をわざわざ見て確認しに行く必要があります。

class FileReadingExample {

    fun readFile(filePath: String) {
        var reader: BufferedReader? = null
        try {
            reader = BufferedReader(FileReader(filePath))
            var line: String?
            while (reader.readLine().also { line = it } != null) {
                println(line)
            }
        } finally {
            reader?.close()
        }
    }
}

fun main() {
    val example = FileReadingExample()
    val filePath = "example.txt"
    
    // エラーハンドリングを強制できない
    example.readFile(filePath)
}

また、Kotlinは標準でResultクラスを用意しています。
このResultクラスはハンドリングをしないと成功値やエラー値を取り出すことができないため1の課題「エラーハンドリングが呼び元で強制されない」を解決できます。
しかし、型情報にどの例外が投げられるか指定することができないため結局関数の中身を見るまではその関数の投げる例外を特定することはできません。
さらにエラーハンドリング自体は強制できるものの、特定の例外についてハンドリングさせることなどはできません。

// Kotlinで標準Resultクラスを使う例
fun divide(a: Int, b: Int): Result<Int> {
    return if (b == 0) {
        Result.failure(ArithmeticException("Cannot divide by zero"))
    } else {
        Result.success(a / b)
    }
}

fun main() {
    val result = divide(10, 0)
    result.onFailure { e -> println("Error: ${e.message}") }
    result.onSuccess { value -> println("Result: $value") }
}

kotlin-result

Kotlinの例外機構の問題を見てきましたが、次はKotlinでRustのResultのようなエラーハンドリングをするにはどうすればいいかを説明します。
Kotlinで上記のような関数型のエラーハンドリングをするなら現実的な選択肢としてArrow.ktかkotlin-resultが挙げられます。

https://github.com/michaelbull/kotlin-result
https://arrow-kt.io/

Arrow.ktはResult型に限らず関数型プログラミングにおける多種多様な機能を提供しています。
しかしログラスでは単にエラーハンドリング自体を型情報で表現してハンドリング漏れを防止したいという課題をミニマムに解決する策としてkotlin-resultを採用しました。

詳しい使い方や導入した所感などは以下を見てください。
https://zenn.dev/loglass/articles/try-using-kotlin-result

KotlinのRailway Oriented Programming

改めて、Railway Oriented Programmingについて説明します。Railway Oriented Programmingとは正常ケースと異常ケースの2つのレールを型で表現しながら設計をする手法です。
正常ケースと異常ケースの二つをレールとなぞらえて Railway Orientedと呼んでいます。

例えばユーザーを更新する処理を考えてみます。この処理にはバリデーションとユーザー情報の更新とメール送信が含まれます。

  1. Validate: リクエストをバリデーション
  2. Update: 更新
  3. Send: メールを送信

例外を使った従来の処理としては以下のような図が挙げられます。

https://fsharpforfunandprofit.com/rop/ 22ページ目から引用

Validateが失敗した場合例外を投げ、Updateが失敗した場合も同様に例外を投げ、Sendが失敗した場合も同様に例外を投げます。
例外は例外を投げられた瞬間にその関数の実行を中断し、例外を投げた関数の呼び出し元に例外を投げます。この処理フローはいわばGoTo文に近く、処理が複雑になればなるほどコントロールが難しくなります。

さらにKotlinに限っていえば、Validate, Update, Sendでそれぞれどのような例外を投げてくるのかわからず、ハンドリングも強制されないため、おかしなエラーメッセージをユーザーに返してしまうこともあります。

そこで下記の図のように正常ケースと異常ケース二つのフローを持つように設計することで、例外を使った従来の処理の問題を解決します。


https://fsharpforfunandprofit.com/rop/ 25ページ目から引用

実際にkotlin-resultを使った処理を書いておきます。
引用したスライドにはUpdateと書いてありますが、実際にコードにするときはユーザーを取得するなどの処理を挟まるので今回はよりシンプルにユーザーの作成処理(Create)として実装しています。


// バリデーション
fun validateRegisterUserRequest(
    request: RegisterUserRequest,
): Result<ValidatedRegisterUserRequest, ValidateRegisterUserRequestError> { return TODO() }

// ユーザー作成
fun createUser(
    request: ValidatedRegisterUserRequest,
): Result<User, CreateUserError> { return TODO() }

// メールを送信
fun sendCreateUserNotificationEmail(
    createdUser: User,
): Result<User, SeneCreateUserNotificationEmailError> { return TODO() }

// ユーザー登録処理。各処理を連結
fun registerUser(
    request: RegisterUserRequest,
): Result<User, RegisterUserError> {
    return validateCreateUserRequest(request)
        .mapError { error -> 
            // なにかしらのエラーハンドリングを強制 
        }
        .andThen { validatedRequest -> 
            createUser(validatedRequest)
                .mapError { error -> 
                    // 何かしらのエラーハンドリングを強制
                }  
        }
        .andThen { createdUser -> 
            sendUpdateUserNotificationEmail(createdUser)
                .mapError { error -> 
                    // 何かしらのエラーハンドリングを強制
                }
        }
}

次は実際にTry Catch構文で書くとどうなるか記述します。
型情報としてバリデーションやメール送信処理がどのような例外を投げてくるのかわかりづらく、ハンドリングもどのようなエラーに対して行うべきかよくわかりません。

// バリデーション
fun validateRegisterUserRequest(
    request: RegisterUserRequest,
): Unit { return TODO() }

// ユーザー作成
fun createUser(
    request: RegisterUserRequest,
): User { return TODO() }

// メールを送信
fun sendCreateUserNotificationEmail(
    createdUser: User,
): Unit { return TODO() }

// 処理を連結
fun registerUser(
    request: RegisterUserRequest,
): Unit {
    try {
        validateCreateUserRequest(request)
        val createdUser = createUser(validatedRequest)
        sendUpdateUserNotificationEmail(it, createdUser)
    } catch (e: Exception) {
        TODO()
    }
}

オニオンアーキテクチャへの組み込み

例をシンプルにするために上記のようなトップレベルの関数だけで説明しましたが、現実ではこのような実装は多くはありません。次はより具体的な例としてサーバサイドを前提にオニオンアーキテクチャでの実装例を紹介します。
オニオンアーキテクチャについての説明は省略します。


引用: 新卒にも伝わるドメイン駆動設計のアーキテクチャ説明(オニオンアーキテクチャ)[DDD]

ログラスでは、部分的にこのRailway Oriented Programming の実装パターンを導入しています。

この時以下のルールで運用しています。

  1. ドメイン層はResultを返す
  2. ユースケース層はドメイン層のResultをハンドリングし、Resultを返す
  3. プレゼンテーション層はResultを例外に変換する。
  4. インフラストラクチャー層は基本的にResultを取り扱わないようにする。

ドメイン層

ドメイン層は基本Resultを返すようにします。ドメイン層内の処理もResultを返す関数を合成しながら処理をします(例えば各項目のバリデーションの合成など)。

class User(
    val id: UserId,
    val userName: UserName,
    val email: Email,
) {
    
    companion object {
        fun validateAndCreate(
            userName: String,
            email: String,
        ): Result<User, ValidateAndCreateUserError> {
            return UserName.validateAndCreate(userName)
                .andThen { validatedUserName ->
                    Email.validateAndCreate(email)
                        .map { validatedEmail ->
                            User(
                                id = UserId.generate(),
                                userName = validatedUserName,
                                email = validatedEmail,
                            )
                        }
                }
            
            // 以下のように書くこともできる
            return zip(
                { UserName.validateAndCreate(userName) },
                { Email.validateAndCreate(email) },
            ) { validatedUserName, validatedEmail ->
                User(
                    id = UserId.generate(),
                    userName = validatedUserName,
                    email = validatedEmail,
                )
            }
        }
    }
}

sealed interface ValidateAndCreateUserError {
    data class UserNameInvalidLength(val name: String) : ValidateAndCreateUserError
    // ...
    data class EmailInvalidFormat(val email: String) : ValidateAndCreateUserError
    // ... 
}


class UserName(val value: String) {
    
    companion object {
        fun validateAndCreate(
            name: String,
        ): Result<UserName, ValidateAndCreateUserError> { 
            return TODO()
        }
    }
}

class Email(val value: String) {
    
    companion object {
        fun validateAndCreate(
            email: String,
        ): Result<Email, ValidateAndCreateUserError> { 
            return TODO()
        }
    }
}

ユースケース層

次にユースケース層ですが、こちらはドメイン層や他の処理から投げられたエラーをハンドリングし、Resultを返します。
ハンドリングするエラーは多種多様なエラーがありますが、ユースケースの返すエラーとしては一つのエラーにまとめることが必要です。

今回の例では ValidateAndCreateUserErrorSendCreateUserNotificationEmailErrorをそれぞれラップし、 RegisterUserUseCaseErrorとして最後返しています。
この書き方は Tagged Unionsと呼ばれる手法です。Kotlinではないですが近しい書き方としてScalaのドキュメントにも紹介されています。
https://docs.scala-lang.org/scala3/book/types-union.html#tagged-unions

class RegisterUserUseCase(
    private val userRepository: UserRepository,
    private val createUserNotificationEmailSender: CreateUserNotificationEmailSender,  
) {
    
    fun execute(
        param: RegisterUserDto,
    ): Result<Unit, RegisterUserUseCaseError> {
        return User.validateAndCreate(
            param.userName,
            param.email,
        ).mapError { error ->
            ValidateAndCreateUserUseCaseError(error)
        }.andThen { createdUser ->
            userRepository.insert(createdUser)
            createUserNotificationEmailSender
                .send(createdUser)
                .mapError { error -> 
                    SendCreateUserNotificationEmailUseCaseError(error)
                }
        }
    }
}

sealed interface RegisterUserUseCaseError {
    data class ValidateAndCreateUserUseCaseError(
        val error: ValidateAndCreateUserError
    ): RegisterUserError
    data class SendCreateUserNotificationEmailUseCaseError(
        val error: SendCreateUserNotificationEmailError
    ): RegisterUserError
}
class CreateUserNotificationEmailSender() {
    
    fun send(
        createdUser: User,
    ): Result<Unit, SendCreateUserNotificationEmailError> {
        // 通知メール送信処理
        return TODO()
    }
}

sealed interface SendCreateUserNotificationEmailError {
    data class RecipientNotFound(val email: String) : SendCreateUserNotificationEmailError
}

プレゼンテーション層

プレゼンテーション層では、Resultを例外に変換します。この辺りはSpring Bootやktorで取り扱いは違う可能性があります。実はここだけはログラスはうまくできていません。というのもログラスではトランザクションをユースケース層で管理していますが、Spring Bootが管理するトランザクションでロールバックさせるためには例外を投げる必要があるからです。

class UserController(
    private val registerUserUseCase: RegisterUserUseCase,
) {
    
    fun registerUser(
        request: RegisterUserRequest
    ): String {
        val dto = request.toDto()
        val result = registerUserUseCase.execute(dto)
        return result
            .map { "OK" }
            .mapError { error -> error.toException() }
    }
}

fun RegisterUserError.toException(): BadRequestException {
    return when (this) {
        is RegisterUserUseCaseError.ValidateAndCreateUserUseCaseError -> TODO()
        is RegisterUserUseCaseError.SendCreateUserNotificationEmailUseCaseError -> TODO()
        // ...
    }
}

インフラストラクチャー層

基本的にインフラストラクチャー層ではResultを取り扱わないようにするのをおすすめしています。
というのもインフラストラクチャー層ではユーザーが復帰できるエラーが発生することは期待されていないからです。
この層で起きたエラーの多くは復帰不可能なエラーであり、即座に処理を中断させてログに残すようにすることが望ましいです。

このように復帰不可能なエラー(DBへのコネクションエラーやデータ不整合)までResultのエラーとして返してしまうと、逆に不要なハンドリングが増え開発しづらくなってしまいます。ユーザーに何かしら連絡したら復帰することができるか?というのをひとつの基準にして使い分けるようにしましょう。

// ドメイン層
interface UserRepository {
  fun insert(user: User)
}

// インフラ層(ドメイン層のInterfaceを実装)
class UserJDBCRepository(): UserRepository {
    
    override fun insert(user: User) {
        // ユーザー登録処理
        return TODO()
    }
}

ただ、インフラストラクチャー層といえど種別は様々で例えば外部サービスを叩くが、その返り値としてハンドリングすべきエラーが返ってくるなどはこの層の中でResultを取り扱うべきです。

さいごに

急ぎ足でしたが、Kotlinでの Railway Oriented Programming の実装の解説でした。オニオンアーキテクチャのような既存のフレームワークに対して部分的にも導入可能だと思うので興味ある方はぜひお試しください。

複雑なビジネスロジックをResultを使って品質高くハンドリングしていきたいエンジニアの方、興味あればぜひ話しましょう!

https://hrmos.co/pages/loglass/jobs/1813462408235663415
https://hrmos.co/pages/loglass/jobs/B0001

株式会社ログラス テックブログ

Discussion