领域驱动设计
关于领域驱动设计
这篇文章参考了Eric Evans《领域驱动设计》一书以及Jimmy Nilsson《以C# .NET为例运用领域驱动设计和模式》,二者详细描述了领域驱动设计的核心概念、技术和模式。在某些情况下,直接使用这些书的措辞是有意义的,并且我认为Eric Evans和Jimmy Nilsson也允许我们这么做。
尽管将方法本身呈现出来是很有用的,但是仅仅对方法进行描述,DDD的许多微妙之处就会消失。这些方法应该是你的工具,而不是你束缚你的规则。它们是为设计而生的语言,在团队内沟通创意和模型十分有益。更为重要的是,记住实践DDD的目的是为了做出更加务实的决定。不要试图将一个方法强加于一个模型。另外,如果你“打破”了一个方法,确保你已经理解了这么做的理由,并将其与其他人沟通。
通常认为,DDD在面向对象范式下表现良好,但远不止这些。DDD解决了如何去理解问题空间这一挑战,甚至是更复杂的交流问题。
值得注意的是,DDD还鼓励将其他领域的概念收入囊中,比如测试驱动开发,设计模式的使用,以及持续重构。
代表模型
领域驱动设计的最主要目的是为了设计和创造出富有表达力的模型。同样,DDD也致力于创造出让参与软件开发中的各方都能理解的模型。
由于非技术人员同样需要使用这些模型工作,所以如果能用不同的方式进行表达将变得极为便利。典型的,描述一个领域模型的方式有多种:UML草图,代码以及领域语言。
使用语言
正在考虑参加培训课程的人会根据主题、成本、以及课程安排搜索课程表。当一个课程被订阅后,一个注册邀请会被下发给订阅人,订阅人可以在稍后选择取消或者接受。
使用代码
class Person { public Registration bookCourse(Course c) { ... } } abstract class Registration { public abstract void accept(); public abstract void cancel(); } class ReservedRegistration extends Registration { ... } class AcceptedRegistration extends Registration { ... } interface CourseRepository { public List<Course> find(...); }
使用UML草图
一致地使用通用语言对理解和传达在领域中发现的内在信息至关重要。在领域驱动开发的过程中,我们通常讨论的是概念而非名词或者动词。概念的意图,其意义和价值对于理解和传达信息至关重要。如何去实现这种意图虽然很重要,但是对于每个意图来说,其实现方式有很多种。为了理解和分享这些意图,任何人都必须在任何地方和任何时机下使用这些语言。当你使用通用语言时,和领域专家的合作将变得更加富有创意和价值。
注意,由于语言中的技术和业务障碍,领域专家可能会无意识的隐藏或者模糊某些重要概念。通常这些术语和实现有关,而非领域概念。DDD虽然不会将实现拒之门外,而它的意义在于反应较高层次模型的意图。
考虑下面的描述:
当一个人订阅了一个课程,如果课程已经满员,那么这个人会得到一个“等待”的状态。一旦课程还有名额可用,那么我们的消息总线必须给这个人发送详细信息以使得支付网关可以继续处理。
以上描述有一些潜在的缺陷。这些术语不会增加价值,但是它们是深入挖掘领域驱动设计的极好线索。
用户有一个状态
状态可能是一个标志或者字段。也许领域专家对其他系统如电子表格很熟悉,并建议使用同样的实现。
通过我们的消息总线发送
这是一个技术实现。实际上在领域中,通过我们的消息总线发送信息没有任何现实结果。
处理
这是模棱两可并且模糊的描述,处理过程中会发生什么?
支付网关
另一个实现。实际上此处更为重要的是支付的一些方式而不是支付的实现。
为了更深的理解
仔细的去注意实现,挖掘出真实的概念以及概念的意图。让我们来看看同一个对话,注意可能隐藏在对话中的线索以及背后的一些实现。当一个人预定一个课程,但是课程满了,那么这个人会得到一个“等待的状态”,一旦课程有名额可用,则必须通过我们的消息总线发送该人的详细信息,以便由支付网关处理。
深入挖掘后我们发现,订购课程的人并不止一个状态,相反,注册过课程的人会得到一个“已注册”的状态。如果课程满了,那么这个人会得到一个“待注册”的状态。所有的“待注册” 则被一个等待队列统一管理。确保该术语所代表的概念被明确定义,并且领域专家也同意它的意图和使用方式。
重构语言
记住,语言是领域模型的表达,就像代码一样。当你的代码重构时,也请一并重构你的语言以纳入新的术语。
让我们重构刚刚关于预定课程的对话。
当一个人试图注册一个课程时,系统会发送一个”保留注册“的动作,如果课程还有名额可用,相应的款项也支付成功,那么“保留注册”则被接受;如果课程已经没有名额可用,那么“保留注册”会被当做“待注册”放置在等待队列中。该等待队列是先进先出的。
使用具体实例
使用具体实例与领域名专家进行协作往往更容易。通常,使用行为驱动开发(BDD)故事和场景模板来描述领域示例是很方便的。我们上述场景使用具体实例的版本,用BDD模板进行改写。
故事:注册一个课程 作为寻找培训的人 我需要预定一个课程 这样我就可以学习并提高我的技能
这个故事描述了一个希望实现某些东西(“预订课程”)的角色(“寻找培训的人”),以达到一些目的(“学习和提高我的技能”)。
现在我们有了这个故事,并且这个故事有很多的场景。让我们考虑课程已满的场景。
场景:课程已满 鉴于Python 101课程可容纳10个座位 并且已经有10个人对Python 101进行了确认注册 当我注册“Python 101”时 那么应该有一个备用注册给我的Python 101 我的备用注册应该在等待名单上
“given”子句描述了场景的情况 “when”子句是在场景中发生的事件 而“Then”子句描述事件发生后应该预期的结果
策略设计
策略设计讨论的是大而复杂的设计,重点是构成大型模型的许多部分,以及这些部分之间如何相互关联。这有利于设计向前推进,取得足够的进展,而不是掉进“我的模型被强制转化成石头”这一陷阱。
在DDD中,这些小的模型存在于上下文的边界,这些有界上下文相互关联的方式称为上下文映射。
上下文边界
对于每一个模型,应该有意和明确地去定义其所处的上下文。创建上下文并没有特定的规则,但重要的是让每个人都能理解上下文的边界条件。
一个系统下的各个团队如果对于不同的上下文以及他们之间的关系没有一个很好的理解,那么在整合有界上下文时会冒着向模型妥协的风险。如果团队没有明确的映射和了解上下文之间的关系,那么模型之间的线条可能会因为数量爆炸而变得越发模糊。
上下文可能会以如下方式被创建(但是不限于):
团队的组织方式
代码库的结构和布局
在特定领域范围内被使用
我们的目标是在上下文中保持一致性和统一性,而不要由于上下文的外部领域而分散精力。不同的上下文将具有不同的模型和不同的概念。对于不同的上下文来说,使用领域通用语言的一种不同的方言是很常见的行为。
上下文映射
上下文映射是针对连接点的设计过程,同时有界上下文之间的转义关系应该被明确的反应出来。我们应该着重于处理现有界限之间的映射关系,之后再去处理实际的转换。
HOT TIP:在单个有限的上下文中使用持续集成,以缓解由于不同理解产生过多碎片的矛盾。频繁的代码合并,自动化测试,使用通用语言都会加剧促使有界上下文中碎片的产生。
上下文映射模式
用于上下文映射的模式有很多,其中的一些将在下文中得到解释。
共享内核
共享内核是一个有限上下文,它通常是领域的一个子集,供不同团队分享,而这需要团队之间的良好沟通和协作。记住它并不是团队间的“最小公分母”。
HOT TIP:慎重的去对待共享内核!它们很难设计和维护,并且几乎只有在高度成熟的团队才有效果。
当一个有界上下文服务于或者消费另一个有界上下文,那么下游的上下文将会依赖于上游上下文。明确上下文处于上游或者下游能使其提供者或者消费者的身份变得更加清晰。
当一个工作于下游的团队没有影响力或者没有机会与上游团队进行合作时,那么他们别无选择,只能遵从于上游。处于上游的上下文有很多理由去“主宰”提供给下游上下文的接口, 但是切换到一个一致的模式则会消除许多痛苦。简单的与上游接口保持一致,降低的复杂度往往会超过尝试改变不可更改的接口的复杂度。
一般来说,下游模型的质量取决于上游模型的质量。如果上游模型设计的比较好,那么下游的模型也同样良好。但是,如果上游的模型设计的比较差,那么下游的模型会同样不堪。无论如何,上游模型将不会适应下游需求,因此不会完美契合。
HOT TIP:践行一致性模式需要更多的实用主义精神。上游模型的质量及其适应性可能“足够好”,这暗示着你不会想让你正在工作的上下文处在一致性关系中的核心领域。
隔离层
当试图给不同系统中的上下文建立联系时,会导致一个模型渗透到另外一个模型中,那么它们的意图可能会在两者的混乱组合中丢失。在这种情况下,最好让两个上下文保持良好的距离,并在期间引入隔离层,负责在两个方向上进行翻译。
HOT TIP:隔离层是处理遗留系统或将逐步淘汰的代码库的一个很好的模式。
独立方式
严格分析有界上下文之间的映射。如果没有不可或缺的功能关系,那么请将上下文分开。这么做的理由是整合在一起的成本高,而相应的收益率却很低。而将上下文分开则大大消除了复杂性,因为它允许开发人员(甚至业务经理)在非常有限的范围内找到高度集中的解决方案。
为领域建模
在处理有界上下文时,我们要重点关注建立真正的有表达力的模型;这些模型更多的反应意图而不是实现。一旦我们这样做了,领域的概念就会显得非常自然,并且模型设计也会变得灵活和易于重构。DDD模式更多地是来自Fowler等人关于GoF的模式的应用,特别是在建模主题领域。下面是最常见的模式:
处理结构
实体
实体类的特征是它的实例可以全局辨识、并且始终保持一致。其他属性的状态可能会有变化,但标识永远不会改变。
在这里例子中,Address可能会变更很多次,但是Client的标识不会变,无论其他属性怎么改变它们的状态。
值对象
值对象都是轻量级、不可变并且没有标识的。虽然它的值很重要,但是它不是简单的数据传输对象。值对象是放置复杂计算的好场所,因此我们可以将笨重的业务逻辑从实体对象中转移出来。它们更容易、更安全的被组合,并且由于复杂的业务逻辑被转移,实体能更加专注于追踪自身的生命周期。
在这个例子中,当Client的地址发生变化时,然后将一个新的Address值对象实例化并分配给Client。
HOT TIP:值对象的生命周期很简单,因此可以大大简化你的模型。他们也非常适合在静态类型语言的编译时期引入类型安全。并且由于值对象的方法应该是无副作用的,他们也增加了一些函数式的编程风格。
关联系数
类之间的关联系数越大,意味着数据结构越复杂。我们可以通过添加限定词来降低系数。
双向关联也增加了复杂性。批判性的去思考这个问题,以确定是否必须这么做。
在这个例子中,我们并不需要向所有Project对象询问Person对象,但是我们总是要在Person的所有角色中询问一个Project对象,这样我们就可以使关系维持在一个方向上。方向意味着在计算机在内存中是以何种方式维护着对象之间的关系。如果我们需要检索出一个Person对象的所有Project对象,我们可以在一个Repository(见下文)中执行一个查询。
服务层
有时候不可能将一个行为分配给任何一个类,无论是实体还是值对象。很多场景都是对多个类进行操作的纯粹的功能运算,而不是一个单一的类为该行为负责。在这种情况下,引入了一种称为服务类的无状态的类来封装此行为。
聚合
随着我们往模型里面添加越来越多的东西,对象图会变得相当大而复杂。过大的对象图使诸如事务边界,分布式和并发等技术实现非常困难。聚合拥有一致性边界,使得边界内的类与对象图的其他部分“断开连接”。每个聚合都有一个实体作为聚合的“根”。
当我们创建聚合时,要确保聚合仍然被视为领域中的有意义的单元。另外,通过使用“删除”测试来测试聚合边界的正确性。在删除测试中,如果根被删除,则批判性地检查聚合中的哪些对象(以及聚合之外)也将被删除。
按照这些简单的规则进行聚合: 根具有全局身份,而其他的实体只有本地身份 根检查所有的常量是否被满足 聚合外面的实体仅仅持有对根的引用 删除操作会移除整个聚合内的所有内容 当有对象发生变化时,必须满足所有常量。
HOT TIP:记住聚合的两个作用:简化领域设计和改进技术。聚合之间可能存在不一致之处,但所有聚合最终彼此一致
处理生命周期
工厂
工厂管理着一些聚合的生命周期的开端,这是GoF工厂或建造者模式的应用。必须注意聚合体的规则得到遵循,尤其是聚合内的不变量。务实的去使用工厂模式。记住,工厂有时非常有用,但不是必需的。
工厂管理生命周期的开始,而仓库管理生命周期的中间和末尾.仓库可能将持久化的责任委托给负责检索对象的ORM框架。记住,仓库也是用聚合的方式进行工作。所以检索到的对象应该遵守聚合规则。
规约模式
规约模式适用于需要对规则,验证和选择标准进行建模的场景。规约模式的实现会测试一个对象是否满足规范的所有条件,考虑下面的类:
class Project { public boolean isOverdue() { ... } public boolean isUnderbudget() { ... } }
overdue和underbudget projects的规则会和project的规则解耦并且为其他的类负责。
public interface ProjectSpecification { public boolean isSatisfiedBy(Project p); } public class ProjectIsOverdueSpecification implements ProjectSpecification { public boolean isSatisfiedBy(Project p) { ... } }
这也使得客户端的代码变得更加灵活可读。
If (projectIsOverdueSpecification.isSatisfiedBy(theCurrentProject) {
...
}
策略模式
策略模式又被称为政策模式,旨在让算法变得更加通用。在这种模式中,不同的“部分”被分解出来。
考虑下面的实例,这决定了项目的成功与否,其基于两个计算: (1)项目能按时完工 (2)项目没有超过预算
public class Project { boolean isSuccessfulByTime(); boolean isSuccessfulByBudget(); }
使用了策略模式后,我们可以将一个算法的两种不同实现封装在不同的策略实现类中。
interface ProjectSuccessPolicy { Boolean isSuccessful(Project p); } class SuccessByTime implements ProjectSuccessPolicy { ... } class SuccessByBudget implements ProjectSuccessPolicy { ... }
下面使用策略模式重构上述Project类,我们在策略实现中封装了成功的标准,而不是Project类本身。
class Project { boolean isSuccessful(ProjectSuccessPolicy policy) { return policy.isSuccessful(this); } }
组合模式
这是GoF在领域驱动设计中最直接的应用,值得注意的是,客户端代码应该仅仅去处理代表组合元素的抽象代表,考虑下面的类:
public class Project { private List milestones; private List tasks; private List subprojects; }
Subproject由Milestones和许多Task组成,一个Milestone是一个到期的Task。通过引入组合模式,我们可以引入一个新的类型Activity并让Subproject、Milestone、Task去实现。
现在Project模型就变得极为简单:
public class Project { private List activities; }
下面是该模型的UML表示:
当设计的重点在于创建行为丰富的领域模型时,那么相应的架构设计必须保证模型不受基础设施的影响。通常来说,分层架构可以将领域与系统中的其他层分隔开,并且每一层只能感知它下一层的存在。因此,较低层的代码不能调用(发送消息)较高层的代码。另外,每一层都是非常聚合的,要严格区分每一层的代码的目的和职责。
用户界面层 负责构建用户界面并管理与领域模型的交互。典型的应用是MVC模式。
应用层 允许视图层与领域层协作的中间层。注意:这是容易聚集比较散乱的行为,诸如“事务脚本”风格的代码。
领域层 领域层通常有着丰富的行为并且富有表达力,注意到仓库和工厂也是领域层的一部分。但是,ORM框架会使得仓库层将它的部分功能委托给底层的基础设施。
基础设施 主要处理特定技术领域的决策,侧重于实现而非意图。注意到虽然领域实例可以在这一层被创建,但是与这层进行交互的往往是仓库层,以获得对这些对象的引用。
我们的目标是设计好层和接口,并且让这些接口做好层与层之间的通讯。此外,要让使用域层的代码控制事务边界。