Xử Lý Lỗi Theo Phong Cách Lập Trình Hàm Trong Kotlin Với Arrow-kt
07/04/2022
589
Bài viết này sẽ giới thiệu một số cách để xử lý lỗi trong Kotlin
theo phong cách lập trình hàm sử dụng thư viện Arrow-kt. Những ví dụ được đưa ra sẽ theo thứ tự từ đơn giản đến phức tạp nhưng mạnh mẽ hơn.
Kotlin là gì?
Kotlin là một ngôn ngữ lập trình kiểu tĩnh, ban đầu được thiết kế để chạy trên máy ảo Java (JVM), sau này có thể biên dịch sang JavaScript và Native binaries sử dụng công nghệ LLVM. Kotlin có cú pháp hiện đại, ngắn gọn, an toàn và hỗ trợ cả lập trình hướng đối tượng (OOP) và lập trình hàm (FP).
Arrow-kt là gì?
Arrow-kt (https://arrow-kt.io/) là một thư viện
Typed Functional Programming
trong Kotlin. Arrow cung cấp một ngôn ngữ chung của các interface và sự trừu tượng hóa trên các thư viện Kotlin. Nó bao gồm các kiểu dữ liệu phổ biến nhất nhưOption
,Either
,Validated
, v.v … và cácfunctional operator
nhưtraverse
vàcomputation blocks
giúp cho người dùng viết các ứng dụng và thư viện pure FP dễ dàng hơn.
Setup
Trong file build.gradle.kts
của root project
, thêm mavenCentral
vào danh sách:
allprojects { repositories { mavenCentral() } }
Thêm dependency vào file build.gradle.kts
của project
:
val arrow_version = "1.0.1" dependencies { implementation("io.arrow-kt:arrow-core:$arrow_version") }
Pure function và exceptions
Pure function
Trong lập trình hàm, pure function
là những function có hai tính chất:
- Giá trị trả về chỉ phụ thuộc vào tham số truyền vào nó (tức là nếu cùng input thì cùng output).
- Không tạo ra các
side effect
.
Side effect là những tác dụng xảy ra khi thực hiện một function mà không phải công dụng chính của nó. Ví dụ: ngoài việc trả về các giá trị, nó gây ra những tương tác thay đổi môi trường, thay đổi các biến toàn cục, thực hiện các hoạt động I/O như HTTP request, in dữ liệu ra console, đọc và ghi files Trong Kotlin, tất cả các function trả về
Unit
rất có thể tạo ra side effect. Đó là bởi vì giá trị trả về làUnit
biểu thị “không có giá trị hữu ích được trả về”, điều này ngụ ý rằng function không làm gì khác ngoài việc thực hiện các side effect.
Một số ví dụ pure function trong Kotlin là những hàm toán học như sin, cos, max, …
Lợi ích của pure functions: dễ dàng combine, dễ dàng test, dễ dàng debug, dễ dàng parallelize, … Vì thế trong lập trình hàm, chúng ta sẽ cố gắng sử dụng nhiều pure functions nhất có thể, và tách biệt các phần pure và impure.
Exceptions
Kotlin có thể throw
và catch
các Exception
tương tự như ngôn ngữ Java, JavaScript, C++,… Sử dụngtry { } catch(e) { } finally { }
là cách xử lý lỗi phổ biến trong các ngôn ngữ lập trình mệnh lệnh.
Tuy nhiên việc throw và catch các Exception, chúng ta có thể thay đổi hành vi của function, khiến cho các function không còn pure nữa (catch Exception là một side effect). Ví dụ, một function catch hai Exception là ex1
và ex2
từ một function khác và tính toán kết quả, lúc đó kết quả đó sẽ phụ thuộc vào thứ tự thực thi của các câu lệnh, thậm chí có thể thay đổi giữa hai lần thực thi khác nhau của cùng một hệ thống.
Partial function
Ngoài ra, việc throw các Exception khiến cho các function trở thành một Partial function, tức là một function không hoàn toàn – không được xác định cho tất cả các giá trị input có thể có, bởi vì trong một số trường hợp, nó có thể không bao giờ trả về bất cứ thứ gì. Ví dụ, trong trường hợp một vòng lặp vô hạn hoặc nếu một Exception được throw.
Ví dụ: findUserById
ở ví dụ dưới là một partial function.
@JvmInline value class UserId(val value: String) @JvmInline value class Username(val value: String) @JvmInline value class PostId(val value: String) data class User( val id: UserId, val username: Username, val postIds: List<PostId> ) class UserException(message: String?, cause: Throwable?) : Exception(message, cause) /** * @return an [User] if found or `null` otherwise. * @throws UserException if there is any error (eg. database error, connection error, ...) */ suspend fun findUserById(id: UserId): User? = TODO()
Đề làm cho findUserById
trở thành một total function, chúng ta thay vì throw UserException, chúng ta có thể return nó như một giá trị, thay return type của findUserById
thành UserResult
.
sealed interface UserResult { data class Success(val user: User?) : UserResult data class Failure(val error: UserException) : UserResult } suspend fun findUserById(id: UserId): UserResult = TODO()
Các vấn đề với Exceptions
Exception có thể được xem như là những câu lệnh GOTO
như trong C/C++, vì chúng làm gián đoạn luồng chương trình bằng cách quay lại nơi gọi nó. Các Exception không nhất quán, đặc biệt là khi trong lập trình Multithread
, chúngta try...catch
một function nhưng Exception được throw ở một Thread
khác mà không thể catch nó được.
Một vấn đề khác là việc lạm dụng catch Exception: catch nhiều hơn những gì cần thiết và cả những Exception từ hệ thống như VirtualMachineError
, OutOfMemoryError
,…
try { doExceptionalStuff() //throws IllegalArgumentException } catch (e: Throwable) { // too broad, `Throwable` matches a set of fatal exceptions and errors // a user may be unable to recover from: /* VirtualMachineError OutOfMemoryError ThreadDeath LinkageError InterruptedException ControlThrowable NotImplementedError */ }
Và cuối cùng, nhìn vào một signature của một function, chúng ta không thể biết được, nó sẽ throw ra Exception nào, ngoài việc đọc docs hay là đọc source code của nó, thay vào đó, chúng ta hay để signature của function đó biểu hiện rõ ràng những lỗi nào có thể xảy ra khi gọi function đó.
Vì vậy, để xử lý lỗi, chúng ta cần một type có thể được compose với nhau, và biểu thị một kết quả hợp lệ hoặc một lỗi. Những type đó là Discriminated union/ tagged union, trong Kotlin đó được triển khai thông qua sealed class
/sealed interface
/enum class
. Chúng ta sẽ cùng tìm hiểu kotlin.Result
được cung cấp bởi Kotlin Sdtlib từ version 1.3 , và sau đó là arrow.core.Either
đến từ thư viện Arrow-kt
.
Sử dụng kotlin.Result để xử lý lỗi
Chúng ta có thể sử dụng Result<T>
như là một type để biểu thị: hoặc là giá trị thành công với type là T
, hoặc là là một lỗi xảy ra với một một Throwable
. Nếu theo cách hiểu đơn giản, Result<T> = T | Throwable
.
Để tạo ra giá trị Result
, ta có thể dụng các function có sẵn như
- Result.success
- Result.failure
- runCatching (tương tự như
try { } catch { }
nhưng trả vềResult
).
suspend fun findUserByIdFromDb(id: String): UserDb? = TODO() fun UserDb.toUser(): User = TODO() suspend fun findUserById(id: UserId): Result<User?> = runCatching { findUserByIdFromDb(id.value)?.toUser() }
Chúng ta có thể kiểm tra Result
là giá trị thành công hay không, thông qua hai property là isSuccess
và isFailure
. Để thực hiện các hành động ứng với mỗi trường hợp của Result
thông qua function onSuccess
và onFailure
.
val userResult: Result<User?> = findUserById(UserId("#id")) userResult.isSuccess userResult.isFailure userResult.onSuccess { u: User? -> println(u) } userResult.onFailure { e: Throwable -> println(e) }
Để có thể lấy giá trị bên trong Result
, chúng ta sử dụng các function getOr__
. Sử dụng exceptionOrNull
để truy cập giá trị Throwable
bên trong nếu Result
đại diện cho giá trị thất bại. Ngoài ra, còn có function fold
có thể handle một trong hai case dễ dàng.
val userResult: Result<User?> = findUserById(UserId("#id")) // Access value userResult.getOrNull() userResult.getOrThrow() userResult.getOrDefault(defaultValue) userResult.getOrElse { e: Throwable -> defaultValue(e) } // Access Throwable userResult.exceptionOrNull() fun handleUser(u: User?) {} fun handleError(e: Throwable) { when (e) { is UserException -> { // handle UserException } else -> { // handle other cases } } } userResult.fold( onSuccess = { handleUser(it) }, onFailure = { handleError(it) } )
Tuy nhiên, sức mạnh thực sự của Result
nằm ở việc chain các hoạt động trên nó. Ví dụ: nếu bạn muốn truy cập một property của User
:
val userResult: Result<User?> = findUserById(UserId("#id")) val usernameNullableResult: Result<Username?> = userResult.map { it?.username }
Chú ý rằng, nếu việc gọi lambda truyền vào function map
throw Exception, thì Exception đó sẽ bị throw ra ngoài. Nếu muốn Exception đó được catch và chuyển thành giá trị Result
, sử dụng mapCatching
để vừa map
vừa catching
.
val usernameResult: Result<Username> = userResult.map { checkNotNull(it?.username) { "user is null!" } }
Một vấn đề đặt ra là làm sao để chain các Result
mà phụ thuộc lẫn nhau
// (UserId) -> Result<User?> suspend fun findUserById(id: UserId): Result<User?> = TODO() // User -> List<Post> suspend fun getPostsByUser(user: User): Result<List<Post>> = TODO() // List<Post> -> Result<Unit> suspend fun doSomethingWithPosts(posts: List<Post>): Result<Unit> = TODO()
Chúng ta tạo một function flatMap
(map
và flatten
).
// Map and flatten inline fun <T, R> Result<T>.flatMap(transform: (T) -> Result<R>): Result<R> = mapCatching { transform(it).getOrThrow() } // or inline fun <T, R> Result<T>.flatMap(transform: (T) -> Result<R>): Result<R> = map(transform).flatten() inline fun <T> Result<Result<T>>.flatten(): Result<T> = getOrElse { Result.failure(it) }
Bằng việc sử dụng flatMap
, chúng ta có thể chain các Result
với nhau
val unitResult: Result<Unit> = findUserById(UserId("#id")) .flatMap { user: User? -> runCatching { checkNotNull(user) { "user is null!" } } } .flatMap { getPostsByUser(it) } .flatMap { doSomethingWithPosts(it) }
Thư viện Arrow-kt
cũng cung cấp block result { ... }
để có thể handle việc chain các Result với nhau, tránh một số trường hợp sử dụng quá nhiều các nested flatMap
. Trong block result { ... }
, sử dụng function bind()
để lấy giá trị T
từ Result<T>
. Nếu bind
được gọi trên một Result
đại diện một lỗi, thì phần code ở phía dưới nó trong block result { ... }
sẽ bị bỏ qua (cơ chế short-circuits
).
import arrow.core.* val unitResult: Result<Unit> = result { /*this: ResultEffect*/ val userNullable: User? = findUserById(UserId("#id")).bind() val user: User = checkNotNull(userNullable) { "user is null!" } val posts: List<Post> = getPostsByUser(user).bind() doSomethingWithPosts(posts).bind() }
Một số tình huống khác có thể yêu cầu các chiến lược xử lý lỗi phức tạp có thể bao gồm khôi phục hoặc báo cáo lỗi. Ví dụ, chúng ta fetch data từ remote server, nếu bị lỗi thì sẽ lấy data từ cache. Chúng ta có thể sử dụng 2 function recover
và recoverCatching
,
class MyData(...) fun getFromRemote(): MyData = TODO() fun getFromCache(): MyData = TODO() val result: Result<MyData> = runCatching { getFromRemote() } .recoverCatching { e: Throwable -> logger.error(e, "getFromRemote") getFromCache() }
Sử dụng Result là một cách tiếp cận này tốt hơn, tuy nhiên vấn đề là lỗi luôn luôn là một Throwable, ta phải đọc docs hoặc đọc source code của nó. Một vấn đề nữa là runCatching
khi kết hợp với suspend function
, nó sẽ catch mọi Throwable
, kể cả kotlinx.coroutines.CancellationException
, CancellationException
là một Exception đặc biệt, được coroutines sử dụng để đảm bảo cơ chế cooperative cancellation (xem issues 1814 Kotlin/kotlinx.coroutines).
Một cách tiếp cận tốt hơn là sử dụng arrow.core.Either
, khắc phục các nhược điểm của Result
.
Sử dụng arrow.core.Either để xử lý lỗi
Chúng ta có thể sử dụng Either<L, R>
như là một type để biểu thị: hoặc là giá trị Left(value: L)
, hoặc là giá trị Right(value: R)
. Nếu theo cách hiểu đơn giản, Either<L, R> = L | R
.
public sealed class Either<out A, out B> { public data class Left<out A> constructor(val value: A) : Either<A, Nothing>() public data class Right<out B> constructor(val value: B) : Either<Nothing, B>() }
Trong đó, Left
thường đại diện cho các giá trị lỗi, giá trị không mong muốn, và Right
thường đại diện cho các giá trị thành công, giá trị mong muốn. Nhìn chung Either<L, R>
tương tự với Result<T>
, Result<T>
chỉ tập trung vào type của giá trị thành công mà không quan tâm đến type của giá trị lỗi, và chúng ta có thể xem Result<R> ~= Either<Throwable, R>
. Either
là right-biased
, tức là các function như map
, filter
, flatMap
, … sẽ theo nhánh Right
, nhánh Left
bị bỏ qua (được return trực tiếp mà không có hành động nào trên nó cả).
Để tạo ra giá trị Either
, ta có thể dụng các function có sẵn như:
Left
constructor, ví dụ:val e: Either<Int, Nothing> = Left(1)
Right
constructor, ví dụ:val e: Either<Nothing, Int> = Right(1)
left
extension function, ví dụval e: Either<Int, Nothing> = 1.left()
.right
extension function, ví dụval e: Either<Nothing, Int> = 1.right()
.Either.catch
functions, catch các Exceptions nhưng nó sẽ bỏ qua các fatal Exception
nhưkotlinx.coroutines.CancellationException
,VirtualMachineError
,OutOfMemoryError
,…- Và nhiều cách được cung cấp bởi
arrow.core.Either.Companion
.
suspend fun findUserByIdFromDb(id: String): UserDb? = TODO() fun UserDb.toUser(): User = TODO() fun Throwable.toUserException(): UserException = TODO() suspend fun findUserById(id: UserId): Either<UserException, User?> = Either .catch { findUserByIdFromDb(id.value)?.toUser() } // Either<Throwable, User?> .mapLeft { it.toUserException() } // Either<UserException, User?>
Chúng ta có thể kiểm tra Either
là giá trị Left
hay Right
, thông qua hai function là isLeft()
và isLeft()
. Either
cũng cung cấp hai function tap
(tương tự onSucess
của Result
) và tapLeft
(tương tự onFailure
của Result
).
val result: Either<UserException, User?> = findUserById(UserId("#id")) result.isLeft() result.isRight() result.tap { u: User? -> println(u) } result.tapLeft { e: UserException -> println(e) }
Tương tự như Result
, chúng ta sử dụng các function getOrElse
, orNull
, getOrHandle
để lấy giá trị mà Right
chứa nếu nó là Right
. Một số function hữu ích nữa là fold
, bimap
, mapError
, filter
,…
val result: Either<UserException, User?> = findUserById(UserId("#id")) // Access value result.getOrElse { defaultValue } result.orNull() result.getOrHandle { e: UserException -> defaultValue(e) } fun handleUser(u: User?) {} fun handleError(e: UserException) { // handle UserException } result.fold( ifRight = { handleUser(it) }, ifLeft = { handleError(it) } )
Tương tự ví dụ khi sử dụng Result
, chúng ta cũng muốn chain nhiều giá trị Either
với nhau
// (UserId) -> Either<UserException, User?> suspend fun findUserById(id: UserId): Either<UserException, User?> = TODO() // User -> Either<UserException, List<Post>> suspend fun getPostsByUser(user: User): Either<UserException, List<Post>> = TODO() // List<Post> -> Either<UserException, Unit> suspend fun doSomethingWithPosts(posts: List<Post>): Either<UserException, Unit> = TODO()
Thư viện Arrow-kt
đã cung cấp sẵn function flatMap
và block either { ... }
để có thể chain các Either
với nhau dễ dàng. Trong either { ... }
block, sử dụng function bind()
để lấy giá trị R
từ Either<L, R>
. Nếu bind
được gọi trên một Either
chứa giá trị Left
, thì phần code ở phía dưới nó trong block either { ... }
sẽ bị bỏ qua (cơ chế short-circuits
).
import arrow.core.* class UserNotFoundException() : UserException("User is null", null) val result: Either<UserException, Unit> = findUserById(UserId("#id")) .flatMap { user: User? -> if (user == null) UserNotFoundException().left() else user.right() } .flatMap { getPostsByUser(it) } .flatMap { doSomethingWithPosts(it) } // or either block val result: Either<UserException, Unit> = either { /*this: EitherEffect*/ val userNullable: User? = findUserById(UserId("#id")).bind() val user: User = ensureNotNull(userNullable) { UserNotFoundException() } val posts: List<Post> = getPostsByUser(user).bind() doSomethingWithPosts(posts).bind() }
Cuối cùng là cách khôi phục lỗi. Tương tự như recover
và recoverCatching
của Result
, chúng ta có thể sử dụng hai function handleError
và handleErrorWith
(giống như flatMap
nhưng theo nhánh Left
).
class MyData(...) suspend fun getFromRemote(): MyData = TODO() suspend fun getFromCache(): MyData = TODO() val result: Either<Throwable, MyData> = Either .catch { getFromRemote() } .handleErrorWith { e: Throwable -> Either.catch { logger.error(e, "getFromRemote") getFromCache() } }
Kết luận
Chúng ta đã cùng tìm hiểu Result
và sau đó là Either
, cả hai type giúp xử lý lỗi và làm giảm side effect. Either
còn chỉ rõ về những lỗi có thể xảy ra mà chỉ cần nhìn vào signature của một function. Ngoài ra, Either
hỗ trợ cho suspend function
mà không làm mất đi cơ chế cancellation, và Arrow-kt
cũng có module Fx
(https://arrow-kt.io/docs/fx/) giúp cho việc sử dụng Kotlin Coroutines
dễ dàng hơn, khi viết các chương trình async
và concurrent
.
Hy vọng bạn thích bài viết này và hôm nay bạn đã học được điều gì đó hữu ích!
Tài liệu tham khảo
- Arrow-kt – Either docs
- Arrow-kt – Error handlding
- Arrow-kt – Monad comprehension
- Ciocîrlan, D. (2021) Idiomatic error handling in scala, Rock the JVM Blog. Available at: https://blog.rockthejvm.com/idiomatic-error-handling-in-scala/ (Accessed: 04 October 2024).
Author: st-hocnguyen
Related Blog