函数式与响应式的领域模型(一)

时间:2022-08-31 14:40:57

1.函数式领域模型:简介

本章包括
>领域模型与领域驱动设计
>纯函数式领域模型的优点
>响应式建模以提高响应性
>函数式如何满足响应式
假设您正在使用大型在线零售商店的门户进行购买操作。在你将想买的商品加入购物车后,购物车却无法注册你的购买操作!这会让你有什么赶脚?或者说你在圣诞节前一周对一件物品进行价格检查,而在过度的延迟之后,你的响应又回来了;你喜欢这种购物体验吗?在这两种情况下,应用程序不具有响应性。第一个例子描述了缺乏对失败情况下的的响应----你的整个购物车都被关闭了因为后端资源是不可用的。第二个例子描述的是对不同负载缺乏响应能力。也许是节日的季节已经给系统带来了过多的负荷,让你的查询反应太慢了。但是这两种情形都会导致用户极度沮丧。
对于我们开发的任何系统而言,核心的概念就是领域模型,它代表着业务是如何工作的。对于一个银行系统,系统的功能包括像banks(银行),accounts(账户),currency(汇率),transaction(交易),和reporting(报告)等的实体,这些实体一起工作,为用户提供良好的银行体验。这是模型的责任,确保用户在使用系统时拥有良好的体验。
当你实现了一个领域模型,您其实是将业务流程转换为软件了。你试着用一种方法使这个转化过程尽可能的像原型的过程一样(其实就是说使得我们的实体的操作可以尽可能的体现现实生活中领域中的实际操作)。为了实现这一点,您需要遵循特定的技术并在设计、开发和实现中采用范例。在本书中,你将会探索如何去使用函数式编程与响应式建模的组合,以交付响应性和可伸缩的模型,并且易于管理和维护。本章介绍了这两种范式的基本概念,并解释这种组合如何协同工作以实现模型的响应性。
如果你现在正在使用该行业目前提供的任何编程技术,设计和实现系统,那么这本书将会使您的眼睛更加灵活和富有表现力地使用新技术。如果你管理开发复杂系统的团队,您将体会到使用功能和响应式编程为客户提供更可靠的软件所带来的好处。本书使用Scala作为实现语言,但是您将学到的基本原理可以应用于当今行业中使用的许多其他语言。

在继续讨论领域建模的核心主题之前,图1.1展示了这一章如何为理解实现域模型的函数式和相应性编程的协同工作提供了基础。这样做的目的是让您熟悉基本概念,这样在本章的最后,您将能够根据函数式和响应性范型来改进您的领域建模技术。

函数式与响应式的领域模型(一)

1.1 what is a domain model(什么是领域模型)

你上次从ATM机withdrew(取款)是什么时候?或者deposited(存钱)进你的bank account(银行账户)是什么时候?或者使用互联网银行来检查你的月工资是否被credited(记入)你的checking account(支票账户)?我在这里使用的英文,是让你们知道,这些是术语,是与银行的业务息息相关的~我们称之为个人银行的domain(领域).我们这里所看到的单词domain,它意味着对这块业务感兴趣的领域。当您正在开发一个自动化银行业务的系统时,您正在为个人银行业务建模。你设计的抽象,你实现的行为,以及你建立的UI交互都反映了个人银行的业务,它们构成了领域的model(模型)。
更正式地说,领域模型是 问题领域 各实体之间关系的蓝图,并勾勒出其他重要的细节,如以下:
>属于领域的对象:例如没在银行领域中,你所拥有的诸如banks(银行),accounts(账户)和transaction(交易)等。
>这些对象在相互作用中表现出来的行为:例如在银行系统中,你从账户中debit(借款),你向客户端issue a statement(发表声明)。这些是在您的领域对象之间发生的典型交互。
>领域的语言:当你对个人银行的领域进行建模时,诸如debit(借贷)、 credit(信用)、portfolio(投资组合)等术语,或者诸如“从账户1到账户2转账100美元”之类的术语,都是非常泛泛的,并形成了领域的词汇表。
>模型运行的上下文:这包括与问题领域相关的一组设想和约束,并且自动适用于您开发的软件模型。比如只能为一个活着的人或者实体开一个新的银行账户-----这可以是为个人银行业务定义域模型上下文的设想之一。
与其他任何建模实践一样,实现域模型最具挑战性的方面是管理它的复杂性。其中的一些复杂性是问题的固有之所在,你无法回避它们。这些被称为系统的essential(必要)复杂性。例如,当您从您的银行申请个人贷款时,根据您的配置文件确定贷款金额额度有一个固定的复杂性,而这个复杂性是由领域的核心业务规则决定的。这是在解决方案模型中无法避免的必要复杂性。但是,解决方案本身引入了一些复杂性,比如当您实现了一个新的银行解决方案,它以额外批处理的形式引入了对操作的额外负载。这些被称为模型的incidental (附带)复杂性。
有效的模型实现的一个重要方面是减少附带的复杂性。通常情况下,您可以通过采用帮助您更好地管理复杂性的技术来减少模型的附带复杂性。比如,如果您的技术能够更好地模块化您的模型,那么您的最终实现就不是一个单一的、难以管理的软件。它被分解为多个小的组件,每个组件都在其自身的上下文中运行。图1.2描述了这样一个系统----为了简洁,我已经展示了两个组件。但您可以理解:使用模块化系统,每个组件在功能上都是自包含的,并且仅通过显式定义的契约与其他组件交互。有了这种布置,您就可以比单片系统更好地管理复杂性了。

函数式与响应式的领域模型(一)

ps:翻译下上面的几行英文:
上图是领域模型及其外部上下文的概览,并使用来自个人银行领域的术语。每个较小的模块都有自己的一组假设和业务规则,这些模块比大型单片系统更容易管理。但是,您需要至少保持它们之间的通信,并使用明确定义的协议。

本书解释了如何采用函数式编程的原则,并将它们与响应性设计相结合,从而实现了更易于创建、维护和使用的领域模型的实现。

1.2 介绍领域驱动设计

在前一章中当我们解释领域模型时候,我们使用了像banks,accounts,debit等等的术语。所有的这些术语都与个人银行领域相关,并且很容易传达出他们在商业运作中所扮演的角色。当您为个人银行实现一个领域模型时,如果您使用与业务相同的术语,对试图理解您的模型的用户来说不是很方便吗?比如说,您可以有一个作为模型一部分的名为Account的实体,它实现了所有行为的变化,这取决于不管是支票,储蓄,还是货币市场账户。这是将概念从问题领域(业务)映射到解决方案领域(您的实现)的直接映射。
当您实现领域模型时,对领域的理解是非常重要的。只有当您掌握了各种实体在现实世界中的工作时,您才会有知识来将它实为解决方案的一部分。理解领域并以模型的形式抽象出核心特征称为域驱动设计(DDD)。Eric Evans写的书,大家可以看看。

1.2.1 限界上下文

1.1小节里描述了模块化的模型和模块化带给领域模型的好处。任何重要复杂度的领域模型实际上都是一些小模型组成的集合,每个模型都有自己的数据和领域词汇表。在领域设计的世界里,术语限界上下文即表示了在整个领域内的如此的一个小模型。所以完整的领域模型实际上是一个限界上下文的集合。让我们考虑一个银行系统:一个投资组合管理系统、税务和监管报告以及定期存款管理都可以被设计成单独的限界上下文。一个限界的上下文通常处于相当高的粒度级别,并表示系统内的一个完整的功能区域。
但是当您在完整的域模型中有多个限界上下文时,您如何在它们之间进行通信呢?记住,每个限界上下文都是自包含的,但可以与其他限界的上下文进行交互。通常,当您设计模型时,这些通信被实现为显式指定的服务或接口集。我们将会看到一些这样的实现。基本思想是将这些交互保持在最低限度,以便每个有界的上下文在自身内具有足够的内聚性,并且与其他有界上下文松散耦合。
您将在下一节中了解每个限界上下文的内容,并了解组成模型的基本领域建模元素的一些基本内容。

1.2.2 领域模型元素

各种抽象定义了领域模型。如果有人要求你列出个人银行领域的一些元素,你可能会说出诸如银行和账户之类;账户类型,如支票、储蓄和货币市场;以及交易类型,如借方和贷方。但是您很快就会意识到,许多这些元素与它们是如何创建的、经过处理的业务流程以及最终被逐出系统的方式是相似的。例如,考虑一个客户帐户的生命周期,如图1.3所示。银行创建的每个客户帐户都通过银行、客户端或任何其他外部系统的某些操作来传递一组状态。

每一个account(账户)都有一个在系统内的整个生命周期内管理的标识。我们将这种元素称为entities(实体,区别于值对象)。对于一个账户,它的标识符是它的帐号。它的许多属性在系统的生命周期中可能会发生变化,但是账户却总是在每次打开它的时候,被已经分配给他的账号所标识,不会改变。两个账户可以有相同的名字,也可以有相同的属性,即使这样也会被认为是两个不同的实体,因为他们的账号是不同的(其实类似于我们为实体创建的ID主键)。

函数式与响应式的领域模型(一)

PS:图片1.3下面的英文翻译
客户帐户生命周期中的状态。从一个状态转换到另一个状态取决于在早期状态中执行的操作。


