Kotlin Arrow : Menguasai navigasi dalam monade Either
Diterbitkan 24 September 2024
- Pendahuluan
- Apa itu monade Either?
- Konteks
- Berbagai pendekatan
- Pendekatan klasik dengan `when
- Penggunaan
folduntuk pendekatan yang lebih ringkas - Transformasi dengan
mapdan `mapLeft - Penanganan error dengan `getOrElse
- Aksi samping dengan
onLeftdan `onRight - Rantai operasi dengan `flatMap
- Transformasi dua arah dengan `bimap
- Inversi sisi dengan `swap
- Penggunaan dari
tapdantapLeft: - Penggunaan
recover:
- Kasus penggunaan praktis
- Kesimpulan
- Untuk pergi lebih jauh
Pendahuluan
Di dunia pemrograman fungsional di Kotlin, perpustakaan Arrow menawarkan alat yang kuat untuk mengelola kesalahan dan kasus alternatif. Di antara alat-alat ini, monade`Either`ditandai oleh kemampuannya untuk mewakili dua keadaan yang mungkin: keberhasilan (Right) atau kegagalan (Left). Namun bagaimana cara menavigasi secara efektif antara kedua keadaan ini? Itulah yang akan kita jelajahi dalam artikel ini.
Apa itu monade Either?
Monad`Either`adalah sebuah struktur data yang merepresentasikan dua kemungkinan yang saling eksklusif. Di Kotlin dengan Arrow, ia sering digunakan untuk mengelola kasus keberhasilan dan kegagalan suatu operasi, menawarkan alternatif yang elegan terhadap pengecualian tradisional.
Mengapa menggunakan Either?
-
Penanganan eksplisit kesalahan: Memaksa developer untuk mempertimbangkan kasus kegagalan.
-
Komposisi fungsional: Memudahkan rantai operasi yang dapat gagal.
-
Keamanan tipe : Kesalahan telah diberi tipe, yang membantu mengelola mereka dengan lebih presisi.
-
Tanpa pengecualian: Hindari efek samping dan interupsi yang tidak terduga dalam aliran eksekusi.
Konteks
Bayangkan Anda sedang mengembangkan sebuah API REST denganhttps://spring.io/projects/spring-boot/[Spring Boot,windows=read-later] et Kotlin, menggunakan perpustakaanhttps://arrow-kt.io/[Panah,Windows=baca-nanti]. Anda memiliki sebuah fungsi`findOneUserByEmail`yang mengembalikan`Either<Throwable, User>`. Bagaimana Anda dapat menangani hasil ini dengan cara yang elegan dan fungsional?
Kami memiliki kelas 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())
}
Berbagai pendekatan
Pendekatan klasik dengan `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}")
}
}
Metode ini, meskipun sederhana dan dapat dibaca, tidak sepenuhnya memanfaatkan kemampuan fungsional Arrow.
Penggunaan fold untuk pendekatan yang lebih ringkas
result.fold(
{ error -> println("Erreur : ${error.message}") },
{ user -> println("Utilisateur trouvé : ${user.login}") }
)
`fold`memungkinkan menentukan tindakan untuk kedua kasus (Left dan Right) dengan cara yang ringkas dan elegan.
Transformasi dengan map dan `mapLeft
val processedResult = result
.map { user -> "Utilisateur trouvé : ${user.login}" }
.mapLeft { error -> "Erreur : ${error.message}" }
println(processedResult.merge())
Pendekatan ini memungkinkan untuk mengubah nilai-nilai yang terkandung dalam Either sambil menjaga struktur-nya, ideal untuk rantai pemrosesan yang lebih kompleks.
Penanganan error dengan `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`menawarkan penanganan kesalahan yang elegan dengan memungkinkan penyediaan nilai default.
Aksi samping dengan onLeft dan `onRight
result.onLeft { error -> println("Erreur : ${error.message}") }
.onRight { user -> println("Utilisateur trouvé : ${user.login}") }
Metode-metode ini memungkinkan melakukan tindakan di setiap sisi tanpa mengubah Either, sempurna untuk logging atau efek samping ringan.
Rantai operasi dengan `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`adalah berguna untuk merai operasi yang mengembalikan sendiri`Either, sehingga menghindari`Either`bersarang.
Transformasi dua arah dengan `bimap
val result: Either<Throwable, User> = // ... obtention du résultat
val processedResult = result.bimap(
{ error -> "Erreur: ${error.message}" },
{ user -> "Utilisateur: ${user.login}" }
)
`bimap`memungkinkan mengubah baik sisi kiri maupun sisi kanan dalam satu operasi.
Inversi sisi dengan `swap
val result: Either<Throwable, User> = // ... obtention du résultat
val swapped = result.swap()
swap`berguna ketika Anda ingin membalikkan sisi dari satu`Either, misalnya untuk menyesuaikan antarmuka satu fungsi dengan fungsi lain.
Penggunaan dari tap dan tapLeft :
result.tap { user -> println("Utilisateur trouvé : ${user.login}") }
.tapLeft { error -> println("Erreur : ${error.message}") }
serupa dengan`onLeft` et onRight, tetapi metode-metode ini mengembalikan Either aslinya, yang berguna untuk menautkan operasi.
Penggunaan recover:
val recoveredUser = result.recover { error ->
println("Erreur récupérée : ${error.message}")
User(login = "recovered", email = "[email protected]")
}
println("Login : ${recoveredUser.login}")
Metode ini memungkinkan mengubah Either.Left menjadi Either.Right dengan menyediakan nilai pengganti.
Kasus penggunaan praktis
-
Gunakan`fold`untuk operasi sederhana yang memerlukan pengolahan untuk setiap kasus. Pilih`map` et
mapLeft`untuk transformasi data tanpa mengubah struktur dari`Either. - Pilih`flatMap`saat menautkan operasi yang dapat gagal. - Gunakan`recover`untuk menyediakan nilai default jika terjadi kesalahan. - Pilih`onLeft` etonRight(outapettapLeft) untuk efek samping seperti logging - Gunakan`bimap`untuk mengubah kedua sisi menjadi satu operasi. - Terapkan`swap`ketika Anda harus menyesuaikan antarmuka sebuah fungsi dengan fungsi lain.
Kesimpulan
Masing-masing pendekatan ini memiliki kelebihannya tergantung pada konteks penggunaannya. Metode-metode seperti`fold`, map/mapLeft, et `recover`Sangat berguna ketika Anda ingin menghubungkan beberapa operasi atau mengubah data dengan cara fungsional.
Monade`Either`d’Arrow menawarkan fleksibilitas yang luar biasa untuk menangani kasus kesuksesan dan kesalahan dalam aplikasi Kotlin Anda. Dengan menguasai pendekatan-pendekatan ini, Anda akan dapat menulis kode yang lebih kuat, lebih mudah dibaca, dan lebih fungsional.
Dalam proyek Anda berikutnya, jangan ragu untuk menjelajah teknik-teknik ini untuk mendapatkan maksimal dari pemrograman fungsional dengan Kotlin dan Arrow!
Untuk pergi lebih jauh
-
Dokumentasi resmi Arrow :https://arrow-kt.io/docs/apidocs/arrow-core/arrow.core/-either/[Panah Either] - Kotlin Coroutines dengan Arrow :https://arrow-kt.io/docs/fx/[Panah Efek Koroutin]
Jangan lupa untuk berbagi pengalaman dan teknik favorit Anda untuk bekerja dengan`Either`dalam komentar di bawah ini!