函数式与响应式的领域模型<Functional and Reactive Domain Modeling>(二)

来源:互联网 发布:python字符串长度函数 编辑:程序博客网 时间:2024/05/29 10:27

2. Scala构建函数式领域模型

本章包含:>理解为什么scala设计领域建模的最好语言之一>使用静态类型语言理解域建模的好处(scala是静态语言,并不是动态语言)>将Scala的OO和FP功能结合起来,实现模块化和纯模型现在呢,你已经了解了函数式和响应式领域建模的概念了,你将如何实现这样的模型呢?当你考虑如何实现的时候,你必须先考虑一下你所使用的开发语言。很多语言都对实现富有变现力的领域模型提供了足够的力量支持。本书使用scala语言,它是一个对象--函数静态类型的语言,在java的虚拟机JVM上运行,并提供优秀的Java互操作性。但你可能会想,为什么是Scala?Scala提供什么特别的特性使它成为域模型实现的一个吸引人的提议?在这一章中,您将了解到其中的一些特性,并了解它们如何帮助构建模型的特定方面。Scala有面向对象的(OO)和函数功能,这在实现和组织领域模型方面是一个强大的组合。您将使用Scala的函数功能来实现不可变的数据和领域行为,并且您将使用它的OO功能来模块化您的领域模型。为了更直观地说明您将如何在本章的章节中取得进展,以及如何增加您对构建功能和反应域模型的知识,图2.1展示了一个草图。图2.1

这里写图片描述

2.1 why Scala?

在研究使Scala成为实现功能和反应域模型的合适语言之前,让我们简要回顾一下第1章中关于您的模型应该具备的一些理想特性的内容。描述我们的建模范式的两个主要特征是函数和响应式。当我说一个模型是函数性的时候,我的意思是,模型行为是作为函数来实现的,其副作用清晰地从纯粹的业务逻辑中描述出来。正如您所见,这有助于以一种零碎的方式通过函数组合来帮助模型的演化,并使您能够通过使用方程式推理来推理您的模型。当我谈到一个模型的响应式时,我指的是各种形式的响应能力----响应失败,响应不同的负载----模型的总体架构确保用户永远不会成为无限界延迟的受害者。要构建一个经过良好设计的系统,同时对用户作出响应,您需要在前面做出正确的架构决策。它可能不是最重要的体系结构决策,但是选择具有正确特性集的实现语言是影响系统整体稳定性的主要因素之一。在决定要使用的开发语言之前,您应该考虑许多因素。您可以选择动态类型的语言,提供快速的应用程序开发平台,并拥有一个活跃的社区。你可以把你的第一个版本的模型快速地推出。但是你的模型可能不太好---当您继续添加越来越多的特性时,您可能会发现自己希望您的开发语言能够为模块化和封装提供更好的支持。另一个需要考虑的问题是性能。静态类型语言比动态类型语言提供了更好的性能,主要是因为有了添加的类型信息,编译器可以进行某些优化。但是您仍然可以选择动态类型的语言,认为可以通过向部署栈添加更多的服务器来解决性能问题。当然环境元素也是一点-----您的团队可能精通某些语言,这可以决定您的实现语言的选择。这本书使用Scala作为实现语言。Scala的一些宏观层面的特性使其在服务器端开发中非常受欢迎。在本章中,您将看到Scala的特性如何映射到开发功能函数式和响应式领域模型的需求。注意,这里我并没有做一个详尽的Scala教程----我的目标是揭示Scala如何帮助你通过它的一些内置特性来开发一个更好的模型。这个部分提供了这个映射的鸟瞰图。后面的小节将详细说明示例。如果您对Scala有经验,您可能想看看当前的部分,跳过本章的其余部分。、表2.1
Scala特性 模型的概念 带有内置支持不可变性的代数数据类型(样本类) 帮助建模域对象—实体和制度相;举例子,Bank,Account作为领域实体 纯函数 帮助建模领域行为;例如,在个人银行系统中实现debit、credit等的逻辑。 函数组合和高阶函数 组成更小的行为来实现更大的行为;例如,您可以编写debit和credit来实现在两个帐户之间转移资金的逻辑。 带有类型推断的高级静态类型系统 通过封装类型本身中的一些约束和业务逻辑,可以帮助您的模型更加健壮。类型推断有助于使代码简洁,因为编译器可以从表达式中推断类型。 特质与组合对象 Scala的特质有助于模块化。您可以将您的模型组织为由多个特质组成的对象,这些特质实现了各种功能。这些特征也可以用作类型参数化,这些类型允许您插入与特定业务规则相对应的行为 对泛型的支持 帮助您构建对泛型类型的抽象,这些抽象类型可以稍后用于特定的类型。例如,您可以定义一个领域服务,PortfolioService[C] ,C指定的一个一般的客户类型,这将为服务的通用工作流建模。然后,您可以通过重新定义模型中的每种类型的客户来专门化可变部分。 支持并发模型,比如在Java并发模型之上构建的计算模型 Scala支持像actor和future那样的抽象,这些抽象可以帮助您建模无阻塞元素,而不需要编写使用线程和锁的低级代码。

在下面的部分中,您将详细了解表2.1中的特性,并举例说明我们的个人银行系统。

2.2 静态类型和富领域模型

让我们考虑一下我们从第一章开始的个人银行领域的例子。假设在银行,客户账户只有在储蓄账户时才有利息;支票账户不提供任何利息。当你实现一个函数,calculateInterest ,接收一个客户帐户并计算出所给定的时间段之内的利息,你将如何建模呢?下面的清单展示了最初的尝试版。

这里写图片描述

