Lesezeit: 12 bis 15 Minuten.

Einführung

Zielgruppe : Kotlin-Entwickler von mittlerem bis fortgeschrittenem Niveau.

In der Welt der funktionalen Programmierung in Kotlin bietet die Arrow-Bibliothek leistungsstarke Werkzeuge zur Behandlung von Fehlern und alternativen Fällen. Unter diesen Werkzeugen ist die Monade`Either`zeichnet sich dadurch aus, dass es zwei mögliche Zustände darstellen kann: Erfolg (Right) oder Fehler (Left). Aber wie kann man effizient zwischen diesen beiden Zuständen navigieren? Das werden wir in diesem Artikel untersuchen.

Was ist die Either-Monade?

Die Monade`Either`ist eine Datenstruktur, die zwei sich gegenseitig ausschließende Möglichkeiten repräsentiert. In Kotlin mit Arrow wird sie häufig verwendet, um die Erfolgs- und Fehlerfälle einer Operation zu behandeln, wobei sie eine elegante Alternative zu traditionellen Ausnahmen bietet.

Warum Either verwenden?

  1. Explizite Fehlerbehandlung: Zwingt den Entwickler, Fehlersituationen zu berücksichtigen.

  2. Funktionale Komposition: Erleichtert das Verketten von Operationen, die fehlschlagen können.

  3. Typsicherheit: Fehler sind typisiert, was hilft, sie präziser zu handhaben.

  4. Keine Ausnahmen: Vermeide Nebeneffekte und unerwartete Unterbrechungen des Ausführungsflusses.

Kontext

Stellen Sie sich vor, Sie entwickeln eine REST-API mithttps://spring.io/projects/spring-boot/[Spring Boot,windows=später-lesen] et Kotlin, indem die Bibliothek verwendet wirdhttps://arrow-kt.io/[Pfeil,Fenster=read-later]. Sie haben eine Funktion`findOneUserByEmail`der ein zurückgibt`Either<Throwable, User>`. Wie können Sie dieses Ergebnis elegant und funktional behandeln?

Wir haben eine Klasse 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())
}

Die verschiedenen Ansätze

Der klassische Ansatz mit `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}")
    }
}

Diese Methode, obwohl einfach und lesbar, nutzt die funktionalen Fähigkeiten von Arrow nicht vollständig aus.

Verwendung von fold für einen präziseren Ansatz

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

`fold`ermöglicht es, Aktionen für die beiden Fälle (Left und Right) kurz und elegant zu definieren.

Transformation mit map und `mapLeft

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

println(processedResult.merge())

Dieser Ansatz ermöglicht es, die in Either enthaltenen Werte zu transformieren, während seine Struktur erhalten bleibt, ideal für komplexere Verarbeitungsketten.

Fehlerbehandlung mit `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`bietet eine elegante Fehlerbehandlung, indem es die Angabe eines Standardwertes erlaubt.

Seitliche Aktionen mit onLeft und `onRight

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

Diese Methoden ermöglichen das Ausführen von Aktionen auf jeder Seite ohne das Either zu ändern, perfekt für das Logging oder leichte Seiteneffekte.

Verkettung von Operationen mit `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`ist nützlich, um Vorgänge zu verketten, die einige zurückgeben`Either, dadurch vermeidend die`Either`verschachtelt.

Bidirektionale Transformation mit `bimap

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

`bimap`ermöglicht es, sowohl die linke als auch die rechte Seite in einem Vorgang zu transformieren.

Umkehrung der Seiten mit `swap

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

swap`ist nützlich, wenn du die Seiten eines umkehren möchtest`Either, zum Beispiel um die Schnittstelle einer Funktion an eine andere anzupassen.

Verwendung von tap und tapLeft :

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

Ähnlich zu`onLeft` et onRight, aber diese Methoden geben das ursprüngliche Either zurück, was nützlich ist, um Operationen zu ketten.

Verwendung von recover :

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

Diese Methode ermöglicht das Umwandeln eines Either.Left in ein Either.Right, indem sie einen Ersatzwert bereitstellt.

Praktische Anwendungsfälle

  • Benutzen Sie`fold`für einfache Vorgänge, die eine Bearbeitung pro Fall erfordern. - Bevorzugen Sie`map` et mapLeft`für Datenumwandlungen ohne die Struktur des`Either. - Wählen Sie für`flatMap`beim Verketten von Operationen, die fehlschlagen können - Verwenden Sie`recover`um einen Standardwert im Fehlerfall bereitzustellen - Wählen`onLeft` et onRight (ou tap et tapLeft) für Nebenwirkungen wie das Logging. - Verwenden Sie`bimap`um beide Seiten in eine einzige Operation zu verwandeln. - Anwenden`swap`wenn Sie die Schnittstelle einer Funktion an eine andere anpassen müssen.

Fazit

Jede dieser Ansätze hat je nach Nutzungskontext ihre Vorteile. Methoden wie`fold`, map/mapLeft, et `recover`sind besonders nützlich, wenn Sie mehrere Operationen hintereinander ausführen oder Daten funktional transformieren möchten.

Die Monade`Either`Arrow bietet eine bemerkenswerte Flexibilität für die Handhabung von Erfolgs- und Fehlerfällen in Ihren Kotlin-Anwendungen. Wenn Sie diese verschiedenen Ansätze beherrschen, können Sie robuster, lesbarer und funktionaleren Code schreiben.

In Ihrem nächsten Projekt zögern Sie nicht, diese Techniken zu erkunden, um das Beste aus der funktionalen Programmierung mit Kotlin und Arrow herauszuholen!

Weitergehen

Offizielle Dokumentation von Arrow:https://arrow-kt.io/docs/apidocs/arrow-core/arrow.core/-either/[Pfeil entweder] - Kotlin Coroutines mit Arrow :https://arrow-kt.io/docs/fx/[Arrow Fx Coroutines]

Vergiss nicht, deine Erfahrungen und deine bevorzugten Techniken zum Arbeiten damit zu teilen.`Either`in den Kommentaren unten!

Verwandte Artikel