Kotlin Arrow: Làm chủ khả năng điều hướng trong monad Either
Đăng ngày 24 September 2024
- Giới thiệu
- Monad Either là gì?
- Bối cảnh
- Các phương pháp tiếp cận khác nhau
- Cách tiếp cận cổ điển với `when
- Sử dụng
foldđể có cách tiếp cận ngắn gọn hơn - Biến đổi với
mapvà `mapLeft - Xử lý lỗi với `getOrElse
- Các tác vụ bên cạnh với
onLeftvà `onRight - Chuỗi các thao tác với `flatMap
- Biến đổi hai chiều với `bimap
- Đảo ngược các phía với `swap
- Sử dụng
tapvàtapLeft: - Sử dụng
recover:
- Các trường hợp sử dụng thực tế
- Kết luận
- Để tìm hiểu thêm
Giới thiệu
Trong thế giới lập trình hàm với Kotlin, thư viện Arrow cung cấp các công cụ mạnh mẽ để quản lý lỗi và các trường hợp thay thế. Trong số các công cụ này, monad`Either`nổi bật với khả năng đại diện cho hai trạng thái có thể xảy ra: thành công (Right) hoặc thất bại (Left). Nhưng làm thế nào để điều hướng hiệu quả giữa hai trạng thái này? Đó là điều chúng ta sẽ khám phá trong bài viết này.
Monad Either là gì?
Monad`Either`là một cấu trúc dữ liệu đại diện cho hai khả năng loại trừ lẫn nhau. Trong Kotlin với Arrow, nó thường được sử dụng để quản lý các trường hợp thành công và thất bại của một thao tác, cung cấp một giải pháp thay thế trang nhã cho các ngoại lệ truyền thống.
Tại sao nên sử dụng Either?
-
Quản lý lỗi tường minh: Buộc nhà phát triển phải xem xét các trường hợp thất bại.
-
Composition hàm: Tạo điều kiện thuận lợi cho việc chuỗi các thao tác có thể thất bại.
-
Type-safety: Các lỗi được định kiểu, giúp quản lý chúng một cách chính xác hơn.
-
Không có ngoại lệ: Tránh các tác dụng phụ và sự gián đoạn không mong muốn của luồng thực thi.
Bối cảnh
Hãy tưởng tượng bạn đang phát triển một API REST vớihttps://spring.io/projects/spring-boot/[Spring Boot,windows=read-later] et Kotlin, bằng cách sử dụng thư việnhttps://arrow-kt.io/[Arrow,windows=read-later]. Bạn có một hàm`findOneUserByEmail`mà trả về một`Either<Throwable, User>`. Làm thế nào bạn có thể xử lý kết quả này một cách thanh thoát và hiệu quả?
Chúng ta có một lớp 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())
}
Các phương pháp tiếp cận khác nhau
Cách tiếp cận cổ điển với `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}")
}
}
Phương pháp này, mặc dù đơn giản và dễ đọc, nhưng không tận dụng hết khả năng chức năng của Arrow.
Sử dụng fold để có cách tiếp cận ngắn gọn hơn
result.fold(
{ error -> println("Erreur : ${error.message}") },
{ user -> println("Utilisateur trouvé : ${user.login}") }
)
`fold`cho phép định nghĩa các hành động cho cả hai trường hợp (Left và Right) một cách súc tích và trang nhã.
Biến đổi với map và `mapLeft
val processedResult = result
.map { user -> "Utilisateur trouvé : ${user.login}" }
.mapLeft { error -> "Erreur : ${error.message}" }
println(processedResult.merge())
Cách tiếp cận này cho phép chuyển đổi các giá trị chứa trong Either trong khi vẫn giữ nguyên cấu trúc của nó, lý tưởng cho các chuỗi xử lý phức tạp hơn.
Xử lý lỗi với `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`cung cấp khả năng quản lý lỗi tinh tế bằng cách cho phép cung cấp một giá trị mặc định.
Các tác vụ bên cạnh với onLeft và `onRight
result.onLeft { error -> println("Erreur : ${error.message}") }
.onRight { user -> println("Utilisateur trouvé : ${user.login}") }
Các phương thức này cho phép thực hiện các hành động trên mỗi phía mà không làm thay đổi Either, hoàn hảo cho việc logging hoặc các tác dụng phụ nhẹ.
Chuỗi các thao tác với `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`hữu ích để chuỗi các hoạt động mà bản thân chúng trả về các`Either, từ đó tránh được các`Either`lồng nhau.
Biến đổi hai chiều với `bimap
val result: Either<Throwable, User> = // ... obtention du résultat
val processedResult = result.bimap(
{ error -> "Erreur: ${error.message}" },
{ user -> "Utilisateur: ${user.login}" }
)
`bimap`cho phép chuyển đổi cả bên trái và bên phải trong một thao tác duy nhất.
Đảo ngược các phía với `swap
val result: Either<Throwable, User> = // ... obtention du résultat
val swapped = result.swap()
swap`là hữu ích khi bạn muốn đảo ngược các cạnh của một`Either, ví dụ như để điều chỉnh giao diện của một hàm này cho phù hợp với một hàm khác.
Sử dụng tap và tapLeft:
result.tap { user -> println("Utilisateur trouvé : ${user.login}") }
.tapLeft { error -> println("Erreur : ${error.message}") }
Tương tự như`onLeft` et onRight, nhưng các phương thức này trả về Either ban đầu, điều này hữu ích cho việc chuỗi các thao tác.
Sử dụng recover:
val recoveredUser = result.recover { error ->
println("Erreur récupérée : ${error.message}")
User(login = "recovered", email = "[email protected]")
}
println("Login : ${recoveredUser.login}")
Phương pháp này cho phép chuyển đổi một Either.Left thành Either.Right bằng cách cung cấp một giá trị thay thế.
Các trường hợp sử dụng thực tế
-
Sử dụng`fold`cho các thao tác đơn giản yêu cầu xử lý cho từng trường hợp. - Ưu tiên`map` et
mapLeft`cho những chuyển đổi dữ liệu mà không làm thay đổi cấu trúc của`Either`Vui lòng cung cấp văn bản cần dịch. - Hãy chọn`flatMap`khi chuỗi các thao tác có thể thất bại. Sử dụng`recover`để cung cấp một giá trị mặc định trong trường hợp xảy ra lỗi. - Chọn`onLeftetonRight(outapettapLeft) cho các tác dụng phụ như logging. - Sử dụng`bimap`để chuyển đổi cả hai phía thành một thao tác duy nhất. - Áp dụng`swap`khi bạn cần điều chỉnh giao diện của một hàm để phù hợp với một hàm khác.
Kết luận
Mỗi cách tiếp cận này đều có những ưu điểm tùy theo ngữ cảnh sử dụng. Các phương pháp như`fold`, map/mapLeft, et `recover`đặc biệt hữu ích khi bạn muốn chuỗi nhiều thao tác hoặc biến đổi dữ liệu theo phong cách lập trình hàm.
Monad`Either`của Arrow mang lại sự linh hoạt đáng kể để quản lý các trường hợp thành công và lỗi trong ứng dụng Kotlin của bạn. Bằng cách nắm vững các phương pháp tiếp cận khác nhau này, bạn có thể viết mã nguồn mạnh mẽ hơn, dễ đọc hơn và mang tính hàm hơn.
Trong dự án tiếp theo của bạn, đừng ngần ngại khám phá những kỹ thuật này để tận dụng tối đa lập trình hàm với Kotlin và Arrow!
Để tìm hiểu thêm
-
Tài liệu chính thức của Arrow:https://arrow-kt.io/docs/apidocs/arrow-core/arrow.core/-either/[Arrow Either] Kotlin Coroutines với Arrow:https://arrow-kt.io/docs/fx/[Arrow Fx Coroutines]
Đừng quên chia sẻ những trải nghiệm và kỹ thuật yêu thích của bạn khi làm việc với`Either`trong phần bình luận bên dưới!