Kotlin Arrow: Either monadında navigasyonu ustalaşmak
Yayınlandı: 24 September 2024
Giriş
Kotlin’de fonksiyonel programlama dünyasında, Arrow kütüphanesi hataları ve alternatif durumları yönetmek için güçlü araçlar sunar. Bu araçlar arasında, monad`Either`İki olası durumu temsil etme kapasitesiyle ayrılır : başarı (Right) veya başarısızlık (Left). Ancak bu iki durum arasında nasıl etkili bir şekilde geziniriz? Bu, bu makalede keşfedeceğimiz şey.
Either monadı nedir?
Monad`Either`Bir veri yapısıdır ve iki mutlak olarak dışlayıcı olasılığı temsil eder. Kotlin’de Arrow ile, bir işlemin başarı ve başarısızlık durumlarını yönetmek için sıklıkla kullanılır, geleneksel istisnalara şık bir alternatif sunar.
Either’i neden kullanmalıyız?
-
Açık hata yönetimi: Geliştiriciyi başarısızlık durumlarını düşünmeye zorlar.
-
Fonksiyonel kompozisyon: Başarısız olabilecek işlemlerin zincirlenmesini kolaylaştırır.
-
Tip güvenliği: Hatalar tipize edilmiştir, bu da onları daha hassas bir şekilde yönetmeye yardımcı olur.
-
İstisna yok: Kenar etkileri ve yürütme akışındaki beklenmedik kesintileri kaçının.
Bağlam
Hayal edin ki bir REST API ile geliştiriyorsunuzhttps://spring.io/projects/spring-boot/[Spring Boot,windows=okunacak] et Kotlin, kütüphaneyi kullanarakhttps://arrow-kt.io/[Arrow,windows=sonra-oku]. Bir fonksiyonunuz var.findOneUserByEmail`bir döndüren`Either<Throwable, User>. Bu sonucu nasıl şık ve işlevsel bir şekilde ele alabilirsiniz?
Bir User sınıfımız var:
@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())
}
Farklı yaklaşımlar
Klasik yaklaşım when ile
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}")
}
}
Bu yöntem, basit ve okunabilir olsa da, Arrow’ın fonksiyonel yeteneklerinden tam anlamıyla yararlanmaz.
Kullanım fold için daha özlü bir yaklaşım
result.fold(
{ error -> println("Erreur : ${error.message}") },
{ user -> println("Utilisateur trouvé : ${user.login}") }
)
`fold`İki durum (Left et Right) için eylemleri kısa ve zarif bir şekilde tanımlamaya olanak tanır.
Dönüşüm map ve mapLeft ile
val processedResult = result
.map { user -> "Utilisateur trouvé : ${user.login}" }
.mapLeft { error -> "Erreur : ${error.message}" }
println(processedResult.merge())
Bu yaklaşım, Either içindeki değerleri dönüştürmeyi ve yapısını koruyarak sağlar; daha karmaşık işlem zincirleri için idealdir.
Hata yönetimi ile `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`hata yönetimi şık bir şekilde sunar, varsayılan bir değer sağlayarak
Yan eylemler ile onLeft ve `onRight
result.onLeft { error -> println("Erreur : ${error.message}") }
.onRight { user -> println("Utilisateur trouvé : ${user.login}") }
Bu yöntemler, Either’i değiştirmeden her iki tarafda eylemler gerçekleştirmeye olanak tanır, hafif yan etkiler veya günlük tutma için idealdir.
flatMap` ile işlem zincirleme
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`Bir işi birden fazla işlemle zincirlemek için faydalıdır, kendileri döndüren`Either, böylece önleyerek`Either`iç içe.
İki yönlü dönüşüm bimap ile
val result: Either<Throwable, User> = // ... obtention du résultat
val processedResult = result.bimap(
{ error -> "Erreur: ${error.message}" },
{ user -> "Utilisateur: ${user.login}" }
)
`bimap`sol tarafı ve sağ tarafı tek bir işlemde dönüştürmeye olanak tanır.
swap` ile kenarların ters çevrilmesi
val result: Either<Throwable, User> = // ... obtention du résultat
val swapped = result.swap()
swap`bir şeyin iki tarafını ters çevirmek istediğinizde faydalı olur`Either, bir fonksiyonun arayüzünü başka bir fonksiyonun arayüzüne uyarlamak için, örnek.
Kullanım tap ve tapLeft :
result.tap { user -> println("Utilisateur trouvé : ${user.login}") }
.tapLeft { error -> println("Erreur : ${error.message}") }
benzer`onLeft` et onRight, ancak bu yöntemler orijinal Either’i döndürür, bu da işlemleri zincirleme yapmak için faydalıdır.
Kullanım recover :
val recoveredUser = result.recover { error ->
println("Erreur récupérée : ${error.message}")
User(login = "recovered", email = "[email protected]")
}
println("Login : ${recoveredUser.login}")
Bu yöntem, bir yer değiştirme değeri sağlayarak bir Either.Left’i Either.Right’e dönüştürmemizi mümkün kılar.
Pratik kullanım örnekleri
-
Kullanınız`fold`Basit işlemler için her duruma işlem gerektiren. - Tercih edin`map` et
mapLeft`içine veri dönüşümleri yapısını değiştirmeden'`Either. - Terçih edin`flatMap`Basarisiz olabilecek islemlerin zincirlemesi sirasinda - Kullanın`recover`bir hata durumunda varsayılan bir değer sağlamak için. - Seçin`onLeft` etonRight(outapettapLeft) yan etkiler gibi log kaydı için. - Kullanin`bimap`iki tarafı tek bir işlem haline getirmek için. - Uygulayın`swap`bir fonksiyonun arayüzünü başka birine uyarlamanız gerektiğinde.
Sonuç
Bu yaklaşımların her biri, kullanım bağlamına göre kendi avantajına sahiptir. Yöntemler gibi`fold`, map/mapLeft, et `recover`birden fazla işlemi zincirlemek veya verileri fonksiyonel bir şekilde dönüştürmek istediğinizde özellikle faydalı olurlar
Monad`Either`d’Arrow, Kotlin uygulamalarınızda başarı ve hata durumlarını yönetmek için esnekliği harika bir şekilde sunar. Bu farklı yaklaşımları öğrenerek, daha sağlam, daha okunabilir ve daha fonksiyonel kod yazabileceksiniz.
Bir sonraki projenizde, Kotlin ve Arrow ile fonksiyonel programlamanın en iyi şekilde faydalanmak için bu teknikleri keşfetmekten çekinmeyin!
Daha ileri gitmek için
-
Resmi Arrow dokümantasyonu :https://arrow-kt.io/docs/apidocs/arrow-core/arrow.core/-either/[Ok Ya] - Kotlin Coroutines ile Arrow :https://arrow-kt.io/docs/fx/[Arrow Fx Korutinler]
Deneyimlerinizi ve tercih ettiğiniz tekniklerinizi paylaşmayı unutmayın, çalışmak için`Either`aşağıdaki yorumlarda !