Scala中的配置数据 - 我应该使用Reader Monad吗?

33

如何在Scala中创建一个功能良好的可配置对象?我已经观看了Tony Morris关于Reader单子的视频,但我仍然无法理解。

我有一个硬编码的Client对象列表:

class Client(name : String, age : Int){ /* etc */}

object Client{
  //Horrible!
  val clients  = List(Client("Bob", 20), Client("Cindy", 30))
}

我希望能在运行时确定Client.clients,并且可以根据需要从属性文件或数据库中读取。在Java世界中,我会定义一个接口,实现两种数据源,并使用依赖注入来分配类变量:

trait ConfigSource { 
  def clients : List[Client]
}

object ConfigFileSource extends ConfigSource {
  override def clients = buildClientsFromProperties(Properties("clients.properties"))  
  //...etc, read properties files 
}

object DatabaseSource extends ConfigSource { /* etc */ }

object Client {
  @Resource("configuration_source") 
  private var config : ConfigSource = _ //Inject it at runtime  

  val clients = config.clients 
} 

对我来说,这似乎是一个相当简洁的解决方案(代码不多,意图清晰),但是var确实很突兀(另一方面,对我来说它似乎真的不麻烦,因为我知道它只会被注入一次)。

在这种情况下,Reader monad看起来像什么?请像我5岁那样向我解释一下,它有什么优点?


1
val可以使用反射进行修改,因此您的依赖注入库可能会“注入一个val”。 - gerferra
2
@gerferra,如果我们有var,那么通过反射修改val的意义是什么? - om-nom-nom
@matt 对,那是另一种方法,但这仍然让我不清楚为什么/为什么要优先选择“Reader”单子。 - Larry OBrien
@om-nom-nom 我想valvar的所有正常优点都适用,至少如果我不在那个val上自己使用反射的话... - gerferra
2
你可能会发现Rúnar的NEScala演讲的前半部分更易于理解。 - mergeconflict
显示剩余2条评论
1个回答

47
让我们从你的方法和“Reader”方法之间的一个简单而表面的区别开始,这是你不再需要在任何地方保留“config”的原因。假设您定义了以下含糊聪明的类型同义词:
type Configured[A] = ConfigSource => A

现在,如果我需要一个ConfigSource用于某个函数,比如获取列表中第n个客户端的函数,我可以将该函数声明为“已配置”:
def nthClient(n: Int): Configured[Client] = {
  config => config.clients(n)
}

所以,基本上我们需要在任何时候都能够轻易地获取一个config!感觉像依赖注入,对吧?现在假设我们想要获取列表中第一个、第二个和第三个客户的年龄(如果存在):

def ages: Configured[(Int, Int, Int)] =
  for {
    a0 <- nthClient(0)
    a1 <- nthClient(1)
    a2 <- nthClient(2)
  } yield (a0.age, a1.age, a2.age)

对于这个问题,当然需要一些关于mapflatMap的适当定义。我不会在这里深入讨论,但简单地说,Scalaz(或Rúnar的精彩NEScala演讲,或者Tony的,你已经看过了)为您提供了所需的一切。
重要的是,在这里ConfigSource依赖项及其所谓的注入大多数是隐藏的。我们唯一能看到的“提示”是ages的类型为Configured[(Int, Int, Int)]而不仅仅是(Int, Int, Int)。我们不需要在任何地方明确引用config
作为旁注,我几乎总是喜欢这样思考单子:它们隐藏了它们的效果,使其不会污染您代码的流程,同时在类型签名中明确声明了效果。换句话说,您不需要重复太多:您只需在函数的返回类型中说“嘿,这个函数处理X效果”,就不要再进一步操作它了。
当然,在这个例子中,效果是从某个固定环境中读取。您可能熟悉的另一个单子效果包括错误处理:我们可以说,Option 隐藏了错误处理逻辑,同时在方法的类型中显式地表明了错误的可能性。或者,与读取相反,Writer 单子隐藏了我们要写入的东西,同时在类型系统中明确了它的存在。
现在最后,正如我们通常需要引导 DI 框架(在我们通常的控制流之外的某个位置,例如在 XML 文件中),我们还需要引导这个奇怪的单子。当然,我们将有一些逻辑入口点到我们的代码,例如:
def run: Configured[Unit] = // ...

这其实很简单:由于Configured[A]只是函数ConfigSource => A的类型同义词,我们可以将该函数应用于其“环境”:

run(ConfigFileSource)
// or
run(DatabaseSource)

Ta-da!与传统的Java风格的DI方法相比,这里没有任何“魔法”发生。唯一的魔法,可以说是封装在我们的Configured类型的定义和它作为单子的行为方式中。最重要的是,类型系统让我们诚实关于依赖注入发生在哪个“领域”:任何类型为Configured[...]的内容都处于DI世界中,而没有这种类型的则不在。我们在老式的DI中根本无法做到这一点,在那里所有可能都由魔法管理,所以你真的不知道你代码的哪些部分是安全的,可以在DI框架之外重用(例如,在你的单元测试中或在完全不同的项目中)。

更新:我撰写了一篇博客文章,更详细地解释了Reader


刚想起来,我还应该说:不用担心制作“可配置对象”。事实上,可配置对象只是具有构造函数参数的东西。这些参数从哪里来?当然是构造函数的调用者,它将依次(如果我已经说服你尝试)从读者(在本例中是Configured[...]环境)获取它们。这一切都关乎函数调用其他函数,而不是关乎对象的实质。 - mergeconflict
1
回复:“所以最终我们仍然需要重构所有的函数签名……” - 并非全部,只有那些依赖全局配置的函数需要 Configured[...] 签名,而你的纯函数则不需要。再次强调,保持这两个世界的明显区分是一件好事。 - mergeconflict
1
关于“魔法”-我可以轻松地解释Reader的工作原理:它是一个函数,需要一个参数。相比之下,Spring(例如)如何工作呢?好吧,您的系统中有一些类不是您自己实例化的;您让Spring为您实例化它们。Spring知道这一点,因为您已经配置了应用程序上下文。因此,您的应用程序上下文告诉Spring在构造对象时应调用哪些方法。但实际上,它们必须遵循bean命名约定,以便它可以derp derp derp,您明白了吗? - mergeconflict
我喜欢这个,感觉它很优雅和不错。但我仍然在努力理解为什么 :) 方法def nthClient(n: Int): Configured[Client],如果像一个函数一样看待,就是Int => (ConfigSource => Client)。将ConfigSource传递到方法中将是def nthClient(n: Int, config: ConfigSource): Client,即(Int => ConfigSource) => Client。所以我很难看出区别,但肯定感觉更好。 - Channing Walton
1
这是一个关于Reader Monad DI与构造函数参数DI的比较,以及如何在具有多个依赖项/嵌套方法调用的情况下使用RM的相关问题:https://dev59.com/214b5IYBdhLWcg3wbA6B - adamw
显示剩余5条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接