F# - 依赖管理

Posted

技术标签:

【中文标题】F# - 依赖管理【英文标题】:F# - Dependencies management 【发布时间】:2021-11-18 07:32:20 【问题描述】:

我想从功能范式的角度来理解依赖管理的概念。我试图应用dependency rejection 的概念,据我了解,这一切归结为从不纯的 [I/O] 和纯操作创建一个“三明治”,并在执行任何 I/O 操作时仅将值传递给纯函数系统边缘。问题是,我仍然必须以某种方式从外部来源获得结果,这就是我被困的地方。

考虑下面的代码:

[<ApiController>]
[<Route("[controller]")>]
type UserController(logger: ILogger<UserController>, compositionRoot: CompositionRoot) =
    inherit BaseController()
    
    [<HttpPost("RegisterNewUser")>]
    member this.RegisterNewUser([<FromBody>] unvalidatedUser: UnvalidatedUser) = // Receive input from external source: Impure layer
        User.from unvalidatedUser            // Vdalidate incoming user data from domain perspective: Pure layer
        >>= compositionRoot.persistUser      // Persist user [in this case in database]: Impure layer
        |> this.handleWorkflowResult logger  // Translate results to response: Impure layer

CompositionRoot 和记录器通过依赖注入注入。这样做有两个原因:

我真的不知道如何以除 DI 之外的其他功能性方式获取这些依赖项。 在这种特殊情况下,CompositionRoot 需要基于 EntityFramework 的数据库存储库,这些存储库也是通过 DI 获得的。

这是组合根本身:

type CompositionRoot(userRepository: IUserRepository) = // C# implementation of repository based on EntityFramework
    member _.persistUser = UserGateway.composablePersist userRepository.Save
    member _.fetchUserByKey = UserGateway.composableFetchByKey userRepository.FetchBy

在我看来,以上内容与在 C# 中完成的“标准”依赖注入没有任何不同。我能看到的唯一区别是,这是对函数而不是抽象-实现对进行操作,并且是“手动”完成的。

