はじめに

対象者 : 中級から上級のKotlin開発者。

Kotlinにおける関数型プログラミングの世界で、Arrowライブラリはエラーと代替ケースを処理するための強力なツールを提供します。これらのツールの中で、モナド`Either`は、成功(Right)または失敗(Left)の2つの可能な状態を表現できる能力によって特徴づけられます。 しかし、これらの2つの状態を効果的にどのようにナビゲートすればよいでしょうか? これが本記事で探求する内容です。

Either モナドとは何ですか?

モナド`Either`は、相互に排他的な2つの可能性を表すデータ構造です。KotlinとArrowでは、操作の成功と失敗のケースを処理するためにしばしば使用され、従来の例外に代わるエレガントな選択肢を提供します。

なぜ Either を使うのか?

  1. 明示的なエラー処理:開発者に失敗ケースを考慮させる。

  2. 機能的な合成 : 失敗する可能性のある操作の連鎖を容易にする。

  3. 型安全 : エラーは型付けされており、これによりより正確に処理するのに役立ちます。

  4. 例外はなし:副作用と実行フローへの予期しない中断を避ける。

コンテキスト

REST APIを開発していると仮定してhttps://spring.io/projects/spring-boot/[Spring Boot,ウィンドウズ=後で読む] et Kotlin, ライブラリを使用してhttps://arrow-kt.io/[矢印,ウィンドウズ=後で読む]あなたは関数を持っています。`findOneUserByEmail`返す`Either<Throwable, User>`この結果をエレガントかつ機能的にどのように処理しますか?

Userクラスがあります:

@file:Suppress(
    "RemoveRedundantQualifierName",
    "MemberVisibilityCanBePrivate",
    "SqlNoDataSourceInspection"
)

package webapp.users

import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Pattern
import jakarta.validation.constraints.Size
import org.springframework.beans.factory.getBean
import org.springframework.context.ApplicationContext
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate
import org.springframework.r2dbc.core.DatabaseClient
import org.springframework.r2dbc.core.awaitOne
import org.springframework.r2dbc.core.awaitRowsUpdated
import webapp.core.property.ANONYMOUS_USER
import webapp.core.property.EMPTY_STRING
import webapp.core.utils.AppUtils.cleanField
import webapp.users.EntityModel.Companion.ID_MEMBER
import webapp.users.User.UserDao.Attributes.EMAIL_ATTR
import webapp.users.User.UserDao.Attributes.ID_ATTR
import webapp.users.User.UserDao.Attributes.LANG_KEY_ATTR
import webapp.users.User.UserDao.Attributes.LOGIN_ATTR
import webapp.users.User.UserDao.Attributes.PASSWORD_ATTR
import webapp.users.User.UserDao.Attributes.VERSION_ATTR
import webapp.users.User.UserDao.Constraints.LOGIN_REGEX
import webapp.users.User.UserDao.Fields.EMAIL_FIELD
import webapp.users.User.UserDao.Fields.ID_FIELD
import webapp.users.User.UserDao.Fields.LANG_KEY_FIELD
import webapp.users.User.UserDao.Fields.LOGIN_FIELD
import webapp.users.User.UserDao.Fields.PASSWORD_FIELD
import webapp.users.User.UserDao.Fields.VERSION_FIELD
import webapp.users.User.UserDao.Relations.INSERT
import webapp.users.security.Role
import webapp.users.security.Role.RoleDao
import webapp.users.security.UserRole.UserRoleDao
import java.util.*
import jakarta.validation.constraints.Email as EmailConstraint

