F# 实现“依赖注入”的 6 种方式

原文:https://fsharpforfunandprofit.com/posts/dependencies/
原作者:ScottW

本文为 F# “依赖注入” 系列翻译 系列的文章

译者注:此系列文章前假设读者已掌握 F# 和函数式编程的基础知识。

在这个系列的文章中,我们将讲述 6 种在 F# 实现“依赖注入”的方式。

这个系列的的文章灵感来自于 Mark Seemann 之前所写的类似文章Bartosz SypytkowskiCarsten König 也写了关于此主题的其他好文章,他们都非常值得一读!

我们今天将谈论的 6 种方式为:

  • 依赖保留:这种方式我们不需要去管理依赖,只需要硬编码所有代码就可以(写死)。
  • 依赖拒绝:上边提到的 Mark Seemann 所提出的一个概念及术语,在这种方式中我们将避免在业务逻辑代码去引入依赖。通过将 I/O 操作和其他非纯函数写在领域的 “边缘” 可以做到这一点。
  • 依赖参数化:将依赖作为函数的参数传入。这种方式在 偏函数(部分应用) 模式中经常使用到。
  • 依赖注入和 Reader 单子:在代码构造时去插入依赖。在 OO 编程中,这种方式是构造器注入(注:最经典的依赖注入方式),而在 FP 中,我们使用 Reader 单子来做到这一过程。
  • 依赖解释:在这种模式中,我们将依赖用一种数据结构(注:在 OO 中应该是接口)替换掉,而在之后将解释执行这段逻辑。这种方法适用于 OO 编程(解释器模式)和 FP 编程(例如 Free Monad)。

每种方式我们都将用一个简单的例子来进行说明,然后再讨论其对应的优缺点。另外,在最后的文章中,我们将分别用这 6 种方式来实现另外一个不同的例子。

什么是“依赖”

在开始之前,我们先来定义一下什么是所谓的 “依赖” 。如果让我说的话,如果函数 A 调用了函数 B,那么函数 B 就是函数 A 的依赖
。所以这是调用者被调用者之间产生的一种依赖关系,而不是数据层面上的依赖,也不是对各种库的依赖和其他在开发中遇到的依赖问题。

但很显然在开发中我们经典会调用其他函数,所以什么样的依赖是有问题的呢?

首先,我们在 FP 中都希望去写可预测及确定性的函数(纯函数)。但当函数调用了其他非纯函数时,这个过程就混乱了。这些非确定性调用包括各种 I/O 操作,随机数生成,获取当前时间操作等等。因此我们需要管理和控制不纯的函数依赖。

其次,即使是纯函数,我们有时也会希望通过传入不同的实现来改变函数运行时的行为,而不是硬编码一段逻辑。在 OO 中,我们会采取策略模式来这么做,而在 FP 中,我们可能会传入 “策略” 函数作为参数来这么做。

所有的其他依赖不需要特殊管理。如果类/模块/函数只有一个实现,并且是纯的,那么直接call就可以了。没有必要去额外多做奇怪的事情!

综上所述,我们有两种需要管理的依赖类型:
– 非纯函数:引入了不干净代码而且让程序充满不确定性,也不方便测试。
– “策略” 依赖(注:接口?):需要支持多种实现的函数。

面向工作流的设计

在接下来的所有代码中,我将使用一种 “面向工作流” 的设计,其中 “工作流” 代表业务逻辑、用户故事、用例等。有关此方法的更多详细信息,请参阅我的 “重塑事务脚本” 演讲(或者针对于 OO 模式的,Jimmy Bogard 的 “垂直切片架构” 演讲)。

需求

让我们来举一个简单的栗子,并用之前提到的 6 种方式来实现。
需求如下:

  • 输入两个字符串
  • 进行大小比较
  • 输出结果

需求就是这么简单,但是来让我们试一下能把它写的多复杂!

方式一: 依赖保留

然我们先用最简单的方式来写一下:

let compareTwoStrings() =
  printfn "Enter the first value"
  let str1 = Console.ReadLine()
  printfn "Enter the second value"
  let str2 = Console.ReadLine()

  if str1 > str2 then
    printfn "The first value is bigger"
  else if str1 < str2 then
    printfn "The first value is smaller"
  else
    printfn "The values are equal"

正如我们所看到的,非常直接非常简单,没有任何多余代码。

这样的好处也很显而易见:代码很好理解,一眼就能看出是在做什么;对于小项目来说只需要很少的维护工作,以上。

而缺点也很明显:非纯,基本没办法去测试,签名是unit -> unit。这个函数没有任何有效输入和输出,只能靠人工去一遍又一遍的手动测试。

