Kotlin Arrow : آموزش ناوبری در موناد Either
منتشر شده در 24 September 2024
معرفی
در دنیای برنامهنویسی عملکردی در Kotlin، کتابخانه Arrow ابزارهای قدرتمندی برای مدیریت خطاها و موارد جایگزین ارائه میدهد. در میان این ابزارها، مونád`Either`به دلیل توانایی آن در نمایش دو وضعیت ممکن: موفقیت (Right) یا شکست (Left) متمایز میشود. اما چگونه میتوان بین این دو وضعیت بهطور مؤثر حرکت کرد؟ این موضوع را در این مقاله بررسی خواهیم کرد.
موناد Either چیست؟
موناد`Either`یک ساختار داده است که دو امکان mutually exclusive را نشان میدهد. در Kotlin با Arrow، غالبًا برای مدیریت موارد موفقیت و شکست یک عملیات استفاده میشود، یک جایگزین شیک برای استثناهای سنتی ارائه میدهد.
چرا از Either استفاده کنیم؟
-
مدیریت صریح خطاها : به توسعهدهنده میفرضد تا موارد شکست را در نظر بگیرد.
-
ترکیب عملکردی : زنجیرهٔ operationsهایی که میتوانند شکست بخورند را تسهیل میدهد
-
Type-safety : خطاها تایپ میشوند، که به مدیریت دقیقتر آنها کمک میکند.
-
هیچ استثنایی نیست: از اثرات جانبی و قطعهای غیرمنتظره جریان اجرا اجتناب کنید.
بهمتن
فرض کنید که شما یک API REST را در حال توسعه میدهید باhttps://spring.io/projects/spring-boot/[Spring Boot,windows=خواندن بعد] et Kotlin, با استفاده از کتابخانهhttps://arrow-kt.io/[تیر,windows=read-later]. شما یک تابع دارید`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`یک مدیریت شایسته-line خطا را با اجازه دادن برای ارائه یک مقدار پیشفرض ارائه میدهد.
اقدامات جانبی با 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` etonRight(outapettapLeft) برای آثار جانبی مثل لاگینگ. - استفاده کنید`bimap`برای تبدیل دو طرف به یک عملیات واحد. - اعمال کنید`swap`وقتی که باید اینترفیس یک تابع را به دیگری تنظیم کنید.
نتیجه
هر یک از این رویکردها مزایای خود را بسته به زمینه استفاده دارد. روشهایی مانند`fold`, map</think> (empty)mapLeft, et `recover`بهویژه زمانی که میخواهید چندین عملیات را بهصورت پی در پی انجام دهید یا دادهها را بهصورت فانکشنال تبدیل کنید، بسیار مفید هستند.
موناد`Either`Arrow انعطافپذیری قابل توجهی برای مدیریت موارد موفقیت و خطا در برنامههای Kotlin شما فراهم میکند. با تسلط بر این روشهای مختلف، شما میتوانید کدی قویتر، قابلخوانیتر و عملکردیتر بنویسید.
در پروژهی بعدی شما، ترسید نکنید از کاوش این تکنیکها برای به حداکثر رساندن استفاده از برنامهنویسی تابعی با Kotlin و Arrow!
برای پیشبرد بیشتر
-
مستندات رسمی Arrow :https://arrow-kt.io/docs/apidocs/arrow-core/arrow.core/-either/[سهم یکی از دو] - کاتلین کوارتینها با اپرو :https://arrow-kt.io/docs/fx/[Arrow Fx کوریوتینها]
فراموش نکنید که تجارب و تکنیکهای مورد علاقه خود را برای کار با به اشتراک بگذارید.`Either`در نظرات زیر!