根据需求,如果输入帐户不是储蓄帐户,却要计算利息,则会出现错误。当您为这个函数编写单元测试时,您将会有测试用例来检查是否只对其适用的特定类型的帐户进行利息计算。如果您传递的Account,它的类型不是储蓄类型,那么这个函数应该会引起一个错误,您的测试也应该反映这个业务验证。让我们考虑一下实现的细微变化。我们不再传递一个具体的Account类型(在上面的清单2.1中,我们也看到了,账户Account是一个样本类,因此它是一个具体的类型),而是让函数多态。通过多态,我的意思是,您不再像使用清单2.1中calculateInterest函数那样传递一个Account的具体的数据类型。相反,您可以用它所需要的帐户类型来参数化该函数。这使得该函数对其能够处理的帐户类型(就是满足约束条件的账户类型)具有多态性。当然您可以使它成为一个无约束的泛型类型A,但是这就表明您可以将任何类型的帐户传递给该函数。在清单2.2中,我们将类型参数A约束为InterestBearingAccount 的子类型。根据定义,你就不可以将不满足账户类型约束的账户传递给这个函数。通过使用类型系统的强大功能,您已经对一些领域逻辑进行了编码。

这里写图片描述

Account现在是一个多态数据类型,函数calculateInterest被认为是在Account类型上是多态的。你正在使用是Scala的类型系统来编码你的领域逻辑,并在函数编码了逻辑,即利息计算只对指定的账户类型可用。让我们来看看这是如何运作的,以及这个策略从长远来看会给你带来什么:>领域逻辑现在更加明确了。一种对account类型进行编码了的独立的数据类型(作者这里想说的应该是InterestBearingAccount,它步进是Account数据类型,而且其名字也很富有变现力),使领域模型更具表达性> calculateInterest函数以一个有效的帐户类型作为签名的一部分---你已经限制了泛型类型A是InterestBearingAccount的子类型。该签名将会是文档的一部分。因此,您的API的用户不需要查看函数的实现来检查函数所允许的有效帐户类型。>通过传递给函数只可以使用InterestBearingAccount的子类型的信息,您可以确保编译器现在有更多的信息任其使用。它可以基于这样的信息进行优化。>您不再需要编写验证传递给函数的适当帐户类型的测试。编译器为您完成这项工作。你将不再需要去以不同于InterestBearingAccount类型的账户作为参数,去执行函数进行测试了。这只是一个例子,说明如何通过添加类型的力量使领域模型更加丰富和简洁。通过将更多的测试委托给编译器,并去掉领域逻辑验证中的样本文件(即清单2.1中的if条件判断),就可以获得可表达性,从而减少额外的臃肿。在这本书中,您将使用越来越多类型的领域模型,并探索此方法为实现带来的所有好处PS:关于类型系统( type systems)的说明第2.2节仅仅介绍了一个强大的类型系统如何在领域模型上施行不变量和约束,以便从编译器得到更多的帮助。在清单2.1中 calculateInterest的例子中,您传递了一个具体的数据类型,并在同一函数中处理了各种类型的帐户的所有逻辑。当您在清单2.2中使用类型系统的强大功能时,您就会限制Account的数据类型,以便函数体只包含用于InterestBearingAccount的计算利息的逻辑。编译器现在有了附加的信息,即这个函数只用于有利息的帐户;它可以丢弃任何其他类型帐户的调用。这意味着现在编译器搜索空间的结构更好了。

2.3 纯函数的领域行为

在第1章中,您看到了使用纯函数建模领域行为的优点。函数组合,您可以通过使用函数组合构建更大的抽象。这可以帮助您通过较小的和可重用的组件来有机地演进您的领域模型。在Scala中,您可以编写纯函数,并且在第1章中看到了它们的例子。让我们继续探索函数,把calculateInterest作为一个开始的例子,但是,让我们先准备一些基本的抽象概念,这些抽象概念将在我们的领域模型中使用,包括 calculateInterest函数的原型。注意现在函数calculateInterest将会返回一个Try[BigDecimal],来对付利息计算中可能失败的情况。如果你想复习下Scala的Try是如何做的,那么就看下1.3.2章节之前的Exceptions in Scala。

这里写图片描述
这里写图片描述

接下来,您将看到如何使用具有calculateInterest的函数组合来在我们的系统中构建更大的功能(请参见清单2.4)。这些片段大多是独立的----其主要目的是向您展示各种功能组合,使您能够以一种完全纯粹、完全透明的方式构建领域行为。

这里写图片描述

