用于组合来自不同来源的数据的FP模式(最好在Kotlin和Arrow中)



免责声明:最近,我对函数式编程的兴趣增加了,我能够在工作中应用最基本的方法(在我的知识和工作环境允许的情况下使用纯函数)。然而,当涉及到更先进的技术时,我仍然非常缺乏经验,我认为通过在这个网站上提问来学习一些可能是正确的想法。我偶尔也会遇到类似的问题,所以我认为FP中应该有一些模式来处理这类问题。

问题描述

它可以归结为以下几点。假设某个地方有一个API提供了所有可能宠物的列表。

data class Pet(val name: String, val isFavorite: Boolean = false)
fun fetchAllPetsFromApi(): List<Pet> {
// this would call a real API irl
return listOf(Pet("Dog"), Pet("Cat"), Pet("Parrot"))
}

这个API对";最喜欢的";字段,它不应该。它不在我的控制之下。它基本上只是返回一个宠物列表。现在我想允许用户将宠物标记为他们的最爱。我把这个标志存储在本地数据库中。

因此,在从api中获取所有宠物后,我必须根据持久化的数据设置最喜欢的标志。

class FavoriteRepository {
fun petsWithUserFavoriteFlag(allPets: List<Pet>) {
return allPets.map { it.copy(isFavorite = getFavoriteFlagFromDbFor(it) }
}
fun markPetAsFavorite(pet: Pet) {
// persist to db ...
}
fun getFavoriteFlagFromDbFor(pet: Pet): Boolean {...}
}

出于某种原因,我认为这段代码处理的是">从一个数据源获取信息的一部分,然后将其与来自另一个的一些信息合并;可能会从FP模式的应用中受益,但我真的不确定该朝哪个方向看。

我已经阅读了Arrow的一些文档(伟大的项目btw:)),并且是一个Kotlin爱好者,所以使用这个库的答案将非常感激。

下面是我可能要做的事情。您的代码有几个重要的缺陷,从函数编程的角度来看,这些缺陷使它不安全:

  • 它不标记副作用,所以编译器不知道这些副作用,也无法跟踪它们是如何使用的。这意味着我们可以在没有任何控制的情况下从任何地方调用这些效应。效果的示例是网络查询或使用数据库的所有操作
  • 您的操作没有明确说明它们可能成功或失败的事实,因此调用方只能尝试/捕获异常,否则程序将崩溃。因此,不需要处理这两种情况,这可能会导致丢失一些异常,从而导致运行时错误

让我们尝试修复它。让我们从建模我们的域错误开始,这样我们就有了一组我们的域可以理解的预期错误。让我们还创建一个映射器,这样我们就可以将抛出的所有潜在异常映射到其中一个预期的域错误,这样我们的业务逻辑就可以对这些错误做出相应的反应。

sealed class Error {
object Error1 : Error()
object Error2 : Error()
object Error3 : Error()
}
// Stubbed
fun Throwable.toDomainError() = Error.Error1

正如您所看到的,我们正在清除错误和映射程序。您可以在体系结构级别上花时间设计您的域需要的错误,并为这些错误编写一个合适的纯映射器。让我们继续前进。

是时候标记我们的效果,让编译器意识到这些了。为此,我们在Kotlin中使用suspendsuspend在编译时强制执行调用上下文,因此除非您处于挂起的环境或集成点(couroutine)中,否则永远无法调用该效果。我们将标记为挂起所有会产生副作用的操作:网络请求和所有数据库操作。

为了可读性,我还可以自由地将所有DB操作提取给Database合作者。

suspend fun fetchAllPetsFromApi(): List<Pet> = ...
class FavoriteRepository(private val db: Database = Database()) {
suspend fun petsWithUserFavoriteFlag(allPets: List<Pet>) {
... will delegate in the Database ops
}
}
class Database {
// This would flag it as fav on the corresponding table
suspend fun markPetAsFavorite(pet: Pet): Pet = ...
// This would get the flag from the corresponding table
suspend fun getFavoriteFlagFromDbFor(pet: Pet) = ...
}