每个账户都可能有一个address(地址)----帐户持有人的住宅地址。地址是由它所包含的值唯一定义的。你改变了地址的任何属性,那么它就变成了一个不同的地址了。你能辨别出account(帐户)和address(地址)之间的语义差别吗?address是没有任何标识符的;它完全基于它所包含的值来标识。毫不奇怪,我们称呼这样的对象为value object(值对象).区分实体和值对象的另一种方法是,值对象是immutable(不可变的,java的String就是一个不可变对象),您不能在创建它之后更改一个值对象的内容后,而却不改变对象本身。
实体和值对象之间的区别是领域建模中最基本的概念之一,您必须对这个概念有一个清晰的理解。当我们谈论一个帐户时,我们指的是账户的具体实例,该账户实例带有一个账号标识符,账户持有者的名字,还有一些其他的属性。其中的一些属性组合在一起形成了账户的唯一标识。通常,帐号是帐户的标识属性。即使你有两个帐户,它们的非标识位的属性有相同的值(比方说是账户的持有者的名字或者是账户的开户时间),如果帐户号码是不同的,那么它们还是两个不同的帐户。一个account(账户)就是一个entity(实体),它有自己特定的标识符(就是账号),但是,对于一个address(地址),您只需要考虑值部分。只有value(值)才是最重要的。您可以在实体中更改某些属性的值,但是标识不会改变;例如,您可以更改一个帐户的地址,但是它指向相同的帐户。但是你不能改变一个值对象的值;否则,它将是一个不同的值对象。因此,一个值对象是不可变的。


PS:实体和值对象的不变性语义
当我们在本章后面讨论实现时,我们将对实体和值对象的不可变性有不同的看法.在函数式编程中,我们的目标是尽可能地建模为不可变性----您也可以将实体建模为不可变对象。因此,区分实体和值对象的最佳方法是记住一个实体有一个不能改变的标识,而一个值对象有一个不能改变的值。值对象在语义上是不可变的。而实体在语义上是可变的,但是您将使用不可变结构来实现它。
那么我们对于具有可变语义的实体,以不可变结构对其进行建模,这有什么坏处呢??让我们面对它吧----可变的引用更加高效。在许多情况下,与不可变的数据结构一起工作,相比于直接的可变性结构,不可变结构会导致更多的对象被实例化,特别是当一个领域实体频繁变化的时候。但是,正如您将在本文和后面的章节中看到的那样,可变数据结构导致了脆弱的代码基础,并且在并发操作的情况下使理解代码变得困难。所以一般的建议是从不可变的数据结构开始----如果你需要使代码的某些部分比你的不可变性更有性能,那就突变为可变结构吧。但是要确保客户端API不会看到这个突变;将这个突变封装在一个引用透明的包装器函数后。
PS:作者对于最后一句话的注释
例如,看看Scala集合API的实现。他们中许多,比如List::take或者List::drop,它们在hood(翻译成引擎盖,实在是没语感。Scala的drop方法的实现,借助了可变的列表ListBuffer进行数据的收集与复制,并最终转换为不可变的List返回,应该像表达的就是将突变封装在包装器内,使得客户端无察觉。)下使用突变,但是客户端API没有看到它。客户调用端返回一个不可变的List。

PS:函数的引用透明referentially transparent
即函数的作用,无副作用,比如说我们定义的函数A,调用A,并获取结果return数据,那么引用透明的意思就是我们可以直接以return的值,来替换掉对整合函数的调用的话,那么这个函数就是引用透明的。下面我们举个例子:
    def A:Int(x:Int,y:Int) = x + y

