Kotlin Arrow: เชี่ยวชาญการนำทางในโมเนด Either
เผยแพร่เมื่อ 24 September 2024
บทนำ
ในโลกของการเขียนโปรแกรมเชิงฟังก์ชันใน Kotlin, ไลบรารี Arrow เสนอเครื่องมือที่มีประสิทธิภาพเพื่อจัดการกับข้อผิดพลาดและตัวเลือกอื่น ๆ ในบรรดาเครื่องมือเหล่านี้, โมนาด`Either`มีความสามารถในการแทนสองสถานะที่เป็นไปได้: ความสำเร็จ (Right) หรือความล้มเหลว (Left). แล้วจะนำทางระหว่างสองสถานะนี้ได้อย่างมีประสิทธิภาพอย่างไร? นี่คือสิ่งที่เราจะสำรวจในบทความนี้
Monad Either คืออะไร ?
โมเนด`Either`เป็นโครงสร้างข้อมูลที่แสดงถึงความเป็นไปได้สองอย่างซึ่งยกเว้นกันและกัน ใน Kotlin พร้อมกับ Arrow ใช้บ่อยในการจัดการกรณีความสำเร็จและความล้มเหลวของการดำเนินการ โดยให้ทางเลือกที่สง่างามแทนการใช้ exception แบบเดิม
ทำไมใช้ Either?
-
การจัดการข้อผิดพลาดอย่างชัดเจน : บังคับให้ผู้พัฒนาพิจารณากรณีที่ล้มเหลว
-
องค์ประกอบเชิงฟังก์ชัน: ช่วยอำนวยความสะดวกในการต่อกันของการดำเนินงานที่อาจล้มเหลว
-
ความปลอดภัยของชนิดข้อมูล : ข้อผิดพลาดถูกกำหนดชนิด ซึ่งช่วยให้การจัดการทำได้อย่างแม่นยำมากขึ้น
-
ไม่มีข้อยกเว้น: หลีกเลี่ยงผลกระทบด้านข้างและการหยุดชะงักที่ไม่คาดคิดในกระแสการทำงาน
บริบท
สมมติว่าคุณกำลังพัฒนา API REST ด้วยhttps://spring.io/projects/spring-boot/[Spring Boot,windows=อ่านทีหลัง] et Kotlin, โดยการใช้ไลบรารี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 จึงเหมาะกับการบันทึก Log หรือผลข้างเคียงที่เบาบาง
การเรียงต่อการดำเนินการด้วย `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`pourการแปลงข้อมูลโดยไม่เปลี่ยนโครงสร้างของ l'`Either. เลือก`flatMap`เมื่อทำการเชื่อมโยงการดำเนินการที่อาจล้มเหลว ใช้`recover`เพื่อให้ค่าเริ่มต้นเมื่อเกิดข้อผิดพลาด เลือกรับ`onLeft` etonRight(outapettapLeft) เพื่อผลข้างเคียงเช่นการทำ logging 使用`bimap`แปลงทั้งสองด้านให้เป็นการทำงานเพียงครั้งเดียว ใช้`swap`เมื่อคุณต้องปรับอินเทอร์เฟซของฟังก์ชันหนึ่งให้เข้ากับฟังก์ชันอื่น
สรุป
แต่ละวิธีเหล่านี้มีข้อดีของมันเองตามบริบทการใช้งาน. วิธีการเช่น`fold`, map(empty)mapLeft, et `recover`มีประโยชน์อย่างมากเมื่อคุณต้องการเชื่อมต่อหลาย ๆ การดำเนินงาน หรือแปลงข้อมูลในรูปแบบการทำงานเชิงฟังก์ชัน
โมเนด`Either`Arrow เสนอความยืดหยุ่นที่น่าทึ่งสำหรับจัดการกรณีสำเร็จและข้อผิดพลาดในแอปพลิเคชัน Kotlin ของคุณ เมื่อคุณเชี่ยวชาญวิธีการต่างๆ นี้แล้ว คุณจะสามารถเขียนโค้ดที่แข็งแรงขึ้น อ่านง่ายขึ้น และทำงานได้ดีขึ้น
ในโครงการต่อไปของคุณ อย่าลังเลที่จะสำรวจเทคนิคเหล่านี้เพื่อใช้ประโยชน์จากการเขียนโปรแกรมเชิงฟังก์ชั่นด้วย Kotlin และ Arrow!
เพื่อไปให้ไกลขึ้น
-
เอกสารอย่างเป็นทางการของ Arrow :https://arrow-kt.io/docs/apidocs/arrow-core/arrow.core/-either/[ลูกศร ทั้งสอง] - Kotlin คอร์ทีน พร้อมกับ Arrow :https://arrow-kt.io/docs/fx/[อาร์โรว์ FX คอร์รูทีน]
อย่าลืมแบ่งปันประสบการณ์และเทคนิคที่คุณชื่นชอบเพื่อทำงานกับ`Either`ในความคิดเห็นด้านล่างนี้!