我在互联网上搜索了一些大型项目中依赖项管理的示例,但我发现的只是简单的示例,最多传递了一个或两个函数。虽然这些都是学习目的的好例子,但我真的看不到它在实际项目中被使用,在这种项目中,这种“手动”依赖项管理可能会迅速失控。关于外部数据源(例如数据库)的其他示例提供了预期接收连接字符串的方法,但是必须从某处获得此输入[通常通过 C# 中的IConfiguration],并将其硬编码在组合根中的某处以将其传递给组合函数是显然远非理想。

我发现的另一种方法是combination of multiple dependencies into single structure。这种方法更类似于带有“接口”的标准 DI,同样由手工组成。

我还有最后一个担心:调用需要某些依赖项的其他函数的函数呢?我应该将这些依赖项传递给所有函数吗?

let function2 dependency2 function2Input =
    // Some work here...
    
let function1 dependency1 dependency2 function1Input =
    let function2Input = ...
        
    function2 dependency2 function2Input
    
// Top-level function which receives all dependencies required by called functions
let function0 dependency0 dependency1 dependency2 function0Input =
    let function1Input = ...
        
    function1 dependency1 dependency2 function1Input

最后一个问题是关于组合根本身:它应该放在哪里?我应该以类似的方式构建它,例如在注册所有服务的 C# Startup 中,还是应该创建特定于给定工作流/案例的单独组合根?这些方法中的任何一种都需要我从某个地方获取必要的依赖项[如存储库],以便创建组合根。

【问题讨论】:

【参考方案1】:

这里的问题不止一个,但我会尽力按顺序回答。

首先,您需要权衡不同架构决策的优缺点。为什么要在第一种情况下将依赖项注入 Controller?

如果您想让控制器接受某些类型的自动化测试,这可能是个好主意。我通常do this with state-based integration testing。然而,还有另一种观点坚持认为您不应该对控制器进行单元测试。这个想法是控制器应该如此耗尽逻辑,以至于单元测试不值得麻烦。在这种情况下,您不需要该级别的依赖注入 (DI) - 至少,不是出于测试目的。相反,您可以将实际的数据库代码留在控制器中,而无需求助于任何 DI。那么,任何测试都必须涉及一个真实的数据库(尽管这也可以自动化)。

这将是一个有效的架构选择,但为了争论,我们假设您至少希望将依赖项注入控制器,以便您可以进行一些自动化测试。

在 C# 中,我会为此使用接口,以及 I'd also use interfaces in F#。没有理由将同事与free monads 混淆。不过,传递函数也可能是可行的。

我在互联网上搜索了一些大型项目中的依赖管理示例

是的,这是一个已知问题(同样在 OOD 中)。由于相当明显的原因,缺乏复杂的示例:现实世界的示例是专有的,通常不是开源的,很少有人会花费几个月的空闲时间来开发足够复杂的示例代码库。

为了解决这个问题,我开发了这样一个代码库来配合my book Code That Fits in Your Head。该代码库使用 C# 而不是 F#,但它确实遵循 Impureim Sandwich architecture(AKA 功能核心,命令式 shell)。我希望您能够从该示例代码库中学习。它处理一小部分不纯的依赖项,但将它们全部限制在控制器中。

那些调用其他需要依赖的函数的函数呢?

在 FP 中,您应该努力编写纯函数。虽然您可以从其他函数 (higher order functions) 组合函数,但它们是 should all still be pure。因此,将具有不纯依赖关系的其他函数组合起来并不是很习惯,因为that makes the entire composition impure。

改为keep all impure dependencies at the boundary of the system (e.g. Controllers) and compose everything else from pure functions。

组合根本身:它应该位于哪里?

如果你需要一个(关于可测试性的第一点),它就相当于 C#。将其关闭应用程序的入口点。

不管是什么语言,我更喜欢pure DI。

【讨论】:

感谢马克的广泛回答。至于您关于将依赖项注入控制器的问题:原因之一确实是可测试性。第二个原因是通过将接口-实现对组合在一个地方,可以轻松更改整个程序的实现。到目前为止,我通过容器和依赖注入模式解决了这个问题。假设我有另一个 ILogger 接口实现,我想在整个程序中进行更改。如果没有 DI 模式和组合根,我无法理解如何实现这一目标。 @Bremewood 正如我所写,在 F# 中,为此使用接口或函数...【参考方案2】:

如果您尝试编写纯函数式代码,那么依赖注入在这里没有多大帮助,因为注入的函数(persistUserfetchUserByKeyhandleWorkflowResult)本身是不纯的。因此,任何调用这些函数的东西(例如RegisterNewUser)也是不纯的。

那么我们如何从纯函数式业务逻辑中分离(即“拒绝”)不纯的依赖关系?在 F# 中执行此操作的一种方法是定义计算表达式,然后您可以使用它来构建纯函数计算,如下所示:

// Pure functional code with no dependencies.
let registerNewUser unvalidatedUser =
    stateful 
        let user = User.from unvalidatedUser
        do! persistUser user            // NOTE: doesn't actually persist anything yet
        do! handleWorkflowResult user   // NOTE: doesn't actually log anything yet
    

然后您可以从系统边缘运行有状态计算:

// Impure method with external dependencies.
member this.RegisterNewUser(unvalidatedUser: UnvalidatedUser) =
    registerNewUser unvalidatedUser
        |> Stateful.run compositionRoot   // NOTE: this is where actual persistence/logging occurs

Scott Wlaschin 将此方法称为“dependency interpretation”。

重要警告:许多 F# 开发人员会认为这对于一个简单的系统来说太过分了。我在这里只是建议它展示如何以(大部分)纯函数方式处理不纯的依赖关系,我认为这就是您所要求的。

【讨论】:

我是否正确理解纯“registerNewUser”函​​数“persistUser”和“handleWorkflowResult”也被认为是纯的,因为不纯的操作[数据库调用和日志记录]被推迟到实际执行的边缘系统?此外,在提供的示例中,传递给“run”方法的“compositionRoot”仍然是手动创建的? 两个问题都是。

以上是关于F# - 依赖管理的主要内容,如果未能解决你的问题,请参考以下文章

JavaScript 依赖管理

创建统一的依赖管理

插件依赖的Maven依赖管理

golang依赖管理

前端包管理器的依赖管理原理

清理 Maven 依赖管理