//1.0
val result = A(1,1
//2.0
val result = 2
对于A函数,我们在1.0中调用它,得到结果,这函数结束后,返回一个新的值,我们以result引用它。而其实,步骤1.0所做的操作,我们可以以步骤2.0替代,而且代码也不出错。这就说明函数A具有引用透明。



任何领域模型的核心都是不同领域元素之间的行为或交互的集合。这些行为的粒度比单独的实体或值对象的粒度要高。我们认为它们是模型提供的主要服务。让我们来看一个来自银行系统的例子。比如,一个客户来到银行或ATM机,并在两个账户之间转账。这个操作导致从其中一个账户取出钱,另一个账户存进钱,这将反映出各自账户余额的变化。验证检查必须完成,例如,确定帐户是否是激活状态,以及转出帐户是否有足够的资金转移。在每一个这样的交互中,可以包含许多领域元素,包括实体和值对象。在DDD中,您可以将整个行为模型建模为一个或多个services(服务,我这里提醒一下,不是分层结构的service,那叫应用服务,这里所指的是领域服务)。根据模型的体系结构和特定的有界上下文,您可以将其打包为独立的服务(你可以给他命名为AccountService)或者作为名为BankingService的一个更通用的模块的服务集合的一部分。
领域服务与实体或值对象不同的主要方式是粒度级别。在领域服务中,多个领域实体根据特定的业务规则进行交互,并在系统中交付特定的功能。从实现的角度来看,服务是一组函数,作用于相关的领域实体和值对象。它封装了一个完整的业务操作,该操作对用户或银行具有一定的价值。表1.1总结了迄今为止您所看到的三个最重要的领域元素的特征。
领域元素 特性
Entity 1.有一个标识符2.在生命周期中经过多个状态3.通常在业务中有一个明确的生命周期
value object 1.语义上不可变的2.可以在实体中*共享
service 1.比实体或值对象更宏观的抽象2.涉及多个实体和值对象3.通常是业务的一个用例模型
图1.4说明了这三种类型的领域元素在一个来自于个人银行领域的示例中是如何关联的。这是DDD的基本概念之一;在继续这段旅程之前,确保你了解了基本知识。

函数式与响应式的领域模型(一)

PS:图片1.4下方的英文翻译:
模型的领域元素之间的关系。这个例子来自于个人银行领域。注意,account(帐户)、badk(银行)等都是实体。一个实体可以包含其他实体或者值对象。服务处于更高的粒度级别,并实现涉及多个领域元素的行为。

1.2.2.1领域元素的语义和限界上下文

让我们以一个重要的概念来结束对各种领域元素的讨论,这个概念将它们的语义与有界上下文联系起来。当我们说一个address(地址)是一个值对象时,它只在被定义的限界上下文的范围内是一个值对象,你不需要用它们的标识符来追踪address(地址)。但是,让我们来考虑另一个实现了地理编码服务的限界上下文。在那里,您需要通过纬度/经度跟踪地址,并且每个地址可能必须被标记为唯一的ID。address(地址)在这个有界的上下文中成为一个实体。同样地,account(帐户)可能是个人银行应用程序中的一个实体,而在投资组合报告的限界上下文下,您可以将一个account(帐户)作为一个仅需要打印的信息容器,从而实现为一个值对象。领域元素的类型总是反映其定义所在的限界上下文。

1.2.3 领域对象的生命周期

您在任何模型中所拥有的每个对象(实体或值对象),都必须有一个明确的生命周期模式。对于您在模型中所拥有的每一种对象,您必须定义处理以下事件的方法:
>Creation(创建)---对象是如何在系统中创建的。在银行系统中,你可能有一个专门的抽象,负责创建银行帐户。
>Participation in behaviors----当它在系统中相互作用时,对象是如何在内存中表示的。这是在系统中建模实体或值对象的方式。一个复杂实体可能包括其他实体以及值对象。作为一个例子,在图1.4中,一个account实体可能有到其他实体,比如说Bank,或者是值对象,比如说是Address或者是Account Type的引用。
>Persistence(持久化)---如何保持对象的持久形式。这包括一些问题,例如:如何将元素写入到持久存储中;如何检索系统中查询的详细信息;如果您的持久性方式是关系数据库,那么如何插入、更新、删除或查询诸如account之类的实体.
和往常一样,统一的词汇量也有帮助。下面的小节将使用特定的术语来表示我们如何在模型中处理这三个生命周期事件。我们称它们为模式,因为我们将在领域建模的不同上下文中重复使用它们。

1.2.3.1 Factories 工厂模式

当你有一个复杂的模型,使用专门的抽象来处理其生命周期的各个部分是一种很好的做法。不要在整个代码库中使用创建实体的代码片段,而是使用模式将它们集中起来。这个策略有两个目的:
>它将所有的创建代码放在一个位置
>它抽象了调用者创建实体的过程。
举个栗子,你可以有一个Account账户工厂,它可以获取创建帐户所需的各种参数,并将其交给新创建的帐户。你从工厂返回来的账户可能是一个支票、储蓄或货币市场类型的账户,这取决于你所传递的参数。因此这个工厂让你可以通过使用相同的API来创建不同类型的对象。它抽象了创建对象的过程和类型。
创建的逻辑在你的工厂之内。但是工厂属于哪里呢?毕竟,一个工厂,为您提供了服务--创建和初始化的服务。工厂的责任是移交给你一个完全构造的、最低有效的领域对象实例。一种选择是将工厂作为定义了领域对象的模块的一部分,这在Scala中有一个使用伴生对象的自然实现,如下清单所示。另一种选择是将工厂看作是一组领域服务的一部分,第2章详细介绍了这一实现。

函数式与响应式的领域模型(一)

1.2.3.2 AGGREGATES 聚合

在我们的个人银行模型中,正如您在前面看到的那样,一个Account(帐户)可以被认为是由一组相关的对象组成的。通常,这包括以下内容:
>核心识别帐户的属性,如账号。
>各种非识别的属性,如持有者的姓名、帐户的开户日期和销户日期(如果是一个已经销户的帐户)
>到其他对象的引用,比如Address和Bank
一种可以可视化整个对象的图形的方法是把它想象成一个内在的一致性边界。当您有一个帐户被实例化了,所有的这些参与的对象和属性必须与领域的每一个业务规则相一致。比如说你的销户日期不能比开户日期还早。你不能有一个没有持有者姓名的账户。这些都是有效的业务规则,实例化的Account(帐户)必须让所有的组合对象都遵守这些规则。当您在这个图中识别出这一组参与对象之后,这个图将成为一个aggregate(聚合)。一个聚合可以包括一个或者多个的实体和值对象(以及其他的私有属性)。除了确保业务规则的一致性之外,在一个限界上下文中还常常将一个聚合作为模型中的事务边界。
聚合内的一个实体构成了Aggregate Root(聚合根)。它是整个图的守护者,作为聚合与客户交互的单一点。聚合根有两个目标要执行:
>在聚合内确保业务规则和事务的一致性边界
>防止聚合的实现泄漏到客户端,作为聚合所支持的所有操作的一个门面(facade)。
清单1.2展示了Scala中一个Account聚合的设计。它包含聚合根Account(它也是一个是一个实体),并且还有像Bank的实体和Address这样的值对象,来作为它的组成部分(作者在这里做了注解:在实际中,当你设计聚合时,你可能会出于对于性能和操作的一致性,而从聚合中优化许多组合实体,只保留聚合根和值对象在一起。比如你可能选择去持有一个Bank的ID,而不是去持有一整个Bank实体去作为Account聚合的一部分)。设计一个聚合并不是一个简单的任务,看一看Vaughn Vernon的“Effective Aggregate Design(有效的聚合设计)”这篇文章,从三部分讨论了设计一个好的聚合体所需要的各种注意事项(地址是:http://dddcommunity.org/library/vernon_2011/)

函数式与响应式的领域模型(一)

trait Account {
def no: String
def name: String
def bank: Bank ---->引用到Bank实体
def address: Address --->Address是一个值对象
def dateOfOpening: Date,
def dateOfClose: Option[Date]
//..
}
case class CheckingAccount( -->这是对于Accopunt的实现,它重写了其一些属性
no: String,
name: String,
bank: Bank,
address: Address,
dateOfOpening: Date,
dateOfClose: Option[Date],
//..
)
extends Account


case class SavingsAccount(
//..
rateOfInterest: BigDecimal,
//..
)
extends Account


trait AccountService {
def transfer(from: Account, to: Account, amount: Amount):Option[Amount]
}
PS:scala的样本类
在清单1.2里我们使用scala的样本类去建模一个Account聚合。样本类在sacala里提供了一种便利的方式,来设计从头就提供不可变形的对象。默认情况下,类获取的所有参数都是不可变的。因此,使用样本类,我们得到以易于使用的方式来定义一个聚合所带来的便利,以及不变性所带来的所有好处。
清单1.1和清单1,2使用了scala的特质。特质允许你在scala中定义模块,他们是可以被组合在一起的。特质的混合是一个小的抽象,可以与其他组件混合以形成更大的组件。
更多的scala的知识,参考官网吧,作者开始啰嗦了。


注意,我们已经在Scala中实现了一个Account聚合的基本契约以及以样本类形式出现的变体。正如前面的“PS:scala中的样本类”所指出的,样本类被方便地用于建模不可变的数据结构。这些都被称为代数数据类型,我们将在继续讨论的过程中更详细地讨论这些数据类型。但是让我们看一下前面提到的Account实体聚合的一个方面。
在1.2.2小节中,您看到可以更新实体的某些属性而不能改变他的标识符。这意味着一个实体是可修改更新的。但是这里我们将Account实体建模为一个不可变的抽象,这似乎是一个明显的矛盾吗?当然不是(小人得志的嘴脸,就你知道的多!!)!我们将允许对实体进行更新,但是以一种函数的方式,它并不会让实体变得可变。您的更新将生成一个带有修改属性值的新实例,而不是对对象本身进行变化。这样做的好处是,您仍然可以在进行更新生成同一实体的新实例的时候,继续将原始的抽象作为不可变的实体进行共享。在函数思维方式的背景下,你将尽可能地争取实体的不变性。这是指导你的模型设计的指导原则之一。在清单1.2还显示了一个示例领域服务(AccountService),它使用帐户聚合来实现两个帐户之间的资金转移。

1.2.3.3 REPOSITORIES 仓储

众所周知,聚合由工厂创建,在对象生命周期的活动阶段表示内存中的底层实体(见图1.3,回顾下一个Account的生命周期)。但是,当您不再需要它时,您还需要一种方法来持久化聚合。你不能把它扔掉,因为你可能需要以后再去取它。
仓储为您提供了这样一个接口,可以将聚合体以持久的形式存放,这样您就可以在需要时将其返回到内存中的实体表示中。通常,仓储都具有基于持久性存储的实现,例如关系数据库管理系统(RDBMS),尽管契约并没有强制要这么执行(在许多小型应用程序中,您可以拥有一个内存存储库。但事实并非如此)。还请注意,聚合的持久模型可能与内存中的聚合表示完全不同,并且主要是由底层存储数据模型驱动的。仓储的职责(见下面清单1.3)是提供从持久性存储中操作实体的接口,而不需要暴露底层关系型(因为我们这里是以关系型数据库谈的,其实应该是任何底层存储支持的模型,比如redis所支持的键值对,还有Neo4j的图形数据库)数据模型。

函数式与响应式的领域模型(一)

仓储的接口不知道底层持久性存储的本质。它可以是关系数据库,也可以是NoSQL数据库---只有接口的实现才知道到底是那种。因此,一个聚合为实体在内存中的表现提供了什么,仓储也就是为持久性存储做的相同的事。一个聚合隐藏了对象的内存表现的 底层细节,而一个仓储则抽象了对象的持久化表现的 底层细节。清单1.3展示了一个  AccountRepository从底层存储中操作Account;而该清单没有显示存储库的任何具体实现。但是用户仍然通过聚合来与仓储交互。看看下面的这个列表,了解一个聚合如何为一个实体的整个生命周期提供一个窗口:
>你向工厂提供了大量的参数,然后得到一个聚合
>通过服务(清单1.2中的 AccountService )实现的所有行为,你都可以使用聚合作为契约(例如清单1.2中的Account)
>您使用聚合来持久化仓储中的实体(看清单1.3)
到目前为止,您已经看到了使用限界上下文在模型中实现模块化,您需要实现的三种最重要的领域元素类型(实体、值对象和服务),以及用于操作它们的三种模式(工厂,聚合和仓储)。正如您现在必须认识到的,这三种元素参与了领域交互(如银行系统中的借方、贷方等),它们的生命周期由三种模式控制。在域驱动设计中,最后要讨论的是将所有这些都绑定在一起的一个方面。它被称为模型的词汇表,在下一节中,您将了解为什么它是重要的。

1.2.4 统一语言

现在您有了由实体、值对象和服务所形成的模型,你也知道这些元素需要彼此交互,以实现业务执行的各种行为。作为一个软件工匠,你有责任以这样一种方式来对这种交互进行建模,不仅可以理解下面的硬件,也可以理解古怪的人类思想。这种交互需要反映底层业务语义,并且必须包含您正在建模的问题领域的词汇表。这里说的通过词汇表,我所指的是参与的对象的名称以及作为用例的一部分执行的行为。领域的词汇表的使用需要被传递到更小的抽象中去。您可以编写一个AccountService实现(这是一个领域服务)如下:
trait AccountService {
def debit(a: Account, amount: Amount): Try[Account] = //..
def credit(a: Account, amount: Amount): Try[Account] = //..
def transfer(from: Account, to: Account, amount: Amount) = for {
d <- debit(from, amount)
c <- credit(to, amount)
} yield (d, c)
}
让我们更详细地了解一下这个实现是如何体现在可理解性的特性上的:
>函数体是最小的,不包含任何无关的细节。它只是封装了在两个帐户之间的资金转移所涉及的领域逻辑
>这个实现使用来自银行领域的术语,因此熟悉业务领域的人不知道底层实现平台的任何信息,也应该能够理解正在发生的事情。
>这个实现描述了执行乐观状态下的处理方式(即没有出现异常的情况下)。异常的方式被完全封装在用于实现的抽象中。如果你知道Scala,这里使用的for循环是一元的,并且会处理在执行序列中可能发生的任何异常。随着我们的进展,我们将讨论其中的许多问题。
PS:Scala中,for循环时一个可以完成filter/map/reduce的语法糖。更多知识看一下scala的相关书籍。
Eric Evans称呼这为ubiquitous language(统一语言)。在你的模型中使用领域词汇表,使这些术语交互,就类似于在该领域所说的语言一样。从正确命名实体和原子行为开始,把这个词汇表扩展到你用它们组成的更大的抽象上。不同的模块会说不同的方言,而同一术语在不同的限界上下文中也意味着不同的事情。但在上下文中,词汇应该是清晰而明确的。
拥有一种一致的通用语言与设计您的模型的适当的api有很大关系。这些API必须具有表达性,这样一个领域的专家才能通过只查看API来理解上下文。这被称为领域特定语言(DSL)。

1.3 函数思想

存在许多领域建模的方法,但是在过去的十年左右,面向对象(OO)技术已经完全占据了最复杂的领域模型。在这本书中,我将有点激进,使用普通函数作为模型领域域行为的主要抽象。在接下来的几节中,您将从建模和维护您的软件的角度看到这样做的好处。

有时,优雅的实现只是一个函数。不是一个方法。不是一个类。不是一个框架。只是一个函数。 ------谁说的不重要
让我们从我们过去几年使用的范例开始。在我们样例中,您将剖析一个实现,并逐渐将其转变为一个函数变体。在此过程中,我将强调后者带来的好处。让我们回到我们日常生活中与之互动的领域:个人银行领域。您将考虑一个简单的模型,该模型包含一个聚合的Account,它具有一个值对象Balance、一些其他属性,以及借款和存款的两个操作方法,如下面的清单所示。

函数式与响应式的领域模型(一)

清单1.4是自解释的。Account类拥有一个可变状态,即帐户所持有的余额。方法 debit和 credit直接改变对象的状态,以改变帐户在任何时间点上保持的余额。
问:你认为这个模型的主要缺点是什么?
请安静的想一下。如果需要的话可以看看清单1.4。我们将要讨论的可能是为什么你应该领会韩式是思维和建模的最重要的原因之一。
答案:这是一种可通过两种方式影响您的可变性:它使在并发环境中使用抽象变得困难,并且使您很难对代码进行推理。

这里有一个稍微长一点的解释:var balance:Balance在我们的领域模型中一个可变的状态。这里的一个关键词是“mutable(可变)”,这表明这个对象所持有的状态(balance)可以由对象的多个客户端进行更新。在并发环境中这可能导致问题,在任何时候确定这个状态的值,您都可能有各种各样的不一致。这是一个非常大的主题,您可以从优《Java并发编程实战(作者是Brian Goetz,出版于2006)》这本书找到答案。当它涉及到你的代码的推理时,可变状态也是一种反模式,您将在本章后面看到。尽管它在建模世界中似乎是一个令人信服的建模方式,但可变状态产生的问题多于解决方案。您需要找到一种摆脱这些可变状态的方法。
让我们看看是否可以改进以前代码的主要缺点,并保持在面向对象的思维领域。接下来的清单展示我们尝试去净化在清单11.4中由于引进了可变状态带来的错误。

函数式与响应式的领域模型(一)

可变状态消失了!在Account上的每一个操作都创建了一个带有修改后状态的新的对象。新的Account类本身带有状态,而不是带有一个可变的状态。一旦你有了这个类的实例,那么在这个实例自身内也就有了balance的一个状态。但不同之处在于这个状态是不可变的。如果不创建另一个Account对象,就无法更改它的值。而这正是debit和credit操作需要做的。Scala确保您在类构造函数中传递的参数默认是不可变的。当然你可以选择一var形式让这些参数可变。但是var也是一个显式的修饰符,标识你需要应用来获得可变性---另一个鼓励不可变抽象设计的优秀决策。
既然您已经将Account变为了一个不可变的抽象,那么在并发模式下,可以*的在多个线程之间共享它。这是一个巨大的收获,是您第一次领会函数思想的优点的婴儿般的一小步,它的工作原理是,在不依赖或影响任何共享的可变状态下接受输入和生成输出的纯函数。不变性在这里扮演着重要的角色。
但你现在你还没有结束。Account仍然是一种既包含状态又包含行为的抽象。其思想是将两者分离,正如您稍后将看到的那样,这将给您更好的模块性和更好的组合性。但是,如果您使用纯函数对其进行建模的话,让我们首先看看这种代码的优点。

1.3.1 啊,纯洁的快乐

想象你回到了你的学校生活,并尝试去学习从数学角度定义一个function(函数)。因为我们在这里讨论函数式编程,这个函数和你在数学课上学到的有哪些不同点呢??
在数学中,函数是一组输入和一组允许的输出之间的关系,每个输入都与一个输出相关。 -----Wikipedia, http://en.wikipedia.org/wiki/Function_(mathematics)
这个定义从来没有提到过对共享可变状态函数的依赖。函数的输出是纯由函数的输入所决定的----特别像图1.5,它将一个函数(f)建模为一个将输入(x)转换为输出(y)的黑盒。在函数编程中,你要努力使你的函数变现的只是像一个数学函数。

函数式与响应式的领域模型(一)

问:清单1.4或清单1.5的哪个模型更接近于本节中引入的函数的定义?
既然您已经了解了函数的定义,并理解了如何在领域模型中实现相同的效果,那么这个问题应该是一件很容易的事。对外部可变状态的依赖越小,模型就越接近数学函数的纯洁性。
答:当然是清单1.5。它将Account变为一个不可变的抽象,接近于函数的定义。
在图1.5中,我们的y = f(x)模型,假定函数是平方操作,那么sqyare(3) = 9,不管你运行函数f多数次,都会得带完全相同的结果。那么接下来让我们来分析我们之间介绍的两种Account。
首先呢,在清单1.4的可变版本的Account中,在一个Account对象上执行debit(100)操作的话,产生的结果不仅依赖于我们传入方法的参数100和Account对象自身(也可以考虑一个隐式参数),而且还与其他客户端共享同一对象。这是因为共享Account对象的所有客户端都具有对可变状态的平等访问权。这远非我们在本节所讨论的纯函数。
在图1.5中的不可变版本的Account中,Account对象也持有当前的状态。因此,在Account上执行debit(100)操作
因此,在拥有2000当前余额(balance)的Account对象上调用debit(100),将总是产生一个新的Account对象,其余额(balance)为1900。输出只取决于所提供的输入。这个模型具有数学函数的纯洁性。
好了,现在是揭晓神谕的时候了。不可变的Account模型是您将很快看到的函数模型的面向对象版本。它仍然具有建模为类方法的函数。通过这种方式,你经常会遇到这样的困境:哪个函数应该是哪个类的一部分。此外,编写函数实现作为不同类的方法也会变得困难。
在我们的样例中,debit和credit都是在一个单独账户上的操作,并且您将其作为Account的行为。但是想transfer这样的操作,是需要有两个账户的。那么它是应该作为Account的一部分,还是应该作为一个领域服务呢?在一个账户上你应该如何处理其他服务,比如说每日余额明细表或者利息计算?您可能倾向于将它们放在一个类中,使其成为一个臃肿的抽象。将这些行为放在一个特定的聚合中也会妨碍模块化和组合性。以下是设计功能领域模型时需要遵循的一般原则:
>以algebraic data type (代数数据类型)(ADT)建模不可变的状态
>模型行为作为模块中的功能,模块表示业务功能的一个粗糙单元(例如,领域服务)。这样,就可以将状态与行为分开。行为比状态更好组合;因此,在模块中保持相关的行为可以实现更多的组合性。
>请记住,模块中的行为对ADTs所代表的类型进行操作。
问1.3:回答对与错:面向对象的范式对状态和行为是一对的关系,而函数式编程将状态和行为分离。
让我们首先看一下使用函数式Scala实现的Account模型。然后你就能回答这个问题了。清单1.6是改进早期实现的模型。它包含了相当多的Scala结构,其中一些是您暂时可以忽略的。下面是我们以函数思想建模我们的模型时候的主要关注点:
>Scala样本类面积按摩一个ADT。默认情况下,ADT的所有参数都是不可变的,这意味着您不需要任何特殊的机制来确保模型的不可变性。
>定义的ADT并不包含任何的行为。注意现在debit和credit都包含在AccountService中,它是你定义的一个领域服务。服务以模块来定义,在这里是以Scala的特质来实现的。Trait(特质)就像混合一样,可以简单将小的模块组合成大的模块。当你需要创建一个模块的实例(即我们上下文的一个服务),你可以使用object关键字。我们以前就提及过,有了函数思想,你就可以将状态与行为分离----现在状态驻留在ADT中,而行为则建模为单独的函数,驻留在模块内。
>debit和credit都是纯的函数,因为他们没有与任何特别的对象相关联。相反,它们接受参数,执行一些功能,生成特定的输出,就像图1.5中的y=f(x)模型一样。
>清单1.6使用了一些其他的结构,比如 Try,Success,和Failure,这些结构比抛出异常更具有功能性和组合性。即将到来的侧栏“Exceptions in Scala(我会以PS的方方式给出来)”给出了在Scala中处理异常的概述。后面的章节还将讨论这个主题,因为它们详细描述了函数式编程模式。

函数式与响应式的领域模型(一)
函数式与响应式的领域模型(一)

图1.6总结了使用Scala将面向对象、不可变域模型转换为函数变体的变化。

函数式与响应式的领域模型(一)

PS:图1.6的英文翻译
从面向对象的不可变建模到函数抽象。注意,我们已经将状态与行为分隔开了。状态被编码在一个代数数据类型,Account,而行为则属于领域服务。另外,像Try这样的结构,会磅为主我们构建可组合的抽象。
答1.3:主流的面向对象语言鼓励函数被封装到与状态相同的抽象中。在面向类的OO语言中,这种抽象是“类”。
下一节将讨论函数组合。但是让我先来看看另一个很酷的组合效应,它是由你的重构成函数抽象(比如说Try)所带来的。你现在可以组成多个debits(借方)和credits(贷方),如下:
val a = Account("a1", "John", today)
for {
b <- credit(a, 1000)
c <- debit(b, 200)
d <- debit(c, 190)
} yield d
res5: scala.util.Try[Account] = Success(Account(a1,John,Sat Nov 22
02:38:03 GMT+05:30 2014,Balance(610)))
PS:Exceptions in Scala
在函数式编程中,异常被认为是不纯的。为了在功能上处理异常,Scala定义了一个抽象,util.Try,他有两个具体的实现,分别是Success和Failutre。清单1.6使用了这个抽象来处理任何可能会从 generateAuditLog操作产生的异常。请注意,generateAuditLog是一个函数,它需要一个account和一个amount参数,并尝试生成字符串形式的审计日志。以Try[String]作为返回值类型去发布事实数据,操作可以失败,一旦失败了就会返回Failure.现在了解细节并不重要。但是请注意,Try是一种可组合的抽象,并且可以以一种纯粹的、函数式的方式与其他抽象结合起来。

1.3.2 纯函数组合

什么是函数组合??在回答你之前,让我们看一下组合的定义:

把东西放在一起或排列的方式:组成某物的部件或元素的组合。
----Merriam Webster (www.merriam-webster.com/dictionary/composition)

当你进行组合时候,你将各部分组合成一个整体。在数学中,把一个函数应用到另一个函数中来产生第三个函数是很明智的。很明显,这是创造力的含义----你创造了一个新的功能,它结合了两个功能的作用。举例子来说,两个函数f:X->Y和g:Y->Z可以组合在一起,得到一个新的函数,它将每一个X中的x映射到g(f(x))的Z上。
现在,让我们把这个例子转换成函数式编程的领域。假设您有一个函数,square:Int->Int,它将一个整数作为参数,并产生另一个整数,它的值做平方操作并作为输出。然后你还有另一个函数,add2:Int->Int,它获取一个Integer的入参,并对其进行加2操作。现在你对这连个函数进行组合操作,形成了add2(square(x:Int))这样的组合函数,它会对我们当入参传入这个组合函数的Integer值进行平方操作后,再执行加2操作。
这是认识到函数式编程是基于函数组合的第一步,这与我们在数学中处理函数的方式类似。这被称为函数程序的compositionality property(复合性属性)。

问1.4:假设你有两个函数:f:String->Int和g:Int->Int.你该如何组合函数f和g呢??你能想到一些满足这些签名的真实函数吗?
使用复合性的属性,您可以从较小的函数中构建更大的函数。这本书的主要主题之一是探索各种可以使函数组合在一起的不同的方法。我们将使用Scala,它提供了使这种组合易于实现的功能。对于Scala函数式编程的详细处理,请参考由Paul Chiusano和Runar Bjarnason编著的优秀书籍: Functional Programming in Scala(2014年出版)。
您将使用Scala REPL来查看在Scala中编写函数的示例,这是与Scala解释器进行交互的环境。但是首先,小问答的主人已经准备好了回答之前的问题。
答1.4:以g(f(x:String))的方式进行函数的组合。一个实际的例子是把f函数当作一个计算字符串长度的函数,g函数则作为一个队输入的Integer值进行双倍操作的函数。因此, double(length(x: String))就是一个实际的例子,它将两个函数组合起来,返回的是输入字符串的长度的两倍。
在你们已经熟悉了函数构成的基本技巧,现在是采取下一步行动的时候了。到目前为止,在讨论组合时,我已经将单个的函数连接到一起,其中一个函数接收到另一个函数产生的输出作为它的输入。但是,当我们在函数式编程中讨论复合性属性时,它远远不止于此。让我们看一下图1.7中的示例.

函数式与响应式的领域模型(一)

函数map有两个入参----一个String的列表和另一个函数,length: String -> Int。map遍历这个列表,并对每一个元素应用length这个函数。最终生成的结果是另一个列表啦,而这个结果列表的每个元素都是应用length函数的结果,这是一个整数列表。这是一个很好的例子,说明了如何从函数的方式思考,并指出了这个范式的一些有趣的特性,如表1.2所示。像map这样的高阶函数也被称为combinators(组合器)。

**Table表 1.2 **
map函数的特性 它与函数式编程有什么关系
你可以将函数作为一个参数传递来。在我们的例子中,map函数接受了一个叫做length的函数。 函数是头等函数。(以scala来解释的话,我们不仅可以定义和定义函数,而且可以将其写成匿名的字面量,并将其作为值进行传递)
map是一个函数,它将另一个函数作为输入。 map是一个高阶函数
map函数遍历字符串列表,但是循环是从API用户中抽象出来的。 通过函数式编程,您可以告诉函数该做什么。如何将它从API用户中抽象出来。您还可以对其他类型的序列进行map(不仅仅是一个列表),而且迭代是由map实现处理的。

问1.5:如果传递给map的函数也会更新一个共享的可变状态,会发生什么呢?这是否意味着多次迭代一个列表将导致不同的输出?
下面的清单中的代码使用了高阶的函数,比如Scala中的map,演示了几种组合方式。每个例子都遵循表1.2中所示的函数思想的指导原则。
函数式与响应式的领域模型(一)
函数式与响应式的领域模型(一)

现在是时候使用这些组合来丰富我们的 Balance领域模型了。在复杂的领域建模中,您将自己定义许多组合器。但是,标准库附带的那些程序是非常有用的,而且你经常会发现自己在构建自己的程序时,又会重新回到它们当中。毕竟,它是你所追求的复合性。
答1.5:当涉及到像map和其他的组合时,不要违反纯洁性。你传递给map的函数必须是没有任何副作用或突变的。我们很快就会讲到副作用。
现在,让我们尝试在我们的个人银行系统中实现一些领域行为。假设您想要为交易(如借记卡和信用)添加审计功能,从而生成审计日志,并将它们写到某个地方。在示例中省略了细节,它们对于理解手边的概念并不重要。假设您有以下两个函数:
>generateAuditLog: (Account, Amount) => Try[String]
>write: String => Unit
这将是一个具有命令式编程模型的简单练习。但这里的想法是使用函数组合和高阶函数来实现相同的结果。下面的清单演示了实现。

函数式与响应式的领域模型(一)

需求是定义一个执行以下操作顺序的函数:
>从一个账户取款
>如果取款操作通过了,那么生成审计日志;否则,停止
>日志写入存储区

您需要使用函数式编程提供的组合器的组合性来实现这个顺序序列。理想情况下,您应该将您的领域行为建模的与上述工作流的序列是一致的。由于您到目前为止所做的函数式思考以及之前讨论的组合器,清单1.8提供了对这一逻辑的忠实描述。
下面的序列描述了清单1.8中的操作流程。如果您像我一样喜欢以图表的形式查看这些交互,请查看图1.8。
1.对于debit的调用将会产生一个Failure(在异常状态下)或者是由于修改Account成功而产生Successful。
2.一旦失败了,整个序列都会被破坏而结束掉。这里没有对失败的明确检查。所有这些样板文件都隐藏在map组合的实现背后。
3.如果debit操作生成了一个成功的的Account,那么这个值就会被嵌入flatmap组合器,并传递一个 generateAuditLog给它。
4.generateAuditLog又是一个纯函数,它会生成一个字符串作为日志行,而这个字符串最终也会被送给foreach。如果生成日志行出错了,那么你就会停止,序列也会被破坏掉。
5.foreach是用于副作用操作的组合器。管道的最后一个阶段是将日志记录写入数据库或文件系统,这必然是一个副作用。您可以使用foreach来实现它。

函数式与响应式的领域模型(一)

本节的主要内容是了解如何通过组合器组合函数,从而使领域行为得到丰富。将较小的组合器组合起来产生更大的行为和函数思维,这两方面是实现这一目标的方法。在下一节中,您将了解函数式思维如何帮助你推理代码的功能,就像数学中的函数一样。

1.4 管理副作用

到目前为止,我们已经讨论了许多纯函数的特性,这些特性使它们具有可组合性,并允许您为领域设计漂亮的抽象。如果事实如此简单,并且您为您的领域模型编写的所有函数都是如此的纯粹,那么整个软件开发行业就不会看到这么多失败的项目了。
考虑一个简单的函数,square,它将一个整数作为输入并输出平方值。但是与前一个不同,除了输出整数的平方之外,它还必须在文件系统上的特定文件中写入结果。游戏规则现在已经改变了。如果你在尝试向特定文件中写入输出时出现一个I/O异常,会发生什么情况?也许磁盘已经满了。或者在你试图创建输出文件的文件夹中没有写权限。
所有这些都是有效的关注点,您的代码需要处理所有这些异常,这意味着您的函数现在必须处理外部实体(例如文件系统),而不是它所接收到的显式输入的一部分。函数不再是纯的。它必须处理属于外部世界的外部影响,也就是所谓的side effects(副作用)。
问1.6:在本章中,您已经看到了副作用的例子。你能指出它在哪吗??
并不是说副作用是邪恶的;在设计一个重要的领域模型时,它们是您需要管理的基本组件之一。如果你仍然不相信,让我们考虑一个来自我们领域的例子并讨论你如何处理这方面的问题。打开Account(账户)的步骤之一是验证客户的身份,并进行必要的背景检查。在这个操作中,您必须与外部系统(你的银行模型之外)进行交互以获得背景检查的验证结果。接下来的列表使我们建模这一行为的第一次尝试。

函数式与响应式的领域模型(一)
函数式与响应式的领域模型(一)

openCheckingAccount所做的第一件事就是调用verifyRecord来验证客户的身份。这是一个与外部世界交互的调用,可能通过外部网关进行web service调用来进行验证。不过,这个调用可能会失败,您的代码需要处理与外部系统失败相关的异常。而这与给客户一个有效的、新打开的支票帐户(我们可以看方法名,openCheckingAccount,意思就是打开支票账户)的核心领域逻辑没有任何关系。
没有办法避免这种情况,而且当您探索领域模型实现的其他用例时候,将看到许多这样的情况。处理这个问题的最佳方法是尽可能地将副作用与纯领域逻辑分离。但是在研究如何解耦之前,让我们再次回顾一下在同一个函数中混合副作用(即不纯的函数)和纯域逻辑的缺点。表1.3列出了所有这些。

表1.3
混合副作用和领域逻辑 为什么这是坏味道?
域逻辑和副作用牵连在一起 违反了关注点分离。领域逻辑和副作用相互正交(数学里正交的意思)—这种相互纠缠违反了基本的软件工程原则。
困难的单元测试。 如果域逻辑与副作用纠缠在一起,那么单元测试就变得困难了。您需要使用mock,这会导致您的源代码中出现其他的复杂问题。
很难对域逻辑进行推理。 你不能推理出与副作用纠缠在一起的领域逻辑。
副作用不会构成和阻碍代码的模块化。 您的纠缠代码仍然是一个无法与其他函数进行组合的孤岛。

您需要重构以避免这种代码纠缠,并且您需要确保副作用与领域逻辑是分离的。下一个示例将两个操作分割为单独的函数,并确保领域逻辑仍然是一个纯函数,您可以单独进行单元测试。这正是清单1.10所做的。它引入了一个通过与外部系统交互来进行验证的新函数。完成后,它将成功的Customer实例传递给openCheckingAccount函数,该函数执行打开帐户的纯逻辑。
函数式与响应式的领域模型(一)
函数式与响应式的领域模型(一)

管理副作用是一个可以在组合领域模型和非组合模型之间产生巨大差异的区域。一个不是组装式的模型,通常会受到粘合代码和baggage of boilerplates(实在找不到合适的意思)的影响。这就变成了一个维护噩梦---难以管理和扩展。但是,在您从纯逻辑中分离出副作用之后,您可以编写提供更好的组合性的代码。在我们的样例中,openCheckingAccount再一次成为纯逻辑。下面的清单展示了如何将verifyCustomer和openCheckingAccount结合在一起,并处理 在处理副作用的代码部分中可能出现的failures故障。

函数式与响应式的领域模型(一)

答1.6:在清单1.4中我们早已见到了副作用,即用于更新共享的可变状态的领域行为debit和credit。注意,副作用是依赖于外部系统的任何东西,无论是处理文件系统还是管理全局的可变状态。

1.5 纯模型元素的优点

如果纯函数组合,你的领域模型(或者至少是有纯函数的模型的一部分)应该表现出一些数学性质,对吗?至少当我在鼓励使用纯函数来建模时,我的想法是这样的。在这一章里,你将尝试去弄清楚是否你可以论证模型的一些属性并检查其正确性,正如你在数学上所能做的那样。这就是所谓的equational reasoning(方程式推理),在后面的章节中你会看到很多例子。
考虑一下清单1.6中的定义,我们将debit和credit实现为纯函数。使用这个模型,你可以写验证你的实现的断言。这些叫做模型的properties,下面的代码片段提供了模型正确性的验证。你从在一个账户中执行一个credit(存款)和一个等量的debit(取款)的表达式开始。当然,在执行表达式之前,你还需要给你的账户以原始余额。为了验证这个property,您可以在派生的每个步骤中替换我们的实现(就像你解一个数学方程的时候),并最终到达您的结果。

函数式与响应式的领域模型(一)

你在这个推导过程中得到了什么?你证明了一个显而易见的引理,对同一账户x存款与取款相同的值,不会改变账户的初始余额。很明显,不是吗?
问1.7:对与错:方程式推理和副作用不会在一起。
当你处理函数调用时,它看起来很明显,就像你在数学中所做的那样。您将一个函数调用替换为它的实现,并期望获得相同的结果,而不管您执行这个操作的次数。让我们来考虑一下清单1.12中的例子。在这个例子中,你追踪f(5)的函数调用,通过不断地以函数的主体和对应的形参的值去替换函数调用。

函数式与响应式的领域模型(一)

正如你所看到的,通过使用替换模型,与每次你用5这个参数去调用f函数,你得到的结果是相同的。但是请注意,只有当函数是纯的且没有未管理的副作用时,替换模型才会起作用。如果函数的平方在返回值的同时写入文件,那么就会产生副作用,因为文件写操作在某些调用上可能会失败,而函数会产生一个异常。因此,在每次调用时产生相同结果的替代模型的保证都是无效的。这是另一个支持纯函数的原因。
答1.7:True.正如之前所讨论的,副作用导致了计算的不确定性,你不能用它们来做方程式推理

与替换模型相关的是我在本书中反复强调的另一个概念。像f(5)这样的表达式每次当你将值5代入函数的形参时产生相同的输出结果是如此重要,以致于它们有一个特殊的术语:引用透明的表达式。它们在函数式编程的世界中扮演着重要的角色。正如您在本节中看到的那样,您可以仅使用引用透明的表达式来进行方程式推理。
那么,我们以函数和响应性建模领域的路线图在哪里呢?以下是本节的结论:
>引用透明的表达式是纯的。
>引用透明的表达式使替换模型工作。
>替换模型有助于方程式推理。
图1.9总结了函数式编程的三大支柱。

函数式与响应式的领域模型(一)

到目前为止我们所讨论的关于函数式编程的基本原则,可以帮助我们设计一个更好的领域模型----更好的是,你可以制作你的模型。
>用函数组合的力量来构建更大的函数
>纯,在你的模型的很多部分,都可以使用引用透明的表达式组成,它具有很多优秀的优点,这个我们刚才讨论了。
>很容易推理,且可以通过方程式推理来验证许多领域行为
接下来的小节专注于一个方面,它可以使你的模型更加灵敏。用户不喜欢在查询账户余额或在银行排队打开支票账户时等待很长时间。您的软件需要在合理的时间内响应所有用户的操作,通常称为latency(延迟)。它是reactive(响应性)的属性,它使你的模型在一个限界的延迟的情况下工作。

1.6 响应性领域模型

作为一名用户,如果你在新推出的、令人眼花缭乱的酒店预订网站上请求一个报价,却需要很长时间才能做出回应,你会有何感受?相信我,并不是所有的罪魁祸首都是网络或基础设施-----应用程序的体系结构也与它有很大的关系。也许领域模型对底层资源的调用太多了,比如数据库或消息服务器,这些资源已经抑制了应用程序的吞吐量。
我们都希望我们的应用程序能够在一个可接受的时间段内响应用户的请求。而这个事件我们称之为latency(延迟),它更正式地定义为:您所请求的请求和从服务器返回的响应之间的时间周期。如果我们将延迟绑定在一个用户可接受的范围之内,那么你就实现了软件系统的responsiveness(响应性)。响应性是你的模型reactive的主要标准。但如何处理失败呢?一个卡住的系统也是不具有响应的。解决这个问题的关键是围绕故障设计您的系统,您将在1.6.2中看到更多这方面的内容。
响应式模型的主要特点是什么呢??表1.4总结了主要的标准。


表1.4
标准 介绍
对用户的交互具可响应 否则,没人会用你的系统
弹性的 这意味着可以回应失败的情况。如果您的系统在失败的情况下陷入了一个不确定的状态,那么您就无法交付一个稳定的模型。它必须通过重新启动部分应用程序模型或者向用户提供关于下一步行动的适当反馈。
伸缩性 这意味着要对不同的负载进行响应。系统可以面对负载峰值,即使在高负载的情况下,也应该能够保持延迟在一定的范围。
消息驱动 为了保持响应性和弹性,系统必须松散耦合,并通过使用异步消息传递来最小化阻塞。

1.6.1 3+1响应性模型视图

如果您仔细查看表1.4中的四个属性,您会注意到使模型具有响应性的关键是responsive(可响应)。其他三种属性只是不同形式的响应的特征。这是另一种看待响应性领域模型的方法---我们称呼它为模型的3+1视图。图1.10说明了这个视图:

函数式与响应式的领域模型(一)

PS:图1.10的英文翻译
响应式模型的3+1视图:模型的响应能力可以通过对failure(故障)、load(负载)和异步消息的响应的各种形式来实现。
问1.8:你最喜欢的在线书店在今年的大部分时间里都能工作。但它的响应时间在圣诞节和其他节日期间急剧下降。它违反了什么标准?
该模型基于弹性、可伸缩性和并行性三个因素来变得具有可响应性。每个因素都有不同的实现策略,本文后面将讨论这些策略。

1.6.2 揭穿“我的模型不会失败”的神话

我在天真的建模者中看到的一个常见的误解是他们的模型不会失败----他们认为他们处理可能出现的异常。实际情况是,不管您认为您在模型中管理异常的程度如何,失败都会发生。模型的规模越大,组件失败的可能性就越大。磁盘失败,内存失败,网络组建的失败,还有其他基础设施的失败---简而言之,失败会发生在你完全无法控制的情况下。
针对失败而设计。在开发包含许多协作组件的大型服务时,这是一个核心概念。这些组件将会失败,并且会经常失败。这些组件并不总是相互协作和失败。一旦服务扩展到超过10,000台服务器和50,000个磁盘,故障将会在一天内多次出现。
-----James Hamilton
确实。响应性模型的一个主要教诲就是围绕失败进行设计,并提高模型的整体弹性。在表1.4提到了对失败的可响应性;只有当您的模型有能力处理来自您的应用程序内部的故障,以及来自其他外部源的故障时,才能实现这一点。这并不意味着让您使用大量的异常处理逻辑来污染您的领域模型。基本的思想是接受失败是必然发生的,并实现策略来明确地处理它们,就像它们发生在您的系统的不同部分一样。
考虑一个例子:假设一个用户要求计算她在银行的各种账户的证券投资组合(我觉得的应该是表达基金的意思)。在计算过程中,其中一个步骤失败,可能是因为持有某一特定帐户的余额的服务器是不可访问的。你应该如何处理你程序中的这种失败?至少有两种解决方案:
>尝试在计算投证券资组合的应用程序代码中包含异常处理逻辑。但是对于任何访问后端服务器的api,都可能发生这种情况!想象一下在所有这些地方复制相同的代码来进行异常处理。其结果是一场软件工程灾难-----我们称之为处理separation of concerns(分离关注点)的失败版本。我们将领域逻辑与异常处理代码合并在一起,最终导致后者污染了前者。显然这并不可取。
>有一个独立的模块来处理故障。所有的失败都被委托给这个模块,这个模块负责根据用户定义的策略来处理它们。这种方法使您的域模型保持干净,并从业务逻辑中分离出故障处理。
图1.11总结了这两种方法.
您一定在想,如果所有的故障都是通过一个模块处理的,那么这是否会成为整个模型的可伸缩性瓶颈?如何确保失败处理与处理其他域逻辑的其他模块一样具有可伸缩性?
答1.8:我确定你回答的是正确的。它就是缺乏随着负载的增长而保持可响应的能力----这意味着不要随着负载的增加而扩大规模。因此,它违反了可伸缩性标准。
解决方案在于故障处理模块本身的设计。这并不是说您必须在整个应用程序中都有一个用于故障处理的单一模块。其思想是要有集中的处理程序。处理程序的数量将取决于应用程序的整体模块性。在前面的例子中,您可能有一个模块来处理投资组合计算的所有失败。

函数式与响应式的领域模型(一)

PS:图1.11英文翻译
在域模型中处理失败的策略。为处理故障设计一个单独的模块(几乎)总是一个更好的选择。

1.6.3 有弹性和消息驱动

当我们讨论伸缩性时,我们的意思是系统必须适应不同的负载。他有两种方式---当负载增加时,它会扩大规模,当负载减少时它会收缩。重要的是能够在平静的时期减少负载,以确保资源和运营的经济。
当系统上的负载增加时,比如在节假日期间,会出现突然的延迟峰值,这可能会违反客户端的服务水平协议(SLA)约束。伸缩意味着你的系统应该能够伸缩以适应不同程度的延迟。
一种方式是通过减少模型组件之间的耦合,可以使系统具有伸缩性。使用异步消息边界作为通信手段的松散连接架构是实现这一目标的一种方式。这正是响应性模型所鼓励的----非阻塞通信,以及使用没有任何共享状态的不可变消息进行交互的组件。当您的组件以异步消息交互时,您就拥有适当的隔离级别,因为你已经有条件去拥有位置透明,并发模型和编程语言本身。
本书着重于使用actor计算模型的消息传递系统。actor提供了相当高的并发性构造,帮助您以松散耦合的模块(它们也可以是限界上下文)来组织您的模型,这些模块使用异步消息进行交互。我们将讨论一个经过良好设计的actor系统如何能够为您的模型提供弹性伸缩,帮助您通过正确的处理背压来扩展与收缩,并为您的系统提供全面的响应能力。
当我说消息驱动的时候,我是故意的。事件也可以被认为是封装了领域概念的消息。当我说一个debit(取款)信息时,这是一个在特定帐户上引入debit(取款)操作的事件。debit(取款)是一个领域概念。下一节将讨论领域事件如何在您的响应式模型中形成组合行为的头等结构。

1.7 事件驱动编程

让我们先从个人银行业务领域的一个例子开始,稍微了解一下事件是什么。您将从一个模型开始,该模型中所有的函数调用都是同步的、阻塞和执行是完全连续的。您将研究该模型的缺点,并尝试通过引入事件驱动的体系结构来提高响应能力。
考虑下面的功能,您作为一个客户,要求从银行获得所有所持资产的投资组合清单。以下是生成清单所需要获取、计算和聚合的一些典型的行项:
>持有的通用货币
>股份
>贷款信息
>退休基金估值
这些项目的一个示例实现可以有如下清单所示的结构

函数式与响应式的领域模型(一)

如清单1.13所示,它是一个连续的代码,所有被执行的代码都会阻塞执行的主线程。只有当序列中的前一个函数完成并使其结果在执行的主线程中可用时,才执行下一个函数。结果是,计算的总延迟是所有单个函数的延迟之和,如图1.12所示。
这种情况可能会损害响应能力,因为某些功能可能正在访问数据库或其他基础设施,而这些功能可能需要相当长的时间才能响应。作为一名用户,您不会想要盯着屏幕,因为后端基础设施需要大量的连续计算。正如你很快会看到的,事件为这一令人不安的体验提供了一个缓解的机会。

函数式与响应式的领域模型(一)

前面的代码无法满足它的承诺的另一方面原因是,它的结构硬连接到本地执行模型(作者在这里有注释:当执行模型是连续的和阻塞的,就像在本例中一样,您可以进行远程过程调用,并向用户呈现本地执行模型的facade。但是,这种方法从来没有扩展过,而且被发现是分布式计算模型谬误的受害者。详情参考Arnon Rotem-Gal-Oz 写的《Fallacies of Distributed Computing Explained》)。当您有网络通信时候,并且你的投资组合的各种组件需要从从一个使用多个服务而不是单个机器的计算机集群中获取,那么这就会很麻烦了。这是另一个基于事件的模型地址作为解决方案的领域。
让我们把前面的代码翻出来,以一种将处理分布在多个并行计算单元的方式组织起来,保持主线程作为一个协调器的角色(请参阅下面的清单)。

函数式与响应式的领域模型(一)

在这里,每个单独的函数不再保证在将控制权返回给主线程之前,它将返回一个Balance。相反,它返回一个future,它是计算的占位符。一个future承诺的是当函数的计算完成时,它最终会给你一个Balance.
但作为一种直接的效果,它不会阻塞主线程。主线程可以继续执行其他任务,而单独的函数可以使用另一个执行线程来完成它的承诺。最终的结果是,所有单独的函数都在各自的线程中执行,只留下主线程作为结果的一个协调器和聚合器。而这就是调用generatePortfolio所发生的事情-----它会收集所有到达的数据,并计算出投资组合的清单。
如果这是一个昂贵的操作,你也可以将这个计算委托给Future(就像清单1.14中那样)。作为一个机敏的读者,您一定已经认识到,在这个场景中,计算的总延迟是执行单个Balance计算函数所涉及的所有延迟的最大值,再加上计算 generatePortfolio 的延迟。但是,由于您还将整个计算功能委托给Future,因此执行的主线程可以*地为其他请求提供服务,而不必等待这些函数的完成。注意,您已经获得了响应性的提高(作者注:通过使用事件和异步编程模型讨论了这样的性能改进,但是事实是并不是所有计算都可以通过异步和非阻塞获得受益。cpu密集型的操作通常可以通过阻塞来获益,因为它们可以利用缓存的一致性和缺少调度器的开销。)!
清单1.14中的通过Future进行计算改变了清单1.13中的阻塞,顺序的代码,使其变为了异步和非阻塞的代码。当计算返回Future时,它相当于向调用线程发送一个事件,该线程并向调用线程说“当我完成时,我将使结果可用”。当计算完成时,调用线程会得到一个指示结果可用性的事件。然后,它可以从它先前注册的回调中获取结果。您现在看到的是事件驱动编程的一种形式,这些事件通过Future的抽象被隐式地发送给调用线程。

1.7.1 事件和命令

我相信您现在可以放心地将事件看作是支持非阻塞编程模型的小消息。您看到了future如何与主执行线程的交互,从而向我们的领域模型交付并行性。当你工作在完全重要的领域模型上时,你将会遇到很多这样的情景,而事件则帮助你去实现模型的可响应性。
我们来考虑一个事件,Debit,它会将钱从你的账户中取出。现在你的账户余额会被改变。如果您在数据库中维护着balance,事件将触发更新,从而改变balance。如果您还维护了聚合的内存副本,那么您还需要用目前账户中的新的余额来更新它。
一旦你的账户被取款了,你就会在你的智能手机上收到你的银行的一条信息,上面写着,通过现金存取,从你的账户取走了金额为x的现金。让我们把这个消息命名为- DebitOccurred 。事实上,这也是一个事件。
你认为这两种类型的交互在模型中有什么不同吗?表1.5解释了他们的不同:

表1.5 两种事件的解释。因为不同的特点,有时候Debit被称为命令,而 DebitOccurred则被称为事件。
Debit DebitOccurred
对系统的全局状态有影响。相当于对聚合的写操作,在某种意义上它改变了一个帐户的余额 只是向感兴趣的订阅者发送的通知—-在这种情况下,感兴趣的订阅者即是帐户持有人
这是系统中的一个对象在系统中发送的消息之前发送的消息 是在效果发生后由系统发送的消息
作为一个突变消息,通常由系统的一个处理程序处理 可以由多个参与方来处理,每个响应对消息的响应都不同
如果违反了某些约束,会失败 不能失败因为相关的效应已经在系统中发生了

我们称Debit为一个命令, DebitOccurred是一个事件。它们都是由模型生成和处理的消息,但是它们在语义上的差别是很微妙的。请注意这些差异,因为当我们谈论领域模型架构时,您将会以非常不同的方式处理命令和事件。

1.7.2 领域事件

事件驱动的编程模型使事件成为一个重要的架构元素。事件触发领域逻辑,并参与领域模型内的各种交互。在更一般的术语中,事件是通知的一种形式。在前面的小节中,您看到了两种类型的通知,它们的语义略有不同:命令和事件。我们将经常使用术语“event(事件)”来指代这两种类型的通知,并且只有在需要以不同的方式处理命令时才破例。
在前面的小节中,您看到了一个Debit(借记,通俗说就是取款)事件的例子,它触发了您的银行帐户的取款操作,以及一个DebitOccurred的事件,它通知有兴趣的各方,某个帐户发生了一笔取款操作。您已经根据在领域模型中执行的操作来命名这些事件;这些事件说明了领域的语言。它们被称为domain events(领域事件)。
事件的一个重要特征是它们是不可变的。这是很直观的,因为你已经看到,事件是系统中已经发生的事情。那么,你怎么能改变过去发生的事情呢?
作为银行的客户,鲍勃在1989年3月1日时候,住在地址A处。2010年6月6日,他搬到了另一个地址B。处理这种情况的一种常见方法是在customer实体中更新Bob的地址。但是,当您谈论事件时,如何发出一个更新语句,并在客户记录中更改Bob的地址?他住在地址B的事实并没有使他曾经住在地址a的事实无效。有些人可能会说,数据库服务器维护了一个日志,它仍然表明,在过去的某个时候,Bob曾在地址A中生活过。但是,毕竟,数据库日志不是我们模型的一部分,而Bob在某个时间点住在某个地址上的事实是我们正在设计的领域模型的语义的一部分。让我们看看我们领域模型中发生的事件序列;表1.6显示了Bob的时间轴变化的历史。

表1.6
时间点 操作 时间
T0 Bob开户了账户,地址属性是A。 触发一个事件 AddressChanged(Bob, _, A, T0)
T1 Bob的地址变为了B 触发一个事件 AddressChanged(Bob, A, B, T1)

这里我们讨论的是一种与标准关系型数据库模型不同的建模方法。我们讨论的是不能更改的事件,但是它们被应用于领域模型以到达当前的快照。在前面的例子中,您可以在银行的Bob的记录中应用最后一个事件来获取他当前的地址。然而,你不会丢失他曾经住在一个不同的地址的信息。这意味着您不仅拥有该模型的当前快照,而且还拥有生成该快照的整个历史记录的日志。图1.13清楚地说明了这一点。
正如前面提到的,领域事件在架构模型中具有重要的作用,它可以伸缩并响应。到目前为止,我已经给你们简单介绍了它们是什么以及它们如何帮助你们建立起自演变以来的整个历史的模型。这样的域模型称为自跟踪模型,因为领域事件日志使我们的模型在任何时间都可以跟踪。
因此,领域事件是反应领域模型的重要参与者,当您构建自己的模型时,您必须给予它们应有的重要性。Jonas Bonér在他的一个事件驱动架构的演讲中给出了一个很好的总结。他解释说,领域事件是:

唯一可识别的类型—对于每个事件,您都在模型中有一个类型。
独立的行为—每个域事件都包含与系统中刚刚发生的变更相关的所有信息
对消费者可观察的—-事件意味着被模型的下游组件,为进一步的操作而被消费。
时间相关的—它可能是领域事件最重要的特征。时间的单调性被构建在事件流中。
函数式与响应式的领域模型(一)

如果领域模型可以通过应用所有的领域事件来构造,从时间t0开始,那么您的整个模型可以归结为以下的数学等式:
M(t n ) = ∑(all events from time t 0 till t n )
M(t n)是模型的状态,表示为从t0开始的所有事件的和。
在后面的章节中,您将看到这个等式如何映射到函数式编程中的等价概念。

1.8 Functional meets reactive

您已经到达了第1章的末尾,本章介绍了使用函数式思考和响应式处理实现域模型的基本概念。为什么我们要一起讨论这两种模式呢?他们是否比你今天在编程中使用的技术更匹配?让我们回顾一下本章所探讨的一些概念。
您实现并交付给客户的任何应用程序都需要响应。正如您所看到的,响应性模型对故障、变化负载和并发性提供了响应。为了使您的模型可以对失败响应,您需要在体系结构中构建弹性。你可以通过对模型进行适当的模块化并确保一个组件的失败不会导致整个系统崩溃。故障需要单独处理,而不是与领域逻辑耦合。即使面对不同的负载,保持模型具有响应性的一种方法是使其成为事件驱动的----执行的主线从未被阻塞。因此用户请求从未停止过,即使有些请求可能正在执行一些重负荷的处理以服务于其他请求。事件是一种小型的、不可变的抽象,带有执行处理的目的并通过事件循环(event loop)处理。循环得到的每个事件都被派生给一个执行实际任务的事件处理程序。
响应式模型意味着代码的良好模块化,这样各种事件处理程序就可以独立运行,并且可以执行领域行为。而且,只有在它们之间没有(或最小)共享状态的情况下,才可以独立地运行进程。函数式编程从一开始就鼓励这种实践。纯粹的功能设计和纯粹逻辑的解耦,是函数性思维最重要的两个基本原则。这就是函数与响应性的化学反应。纯粹的、引用透明的模块作为事件处理程序可以并发运行来执行领域逻辑,使您的模型具有响应性和可伸缩性。纯粹的逻辑尺度和副作用是没有的,当你把函数思考和响应性建模结合在一起时,你就会得到很多东西。

1.9 总结

本章以函数和响应性范例开始我们的领域建模之旅。你刚刚了解了两种范式的一些优点。函数式编程基于函数组合:通过将函数组合成语言的一流构件来构建抽象。您可以使用响应性原则使应用程序具有可响应性。以下是这一章的主要内容:
>避免在您的模型内共享可变状态---共享的可变状态难以管理,并导致在语义上的不确定性,使并发变得困难。
>引用透明----函数式编程为您提供了设计引用透明(纯)模型组件的能力。当你的大多数模型行为都是建立在纯粹的函数基础上的,你就会得到组合性的力量;您可以通过组合构建更大的函数。
>有机增长----有了函数设计和思维,你的模型就会有机地成长。因为它是纯的,你的模型可以用数学方法处理,你可以推理它。
>专注于核心领域---当您通过使用域驱动设计的原则构建模型时,您就有了实体、值对象和服务,这些服务围绕诸如存储库和工厂之类的模式进行组织。你可以让这些工件都发挥作用。违反纯粹和引用透明原则的原则是例外,但你必须能够证明这样做的理由。可变性使代码的某些部分运行得更快,但同时又难以理解。在您的DDD代码的每一层都要努力实现不可变性----functional meets DDD。
>函数使得响应式变得更容易---纯函数是响应式建模的理想候选对象,因为您可以*地将它们分布在一个并行的环境中,而不需要管理可变的共享状态----functional meets reactive。
>针对失败进行设计----在你的模型中,永远不要假设事情不会失败。总是针对失败进行设计,并将故障作为单独的关注点来管理,而不使用业务逻辑代码耦合异常处理程序。
>基于事件的建模补充了函数模型---基于事件的编程从模型的“how”中描述了“what”(我知道挺拗口的,附上英文:Event-based programming delineates the “what” from the “how” of your model.)。这也是函数式编程所鼓励的。事件是指定您想要做什么的小消息,事件处理程序则描述了“how”部分。难怪函数式编程和事件驱动编程在一起很好。