第一部分 让领域模型发挥作用
第二部分 模型驱动设计的构造块
第4章 分离领域(已看)
第5章 软件中所表示的模型(已看)
第6章 领域对象的生命周期(已看)
第7章 使用语言:一个扩展的示例(已看)
第三部分 通过重构来加深理解
第10章 柔性设计(已看)
第四部分 战略设计
第1章 消化知识
1.1 有效建模的要素
1.2 知识消化
1.3 持续学习
1.4 知识丰富的设计
1.5 深层模型
第2章 语言的交流和适用
2.1 模式:UBIQUITOUS LANGUAGE
2.2 “大声地”建模
2.3 一个团队,一种语言
2.4 文档和图
2.4.1 书面设计文档
2.4.2 完全依赖可执行代码的情况
2.5 解释性模型
第3章 绑定模型和实现
3.1 模式:MODEL-DRIVEN DESIGN
3.2 建模范式和工具支持
3.3 揭示主旨:为什么模型对用户至关重要
3.4 模式:HANDS-ON MODELER
第4章 分离领域
4.1 模式:LAYERD ARCHITECTURE
在一个运输应用程序中,要想支持从城市列表中选择运送货物目的地这样的简单用户行为,程序代码必须包括:
- 在屏幕上绘制一个屏幕组件(widget)
- 查询数据库,调出所有可能的城市
- 解析并验证用户输入
- 将所选城市与货物关联
- 向数据库提交此次数据修改
上面所有的代码都在同一个程序中,但是只有一小部分代码与运输业务相关
软件程序需要通过设计和编码来执行许多不同类型的任务。它们接收用户输入,执行业务逻辑,访问数据库,进行网络通信,向用户显示信息,等等。因此程序中的每个功能都可能需要大量的代码来实现
要想创建出能够处理复杂任务的程序,需要把不同的关注点分开考虑,使设计中的每个部分都得到单独的关注。在分离的同时,也需要维持系统内部复杂的交互关系
分割软件系统有各种各样的方式,但是根据软件行业的经验和惯例,普遍采用LAYERED ARCHITECTURE(分层架构),特别是有几个层基本上已成了标准层。分层这种隐喻被广泛采用,大多数开发人员都对其有着直观的认识。许多文献对LAYERED ARCHITECTURE也进行了充分的讨论,有些是以模式的形式给出的。LAYERED ARCHITECTURE的基本原则是层中的任何元素都仅依赖于本层的其他元素或其下层的元素。向上的通信必须通过间接的传递机制进行
分层的价值在于每一层都只代表程序中的某一特定方面。这种限制使每个方面的设计都更具内聚性,更容易解释。尽管LAYERED ARCHITECTURE的种类繁多,但是大多数成功的架构使用的都是包括下面这四个概念层的某个版本
- 用户界面层(或表示层) 负责向用户显示信息和解释用户指令。这里指的用户可以是另一个计算机系统,不一定是使用用户界面的人
- 应用层 定义软件要完成的任务,并且指挥表达领域概念的对象来解决问题。这一层所负责的工作对业务来说意义重大,也是与其他系统的应用层进行交互的必要渠道
应用层要尽量简单,不包含业务规则或者知识,而只为下一层中的领域对象协调任务,分配工作,使它们互相协作。它没有反映业务情况的状态,但是却可以具有另一种状态,为用户或程序显示某个任务的进度 - 领域层(或模型层) 负责表达业务概念,业务状态信息以及业务规则。尽管保存业务状态的技术细节是由基础设施层实现的,但是反映业务情况的状态是由本层控制并且使用的。领域层是业务软件的核心
- 基础设施层 为上面各层提供通用的技术能力;为应用层传递消息,为领域层提供持久化机制,为用户界面层绘制屏幕组件,等等。基础设施层还能通过架构框架来支持四个层次间的交互模式
有些项目没有明显划分出用户界面层和应用层,而有些项目则有多个基础设施层。但是将领域层分离出来才是实现MODEL-DRIVEN DESIGN的关键
给复杂的应用程序划分层次。在每一层内分别进行设计,使其具有内聚性并且只依赖于它的下层。采用标准的架构模式,只与上层进行松散连接。将所有与领域模型相关的代码放在一个层中,并把它与用户界面,应用层以及基础设施层的代码分开。领域对象应该将重点放在如何表达领域模型上,而不需要考虑自己的显示和存储问题,也无需管理应用任务等内容。这使得模型的含义足够丰富,结构足够清晰,可以捕捉到基本的业务知识,并有效地使用这些知识
将领域层与基础设施层以及用户界面层分离,可以使每层的设计更加清晰。彼此独立的层更容易维护,因为它们往往以不同的速度发展并且满足不同的需求。层与层的分离也有助于在分布式系统中部署程序,不同的层可以灵活地放在不同服务器或客户端中,这样可以减少通信开销,并优化程序性能
示例 为网上银行功能分层
该应用程序能提供维护银行账户的各种功能。其中一个功能就是转账,用户可以输入或者选择两个账户号码,填写要转的金额,然后开始转账
注意,负责处理基本业务规则的是领域层,而不是应用层----在这个例子中,业务规则就是“每笔贷款必须有与其数目相同的借款”
4.1.1 将各层关联起来
显然,各层之间也需要互相连接。在连接各层的同时不影响分离带来的好处,这是很多模式的目的所在
各层之间是松散连接的,层与层的依赖关系只能是单向的。上层可以直接使用或操作下层元素,方法是通过调用下层元素的公共 接口,保持对下层元素的引用(至少是暂时的)以及采用常规的交互手段。而如果下层元素需要与上层元素进行通信(不只是回应直接查询),则需要采用另一种通信机制,使用架构模式来连接上下层,比如回调模式或OBSERVER模式
最早将用户界面层与应用层和领域层相连的模式是MODEL-VIEW-CONTROLLER框架
还有许多其他连接用户界面层和应用层的方式。对于我们而言,只要连接方式能够维持领域层的独立性,保证在设计领域对象时不需要同时考虑可能与其交互的用户界面,那么这些连接方式就都是可用的
通常,基础设施层不会发起领域层中的操作,它处于领域层“之下”,不包含其所服务的领域中的知识。事实上这种技术能力最常以SERVICE的形式提供。例如,如果一个应用程序需要发送电子邮件,那么一些消息发送的接口可以放在基础设施层中,这样,应用层中的元素就可以请求发送消息了。这种解耦使程序的功能更加丰富。消息发送接口可以连接到电子邮件发送服务,传真发送服务或任何其他可用的服务。但是这种方式最主要的好处是简化了应用层,使其只专注于自己所负责的工作:知道何时该发送消息,而不用操心怎么发送
应用层和领域层可以调用基础设施层所提供的SERVICE。如果SERVICE的范围选择合理,接口设计完善,那么通过把详细行为封装到服务接口中,调用程序就可以保持与SERVICE的松散连接,并且不会那么复杂
然而,并不是所有的基础设施都是以可供上层调用的SERVICE的形式出现的。有些技术组件被设计成直接支持其他层的基本功能(比如为所有的领域对象提供抽象基类),并且提供关联机制(比如MVC及类似框架的实现)。这种“架构框架”对于程序其他部分的设计有着更大的影响
4.1.2 架构框架
不妄求万全之策,而是有选择性地运用框架来解决难点问题,就可以避开框架的很多不足之处。明智而审慎地选择框架中最具价值的功能能够减少程序实现和框架之间的耦合,使随后的设计决策更加灵活。更重要的是,现在许多框架的用法都极其复杂,这种简化方式有助于保持业务对象的可读性,使其更富有表达力
架构框架和其他工具都在不断的发展。在新出现的框架中,越来越多的技术问题会自动得到解决或者被预先设定好解决方案。如果框架使用得当,那么程序开发人员将可以更加专注于核心业务问题的建模工作,这会大大提高开发效率和程序质量。但与此同时,我们必须要保持克制,不要总是想着寻找框架,因为精细的框架可能会束缚住程序开发人员
4.2 模型属于领域层
现在,大部分软件系统都采用了LAYERED ARCHITECTURE,只是采用的分层方案存在不同而已。许多类型的开发工作都能从分层中收益。然而,领域驱动设计只需要一个特定的层存在即可
领域模型是一系列概念的集合。“领域层”则是领域模型以及所有与其直接相关的设计元素的表现,它由业务逻辑的设计和实现组成。在MODEL-DRIVEN DESIGN中,领域层的软件构造反映出了模型概念
如果领域逻辑与程序中的其他关注点混在一起。就不可能实现这种一致性。将领域实现独立出来是领域驱动设计的前提
4.3 模式:THE SMART UI “ANTI-PATTERN”
本节讨论SMART UI是为了与领域驱动模式进行对比,并帮助我们认清在本书后面章节中的哪些情况下需要选择相对而言更难于实现的领域驱动设计模式
在项目中,人们经常会尝试分离用户界面,应用和领域,但是成功的分离却不多见,这种负面作用本身很值得讨论
许多软件项目都采用并且应该保持一种不那么复杂的设计方法,我称其为SMART UI(智能用户界面)。但是SMART UI是另一种设计方法,与领域驱动设计方法迥然不同且互不兼容。
优点
- 效率高,能在短时间内实现简单的应用程序
- 能力较差的开发人员可以几乎不经过培训就采用它
- 甚至可以克服需求分析上的不足,只要把原型发布给用户,然后根据用户反馈快速修改软件产品即可
- 程序之间彼此独立,这样,可以相对准确地安排小模块交付的日期。额外扩展简单的功能也很容易
- 可以很顺利地使用关系数据库,能够提供数据级的整合
- 可以使用第四代语言工具
- 移交应用程序后,维护程序员可以迅速重写他们不明白的代码段,因为修改代码只会影响到代码所在的用户界面
缺点
- 不通过数据库很难集成应用模块
- 没有对行为的重用,也没有对业务问题的抽象。在每一次用到业务规则的操作时,都必须重写规则
- 快速的原型建立和迭代都有自然的局限性,因为抽象的缺乏限制了重构的选择
- 复杂的功能很快会让你无所适从,所以程序的扩展只能是增加简单的应用模块,没有很好的办法来实现更丰富的功能
4.4 其他分离方式
遗憾的是,除了基础设施和用户界面之外,还有一些其他的因素也会破坏你精心设计的领域模型。你必须要考虑那些没有完全集成到模型中的领域元素。你不得不与同一领域中使用不同模型的其他开发团队合作。还有其他的因素会让你的模型结构不再清晰,并且影响模型的使用效率
第5章 软件中所表示的模型
一个对象是用来表示某种具有l连续性和标识的事务的呢(可以跟踪它所经历的不同状态,甚至可以跨不同的实现跟踪它),还是用于描述某个事物的某种状态的属性呢?这是ENTITY与VALUE OBJECT之间的根本区别。明确地选择这两种模式中的一个来定义对象,有利于减少歧义,并帮助我们做出特定的选择,这样才能得到健壮的设计
5.1 关联
对象模型中每个可遍历的关联,在软件中都有一个具有同样属性的机制
一个显示了顾客与销售之间关联的模型含有两个含义。一方面,它把开发人员所认为的两个真是的人之间的关系抽象出来。另一方面,它相当于两个Java对象之间的对象指针,或者相当于数据库查询(或类似实现)的一种封装
例如,一对多关联在实例变量中可以用一个集合来实现。但设计不一定要这样直接。可能没有集合,这时可以使用一种访问方法(accessor method)来查询数据库,找到相应的记录,并用这些记录来实例化对象。这两种设计方法反映了同一个模型。在设计中必须指定一种特殊的遍历机制,这种遍历的行为应该与模型中的关联一致
现实生活中有大量“多对多”关联,其中有很多关联自然而然是双向的。我们在模型开发的早期进行头脑风暴活动并探索领域时,也会得到很多这样的关联。但这些普遍的关联会使实现和维护变得很复杂。此外,他们很少能表示出关系的本质
至少有三种方法可以使得关联更易于控制
- 规定一个遍历方向
- 添加一个限定符,以便有效地减少多重关联
- 消除不必要的关联
尽可能地对关系进行约束是非常重要的。双向关联意味着只有将这两个对象放在一起考虑才能理解它们。当应用程序不要求双向遍历时,可以指定一个遍历方向,以便减少相互依赖性,并简化设计。理解了领域之后就可以自然地确定一个方向
像很多国家一样,美国有过很多位总统。这是一种双向的,一对多的关系。然而,在提到“乔治·华盛顿”这个名字时,我们很少会问“他是哪个国家的总统?”。从实用的角度讲,我们可以将这种关系简化为从国家到总统的单向关联。如图5-1所示。这种精华实际上反映了对领域的深入理解,而且也是一个更实用的设计。它表明一个方向的关联比另一个方向的关联更有意义且更重要。也使得Person类独立于基础概念President
通常,通过更深入的理解可以得到一个“限定的”关系。进一步研究总统的例子就可以知道,一个国家在一段时期内只能有一位总统(内战期间或许有例外)。这个限定条件把多重关系简化为一对一关系,并且在模型中植入了一条明确的规则。如图5-2所示。1790年谁是美国总统?乔治·华盛顿
限定多对多关联的遍历方向可以有效地将其实现简化为一对多关联,从而得到一个简单得多的设计
坚持将关联限定为领域中所偏向的方向,不仅可以提高这些关联的表达力并简化其实现,而且还可以突出剩下的双向关联的重要性。当双向关联是领域的一个语义特征时,或者当应用程序的功能要求双向关联时,就需要保留它,以便表达出这些需求
当然,最终的简化是清除那些对当前工作或模型对象的基本含义来说不重要的关联
示例 Brokerage Account(经纪账户)中的关联
此模型中的Brokerage Account的一个Java实现
public class BrokerageAccount { String accountNumber; Customer customer; Set investments; // constructors, etc. omitted public Customer getCustomer() { return customer; } public Set getInvestment() { return investments; } }
但是,如果需要从关系数据库取回数据,那么就可以使用另一种实现(它同样也符合模型)
public class BrokerageAccount { String accountNumber; String customerSocialSecurityNumber; // Omit constructors, etc. public Customer getCustomer() { String sqlQuery = "SELECT * FROM CUSTOMER WHERE " + SS_NUMBER = '" + customerSocialSecurityNumber + "'"; return QueryService.findSingleCustomerFor(sqlQuery); } public Set getInvestments() { String sqlQuery = "SELECT * FROM INVESTMENT WHERE" + "BROKERAGE_ACCOUNT='" + accountNumber + "'"; return QueryService.findInvestmentsFor(sqlQuery); } }
下面,我们通过限定Brokerage Account(经纪账户)与Investment(投资)之间的关联来简化其多重性,从而对模型进行精华。具体的限定是:每只股票只能对应于一笔投资
仔细地简化和约束模型的关联是通往MODEL-DRIVEN DESIGN的必经之路。现在我们转向对象本身。仔细区分对象可以使得模型更加清晰,并得到更实用的实现
5.2 模式:ENTITY(又称为REFERENCE OBJECT)
对象建模有可能把我们的注意力引到对象的属性上,但实体的基本概念是一种贯穿整个生命周期(甚至会经历多种形式)的抽象的连续性
一些对象主要不是由它们的属性定义的。他们实际上表示了一条“标识线”(A Thread of Identity),这条线经过了一个时间跨度,而且对象在这条线上通常经历了多种不同的表示。有时,这样的对象必须与另一个具有不同属性的对象相匹配。而有时一个对象必须与具有相同属性的另一个对象区分开。错误的标识可能会破坏数据
主要由标识定义的对象被称作ENTITY。ENTITY(实体)有特殊的建模和设计思路。它们具有生命周期,这期间它们的形式和内容可能发生根本改变,但其标识保持不变。为了有效地跟踪这些对象,必须定义它们的标识。它们的类定义,职责,属性和关联必须围绕标识来变化,而不会随着特殊属性来变化。即使对于那些不发生根本变化或者生命周期不太复杂的ENTITY,也应该在语义上把它们作为ENTITY来对待,这样可以得到更清晰的模型和更健壮的实现
ENTITY可以是任何事物,只要满足两个条件既可。一是它在整个生命周期中具有连续性,二是它的一些对用户来说非常重要的不同状态不是由属性决定的。
标识是ENTITY的一个微妙的,有意义的属性,我们是不能把它交给语言的自动特性来处理的
当一个对象由其标识(而不是属性)区分时,那么在模型中应该主要通过标识来确定该对象的定义。使类定义变得简单,并集中关注生命周期的连续性和标识。定义一种区分每个对象的方式,这种方式应该与其形式和历史无关。要格外注意那些需要通过属性来匹配对象的需求。在定义标识操作时,要确保这种操作为每个对象生成唯一的结果,这可以通过附加一个保证唯一性的符号来实现。这种定义标识的方法可能来自外部,也可能是由系统创建的任意标识符,但它在模型中必须是唯一的标识。模型必须定义出“符合什么条件才算是相同的事物”
5.2.1 ENTITY建模
当一个对象进行建模时,我们自然而然会考虑的它的属性,而且考虑它的行为也显得非常重要。但ENTITY的最基本职责是确保连续性,以便使其行为更清楚且可预测。保持实体的简练是实现这一责任的关键。不要将注意力集中在属性或行为上,应该摆脱这些细枝末节,主抓ENTITY对象定义的最基本特征,尤其是那些用于识别,查找或匹配对象的特征。只添加那些对概念至关重要的行为和这些行为所必需的属性。此外,应该将行为和属性转移到与核心实体关联的其他对象中。在这些对象中,有些可能是ENTITY,有些可能是VALUE OBJECT
5.2.2 设计标识操作
标识是在模型中定义的。定义标识要求理解领域
5.3 模式:VALUE OBJECT
很多对象没有概念上的标识,它们描述了一个事务的某种特征
跟踪ENTITY的标识是非常重要的,但为其他对象也加上标识会影响系统性能并增加分析工作,而且会使模型变得混乱,因为所有对象看起来都是相同的
软件设计要时刻与复杂性做斗争。我们必须区别对待问题,将特殊的处理方式应用到真正需要的地方
然而,如果仅仅把这类对象当作没有标识的对象,那么就忽略了它们的工具价值或术语价值。事实上,这些对象有其自己的特征,对模型也有着自己的重要意义。这些是用来描述事物的对象
用于描述领域的某个方面而本身没有概念标识的对象称为VALUE OBJECT(值对象)。VALUE OBJECT被实例化之后用来表示一些设计元素,我们只关心这些元素是什么,而不关心它们是谁
当我们只关心一个模型元素的属性时,应把它归类为VALUE OBJECT。我们应该使这个模型元素能够表示出其属性的意义,并为它提供相关功能。VALUE OBJECT应该是不可变的。不要为它分配任何标识,而且不要把它设计成像ENTITY那么复杂
VALUE OBJECT所包含的属性应该形成一个整体概念。
5.3.1 设计VALUE OBJECT
复制和共享哪个更划算取决于实现环境。虽然复制有可能导致系统被大量的对象阻塞,但共享可能会减慢分布式系统的速度
5.3.2 设计包含VALUE OBJECT的关联
如果说ENTITY之间的双向关联很难维护,那么两个VALUE OBJECT之间的双向关联则完全没有意义
5.4 模式:SERVICE
有时,对象不是一个事物
在某些情况下,最清楚,最实用的设计会包含一些特殊的操作,这些操作从概念上讲不属于任何对象。与其把它们强制地归于哪一类,不如顺其自然地在模型中引入一种新的元素,这就是SERVICE(服务)
有些重要的领域操作不适合归到ENTITY或VALUE OBJECT的类别中。这当中有些操作从本质上讲是一些活动或动作,而不是事物,但由于我们的建模范式是对象,因此要想办法将它们划归到对象这个范畴里
现在,一个比较常见的错误是没有努力为这类行为找到一个适当的对象,而是逐渐转为过程化的编程。但是,当我们勉强将一个操作放到不符合对象定义的对象时,这个对象就会产生概念上的混淆,而且会变得很难理解或重构。复杂的操作很容易把一个简单对象搞乱,使对象的角色变得模糊。此外,由于这些操作常常会牵扯到很多领域对象,需要协调这些对象以便使它们工作,因此这些对象需要承担更多的职责,从而对所有这些对象产生依赖性,这使得那些本来可以一个个去理解的概念被缠杂在一起
有时,一些SERVICE看上去就像是模型对象,它们以对象的形式出现,除了执行一些操作之外并没有其他意义。这些“实干家”(Doer)的名字通常以“Manager”之类的名字结尾。它们没有自己的状态,而且除了所掌握的操作之外在领域中也没有其他意义。尽管如此,我们仍然应该把它们归到SERVICE这个类别中,这样可以避免它们与真正的模型对象混淆
一些领域概念不适合被建模为对象。如果勉强地把这些重复的领域功能归为ENTITY或VALUE OBJECT的职责,那么不是歪曲了基于模型的对象的定义,就是人为地增加了一些无意义的对象
SERVICE是作为接口提供的一种操作,它在模型中是独立的,它不像ENTITY和VALUE OBJECT那样具有封装的状态。SERVICE是技术框架的一种常见模式,但它们可以在领域层中使用
所谓SERVICE,它强调的是与其他对象的关系。与ENTITY和VALUE OBJECT不同,它只是定义了能够为客户做什么。SERVICE是以一个活动命名,而不是以一个ENTITY命名,也就是说,它是动词而不是名词。SERVICE也可以有一个抽象的,有意图的定义,只是它使用了一种与对象不同的定义风格。SERVICE也应该有定义的职责,而且这种职责以及履行它的接口也应该作为领域模型的一部分被定义。操作名称应该来自于UBIQUITOUTS LANGUAGE,如果UBIQUITOUS LANGUAGE中没有这个名称,则应该将其引入到UBIQUITOUS LANGUAGE中。参数和结果应该是领域对象
使用SERVICE时应谨慎,它们不应该替代ENTITY和VALUE OBJECT的所有行为。但是,当一个操作实际上是一个重要的领域概念时,SERVICE很自然就会成为MODEL-DRIVEN DESIGN中的一部分。将模型中的独立操作声明为一个SERVICE,而不是声明为一个不代表任何事情的虚拟对象,可以避免对任何人产生误导
好的SERVICE有以下3个特征
- 与领域概念相关的操作不是ENTITY或VALUE OBJECT的一个自然的部分
- 接口是根据领域模型的其他元素定义的
- 操作是无状态的
这里所说的无状态是指任何客户都可以使用某个SERVICE的任何实例,而不必关心该实例的历史状态。执行SERVICE时将使用可全局访问的信息,甚至可以更改这些全局信息(也就是说,它可能具有副作用)。但SERVICE不保持影响其自身行为的状态,这一点与大多数领域对象不同
当领域中的某个重要的过程或转换操作不属于实体或值对象的自然职责时,应该在模型中添加一个作为独立接口的操作,并将其声明为SERVICE。定义接口时要使用模型语言,并确保操作名称是UBIQUITOUS LANGUAGE中的术语。此外,应该将SERVICE定义为无状态的
5.4.1 SERVICE与孤立的领域层
这种模式只重视那些在领域中具有重要意义的SERVICE,但SERVICE并不只是在领域层中使用。我们需要注意区分属于领域层的SERVICE和那些属于其他层的SERVICE,并划分责任,以便将它们明确地区分开
文献中对SERVICE的讨论太多是单纯从技术角度讨论,而且它们属于基础设施层。领域层和应用层的SERVICE与这些基础设施层SERVICE进行协作。例如,银行可能有一个用于向客户发送电子邮件的应用程序,当客户的账户余额小于一个特定的临界值时,这个程序就向客户发送一封电子邮件。封装了电子邮件系统的接口(也可能是其他的通知方式)就是基础设施层中的一个SERVICE
应用层SERVICE和领域层SERVICE可能很难区分。应用层负责安排一个通知,而领域层负责确定是否满足临界值,尽管这项任务可能并不需要使用SERVICE,因为它可以被划分到account(账户)对象的职责中。这个银行应用程序可能还负责资金转账。如果设计一个SERVICE来处理资金转账相应的借方和贷方,那么这项功能将属于领域层。资金转账在银行领域语言中是一项有意义的操作,而且它涉及基本的业务逻辑。而技术层SERVICE应该是没有任何业务意义的
很多领域或应用层SERVICE是在ENTITY和VALUE OBJECT的基础上建立起来的,它们的行为类似于将领域的一些潜在功能组织起来以执行某种任务的脚本。ENTITY和VALUE OBJECT往往由于粒度过细而无法提供对领域层功能的便捷访问。我们在这里会遇到领域层与应用层之间的一条很细的分界线。例如,如果银行应用程序可以把我们的交易进行转换并导出到一个电子表格文件中,以便进行分析,那么这个导出操作就是一种应用层SERVICE。“文件格式”在银行领域中是没有意义的,它也不涉及业务规则
另一方面,账户之间的转账功能属于一种领域层SERVICE,因为它包含重要的业务规则(例如,处理相应的借方账户和贷方账户),而且“资金转账”是一个有意义的银行术语。在这种情况下,SERVICE自己并不会做太多的事情,而只是要求两个Account对象完成大部分工作。但如果将“转账”操作强加在Account对象上将是很别扭的,因为这个操作涉及两个账户和一些全局规则
我们可能喜欢创建一个Funds Transfer(资金转账)对象来表示两个账户,外加一些与转账有关的规则和历史记录。但在银行间的网络中进行转账时,仍然需要使用SERVICE。此外,在大多数开发系统中,在一个领域对象和外部资源之间直接建立一个接口是很别扭的。我们可以利用一个FACADE(外观)将这样的外部SERVICE包装起来,这个外观负责接受模型的输入,也可以返回一个“Funds Transfer”对象(作为它的结果)。但无论使用什么中间SERVICE(尽管它们不属于我们),这些SERVICE都是在履行资金转账和领域职责
5.4.2 粒度
上述对SERVICE的讨论强调的是将一个概念建模为SERVICE的表现力,但SERVICE还有其他有用的功能,它可以控制领域层中的接口的粒度,并且避免客户与ENTITY和VALUE OBJECT的耦合
在大型系统中,中等粒度的,无状态的SERVICE更容易被重用,因为它们在一个简单的接口背后封装了重要的功能。此外,细粒度的对象可能导致分布式系统的消息传递的效率低下
如前所述,由于应用层负责对领域对象的行为进行协调,因此细粒度的领域对象可能会把领域层的知识泄露到应用层中。这产生的结果是应用层不得不处理复杂的,细致的交互,从而使得领域知识蔓延到应用层和用户界面代码中,而领域层会丢失这些知识。明智地引入领域层服务有助于在应用层和领域层之间保持一条明确的界限
这种模式有利于保持接口的简单性,便于客户控制并提供了多样化的功能。它提供了一种在大型或分布式系统中便于对组件进行打包的中等粒度的功能。而且,有时SERVICE是表示领域概念的最自然的方式
5.4.3 对SERVICE访问
5.5 模式:MODULE(也称为PACKAGE)
MODULE是一个传统的,较成熟的设计元素。虽然使用模块有一些技术上的原因,但主要原因却是“认知超载”。MODULE为人们提供了两种观察模型的方式,一是可以在MODULE中查看细节,而不会被整个模型淹没,二是观察MODULE之间的关系,而不考虑其内部细节
领域层中的MODULE是模型中有意义的部分,MODULE从更大的角度描述了领域
每个人都会使用MODULE,但却很少有人认为它们是模型的一个相对独立的部分。这是因为代码被分割为很多块,有时是按照技术架构来分割的,有时是按照开发人员的任务来分割的。甚至那些从事大量重构工作的开发人员也倾向于使用项目早期形成的一些MODULE
事实上,MODULE之间应该是低耦合的,而在MODULE的内部则是高内聚的。耦合和内聚的解释使得MODULE听上去像是一种技术指标,仿佛是根据关联和交互的分布情况来机械地判断它们。然而,MODULE并不仅仅是代码的划分,而且也是概念的划分。一个人一次考虑的事情是有限的(因此才有低耦合)。不连贯的思想和“一锅粥”似的思想同样难于理解(因此才有高内聚)
低耦合高内聚作为通用的设计原则既适用于各种对象,也适用于MODULE,但MODULE作为一种更粗粒度的建模和设计元素,采用低耦合高内聚原则显得更为重要
只要两个模型元素被划分到不同的MODULE中,它们的关系就不如原来那样直接,这会导致我们更难理解它们在设计中的作用。MODULE之间的低耦合可以将这种负面作用减至最小,而且在分析一个MODULE的内容时,只需从那些与之交互的其他MODULE中引用很少的内容
同时,在一个好的模型中,元素之间是可以协同工作的,而仔细选择的MODULE可以将那些具有紧密概念院系的模型元素集中到一起。将这些具有相关职责的对象元素聚合到一起,可以把建模和设计工作集中到单一MODULE中,这会极大地降低建模和设计的复杂性,使人们可以从容应对这些工作
MODULE和较小的元素应该共同演变,但实际上它们并不是这样。MODULE是用来组织一种早期的对象形式的。在这之后,对象在变化时不脱离现有模块定义的边界。重构MODULE需要比重构类做更多工作,这些工作也具有更大的破坏性,因此MODULE的重构不能像类的重构那么频繁。但就像模型对象从简单具体逐渐转变为反映更深层次的本质一样,MODULE会变得微妙和抽象。让MODULE反映出对领域理解的不断变化,可以使MODULE中的对象能够更自由地演变
像领域驱动设计中的其他元素一样,MODULE是一种表达机制。MODULE的选择应该取决于被划分到模块中的对象的意义。当你将一些类放到MODULE中时,相当于告诉下一位看到你的设计的开发人员要把这些类放在一起考虑。如果说模型讲述了一个故事,那么MODULE就是这个故事的各个章节。模块的名称表达了其意义
选择能够描述系统的MODULE,并使之包含一个内聚的概念集合。这通常会实现MODULE之间的低耦合,但如果效果不理想,则应寻找一种更改模型的方式来消除概念之间的耦合,或者找到一个可作为MODULE基础的概念(这个概念先前可能被忽视了),基于这个概念组织的MODULE可以以一种有意义的方式将元素集中到一起。找到一种低耦合的概念组织方式,从而可以相互独立地理解和分析这些概念。对模型进行精化,直到可以根据高层领域概念对模型进行划分,同时相应的代码也不会产生耦合
MODULE的名称应该是UBIQUITOUS LANGUAGE中的术语。MODULE及其名称反映出领域的深层知识
仅仅研究概念关系是不够的,它并不能完全替代技术手段。这二者是相同问题的不同层次,都是必须要完成的。但是,只有以模型为中心进行思考,才能得到更深层次的解决方案,而不是随便找一个解决方案应付了事。当必须做出一个这种选择时,务必保证概念清晰,即使这意味着MODULE之间会产生更多引用,或者更改MODULE偶尔会产生“涟漪效应”。开发人员只要理解了模型所描述的内容,就可以应付这些问题
5.5.1 敏捷的MODULE
MODULE需要与模型的其他部分一同演变。这意味着MODULE的重构必须与模型和代码一起进行。但这种重构通常不会发生。更改MODULE可能需要大范围地更新代码。这些更改可能会对团队沟通起到破坏作用,甚至会妨碍开发工具(例如源代码控制系统)的使用。因此,MODULE结构和名称往往反映了模型的较早形式,而类则不是这样
在MODULE选择的早期,有些错误是不可避免的,这些错误导致了高耦合,从而使MODULE很难进行重构。而缺乏重构又会导致问题变得更加严重。克服这一问题的唯一方法是接受挑战,仔细地分析问题的要害所在,并据此重新组织MODULE
5.5.2 基础设施驱动的打包存在的隐患
除非真正有必要将代码分布到不同的服务器上,否则就把实现单一概念对象的所有代码放在同一个模块中(如果不能放在同一个对象中的话)
利用打包把领域层从其他代码中分离出来。否则,就尽可能让领域开发人员自由地采用支持他们的模型和设计选择的方式来对领域对象进行打包
5.6 建模范式
MODEL-DRIVEN DESIGN要求使用一种与建模范式协调的实现技术。人们曾经尝试了大量的建模范式,但在实践中只有少数几种得到了广泛应用。目前,主流的范式是面向对象设计
5.6.1 对象范式流行的原因
一些团队选择对象范式并不是处于技术上的原因,甚至也不是处于对象本身的原因,而是从一开始,对象建模就在简单性和复杂性之间实现了一个很好的平衡
5.6.2 对象世界中的非对象
领域模型不一定是对象模型
当领域的主要部分明显属于不同的范式时,明智的做法是用适合各个部分的范式对每个部分分别建模,并使用混合的工具集来为实现提供支持
5.6.3 在混合范式中坚持使用MODEL-DRIVEN DESIGN
虽然MODEL-DRIVEN DESIGN不一定是面向对象的,但它确实需要一种富有表达力的模型结构实现,无论是对象,规则还是工作流,都是如此
当将非对象元素混合到以面向对象为主的系统中时,需要遵循以下4条经验规则
- 不要和实现范式对抗。我们总是可以用别的方式来考虑领域。找到适合于范式的模型概念
- 把通用语言作为依靠的基础。即使工具之间没有严格的联系时,语言使用上的高度一致性也能防止各个设计部分的分裂
- 不要一味依赖UML。有时固定使用某种工具(例如UML绘图工具)将导致人们通过歪曲模型来使它适合于一些容易画出来的图形。例如,UML确实有一些特性很适合表达约束,但它并不是在所有情况下都适用。有时使用其他绘图风格(可能适用于其他范式)或者简单的语言描述比牵强附会地适应某种绘图风格更好,因为后者主要针对的是对象的某种特定视图
- 保持怀疑态度。工具是否真正有用武之地?只是有一些规则并不一定意味着必须使用规则引擎。规则也可以表示为对象,使用规则引擎可能看起来更整齐,但多个范式会使问题变得非常复杂
第6章 领域对象的生命周期
接下来,我们将注意力转移到生命周期的开始阶段,使用FACTORY(工厂)来创建和重建复杂对象,并使用AGGREGATE来封装它们的内部结构。最后,在生命周期的中间和末尾使用REPOSITORY(存储库)来提供查找和检索持久对象并封装庞大基础设施的手段
尽管REPOSITORY和FACTORY本身并不是来源于领域,但它们在领域设计中扮演着重要的角色。这些结构提供了易于掌握的模型对象处理方式,使MODEL-DRIVEN DESIGN更完备
使用AGGREGATE进行建模,并且在设计中结合使用FACTORY和REPOSITORY,这样我们就能够以整个生命周期为一个单元系统地操纵模型对象。AGGREGATE可以划分出一个范围,这个范围内的模型元素在生命周期各个阶段都应该维护其固定规则。FACTORY和REPOSITORY在AGGREGATE基础上进行操作,将特定生命周期转换的复杂性封装起来
6.1 模式:AGGREGATE
将关联减少至最少的设计有助于简化对象之间的遍历,并在某种程度上限制关系的急剧增多。但大多数业务领域中的对象都具有十分复杂的联系,以至于最终会形成一个很长,很深的对象引用路径,我们不得不在这个路径追踪对象。在某种程度上,这种混乱状态反映了现实世界,因为现实世界中就很少有清晰的边界。这在软件设计中是一个重要问题
在具有复杂关联的模型中,要想保证对象更改的一致性是很困难的。不仅互不关联的对象需要遵守一些固定规则,而且紧密关联的各组对象也要遵守一些固定规则。然而,过于谨慎的锁定机制又会导致多个用户之间毫无意义地互相干扰,从而使系统不可用
换句话说,我们如何知道一个由其他对象组成的对象从哪里开始,又到何处结束呢?在任何具有持久化数据存储的系统中,对数据进行修改的事务必须要有一个范围,而且要有一种保持数据一致性的方式(也就是说,保持数据遵守固定规则)。数据库支持各种锁定机制,而且可以编写一些测试来验证。但这些特殊的解决方案分散了人们对模型的注意力,很快人们就会回到“走一步,看一步“的老路上来
实际上,要想找到一种兼顾各种问题的均衡解决方案,要求对领域有更深入的理解,例如要了解特定类的实例之间的更改频率这样的深层次因素。我们需要找到一个对象间冲突较少而固定规则联系更紧密的模型
AGGREGATE就是一组相关对象的集合,我们把它作为数据修改的单元。每个AGGREGATE都有一个根(root)和一个边界(boundary).边界定义了AGGREGATE的内部都有什么。根则是AGGREGATE中所包含的一个特定ENTITY。在AGREGGATE中,根是唯一允许外部对象保持对它的引用的元素,而边界内部的对象之间则可以互相引用。除根以外的其他ENTITY都有本地标识,但这些标识只有在AGGREGATE内部才需要加以区别,因为外部对象除了根ENTITY之外看不到其他对象
固定规则(invariant)是指在数据变化时必须保持不变的一致性规则。AGGREGATE内部的成员之间可能存在固定关系。AGGREGATE中的所有规则并不是每时每刻都被更新为最新的状态。通过事件处理,批处理或其他更新机制,在一定的时间内可以解决部分依赖性。但在每个事务完成时,必须满足AGGREGATE内所应用的固定规则的要求,如图6-3所示
现在,为了实现这个概念上的AGGREGATE,需要对所有事务应用一组规则
- 根ENTITY具有全局标识,它最终负责检查固定规则
- 根ENTITY具有全局标识。边界内的ENTITY具有本地标识,这些标识只有在AGGREGATE内部才是唯一的
- AGGREGATE外部的对象不能应用除根ENTITY之外的任何内部对象。根ENTITY可以把对内部ENTITY的引用传递给它们,但这些对象只能临时使用这些引用,而不能保持引用。根可以把一个VALUE OBJECT的副本传递给另一个对象,而不必关心它发生什么变化,因为它只是一个VALUE,不再与AGGREGATE有任何关联
- 作为上一条规则的推论,只有AGGREGATE的根才能直接通过数据库查询获取。所有其他对象必须通过关联的遍历才能找到
- AGGREGATE内部的对象可以保持对其他AGGREGATE根的引用
- 删除操作必须一次删除AGGREGATE边界之内的所有对象(利用垃圾收集机制,这很容易做到。由于除根以外的其他对象都没有外部引用,因此删除了根以后,其他对象均会被回收)
- 当提交对AGGREGATE边界内部的任何对象的修改时,整个AGGREGATE中的所有固定规则都必须被满足
我们应该将ENTITY和VALUE OBJECT分门别类放到AGGREGATE中,并定义每个AGGREGATE的边界。在每个AGGREGATE中,选择一个ENTITY作为根,并通过根来控制边界内其他对象的所有访问。只允许外部对象保持对根的引用。对内部成员的临时引用可以被传递出去,但仅在一次操作中有效。由于根控制访问,因此不能绕过它来修改内部对象。这种设计有利于确保AGGREGATE中的对象满足所有固定规则,也可以确保在任何状态变化时AGGREGATE作为一个整体满足固定规则
示例 采购订单的完整性
6.2 模式:FACTORY
当创建一个对象或创建整个AGGREGATE时,如果创建工作很复杂,或者暴露了过多的内部结构,则可以使用FACTORY进行封装
对象的功能主要体现在其复杂的内部配置以及关联方面。我们应该一直对一个对象进行提炼,直到所有与其意义或在交互中的角色无关的内容已完全被剔除为止。一个对象在它的生命周期当中要承担大量的职责。如果再让复杂对象复杂其自身的创建,那么职责的过载将会导致问题产生
汽车发动机是一种复杂的机械装置,它由数十个零件共同协作来履行发动机的职责----使轴转动。我们可以试着设计一种发动机组,让它自己抓取一组活塞并塞到气缸中,火花塞也可以自己找到插孔并把自己拧进去。但这样组装的复杂机器可能没有我们常见的发动机那样可靠或高效。相反,我们用其他东西来装备发动机。或许是一个机械师,或者是一个工业机器人。无论是机器人还是人,实际上都比二者要装配的发动机复杂。装配零件的工作与使轴旋转的工作完全无关。装配者的功能只是在生产汽车时才需要,我们驾驶时并不需要人或机械师。由于汽车的装配和驾驶永远不会同时发生,因此将这两种功能合并到同一个机制中是毫无价值的。同理,装配复杂的复合对象的工作也最好与对象要执行的工作分开
但将职责转交给另一个相关方(应用程序中的客户对象)会产生更严重的问题。客户知道需要完成什么工作,并依靠领域对象来执行必要的计算。如果希望让客户来装配它所需的领域对象,那么它必须要知道对象内部结构的一些知识。为了确保满足应用于领域对象中各部分关系的所有固定规则,客户必须知道对象的一些规则。甚至调用构造函数也会使客户与它要构建的对象的某些具体类产生耦合。对领域对象实现所做的任何修改都要求客户做出相应修改,这使得重构变得更加困难
当让一个客户负责创建对象时,它会变得不必要的复杂,而且其职责也会模糊不清。它会破坏领域对象的封装和AGGREGATE的创建。更严重的是,如果客户是应用层的一部分,那么职责就会从领域层泄露到应用层中。应用层与实现细节之间的这种耦合会使得领域层抽象的大部分优势荡然无存,而且导致后续更改的代价变得更加高昂
对象的创建本身可以是一个主要操作,但被创建的对象并不适合承担复杂的装配操作。将这些职责混合在一起可能导致出现难以理解的拙劣设计。让客户直接负责创建对象又会使客户的设计陷入混乱,并且破坏被装配对象或AGGREGATE的封装,而且导致客户与创建对象的实现之间产生过于紧密的耦合
复杂的对象创建是领域层的职责,然而这项任务并不属于那些用于表示模型的对象。在有些情况下,对象创建和装配会在领域种产生一次里程碑式的重要事件,例如“开一个银行账户”。但在一般情况下,对象创建和装配在领域中并没有什么意义,它们只不过是实现的一种需要。为了解决这一问题,我们必须在领域设计中增加一种新的构造,它不是ENTITY,VALUE OBJECT,也不是SERVICE。我们正在向设计中添加一些新的元素,但它们不对应于模型中的任何事物,而确实又承担领域层的部分职责
每种面向对象的语言都提供了一种创建对象的机制(例如,Java和C++中的构造函数,Smalltalk中的实例创建类),但我们仍然需要一种更加抽象且不与其他对象发生耦合的构造机制。这就是FACTORY,它是一种负责创建其他对象的程序元素。如图6-12所示
应该将创建复杂对象的实例和聚合的职责转移给一个单独的对象,这个对象本身在领域模型中可能没有职责,但它仍是领域设计的一部分。提供一个封装所有复杂装配操作的接口,而且这个接口应该不需要客户引用要被实例化的对象的具体类。在创建AGGREGATE时要把它作为一个整体,并确保它满足固定规则
FACTORY有很多种设计方式。包括FACTORY METHOD(工厂方法),ABSTRACT FACTORY(抽象工厂)和BUILDER(构建器)
任何好的工厂都需要满足以下两个基本需求
- 每个创建方法都是原子方法,而且满足被创建对象或AGGREGATE的所有固定规则
- FACTORY应该被抽象为所需的类型,而不是创建出具体的类
6.2.1 选择FACTORY及其应用位置
一般来说,FACTORY的作用是使创建出的对象将细节隐藏起来,而且我们把FACTORY用在那些需要隐藏细节的地方。这些决定通常与AGGREGATE有关
例如,如果需要向一个已存在的AGGREGATE添加元素,可以在AGGREGATE的根上创建一个FACTORY METHOD。这样就可以把AGGREGATE的内部实现细节隐藏起来,使任何外部客户看不到这些细节,同时使根负责确保AGGREGATE在被添加元素时的完整性
另一个示例是在一个对象上使用FACTORY METHOD, 这个对象与生成另一个对象密切相关,但它不拥有所生成的对象。当一个对象的创建主要使用另一个对象的数据(或许还有规则)时,则可以在后者的对象上创建一个FACTORY METHOD,这样就不必将后者的信息提取到其他地方来创建前者。这样做还有利于传递前者与后者之间的关系
在图6-14中,Trade Order不属于Brokerage Account所在的那个AGGREGATE,因为它从一开始就与交易执行应用程序进行交互,所以把它放在Brokerage Account中只会碍事。尽管如此,让Brokerage Account负责控制Trade Order的创建却是很自然的事情。Brokerage Account中含有一些将会被嵌入到Trade Order中的信息(从其自己的标识开始),而且它还包含一些规则(这些规则控制了哪些交易是允许的)。隐藏Trade Order的实现细节还会带来一些其他好处。例如,我们可以将它重构成为一个层次结构,分别为Buy Order和Sell Order创建一些子类。FACTORY可以避免客户与具体类之间产生耦合
FACTORY与其产品之间是紧密耦合的,因此FACTORY应该只被关联到与产品有着紧密联系的自然关系对象。当有些细节需要隐藏(无论要隐藏的是具体实现还是构造的复杂性)而又找不到一个自然的地方来隐藏它们时,必须创建一个专用的FACTORY对象或SERVICE。整个AGGREGATE通常由一个独立的FACTORY来创建,FACTORY负责把对根的引用传递出去,并确保创建出的AGGREGATE满足固定规则。如果AGGREGATE内部的某个对象需要一个FACTORY,而这个FACTORY又不适合在AGGREGATE根上创建,那么应该构建一个独立的FACTORY。但仍应遵守规则----把访问限制在AGGREGATE内部,并确保从AGGREGATE外部只能对产品进行临时引用
6.2.2 有些情况下只需要使用构造函数
我曾经在很多代码中看到过所有实例都是通过直接调用类构造函数来创建的,或者是使用编程语言的最基本的实例创建方式。FACTORY的引入提供了巨大的优势,而这种优势往往并未得到充分利用。但是,在有些情况下直接使用构造函数确实是最佳选择。FACTORY实际上会使那些不具有多态性的简单对象复杂化
在以下情况下最好使用简单的,公共的构造函数
- 类(class)是一种类型(type)。它不是任何相关层次结构的一部分,而且也没有通过接口实现多态性
- 客户关心的是实现,可能是将其作为选择STRATEGY的一种方式
- 客户可以访问对象的所有属性,因此向客户公开的构造函数中没有嵌套的对象创建
- 构造并不复杂
- 公共构造函数必须遵守与FACTORY相同的规则:它必须是一个原子操作,而且满足被创建对象的所有固定规则
不要在构造函数中调用其他类的构造函数。构造函数应该保持绝对简单。复杂的装配,特别是AGGREGATE,需要使用FACTORY。选择使用小的FACTORY METHOD的门槛并不高
Java类库提供了一些有趣的例子。所有集合都实现了接口,接口使得客户与具体实现之间不产生耦合。然而,它们都是通过直接调用构造函数创建的。但是,集合类本来是可以使用FACTORY来封装集合的层次结构的。而且,客户也可以使用FACTORY的方法来请求所需的特性,然后由FACTORY来选择适当的类来实例化。这样一来,创建集合的代码就会有更强的表达力,而且在安装新的集合类时不会破坏每个Java程序
但在某些场合下使用具体的构造函数更为合适。首先,在很多应用程序中,实现方式的选择对性能的影响是非常敏感的,因此应用程序需要控制选择哪种实现(尽管如此,真正智能的FACTORY仍然可以满足这些因素的要求)。不管怎样,集合类的数量并不多,因此选择并不复杂
虽然没有使用FACTORY,但抽象集合类型仍然具有一定价值,原因就在于它们的使用模式。集合通常都是在一个地方创建,而在其他地方使用。这意味着最终使用集合(添加,删除和检索其内容)的客户仍可以与接口进行对话,从而不与实现发生耦合。集合类的选择通常由拥有该集合的对象来决定,或是由该对象的FACTORY来决定
6.2.3 接口的设计
当设计FACTORY的方法签名时,无论是独立的FACTORY还是FACTORY METHOD,都要记住以下两点
- 每个操作都必须是原子的。我们必须在与FACTORY的一次交互中把创建对象所需的所有信息传递给FACTORY。同时必须决定当某些固定规则没有被满足而导致创建失败时,将执行什么操作。可以抛出一个异常或仅仅返回null。为了保持一致,可以考虑采用一个编码标准来处理FACTORY失败
- FACTORY将与其参数发生耦合。如果在选择输入参数时不小心,可能会产生错综复杂的依赖关系。耦合程度取决于对参数(argument)的处理。如果只是简单地将参数插入到要构建的对象中,则依赖度就是适中的。如果要从参数中选出一部分在构造对象时使用,耦合将更紧密
最安全的参数是那些来自较低的设计层的参数。即使在同一层中,也有一种自然的分层倾向,其中更基本的对象被更高层的对象使用
另一个好的参数选择是与模型中的产品密切相关的对象,这样不会增加新的依赖性
使用抽象类型的参数,而不是它们的具体类。FACTORY与产品的具体类发生耦合,而无需与具体的参数发生耦合
6.2.4 固定规则的逻辑应放置在哪里
FACTORY负责确保它所创建的对象或AGGREGATE满足所有固定规则,然而在把应用于一个对象的规则移到该对象外部之前应三思。FACTORY可以将固定规则检查工作委派给产品,而且这通常是最佳选择
但FACTORY与它们的产品之间存在一种特殊的关系。FACTORY已经知道其产品的内部结构,而且使用FACTORY的目的也是很明确的,那就是要把产品创建出来。在某些情况下,把固定规则逻辑放到FACTORY中是有优点的,这样可以避免产品中发生混乱。对于AGGREGATE规则来说尤其如此(这些规则约束很多对象)。但固定规则逻辑却特别不适合放到那些与其他领域对象关联的FACTORY METHOD中
6.2.5 ENTITY FACTORY与VALUE OBJECT FACTORY
ENTITY FACTORY和VALUE OBJECT FACTORY有两个方面的不同。由于VALUE OBJECT是不可变的,所以完成产品就是最终形式。因此FACTORY操作必须要提供产品的完整描述。而ENTITY FACTORY则只需要具有构造有效AGGREGATE所需的那些属性。如果固定规则不需要细节,则可以过后再添加这些细节
我们来看一下为ENTITY分配标识时将涉及的问题(VALUE OBJECT不会涉及这些问题)。既可以由程序自动分配一个标识符,也可以通过外部(通常是用户)提供一个标识符。如果客户的标识是通过电话号码追踪的,那么该电话号码必须作为参数被显式地传递给FACTORY。当由程序分配标识符时,FACTORY是控制它的理想场所。尽管唯一跟踪ID实际上是由数据库“序列”或其他基础设施机制生成的,但FACTORY知道需要什么样的标识,以及将标识放到何处
6.2.6 重建已存在的对象
到目前为止,FACTORY只是发挥了它在对象生命周期开始时的作用。到了某一时刻,大部分对象都要存储在数据库中或通过网络传输,而在当前的数据库技术中,几乎没有哪种技术能够保持对象的内容特征。大多数传输方法都要将对象转换为平面数据才能传输,这使得对象只能以非常有限的形式出现。因此,检索操作潜在地需要一个复杂的过程将各个部分重新装配成一个可用的对象
用于重建对象的FACTORY与用于创建对象的FACTORY很类似,主要有以下两点不同
- 用于重建对象的ENTITY FACTORY不分配新的跟踪ID。如果重新分配ID,将与先前的对象ID发生冲突。因此,在重建对象的FACTORY中,标识属性必须是输入参数的一部分
- 当固定规则未被满足时,重建对象的FACTORY采用不同的处理方式。当创建新对象时,如果未满足固定规则,FACTORY应该简单地拒绝创建对象,但在重建对象时则需要更加灵活的响应。如果对象已经在系统的某个地方存在(例如在数据库中),那么不能忽略这个事实。但是,同样也不能任凭规则被破坏。必须通过某种策略来修复这种不一致的情况,这使得重建对象比创建新对象更困难
FACTORY通常不表示模型的任何部分,但它们是领域设计的一部分能够使模型更明确地表示出对象
6.3 模式:REPOSITORY
无论要对一个对象执行什么操作,都需要保持一个对它的引用。那么如何获得这个引用呢?一种方法是创建对象,因为创建操作将返回新对象的引用。第二种方法是遍历一个关联。我们以一个已知对象作为起点,并向它索取一个关联的对象。这样的操作在任何面向对象的程序中都会大量用到,而且对象之间的这些链接使对象模型具有更强的表达能力
把从已存储的数据中创建实例的过程称为重建
领域驱动设计的目标是通过关注领域模型(而不是技术)来创建更好的软件
客户需要以一种符合实际的方式来获取对已存在的领域对象的引用。如果基础设施随随便便地就允许开发人员获得这些引用,那么他们可能会增加很多可遍历的关联,这会使得模型变得非常混乱。另一方面,开发人员可能使用查询从数据库中提取他们所需的数据,或是直接提取几个具体的对象,而不是通过从AGGREGATE跟开始导航来得到这些对象。这样会导致领域逻辑泄露到查询和客户代码中,而且ENTITY和VALUE OBJECT变成单纯的数据容器。大多数用于数据库访问的基础设施的技术复杂性很快就会使客户代码变得混乱,这将导致开发人员放弃领域层,最后使模型变得无关紧要
除了可以使用从根遍历来查找对象这种方法以外,禁止用其他方法对AGGREGATE内部的任何对象进行访问
大多数对象都不应该通过全局搜索来访问。
在所有持久对象中,有一小部分必须能够通过基于对象属性的搜索来全局访问。当不易通过遍历的方式来访问某些AGGREGATE根的时候,就需要使用这种访问方式。它们通常是ENTITY,有时是具有复杂内部结构VALUE OBJECT,有时还可能是枚举VALUE。而其他对象则不宜使用这种访问方式,因为这会混淆它们之间的重要区别。毫无约束的数据库查询可能会破坏领域对象的封装和AGGREGATE。技术基础设施和数据库访问机制的暴露会增加客户的复杂度,并妨碍模型驱动的设计
REPOSITORY将同一类型的所有对象表示为一个概念集合(通常是虚拟的)。它的行为类似于集合,只是具有更复杂的查询功能。在添加和删除相应类型的对象时,REPOSITORY的后台机制负责将对象添加到数据库种,或从数据库中删除对象。这个定义表明,AGGREGATE把一组紧密相关的职责收集到一起,这些职责提供了对AGGREGATE根的从其生命周期开始直至结束的全程访问
客户使用查询方法向REPOSITORY请求对象,这些查询方法根据客户所指定的标准(通常是特定属性的值)来挑选对象。REPOSITORY检索被请求的对象,并封装数据库查询和元数据映射机制。REPOSITORY可以根据客户所要求的各种标准来挑选对象。它们也可以返回汇总信息,例如有多少个实例满足查询条件。REPOSITORY甚至能返回汇总计算,例如所有匹配对象的某个数值属性的总和
REPOSITORY解除了客户的巨大负担,使客户只需与一个简单的,易于理解的接口进行对话,并根据模型向这个接口提出它的请求。要实现所有这些功能需要大量复杂的技术基础设施,但接口很简单,而且从概念上讲,接口与领域模型是紧密联系的
为每种需要全局访问的对象类型创建一个对象,这个对象就相当于该类型的所有对象在内存中的一个集合的“替身”。通过一个众所周知的接口来提供访问。提供添加和删除对象的方法,用这些方法来封装在数据存储中实际插入或删除数据的操作。提供根据具体标准来挑选对象的方法,并返回属性值满足查询标准的对象或对象集合(所返回的对象是完全实例化的),从而将实际的存储和查询技术封装起来。只为那些确实需要直接访问的AGGREGATE根提供REPOSITORY。让客户始终聚焦于模型,而将所有对象存储和访问操作交给REPOSITORY来完成
REPOSITORY有很多优点,包括:
- 它们为客户提供了一个简单的模型,可用来获取持久对象并管理它们的生命周期
- 它们使应用程序和领域设计与持久化技术(多种数据库策略甚至是多个数据源)解耦
- 它们体现了有关对象访问的设计决策
- 可以很容易将它们替换为“哑实现”(dummy implementation),以便在测试中使用(通常使用内存中的集合)
6.3.1 REPOSITORY的查询
所有存储库都为客户提供了根据某种标准来查询对象的方法,但如何设计这个接口却有很多选择
最容易构建的REPOSITORY用硬编码的方式来实现一些具有特定参数的查询。
在任何基础设施上,都可以构建硬编码式的查询,也不需要很大的投入,因为即使它们不做这些事,一些客户也必须要做
即使一个REPOSITORY的设计采用了灵活的查询方式,也应该允许添加专门的硬编码查询
6.3.2 客户代码可以忽略REPOSITORY的实现,但开发人员不能忽略
持久化技术的封装可以使得客户变得十分简单,并且使客户与REPOSITORY的实现之间完全解耦。但像一般的封装一样,开发人员必须知道在封装背后都发生了什么事情。
开发人员需要理解使用封装行为的隐含问题,但这并不意味着要熟悉实现的每个细节。设计良好的组件是可以被刻画出来的
6.3.3 REPOSITORY的实现
REPOSITORY概念在很多情况下都适用。可能的实现方法有很多,这里只能列出如下一些需要记住的注意事项
- 对类型进行抽象。REPOSITORY“含有”特定类型的所有实例,但这并不意味着每个类都需要有一个REPOSITORY。类型可以是一个层次结构中的抽象的超类(例如,TradeOrder可以是BuyOrder或SellOrder)。类型可以是一个接口(即使这个接口的实现者与该类型没有层次结构上的关联),也可以是一个具体的类。记住,由于数据库技术缺乏这样的多态性质,因此我们将面临很多约束
- 充分利用REPOSITORY与客户解耦的优点。如果客户直接调用REPOSITORY,那么我们修改其实现的自由度就小多了。也可以利用解耦来优化性能,因为这样就可以使用不同的查询技术,或在内存中缓存对象,这样可以随时自由地切换持久化策略。通过提供一个易于操纵的,内存中的(in-memory)哑实现,还能够方便客户代码和领域对象的测试
- 将事务的控制权留给客户。尽管REPOSITORY可以对数据库执行插入和删除数据的操作,但它一般不会提交事务。例如,在保存一个事务之后,应该提交它,而客户所处的位置使其看起来正应该负责初始化和提交工作单元。如果REPOSITORY不插手事务控制,那么事务管理就会简单得多
6.3.4 在框架内工作
一般来讲,在使用框架时要顺其自然。在大方向上要保持领域驱动设计的基本要素,而一些不符的细节则不必过分苛求
6.3.5 REPOSITORY与FACTORY的关系
FACTORY负责处理对象生命周期的开始,而REPOSITORY帮助管理生命周期的中间和结束。当对象驻留在内存中或存储在对象数据库中时,这是很好理解的。但通常至少有一部分对象存储在关系数据库,文件或其他非面向对象的系统中。在这些情况下,检索出来的数据必须被重建为对象形式
由于在这种情况下REPOSITORY基于数据来创建对象,因此很多人认为REPOSITORY就是FACTORY,而从技术角度来看的确如此。但我们最好还是从模型的角度来看待这一问题,而且前面讲过,重建一个已存储的对象并不是创建一个新的概念对象。 从领域驱动设计的角度来看,FACTORY和REPOSITORY具有完全不同的职责。FACTORY负责制造新对象,而REPOSITORY负责查找已有对象。REPOSITORY应该让客户感觉到那些对象就好像驻留在内存中一样。对象可能必须被重建(的确,可能会创建一个新实例),但它是同一个概念对象,仍旧处于生命周期的中间
REPOSITORY也可以委托FACTORY创建一个对象,这种方法(虽然实际很少这样做,但在理论上是可行的)可用于从头开始创建对象,此时就没有必要区分这两种看问题的角度了
这种职责上的明确区分还有助于FACTORY拜托所有持久化职责。FACTORY的工作是用数据来实例化一个可能很复杂的对象。如果产品是一个新对象,那么客户端将知道在创建完成之后应该把它添加到REPOSITORY中,由REPOSITORY来封装对象在数据库中的存储
另一种情况促使人们将FACTORY和REPOSITORY结合起来使用,这就是想要实现一种“查找或创建”功能,即客户端描述它所需的对象,如果找不到这样的对象,则为客户端新创建一个。我们最好不要追求这种功能,它不会带来多少方便。当讲ENTITY和VALUE OBJECT区分开时,很多看上去有用的功能就不复存在了。需要VALUE OBJECT的客户可以直接请求FACTORY来创建一个。通常,在领域中将新对象和原有对象区分开是很重要的,而将它们明显组合在一起的框架实际上只会使局面变得混乱
6.4 为关系数据库设计对象
在以面向对象技术为主的软件系统中,最常用的非对象组件就是关系数据库
大多数情况下关系数据库是面向对象领域中的持久化存储形式,因此简单的对应关系才是最好的
第7章 使用语言:一个扩展的示例
7.1 货物运输系统简介
假设我们正在为一家货运公司开发新的软件。最初的需求包括3项基本功能
- 跟踪客户货物的主要处理部署
- 事先预约货物
- 当货物到达其处理过程中的某个位置时,自动向客户寄送发票
这个模型将领域知识组织起来,并为团队提供了一种语言。我们可以做出像下面这样的描述
“一个Cargo(货物)涉及多个Customer(客户),每个Customer承担不同的角色”
“Cargo的运送目标已指定”
“由一系列满足Specification(规格)的Carrier Movement(运输动作)来完成运送目标”
Handling Event(处理事件)是对Cargo采取的不同操作,例如将它装上船,或清关。这个类可以被细化为一个由不同种类的事件(例如装货,卸货或由收货人提货)构成的层次结构
Delivery Specification(运送规格)定义了运送目标,这至少包括目的地和到达日期,但也可能更为复杂。
这个职责本来可以由Cargo对象来承担,但将Delivery Specification抽象出来至少有以下3个优点
- 如果没有Delivery Specification,Cargo对象就需要负责提供用于指定运送目标的所有属性和关联的详细意义。这会使Cargo对象变得混乱,导致它难以理解或修改
- 当将模型作为一个整体来解释时,这个抽象使我们能够轻松且安全地省略掉细节。例如,Delivery Sepcification中可能还封装了其他标准,但这种细节级别的图可以不必显示出来。这个图告诉读者有一个运送规格,但其细节却并不是要考虑的重点(事实上,过后修改细节也很容易)
- 这个模型具有更强的表达力。Delivery Specification清楚地说明了Cargo运送的具体方式没有明确规定,但它必须完成Delivery Specification中规定的目标
Customer在运输中所承担的部分是按照角色(role)来划分的,例如shipper(托运人),receiver(收货人),payer(付款人),等等。由于一个Cargo只能由一个Customer来承担某一给定角色,因此它们之间的关联是限定的多对一关系,而不是多对多。角色可以被简单地实现为字符串,当需要其他行为的时候,也可以将它实现为类
Carrier Movement表示由某个Carrier(例如一辆卡车或一艘船)执行的从一个Localtion(地点)到另一个Location的旅程。Cargo被装上Carrier后,通过Carrier的一个或多个Carrier Movement,就可以在不同地点之间转移
Delivery History(运送历史)反映了Cargo实际上都发生了什么事情,它与Delivery Specification正好相对,后者描述了目标。Delivery History对象可以通过分析最后一次装货和卸货以及对应的Carrier Movement的目的地来计算货物的当前位置。成功的运送将会是一个满足Delivery Specification目标的Delivery History对象
将满足上述需求的所有概念都在这个模型中表示出来,并假定已经有了一些适当的机制来实现保存对象,查找相关对象等功能。这些实现问题不在模型中处理,但它们必须在设计中考虑到
7.2 隔离领域:应用程序的引入
为了防止领域的职责与系统的其他部分的职责混杂在一起,我们应用LAYERED ARCHITECTURE把领域层划分出来
无需进行深入的分析,就可以识别出三个用户层的应用程序功能,我们可以将这三个功能分配给三个应用层
- 第一个类是Tracking Query(跟踪查询),它可以访问某个Cargo过去和现在的处理情况
- 第二个类是Booking Application(预订应用),它允许注册一个新的Cargo,并使系统准备好处理它
- 第三个类是Incident Logging Application(事件日志应用),它记录对Cargo的每次处理(提供通过Tracking Query找到的信息)
这些应用层类是协调者,它们只是负责提问,而不负责回答,回答是领域层的工作
7.3 将ENTITY和VALUE OBJECT区别开
依次考虑每个对象,看看这个对象是必须被跟踪的实体还是仅表示一个基本值。首先,我们来看一些比较明显的情况,然后考虑更含糊的情况
Customer
Customer对象表示一个人或一家公司,从一般意义上来讲它是一个实体。Customer对象显然有一个对用户来说很重要的标识,因此它在模型中是一个ENTITY
ID号最初是手工录入的
Cargo
两个完全相同的货箱必须要区分开,因此Cargo对象是ENTITY。在实际情况中,所有运输公司都会为每件货物分配一个跟踪ID。这个ID是自动生成的,对用户是可见的,而且在本例中可能在预订时还要发送给客户
所有运输公司都会为每件货物分配一个跟踪ID。这个ID是自动生成的,对用户是可见的
Handling Event和Carrier Movement
我们关心这些独立事件是因为通过它们可以跟踪正在发生的事情。它们反映了真实世界的事件,而这些事件一般是不能互换的,因此它们是ENTITY。
用CargoID,完成时间和类型的组合。例如,同一个Cargo不会在同一时间既装货又卸货
Location
使用任何一种自动生成的内部标识符就足够了
Delivery History
Delivery History是不可互换的,因此它是ENTITY。但Delivery History与Cargo是一对一关系,因此它实际上并没有自己的标识。它的标识是从拥有它的Cargo处借来的。当对AGGREGATE进行建模时这个问题会变得更清楚
Delivery Specification
尽管它表示了Cargo的目标,但这种抽象并不依赖于Cargo。它实际上表示某些Delivery History的假定状态。运送货物实际上就是让Cargo的Delivery History最后满足该Cargo的Delivery Sepcification。 如果有两个Cargo去往同一地点,则它们可以用同一个Delivery Specification,但它们不共用同一个Delivery History,尽管运送历史都是从同一个状态(空)开始。因此,Delivery Specification 是VALUE OBJECT
Role和其他属性
Role表示了有关它所限定的关联的一些信息,但它没有历史或连续性。因此它是一个VALUE OBJECT,可以在不同的Cargo/Customer关联中共享它
其他属性(例如时间戳或名称)都是VALUE OBJECT
7.4 设计运输系统中的关联
双向关联在设计中是容易产生问题的.只有深刻理解领域后才能确定遍历方向,因此理解遍历方向能够使模型更深入
如果Customer对它所运送的每个Cargo都有一个直接的引用,那么这对一些长期,频繁托运货物的客户将会非常不便。此外,Customer这一概念并非只与Cargo相关。在大型系统中,Customer可能具有多种角色,以便与许多对象交互,因此最好不要将它限定为这种具体的职责。如果需要按照Customer来查找Cargo,那么可以通过数据库查询来完成。
如果我们的应用程序要对一系列货船进行跟踪,那么从Carrier Movement遍历到Handling Event将是很重要的。但我们的业务只需跟踪Cargo,因此只需从Handling Event遍历到Carrier Movement就能满足我们的业务需求,这也把实现简化为一个简单的对象引用,因为双向关系已经被禁用
在我们的模型中有一个循环引用,Cargo知道它的Delivery History,Delivery History中保存了一系列的Handling Event,而Handling Event反过来又指向Cargo。很多领域在逻辑上都存在循环引用,而且循环引用在设计中有时是必要的,但它们维护起来很复杂。在选择实现时,应该避免把必须同步的信息保存在两个不同的地方
7.5 AGGREGATE边界
Customer,Location和Carrier Movement都有自己的标识,而且被许多Cargo共享,因此,它们在各自的AGGREGATE中必须是根,这些聚合除了包含它们的属性之外,可能还包含其他比这里讨论的细节级别更低层的对象。Cargo也是一个明显的AGGREGATE根,但把它的边界画在哪里还需要仔细思考一下
Cargo AGGREGATE可以把一切只因Cargo存在的事物包含进来,这当中包括Delivery History,Delivery Specification和Handling Event。这很适合Delivery History,因为没人会在不知道Cargo的情况下直接去查询Delivery History。因为Delivery History不需要直接的全局访问,而且它的标识实际上只是由Cargo派生出的,因此很适合将Delivery History放在Cargo的边界之内,并且它也无需是一个AGGREGATE根。Delivery Specification是一个VALUE OBJECT,因此将它包含在Cargo AGGREGATE中也不复杂
Handling Event就是另外一回事了。前面已经考虑了两种与其有关的数据库查询,一种是当不想使用集合时,用查找某个Delivery History的Handling Event作为一种可行的代替方法,这种查询是位于Cargo AGGREGATE内部的本地查询;另一种查询是查找装货和准备某次Carrier Movement时所进行的所有操作。在第二种情况下,处理Cargo的活动看起来是有意义的(即使是与Cargo本身分开来考虑时也是如此)因此Handling Event应该是它自己的AGGREGATE根
7.6 选择REPOSITORY
在我们的设计中,有5个ENTITY是AGGREGATE根,因此在选择存储库时只需要考虑这5个实体,因为其他对象都不能有REPOSITORY
7.7 场景走查
为了复核这些决策,我们需要经常走查场景,以确保能够有效地解决应用问题
7.7.1 应用程序特性举例:更改Cargo的目的地
有时一个Customer会打电话说:”糟了!我们原来说把货物运到Hackensack,但实际上应该运往Hoboken。” 既然我们提供了运输服务,就一定要让系统能够进行这样的修改
Delivery Specification是一个VALUE OBJECT,因此最简单的方法是放弃它,再创建一个新的,然后使用Cargo上的setter方法把旧值替换成新值
7.7.2 应用程序特性举例:重复业务
用户指出,相同Customer的重复预订往往是类似的,因此他们想要将旧Cargo作为新Cargo的原型。应用程序应该允许用户在存储库中查找一个Cargo,然后选择一条命令来基于选中的Cargo创建一个新的Cargo。我们将利用PROTOTYPE来设计这一功能
Cargo是一个ENTITY,而且是AGGREGATE的根。因此在复制它时要非常小心,其AGGREGATE边界内的每个对象或属性的处理都需要仔细考虑,下面逐个来看一下
- Delivery History:应创建一个新的,空的Delivery History,因为原有Delivery History的历史并不适用。这是AGGREGATE内部的实体的常见情况
- Customer Rules: 应该复制Map(或其他集合),Map保存了对Customer的引用(这些引用是用键值标识的),键也应该一起复制,因为它们在新的运输业务中的角色可能是相同的。但必须注意不要复制Customer对象本身。在复制之后,应该保证和原来的Cargo引用相同的一些Customer对象,因为它们是AGGREGATE边界之外的ENTITY
- Tracking ID:我们必须提供一个新的Tracking ID,它应该来自创建新Cargo时的同一个来源
注意,我们复制了Cargo AGGREGATE边界内部的所有对象,并对副本进行了一些修改,但这并没有对AGGREGATE边界之外的对象产生任何影响
7.8 对象的创建
7.8.1 Cargo的FACTORY和构造函数
即使为Cargo使用了一个复杂而精致的FACTORY,或像“重复业务”一节的情况一样使用另一个Cargo作为FACTORY,我们仍然需要有一个基本的构造函数。我们希望用构造函数来生成一个满足固定规则的对象,或者在所生成的对象是ENTITY的时候,至少保持其标识不变
做出这些决定之后,我们可以在Cargo上创建一个FACTORY方法,如下所示
public Cargo copyPrototype(String newTrackingID)
或者可以为一个独立的FACTORY添加以下方法
public Cargo newCargo(Cargo prototype, String newTrackingID)
独立的FACTORY还可以把为新的Cargo获取新的(自动生成的)ID的过程封装起来,这样它只需要一个参数
public Cargo newCargo(Cargo prototype)
这些FACTORY返回的结果是完全相同的,都是一个Cargo,其Delivery History为空,且Delivery Specification为null
Cargo与Delivery History之间的双向关联意味着它们必须要互相指向对方才算是完成的,因此它们必须一起被创建。记住,Cargo是AGGREGATE的根,而这个AGGREGATE包含Delivery History。因此,我们可以用Cargo的构造函数或FACTORY来创建Delivery History。Delivery History构造函数将把Cargo作为参数。这样就可以编写以下代码
public Cargo(String id) {
trackingID = id;
deliveryHistory = new DeliveryHistory(this);
customerRoles = new HashMap();
}
结果得到一个新的Cargo,它带有一个指向它自己的新的Delivery History。Delivery History构造函数只供其AGGREGATE根(即Cargo)使用,这样Cargo的组成就被封装起来了
7.8.2 添加一个Handling Event
货物在真实世界中的每次处理,都会有人使用Incident Logging Application来输入一条Handling Event记录
每个类都必须有一个基本的构造函数。由于Handling Event是一个ENTITY,所以必须把定义了其标识的所有属性传递给构造函数。如前所述,Handling Event是通过Cargo的ID,完成时间和时间类型的组合来唯一标识的。Handling Event唯一剩下的属性是与Carrier Movement的关联,而有些类型的Handling Event甚至没有这个属性。综上,创建一个有效的Handling Event的基本构造函数是:
public HandlingEvent(Cargo c, String eventType, Date timeStamp) {
handled = c;
type = eventType;
completionTime = timeStamp;
}
在ENTITY中,那些不起到标识作用的属性通常可以过后再添加。在本例中,Handling Event的所有属性都是在初始事务中设置的,而且过后不再改变(纠正数据录入错误除外),因此为每个事件类型的Handling Event添加一个简单的FACTORY METHOD(并带有所有必要的参数)是很方便的做法,这还使得客户代码具有更强的表达能力。例如,loading event(装货事件)确实涉及一个Carrier Movement
public static HandlingEvent newLoading(Cargo c, CarrierMovement loadedOnto, Date timeStamp) {
HandlingEvent result = new HandlingEvent(c, LOADING_EVENT, timeStamp);
result.setCarrierMovement(loadedOnto);
return result;
}
模型中的Handling Event是一种抽象,它可以把各种专用的Handling Event类封装起来,包括装货,卸货,密封,存放以及其他在与Carrier无关的活动。它们可以被实现为多个子类,或者通过复杂的初始化过程来实现,也可以将这两种方法结合起来使用。通过在基类(Handling Event)中为每个类型添加FACTORY METHOD,可以将实例创建的工作抽象出来,这样客户就不必知道实现的知识。FACTORY必须知道哪个类需要被实例化,以及应该如何对它初始化
遗憾的是,事情并不是这么简单。Cargo->Delivery History->History Event->Cargo这个引用循环使实例创建变得很复杂。Delivery History保存了与其Cargo有关的Handling Event集合,而且新对象必须作为事务的一部分被添加到这个集合中。如果没有创建这个反向指针,那么对象间将发生不一致
我们可以把反向指针的创建封装到FACTORY中(并将其放在领域层中----它属于领域层),但我们来看另一种设计,它完全消除了这种别扭的交互
7.9 停下来重构:Cargo AGGREGATE的另一种设计
7.10 运输模型中的MODULE
按模式划分看起来像是一个明显的错误,但按照对象是持久对象还是临时对象来分开,或者使用任何其他划分方法,而不是根据对象的意义来划分,也同样没有什么意义
相反,我们应该寻找紧密关联的概念,并弄清楚我们打算向项目中的其他人员传递什么信息。在做一些较小的建模决定时,可以采用很多划分方式。图7-8显示了一个简单的例子
7.11 引入新特性:配额检查
7.11.1 连接两个系统
销售管理系统(Sales Management System)并不是根据这里所使用的模型编写的。如果Booking Application与它直接交互,那么我们的应用程序就必须兼容另外一个系统(即Cargo Repository)的设计,这将很难保持一个清晰的MODEL-DRIVEN DESIGN,而且将混淆UBIQUITOUS LANGUAGE。相反,我们创建另一个类,让它充当我们的模型和销售管理系统的语言之间的翻译。它并不是一种通用的翻译机制,而只是对我们的应用程序所需的特性进行翻译,并根据我们的领域模型重新对这些特性进行抽象。这个类将作为一个ANTICORRUPTION LAYER
7.11.2 进一步完善模型:划分业务
7.11.3 性能优化
7.12 小结
第8章 突破
一般来说,持续重构是在为突破做好准备
8.1 一个突破的故事
8.1.1 华而不实的模型
8.1.2 突破
8.1.3 更深层模型
8.1.4 冷静决策
8.1.5 成果
8.2 机遇
8.3 关注根本
8.4 后记:越来越多的新理解
第9章 将隐式概念转变为显式概念
9.1 概念挖掘
9.1.1 倾听语言
9.1.2 检查不足之处
9.1.3 思考矛盾之处
9.1.4 查阅书籍
9.1.5 尝试,再尝试
9.2 如何为那些不太明显的概念建模
9.2.1 显式的约束
9.2.2 作为领域对象的过程
9.2.3 模式:SPECIFICATION
9.2.4 SPECIFICATION的应用和实现
第10章 柔性设计
为了使项目能够随着开发工作的进行加速前进,而不会由于它自己的老化停滞不前,设计必须要让人们乐于使用,而且易于做出修改。这就是柔性设计(supple design)
10.1 模式: INTENTION-REVEALING INTERFACES
如果开发人员为了使用一个组件而必须要去研究它的实现,那么就失去了封装的价值。当某个人开发的对象或操作被别人使用时,如果使用这个组件的新的开发者不得不根据其实现来推测其用途,那么他推测出来的可能并不是那个操作或类的主要用途。如果这不是那个组件的用途,虽然代码暂时可以工作,但设计的概念基础已经被误用了,两位开发人员的意图也是背道而驰
在命名类和操作时要描述它们的效果和目的,而不要表露它们是通过何种方式达到目的的。这样可以使客户开发人员不必理解内部的细节。这些名称应该与UBIQUITOUS LANGUAGE保持一致,以便团队成员可以迅速推断出它们的意义。在创建一个行为之前先为它编写一个测试,这样可以促使你站在客户开发人员的角度上来思考它
示例 重构:调漆应用程序
一家油漆店的程序能够为客户显示出标准调漆的结果。下面是一个初始的设计,它有一个简单的领域类
paint(Paint)方法的行为根本猜不出,想知道它的唯一方法就是阅读代码
public void paint(Paint paint) { v = v + paint.getV(); // After mixing, volume is summed // Omitted many lines of complicated color mixing logic // ending with the assignment of new r, b, and y values.
从代码上看,这个方法是把两种油漆(Paint)混合到一起,结果是油漆的体积增加了,并变为混合颜色
为了换个角度来看问题,我们为这个方法编写一个测试(这段代码基于JUnit测试框架)
public void testPaint() { // Create a pure yellow paint with volume = 100 Paint yellow = new Paint(100.0, 0, 50.0); // Create a pure blue paint with volume = 100 Paint blue = new Paint(100.0, 0, 0, 50); // Mix the blue into the yellow yellow.paint(blue); // Result should be volume of 200.0 of green paint assertEquals(200.0, yellow.getV(), 0.01); assertEquals(25, yellow.getB()); assertEquals(25, yellow.getY()); assertEquals(0, yellow.getR()); }
通过这个测试只是一个起点,这无法让令我们满意,因为这段测试代码并没有告诉我们这个方法都做了什么。让我们来重新编写这个测试,看一下如果我们正在编写一个客户应用程序的话,将以何种方式来使用Paint对象。最初,这个测试会失败。实际上,它甚至不能编译。我们编写它的目的是从客户开发人员的角度来研究一下Paint对象的接口设计
public void testPaint() { // Start with a pure yellow paint with volume = 100 Paint ourPaint = new Paint(100.0, 0, 50, 0); // Take a pure blue paint with volume = 100 Paint blue = new Paint(100.0, 0, 0, 50); // Mix the blue into the yellow ourPaint.mixIn(blue); // Result should be volume of 200.0 of green paint assertEquals(200.0, ourtPaint.getVolume(), 0.01); assertEquals(25, ourPaint.getBlue()); assertEquals(25, ourPaint.getYellow()); assertEquals(0, ourPaint.getRed()); }
花时间编写这样的测试是非常必要的,因为它可以反映出我们希望以哪种方式与这些对象进行交互。在这之后,我们重构Paint类,使它通过测试
新的方法名称可能不会告诉读者有关混合另一种油漆(Paint)的效果的所有信息(要达到这个目的需要使用断言,接下来我们就会讨论它)。但从这个名称为读者提供了足够多的线索,使读者可以开始使用这个类,特别是从测试提供的示例开始。而且它还使客户代码的阅读者能够理解客户的意图
10.2 模式:SIDE-EFFECT-FREE FUNCTION
我们可以宽泛地把操作分为两个大的类别:命令和查询。查询是从系统获取信息,查询的方式可能只是简单地访问变量中的数据,也可能是用这些数据执行计算。命令(也称为修改器)是修改系统的操作(举一个简单的例子,设置变量)。在标准英语中,“副作用‘这个词暗示着”意外的结果’,但在计算机科学中,任何对系统状态产生的影响都叫副作用。这里为了便于讨论,我们把它的含义缩小一下,任何对未来操作产生影响的系统状态的改变都可以称为副作用
为什么人们会采用“副作用”这个词来形容那些显然是有意影响系统状态的操作呢?我推测这大概是来自于复杂系统的经验。大多数操作都会调用其他的操作,而后者又会调用另外一些操作。一旦形成这种任意深度的嵌套,就很难预测调用一个操作将要产生的所有后果。第二层和第三层操作的影响可能并不是客户开发人员有意而为之,于是它们就变成了完全意义上的副作用。在一个复杂的设计中,元素之间的交互同样也会产生无法预料的结果。副作用这个词强调了这种交互的不可避免性
多个规则或计算组合的相互作用所产生的结果是很难预测的。开发人员在调用一个操作时,为了预测操作的结果,必须理解它的实现以及所有派生操作的实现。如果开发人员不得不“揭开接口的面纱”,那么接口的抽象的作用就受到了限制。如果没有了可以安全地预见到结果的抽象,开发人员就必须限制“组合爆炸”,这就限制了系统行为的丰富性
返回结果而不产生副作用的操作称为函数。一个函数可以被多次调用,每次调用都返回相同的值。一个函数可以调用其他函数,而不必担心这种嵌套的深度。函数比那些有副作用的操作更易于测试。由于这些原因,使用函数可以降低风险
显然,在大多数软件系统中,命令的使用都是不可避免的,但有两种方法可以减少命令产生的问题。首先,可以把命令和查询严格地放在不同的操作中。确保导致状态改变的方法不返回领域数据,并尽可能保持简单。在不引起任何可观测到的副作用的方法中执行所有查询和计算
第二,总是有一些替代的模型和设计,它们不要求对现有对象做任何修改。相反,它们创建并返回一个VALUE OBJECT,用于表示计算结果。这是一种很常见的技术,在接下来的示例中我们就会演示出它的使用。VALUE OBJECT可以在响应一次查询中被创建和传递,然后被丢弃----不像ENTITY,实体的生命周期是受到严格管理的
VALUE OBJECT是不可变的,这意味着除了在创建期间调用的初始化程序之外,它们的所有操作都是函数。像函数一样,VALUE OBJECT使用起来很安全,测试也很简单。如果一个操作把逻辑或计算与状态改变混合在一起,那么我们就应该把这个操作重构为两个独立的操作。但从定义上来看,这种把副作用隔离到简单的命令方法中的做法仅适用于ENTITY。在完成了修改和查询的分离之后,可以考虑再进行一次重构,把复杂计算的职责转移到VALUE OBJECT中。通过派生出一个VALUE OBJECT(而不是改变现有状态),或者通过把职责完全转移到一个VALUE OBEJCT中,往往可以完全消除副作用
尽可能把程序的逻辑放到函数中,因为函数是只返回结果而不产生明显副作用的操作。严格地把命令(引起明显的状态改变的方法)隔离到不返回领域信息的,非常简单的操作中。当发现了一个非常适合承担复杂逻辑职责的概念时,就可以把这个复杂逻辑到VALUE OBJECT中,这样可以进一步控制副作用
SIDE-EFFECT-FREE FUNCTION,特别是不变的VALUE OBJECT,允许我们安全地对多个操作进行组合。当通过一个INTENTION-REVEALING INTERFACE把一个FUNCTION呈现出来的时候,开发人员就可以在无需理解其实现细节的情况下使用它
示例 再次重构调漆应用程序
下面是重构后得到的那个类
public void mixIn(Paint other) { volume = volume.plus(other.getVolume()); // Many lines of complicated color-mixing logic // ending with the assignment of new red, blue, and yellow values }
在这个领域中,颜色是一个重要的概念。把Pigment Color(颜料颜色)分离出来之后,确实比先前表达了更多信息,但计算还是相同的。要注意Pigment Color是一个VALUE OBJECT。因此,它应该是不可变的。当我们调漆时,Paint对象本身被改变了,它是一个具有生命周期的实体。相反,标识某个色调(例如黄色)的Pigment Color则一直表示那种颜色。调漆的结果是产生一个新的Pigment Color对象,用于表示新的颜色
public class PigmentColor { public PigmentColor mixedWith(PigmentColor other, double ratio) { // Many lines of complicated color-mixing logic // ending with the creation of a new PigmentColor object // with appropriate new red, blue, and yellow values } } public class Paint { public void mixIn(Paint other) { volume = volume + other.getVolume(); double ratio = other.getVolume() / volume; pigmentColor = pigmentColor.mixedWith(other.pigmentColor(), ratio); } }
现在,Paint中的代码已经尽可能简单了。新的Pigment Color类捕获了知识,并显式地把这些知识表达出来,而且它提供了一个SIDE-EFFECT-FREE FUNCTION,函数的计算结果很容易理解,也很容易测试,因此可以安全地使用或与其他操作进行组合。由于它的安全性很高,因此复杂的调色逻辑真正被封装起来了。使用这个类的开发人员不必理解其实现
10.3 模式:ASSERTION
把复杂的计算封装到SIDE-EFFECT-FREE FUNCTION中可以简化问题,但实体仍然会留有一些有副作用的命令,使用这些ENTITY的人必须了解使用这些命令的后果。在这种情况下,使用ASSERTION(断言)可以把副作用明确地表示出来,使它们更易于处理
如果操作的副作用仅仅是由它们的实现隐式定义的,那么在一个具有大量相互调用关系的系统中,起因和结果会变得一团糟。理解程序的唯一方式就是沿着分支路径来跟踪程序的执行。封装完全失去了价值。跟踪具体的执行也使抽象失去了意义
我们需要在不深入研究内部机制的情况下理解设计元素的意义和执行操作的后果。INTENTION-REVEALING INTERFACE可以起到一部分作用,但这样的接口只能非正式地给出操作的用途,这常常是不够的。“契约式设计”(design by contract)向前推进了一步,通过给出类和方法的“断言”使开发人员知道肯定会发生的结果。 [Meyer 1998]中详细讨论了这种设计风格。简言之,“后置条件”描述了一个操作的副作用,也就是调用一个方法之后必然会发生的结果。“前置条件”就像是合同条款,即为了满足后置条件而必须要满足的前置条件。类的固定规则规定了在操作结束时对象的状态。也可以把AGGREGATE作为一个整体来为它声明固定规则,这些都是严格定义的完整性规则
所有这些断言都描述了状态,而不是过程,因此它们更易于分析。类的固定规则在描述类的意义方面起到帮助作用,并且使客户开发人员能够更准确地预测对象的行为,从而简化他们的工作。如果你确信后置条件的保证,那么就不必考虑方法是如何工作的。断言应该已经把调用其他操作的效果考虑在内了
把操作的后置条件和类及AGGREGATE的固定规则表述清楚。如果在你的编程语言中不能直接编写ASSERTION,那么就把它们编写成自动的单元测试。还可以把它们写到文档或图中(如果符合项目开发风格的话)
寻找在概念上内聚的模型,以便使开发人员更容易推出预期的ASSERTION,从而加快学习过程并避免代码矛盾
尽管很多面向对象的语言目前都不支持直接使用ASSERTION,但ASSERTION仍然不失为一种功能强大的设计方法。自动单元测试在一定程度上弥补了缺乏语言支持带来的不足。由于ASSERTION只声明状态,而不声明过程,因此很容易编写测试。测试首先设置前置条件,在执行之后,再检查后置条件是否被满足
把固定规则,前置条件和后置条件清楚地表述出来,这样开发人员就能够理解使用一个操作或对象的后果。从理论上讲,如果一组断言之间互不矛盾,那么就可以使用。但人的大脑并不会一丝不苟地把这些断言编译到一起。人们会推断和补充模型的概念,因此找到一个既易于理解又满足应用程序需求的模型是至关重要的
示例 回到调漆应用程序
public void testMixingVolume { PigmentColor yellow = new PigmentColor(0, 50, 0); PigmentColor blue = new PigmentColor(0, 0, 50); StockPaint paint1 = new StockPaint(1.0, yellow); StockPaint paint2 = new StockPaint(1.5, blue); MixedPaint mix = new MixedPaint(); mix.mixIn(paint1); mix.mixIn(paint2); assertEquals(2.5, mix.getVolume(), 0.01); }
INTENTION-REVEALING INTERFACE清楚地表明了用途,SIDE-EFFECT-FREE FUNCTION和ASSERTION使我们能够更准确地预测结果,因此封装和抽象更加安全
10.4 模式:CONCEPTUAL CONTOUR
有时,人们会对功能进行更细的分解,以便灵活地组合它们,有时却要把功能组合成大块,以便封装复杂性。有时,人们为了使所有类和操作都具有相似的规模而寻找一种一致的粒度。这些方法都过于简单了,并不能作为通用的规则。但使用这些方法的动机都来自于一系列基本的问题
如果把模型或设计的所有元素都放在一个整体的大结构中,那么它们的功能就会发生重复。外部接口无法全部给出客户可能关心的信息。由于不同的概念被混合在一起,它们的意义变得很难理解
而另一方面,把类和方法分解开也是毫无意义的,这会使客户更复杂,迫使客户对象去理解各个小部分是如何组合在一起的。更糟的是,有的概念可能会完全丢失。铀原子的一般并不是铀。而且,粒度的大小并不是唯一要考虑的问题,我们还要考虑粒度是在哪种场合下使用的
把设计元素(操作,接口,类和AGGREGATE)分解为内聚的单元,在这个过程中,你对领域中一切重要划分的直观认识也要考虑在内。在连续的重构过程中观察发生变化和保证稳定的规律性,并寻找能够解释这些变化模式的底层CONCEPTUAL CONTOUR。使模型与领域中那些一致的方面(正是这些方面使得领域成为一个有用的知识体系)相匹配
10.5 模式:STANDALONE CLASS
互相依赖性使模型和设计变得难以理解,测试和维护。而且,互相依赖性很容易越积越多
当然,每个关联都是一种依赖性,要想理解一个类,必须理解它与哪些对象有联系。与这个类有联系的其他对象还会与更多的对象发生联系,而这些联系也是必须要弄清楚的。每个方法的每个参数的类型也是一个依赖性,每个返回值也都是一个依赖性
如果有一个依赖关系,我们必须同时考虑两个类以及它们之间的关系的本质。如果某个类依赖另外两个类,我们就必须考虑这三个类当中的每一个,这个类与其他两个类之间的相互关系的本质,以及这三个类可能存在的其他下相互关系。如果它们之间一次存在依赖关系,那么我们还必须考虑这些关系。如果一个类有三个依赖关系,问题就会像滚雪球一样越来越多
MODULE和AGGREGATE的目的都是为了限制互相依赖的关系网。当我们识别出一个高度内聚的子领域并把它提取到一个MODULE中的时候,一组对象也随之与系统的其他部分解除了联系,这样就把互相联系的概念的数量控制在一个有限的范围之内。但是,即使把系统分成了各个MODULE,如果不严格控制MODULE内部的依赖性的话,那么MODULE也一样会让我们耗费很多精力去考虑依赖关系。
即使是在MODULE内部,设计也会随着依赖关系的增加而变得越来越难以理解。这加重了我们的思考负担,从而限制了开发人员能处理的设计复杂度。隐式概念比显式的引用增加的负担更大
我们可以讲模型一致精炼下去,直到每个剩下的概念关系都表示出概念的基本含义为止。在一个重要的子集中,依赖关系的个数可以减小到零,这样就得到一个完全孤立的类,它只有很少的几个基本类型和基础库概念
在每种编程环境中,都有一些非常基本的概念,它们经常用到,以至于已经根植于我们的大脑中。例如,在JAVA开发环境中,基本类型和一些标准类库提供了数字,字符串和集合等基本概念。从实际来讲,“整数“这个概念是不会增加思考负担的。除此之外,为了理解一个对象而必须保留在大脑中的每个其他概念都会增加思考负担
隐式概念,无论是否已被识别出来,都与显式引用一样会加重思考负担。虽然我们通常可以忽略像整数和字符串这样的基本类型值,但无法忽略它们所表示的意义。例如,在第一个调漆应用程序的例子中,Paint对象包含三个公共的整数,分别表示红,黄,蓝三种颜色值。Pigment Color对象的创建并没有增加所涉及的概念数量,也没有增加依赖关系。但它确实使现有概念更明显,更易于理解了。另一方面,Collection的size()操作返回一个整数(只是一个简单的合计数),它只表示整数的基本含义,因此并不产生隐式的新概念
我们应该对每个依赖关系剔除质疑,直到证实它确实表示对象的基本概念为止。这个仔细检查依赖关系的过程从提取模型概念本身开始。然后需要注意每个独立的关联和操作。仔细选择模型和设计能够大幅减少依赖关系----常常能减少到零
低耦合是对象设计的一个基本要素。尽一切可能保持低耦合。把其他所有无关概念提取到对象之外。这样类就变成完全孤立的了,这就使得我们可以单独地研究和理解它。每个这样的孤立类都极大地减轻了因理解MODULE而带来的负担
当一个类与它所在的模块中的其他类存在依赖关系时,比它与模块外部的类有依赖关系要好得多。同样,当两个对象具有自然的紧密耦合关系时,这两个对象共同涉及的多个操作实际上能够把它们的关系本质明确地表示出来。我们的目标不是消除所有依赖,而是消除所有不重要的依赖。当无法消除所有的依赖关系时,每清除一个依赖对开发人员而言都是一种解脱,使他们能够集中精力处理剩下的概念依赖关系
尽力把最复杂的计算提取到STANDLONE CLASS(孤立的类)中,可能实现此目的的一种方法是把具有紧密联系的类中所含有的VALUE OBJECT建模出来
从根本上讲,油漆的概念与颜色的概念紧密相关。但在考虑颜色(甚至是颜料)的时候却不必去考虑油漆。通过把这两个概念变为显式概念并精炼它们的关系,所得到的单向关联就可以表达出重要的信息,同时我们可以对Pigment Color类(大部分计算复杂性都隐藏在这个类中)进行独立的分析和测试
低耦合是减少概念过载的最基本办法。孤立的类是低耦合的极致
消除依赖性并不是说要武断地把模型中的一切都简化为基本类型,这样只会削弱模型的表达能力。CLOSURE OF OPERATION(闭合操作)就是一种在减小依赖性的同时保持丰富接口的技术
10.6 模式:CLOSURE OF OPERATION
两个实数相乘,结果仍为实数(实数是所有有理数和所有无理数的集合)。由于这一点永远是正确的,因此我们说实数的”乘法运算是闭合的“:乘法运算的结果永远无法脱离实数这个集合。当我们对集合中的任意两个元素组合时,结果仍在这个集合中,这就叫做闭合操作
当然,依赖是必然存在的,当依赖性是概念的一个基本属性时,它就不是坏事。如果把接口精简到只处理一些基本类型,那么它也就没有什么表达能力了。但我们经常为接口引入很多不必要的依赖性,甚至是整个不必要的概念
大部分引起我们兴趣的对象所产生的行为仅用基本类型是无法描述的
闭合的性质极大地简化了对操作的理解,而且闭合操作的链接或组合也很容易理解
在适当的情况下,在定义操作时让它的返回类型与其参数的类型相同。如果实现者(implementer)的状态在计算中会被用到,那么实现者实际上就是操作的一个参数,因此参数和返回值应该与实现者有相同的类型。这样的操作就是在该类型的实例集合中的闭合操作。闭合操作提供了一个高层接口,同时又不会引入对其他概念的任何依赖性
一个操作可能是在某一抽象类型之下的闭合操作
本章介绍的模式演示了一个总体的设计风格和一种思考设计的方式。把软件设计得便于,容易预测且富有表达力,可以有效地发挥抽象和封装的作用。我们可以对模型进行分解,使得对象更易于理解和使用,同时仍具有功能丰富的,高级的接口
运用这些技术需要掌握相当高级的设计技巧,甚至有时编写客户端代码也需要掌握高级技巧才能运用的技术。MODEL-DRIVEN DESIGN的作用受细节设计的质量和实现决策的质量影响很大,而且只要有少数几个开发人员没有弄清楚它们,整个项目就会偏离目标
尽管如此,团队只要愿意培养这些建模和设计技巧,那么按照这些模式的思考方式就能够开发出可以反复重构的软件,从而最终创建出非常复杂的软件
10.7 声明式设计
10.8 声明式设计风格
10.9 切入问题的角度
10.9.1 分割子领域
10.9.2 尽可能利用已有的形式
第11章 分析模式的应用
第12章 将设计模式应用于模型
设计模式与领域模式之间有什么区别?《设计模式》这部经典著作的作者为初学者指出了以下事实
观点的不同会影响人们对什么是模式和什么不是模式的理解。一个人所认为的模式在另一个人看来可能是基本构造块。本书将在一定的抽象层次上讨论模式。设计模式并不是指像链表和散列表那样可以被封装到类中并供人们直接重用的设计,也不是直接用于整个应用程序或子系统的复杂的,专用于领域的设计。本书中的设计模式是对一些交互的对象和类的描述,我们通过定制这些对象和类来解决特定上下文中的一般设计问题
为了在领域驱动的设计中充分利用这些模式,我们必须同时从两个角度看待它们:从代码的角度来看它们是技术设计模式,从模型的角度来看它们就是概念模式
12.1 模式:STRATEGY(也称为POLICY)
领域模型包含一些并非用于解决技术问题的过程,将它们包含进来是因为它们对处理问题领域具有实际的价值。当必须从多个过程中进行选择时,选择的复杂性再加上多个过程本身的复杂性会使局面失去控制
当对过程建模时,我们经常会发现有不止一种合理的建模方式,而如果把所有的选择都写到过程的定义中,定义就会变得臃肿而复杂,而且我们所选择的行为描述也会因为混在在其他行为中而显得模糊不清
我们希望把这些选择从过程的主体概念中分离出来,这样既能够看清主体概念,也能更清楚地看到这些选择。软件设计社区中众所周知的STRATEGY模式就是为了解决整个问题的,虽然它的侧重点在于技术方面。这里,我们把它当成模型中的一个概念来使用,并在该模型的代码实现中把它反映出来。我们同样也需要把过程中极易发生变化的部分与那些更稳定的部分分离开
我们需要把过程中的易变部分提取到模型的一个单独的”策略“对象中。将规则与它所控制的行为区分开。按照STRATEGY设计模式来实现规则或可替换的过程。策略对象的多个版本表示了完成过程的不同方式
传统上,人们把STRATEGY模式看作一种设计模式,这种观点的侧重点是它替换不同算法的能力。而把它看作领域模型的侧重点是其表示概念的能力,这里的概念通常是指过程或策略规则
示例 路线查找(Route-Finding)策略
我们把一个Route Specification(路线规格)传递给Routing Service(路线服务),Routing Service的工作是构造一个满足SPECIFICATION的详细的Itinerary。这个SERVICE是一个优化引擎,可以通过调节它来查找最快的路线或最便宜的路线
这种设置按上去似乎没问题,但仔细观察路线代码就会发现,每个计算中都有条件判断,到处都是判断最快还是最便宜的逻辑。当为了做出更精细的航线选择而把新标准添加进来时,麻烦会更多
解决此问题的一种方法是把这些起调节作用的参数分离到STRATEGY中,这样它们就可以被明确地表示出来,并作为参数传递给Routing Service
现在,Routing Service就可以用一种完全相同的,无需进行条件判断的方式来处理所有请求了,它按照Leg Magnitude Policy(航段规模策略)的计算,找出一些列规模较小的Leg(航段)
这种设计具有《设计模式》中所介绍的STRATEGY模式的优点。按照这种思路设计的应用程序可以提供丰富的功能,同时也很灵活,现在,可以通过安装适当的Leg Magnitude Policy来控制和扩展Routing Service的行为.图12-2中显示的只是最明显的两种STRATEGY(最快或最便宜)。可能还会有一些在速度和成本之间进行权衡考虑的组合策略。也可以加进其他的因素,例如在预订货物时优先选择公司自己的运输系统,而不是外包给其他运输公司。不使用STRATEGY模式同样能实现这些修改,但必须将逻辑添加到Routing Service的内部(这会是一个麻烦的过程),而且这些逻辑会使接口变得臃肿。解耦可以令Routing Service更清楚且易于测试
现在,领域中的一个至关重要的规则明确地显示出来了,也就是在构建Itinerary时用于选择Leg的基本规则。它传达了这样一个知识:路线选择的基础是航段的一个特定属性(有可能是派生属性),这个属性最后可归结为一个数字。这样,我们就可以在领域语言中用一句简单的话来定义Routing Service的行为:Routing Service根据所选定的STRATEGY来选择Leg总规模最小的Itinerary
我们在领域层中使用技术设计模式时,必须认识到这样做的另外一种动机,也是它的另一层含义。当所使用的STRATEGY对应于某种实际的业务策略时,模式就不再仅仅是一种有用的实现技术了(但它在实现方面的价值并未改变)
设计模式的结论也完全适用于领域层。例如,在《设计模式》一书中,Gamma等人指出客户必须知道不同的STRATEGY,这也是一个建模关注点。如果单纯从实现上来考虑,使用策略可能会增加系统中对象的数目。如果这是一个问题,可以通过把STRATEGY实现为可在上下文中共享的无状态对象来减小开销。《设计模式》中对实现方法的全面讨论在这里也适用,这是因为我们仍然在使用STRATEGY,只是动机有些不同,这将影响我们的一些选择,但设计模式中的经验仍然是可以借鉴的
12.2 模式:COMPOSITE
12.3 为什么没有介绍FLYWEIGHT
第13章 通过重构得到更深层的理解
13.1 开始重构
13.2 探索团队
13.3 借鉴先前的经验
13.4 针对开发人员的设计
13.5 重构的时机
13.6 危机就是机遇
第14章 保持模型的完整性
14.1 模式:BOUNDED CONTEXT
14.2 模式:CONTINUOUS INTEGRATION
14.3 模式:CONTEXT MAP
14.3.1 测试CONTEXT的边界
14.3.2 CONTEXT MAP的组织和文档化
14.4 BOUNDED CONTEXT之间的关系
14.5 模式:SHARED KERNEL
14.6 模式:CUSTOMER/SUPPLIER DEVELOPMENT TEAM
14.7 模式:CONFORMIST
14.8 模式:ANTICORRUPTION LAYER
14.8.1 设计ANTICORRUPTION LAYER 的接口
14.8.2 实现ANTICORRUPTION LAYER
14.8.3 一个关于防御的故事
14.9 模式:SEPARATE WAY
14.10 模式:OPEN HOST SERVICE
14.11 模式:PUBLISHED LANGUAGE
14.12 “大象”的统一
14.13 选择你的模型上下文策略
14.13.1 指定团队决策或更高层的决策
14.13.2 在上下文中工作
14.13.3 转换边界
14.13.4 接受那些我们无法更改的事物:描述外部系统
14.13.5 与外部系统的关系
14.13.6 正在设计的系统
14.13.7 满足不同模型的特殊需要
14.13.8 部署
14.13.9 权衡
14.13.10 当项目正在进行时
14.14 转换
14.14.1 合并CONTEXT:SEPARATE WAY->SHARED KERNEL
14.14.2 合并CONTEXT:SHARED KERNEL->CONTINUOUS
14.14.3 逐步淘汰遗留系统
14.14.4 OPEN HOST SERVICE->PUBLISHED LANGUAGE
第15章 精炼
15.1 模式:CORE DOMAIN
15.1.1 选择核心
15.1.2 工作的分配
15.2 精炼的逐步提升
15.3 模式:GENERIC SUBDOMAIN
15.3.1 通用不等于可以重用
15.3.2 项目风险管理
15.4 模式:DOMAIN VISION STATEMENT
15.5 模式:HIGHLIGHTED CORE
15.5.1 精炼文档
15.5.2 标明CORE
15.5.3 把精炼文档作为过程工具
15.6 模式:COHESIVE MECHANISM
15.6.1 GENERIC SUBDOMAIN与COHESIVE MECHANISM的比较
15.6.2 MECHANISM是CORE DOMAIN一部分
15.7 通过精炼得到声明式风格
15.8 模式:SEGREGATED CORE
15.8.1 创建SEGREGATED CORE的代价
15.8.2 不断发展演变的团队决策
15.9 模式:ABSTRACT CORE
15.10 深层模型精炼
15.11 选择重构目标
第16章 大比例结构
16.1 模式:EVOLVING ORDER
16.2 模式:SYSTEM METAPHOR
16.3 模式:RESPONSIBILITY LAYER
16.4 模式:KNOWLEDGE LEVEL
16.5 模式:PLUGGABLE COMPONENT FRAMEWORK
16.6 结构应该有一种什么样的约束
16.7 通过重构得到更适当的结构
16.7.1 最小化
16.7.2 沟通和自律
16.7.3 通过重构得到柔性设计
16.7.4 通过精炼可以减轻负担
第17章 领域驱动设计的综合运用
17.1 把大比例结构与BOUNDED CONTEXT结合起来使用
17.2 将大比例结构与精炼集合起来使用
17.3 首先评估
17.4 由谁制定
17.4.1 从应用程序开发自动得出的结构
17.4.2 以客户为中心的架构团队
17.5 制定战略设计决策的6个要点
17.5.1 技术框架同样如此
17.5.2 注意总体规划
附录
术语表
Aggregate(聚合) 聚合就是一组相关对象的集合,我们把聚合作为数据修改的单元。外部对象只能引用聚合中的一个成员,我们把它称为根。在聚合的边界之内应用一组一致的规则
分析模式(analysis pattern) 分析模式是用来表示业务建模中的常见构造的概念集合。它可能只与一个领域有关,也可能跨多个领域
Assertion(断言) 断言是对程序在某个时刻的正确状态的声明,它与如何达到这个状态无关。通常,断言指定了一个操作的结果或者一个设计元素的固定规则
Bounded Context (限界上下文) 特定模式的限界应用。限界上下文使团队所有成员能够明确地知道什么必须保持一致,什么必须独立开发
客户(client) 一个程序元素,它调用正在设计的元素,使用其功能
内聚(cohesion) 逻辑协定和依赖性
命令,也称为修改器命令(command/modifier) 使系统发生改变的操作(例如,设置变量)。它是一种有意产生副作用的操作
Conceptual Contour(概念轮廓) 领域本身的基本一致性,如果它能够在模型中反映出来的话,则有助于使设计更自然地适应变化
上下文(context) 一个单词或句子出现的环境,它决定了其含义。参见Bounded Context
Context Map (上下文图) 项目所涉及的限界上下文以及它们与模型之间的关系的一种表示
Core Domain (核心领域) 模型的独特部分,是用户的核心目标,它使得应用程序与众不同并且有价值
参考文献