data class User(
    override val id: UUID? = null,

    @field:NotNull
    @field:Pattern(regexp = LOGIN_REGEX)
    @field:Size(min = 1, max = 50)
    val login: String,

    @JsonIgnore
    @field:NotNull
    @field:Size(min = 60, max = 60)
    val password: String = EMPTY_STRING,

    @field:EmailConstraint
    @field:Size(min = 5, max = 254)
    val email: String = EMPTY_STRING,

    @JsonIgnore
    val roles: MutableSet<Role> = mutableSetOf(Role(ANONYMOUS_USER)),

    @field:Size(min = 2, max = 10)
    val langKey: String = EMPTY_STRING,

    @JsonIgnore
    val version: Long = -1,
) : EntityModel<UUID>() {

    companion object {
        @JvmStatic
        fun main(args: Array<String>) = println(UserDao.Relations.sqlScript)
    }

    object UserDao {
        object Constraints {
            // Regex for acceptable logins
            const val LOGIN_REGEX =
                "^(?>[a-zA-Z0-9!$&*+=?^_`{|}~.-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*)|(?>[_.@A-Za-z0-9-]+)$"
            const val PASSWORD_MIN: Int = 4
            const val PASSWORD_MAX: Int = 16
            const val IMAGE_URL_DEFAULT = "https://placehold.it/50x50"
            const val PHONE_REGEX = "^(\\+|00)?[1-9]\\d{0,49}\$"
        }

        object Members {
            const val PASSWORD_MEMBER = "password"
            const val ROLES_MEMBER = "roles"
        }

        object Fields {
            const val ID_FIELD = "`id`"
            const val LOGIN_FIELD = "`login`"
            const val PASSWORD_FIELD = "`password`"
            const val EMAIL_FIELD = "`email`"
            const val LANG_KEY_FIELD = "`lang_key`"
            const val VERSION_FIELD = "`version`"
        }

        object Attributes {
            val ID_ATTR = ID_FIELD.cleanField()
            val LOGIN_ATTR = LOGIN_FIELD.cleanField()
            val PASSWORD_ATTR = PASSWORD_FIELD.cleanField()
            val EMAIL_ATTR = EMAIL_FIELD.cleanField()
            const val LANG_KEY_ATTR = "langKey"
            val VERSION_ATTR = VERSION_FIELD.cleanField()
        }

        object Relations {
            const val TABLE_NAME = "`user`"
            const val SQL_SCRIPT = """
            CREATE TABLE IF NOT EXISTS $TABLE_NAME (
                $ID_FIELD                     UUID default random_uuid() PRIMARY KEY,
                $LOGIN_FIELD                  VARCHAR,
                $PASSWORD_FIELD               VARCHAR,
                $EMAIL_FIELD                  VARCHAR,
                $LANG_KEY_FIELD               VARCHAR,
                $VERSION_FIELD                bigint
            );
            CREATE UNIQUE INDEX IF NOT EXISTS `uniq_idx_user_login`
            ON $TABLE_NAME ($LOGIN_FIELD);
            CREATE UNIQUE INDEX IF NOT EXISTS `uniq_idx_user_email`
            ON $TABLE_NAME ($EMAIL_FIELD);
"""

            @Suppress("SqlDialectInspection")
            const val INSERT = """
            insert into $TABLE_NAME (
                $LOGIN_FIELD, $EMAIL_FIELD,
                $PASSWORD_FIELD, $LANG_KEY_FIELD,
                $VERSION_FIELD
            ) values ( :login, :email, :password, :langKey, :version)"""

            @JvmStatic
            val sqlScript: String
                get() = setOf(
                    UserDao.Relations.SQL_SCRIPT,
                    RoleDao.Relations.SQL_SCRIPT,
                    UserRoleDao.Relations.SQL_SCRIPT
                ).joinToString("")
                    .trimMargin()
        }

        object Dao {
            val Pair<User, ApplicationContext>.toJson: String
                get() = second.getBean<ObjectMapper>().writeValueAsString(first)

            suspend fun Pair<User, ApplicationContext>.save(): Either<Throwable, Long> = try {
                second.getBean<R2dbcEntityTemplate>()
                    .databaseClient
                    .sql(INSERT)
                    .bind(LOGIN_ATTR, first.login)
                    .bind(EMAIL_ATTR, first.email)
                    .bind(PASSWORD_ATTR, first.password)
                    .bind(LANG_KEY_ATTR, first.langKey)
                    .bind(VERSION_ATTR, first.version)
                    .fetch()
                    .awaitRowsUpdated()
                    .right()
            } catch (e: Throwable) {
                e.left()
            }


            suspend fun ApplicationContext.findOneUserByEmail(
                email: String
            ): Either<Throwable, User> = try {
                getBean<DatabaseClient>()
                    .sql("SELECT * FROM `user` WHERE LOWER(email) = LOWER(:email)")
                    .bind("email", email)
                    .fetch()
                    .awaitOne()
                    .let { row ->
                        User(
                            id = row[ID_ATTR] as UUID?,
                            login = row[LOGIN_ATTR] as String,
                            password = row[PASSWORD_ATTR] as String,
                            email = row[EMAIL_ATTR] as String,
                            langKey = row[LANG_KEY_ATTR] as String,
                            version = row[VERSION_ATTR] as Long
                        )
                    }.right()
            } catch (e: Throwable) {
                e.left()
            }
        }
    }

    /** Account REST API URIs */
    object UserRestApis {
        const val API_AUTHORITY = "/api/authorities"
        const val API_USERS = "/api/users"
        const val API_SIGNUP = "/signup"
        const val API_SIGNUP_PATH = "$API_USERS$API_SIGNUP"
        const val API_ACTIVATE = "/activate"
        const val API_ACTIVATE_PATH = "$API_USERS$API_ACTIVATE?key="
        const val API_ACTIVATE_PARAM = "{activationKey}"
        const val API_ACTIVATE_KEY = "key"
        const val API_RESET_INIT = "/reset-password/init"
        const val API_RESET_FINISH = "/reset-password/finish"
        const val API_CHANGE = "/change-password"
        const val API_CHANGE_PATH = "$API_USERS$API_CHANGE"
    }
}

// Abstract entity model with Generic ID, which can be of any type
abstract class EntityModel<T>(
    open val id: T? = null
) {
    companion object {
        const val ID_MEMBER = "id"
    }
}

// Generic extension function that allows the ID to be applied to any EntityModel type
inline fun <reified T : EntityModel<ID>, ID> T.withId(id: ID): T {
    // Use reflection to create a copy with the passed ID
    return this::class.constructors.first { it.parameters.any { param -> param.name == ID_MEMBER } }
        .call(id, *this::class.constructors.first().parameters.drop(1).map { param ->
            this::class.members.first { member -> member.name == param.name }.call(this)
        }.toTypedArray())
}