同样,与实际领域模型中的示例相比,这些示例很简单。但是这种简单性有助于澄清我们的解释。让我们来看看这些例子所演示的函数组合的一些味道。理想情况下,您可以在REPL中尝试这些示例,并了解这些组合是如何工作的。每个返回特定的值,您可以再次组合以构建更大的抽象。在清单2.3的第一个例子中,您可以使用map组合器来操作一个定义在其中的帐户列表。通过使用map,您必须指定要对列表的每个元素做什么,而不是如何遍历列表。您知道这与命令程序的for循环有什么不同吗?在for循环中,您必须明确地指定如何遍历列表,以及如何处理迭代中的每个元素。使用像map这样的函数组合器,您只需要指定对每个元素做什么(作者称呼这部分为what部分),而对如何遍历以及遍历的方向(作者称呼这部分为how部分)则被封装进组合器里了。这减少了用户的认知负荷,使API更具表达力。接下来的栗子则展示了fold组合器。在这里我们使用的是foldLeft。它帮助您迭代一个帐户列表,并计算它们所累积的利息的总和。再次强调,它是一个纯粹的表达式,通过在迭代的每一步中累积总和来进行评估。然后接下来的栗子演示了另一个组合器的使用:filter.该表达式得到计算的利息列表,不包括计算失败的任何利息。如果您是函数式编程的初学者,我建议您在此停留一段时间,并通过使用Java或c之类的命式语言来实现相同的功能。你会注意到,在这些语言中,你所实现的计算利息总和的代码,就像是语句的有序列表(作者表达的是sequence,翻译来就是序列,怕各位看官忽略了有序这个关键词,就以有序列表来阐述吧)一样,它们会一个接一个地执行。您可能会使用for循环遍历所有帐户并分别在每个帐户上调用函数,并且在这个过程中,在一个可变的状态下(其实就是一个变量,我们称呼类中的属性都是该类的状态。还是老外会说~~)累计所有的利息。这里你会注意到的第一个区别是当你使用纯函数时,逻辑被表示为一个单一的表达式(而不是语句的列表罗列)。因此,您可以在不使用中间状态的情况下将值从一个函数传递到另一个函数。这就是编译器可以应用多个优化技术的原因,比如将在下一节讲的fusion。表达式有一个值,它直接提供给另一个表达式。请注意,在清单2.4中,map组合器从List(s1、s2、s3)中获得了输入,而没有任何中间的计数器,但是如果您使用了for循环,您将需要这些计数器。清单2.4中的最后一个例子是一种更强大的面向表达的编程的演示。您可以以一种零碎的方式来构建表达式,从而到达最终的解决方案,而不需要任何中间状态。果您仍然不相信,请查看图2.2,它展示了从命令式到函数式的转换。另一方面,语句大多数是有副作用,而在命令式语言中,您所做的是按顺序生成副作用,以获得相同的结果。这个过程很容易出错,因为涉及到可变状态----因此,函数式编程通常也被称为面向表达的编程。图2.2总结了语句和表达式之间的区别。清单2.4的最后一个例子也说明了对Scala的for语句的理解。Scala的for语句使用flatMap和map组合器对管道中的操作进行序列。我说的是序列吗??这应该是一种命令式的语言风格,就像你刚才看到的那样。确实如此,这是Scala的语法糖,Scala的for执行起来就像是你使用map或者flatMap来操作数据一样。因此你得到了双重好处:对用户来说,不仅排序操作更直观,而且面向表达式的执行则为您提供了函数式编程的所有好处。清单2.3展示了如何将for循环转换为map或者flatmap。

这里写图片描述

至于for语法糖的细节,建议去看Scala编程思想。里面有更好的讲解,包括for中的if等条件过滤。

这里写图片描述

您可能想知道,如果“for”中的任何步骤失败或抛出异常,会发生什么。这些都是由map和flatMap组合器的实现来处理的。如果有任何步骤失败,整个序列就会自动被破坏。因此,错误处理是这些抽象的组合器的另外一个需要关注的点。、

2.3.1 Purity of abstractions, revisited 纯的抽象,再谈

您可能已经注意到,在建模领域行为的环境中,我一直在使用纯函数这个术语。我在第1章也做了这个,解释了纯粹是如何帮助你解释你的功能的。回顾一下,如果没有任何副作用,函数是纯的。什么是副作用呢?副作用是在你实现的功能的控制范围之内的。如果您正在操纵文件系统,或者在您的函数中使用数据库或任何其他外部资源,那么您将产生副作用。如果你这样做了,就是与纯函数的定义相违背,纯函数式要求该函数在每次调用时都应该为相同的输入生成相同的输出。顺便说下,纯代码也有一个正式名称:引用透明。它的意思是一样的:传给表达式相同的输入值,获取的输出是一致的。本节介绍了可以用纯函数和抽象来在模型上执行的优化。让我们从个人银行领域的一个例子开始。考虑两个计算账户余额的函数,并根据一些逻辑从计算的利息中扣除税金。同样,特定的逻辑并不重要,您将会只考虑一些不真实和简单的假设(其实就是为了减小代码量,而不是说逻辑不重要)。下面的清单实现了这些函数以及基本的抽象。

这里写图片描述
这里写图片描述

现在您已经熟悉了清单2.5中的代码。第二个map中使用的技术称为fusion:通过使用函数组合,可以将两个map组合器融合。这两个计算扣除税金后的净利息的表达式①和②之间有什么区别吗?现在呢,让我们将关注点放在函数 calculateInterest和 deductTax的定义上。让我们先来看表达式①,它map操作了两次,分别操作了 calculateInterest和deductTax,使得该表达式表现的就像图2.4中所示。第一个map生成一个利息的列表,并将其作为输入发送给第二个map,而这个map所做的是在扣除税款后产生净利息的清单。这里,除了输入集合和最终输出,还有一个中间集合,也就是利息列表。

这里写图片描述

在第二个表达式②中,您执行了一个优化,注意这两个函数的类型:SavingAccount => BigDecimal和BigDecimal => BigDecimal。他们排列整齐,适合组合。因此,与其将两个函数分别应用到列表中,为什么不应用该组合本身呢?毕竟,这也是您在第一个表达式①中所做的:将第一个应用程序的结果输入第二个应用程序,并在流程中生成一个中间集合。图2.5显示了组合方法。

这里写图片描述

