0

免责声明:最近,我对函数式编程的兴趣越来越大,我已经能够在我的工作中应用最基本的方法(在我的知识和工作环境允许的情况下使用纯函数)。然而,当涉及到更高级的技术时,我仍然非常缺乏经验,我认为通过在这个网站上提问来学习一些可能是正确的想法。我每隔一段时间就会偶然发现一次类似的问题,所以我认为 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的一些文档(顺便说一句很棒的项目 :)),并且是 Kotlin 的狂热爱好者,因此非常感谢使用这个库的答案。

4

1 回答 1

1

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

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

让我们尝试修复它。让我们首先对我们的域错误进行建模,这样我们就有了一组我们的域可以理解的预期错误。让我们还创建一个映射器,以便我们将所有潜在的异常都映射到那些预期的域错误之一,以便我们的业务逻辑可以相应地对这些错误做出反应。

sealed class Error {
    object Error1 : Error()
    object Error2 : Error()
    object Error3 : Error()
}

// Stubbed
fun Throwable.toDomainError() = Error.Error1

如您所见,我们正在对错误和映射器进行存根处理。您可以花时间在架构级别上设计您的域所需的错误,并为这些错误编写适当的纯映射器。我们继续吧。

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

为了可读性,我还可以自由地将所有数据库操作拉出给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将潜在的异常抛出(Leftside)映射到我们的强类型域错误之一。这就是为什么它返回Either<Error, List<Pet>>而不是Either<Throwable, List<Pet>>.

请注意,Either我们总是在左侧建模错误。这是约定俗成的,因为Right代表了幸福的道路,我们希望我们成功的数据在那里,所以我们可以用 、 或其他方式继续map计算flatMap

我们现在可以对我们的 db 方法应用相同的想法:

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 {}上面每个块内的数据库表中加载或更新我们的实际暂停效果。

最后,我们可以在 repo 中添加一些逻辑:

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>>一个结果,这可能会使处理完整的宠物列表变得困难,因为我们需要迭代,并且首先我们需要检查每个宠物是否是LeftRight

为了更容易地使用List<Pet>整体,我们可能想在这里交换类型,所以我们应该有Either<Error, List<Pet>>

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

我们还利用这个机会将其映射ListK到 stdlibList中,因为ListK它是sequence内部使用的,但我们可以将其理解为用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从数据库中并行加载所有最喜欢的状态以获取宠物列表并最终收集结果,但我没有使用它,因为我不确定你的数据库是否准备好并发使用权。

你可以这样做:

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) }
    )
}

于 2020-12-09T09:09:28.963 回答