我们的副作用现在是安全的。它们已经变成了效果的描述,因为如果不提供一个能够运行挂起效果(协同程序或另一个挂起的函数)的环境,我们就无法运行它们。用功能术语来说,我们会说我们的效果现在是纯粹的。

现在,让我们来看第二期。

我们还说,我们没有明确说明每个效果可能成功或失败的事实,因此调用方可能会错过抛出的潜在异常,并导致程序崩溃。我们可以通过用函数Either<A, B>数据类型包装数据来引起对数据的关注。让我们把这两个想法结合起来:

suspend fun fetchAllPetsFromApi(): Either<Error, List<Pet>> = ...
class FavoriteRepository(private val db: Database = Database()) {
suspend fun petsWithUserFavoriteFlag(allPets: List<Pet>): Either<Error, List<Pet>> {
... will delegate in the Database ops
}
}
class Database {
// This would flag it as fav on the corresponding table
suspend fun markPetAsFavorite(pet: Pet): Either<Error, Pet> = ...
// This would get the flag from the corresponding table
suspend fun getFavoriteFlagFromDbFor(pet: Pet): Either<Error, Boolean> = ...
}

现在,这明确了这样一个事实,即这些计算中的每一个都可能成功或失败,因此调用者将被迫处理双方,并且不会忘记处理潜在的错误。我们在这里使用福利中的类型。

让我们现在添加效果的逻辑:

// Stubbing a list of pets but you'd have your network request within the catch block
suspend fun fetchAllPetsFromApi(): Either<Error, List<Pet>> =
Either.catch { listOf(Pet("Dog"), Pet("Cat")) }.mapLeft { it.toDomainError() }

我们可以使用Either#catch来包装可能抛出的任何挂起的效果。这会自动将结果封装到Either中,这样我们就可以继续对其进行计算

更具体地说,如果成功,它会将块的结果封装在Either.Right中,如果抛出则会将异常封装到Either.Left。我们还使用mapLeft将抛出的潜在异常(Left侧)映射到我们的一个强类型域错误。这就是为什么它返回Either<Error, List<Pet>>而不是Either<Throwable, List<Pet>>

注意,对于Either,我们总是在左侧建模误差。这是惯例,因为Right代表了快乐的道路,我们希望我们的成功数据在那里,所以我们可以用mapflatMap或其他什么来继续计算。

我们现在可以将同样的想法应用于我们的数据库方法:

class Database {
// This would flag it as fav on the corresponding table, I'm stubbing it here for the example.
suspend fun markPetAsFavorite(pet: Pet): Either<Error, Pet> =
Either.catch { pet }.mapLeft { it.toDomainError() }
// This would get the flag from the corresponding table, I'm stubbing it here for the example.
suspend fun getFavoriteFlagFromDbFor(pet: Pet): Either<Error, Boolean> =
Either.catch { true }.mapLeft { it.toDomainError() }
}

我们再次对结果进行存根处理,但您可以想象,我们会从上面的每个Either.catch {}块中加载或更新DB表,从而获得实际的挂起效果。

最后,我们可以为回购添加一些逻辑:

class FavoriteRepository(private val db: Database = Database()) {
suspend fun petsWithUserFavoriteFlag(allPets: List<Pet>): Either<Error, List<Pet>> =
allPets.map { pet ->
db.getFavoriteFlagFromDbFor(pet).map { isFavInDb ->
pet.copy(isFavorite = isFavInDb)
}
}.sequence(Either.applicative()).fix().map { it.toList() }
}

好吧,由于我们的效果是如何写的,这个可能会有点复杂,但我会尽量弄清楚。

我们需要映射列表,以便从网络加载的每个宠物都可以从Database加载其最喜欢的状态。然后我们像你一样复制它。但如果getFavoriteFlagFromDbFor(pet)返回Either<Error, Booelan>,那么现在我们将有一个List<Either<Error, Pet>>。这可能会使处理完整的宠物列表变得困难,因为我们需要迭代,并且对于每个宠物,我们需要首先检查它是Left还是Right