组合还为你提供了性能优化。当你在代码中发现了list.amp(f).map(g)这样的模式,你可以以list.map(g compose  f)做替换以实现优化。这个语句假设您可以为f和g使用这个优化。但对于Scala,这是一个有效的命题吗?考虑一下当你选择一个可能产生副作用的f函数(比如:它创建一个文件或更新数据库),而函数g则使用这个副作用的时候会发生什么??那么这个优化是失败的。函数f和g不再只依赖于他们所获取到的输入(就像我们的calculateInterest 和deductTax一样),而是还依赖于其他的外部资源。它们不再是引用透明的。他们失去了纯洁和组合的能力。到目前为止,这个讨论的结论是,你应该努力使函数尽可能地远离副作用。与其将纯逻辑和副作用集中在一个函数中,不如将它们解耦,这样就可以至少从纯逻辑部分中获得组合性的好处。

2.3.2 引用透明的其他的好处

在上一节中,你也看到了如何去租借纯函数,并使用fusion技术执行优化。是你的函数引用透明还有一些其他的好处,这也是函数编程通常所能带来的好处----并不仅仅是对于Scala。

TESTABILITY(可测试性)

纯函数更易于测试,因为您不依赖于任何副作用或外部状态。如果您的函数仅依赖于您指定的输入,那么该规范可以表示为属性。您可以将这些属性作为表达式提供给基于属性的测试库,如ScalaCheck,然后他们可以生成随机数据并为你做测试。这是一个巨大的好处,并且比传统的基于xUnit的测试更有效。在本书的后面,您将了解到基于属性的测试的更多好处。

PARALLEL EXECUTION(并行执行)

如果您的代码是无副作用的,那么您可以更有效地使用并行数据结构,而不用担心外部状态会妨碍您。在Scala中,如果你代码片段有对一个集合进行遍历映射操作,比如说Collection.map(),那么可以将其转换为并行执行,你只需要将代码改成collection.par.map结构即可。但是只有当您传递给map的函数是纯的时,它才会有预期的效果,而不会有任何令人不快的意外。

2.4 代数数据类型和不可变性

在本书中,当我们讨论领域模型时,我将讨论建模实体、值对象和其他类型的抽象,并且代数数据类型(ADT)是您将遇到的一个概念。您需要清楚地了解您可以在Scala中建模的各种类型的ADTs。我们都知道什么是数据类型,但代数数据类型是什么意义呢?这一节不是从理论开始的。相反,它从示例开始,向您展示我所说的ADTs是什么,以及当您使用它们来建模您的领域时它们是如何有作用的。

2.4.1 基础知识: sum类型和product类型

在我们的个人银行领域中,我们需要处理各种各样的货币---USD,AUD还有RMB。有各种各样的分类,但基本类型仍然是一种货币。下面的清单展示了如何在Scala中对其进行建模。

这里写图片描述

这里有一个基本的抽象,它概括了货币模型。您也有专门的子类型来指示系统中货币的类型。在这个模型中,货币的一个实例可以是以下的: USD,AUD,EUR 或者INR。它只能取其中一个值;你不可能有一个USD和一个INR。因此这应该是一个OR,在逻辑上你以加号来代表OR:type Currency = USD + AUD + EUR + INR.你有了一个新的数据类型,Currency。你能算出一种类型的货币有多少种不同的值吗?就类型理论而言,我们称之为数据类型Currency的居民数量.答案是四个。通过对Currency数据类型可以拥有的不同值的数量进行求和,可以找到这些值。没错,Currency就是一个sum type(和类型)。让我们再举一个例子,这次是Scala标准库。下面的清单展示了Scala如何建模Either数据类型。

这里写图片描述

清单2.7展示了Either数据类型的基本模型,它带有两个参数,并且有Left和Right这两个特定的类型。当您构造一个Either实例时,您可以使用Left的构造函数注入类型A的值,或者您可以通过使用Right的构造函数来注入B类型的值。因此,当你定义一个Either的实例时,它必须是Left或Right,而不是两者。这是sum type(和类型)的另一个例子。问2.1:对于清单2.7中的Either[A,B]数据类型,它有多少居民呢?提示:这不是两个。现在让我们来回顾一下我们之前讨论过的老朋友---Account抽象。下面的清单显示了Scala中的Account类的一个稍微改变的版本。

这里写图片描述

一个Account可以是一个CheckingAccount类型,也可以是一个SavingsAccount类型 。这也是一个sum type(和类型)的例子。但是现在让我们关注于一个Account的特定实例中都包含什么?CheckingAccount里有一个number,一个name和一个dateOfOpening。您将这些属性组合在一起,并创建了一个新的数据类型,以便为这些数据类型的集合分配新的语义。在类型的语言中,你以(String,String,Date) => CheckingAccount来代表它,或者更一般地说,type CheckingAccount = String x String x Date(注意,这里的x标识的是乘的意思)。简而言之,一个 CheckingAccount数据类型是元组(String, String, Date)的所有有效组合的集合。这就是这三种数据类型的笛卡尔积。这就是所谓的product type(积类型)。在本例中,您将Account作为sum type(和类型),并且每种类型的Account都是一个product type(积类型)。图2.6描述了sum type(和类型)和product type(积类型)及其居民。

这里写图片描述

答2.1:通过对每个类型的居民数量进行汇总,您可以找到总的居民的数量。在货币的例子中,所有的个体类型都有一个单一的居民,因为每一个都是Scala中的样本类对象,而样本类是单例模式的。在Either[+A,+B]样例中,其居民的数量是A的居民数量加上B的居民数量。我们举个栗子,如果你又一个Either[Boolean,Unit],那么居民的数量将会是2(这是Boolean) + 1(这是Unit的),也就是其居民数量是3。我想那是有点难的!

2.4.2 模型内的ADTs结构数据

