函数响应式领域建模(txt+pdf+epub+mobi电子书下载)


发布时间:2020-06-07 12:13:10

点击下载

作者:李源

出版社:电子工业出版社有限公司

格式: AZW3, DOCX, EPUB, MOBI, PDF, TXT

函数响应式领域建模

函数响应式领域建模试读:

推荐序

开发人员正淹没在各种错综复杂的问题中,需要借助多核处理器以及分布式基础架构的优势,来应对产生数据越来越多的高要求用户规模的迅猛增长,以确保更低的延迟以及更高的吞吐率。所以开发人员不得不在消费者日益苛刻的紧张截止时间前按时交付。

开发人员的工作从来没有轻松过。为了能保持多产的同时又能享受工作,需要采用合适的工具集——这些工具可以通过优化资源的使用来管理日益增长的复杂性以及需求。通常,并不是简单地追逐最新、最炫的东西——尽管这很诱人。所以必须要回顾总结,从过去艰难获胜的经验中学习,看是否可以将其应用到今天的场景以及挑战中。我认为开发人员开发的那些非常有用的工具中所包含的领域驱动设计(domain-driven design,DDD)、函数式编程(FP)以及响应式原则,都可以帮助我们管理复杂事务的某个方面。

● 领域复杂性:领域驱动设计帮助我们挖掘并理解领域的不同特性与语义。通过跟利益相关方用他们的语言进行沟通,DDD可以更容易地创建可扩展的领域模型来映射真实世界,同时允许持续的变化。

● 解决方案复杂性:函数式编程可以帮助我们保持合理性及可组合性。通过可重用的纯函数并使用稳定(不可变)值,函数式编程提供了一个伟大的工具集,通过不会“撒谎”的代码来得出运行时间、并发性以及抽象过程。

