Kotlin Arrow: Navigatie beheersen in de Either monade
Gepubliceerd op 24 September 2024
- Inleiding
- Wat is de Either-monade?
- Context
- De verschillende benaderingen
- De klassieke aanpak met `when
- Gebruik van
foldvoor een meer beknopte aanpak - Transformatie met
mapen `mapLeft - Foutafhandeling met `getOrElse
- Laterale acties met
onLeften `onRight - Ketting van bewerkingen met `flatMap
- Bidirectionele transformatie met `bimap
- Inversie van de zijden met `swap
- Gebruik van
tapentapLeft: - Gebruik van
recover:
- Praktische gebruiksscenario’s
- Conclusie
- Verder gaan
Inleiding
In de wereld van functioneel programmeren in Kotlin biedt de Arrow-bibliotheek krachtige hulpmiddelen om fouten en alternatieve gevallen te verwerken. Onder deze hulpmiddelen, de monade`Either`onderscheidt zich door zijn vermogen om twee mogelijke toestanden weer te geven: succes (Right) of falen (Left). Maar hoe navigeer je effectief tussen deze twee toestanden? Dit gaan we in dit artikel onderzoeken.
Wat is de Either-monade?
de monade`Either`is een datastructuur die twee wederzijds uitsluitende mogelijkheden representeert. In Kotlin met Arrow wordt het vaak gebruikt om de gevallen van succes en mislukking van een operatie af te handelen, wat een elegant alternatief biedt voor traditionele uitzonderingen.
Waarom Either gebruiken?
-
Expliciete foutafhandeling: Dwingt de ontwikkelaar om falingsgevallen te overwegen.
-
Functiecompositie : Vergemakkelijkt het ketenen van bewerkingen die kunnen mislukken.
-
Type-safety : Fouten zijn getypt, wat helpt om ze nauwkeuriger te beheren.
-
Geen uitzonderingen: vermijd bijwerkingen en onverwachte onderbrekingen van de uitvoeringsstroom.
Context
Stel je voor dat je een REST API ontwikkelt methttps://spring.io/projects/spring-boot/[Spring Boot,windows=read-later] et Kotlin, met behulp van de bibliotheekhttps://arrow-kt.io/[Arrow,windows=read-later]. U heeft een functie`findOneUserByEmail`die een retourneert`Either<Throwable, User>`Hoe kun je dit resultaat op een elegante en functionele manier verwerken?
We hebben een User-klasse:
@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())
}
De verschillende benaderingen
De klassieke aanpak met `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}")
}
}
Deze methode, hoewel eenvoudig en leesbaar, maakt niet volledig gebruik van de functionele mogelijkheden van Arrow.
Gebruik van fold voor een meer beknopte aanpak
result.fold(
{ error -> println("Erreur : ${error.message}") },
{ user -> println("Utilisateur trouvé : ${user.login}") }
)
`fold`maakt het mogelijk om acties te definiëren voor beide gevallen (Left en Right) op een korte en elegante manier.
Transformatie met map en `mapLeft
val processedResult = result
.map { user -> "Utilisateur trouvé : ${user.login}" }
.mapLeft { error -> "Erreur : ${error.message}" }
println(processedResult.merge())
Deze aanpak maakt het mogelijk de waarden die in Either zijn opgenomen te transformeren terwijl de structuur behouden blijft, ideaal voor complexere verwerkingsketens.
Foutafhandeling met `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`biedt een elegante foutafhandeling aan door het mogelijk te maken een standaardwaarde op te geven.
Laterale acties met onLeft en `onRight
result.onLeft { error -> println("Erreur : ${error.message}") }
.onRight { user -> println("Utilisateur trouvé : ${user.login}") }
Deze methoden maken het mogelijk acties uit te voeren aan elke kant zonder het Either te wijzigen, perfect voor loggen of lichte neveneffecten.
Ketting van bewerkingen met `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`Het is nuttig voor het koppelen van bewerkingen die zelf weer teruggeven`Either, zodoende de`Either`geneste.
Bidirectionele transformatie met `bimap
val result: Either<Throwable, User> = // ... obtention du résultat
val processedResult = result.bimap(
{ error -> "Erreur: ${error.message}" },
{ user -> "Utilisateur: ${user.login}" }
)
`bimap`maakt het mogelijk om zowel de linkerkant als de rechterkant in één bewerking te transformeren.
Inversie van de zijden met `swap
val result: Either<Throwable, User> = // ... obtention du résultat
val swapped = result.swap()
swap`is nuttig wanneer je de zijden wilt omkeren van een`Either, bijvoorbeeld om de interface van een functie aan te passen aan een andere.
Gebruik van tap en tapLeft :
result.tap { user -> println("Utilisateur trouvé : ${user.login}") }
.tapLeft { error -> println("Erreur : ${error.message}") }
gelijk aan`onLeft` et onRight, maar deze methoden retourneren de oorspronkelijke Either, wat handig is voor het ketenen van bewerkingen.
Gebruik van recover :
val recoveredUser = result.recover { error ->
println("Erreur récupérée : ${error.message}")
User(login = "recovered", email = "[email protected]")
}
println("Login : ${recoveredUser.login}")
Deze methode stelt in staat een Either.Left om te zetten in een Either.Right door een vervangende waarde te verstrekken.
Praktische gebruiksscenario’s
-
Gebruik`fold`voor eenvoudige bewerkingen die een verwerking per geval vereisen. - Verkies`map` et
mapLeft`voor gegevenstransformaties zonder de structuur van de`Either. - Kies voor`flatMap`bij het koppelen van bewerkingen die kunnen mislukken. - Gebruik`recover`om een standaardwaarde te bieden in geval van fout. - Kies`onLeft` etonRight(outapettapLeft) voor neveneffecten zoals logging. - Gebruik`bimap`om beide zijden in één enkele bewerking om te zetten - Toepassen`swap`wanneer je de interface van een functie moet aanpassen aan een andere.
Conclusie
Elk van deze benaderingen heeft zijn voordelen afhankelijk van de gebruikscontext. De methoden zoals`fold`, map/mapLeft, et `recover`zijn bijzonder nuttig wanneer je meerdere bewerkingen wilt koppelen of de gegevens op een functionele manier wilt transformeren.
De monade`Either`d’Arrow biedt een opmerkelijke flexibiliteit om succes- en foutgevallen in uw Kotlin-applicaties te verwerken. Door deze verschillende benaderingen onder de knie te krijgen, kunt u robuustere, leesbare en functionelere code schrijven.
In je volgende project, aarzel niet om deze technieken te verkennen om het beste uit functionele programmeren met Kotlin en Arrow te halen!
Verder gaan
Officiële documentatie van Arrow:https://arrow-kt.io/docs/apidocs/arrow-core/arrow.core/-either/[Arrow Either] - Kotlin Coroutines met Arrow :https://arrow-kt.io/docs/fx/[Arrow Fx Coroutines]
Vergeet niet om je ervaringen en je favoriete technieken te delen om mee te werken`Either`in de reacties hieronder !