Sum type(和类型)和product type(产品类型)为构造领域模型的各种数据提供了必要的抽象。而Sum type(和类型)允许您在特定数据类型中建模变量,product type(产品类型)帮助将相关数据聚集到一个更大的抽象中。还记得我们讨论过的函数式编程如何帮助我们从小的抽象来创建更大的抽象吗?这是一个恰当应用ADTs使数据类型可组合的例子。当你说case class CheckingAccount(number: String, name: String,dateOfOpening: Date)时候,你正在将一个 (String, String, Date)组合在一起,并将其标记为一个新的数据类型,CheckingAccount。思考一下:您可以避免额外的标记,并使用一个由三个元素组成的元组来替代。但是有了标记的数据类型,您可以将一个命名的标识符关联到您正在建模的领域。使用ADT的另一个优点是,编译器会自动验证包含数据类型有效组合的各种标记。尝试带有附加项 rateOfInterest: BigDecimal来实例化一个CheckingAccount,编译器将会立即捕获你!你所做的每一个实例化都必须是你的数据类型所列举的枚举列表的有效编码----不仅标签必须是有效的(在我们的样本类CheckingAccount和SavingsAccount之间),而且组件数据类型也必须匹配一对一。本节的主要内容是:ADT强制您严格按照您为之定义的规则构建抽象。ADTs定义模型中数据的结构。在下一章中,我们将会学习怎么去关联领域特定行为与ADT的相应标记。

2.4.3 ADTs和模式匹配

既然您已经了解了代数数据类型如何帮助组织模型中的数据的结构,那么让我们看看另一个有关ADTs的相关概念,并帮助构造函数性的模型。Pattern matching helps keep functionality local to the respective variant of the ADT。这不仅增加了模型的可读性,而且使您的代码更加健壮,正如您所看到的。是时候转向我们领域的一个例子了;看一下下面的清单。

这里写图片描述
这里写图片描述

