F# - 依赖管理
F# - Dependencies management
我想从功能范式的角度理解依赖管理的概念。我尝试应用 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
和 logger 是通过依赖注入注入的。这样做有两个原因:
- 我真的不知道如何以 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 中注册所有服务的方式构建它,还是应该创建特定于给定工作流/案例的单独组合根?这些方法中的任何一种都需要我从某个地方获取必要的依赖项 [如存储库] 以创建组合根。
这里的问题不止一个,我尽量按顺序回答。
首先,您需要权衡不同架构决策的优缺点。为什么在第一种情况下将依赖项注入控制器?
如果您想让控制器接受某些类型的自动化测试,这可能是个好主意。我通常do this with state-based integration testing。然而,还有另一种观点坚持认为您不应该对控制器进行单元测试。这个想法是控制器应该如此耗尽逻辑单元测试不值得麻烦。在这种情况下,您不需要那个级别的依赖注入 (DI) - 至少,不需要用于测试目的。相反,您可以将实际的数据库代码留在控制器中,而不必求助于任何 DI。那么,任何测试都必须涉及一个真实的数据库(尽管也可以自动化)。
这将是一个有效的架构选择,但为了争论起见,我们假设您至少想要将依赖项注入控制器,以便您可以进行一些自动化测试。
在 C# 中,我会为此使用接口,. There's no reason to confuse co-workers with 。不过,传递函数也可能是可行的。
I searched over the internet for some examples of dependencies management in larger project
是的,这是一个已知问题(在 OOD 中也是如此)。缺乏复杂的例子,原因很明显:现实世界的例子是专有的,通常不是开源的,很少有人会花几个月的空闲时间来开发足够复杂的示例代码库。
为了弥补这一不足,我开发了这样一个代码库来配合 my book Code That Fits in Your Head. That code base is in C# rather than F#, but it does follow the Impureim Sandwich architecture(又称 功能核心,命令式 shell)。我希望您能够从该示例代码库中学习。它兼顾了一小部分不纯的依赖关系,但将它们全部限制在控制器中。
What about functions that call other functions that require some dependencies?
在FP中,你应该努力写纯函数。虽然您可以从其他函数组合函数 (higher order functions), they should all still be pure. Thus, it's not really idiomatic to compose functions of other functions that have impure dependencies, because that makes the entire composition impure.
composition root itself: Where should it be located?
如果您完全需要一个(关于可测试性的第一点),它将等同于 C#。将其关闭应用程序的入口点。
无论语言如何,我更喜欢pure DI。
如果您正在尝试编写纯函数代码,那么依赖注入在这里没有多大帮助,因为注入的函数(persistUser
、fetchUserByKey
和 handleWorkflowResult
)本身是不纯的。因此,调用这些函数的任何东西(例如 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# 开发人员会认为这对于一个简单的系统来说是矫枉过正。我只是在这里建议它来展示如何以(大部分)纯功能方式处理不纯的依赖关系,我认为这就是你所要求的。
我想从功能范式的角度理解依赖管理的概念。我尝试应用 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
和 logger 是通过依赖注入注入的。这样做有两个原因:
- 我真的不知道如何以 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 中注册所有服务的方式构建它,还是应该创建特定于给定工作流/案例的单独组合根?这些方法中的任何一种都需要我从某个地方获取必要的依赖项 [如存储库] 以创建组合根。
这里的问题不止一个,我尽量按顺序回答。
首先,您需要权衡不同架构决策的优缺点。为什么在第一种情况下将依赖项注入控制器?
如果您想让控制器接受某些类型的自动化测试,这可能是个好主意。我通常do this with state-based integration testing。然而,还有另一种观点坚持认为您不应该对控制器进行单元测试。这个想法是控制器应该如此耗尽逻辑单元测试不值得麻烦。在这种情况下,您不需要那个级别的依赖注入 (DI) - 至少,不需要用于测试目的。相反,您可以将实际的数据库代码留在控制器中,而不必求助于任何 DI。那么,任何测试都必须涉及一个真实的数据库(尽管也可以自动化)。
这将是一个有效的架构选择,但为了争论起见,我们假设您至少想要将依赖项注入控制器,以便您可以进行一些自动化测试。
在 C# 中,我会为此使用接口,
I searched over the internet for some examples of dependencies management in larger project
是的,这是一个已知问题(在 OOD 中也是如此)。缺乏复杂的例子,原因很明显:现实世界的例子是专有的,通常不是开源的,很少有人会花几个月的空闲时间来开发足够复杂的示例代码库。
为了弥补这一不足,我开发了这样一个代码库来配合 my book Code That Fits in Your Head. That code base is in C# rather than F#, but it does follow the Impureim Sandwich architecture(又称 功能核心,命令式 shell)。我希望您能够从该示例代码库中学习。它兼顾了一小部分不纯的依赖关系,但将它们全部限制在控制器中。
What about functions that call other functions that require some dependencies?
在FP中,你应该努力写纯函数。虽然您可以从其他函数组合函数 (higher order functions), they should all still be pure. Thus, it's not really idiomatic to compose functions of other functions that have impure dependencies, because that makes the entire composition impure.
composition root itself: Where should it be located?
如果您完全需要一个(关于可测试性的第一点),它将等同于 C#。将其关闭应用程序的入口点。
无论语言如何,我更喜欢pure DI。
如果您正在尝试编写纯函数代码,那么依赖注入在这里没有多大帮助,因为注入的函数(persistUser
、fetchUserByKey
和 handleWorkflowResult
)本身是不纯的。因此,调用这些函数的任何东西(例如 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# 开发人员会认为这对于一个简单的系统来说是矫枉过正。我只是在这里建议它来展示如何以(大部分)纯功能方式处理不纯的依赖关系,我认为这就是你所要求的。