领域驱动设计笔记
在传统的瀑布方法中,业务专家与分析员进行讨论,分析员消化理解这些知识后,对其进行抽象并将结果传递给程序员,再由程序员编写软件代码。 传统的需求方---->产品经理---->程序员形式,产品经理负责理解业务,把抽象和理解完的内容给程序员(就像把嚼过的肉给下一个人吃一样)这种知识流向是单向的,需求方和程序员没有互相交流的机会,知识只朝一个方向流动,不会积累。
如果程序员愿意进行重构,则能够保持软件足够整洁,以便继续扩展它;但如果程序员对领域不感兴趣,则他们只会了解程序应该执行的功能,而不去了解它背后的原理。虽然这样也能开发出可用的软件,但项目不会从原有的特性中自然地扩展出强大的新特性。 领域驱动不依赖于程序员的自觉性,而依赖于模型在设计之初的合理性和可扩展性。
传统瀑布模式程序员过于被动,接需求完成功能,无法着眼于整套业务,无法抽象出工作模型。但如果在建模时技术人员唱独角戏,那么得到的概念将不够专业,过于浅显。
模型永远都不会是完美的他是一个不断演化完善的过程。
当我们的建模不在局限于寻找实体和值对象时,我们才能充分吸取知识,因为业务规则之间可能会存在不一致,领域专家在反复研究所有规则、解决规则之间的矛盾以及以尝试来弥补规则的不足等一系列工作中,往往不会意识到他们的思考过程有多么复杂。
模型之间的关系成为所有语言都具有的组合规则。词和短语的意义反映了模型的语意。
将模型作为语言支柱。确保团队在内部的所有交流中以及代码中坚持使用这种语言。在画图,写东西,特别是讲话时也要使用这种语言。
有了Ubiquitous language之后,开发人员之间的对话、领域专家之间的讨论以及代码本身所表达的内容都基于同一种语言,都来自于一个共享的领域模型。
不要在UML中添加过多细节,细节过多的结果是只见树木,不见森林。UML图无法传达模型的两个重要方面,一个是模型表示的概念的意义,另一个是对象应该做哪些事情。
模型不是图,图的目的是帮助表达和解释模型,以文本为主,用精心挑选的简化图作为说明。
文档不应再重复表示代码已经明确暴打出的内容,代码已经含有各个细节,它本身就是一种精确的程序行为说明
当开发人员实现应用程序时,尽管分析人员说得头头是道,他们依然无法将这种错综复杂的关系转换成可存储、可检索的且具有事务完整性的单元。由于模型是正确的,这是经过技术分析人员和业务专家大量协作才得到的结果,但无法把机遇概念的对象作为设计的基础。------领域驱动设计要求一种不同的建模方法,与传统建模存在差异。
Model-Driven Design
纯粹的分析模型甚至在实现理解领域这一主要目的方面也捉襟见肘,因为在程序设计和实现过程中总是会发现一些关键的知识点,而细节问题则会出人意料地层出不穷。前期模型可能会深入研究一些不相关的问题,反而忽略了一些重要方面。而且它对于其他问题的描述也可能对应用程序没有任何帮助。最后的结果就是:编码工作一开始,纯粹的分析模型就被抛到一边,大部分的模型都需要重新设计。即便是重新设计,如果开发人员认为分析与程序开发毫不相关,那么建模过程就不会那么规范。而如果项目经理也这么认为,那么开发团队可能没有足够的机会与领域专家进行交流。
如果整个程序设计或者其核心部分没有与领域模型相对应,那么这个模型就是没有价值的,软件的正确性也值得怀疑。同时,模型和设计功能之间过于复杂的对应关系也是难于理解的,在实际项目中,当设计改变时也无法维护这种关系。若分析与和设计之间产生严重分歧,那么在分析和设计活动中所获得的知识就无法彼此共享。
软件系统各个部分的设计应该忠实地反映领域模型,以便体现出这二者之间的明确对应关系。我们应该反复检查并修改模型,以便软件可以更加自然地实现模型,即使想让模型反映出更深层次的领域概念时也应如此。我们需要的模型不但应该满足这两种需求,还应该能够支持健壮的UBIQUITOUS LANGUAGE(通用语言)。 从模型中获取用于程序设计和基本职责分配的术语。让程序代码成为模型的表达,代码的改变可能会是模型的改变。而其影响势必要波及接下来相应的项目活动。 完全依赖模型的实现通常需要支持建模范式的软件开发工具和语言,比如面向对象的编程。
模型和设计的绑定需要的是在分析和程序设计阶段都能够发挥良好作用的模型。如果模型对于程序的实现来说显得不太实用时,我们必须重新设计它。而如果模型无法忠实地描述领域的关键概念,也必须重新设计它。这样,建模和程序设计就结合为一个统一的迭代开发过程。
软件系统的各个部分的设计应该忠实反映领域建模,以便体现出这二者之间的明确对应关系。我们应该反复检查并修改模型,以便软件可以更加自然的实现模型,即使想让模型反映出更深层次的领域概念时也应如此。我们需要的模型不但应该满足这两种需求,还应该能够支持健壮的通用语言。从模型中获取程序设计和基本职能分配的属于。让程序代码成为模型的表达,代码的改变可能会是模型的改变,而其影响势必要波及接下来相应的项目活动。完全依赖模型的实现通常需要支持建模范式的软件开发工具和语言,比如面向对象的编程
Hands-On Modeler 亲身实践的建模者
虽然开发团队中的每个成员都有自己的职责,但是将分析、建模、设计和编程工作过度分离会对Model-Driven Defign产生不良影响。
- 模型的意图会在传达时丢失,模型的整体效果受细节的影响很大,这些细节问题并不总能在UML图或者一般讨论中遇到
- 模型与程序实现及技术互相影响,而我无法直接获得这种反馈,当这种影响在后期被我发现时,很可能已经被开发人员使用脱离领域设计的方法给解决了,长久以此将会完全脱离模型的设计,
如果编写代码的人员认为自己没必要对模型负责,或者不知道如何让模型为应用程序服务,那么这个模型就和程序没有任何关联。如果开发人员没有意识到改变代码就意味着改变模型,那么他们对程序的重构不但不会增强模型的作用,反而还会削弱他的效果。同样,如果建模人员不参与到程序实现的过程中,那么对程序实现的约束就没有切身的感受,即使有,也很快就会忘记。Model-Driven Design的两个基本要素(即模型要支持有效的实现并抽象出关键的领域知识)已经失去了一个,最终模型将变得不再实用。最后一点,如果分工阻断了设计人员与开发人员之间的协作,使他们无法转达实现模型驱动设计的种种细节那么惊艳丰富的设计人员则不能将自己的知识和技术传递给开发人员。
任何参与建模的技术人员,不管在项目中的主要职责是什么,都必须花时间了解代码,任何负责修改代码的人员必须学会用代码来表达模型。每一个开发人员都必须不同程度地参与模型讨论并且与领域专家保持联系。参与不同工作的人都必须有意识地通过通用健壮语言与接触代码的人即使交换关于模型的想法
模型驱动设计的构造块
共用这些标准模式可以使设计有序进行,也使项目组成员能够更方便地了解彼此的工作内容。
开发一个好的领域模型是一门艺术。而模型中各个元素的实际设计和实现则相对系统化。将领域设计与软件系统中的其他关注点分离会使设计与模型之间的关系非常清晰。根据不同的特征来定义模型元素则会使元素的意义更加鲜明。对每个元素使用已验证的模式有助于创建出更易于实现的模型。
只有充分考虑这些基本原理后,进行设计的模型才能化繁为简,创建出项目组成员可以放心进行组合使用的详细元素。
分离领域
我们需要将领域对象与系统中其他功能分离,这样能够避免将领域概念和其他只与软件技术相关的概念搞混了
在面向对象的程序中,常常会在业务对象中直接写入用户界面(请求体的接收,处理等)、数据库访问等支持代码。而一些业务逻辑则会被嵌入到用户页面组件和数据库脚本中。这么做是为了以最简单的方式在短期内完成开发工作。如果与领域有关的代码分散在大量的其他代码之中,那么查看和分析领域代码就会变得非常困难。对于用户界面的简单修改实际上可能会改变业务逻辑,而想要调整业务规则也很可能系要对用户界面代码、数据库操作代码或者其他的程序元素进行仔细的筛选。这样就不太可能实现一致的、模型驱动的对象,同时也会给自动化测试带来困难。考虑到程序中各个活动所设计的大量逻辑和技术,程序本身必须简单明了,否则就会让人无法理解
在传统开发MVC时不太会考虑层级之间的入侵状况,为的其实是快速启动项目,实现功能,这种模式在后续扩展维护时会出现较大的麻烦
给复杂的应用程序划分层次。在每一层内分别进行设计,使其具有内聚性并且只依赖于它的下层。采用标准的架构模式,只与上层进行松散的耦合。将所有与领域模型相关的代码放在一个层中,并把它与用户界面层、应用层以及基础设施层的代码分开。领域对象应该将重点放在如何表达领域模型上,而不需要考虑自己的显示和存储问题,也无需管理应用任务等内容。这使得模型的含义足够丰富,结构足够清晰,可以捕捉到基本的业务知识,并有效地使用这些知识。
架构框架
最好的架构框架即能解决复杂技术问题,也能让领域开发者集中精力去表达模型,而不考虑其他问题。然而使用框架很容易为项目制造障碍,要么是设定了太多的假设,减小了领域设计的可选范围;要么是需要实现太多的东西,影响开发进度.
选择框架时不妄求万全之策,只要有选择地运用框架来解决难点问题,就可以避开框架的很多不足之处。减少对框架的依赖有助于保持业务对象的可读性,使其更富有表达力,我们必须保持克制,不要总想着要寻找框架,因为精细框架也可能会束缚住程序开发人员。
SmartUI
这是和领域建模完全相悖的设计方法,用于快速产生简单应用
- 优点:效率高,能及差的开发人员不经过培训就能采用它,甚至可以克服需求分析上的不足,只需要把原型给客户,根据客户的反馈快速修改产品即可,程序之间独立,可以相对准确安排小模块交付的日期。
- 缺点:没有对行为进行重用,没有对业务问题抽象,每当操作用到业务规则时,必须重复这些规则。快速迭代很快会到达极限,因为抽象的缺乏限制了重构的选择。
项目团队常犯的一种错误是采用了一种复杂的设计方法,却无法保证项目从头到尾始终使用它。另一种常见的也是代价高昂的作物则是为项目构建一种复杂的基础设施,以及工业级的工具,而这样的项目根本不需要它们。
软件中所表示的模型
侧重于区分表示模型的三种模型元素模式:Entity(实体)、ValueObject(值对象)、Service(服务)。
一个对象是用来表示某种具有连续性和标识的事物(可跟踪不同状态和存在生命周期),还是用于描述某种状态的属性,这是实体和值对象之间的根本区别。
领域中还有一些方面适合用动作或操作来表示,这比用对象表示更加清楚。这些方面最好用SERVICE来表示,而不应把操作的责任强加到ENTITY或VALUE OBJECT上,尽管这样做稍微违背了面向对象的建模传统。SERVICE是应客户端请求来完成某事。在软件的技术层中有很多SERVICE。在领域中也可以使用SERVICE,当对软件要做的某项无状态的活动进行建模时,就可以将该活动作为一项SERVICE。
Entity 实体
对象建模可能把我们的注意力放在对象的属性上,但实体的基本概念是一种贯穿整个生命周期的抽象的连续性。
一些对象主要不是由他们的属性定义的。他们实际上表示了一条“标识线”,这条线跨越时间,而且常常尽力多种不同的标识。有时这样的对象必须与另一个具有不同属性的对象相匹配。而有时一个对象必须与具有相同属性的另一个对象区分开。错误的标识很可能会破坏数据。
实体具有生命周期,这期间形式和内容可能发生更笨变化,但保持一种内在的连续性。为有效跟踪,必须定义他们的标识。他们的属性职责会根据标识的不同而动态变化,这些变化依赖于这个标识而不依赖其他条件,这些内容组成了实体。即使对于那些不发生根本变化或者生命周期不太复杂的实体,也应该在语意上把他们作为实体来对待,这样可以得到更清晰的模型和更健壮的实现。标识是一个实体的一个微妙、有意义的属性,我们不能把它交给语言的自动特性来处理。
当一个对象由其标识(而不是属性)区分时,那么在模型中应该主要通过标识来确定该对象的定义。使类定义变得简单,并集中关注生命周期的连续性和标识。定义一种区分每个对象的方式,这种方式应该与其形式和历史内容无关。要格外注意哪些需要通过属性来匹配对象的需求。在定义标识操作时,要确保这种操作为每个对象生成唯一的结果,这可以通过附加一个保证唯一性的符号来实现。这种定义标识的方法可能来自外部,也可能是由系统创建的任意标识符,但他在模型中必须是唯一的标识。模型必须定义出“符合什么条件才算是相同的事物”
标识重不重要完全取决于它有没有用
在现实世界中,并不是每一个事物都必须有一个标识,标识重不重要,完全取决于它是否有用。实际上,现实世界中的同一个事物在领域模型中可能需要表示为ENTITY,也可能不需要表示为ENTITY。 体育场座位预订程序可能会将座位和观众当作ENTITY来处理。在分配座位时,每张票都有一个座位号,座位是ENTITY。其标识符就是座位号,它在体育场中是唯一的。座位可能还有很多其他属性,如位臵、视野是否开阔、价格等,但只有座位号(或者说某一排的一个位臵)才用于识别和区分座位。 另一方面,如果活动采用入场卷的方式,那么观众可以寻找任意的空座位来坐,这样就不需要对座位加以区分。在这种情况下,只有座位总数才是重要的。尽管座位上仍然印有座位号,但软件已经不需要跟踪它们。事实上,这时如果模型仍然将座位号与门票关联起来,那么它就是错误的,因为采用入场卷的活动并没有这样的约束。这种情况下,座位不是Entity,因此不需要标识符
Entity建模
Entity最基本的职责是保持连续性,以便使其行为更清楚且可预测。保持实体的简练是实现这一责任的关键。不要将注意力集中在属性或者行为上,应该摆脱这些细枝末节,抓住Entity对象定义的基本特征,尤其是那些用于识别、查找或匹配对象的特征。
customerID是Customer ENTITY的一个(也是唯一的)标识符,但phone number (电话号码)和address(地址)都经常用来查找或匹配一个Customer(客户)。name(姓名)没有定义一个人的标识,但它通常是确定人的方式之一。在这个示例中,phone和address属性被移到Customer中,但在实际的项目上,这种选择取决于领域中的Customer一般是如何匹配或区分的。例如,如果一个Customer有很多用于不同目的的phone number,那么phone number就与标识无关,因此应该放在Sales Contact(销售联系人)中。
两个对象是同一个事物时意味着什么?我们很容易为每个对象分配一个ID,或是编写一个用于比较两个实例的操作,但如果这些ID或操作没有对应领域中有意义的区别,那只会使问题更加混乱。这就是分配表示的操作通常需要人工输入的原因。例如支票簿队长软件可以提供一些有可能匹配的账目,但他们是否真的匹配则要由用户最终决定。
ValueObject 值对象
跟踪Entity的标识非常重要,但为其他对象也加上标识会影响系统性能并增加分析工作,而且会使模型变得混乱,因为所有对象看起来都相同。软件设计要时刻与复杂性作斗争,我们必须去区别对待问题,仅在真正需要的地方进行特殊处理。然而,如果仅仅把这类对象当做没有表示的对象,那么就忽略了他们的工具价值或术语价值。事实上,这些对象有其自己的特征,对模型也有着自己的重要意义。这些是用来描述事物的对象。
当我们只关心一个模型元素的属性时,应把它归类为ValueObject。我们应该使这个模型元素能够表示出其属性的意义,并为他提供相关功能。ValueObject应该是不可变的。不要为他分配任何标识,而且不要把它设计成实体那么复杂。
两个人同名并不意味着他们是同一个人,也不意味着他们是可互换的。但表示名字的对象是可以互换的,因为它们只涉及名字的拼写。一个Name对象可以从第一个Person对象复制给第二个Person对象。
值对象为性能优化提供了更多选择。复制和共享哪个更划算取决于实现环境。虽然复制有可能导致系统被大量的对象阻塞,但共享可能会减慢分布式系统的速度,
Service
一些领域概念不适合被建模为对象。如果勉强把这些重要的领域功能归为实体或值对象的职责,那么不是歪曲了基于模型的对象的定义,就是认为的增加了一些无意义的对象。
好的service特征
- 与领域概念相关的操作不是Entity或者ValueObject的一个自然组成部分
- 接口是根据领域模型的其他元素定义的
- 操作时无状态的
当领域中的某个重要的过程或转换操作不是Entity或者ValueObject的自然职责时,应该在模型中添加一个作为独立接口的操作,并将其声明为Service。定义接口时要使用模型语言,并确保操作名称是通用语言中的术语。此外,应该使Service成为无状态的。
纯技术的service应该没有任何业务意义
由于应用层负责对领域对象的行为进行协调,因此细粒度的领域对象可能会吧领域层的知识泄漏到应用层中。这产生的结果是应用层不得不处理复杂的、细致的交互,从而使得领域知识蔓延到应用层或用户界面代码中,而领域层会丢失这些知识。明智地引入领域层服务有助于在应用层和领域层之间保持一条明确的界限。这种模式有利于保持接口的简单性,便于客户端控制并提供了多样化的功能。它提供了一种在大型或分布式系统中便于对组件进行打包的中等粒度的功能。而且,有时用Service是表示领域概念的最自然的方式。
建模范式
Model-Driven Design要求使用一种与建模范式协调的实现技术。目前主流范式是面向对象设计,而且现在的大部分复杂项目都开始使用对象。这种范式的流行有许多原因,包括对象本身的固有因素、一些环境因素,以及广泛使用所带来的一些优势。
领域模型不一定是对象模型。模型可能有规则逻辑和事实组成。
- 不要和实现范式对抗,我们总是可以用别的方法来考虑领域。找到适合于范式的模型概念。
- 把通用语言作为依靠的基础。即使工具之间没有严格联系时,语言使用上的高度一致性也能防止各个设计部分分裂。
- 不要一味依赖UML。有时固定使用某种工具将导致人们通过歪曲模型来使它更容易画出来。
- 保持怀疑态度。工具是否真的有用武之地,不能因为存在一些规则,就必须使用规则引擎。规则也可以表示为对象,虽然可能不是特别优雅。多个范式会使问题变得非常复杂。
领域对象的生命周期
在具有复杂关联的模型中,想要保证对象更改的一致性时很困难的。不仅互不关联的对象需要遵守一些固定规则,而且紧密关联的各组对象也要遵守一些固定规则。然而,过于谨慎的锁定机制又会导致多个用户之间毫无意义的互相干扰,从而使系统不可用。
聚合
我们需要一个抽象来封装模型中的引用。aggregate就是一组对象的集合,我们把它作为数据修改的单元。每个aggregate都有一个根和边界。边界定义了聚合的内部都有什么。根则是聚合所包含的一个特定实体。对聚合而言,外部对象只可以引用根,而边界内部的对象之间则可以互相引用。除根以外的其他实体都有本地表示,但这些标识只在聚合内部才需要加以区别,因为外部对象除了有根实体之外看不到其他对象。
我们应该将Entity和ValueObject分门别类地聚集到聚合中,并定义每个聚合的边界。在每个聚合中,选择一个实体作为根,并通过根来控制对边界内其他对象的所有访问。只允许外部对象保持对根的引用。对内部成员的临时引用可以被传递出去,但仅在一次操作中有效。由于根控制访问,因此不能绕过他来修改内部对象。这种设计有利于确保聚合中的对象满足所有固定规则,也可以确保在任何状态变化时聚合作为一个整体满足固定规则。
聚合可以提供一种固定规则来对实体原有的能力进行约束,在聚合中可以对实体的能力进行增强
工厂
当创建一个对象或者创建整个聚合时,如果创建工作很复杂,或者暴露了过多的内部结构,则可以使用工厂进行封装。
对象的功能主要体现在其复杂的内部配置以及关联方面。我们应该一直对对象进行提炼,直到所有与其意义或在交互中的对象无关的内容被完全剔除为止。一个对象在他的生命周期中要承担大量职责。如果再让复杂对象负责自身的创建,那么职责过载将会导致问题。
当客户创建对象时,他会牵涉不必要的复杂性,并将其职责搞的模糊不清。这违背了领域对象及所创的聚合的封装要求。更严重的是,如果客户是应用层的一部分,那么职责就会从领域层泄漏到应用层中。应用层与实现细节之间的这种耦合使得领域层抽象的大部分优势荡然无存,而且导致后续更改的代价更加高昂。
对象的创建本身可以使一个主要操作,但被创建的对象并不适合承担复杂的装配操作。将这些职责混在一起可能产生难以理解的拙劣设计。让客户直接负责创建对象又会使客户的设计陷入混乱,并且破坏被装配对象或聚合的封装,而且导致客户与被创建对象的实现之间产生过与紧密的耦合。
正如对象的接口应该封装对象的实现一样(从而使客户无需知道对象的工作机理就可以使用对象的功能),工厂封装了创建复杂对象或聚合所需的知识。它提供了反映客户目标的接口,以及被创建对象的抽象视图。
应该将创建复杂对象的实例和聚合的职责转移给单独的对象,这个对象本身可能没有承担领域模型中的职责,但它仍是领域设计中的一部分。提供一个封装所有复杂装配操作的接口,而且这个接口不需要客户引用要被实例画的对象的具体类。在创建聚合时要把它作为一个整体,并确保它满足固定规则
接口设计
工厂每个操作都必须是原子的。我们必须在与工厂的一次交互中把创建对象所需的信息传递给工厂。
ENTITY FACTORY与VALUE OBJECT FACTORY有两个方面的不同。由于VALUE OBJECT是不可变的,因此,FACTORY所生成的对象就是最终形式。因此FACTORY操作必须得到被创建对象的完整描述。而ENTITY FACTORY则只需具有构造有效AGGREGATE所需的那些属性。对于固定规则不关心的细节,可以之后再添加。
存储 Repository
把使用已存储的的数据创建实例的过程称为重建
现在从技术的观点来看,检索已存储对象实际上属于创建对象的范畴,因为从数据库中检索出来的数据要被用来组装新的对象。实际上,由于需要经常编写这样的代码,我们对此形成了根深蒂固观念。但从概念上讲,对象检索发生在实体生命周期中间。不能只是因为我们将对象保存在数据库中,而后把它检索出来,这个就代表一个新客户。为了记住这个区别,我把使用已存储的数据创建实例的过程称为重建。
客户需要一种有效的方式来获取对已存在的领域对象的引用。如果基础设施提供了这方面的便利,那么开发人员可能会增加很多可遍历的关联,这会使模型变得非常混乱。另一方面,开发人员可能使用查询从数据库中提取他们所需的数据,或是直接提取具体的对象,而不是通过AGGREGATE的根来得到这些对象。这样就导致领域逻辑进入查询和客户代码中,而ENTITY和VALUE OBJECT则变成单纯的数据容器。采用大多数处理数据库访问的技术复杂性很快就会使客户代码变得混乱,这将导致开发人员简化领域层,最终使模型变得无关紧要----------客户跨过聚合直接调用基础设施提供出来的工具来重建目标实体
在所有持久化对象中,有一小部分必须通过基于对象属性的搜索来全局访问。当很难通过遍历方式来访问某些AGGREGATE根的时候,就需要使用这种访问方式。它们通常是ENTITY,有时是具有复杂内部结构的VALUE OBJECT,还可能是枚举VALUE。而其他对象则不宜使用这种访问方式,因为这会混淆它们之间的重要区别。随意的数据库查询会破坏领域对象的封装和AGGREGATE。技术基础设施和数据库访问机制的暴露会增加客户的复杂度,并妨碍模型驱动的设计。
为每种需要全局访问的对象类型创建一个对象,这个对象相当于该类型的所有对象在内存中的一个集合的“替身”。通过一个众所周知的全局接口来提供访问。提供添加和删除对象的方法,用这些方法来封装在数据存储中实际插入或删除数据的操作。提供根据具体条件来挑选对象的方法,并返回属性值满足查询条件的对象或对象集合(所返回的对象是完全实例化的),从而将实际的存储和查询技术封装起来。只为那些确实需要直接访问的AGGREGATE根提供REPOSITORY。让客户始终聚焦于模型,而将所有对象的存储和访问操作交给REPOSITORY来完成。 REPOSITORY有很多优点,包括: 它们为客户提供了一个简单的模型,可用来获取持久化对象并管理它们的生命周期; 它们使应用程序和领域设计与持久化技术(多种数据库策略甚至是多个数据源)解耦; 它们体现了有关对象访问的设计决策:可以很容易将它们替换为“哑实现”(dummy implementation),以便在测试中使用(通常使用内存中的集合)。
客户代码可以忽略Repository的实现,但开发人员不能忽略
持久化技术的封装可以使得客户变得十分简单,并且使客户与Repository的实现之间完全解耦。但像一般的封装一样,开发人员必须知道在封装背后都发生了什么事情。在使用Repository时,不同的使用方式或工作方式可能会对性能产生极大的影响。
另一种情况促使人们将Factory和Repository结合起来使用,这就是想要实现一种“查找或创建”功能,即客户描述它所需的对象,如果找不到这样的对象,实体和值对象区分开来时,很多看上去有用的功能,他不会带来多少方便。当将实体和值对象区分开始,很多看上去有用的功能就不复存在了。需要值对象的客户可以直接请求工厂来创建一个,通常在领域中将新对象和原有对象区分开很重要,而将它们组合在一起的框架实际上只会使局面变得混乱。单一职责!!!
为关系数据库设计对象
大部分其他组件相比,数据库与对象模型的关系要紧密得多。数据库不仅仅与对象进行交互,而且它还把构成对象的数据存储为持久化形式。常见几种情况会导致对象模型产生很大影响。
- 数据库是对象的主要存储库
- 数据库是为另一个系统设计的
- 数据库是为这个系统设计的,但它的任务不是用于存储对象
从技术上看,关系表不需要反映出领域模型。映射工具已经非常完善,足以消除两者之间的巨大差距。问题在于多个重叠的模型过于复杂。这确实会牺牲一些对象模型的丰富性,而且有时必须在数据库设计中做出一些折中(如有些地方不能规范化)。但如果不做这些牺牲就会冒另一种风险,那就是模型与实现之间失去了紧密的耦合。这种方法并不要必须使用一种简单的、一个对象/一个表的映射。依靠映射工具的功能,可以实现一些聚合或对象的组合。但至关重要的是:映射要保持透明,并易于理解——能够通过审查代码或阅读映射工具中的条目就搞明白。
当数据库被视作对象存储时,数据模型与对象模型的差别不应太大(不管映射工具有多么强大的功能)。可以牺牲一些对象关系的丰富性,以保证它与关系模型的紧密关联。如果有助于简化对象映射的话,不妨牺牲某些正式的关系标准(如规范化)。
数据库的设计可以为了保证模型的合理做一些妥协
很多情况下数据是来自遗留系统或者外部系统的,而这些系统从来没有被打算用作对象的存储。在这种情况下,同一个系统中就会有两个领域模型共存。或许与另一个系统中隐含的模型保持一致有一定的道理,也可能更好的方法是这两个模型完全不同。
表中的一行数据应该包含一个对象,也可能还包含Aggregate中的一些附属项。表中的外键应该转换为对另一个实体对象的引用。有时我们不的不违背这种简单的对应关系,但不应该由此就全盘放弃简单映射的原则。
数据库也有可能被其他一些不对对象进行实例化的软件使用。即使当对象的行为快速变化或演变的时候,数据库可能并不需要修改,让模型与数据库之间保持松散的关联是很有吸引力的。但这种情况往往是无意为之,原因是团队没有保持数据库与模型之间的同步。如果有意将两个模型分开,那么它可能会产生更整洁的数据库模式,而不是为了一个与早前的对象模型保持一致而到处都是折中处理的劣质的数据库模式。
货物运输系统
- 更在客户货物的主要处理
- 实现预约货物
- 当货物到达起处理过程中的某个位置时,自动向客户寄送发票
在实际的项目中,需要花费一些时间,并经过多次迭代才能得到清晰的模型。本书的第三部分将深入讨论这个发现过程。这里,我们先从一个已包含所需概念并且形式合理的模型开始,我们将通过调整模型的细节来支持设计。 这个模型将领域知识组织起来,并为团队提供了一种语言。我们可以做出像下面这样的陈述。 “一个Cargo(货物)涉及多个Customer(客户),每个Customer承担不同的角色。” “Cargo的运送目标已指定。” “由一系列满足Specification(规格)的Carrier Movement(运输动作)来完成运送目标。 ”
Handling Event(处理事件)是对Cargo采取的不同操作,如将它装上船或清关。这个类可以被细化为一个由不同种类的事件(如装货、卸货或由收货人提货)构成的层次结构。 Delivery Specification(运送规格)定义了运送目标,这至少包括目的地和到达日期,但也可能更为复杂。这个类遵循规格模式(参见第9章)。
Delivery Specification的职责本来可以由Cargo对象承担,但将Delivery Specification抽象出来至少有以下3个优点。 (1) 如果没有Delivery Specification,Cargo对象就需要负责提供用于指定运送目标的所有属性和关联。这会把Cargo对象搞乱,使它难以理解或修改。 (2) 当将模型作为一个整体来解释时,这个抽象使我们能够轻松且安全地省略掉细节。例如,Delivery Specification中可能还封装了其他标准,但就图7-1所要展示的细节而言,可以不必将其显示出来。这个图告诉读者存在运送规格,但其细节并非思考的重点(事实上,过后修改细节也很容易)。 (3) 这个模型具有更强的表达力。Delivery Specification清楚地表明:运送Cargo的具体方式没有明确规定,但它必须完成Delivery Specification中规定的目标。
Customer在运输中所承担的部分是按照角色(role)来区分的,如shipper(托运人)、receiver (收货人)、payer(付款人)等。由于一个Cargo只能由一个Customer来承担某个给定的角色,因此它们之间的关联是限定的多对一关系,而不是多对多。角色可以被简单地实现为字符串,当需要其他行为的时候,也可以将它实现为类。
Carrier Movement表示由某个Carrier(如一辆卡车或一艘船)执行的从一个Location(地点)到另一个Location的旅程。Cargo被装上Carrier后,通过Carrier的一个或多个Carrier Movement,就可以在不同地点之间转移。 Delivery History(运送历史)反映了Cargo实际上发生了什么事情,它与Delivery Specification正好相对,后者描述了目标。Delivery History对象可以通过分析最后一次装货和卸货以及对应的Carrier Movement的目的地来计算货物的当前位臵。成功的运送将会得到一个满足Delivery Specification目标的 Delivery History。 用于实现上述需求的所有概念都已包含在这个模型中,并假定已经有适当的机制来保存对象、查找相关对象等。这些实现问题不在模型中处理,但它们必须在设计中加以考虑。
用于实现上述需求的所有概念都已包含在这个模型中,并假定已经有适当的机制来保存对象、查找相关对象等。这些实现问题不在模型中处理,但它们必须在设计中加以考虑。 为了建立一个健壮的实现,这个模型需要更清晰和严密一些。 记住,一般情况下,模型的精化、设计和实现应该在迭代开发过程中同步进行。但在本章中,为了使解释更加清楚,我们从一个相对成熟的模型开始,并严格限定修改的唯一动机是保证模型与具体实现相关联,在实现时采用构造块模式。 一般来说,当为了更好地支持设计而对模型进行精化时,也应该让模型反映出对领域的新理解。但在本章中,仍然是为了使解释更加清楚,严格限定修改的动机在于保证模型与具体实现相关联,在实现时采用构造块模式。
引入应用层
我们可以简单识别出三个用户级别的应用程序功能
- 第一个类跟踪查询,它可以访问某个货物过去和现在的处理情况
- 第二个类预定应用,允许注册一个新的货物,并使系统准备好处理它
- 第三个类事件日志应用,它记录对货物的每次处理(提供通过TrackingQuery查找的信息)
这些应用层是协调者,他们只负责提问,而不负责回答,回答是领域层的工作。
区分实体和值对象
Customer
我们从一个简单的对象开始。Customer对象表示一个人或一家公司,从一般意义上来讲它是一个实体。Customer对象显然有对用户来说很重要的标识,因此它在模型中是一个ENTITY。那么如何跟踪它呢?在某些情况下可以使用Tax ID(纳税号),但如果是跨国公司就无法使用了。这个问题需要咨询领域专家。我们与运输公司的业务人员讨论这个问题,发现公司已经建立了客户数据库,其中每个Customer在第一次联系销售时被分配了一个ID号。这种ID已经在整个公司中使用,因此在我们的软件中使用这种ID号就可以与那些系统保持标识的连贯性。ID号最初是手工录入的。
Cargo
两个完全相同的货箱必须要区分开,因此Cargo对象是ENTITY。在实际情况中,所有运输公司会为每件货物分配一个跟踪ID。这个ID是自动生成的、对用户可见,而且在本例中,在预订时可能还要发送给客户。
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中也不复杂。
重复业务
相同Customer的重复预定往往是类似的,因此他们想要将就Cargo作为新Cargo的原型。通过Id获取到被复制的Cargo原型,保证工厂创建的Cargo不含历史信息,只保留基础信息。这没有对Aggregate边界之外的对象产生任何影响。
Handling Event
由于Handling Event是一个实体,由CargoID,完成时间和事件类型的组合来唯一标识的,Handling Event唯一剩下的属性是与Carrier Movement的关联(运输载体)但有些事件都没有这个属性。
配额检查
在这个假想的运输公司中,销售部门使用其他软件来管理客户关系、销售计划等。其中有一项功能是效益管理(yield management),利用此功能,公司可以根据货物类型、出发地和目的地或者任何可作为分类名输入的其他因素来制定不同类型货物的运输配额。这些配额构成了各类货物的运输量目标,这样利润较低的货物就不会占满货舱而导致无法运输利润较高的货物,同时避免预订量不足(没有充分利用运输能力)或过量预订(导致频繁地发生货物碰撞,最终损害客户关系)。 现在,他们希望把这个功能集成到预订系统中。这样,当客户进行预订时,可以根据这些配额来检查是否应该接受预订。
销售系统不是根据模型来编写的,预定系统与他直接交互意味着这套系统必须适应另一套系统,这将很难保证一个清晰的模型,而且会混淆通用语言。相反我们可以创建一个对象来保证对特定场景的翻译,并根据我们的领域模型重新对这些特性进行抽象。
通过重构来加深理解
- 复杂巧妙的领域模型是可以实现的,也是值得我们去花力气实现的
- 这样的模型离开不断的重构是很难开发出来的,重构需要领域专家和热爱学习领域知识的开发人员密切参与进来
- 要实现并有效地运用模型,需要精通设计技巧
重构就是在不改变软件功能的前提下重新设计它。开发人员无需在着手开发之前做出详细的设计决策,只需要在开发过程中不断小幅调整设计即可,这不但能够保证软件原有的功能不变,还可使整个设计更加灵活易懂。自动化的单元测试套件能够保证对代码进行相对安全的试验。这个过程解放了开发人员,使他们不再需要提前考虑将来的事情。
深层模型
对象分析的传统方式是现在需求文档中确定名词和动词,并将其作为系统的初始对象和方法。这种方式太过简单,只适用于教导初学者如何进行对象建模。事实上,初始模型通常都是基于对领域的浅显认知而构建的,即不够成熟也不够深入。
例如,我曾参与过一个运输应用系统的开发,我的初始想法是构建一个包括货轮(ship)和集装箱的对象模型。货轮将货物从一个地点运送到另一个地点。集装箱则通过装卸操作与货轮建立关联或解除关联。这确实能够准确描述一部分实际运输活动。但事实证明,它对于运输业务的软件实现并没有太多帮助。 最终,在与运输专家一起工作了几个月并进行了多次迭代后,我们得到了一个完全不同的模型。在外行人看来,它也许没那么浅显易懂,但却能贴切地反映出专家的想法。这个模型的关注点再次回到了运送货物的业务。
我们依然保留了ship,但是将其抽象为“船只航次”(vessel voyage),即货轮、火车或其他运输工具的某一调度好的航程。货轮本身不再重要,如遇维修或计划变动可临时改用其他方式,只要保证原定航次按计划执行即可。运输集装箱则完全从模型中移除了。它现在以一种完全不同的复杂形式出现在货物装卸应用程序中,而在原来的应用程序中,集装箱变成了操作细节。货物实际的位臵变化已不重要,重要的是其法律责任的转移。原来一些诸如“提货单”之类不被关注的对象也出现在模型中。
深层模型能够穿过领域表象,清楚地表达出领域专家们的主要关注点以及最相关的知识。以上定义并没有涉及抽象。事实上,深层模型通常含有抽象元素,但在切中问题核心的关键位置也同样会出现具体元素。
深层模型/柔性设计
如果每次对模型和代码进行的修改都能反映出对领域的新理解,那么通过不断的重构就能给系统最需要修改的地方添加灵活性,并找到简单快捷的方式来实现普通 的功能。戴久了的手套在手指关节处会变得柔软;而其他部分则依然硬实,可起到保护的作用。同样的道理,用这种方式来进行建模和设计时,虽然需要反复尝试、不断改正错误,但是对模型的设计的修改却因此更容易实现,同时反复的修改也能让我们越来越接近柔性设计。
发现过程
由于模型和设计之间具有紧密关系,因此如果代码难于重构,建模过程也会停滞不前。
重构的投入与回报并非呈线性关系。通常,小的调整会带来小的回报,小的改进也会积少成多。小改进可防止系统退化,成为避免模型变得陈腐的第一道防线。但是,有些最重要的理解也会突然出现,给整个项目带来巨大的冲击。 可以确定的是,项目团队会积累、消化知识,并将其转化成模型。微小的重构可能每次只涉及一个对象,在这里加上一个关联,在那里转移一项职责。然而,一系列微小的重构会逐渐汇聚成深层模型。 一般来说,持续重构让事物逐步变得有序。代码和模型的每一次精化都让开发人员有了更加清晰的认识。这使得理解上的突破成为可能。之后,一系列快速的改变得到了更符合用户需要并更加切合实际的模型。其功能性及说明性急速增强,而复杂性却随之消失。
当突破带来更深层的模型,通常会令人感到不安,与大部分重构相比,这种变化的回报更多,风险也更高。而且突破的时机可能很不合时宜。
关注根本
不要试图去制造突破,那只会使项目陷入困境。通常,只有在实现了许多适度的重构后才有可能出现突破。在大部分时间里,我们都在进行微小的改进,而在这种连续的改进中模型深层含义也会逐渐显现。不要犹豫不去做小的改进,这些改进即使脱离不开常规的概念框架,也可以逐渐加深我们对模型的理解。不要因为好高骛远而使项目陷入困境。只要随时注意可能出现的机会就够了。
若开发人员识别出设计中隐含的某个概念或者是在讨论中受到启发而发现一个概念时,就会对领域模型和相应的代码进行许多转换,在模型中加入一个或多个对象或关系,从而将此概念显式表达出来
业务规则通常不适合作为实体和值对象的职责,而且规则的变化和组合也会掩盖领域对象的基本含义。但是将规则移出领域层的结果会更糟糕,因为这样一来,领域代码就不再表达模型。
逻辑编程提供了一种概念,即“谓词”这种可分离可组合的对象,但是要把这种概念用对象完全实现是很麻烦的。同时,这种概念过于通用,在表达设计意图方面,他的针对性不如专门的设计那么好。
柔性设计
为了使项目能够随着开发工作的进行加速前进,而不会由于它自己的老化停滞不前,设计必须要让人们乐于使用,而且易于做出修改。这就是柔性设计(supple design)。
很多过度设计借着灵活性的名义而得到合理的外衣。但是过多的抽象层和简洁设计常常称为项目的绊脚石。看一下真正位用户带来强大功能的软件设计,你常常会发现一些简单的东西。简单并不容易做到。为了把创建的元素装配到复杂系统中,而且在装配之后仍然能够理解他们,必须坚持模型驱动的设计方法,于此同时还要坚持适当严格的设计风格。要创建或使用这样的设计可能需要我们掌握相对熟练的设计技巧。
Intention-Revealing Interfaces 模式(释意命名选择器)
客户开发人员想要有效使用对象,必须知道对象的一些信息,如果接口没有告诉开发人员这些信息,那么就必须深入研究对象的内部机制,以便理解细节。这样就失去了封装的大部分价值。我们需要避免出现“认识过载”的问题。
如果开发人员为了使用一个组件而必须要去研究它的实现,那么就失去了封装的价值。当某个人开发的对象或操作被别人使用时,如果使用这个组件的新的开发者不得不根据其实现来推测其用途,那么他推测出来的可能并不是那个操作或类的主要用途。如果这不是那个组件的用途,虽然代码暂时可以工作,但设计的概念基础已经被误用了,两位开发人员的意图也是背道而驰。
在命名结构体和操作时要描述他们的效果和目的,而不要表露他们式通过何种方式达到目的的。这样可以使客户开发人员不必去理解内部细节。这些名词应该与通用语言保持一致,以便团队成员可以迅速推断出它们的意义。在创建一个行为之前先为它编写一个测试,这样可以促使你站在客户开发人员的角度上来思考它。