因此,我推荐以下情况使用这种方式:
– 不值得测试或抽象化的简单逻辑。
– 为了了解需求而快速写出一个一次性使用草图或原型的时候。
– “业务逻辑” 很少,基本上是各种输入输出混合在一起的程序。比如 ETL 管道。或者是涉及到许多数据科学并将很多脚本组合在一起然后手动查看结果的东西。在这些情况下,应重点关注的是数据,没有必要去做太多测试和抽象逻辑。

依赖拒绝

一种可以让代码变得可预知,可测试的简单方法是从代码中移除非纯依赖,只留下纯函数。我们将称之为“依赖拒绝”。

举个栗子:在我们之前的实现里边,非纯 I/O 操作(printfnReadLine)和纯函数(if str1 > str2)耦合在了一起。

Dependencies1a

如果我们想让代码纯净,那么该怎么做呢?
– 首先,所有输入都应该以参数形式传入函数。
– 其次,所有决策都应该以纯数据的方式返回,而不是进行 I/O 操作
更改之后的代码如下:

module PureCore =

  type ComparisonResult =
    | Bigger
    | Smaller
    | Equal

  let compareTwoStrings str1 str2 =
    if str1 > str2 then
      Bigger
    else if str1 < str2 then
      Smaller
    else
      Equal

在新的实现里边,I/O 操作已经不见了。
这段代码是完全具有可预测性的,因此很方便来进行测试,比如以下unit test代码(用到了Expecto库)。

testCase "smaller" <| fun () ->
  let expected = PureCore.Smaller
  let actual = PureCore.compareTwoStrings "a" "b"
  Expect.equal actual expected "a < b"

testCase "equal" <| fun () ->
  let expected = PureCore.Equal
  let actual = PureCore.compareTwoStrings "a" "a"
  Expect.equal actual expected "a = a"

testCase "bigger" <| fun () ->
  let expected = PureCore.Bigger
  let actual = PureCore.compareTwoStrings "b" "a"
  Expect.equal actual expected "b > a"

但是我们要怎么去使用这段代码呢?嗯,我们需要调用者来提供输入和输出函数的具体实现。通常情况下,I/O 操作应该由上层调用栈实现,“上层” 就是我们很熟悉的 “api 层”、“ui 层”、“组合根”这种。

在这里调用代码如下:

module Program =
  open PureCore

  let program() =
    // ----------- impure section -----------
    printfn "Enter the first value"
    let str1 = Console.ReadLine()
    printfn "Enter the second value"
    let str2 = Console.ReadLine()

    // ----------- pure section -----------
    let result = PureCore.compareTwoStrings str1 str2

    // ----------- impure section -----------
    match result with
    | Bigger ->
      printfn "The first value is bigger"
    | Smaller ->
      printfn "The first value is smaller"
    | Equal ->
      printfn "The values are equal"

通过使用 “依赖拒绝” 模式,我们现在得到了一个 非纯/纯/非纯函数 的结构(就像三明治一样!):

Dependencies1b

一般来说,我们想让我们的函数管道看起来像这样:
– 一些 I/O 操作或者非纯函数,比如从控制台读取输入,文件/数据库操作等等
– 纯净的业务逻辑
– 另一些 I/O 操作或非纯函数,比如保存数据到文件/数据库等等

Dependencies2a

这样做的好处是 I/O 代码段只限定于这个特定的工作流程。例如,
与其有一个为每个可能的用例都包含数百个方法的仓储接口,我们只需要实现这一个流程的需求即可,从而使代码更清晰。

多层 “三明治” 结构

如果在流程之间需要插入更多的 I/O 操作该怎么办?这种情况下你只要建立一个更多层级的流程即可,如下:

Dependencies2c

最重要的事情是让 I/O 操作从业务逻辑中分离开来,原因正如以上讨论。

测试

这种方式的一种好处是使测试边界变得非常清晰。你的单元测试只需要测试纯函数,也方便集成进整个测试管道。

总结

在这篇文章中,我们讨论了两种方式:“依赖保留”,依赖全都硬编码在程序中;“依赖拒绝”,消除了 I/O 操作,领域里只留下了纯函数。
鉴于明显的好处,应尽可能使用 “依赖拒绝” 方法。缺点是需要额外的间接调用代码:
– 你或许需要定义额外的数据结构来封装返回的结果。
– 你需要一个上层调用层来运行你的非纯函数,并需要处理运行结果以执行 I/O 操作。

这篇文章的源码可以在 Gist 上找到
DependencyRejection.fsx
DependencyRetention.fsx

在下一篇文章中,我们将继续讨论 “依赖参数化”,即是说,将依赖以函数参数的方式传入我们的函数。

本站原创内容均采用 CC BY-SA 4.0 协议,转载请注明原作者 神楽千雪 及本文链接。
F# 实现“依赖注入”的 6 种方式 https://chiyuki.cc/posts/fs-dependencies/
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