Kotlin Arrow : Maîtriser la navigation dans la monade Either
Publié le 24 September 2024
- 1. Introduction
- 2. Qu’est-ce que la monade Either ?
- 3. Contexte
- 4. Les différentes approches
- 4.1. L’approche classique avec
when
- 4.2. Utilisation de
fold
pour une approche plus concise - 4.3. Transformation avec
map
etmapLeft
- 4.4. Gestion des erreurs avec
getOrElse
- 4.5. Actions latérales avec
onLeft
etonRight
- 4.6. Chaînage d’opérations avec
flatMap
- 4.7. Transformation bidirectionnelle avec
bimap
- 4.8. Inversion des côtés avec
swap
- 4.9. Utilisation de
tap
ettapLeft
: - 4.10. Utilisation de
recover
:
- 4.1. L’approche classique avec
- 5. Cas d’utilisation pratiques
- 6. Conclusion
- 7. Pour aller plus loin
12 à 15 minutes.
Développeurs Kotlin de niveau intermédiaire à avancé.
1. Introduction
Dans le monde de la programmation fonctionnelle en Kotlin, la bibliothèque Arrow offre des outils puissants pour gérer les erreurs et les cas alternatifs. Parmi ces outils, la monade Either
se distingue par sa capacité à représenter deux états possibles : succès (Right) ou échec (Left). Mais comment naviguer efficacement entre ces deux états ? C’est ce que nous allons explorer dans cet article.
2. Qu’est-ce que la monade Either ?
La monade Either
est une structure de données qui représente deux possibilités mutuellement exclusives. En Kotlin avec Arrow, elle est souvent utilisée pour gérer les cas de succès et d’échec d’une opération, offrant une alternative élégante aux exceptions traditionnelles.
2.1. Pourquoi utiliser Either ?
-
Gestion explicite des erreurs : Force le développeur à considérer les cas d’échec.
-
Composition fonctionnelle : Facilite le chaînage d’opérations qui peuvent échouer.
-
Type-safety : Les erreurs sont typées, ce qui aide à les gérer de manière plus précise.
-
Pas d’exceptions : Évite les effets de bord et les interruptions inattendues du flux d’exécution.
3. Contexte
Imaginons que vous développez une API REST avec Spring Boot et Kotlin, en utilisant la bibliothèque Arrow. Vous avez une fonction findOneUserByEmail
qui renvoie un Either<Throwable, User>
. Comment pouvez-vous traiter ce résultat de manière élégante et fonctionnelle ?
Nous avons une classe 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())
}
4. Les différentes approches
4.1. L’approche classique avec 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}")
}
}
Cette méthode, bien que simple et lisible, ne tire pas pleinement parti des capacités fonctionnelles d’Arrow.
4.2. Utilisation de fold
pour une approche plus concise
result.fold(
{ error -> println("Erreur : ${error.message}") },
{ user -> println("Utilisateur trouvé : ${user.login}") }
)
fold
permet de définir des actions pour les deux cas (Left et Right) de manière concise et élégante.
4.3. Transformation avec map
et mapLeft
val processedResult = result
.map { user -> "Utilisateur trouvé : ${user.login}" }
.mapLeft { error -> "Erreur : ${error.message}" }
println(processedResult.merge())
Cette approche permet de transformer les valeurs contenues dans Either tout en préservant sa structure, idéal pour des chaînes de traitement plus complexes.
4.4. Gestion des erreurs avec getOrElse
val user = result.getOrElse { error ->
println("Erreur : ${error.message}")
User(login = "default", email = "default@example.com") // utilisateur par défaut
}
println("Login : ${user.login}")
getOrElse
offre une gestion élégante des erreurs en permettant de fournir une valeur par défaut.
4.5. Actions latérales avec onLeft
et onRight
result.onLeft { error -> println("Erreur : ${error.message}") }
.onRight { user -> println("Utilisateur trouvé : ${user.login}") }
Ces méthodes permettent d’effectuer des actions sur chaque côté sans modifier l’Either, parfait pour le logging ou les effets secondaires légers.
4.6. Chaînage d’opérations avec flatMap
fun findUser(email: String): Either<Throwable, User> = // ... implémentation
fun getUserPermissions(user: User): Either<Throwable, List<String>> = // ... implémentation
val userPermissions = findUser("user@example.com")
.flatMap { user -> getUserPermissions(user) }
flatMap
est utile pour enchaîner des opérations qui retournent elles-mêmes des Either
, évitant ainsi les Either
imbriqués.
4.7. Transformation bidirectionnelle avec bimap
val result: Either<Throwable, User> = // ... obtention du résultat
val processedResult = result.bimap(
{ error -> "Erreur: ${error.message}" },
{ user -> "Utilisateur: ${user.login}" }
)
bimap
permet de transformer à la fois le côté gauche et le côté droit en une seule opération.
4.8. Inversion des côtés avec swap
val result: Either<Throwable, User> = // ... obtention du résultat
val swapped = result.swap()
swap
est utile lorsque vous voulez inverser les côtés d’un Either
, par exemple pour adapter l’interface d’une fonction à une autre.
4.9. Utilisation de tap
et tapLeft
:
result.tap { user -> println("Utilisateur trouvé : ${user.login}") }
.tapLeft { error -> println("Erreur : ${error.message}") }
Similaire à onLeft
et onRight
, mais ces méthodes retournent l’Either original, ce qui est utile pour le chaînage d’opérations.
4.10. Utilisation de recover
:
val recoveredUser = result.recover { error ->
println("Erreur récupérée : ${error.message}")
User(login = "recovered", email = "recovered@example.com")
}
println("Login : ${recoveredUser.login}")
Cette méthode permet de transformer un Either.Left en Either.Right en fournissant une valeur de remplacement.
5. Cas d’utilisation pratiques
-
Utilisez
fold
pour des opérations simples nécessitant un traitement pour chaque cas. -
Préférez
map
etmapLeft
pour des transformations de données sans changer la structure de l'`Either`. -
Optez pour
flatMap
lors du chaînage d’opérations pouvant échouer. -
Employez
recover
pour fournir une valeur par défaut en cas d’erreur. -
Choisissez
onLeft
etonRight
(outap
ettapLeft
) pour des effets secondaires comme le logging. -
Utilisez
bimap
pour transformer les deux côtés en une seule opération. -
Appliquez
swap
lorsque vous devez adapter l’interface d’une fonction à une autre.
6. Conclusion
Chacune de ces approches a ses avantages selon le contexte d’utilisation. Les méthodes comme fold
, map
/mapLeft
, et recover
sont particulièrement utiles lorsque vous voulez enchaîner plusieurs opérations ou transformer les données de manière fonctionnelle.
La monade Either
d’Arrow offre une flexibilité remarquable pour gérer les cas de succès et d’erreur dans vos applications Kotlin. En maîtrisant ces différentes approches, vous pourrez écrire un code plus robuste, plus lisible et plus fonctionnel.
Dans votre prochain projet, n’hésitez pas à explorer ces techniques pour tirer le meilleur parti de la programmation fonctionnelle avec Kotlin et Arrow !
7. Pour aller plus loin
-
Documentation officielle d’Arrow : Arrow Either
-
Kotlin Coroutines avec Arrow : Arrow Fx Coroutines
N’oubliez pas de partager vos expériences et vos techniques préférées pour travailler avec Either
dans les commentaires ci-dessous !