为了更容易将List<Pet>作为一个整体使用,我们可能希望在这里交换类型,因此我们将使用Either<Error, List<Pet>>

对于这种魔力,一种选择是sequence。在这种情况下,sequence需要Either应用程序,因为它将用于将中间结果和最终列表提升到Either中。

我们还利用这个机会将ListK映射到stdlibList中,因为ListKsequence内部使用的,但我们可以将其理解为广义上封装在List上的函数,所以您有了一个想法。由于这里我们只对匹配我们的类型的实际列表感兴趣,所以我们可以将Right<ListK<Pet>>映射到Right<List<Pet>>

最后,我们可以继续使用这个暂停的程序:

suspend fun main() {
val repo = FavoriteRepository()
val hydratedPets = fetchAllPetsFromApi().flatMap { pets -> repo.petsWithUserFavoriteFlag(pets) }
hydratedPets.fold(
ifLeft = { error -> println(error) },
ifRight = { pets -> println(pets) }
)
}

我们选择flatMap,因为这里有顺序操作。

我们可以做一些潜在的优化,比如使用parTraverse从数据库中并行加载宠物列表的所有fav状态,并最终收集结果,但我没有使用它,因为我不确定您的数据库是否准备好进行并发访问。

以下是如何做到这一点:

suspend fun petsWithUserFavoriteFlag(allPets: List<Pet>): Either<Error, List<Pet>> =
allPets.parTraverse { pet -> 
db.getFavoriteFlagFromDbFor(pet).map { isFavInDb ->
pet.copy(isFavorite = isFavInDb)
}
}.sequence(Either.applicative()).fix().map { it.toList() }

我认为我们也可以通过改变一些类型和操作的结构来简化整个过程,但我不确定是否要从代码库中过多地重构它,因为我不知道你目前的团队限制。

这是完整的代码库:

import arrow.core.Either
import arrow.core.extensions.either.applicative.applicative
import arrow.core.extensions.list.traverse.sequence
import arrow.core.extensions.listk.foldable.toList
import arrow.core.fix
import arrow.core.flatMap
data class Pet(val name: String, val isFavorite: Boolean = false)
// Our sealed hierarchy of potential errors our domain understands
sealed class Error {
object Error1 : Error()
object Error2 : Error()
object Error3 : Error()
}
// Stubbed, would be a mapper from throwable to any of the expected domain errors used via mapLeft.
fun Throwable.toDomainError() = Error.Error1
// This would call a real API irl, stubbed here for the example.
suspend fun fetchAllPetsFromApi(): Either<Error, List<Pet>> =
Either.catch { listOf(Pet("Dog"), Pet("Cat")) }.mapLeft { it.toDomainError() }
class FavoriteRepository(private val db: Database = Database()) {
suspend fun petsWithUserFavoriteFlag(allPets: List<Pet>): Either<Error, List<Pet>> =
allPets.map { pet ->
db.getFavoriteFlagFromDbFor(pet).map { isFavInDb ->
pet.copy(isFavorite = isFavInDb)
}
}.sequence(Either.applicative()).fix().map { it.toList() }
}
class Database {
// This would flag it as fav on the corresponding table, I'm stubbing it here for the example.
suspend fun markPetAsFavorite(pet: Pet): Either<Error, Pet> =
Either.catch { pet }.mapLeft { it.toDomainError() }
// This would get the flag from the corresponding table, I'm stubbing it here for the example.
suspend fun getFavoriteFlagFromDbFor(pet: Pet): Either<Error, Boolean> =
Either.catch { true }.mapLeft { it.toDomainError() }
}
suspend fun main() {
val repo = FavoriteRepository()
val hydratedPets = fetchAllPetsFromApi().flatMap { pets -> repo.petsWithUserFavoriteFlag(pets) }
hydratedPets.fold(
ifLeft = { error -> println(error) },
ifRight = { pets -> println(pets) }
)
}

相关内容

  • 没有找到相关文章

最新更新