Kotlin Arrow: Επικράτεια στη πλοήγηση στην Either monad
Publié le 24 September 2024
- Εισαγωγή
- Τι είναι η μονάδα Either;
- Συγκρότηση
- Οι διαφορετικές προσεγγίσεις
- Η κλασική προσέγγιση με `when
- Χρήση του
foldγια μια πιο συντομική προσέγγιση - Μετασχηματισμός με
mapκαι `mapLeft - Διαχείριση σφαλμάτων με `getOrElse
- Πλευρικές ενέργειες με
onLeftκαι `onRight - Συρραφή λειτουργιών με `flatMap
- Διπλεύς μετασχηματισμός με το `bimap
- Αντιστροφή των πλευρών με `swap
- Χρήση του
tapκαι τουtapLeft: - Χρήση του
recover:
- Πρακτικές περιπτώσεις χρήσης
- Συμπέρασμα
- Για να πάτε πιο μακριά
Εισαγωγή
Στον κόσμο της συνάρτησης προγραμματισμού στην Kotlin, η βιβλιοθήκη Arrow προσφέρει ισχυρά εργαλεία για τη διαχείριση σφαλμάτων και εναλλακτικών περιπτώσεων. Μεταξύ αυτών των εργαλείων, η μονάδα`Either`διακρίνεται για τη δυνατότητά του να αντιπροσωπεύει δύο πιθανές καταστάσεις: επιτυχία (Right) ή αποτυχία (Left). Αλλά πώς μπορούμε να πλοηγηθούμε αποτελεσματικά μεταξύ αυτών των δύο καταστάσεων; Αυτό είναι αυτό που θα εξετάσουμε σε αυτό το άρθρο.
Τι είναι η μονάδα Either;
Η μονάδα`Either`είναι μια δομή δεδομένων που αντιπροσωπεύει δύο αλληλοαποκλειστικές επιλογές. Στο Kotlin με το Arrow, χρησιμοποιείται συχνά για να διαχειριστεί τις περιπτώσεις επιτυχίας και αποτυχίας μιας λειτουργίας, προσφέροντας μια ελεγαντική εναλλακτική στα παραδοσιακά εξαιρέσεις.
Γιατί να χρησιμοποιήσουμε το Either;
-
Εξακρίβεια διαχείρισης σφαλμάτων: Αναγκάζει τον προγραμματιστή να λάβει υπόψη τις περιπτώσεις αποτυχίας.
-
Composition fonctionnelle : Facilite le chaînage d’opérations qui peuvent échouer.
-
Type-safety : Τα σφάλματα είναι τυποποιημένα, πράγμα που βοηθά να τα χειρίζεται ακριβότερα
-
Δεν υπάρχουν εξαιρέσεις: αποφεύγετε τις πρόσθετες επιπτώσεις και τις απρόσμενοι διακοπές της ροής εκτέλεσης.
Συγκρότηση
Φανταστείτε ότι αναπτύσσετε μια API REST μεhttps://spring.io/projects/spring-boot/[Spring Boot,windows=read-later] et Kotlin, χρησιμοποιώντας τη βιβλιοθήκηhttps://arrow-kt.io/[Arrow,windows=read-later]. Έχετε μια συνάρτηση`findOneUserByEmail`που επιστρέφει ένα`Either<Throwable, User>`. Πώς μπορείτε να πεdosiείτε αυτό το αποτέλεσμα με εξαιρετικό και operativo τρóπο;
Έχουμε μια κλάση 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`Προσφέρει διαχείριση σφαλμάτων με έναν ελεγamment τρόπο, επιτρέποντας τη παροχή μιας προεπιλεγμένης τιμής.
Πλευρικές ενέργειες με onLeft και `onRight
result.onLeft { error -> println("Erreur : ${error.message}") }
.onRight { user -> println("Utilisateur trouvé : ${user.login}") }
Αυτές οι μέθοδοι επιτρέπουν να εκτελείς ενέργειες σε κάθε πλευρά χωρίς να τροποποιήσεις το Either, ιδανικό για το logging ή τις ελαφριές παρενέργειες.
Συρραφή λειτουργιών με `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`για μετασχηματισμούς δεδομένων χωρίς να αλλάξει τη δομή του`Either. - Επιλέξτε για`flatMap`Κατά τη σειρά λειτουργιών που μπορούν να αποτυχούν - Χρησιμοποιείτε`recover`για να παρέχει μια προεπιλεγμένη τιμή σε περίπτωση σφάλματος Επιλέξτε`onLeft` etonRight(outapettapLeft) για παρενέργειες όπως το logging. Χρησιμοποιήστε`bimap`για να μετατρέψετε και τις δύο πλευρές σε μια μόνο λειτουργία. - Εφαρμόστε`swap`Όταν πρέπει να προσαρμόσετε την διεπαφή μιας συνάρτησης σε μια άλλη.
Συμπέρασμα
Κάθε μια από αυτές τις προσεγγίσεις έχει τα πλεονεκτήματά της ανάλογα με το πλαίσιο χρήσης. Οι μέθοδοι όπως`fold`, map/mapLeft, et `recover`είναι ιδιαίτερα χρήσιμα όταν θέλετε να αλυσοδέσετε πολλές λειτουργίες ή να μετατρέπειτε τα δεδομένα με λειτουργικό τρόπο.
Η μονάδα`Either`Το d’Arrow προσφέρει εξαιρετική ευελιξία για τη διαχείριση των σεναρίων επιτυχίας και σφάλματος στις εφαρμογές σας Kotlin. Κατανειώνοντας αυτές τις διαφορετικές προσεγγίσεις, θα μπορέσετε να γράψετε κώδικα που είναι πιο ανθεκτικό, πιο ευανάγνωστο και πιο λειτουργικό.
Στο επερχόμενο σας πρόγραμμα, μη διστάσετε να εξερευνήσετε αυτές τις τεχνικές για να εξασφαλίσετε το μέγιστο όφελος από τη λειτουργική προγραμματισμό με Kotlin και Arrow !
Για να πάτε πιο μακριά
Επίσημη τεκμηρίωση του Arrowhttps://arrow-kt.io/docs/apidocs/arrow-core/arrow.core/-either/[Βέλος ή] - Κορουτίνες Kotlin με το Arrow :https://arrow-kt.io/docs/fx/[Arrow Fx Coroutines]
Μην ξεχάσετε να μοιραστείτε τις εμπειρίες σας και τις αγαπημένες τεχνικές σας για να εργάζεστε με`Either`στα σχόλια παρακάτω!