�異なるアプローチ

従来の`when`を使用したアプローチ

val user: User by lazy { userFactory(USER) }

val result: Either<Throwable, User> = context.findOneUserByEmail(user.email)

when (result) {
    is Either.Left -> {
        val error = result.value
        println("Erreur : ${error.message}")
    }
    is Either.Right -> {
        val user = result.value
        println("Utilisateur trouvé : ${user.login}")
    }
}

この方法はシンプルで読みやすいですが、Arrowの機能的な能力を十分に活用していません。

fold` を使用したより簡潔なアプローチ

result.fold(
{ error -> println("Erreur : ${error.message}") },
{ user -> println("Utilisateur trouvé : ${user.login}") }
)

`fold`これにより、Left と Right の両ケースに対して簡潔かつエレガントにアクションを定義することができます。

変�換 map と `mapLeft

val processedResult = result
    .map { user -> "Utilisateur trouvé : ${user.login}" }
    .mapLeft { error -> "Erreur : ${error.message}" }

println(processedResult.merge())

このアプローチは、Eitherに格納された値を変換しつつその構造を保持し、より複雑な処理チェーンに適しています。

getOrElse` を使ったエラー処理

val user = result.getOrElse { error ->
    println("Erreur : ${error.message}")
    User(login = "default", email = "[email protected]") // utilisateur par défaut
}
println("Login : ${user.login}")

`getOrElse`デフォルト値を提供することでエラー処理をエレガントに行う。

横のアクション onLeft と `onRight

result.onLeft { error -> println("Erreur : ${error.message}") }
.onRight { user -> println("Utilisateur trouvé : ${user.login}") }

これらのメソッドは、Either を変更せずに各側でアクションを実行でき、ロギングや軽微な副作用に最適です。

flatMap` を使った操作のチェーン

fun findUser(email: String): Either<Throwable, User> = // ... implémentation

fun getUserPermissions(user: User): Either<Throwable, List<String>> = // ... implémentation

val userPermissions = findUser("[email protected]")
    .flatMap { user -> getUserPermissions(user) }

flatMap`は、自分自身が何かを返す操作を連鎖させるのに便利です`Either, これにより を避ける`Either`ネストされた。

双方向変換と `bimap

val result: Either<Throwable, User> = // ... obtention du résultat
val processedResult = result.bimap(
    { error -> "Erreur: ${error.message}" },
    { user -> "Utilisateur: ${user.login}" }
)

bimap� 左側と右側の両方を一度の操作で変換できる。

swap` を使用した側面の反転

val result: Either<Throwable, User> = // ... obtention du résultat
val swapped = result.swap()

swap`これは、一方の側を逆にしたいときに便利です`Either, 例えば、ある関数のインターフェイスを別の関数に適応させるために。

tap` と tapLeft の使用:

result.tap { user -> println("Utilisateur trouvé : ${user.login}") }
.tapLeft { error -> println("Erreur : ${error.message}") }

同様に`onLeft` et onRight, ただし、これらのメソッドは元のEitherを返し、これは操作のチェーンに役立ちます。

recover` の使用 :

val recoveredUser = result.recover { error ->
    println("Erreur récupérée : ${error.message}")
    User(login = "recovered", email = "[email protected]")
}
println("Login : ${recoveredUser.login}")

このメソッドは、代替値を提供することで Either.Left を Either.Right に変換することができます。

実践的なユースケース

使ってください`fold`各ケースに対する処理が必要な単純な操作の場合 - お好みください`map` et mapLeft`データ変換のために、構造を変更せずに l'`Either。 - 選んでください`flatMap`失敗する可能性のある操作のチェーン中に - 使用してください`recover`エラー時にデフォルト値を提供するために。 - 選んでください`onLeft` et onRight (ou tap et tapLeft) ロギングなどの副作用のために - 使って`bimap`両側を一つの操作に変換するために。 - 適用して`swap`関数のインターフェイスを別の関数に合わせなければならないとき。

結論

それぞれのアプローチは、使用状況に応じて利点があります。方法としては`fold`, map/mapLeft, et `recover`特に便利です。複数の操作を連鎖させたいときや、データを関数的に変換したいときに使用できます。

モナド`Either`d’Arrow は、Kotlin アプリケーションにおける成功とエラーのケースを処理する際に、卓越した柔軟性を提供します。これらのさまざまなアプローチを習得することで、より堅牢で、読みやすく、機能的なコードを記述できるようになります。

次のプロジェクトでは、ためらわずにこれらのテクニックを探求し、KotlinとArrowを使った関数型プログラミングの最大限の活用を試みてください!

さらに進むために

Arrow 公式ドキュメント :https://arrow-kt.io/docs/apidocs/arrow-core/arrow.core/-either/[Arrow Either] - Kotlin Coroutines と Arrow :https://arrow-kt.io/docs/fx/[Arrow Fx コルーチン]

体�験談とお気に入りのテクニックを共有するのを忘れずに、作業する際に`Either`以下のコメントで!

関連記事