清单2.9定义了几个sum type(和类型)和product type(乘类型)来建模领域对象。我将使用这些ADTs来解释如何将领域行为与ADT的每个枚举关联起来。在清单2.9中,使用模式匹配的主函数是getHolding,它根据它所持有的Balance类型计算帐户的净持有值。Instrument被定义为一个sum-of-product(加-乘类型),而模式匹配使您能够使用您所使用的sum type(和类型)来定位精确的逻辑。在每个sum type(加类型)中,都有一个product type(乘类型),它可以再次被模式匹配匹配。这使您能够选择用于计算的必要属性值。通过使用OO和子类型来编码这种类型的实现,将引导您进入访问者模式,正如我们所知道的那样,这种模式充满了严重的危险(作者注:Java中的访问者模式导致了一个难以扩展的复杂代码库。这也许就是为什么会有这么多模式的变化的原因。更详细的介绍,请参考Martin E.Nordberg的《Variations on the Visitor Pattern》,链接地址是http://c2.com/cgi/wiki?VariationsOnTheVisitorPattern。然而袋鼠数据类型优雅的解决了这个问题)。Scala中模式匹配的另一个好处是,编译器检查模式匹配的穷尽性。如果您忘记在模式匹配子句中插入任何Balance的枚举,编译器将发出警告。如果您在稍后的时间中向sum type(和类型)添加了一个额外的枚举,编译器将指向所有模式匹配子句需要使用ADT的附加变体进行更新的位置。

2.4.4 ADTs鼓励不可变形

Scala中的代数数据类型的一个基本特征是它对不可变性的鼓励。当你写下 case class Person (name: String, address: String,dateOfBirth: Date)时候,Scala默认情况下会创建一个不可变的抽象。如果您需要使属性具有可变性,那么您必须有一个带有该属性的显式var,以表明您想要可变性。但你当然不想那样做。函数式编程鼓励不变性和避免原处突变,使抽象成为引用透明的。问题是,在Scala中,一旦定义了ADT,如何修改它的任何属性的值?答案是,你不需要这样做。相反,您可以从具有修改值的原始版本创建另一个抽象(请参阅下面的清单)。

这里写图片描述

在清单2.10中,a1是一个你在今天开户的SavingsAccount类型的账户。稍后您决定将利率从之前的0.2的值更改为0.15。在Scala中,在不放弃不可变性的情况下的方法是使用复制方法并创建一个SavingsAccount的另一个实例,它带有更改后的rateOfInterest值。问2.2 :假设您有以下ADT定义:
case class Address(no: Int, street: String, city: String, zip: String)case class Identity(name: String, address: Address)
而标识符Identity的创建操作是如下这样的:
val i = Identity("john", Address(12, "Monroe street", "Denver", "80231"))
你将如何将邮政编码更改为80234,而不会发生内部突变?提示一下:使用一个嵌套的复制。不可变性有很多优点,其中很多都在第一章中讨论过。在并行和并发设置中,它使生命变得如此简单,您可以自由地共享值,而不必担心来自可变状态的任何恐惧(作者注:更多的详情参考Brian Goetz的《Java Concurrency in Practice 》)。不可变数据是我们在函数式编程中实践的最重要的原则之一,它与纯函数的概念很好地联系在一起。在您的领域模型中,总是努力实现尽可能多的这种思想;保持领域行为的纯性,并使用不可变的数据。在我们讨论实现函数式领域模型的讨论中,您将探索高阶抽象,例如在不可变的ADTs之上构建的透镜,以及用于操作它们且易于使用的组合器。答2.2 :这有点棘手,因为你必须在两个层面上使用copy。下面是解决方案:
i.copy(address = i.address.copy(zip = "80234"))
您已经了解了Scala如何支持函数编程函数的一些基本思想,如作为头等值的函数、高阶函数、函数的纯性和数据的不可变性。这些使Scala成为实现函数域模型的好语言。在最后一节中,您将了解模块化,通过将整个结构组织成可管理的部分,使您的模型渐进地演进。

2.5 Functional in the small, OO in the large

一个重要的领域模型必然包含许多抽象,包括实体、值对象、服务和它们相关的行为。如果所有这些工件都在同一个名称空间中捆绑在一起,并使用一个同意的应用程序,您会作何感想?作为一个整体结构实现的模型很有可能是一种不和谐的纠缠。您不会有明确的职责分离,抽象也不会有正确的命名空间,并且您不会有任何基于上下文的隔离。毫无疑问,这将是一片混乱。模块是所有这些问题的答案。各种功能需要驻留在不同的模块中。在我们个人银行系统的背景下,客户的投资组合报告可以是一个模块,税务计算可以是另一个模块,审计可以是另一个模块。你必须问的一个很明显的问题是,模块应该是如何相互连接的。毕竟,整个模型必须作为一个整体来工作,并且需要在模块之间进行一些互连。当在在线客户交互模块中发生交易时,该信息必须流向审计模块。因此,这绝对需要一些想法,让你的模型模块化,清晰地划分责任。模块需要松散耦合,但具有很强的内聚性。这是什么意思?因为一个模块执行一个明确的任务,它必须在自身内具有内聚性。抽象需要小而紧凑,每一个都专注于做一件具体的事情。另一方面,当我们讨论两个不同的模块时,它们之间的耦合应该尽可能地小。在两个模块之间有很强的依赖性显然不是一个健康的标志----改变其中一个将会影响另一个,这违背了模块化设计的原则。

2.5.1 Scala中的模块

Scala提供了trait(特质)和objects(对象,不是面向对象的对象,而是Scala的关键字object,其实创建的是类的一个单例对象)作为模块化设计的实现技术。使用特性,您可以使用基于mixin(混合)的组合。您可以使用trait(特质)组合几个较小的抽象来构建更大的抽象。这些不是函数,这里我不讨论函数组合。通常,一个trait(特质)是一个小的功能单元,它包含一个或几个只专注于交付该功能的方法。这是我们领域的一个例子:

这里写图片描述

如前所述,客户证券投资组合生成是一个单独的模块,但是它还包含了几个不同的功能,这些功能它们是相互独立的。因此,您将保留它们作为单独的特质(因此它们可以独立重用),并将它们组合在一起,以帮助构建组合生成更大的功能。需要关注的最重要的点是你所混合在一起的特质是相互正交的----举个栗子,Logging可以在其他上下文中重用, BalanceComputation则可以作为客户对账单生成的一部分而重用。现在您已经具备了证券投资组合生成所需的组合功能,您可以将该模块具体化为一个对象:
object PortfolioGeneration extends PortfolioGeneration
你可能会问一个问题,你需要特质PortfolioGeneration 吗??您可以直接通过构成混合来实例化对象。在提交具体的实现之前,将最终模块以特质的形式存在是一种很好的做法。明天你可能需要使用PortfolioGeneration来定义一个更大的模块;然后您可以使用中间的特质与其他功能混合使用。所以,这都是关于模块的模块性和可组合性。您之前看到的是Scala中最简单的模块化技术之一。Scala的类型系统在其库中有足够的能力,您可以执行复杂的技巧并实现参数化的模块。您可以将一些抽象作为trait(特质)中的参数,并在创建最终对象时只提供具体的实例。这是一种非常有用的技术,我将向您展示我们领域的一个例子,它展示了它的强大功能.在领域模型的实现中,一个模块实现了特定的业务功能。例如,客户的税务计算可以是一个模块;证券投资组合的生成可以是另一个模块。一个模块通常是一个限界上下文的一部分,尽管一个限界上下文可以包含许多模块。1.利息计算2.需要从利息中扣除的税金的计算3.第二项需要特定的税表,其中包含了对交易类型的税目和税率。(例如,您正在考虑将利息计算作为交易的类型,但也可以计算其他交易类型,例如外汇交易或股息计算。)定义模块的最重要的部分是考虑它们可能依赖于跨部署和跨实现的业务规则的可变性。在我们的场景中,我们可以说一个用于计算客户的净利息的模块需要引用另一个计算要扣除的税款的模块。计算税的模块需要在依赖于执行的交易类型的税表上进行参数化(在这种情况下,类型即是:利息计算,我们在上文序列中第三点提及要将利息计算作为一个交易类型)。下面的清单显示了这三个模块及其相互依赖关系的定义。

这里写图片描述
这里写图片描述

您还没有任何具体的模块实例----只有模块定义和它们的依赖关系,它们反映了我们领域模型中的可变性。在这里,您可以使用Scala提供的abstract types(抽象类型)和vals来实现可变功能,这使得依赖图更加明确和富有变现力。模块组合的最后一步是创建一个模块的实例,该模块提供了一个特定用例的功能。在清单2.12中,您将构建一个具体的模块,InterestCalculation,通过使用 InterestComputation作为交易类型来计算非新加坡客户的兴趣。您所需要做的就是为所有abstract types (抽象类型)和vals指定值,来具体化所有模块定义。

这里写图片描述

图2.7演示了如何通过多个参数化模块的组合来在Scala中生成一个模块的具体实例。它的主要目的是保持设计的灵活性,使模块间的通信是最小的,而且是灵活的和外部的可注入的。Scala中的object(对象)和trait(特性)的组合很好地完成了这一工作。您已经看到了关于Scala如何映射到我们的领域建模技术的广泛概述。这三个主要的Scala特性使这个建模过程富有成效并且有效,它们是静态类型系统,函数式编程的能力,头等的模块。如果您没有机会浏览本章的全部示例,图2.8提供了一个鸟瞰图。即使你已经学习了所有的例子,这个图也是你参考的一个有用的笔记。

这里写图片描述
这里写图片描述

2.6 用Scala让模型具有响应性

函数思想和实现纯函数式是领域模型设计的一个伟大的工程学科。但是您也需要语言支持来帮助您构建可以对失败响应的模型,并随着负载的增加而扩展,为用户提供良好的体验。第1章将此特性称为being reactive(被动响应),并确定了以下两个方面,以便使您的模型具有反应性:>管理失败,也称为 design for failure(围绕失败设计)>通过将长时间运行的进程委托给后台线程而不阻塞执行的主线程来最小化延迟在本节中,您将看到Scala如何提供抽象,来帮助您解决这两个问题。您可以管理异常和延迟,并将其作为与领域模型的其他纯抽象相结合的效果(effect)。一个效果增加了你的计算能力你不需要使用副作用来对它们进行建模。侧栏“What is an effectful computation(什么是有效的计算)?”详细说明我的意思。管理异常是响应式模型的一个关键组成部分----您需要确保一个失败的组件不会导致整个应用程序的失败。管理延迟是另一个需要注意的关键方面----应用程序中阻塞调用的无限界延迟是良好用户体验的一种严重的反模式。幸运的是,Scala通过提供抽象作为标准库的一部分来覆盖它们。PS:What is an effectful computation(什么是有效的计算)?在函数式编程中,一个效果为一个计算增加了一些能力。因为我们处理的是静态类型的语言,这些能力以来自类型系统的能力的形式出现。这种效果通常以类型构造器的形式建模,而类型构造器是以这些这些额外能力来构造类型。假设您有任何类型A,并且您希望添加聚合的能力,这样您就可以将A的集合作为一个单独的类型来对待。您可以通过构造一个类型List[A](对应的类型构造器是List)来实现这个目标,这就为A增加了聚合的效果。类似地,你可以有一个Option[A],它为A类型增加可选性效果。在下一节中,您将学习如何使用诸如Try和Future这样的类型构造器来分别建模异常和延迟的影响。在第四章中,我们将讨论使用applicatives和monads的更高级的效果处理。PS:这里我翻译了一个对于applicatives,monads理解为文章,建议大家看一看,要么下面讲这些名词,你会懵逼的~~~~

2.6.1 Managing effects

当我们在使您的模型具有响应性的环境中,讨论异常、延迟等问题时,您必须考虑如何将这些概念应用到函数式编程的领域中。第1章我们称呼它们为副作用,并警告你,它们给你的纯领域逻辑带来的负面影响。既然我们讨论的是如何管理它们以使我们的模型具有反应性,如何将它们作为模型的一部分,以便它们能够以一种与其他领域元素同时以引用透明的方式进行组合?在Scala里,就你将它们抽象到能够向世界公开函数接口的容器中的意义而言,你将其作为一个effect(效果)。在Scala中处理异常的最常见的例子是Try抽象,您以前见过。Try提供了一个sum type(和类型),其中的一个变体(这里我们说的是Failure)抽象了在你的计算中会出现的异常。Try将异常的效果封装在内部,并为用户提供一个纯函数的接口。在更一般的意义上,尝试是一个monad。还有一些其他的效果处理的例子。延迟是另一个例子,你可以把它当作一个效果-----您不需要将模型暴露在变幻莫测的无限界的延迟中,而是使用诸如Future这样的构造作为抽象来管理延迟。稍后您将看到一些示例。monad的概念来自于范畴论。这本书并没有深入到作为一个类别的单一广告的理论基础。它更关注monads作为抽象计算,帮助您模拟典型的不纯行为的影响,例如为用户提供函数性接口时候的异常、I/O、延续等。我们将把自己限制在一些Scala抽象的实现中,比如其提供的Try和Future。在函数式和响应式建模的上下文中,monad所做的就是它抽象了效果,让您可以使用一个纯粹的函数接口,该接口与其他组件很好地组合在一起。

2.6.2 Managing failures

第1章探讨了针对失败而设计的范例。无论您如何设计您的模型,以及为解决异常而包含多少保护,失败都是必然发生的。硬件失败,网络失败,第三方软件失败,甚至你自己的软件组件,你精心设计了所有可能的防御策略都失败了。因此,作为一个模型设计师,您应该采用什么策略来解决这种普遍存在的故障情况呢?正如您在第1章中所看到的,在代码中乱丢代码并不是解决问题的方法。它没有其作用,从软件工程的角度来看,那么它就是一个糟糕的实践。您的核心领域逻辑被大量的错误检查代码所笼罩。Scala提供了一个双管齐下的策略来处理异常:>明确地声明您的代码的一部分可以引发异常。使用类型系统来帮助。>使用抽象,不要在您的领域逻辑中泄漏异常管理细节,这样核心逻辑就会保持函数地组合性。下面让我们考虑一个函数,def getCurrencyBalance(account: SavingsAccount): BigDecimal .一般情况下,我们称其为happy path,函数以BigDecimal数据类型的形式来获取余额。但是异常怎么办?在某些情况下,这个函数可以抛出异常,但是,在函数签名的任何部分中都没有记录异常。在Scala中,你可以通过将失败作为效果(effects,虽然效果是有好的意思,用影响可能会好一点。)来做得更好。让我们从我们的领域来看看具体的例子,来理解以真正的函数方式管理异常的真正优点。清单2.13显示了三个函数,其中每个函数在某些情况下都可能失败。我们一清二楚的写着----函数返回的是Try[BigDecimal],而不是BigDecimal。您以前见过Try,并且知道它是如何在Scala代码中抽象出异常的。在这里,通过返回一个Try,该函数使它显式地显示它可能会失败。如果一切都很好,那么您就可以在Try的Success分支中获得结果,如果有一个异常,那么您就可以从Failure变体中获得它。要注意的最重要的一点是,异常永远不会从Try中逃脱,因为它是一个不受欢迎的副作用,它会污染您的组合代码。因此,您已经实现了Scala中失败管理的两种策略的第一个承诺:明确地说明这个函数可能会失败。让我们从我们的领域来看看具体的例子,来理解以真正的功能方式管理异常的真正优点。清单2.13显示了三个函数,其中每个函数在某些情况下都可能失败。

这里写图片描述

但是可组合性的承诺呢?是的,Try通过做一个monad来并提供大量的高阶函数来为你提供这种承诺。这是Try的flatmap方法,它使它成为一个monad并帮助你与其他可能失败的函数组合在一起:
def flatMap[U](f: T => Try[U]): Try[U]
您在前面看到了flatMap如何一下的两点绑定在一起:1,计算;2帮助您编写漂亮的、顺序的for表达式而却不放弃面向表达式的评估所带来的好处。你可以用Try编写出可能失败的代码,同时还获得同样的好处。
for {    b <- getCurrencyBalance(s1)    i <- calculateInterest(s1, b)    v <- calculateNetAssetValue(s1, b, i)} yield (s1, v)
这段代码处理所有异常,组合的也很棒,并以一种清晰简洁的方式表达代码的意图。这就是当您拥有强大的语言抽象来支持您的领域模型时所得到的结果。Try是处理异常的抽象,而flatMap是让您可以以happy path方式进行编程的秘密武器。因此您现在可以拥有可以抛出异常的领域模型元素---使用Try抽象来管理效果,您可以使你的代码对失败有弹性。

2.6.3 Managing latency

正如Try使用效果来管理异常,Scala库中的另一个抽象概念帮助您将延迟作为一种效果来管理。这是什么意思?响应式编程表明,我们的模型需要适应延迟的变化,这可能是由于系统负载增加或网络延迟或超出了实现者控制之外的许多其他因素导致的。为了提供对响应时间的可接受的用户体验,我们的模型需要保证延迟的一些限制。这个主意很简单:将你长时间运行计算以Future包装。计算将被委托给一个后台线程,而不会阻塞执行的主线程。因此,用户体验不会受到影响,并且您可以在任何情况下对用户进行计算。注意,这个结果也可能是会失败,为了以防计算失败-----因此,Future将延迟和异常都作为效果进行处理。未来也是一个monad,就像Try一样,它拥有flatMap方法,它可以帮助您将领域逻辑绑定到计算的happy path上。您已经在第1章的清单1中看到了这一点,您可以将延迟与最慢计算的最大时间绑定在一起,而不是每个单独计算的总和。即使在失败的情况下,您也可以在基于Future的计算中指定超时,这将有助于根据客户端的SLA来约束延迟。继续我们当前的思路,假设您在清单2.13中所写的函数涉及到网络调用,因此它们总是存在潜在的长期延迟。如前所述,让我们对API的用户进行明确的声明,并使每个函数都返回Future(请参阅下面的清单)。

这里写图片描述

通过使用flatMap,现在,您可以按顺序组合这些函数,以生成另一个Future.最终的结果是,整个计算被委托给后台线程,而执行的主线程仍然是自由的。这是可以保证更好的用户体验的,你已经实现了响应性原则所讨论的内容。下面的清单展示了Scala中future的顺序组合。

这里写图片描述

在这里,result也是一个Future,您可以为已完成的Future的happy path和failure path上插入回调。如果Future成功完成,您讲拥有可以传递给客户端的净资产值。如果失败,您也可以获得该异常,并实现异常的定制处理。除了使用flatMap的顺序组合之外,Scala的Future还提供了许多并发组合,您可以构建多个期future,然后让它们并发执行,从而产生异步的、非阻塞的并行代码。我们将讨论在第六章中使用Scala的future实现并行的非阻塞代码。

2.7 总结

在本章中,您了解了一些使Scala成为领域建模的适当函数编程语言的核心特性。您可以使用任何您想要的语言进行领域建模。但是有些语言提供了更多的语言和库支持,使您能够在更高的抽象层次上进行编程。Scala就是那么一种语言。从这一章的主要点如下所示:>强大的Scala类型系统----Scala有一个强大的类型系统,它可以有效地对一些领域逻辑进行编码。有效地使用了类型系统,可以帮助减少样板文件和不必要的测试代码(就像本章所讲到的,使用泛型来实现领域逻辑)。>为函数式编程提供头等语言支持---Scala支持函数式编程作为头等范例。它支持在Scala标准库中提供高阶函数和丰富的组合器。使用函数作为头等的抽象,您可以用其实现领域行为,这些行为为是引用透明的,因此是可组合的。>代数数据类型和模式匹配支持----Scala支持用于建模不可变数据的ADT的实现。通过使用模式匹配,可以将数据与操作逻辑相匹配。实际上,ADTs和模式匹配的组合提供了一种以简洁的方式表达领域逻辑的强大方法。>头等的模块----模块化是好的软件工程实践的一个关键方面,Scala为模块提供了强大的支持。Scala中的trait(特质)和object(对象)帮助定义了可以组合的模块,并帮助从较小的组件中演化出更大的组件.So Scala is a language where the ideal paradigm of domain model implementation is functions in the small objects at large(大家自己理解吧,我也看不懂~~)。
阅读全文
0 0