● 系统复杂性:正如在The Reactive Manifesto(http://www.reactivemanifesto.org)中所定义的,响应式原则能帮助我们管理日益复杂的世界,包括多核处理器、云计算、移动设备以及物联网。在这里,所有新系统本质上都是分布式系统。要运作这个世界是非常困难而且很有挑战的,但同样,也拥有很多有趣的新的机会。这种变化迫使我们的行业去反思过去一些围绕系统架构以及设计方面的最佳实践。

我非常喜欢阅读这本书,它完全体现了我在过去十几年的自身经历。我从OO实习生开始——白天埋头于C++和Java,而晚上阅读经[1]典的Gang of Four 。2006年我开始阅读Eric Evan关于领域驱动建模

[2]的书,它对我或多或少有所启发。然后我就变成一个DDD狂热爱好[3]者,在所有可能的地方去应用它。多年后,我又开始使用Erlang,[4]然后是Scala,它们都让我再次感受到了函数式编程的魅力并深深地爱上了它。我在大学期间学过函数式编程,但当时并没有真正意识到它的威力。在这段时间里,我开始对Java在并发性、适应性以及可伸缩性方面的“最佳实践”逐渐失去信仰。在Erlang方式,特别是[5]actor模型(我认为这是一个更好的做事方式)的指引下,我开始了Akka项目,我相信这会有助于将响应式原则带入主流。

这本书之所以能吸引我是因为它设立了一个更加宏大的目标,将3个完全不同的工具(领域驱动设计、函数式编程以及响应式原则)用可实践的方式整合到了一起。它教会你诸如边界上下文、领域事件、[6]函数、monad、applicative、future、actor、流以及CQRS等内容是如何使复杂性保持可控的。如果内心不够强大,那么这本书将不适合你,阅读它很费劲。但如果花上数小时,你就会收获一些基础概念。亲爱的读者,幸运的你己经迈出了第一步,接下来所需要做的就是继续读下去。JONAS BONÉR Lightbend创始人兼CFOAkka创始人[1]Erich Gamma、Richard Helm、Ralph Johnson与John Vlissides所著的Design Patterns: Elements of Reusable Object-Oriented Software(Addison-Wesley,1994年),也是我们常说的Gang of Four。[2]Eric Evans所著的Domain-Driven Design:Tackling Complexity in the Heart of Software(Addison-Wesley,2003年)。[3]一种面向并发的编程语言,由瑞典电信设备制造商爱立信所辖的CS-Lab开发。——译者注[4]一种类似Java的多范式编程语言,设计初衷是实现可伸缩的语言,并集成面向对象编程和函数式编程的各种特性。——译者注[5]https://en.wikipedia.org/wiki/Actor_model。[6]command query responsibility segregation,命令查询责任分离。——译者注序

在2014年夏天,Manning出版社希望出版DSLs in Action(https://www.manning.com/books/dsls-in-action)的升级版本,因为DSL的所有新特性都围绕编程语言的设计和实现。巧合的是正好在那个时间,我用函数模式对一个复杂的领域模型成功地进行了重构。

跟一群刚毕业进入Scala函数式编程世界的软件工程师们一起,我将域行为建模为纯粹的函数,将域对象设计为代数数据类型,并开始意识到代数API设计的价值。团队的每个成员人手一本Paul Chiusano和Rúnar Bjarnason刚完成的Functional Programming in Scala(中文版为《Scalo函数式编程》,由电子出版社出版)。

我们的域模型非常复杂,实现严格遵守Eric Evans在他的著作Domain-Driven Design:Tackling Complexity in the Heart of Software(Addison-Wesley,2003年)中所阐述的领域驱动设计(DDD)的原则。不过我们没有用面向对象的方式,而是决定采用函数式编程。一切的开始都像是一个实验,但在最后证明这是一次非常成功并且令人满意的经历。现在当我回头看时,发现DDD的内容与软件工程的通用规则非常协调一致。因此也不用担心函数式、领域驱动设计会显得像是领域建模的典型范例。

这本书是我们成功运用函数式编程进行领域模型开发的证据。我决定跟读者分享我们遵守的实践、采用的原则,以及在实现中所使用的Scala风格。Manning出版社完全同意这个想法并决定继续该项目。

不管你的领域模型是什么样的,定义实现成功的一个关键标准是应用的响应能力。没有一个用户喜欢盯着屏幕上的等待光标,根据我们的经验来看,这通常是因为架构师非必要地阻塞了主线程的执行。需要花费时间执行的昂贵的操作应该用异步的方式来执行,把主线程空出来给其他用户行为。The Reactive Manifesto(www.reactivemanifesto.org)中定义了建模所需要使用的特性,以便保证应用程序是非阻塞、响应及时的,并避免巨大延迟带来的恶劣影响。这也是我要在书中写的另一个方面。在经过与Manning团队多次友好的商讨后,我们决定在这本书中将函数与响应式编程结合起来。

于是本书就诞生了。通过这个项目,我收获了巨大的乐趣,也希望读者能有类似的体验。我收到了无数读者、评审者、良好祝愿者们的留言,他们陪着我一起提升了这本书的质量。我也非常感谢来自Manning出版社经验丰富的编辑以及评审者团队的巨大支持。

致谢

我要感谢很多人,他们直接或间接地参与了这本书的创作。

首先,我要感谢Martin Odersky,Scala编程语言的创建者,我用Scala完成了所有函数响应式领域建模的案例。同时也非常感谢你建立了Scalaz,这个有趣的库使我们在用Scala语言进行纯函数编程时充满乐趣。

Twitter是一个非常酷的沟通方式,承载了各种各样的讨论。我在上面和一些牛人就函数式编程有过很多非常激烈的讨论。感谢每一位牛人,是你们促使我完成了这本书。

感谢所有的评审者:Barry Alexander、Cosimo Attanasi、Daniel Garcia、Jan Nonnen、Jason Goodwin、Jaume Valls、Jean-François Morin、John G.Schwitz、Ken Fricklas、Lukasz Kupka、Michael Hamrah、Othman Doghri、Rintcius Blok、Robert Miller、Saleem Shafi、Tarek Nabil,以及William E.Wheeler。时间可能是我们拥有的最宝贵的资源,我非常感谢他们愿意在这本书上花费时间,每个评审者都给了我很棒的建议,极大地提升了这本书的质量。[1]

感谢所有购买了MEAP的读者,在作者在线论坛里的定期沟通,一直鼓励着我完成这本书。特别要感谢Arya Irani,她贡献的一个pull请求帮助我更新了monad代码(从基于Scalaz 7.1到7.2)。同样要特别感谢Thomas Lockney和Charles Feduke,他们对每个不同的MEAP版本做了彻底的技术评审。

我还要感谢Manning出版社再次信任我。在我写第一本书的时候,我们有过非常美好的合作,而再次合作甚至更有乐趣。我要感谢以下Manning员工的杰出工作。

● 感谢Michael Stephens和Christina Rudloff促使我启动这个项目。

● 感谢Jennifer Stout在10个章节的漫长过程中不屈不挠地纠正了我所有的错误。

● 感谢Alain Gouniot在整个过程中提供了深入的技术评审。

● 感谢Gandace Gilhooley与Ana Romac帮助推动这本书。

● 感谢Mary Piergies、Kevin Sullivan、Maureen Spencer,以及所有幕后工作人员(包括Sharon Wilkey、Alyson Brener、April Milne,以及Dennis Dalinnik),他们帮助我把一个粗糙的草稿变成一本真正的书。

感谢Jonas Bonér为我的书写序。我很荣幸,我与Jonas己经相识了很长时间,他也是我很多软件开发项目的重要灵感来源。

最后,我要感谢我的妻子、母亲以及我的儿子Aarush,他们给我提供了最完美的“生态环境”,在那里,写一本关于函数式编程的书这种创造性任务才有可能完成。[1]Manning Early Access Program,在书的写作过程中,你可以阅读到刚完成的章节,并第一时间得到最终版本的电子版。——译者注1  函数式领域建模:介绍本章包括■ 领域模型以及领域驱动设计■ 函数式纯领域模型的好处■ 针对提升响应能力的响应式建模■ 当函数式遇上响应式假设你正在使用一个大型在线零售商店的门户网站进行购物。在添加完所有要买的商品后,却发现购物车没有记录这些商品的信息,这时会有什么样的感受?或者说在圣诞节前的一个礼拜,你想确认一件商品的价格,但经过无比漫长的等待后才得到响应,你会喜欢这种购物体验吗?这两种场景中应用程序的响应能力都很差。第一个场景描绘了失败响应的缺失——因为一个后端资源的不可用导致整个购物车失效。第二个场景描绘了针对不同负载的响应缺失。可能在节假日这种旺季产生了过度的系统负载,从而使得用户的查询请求响应得无比缓慢。这两种情况都会让用户感到极度沮丧。对任何你所开发的应用来说,核心概念就是领域模型,它是业务如何进行工作的体现。对于一个银行系统来说,系统功能通常由银行、账号、货币、交易以及报表等实体组成,它们彼此协同工作并给用户传递一个良好的银行业务体验。模型的责任就是确保用户在使用系统时有一个良好的用户体验。实现一个领域模型,就是将业务流程翻译成软件。这时需要尝试找到一种方式使得软件与原有业务流程尽可能地类似。为了达到这个目的,会在设计、开发和实现中采用某些技术以及某些范例。在本书中,你会看到如何将函数式编程(FP)与响应式建模结合起来,去实现有良好响应性和伸缩性的模型,同时还要方便管理和维护。本章将介绍这两种范式的基础概念,以及两者如何共同实现模型的响应性。如果你正在设计开发系统,并且使用某些编程技术,本书能帮助你扩大视野、看到一些新技术,使模型更具有弹性。如果你管理的团队正在开发复杂的系统,使用函数响应式编程会带来很多好处,同时也可以交付更可靠的软件给用户。本书使用Scala作为实现语言,但提及的基本概念同样适用于当今行业中的其他语言。在进入领域建模的核心主题之前,图1.1展示了本章为实现领域模型以及函数响应式编程所提供的准备工作。这样可以更容易地了解这些基本概念,到本章结束,就可以用函数式和响应式范式的术语来提炼领域建模技术。图1.1 建立函数响应式领域模型。1.1 什么是领域模型

你最近一次是什么时候从ATM中取现金、在银行账户中存钱、用在线银行查询工资是否己经转入活期账户,或者向银行索要一份投资报表?所有这些楷体字都与个人银行业务有关,我们称为个人银行业务的领域。领域这个词在这里意味着在业务中的某个具体范围。开发一个系统来自动化银行业务的活动,就是在建立个人银行业务的模型。所设计的抽象、所实现的行为,以及所建立的人机交互界面,这些都反映了个人银行业务,它们在一起组成了领域的模型。

用更正式的语言就是,领域模型是问题领域不同实体之间关系的蓝图以及其他一些重要的细节,比如:

● 属于领域的对象:在银行领域里,会有诸如银行、账号、交易事务等对象。

● 对象之间互动所展示出来的行为:比如,在银行系统账户的借方中记入一笔款项,同时生成一个报表给客户。这些就是领域对象之间的典型交互。

● 领域的语言:对个人银行进行建模时,诸如记入借方、信用、投资组合等术语,或者如“从账户1转账100美元到账户2”之类的短语,将会无处不在,然后组成该领域的词汇表。

● 模型操作内的上下文:其中包含了问题领域相关的假设与约束,并且自动适用于你所开发的软件模型。比如只能为一个活人或实体开放新的银行账号,这一条就可以成为个人银行领域模型的一个上下文约束。

在其他建模练习中,实现一个领域模型最主要的挑战是管理它的复杂度。有些复杂度是问题本身所固有的,无法回避,这些被称为系统的基本复杂度。比如,从银行申请个人借贷,需要根据个人信息决定合适的额度,这就有一个固定的复杂度,由领域的核心业务规则所决定。以上就是一个基本复杂度,它在解决方案模型里是不能回避的。但有些复杂度是由解决方案本身所引入的,比如当实现一个新的银行业务解决方案时引入了额外的负载,如额外的批量处理。这也被称为模型的附带复杂度。

高效模型的实现方式有一个基本原则就是减少附带复杂度的数量。通常情况下,可以使用技术手段来降低附带复杂度的数量,这有助于更好地管理复杂性。比如,如果你的技术可以使模型更好地结构化,那最终实现就不会是一整块庞大而难以管理的软件。它会被分解成多个小型组件,每一个都会结合自身的上下文以及假设来开展工作。图1.2描绘了这样一个系统——为了方便只显示了两个组件。但你要知道:对一个模块化的系统,每个组件在功能性上是独立的,而且只通过明确定义的协议与其他组件进行交互。相对于面对一个整体系统,通过这些小模块,可以更好地管理复杂度。图1.2 领域模型总览与外部上下文,包括个人银行领域术语。每个小模块都有其自身的假设和业务规则,因此相对于整个大系统来说,这些小模块更容易管理。但需要使用明确定义的协议使小模块之间的通信保持在最小值。

本书会介绍如何使用函数式编程规则并将它们和响应式设计结合起来,进而使领域模型的实现更加容易创建、维护和使用。1.2 领域驱动设计介绍

在前面章节介绍领域模型时,使用了一些术语,诸如银行、账号、记入借方、记入贷方,等等。所有这些术语都跟个人银行领域有关,而且很容易地表达了它们在业务功能中执行的规则。在为个人银行业务实现领域模型时,如果使用与业务相关的专业术语,对用户来说无疑会更容易理解。比如,模型里可以有一个名为账户的实体,它实现所有行为的变动,类似校验、存款,或者成为货币交易账号。这就是从问题领域(业务)到解决领域(实现)的直接映射。

在实现一个领域模型时,对领域的理解是至关重要的。只有掌握不同实体在现实世界中是如何工作的,才能知道如何在解决方案中实现它们。理解领域并将核心特性抽象成模型的形式,就是我们所说的领域驱动设计(DDD)。Eric Evans在Domain-Driven Design:Tackling Complexity in the Heart of Software(Addison-Wesley Professional,2003年)一书中为这个主题提供了绝佳的处理手段。1.2.1 边界上下文

1.1节描述了模型的模块以及模块化给领域模型带来的一些好处。任何复杂的领域模型都是一系列小模型的集合,每个小模型都有它自己的数据和领域术语表。在领域驱动设计的世界里,术语边界上下文就描述了在整个模型里的某个小模型。于是,完成的领域模型就是多个边界上下文的集合。让我们看一个银行业务系统:投资组合管理系统,税收与调整报告和定期存款管理就可以被拆分成不同的边界上下文。边界上下文通常在一个非常高水平的粒度上描述系统里某个完整的功能域。

但是,在完整领域模型中拥有多个边界上下文时,它们之间如何进行通信?要记得,每个边界上下文都是独立的模块,但可以跟其他所有边界上下文发生交互。当设计模型时,这些通信都被实现为特定的服务或接口集合。后面会看到这种实现。基本思路是将这些交互控制在尽可能小的数量,这样每个边界上下文就能充分地内聚,同时降低与其他边界上下文的耦合。

在下一节中会看到每个边界上下文的内容有什么,并学习到一些领域建模的基本要素,这些将组成模型的核心。1.2.2 领域模型元素

不同类型的抽象定义了领域模型。如果有人要我们列出一些个人银行领域的元素,我们就有机会自己命名这些内容,比如银行和账户,账户类型诸如核验、存款和货币市场,以及交易类型,如记入贷方、记入借方。但很快就会发现很多元素在创建方法上是非常相似的,通过业务管道处理,并最终从系统中排出。我们来看一个例子,如图1.3所示,这是一个客户账户的生命周期。每个客户账户都会被银行创建出来并传递一系列状态,以此作为银行、客户或其他外部系统的行为的结果。图1.3 客户账户的生命周期状态,从一个状态转移到另一个状态依赖于在前一个状态上执行的行为。

每个账户都有一个身份,在账户的整个生命周期内,系统会对它进行管理,这类元素被称为实体。对于一个账户来说,它的身份就是它的账户号码。账户的很多属性在系统的生命周期内都有可能发生变化,但开户时生成并分配给账户的号码总是确定的。两个同时生成并有相同属性的账户被认为是不同的实体,因为账户号码是不同的。

每个账户可能会有一个地址——账户持有人的居住地址。一个地址由其包含的值唯一定义。一旦改变地址的任意属性,它就变成了一个不同的地址。我们能否从语义上区别账户和地址的不同之处?一个地址没有任何身份,它完全取决于它所包含的值。我们称这种对象为值对象。实体与值对象的另一个区别是值对象是不可变的——在创建值对象之后,不能在不改变对象的情况下改变它的内容。

实体与值对象的区别是领域建模中一个最基本的概念,我们必须对它有清楚的认知。当谈到一个账户时,指的是一个特定的账户实例,包含一个账户号码、持有人姓名以及其他属性。这里某些属性组合起来形成这个账户的独特身份标识。例如,账户号码就是这个账户的身份属性,哪怕有两个有相同属性值的账户(比如持有人姓名或开户时间),但如果账户号码不同,那它们就是两个不同的账户。一个账户就是一个有特定身份的实体,但对于一个地址只需要考虑值的部分。因此在模型中,可以选择对一个特定地址只生成一个实例,然后在所有居住在这个地址的持有人中间共享它。这只跟值有关系。一个实体的某些属性的值可以改变,但是身份标识不会改变。比如可以改变一个账户的地址,但它还是会指向相同的账户。但不能改变值对象的值,否则它就变成了另外一个值对象。所以值对象是不可变的。实体和值对象的“不变性”

在本章后面讨论实现方式时,对实体和值对象的不变性会有不同的看法。在函数式编程中,我们的目标是建模应该尽可能地不可变——应该将实体尽可能建模为不可变对象。所以,区分实体和值对象的最佳方法是记住实体有一个不变的身份标识,而值对象拥有一个不变的值。值对象是语义上的不可变,而实体是语义上的可变,但需要用不可变的结构来实现它。

用不可变结构建模语义上的可变实体是否有缺点?让我们直面它——可变引用拥有更好的性能。在很多场景下,使用不可变数据结构会导致更多的对象,特别是当一个领域实体频繁变化时。不过,在本章以及后面的章节里可以看到,可变数据结构在面对并发操作时,会导致薄弱的代码基础,同时还非常难以理解。所以通常的建议是,从不可变数据结构开始,如果需要某些代码获得更好的性能,再使用可变结构。但要确保客户端API不会看到变化,通过一个引用包装函[1]数来封装易变部分。

任何领域模型的核心都是不同领域元素之间行为或交互的集合。相较于单独的实体或值对象,这些行为位于一个更高级别的粒度。我们认为它们是模型给出的概念服务。让我们看一个银行系统的例子。比如一个用户去银行或用ATM机在两个账户之间进行转账。这个行为会导致一个账户中减少一笔款项并在另一个账户中存入这笔款项,这也反映为不同账户里的余额变化。而且必须做验证,比如,账户是否处于激活状态,源账户是否有足够的资金进行转账。在每个这样的交互中,会涉及非常多的领域元素,包括实体和值对象。在DDD中,将这种行为的集合建模为一个或多个服务。依靠架构和模型特定的边界上下文,既可以将它打包成一个独立的服务(可以命名其为AccountService),也可以将其作为一个服务集合的一个组成部分,这个集合是一个更通用的模块,被命名为BankingService。

区分领域服务和实体、值对象的主要方式是粒度的层次。在一个服务里,多个领域实体根据特定业务规则进行交互的同时执行系统中特定的功能。从实现的角度来看,一个服务是一系列领域实体及值对象功能行为的集合。它囊括了一个完整的业务操作,同时对于用户或银行来说它有一个具体的值。表1.1汇总了到此为止所提到的3个重要的领域元素的特性。表1.1 领域元素

图1.4描述了在个人银行领域的一个案例中,这3种领域元素是如何关联起来的。这是DDD的一个基础概念,在继续学习之前,请确保理解这个基本知识。图1.4 模型的领域元素关系图。这个例子来源于个人银行领域。注意,账户、银行等都是实体。一个实体可以包含其他实体或值对象。服务位于更高水平的粒度,执行的行为也涵盖多个领域元素。领域元素语义与边界上下文

让我们用一个很重要的概念来结束不同领域元素的讨论,这就是将它们的语义关联到边界上下文。当我们说地址是一个值对象时,它只是在定义边界上下文范围内的一个值对象。在个人银行业务应用的边界上下文里,地址可能是一个值对象,也不需要通过它们的标识来追踪。但如果是另外一个实现地理编码服务的边界上下文,就需要通过经纬度来追踪地址,并且每个地址都可能必须打上唯一的ID标识。在这个边界上下文中,地址就成为了一个实体。类似地,在个人银行业务应用中,账户可能是一个实体,但在投资报告的边界上下文中,账户可能仅仅只是信息的一个载体,只需要被打印出来,因此它被实现为一个值对象。领域元素的类型通常跟定义它的边界上下文有关。1.2.3 领域对象的生命周期

在任意模型中的每个对象(实体或值对象)都必须有明确的生命周期模式。在模型中的每个对象类型,必须通过定义途径来处理下列每个事件。

● 创建:在系统中对象如何被创建。在银行业务系统中,可能需要一个特殊的抽象来负责创建银行账户。

● 参与行为:当对象在系统中交互时,它在内存中如何被表现出来。这也是在系统中建立一个实体或者一个值对象的方式。一个复杂实体可能会包括其他实体以及值对象。比如在图1.4中,账户实体会包含对其他实体如银行或其他值对象(如地址或账户类型)的引用。

● 持久化:对象如何在持久化的形态下维护。这包含一系列问题,比如:如何将元素写入持久化存储;如何检索详情来响应系统的查询;以及如果持久化方式是关系型数据库,那么如何插入、更新、删除,或者查询一个实体,如账户。

通常情况下,需要一个统一的词汇表。在下面的章节中会使用特定的术语来描述如何在模型中处理这3种生命周期事件。我们称其为[2]模式,因为会在领域建模的不同上下文中反复使用它们。工厂

当我们拥有一个复杂的模型时,使用专门的抽象来处理其生命周期的各个部分一向是一种很好的实践。它再不是一堆杂乱的代码,包含了一个个创建实体的代码片段,而是用一个模式将它们集中起来。这种策略服务于以下两个目的。

● 将所有创建的代码保存在一个位置。

● 抽象了来自调用者创建实体的过程。

比如说,有一个账户工厂(factory),它获取创建一个账户所需的不同参数,然后交给你一个新创建的账户。从工厂取回来的账户可能是一个校验、存款或者货币市场账户,这依赖于传入的参数。所以工厂使我们可以使用相同的API创建不同类型的对象。它抽象了创建对象的过程以及类型。

创建逻辑被隐藏在工厂内部。但工厂属于哪里呢?毕竟,工厂提供了一个服务——创建以及可能的初始化服务。这里工厂的职责就是提供一个完整构造的、最低程度的、合法的领域对象实例。一个选项就是使工厂成为定义领域对象模块的一个组成部分。在Scala中使用伴生对象(companion object)就有天然的实现,在清单1.1中会有描述。另一个选项是将工厂作为领域服务集合的一个组成部分。第2章会详细讲述一个这样的实现。

清单1.1 在Scala中用工厂实例化账户聚合

在前面的个人银行模型里,账户可以被认为是一组相关对象的组合。它一般包括下面这些内容。

● 核心账户标识属性,如账户号码。

● 各种非标识类属性,如持有人姓名,账户开户日期以及关闭日期(如果它是一个被关闭的账户)。

● 对其他对象的引用,比如地址和银行。

通过把账户看成一个内部由一致性边界组成的整体,就可以在脑海中想象整个对象图。在实例化一个账户时,所有这些独立参与的对象以及属性必须与领域的业务规则保持一致。不可能让一个账户在有开户日期之前先有关闭日期,也不可以让一个账户里没有任何持有人姓名。这些都是合理的业务规则,实例化的账户必须让所有组合的对象遵守每一条规则。如果在图中识别出参与对象的集合,那么这张图就成为了一个聚合(aggregate)。一个聚合可以由一个或多个实体、值对象以及原始属性(primitive attribute)组成。除了确保与业务规则的一致性,边界上下文里的聚合也通常被看作模型中的执行边界。

聚合里的一个实体构成聚合根。在一定程度上来说它有点像整个图的守门人,同时也是聚合与客户进行交互的单一点。聚合根有两个目标需要关注:

● 确保聚合内部业务规则与执行的一致性边界。

● 防止聚合的实现泄露给它的客户端,聚合支持的所有操作都要[3]通过外观执行。

清单1.2显示了用Scala设计一个账户聚合。它包含聚合根账户(同样也是一个实体),包含实体如银行,以及值对象如地址,这些[4]都是它的组成元素。设计聚合不是一件容易的事,特别是对于DDD的初学者。除了Eric Evans的Domain-Driven Design之外,还可以看一下Vaughn Vernon的文章Effective Aggregate Design,这里面讨论了3个需要关注的内容,有助于设计一个好的聚合(http://dddcommunity.org/library/vernon_2011/)。

清单1.2 账户聚合Scala里的case class

清单1.2中使用Scala的case class对账户聚合进行了建模。在指南中Scala的case class提供了一个便捷的方式来设计不可变对象。默认情况下,这个类获取的所有参数都是不可变的。因此,使用case class,是一个更容易设计聚合的捷径,当然使用不变性所带来的好处同样重要。

清单1.1和1.2使用了Scala里的trait。trait可以使用基于mixin的方式组合在一起,所以可以使用它来定义模块。mixin是一种小型抽象,可以与其他组件混合在一起形成更大的组件。

关于case calss、mixin以及trait的细节,可以查看Scala的官方主页(www.scala-lang.org),或者参考Martin Odersky的Programming in Scala(Artima Press,2016年)一书。

前文首先用Scala的trait实现了账户聚合的基本特征,然后用case class的方式进行了扩展。正如前文所述,可以很便捷地使用case class来建立不可变数据结构模型。这也是我们所说的代数数据类型,在后文会讨论更多的细节。但让我们先来看一个前面接触到的账户实体聚合的概念。

在1.2.2节中,提到我们可以更新一个实体的某些属性但不改变它的身份标识。这意味着实体是可以更新的。但在这里,我们将账户实体建模为一种不可变的抽象。这看起来似乎是一个很明显的矛盾,但事实并非如此。我们允许更新实体,但仅限于不能在适当的地方使对象保持可变的函数方式。与改变对象本身相反,更新会使要变更的[5]属性值生成一个新的实例。这样做的好处是可以继续将原来的抽象共享为一个不可变实体,同时根据这个实体生成一个新的实例并进行更新。从函数式的角度来考虑,我们将尽可能保证实体的不变性(与值对象很类似)。同时这也是指导模型设计的原则之一。清单1.2同样也展示了一个领域服务(AccountService)的案例,其中使用了账户聚合来实现两个账户之间的资金转移。仓储

现在我们知道,聚合是通过工厂创建出来的,并在对象生命周期的激活阶段在内存里代表基础的实体(如图1.3所示,回顾一个账户的生命周期)。但还需要一个途径去保存一个聚合,当我们不再需要它时,不能把它直接抛弃,因为后面可能还会因为其他目的而需要它。

仓储(repository)提供了一个接口,以持久化的形态来存放这个聚合,这样当我们需要它时,就可以把它取回来变为内存中的实体形态。通常来说,仓储的实现基于持久化存储,比如一个关系型数据[6]库管理系统(RDBMS),尽管并不是必须这样。要注意的是,聚合的持久化模式可能会与内存中聚合的表现完全不同,而且通常被底层存储数据模式所驱动。仓储的职责(参见清单1.3)就是提供在持久化存储中操作实体的接口,但不会暴露底层关系型数据模型(或任何底层存储支持的模型)。

清单1.3 AccountRepository——在数据库中操作账户的接口

这个仓储的接口不包含任何底层持久化存储的相关特性。它可能是一个关系型数据库,也可能是一个NoSQL数据库——这只有在具体执行时才知道。聚合在内存中生成一个实体实例,仓储就会在持久化存储中保存一个一模一样的实体。聚合隐藏了对象在内存中的底层细节,而仓储抽象了对象在持久化存储中的底层细节。在清单1.3中可以看到AccountRepository从底层存储操作账户,清单中没有展示任何仓储的具体实现。用户依然可以通过一个聚合与仓储进行交互。我们看看下面的思路就知道聚合是如何为实体的整个生命周期提供一个单一窗口的:

● 提供一串参数给工厂,并取得一个聚合(如Account)。

● 将这个聚合(清单1.2中的Account)作为你的协议,对应通过服务(如清单1.2中的AccountService)实现的所有行为。

● 通过聚合将实体在仓储中持久化(清单1.3的AccountRepository)。

现在己经了解了在模型中模块化的几个手段:使用边界上下文,需要实现的领域元素3个最重要的类型(实体、值对象、服务),以及操作它们的3种模式(factory、aggregate、repository)。现在我们必须意识到,这3种元素类型在领域中的交互(诸如银行系统中的debit、credit等)以及它们的生命周期都是通过3种模式来控制的。在领域驱动设计中要讨论的最后一件事就是将它们捆绑到一起,这被称为模型的词汇表,在下一节中会学到它为什么很重要。1.2.4 通用语言

现在我们己经拥有组成模型的实体、值对象和服务,也知道所有这些元素都需要彼此进行交互,以便实现业务执行的不同行为。作为一个具有工匠精神的软件工程师,对交互的建模不应该仅仅被底层硬件所理解,同时还要满足人们的好奇心。这个交互需要体现出基本的业务语义,并且包含正在建立的问题领域的词汇表。所谓词汇表,就是在用户场景中所涉及的对象与行为的名称。在案例中,实体如Bank(银行)、Account(账户)、Customer(用户),行为如debit(记入借方)和credit(记入贷方),都与业务术语强相关,因此也成为了领域词汇表的一部分。对词汇表的使用需要对业务进行更大范围的抽象,从而形成更小的词汇表。比如,可以这样实现一个[7]AccountService(领域服务):

让我们更仔细地看一下这个实现是如何体现前面所说的易懂性的。

● 函数体最小化,同时不包含任何无关的细节。它仅仅封装了涉及两个账户间转账的领域逻辑。

● 该实现使用了银行领域的术语,这样一个熟悉业务领域但对底层实现平台一无所知的人同样可以很容易地理解它能做什么。

● 该实现只描述了正常的执行路径。所有的异常路径都通过抽象被封装起来了。如果了解Scala就会知道,这里使用的for表达式是单[8]子化的(monadic),它可以在序列执行过程中照顾好所有异常。后面还会讨论更多有关这方面的内容。

Eric Evans将它称为通用语言(ubiquitous language)。在模型中使用领域词汇表并用术语进行互动,就如同领域所说的语言一样。从正确命名实体与原子行为开始,将词汇表扩展到更广泛的抽象。不同的模块可以使用不同的“方言”,在不同的边界上下文中相同的术语可能有不同的含义。但在一个上下文内部,词汇表必须是清晰明确的。

要形成一个一致的通用语言,很多方面与设计合适的模型API有关。API必须具备足够的表达力,这样一个该领域的专家可以只看API就能理解上下文。这也是我们所知道的领域特征语言,在我写的DSLs in Action(Manning,2010年)一书中可以看到这方面更多的细节。1.3 函数化思想

领域建模有很多种途径,但在过去的这十几年中,面向对象技术在复杂领域模型的开拓中占据了绝对的主导地位。本书中,我会有一点点激进,会用简单的老旧函数作为领域行为建模的主要抽象方式。在接下来的章节中,可以从建模以及维护软件两个方面,看到这样做的好处。

有些时候,优雅的实现仅仅是一个函数。不是一个方法,不是一个类,不是一个框架,只是一个函数。——John Carmack(https://twitter.com/ID_AA_Carmack/statuses/53512300451201024)

不过还是让我们从近些年所使用的范式作为开始。在这个例子中,将深入分析一个实现,然后逐步将其改变为函数式的变体。让我们回到日常生活中都会遇到的一个领域:个人银行业务。这个简单的模型会包括:一个聚合Account、一个值对象Balance、一些其他属性,以及debit和credit这对操作,如清单1.4所示。

清单1.4 个人银行业务领域模型实例

清单1.4中的内容浅显易懂。类Account包含一个可变状态,账户用它来保存余额。方法debit和credit在任何时候都可以及时地通过直接改变对象的状态来改变账户余额的值。

测验时间1.1 这种模型最主要的缺点是什么?

认真想一想,回顾一下清单1.4。我们将要讨论的内容很可能是为什么要重视函数化思想与建模的最重要的原因。

测验答案1.1 主要问题是易变性,它会从两个方面打击我们:很难在并行设置下使用抽象,而且很难推理代码。

这里要解释得更详细一些。var balance:Balance在领域模型中是一个可变状态。这里的关键词是“可变”,这就意味着该对象包含的这种状态可以被对象的多个客户端更新。进一步延伸到一个并发环境中,在决定状态值的时候就会遇到各种各样的矛盾类型,在任何时间任何位置。这本身就是一个非常庞大的话题,在Brian Goetz所著的Java Concurrency in Practice(Addison-Wesley Professional,2006年)一书中可以获得所有此类问题的详细描述。可变状态在推导代码时也[9]是一个反模式,本章的后面会有这块内容。尽管它看上去是一个有说服力的建模手段,但可变状态带来的问题远远多于它解决的问题。我们需要想办法干掉这些可变状态。

接下来看看是否可以继续用面向对象的思想来解决前面代码的这个主要缺陷。清单1.5展示了下一步的尝试,针对清单1.4中在模型中引入可变状态造成的“罪恶”进行弥补。

清单1.5 不可变Account模型

现在可变状态没有了!Account的每次操作都会用变更后的状态生成一个新的对象。不用再包含一个可变状态,这个新的Account类可以自己承载状态。一旦生成一个该类的实例,在实例内部就有balance作为状态。区别在于这个状态是不可变的。如果不创建另一个Account对象,就不能改变它的值。这也正是debit和credit操作所要做的事。Scala会确保传递给类构造器的参数默认是不可变的。可以选择通过将其定义为var使之成为可变的。但var是一个非常露骨的修饰词,因为需要去申请以获取可变性——而更好的决定是鼓励不可变抽象设计。

现在我们己经将Account改造成一个不可变抽象,可以在并发配[10]置下通过线程自由地共享Account。这是一个巨大的收获,也是向函数化思想迈出的第一步:用纯函数的方式工作,接受输入然后生成输出,不再依赖或影响可变状态。不变性在这里扮演一个非常重要的角色。

但这还没有结束。Account依然是一个同时拥有状态与行为的抽象。后面会看到,我们的想法是将这两者进行解耦,这会带来更好的模块化,也因此有了更好的可组合性。不过在此之前,先看一下,如果用纯函数建模,代码会得到什么好处。1.3.1 哈,纯粹的乐趣

想象一下,如果回到学校时光,然后尝试从数学的角度学习“函数”的定义。现在讨论函数式编程,那么这个函数和我们在数学班里所学的函数有什么区别?

在数学里,一个函数就是一组输入与一组容许的输出之间的关系,并且每个输入都与唯一一个输出相对应。——维基百科,http://en.wikipedia.org/wiki/Function_(mathematics)

这个定义永远不会提到涉及共享可变状态的函数。函数的输出纯粹由输入决定——如图1.5所示,将函数(f)建模成一个黑盒,输入(x)得到输出(y)。在函数式编程中,需要努力使函数像数学函数一样。

测验时间1.2 清单1.4与1.5中,哪个模型看起来更接近前面介绍的函数的定义?

现在我们己经了解了函数的定义,而且也知道了为什么要在领域模型中尝试着达到同样的效果。要回答这个问题根本就是小意思。哪个模型对可变状态依赖更少,谁就更接近于纯粹的数学函数。

测验答案1.2 清单1.5因为对Account做了不可变抽象,因此它更接近该定义。

在图1.5中,模型y=f(x)假设f是一个平方函数。那么不论调用多少次f,square(3)=9这个结果是绝对相同的。接下来结合刚才介绍的两种模型针对这个问题做更多细节方面的讨论。图1.5 y=f(x)建立了一个纯函数模型。f是一个黑盒,输入一个x,得到一个输出y。

在清单1.4中的可变模型中,Account对象调用了debit(100)并得出一个值,但这个结果不仅仅依赖输入参数100以及对象本身(可以把它当作一个隐性参数),还依赖于共享该对象的其他客户端。因为共享Account对象的所有客户端都有同样的通道访问可变状态。这也是与纯函数的区别所在。

在清单1.5中的不可变模型中,Account对象本身就包含当前状态。因此,对一个当前余额是2000的Account对象的调用debit(100),将会生成一个余额更新为1900的新的Account对象。输出仅仅依赖于提供的输入,所以这个模型拥有数学函数的纯洁性。

我们很快就会看到,Account的不可变模型其实是一个函数式模型的面向对象版本。它还将函数建模为一个类的方法。在这种情况下,通常会使我们左右为难,哪个函数应该成为哪个类的组成部分。同样,要将这些组合成为不同类方法的函数也会非常困难。

在这个例子里,debit和credit是针对单个账户的操作,将它们看成Account的行为。但诸如transfer这种操作会涉及两个账户。那它应该成为类Account的一部分,还是应该变成领域服务的一部分呢?应该如何处理针对一个账户的其他服务,比如每日余额结算或利息计算?我们可能会试着将它们放到一个类里形成一个臃肿的抽象。但是,将这类行为放进一个特定的聚合会妨碍模块化与组合性。所以,当设计函数式领域模型时,需要遵守一些通用原则:

● 将不可变状态建模为代数数据类型(algebraic data type,ADT)。

● 在模块中将行为建模为函数,这里的模块是指一个粗糙的业务功能单元(比如一个领域服务)。这样,就将状态从行为中分离了出来。行为比状态更好组合,因此,在模块中包含相关的行为有助于提升组合性。

● 记住,模块里的行为对ADT中的类型起作用。[11]

测验时间1.3 对或错:面向对象范式捆绑了状态和行为。函数式编程则对它们进行了解耦。

要回答上面的问题,先看一下如何用函数式Scala实现Account模型。清单1.6就是对前面的实现进行了改进的模型。它包含了一点点Scala构造,不过可以暂且忽略它们。但这里面来自函数化思想的结论,需要应用到领域模型中去:

● 在Scala中,用case class对ADT建模。默认情况下,所有ADT的参数都是不可变的,这也就意味着不需要特定的机制来保证模型的不变性。

● ADT的定义不包含任何行为。可以注意到debit和credit现在是[12]在被定义为一个领域服务的AccountService里。服务在模块中定义,在Scala中被实现为trait。trait充当mixin(混入类),可以很容易地把多个小模块组合成大模块。当需要一个模块实例时(上下文中的一个服务),请使用关键字object。就像前面所说的,要用函数化思想将状态和行为解绑——状态现在定居在ADT里,而行为被建模为模块里的独立函数。

● debit和credit都是纯函数,因为它们没有被绑定到任何特定对象。它们获取参数,执行一些函数功能,然后生成特定的输出,就像图1.5中的y=f(x)模型。

● 清单1.6中引入了一些其他构造如Try、Success和Failure,相对抛出异常来说,这会更加函数化,而且有更好的组合性。在后面的贴士“Scala里的异常”中会介绍在Scala中如何处理异常。后面的章节中也包含这个专题,以及更多关于函数式编程模式的细节。

清单1.6 净化模型

图1.6汇总了用Scala将面向对象的不可变领域模型转变为函数式变体所做的改动。图1.6 从面向对象不可变建模到函数化抽象。要注意到,我们已经将状态和行为分隔开。状态被编码为代数数据类型Account,而行为都被包含进一个领域服务。诸如Try这样的构造也有助于建立可组合的抽象。

测验答案1.3 主流的面向对象语言鼓励函数和状态封装在同一个抽象里。在OO语言里这个抽象就是class。

下一节主要谈函数组合。一起窥探另一个很酷的组合效果,比如用Try重构为函数式抽象带来的结果。现在可以组合多个debit和credit,就像下面这样:

Scala里的异常

在函数式编程里,异常被认为是不纯粹的。为了能函数化地处理异常,Scala定义了一个抽象util.Try,它包含Success和Failure两个具体实现。清单1.6用这个抽象来捕获generateAuditLog操作可能产生的所有异常。注意,generateAuditLog函数取得一个账户和一个数目,然后尝试生成一个字符串型的审计记录。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,它输入一个整数并将其加2。将这两者的组合定义为add2(square(x:Int)),也就是在输入的整数平方结果上再加2。

这就是我们首先要意识到的,函数式编程是基于函数组合的,这和在数学上处理函数非常类似,也就是常说的函数式编程的组合性。

测验时间1.4 假设有两个函数:f:String->Int和g:Int->Int,会怎么定义f和g的组合呢?能想到哪些满足这个组合的真实函数?

利用组合性的特质,可以用小函数构成一个大函数。本书的一个重要主题就是探索不同的方式来组合各种函数。我们会使用Scala,它完全具备把这种组合变得更加简单的能力。关于Scala中函数式编程的更多细节,请参考Paul Chiusano 和Runar Bjarnason的著作Functional Programming in Scala(Manning,2014年)。[13]

在这本书中可以看到用Scala REPL来组合函数的例子,REPL是与Scala解释器交互的一个环境。

测验答案1.4 组合可以被定义为g(f(x: String))。实际的例子就是:把f作为计算一个字符串长度的函数,把g作为将输入整数翻倍的函数。于是,double (length(x:String))就是组合两个函数的实际例子,它会返回输入字符串长度两倍的值。

现在我们己经熟悉了函数式编程的基本技术,是时候更进一步了。到现在为止,我们所讨论的组合,都是把一个个独立的函数串联到一起,将一个函数的输出作为另一个函数的输入。但在讨论函数式编程里的组合特性时,就远远不止这些内容了。让我们看如图1.7所示的例子。图1.7 map是一个将其他函数作为输入的高阶函数。

函数map有两个参数:一个字符串的list以及另外一个函数length:String>Int。map在list上进行迭代,并对list的每个元素应用函数length,它生成的结果是另外一个list,每个元素的结果是length的运行结果,于是得到一个整数list。这是一个如何用函数思想思考问题的精彩案例,在表1.2中列出了这个范式一些有意思的特性。像map这种高阶函数也被称为组合器(combinator)。表1.2 结合map函数,用函数化的方式思考。

测验时间1.5 如果传递给map的函数碰巧更新了一个共享的可变状态,这时候会发生什么?是不是意味着对一个list遍历多次会导致不同的输出?

清单1.7中的代码使用了高阶函数,如Scala里的map,来演示组合的不同方式。每个例子都遵守表1.2中所列的指导原则。

清单1.7 函数组合与高阶函数

现在是时候用一些组合器来充实Balance领域模型了。在复杂领域建模中,会有很多自己的组合器。但那些来自标准库的组合器非常管用,经常会发现在建立自己组合器的过程中,最后还是会回去使用标准库的组合器。毕竟,它代表了我们一直追寻的组合性。

测验答案1.5 我们必须坚持一点,在使用map或其他组合器时,不要违背纯粹原则。可以传递任何函数给map,不管有没有副作用或者可变性。后面内容会讨论副作用。

现在试着在个人银行系统中实现一些领域行为。假设想要对交易(比如debit和credit)增加一个查账功能,同时生成查询记录并把它们记录在某个地方。在以下例子里会略过一些细节——这对于理解概念来说并不重要。假设有如下两个函数:

这两个函数可以作为一个编程模式的简单练习。但我们现在的想法是用函数组合以及高阶函数来达到同样的目的。清单1-8描述了具体实现。

清单1.8 通过高阶函数组合

这个需求被定义成一个函数,它执行如下序列的操作:

1.在一个账户中执行debit。

2.如果debit通过,生成一个查账记录;否则,结束。

3.将记录写入存储。

需要用函数式编程提供的组合器来准确实现这个序列。在这个序列中的行为流与领域行为的建模必须反映同一个序列。体会现在所掌握的函数化思想和前面讨论的组合器,清单1.8用同样的逻辑提供了一个真实的画像。

试读结束[说明:试读内容隐藏了图片]

下载完整电子书


相关推荐

最新文章


© 2020 txtepub下载