Kotlin Arrow: إتقان التنقل في موناد Either
Publié le 24 September 2024
مقدمة
في عالم البرمجة الوظيفية في كوتلن، توفر مكتبة Arrow أدوات قوية لإدارة الأخطاء والحالات البديلة. من بين هذه الأدوات، الأحادية`Either`يتميز بقدرته على تمثيل حالتين محتملتين : النجاح (Right) أو الفشل (Left). لكن كيف يمكن التنقل بفعالية بين هاتين الحالتين؟ هذا ما سنستكشفه في هذه المقالة.
ما هو الموناد Either؟
الواحد`Either`هي بنية بيانات تمثل إمكانيتين متبادلتين حصرًا. في Kotlin مع Arrow، تُستخدم غالبًا لمعالجة حالات النجاح والفشل لعملية، وتقديم بديل أنيق للاستثناءات التقليدية.
لماذا استخدام Either؟
-
إدارة صريحة للأخطاء: تفرض على المطور النظر في حالات الفشل.
-
التركيب الوظيفي: يسهل ربط العمليات التي يمكن أن تفشل.
-
سلامة النوع : الأخطاء مكتوبة النوع، مما يساعد على إدارتها بدقة أكبر.
-
لا استثناءات: تجنّب الآثار الجانبية والانقطاعات غير المتوقعة لتدفق التنفيذ.
السياق
تخيل أنك تطور واجهة برمجة تطبيقات REST معhttps://spring.io/projects/spring-boot/[Spring Boot,windows=اقرأ لاحقًا] et كوتلن، النوافذ = اقرأ لاحقًا, باستخدام المكتبةhttps://arrow-kt.io/[سهم،نوافذ=اقرأ-لاحقًا]. لديك وظيفة`findOneUserByEmail`الذي يعيد`Either<Throwable, User>`كيف يمكنك معالجة هذه النتيجة بطريقة أنيقة ووظيفية؟
لدينا فئة 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`توفر إدارة أنيقة للأخطاء من خلال السماح بتوفير قيمة افتراضية.
الإجراءات الجانبية مع onLeft و `onRight
result.onLeft { error -> println("Erreur : ${error.message}") }
.onRight { user -> println("Utilisateur trouvé : ${user.login}") }
هذه الطرق تسمح بأداء إجراءات على كل جانب دون تعديل Either، وهو مثالي للتسجيل أو للآثار الجانبية الخفيفة.
سلسلة العمليات مع `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` et onRight (ou tap et tapLeft) لآثار جانبية مثل التسجيل. - استخدموا`bimap`لتحويل كلا الجانبين إلى عملية واحدة. طبقوا`swap`عندما تحتاج إلى تعديل واجهة وظيفة لتتوافق مع أخرى.
خاتمة
لكل من هذه المناهج مزاياها وفقًا لسياق الاستخدام. الطرق مثل`fold`, map/mapLeft, et `recover`هم مفيدون بشكل خاص عندما تريد ربط عدة عمليات أو تحويل البيانات بطريقة وظيفية.
الواحدة`Either`يوفر d’Arrow مرونة ملحوظة لإدارة حالات النجاح والخطأ في تطبيقات Kotlin الخاصة بك. من خلال إتقان هذه الأساليب المختلفة، ستتمكن من كتابة كود أكثر متانة، أكثر وضوحًا، وأكثر وظيفية.
في مشروعك القادم، لا تتردد في استكشاف هذه التقنيات للاستفادة القصوى من البرمجة الوظيفية مع Kotlin وArrow!
للمضي قدمًا
-
توثيق رسمي لـ Arrow :https://arrow-kt.io/docs/apidocs/arrow-core/arrow.core/-either/[سهم إما] - كوتليـن كوروتينز مع أرو :https://arrow-kt.io/docs/fx/[سهم Fx كوروتين]
لا تنسى مشاركة تجاربك وتقنياتك المفضلة للعمل مع`Either`في التعليقات أدناه !