背景
域驱动设计(DDD)是关于将业务域概念映射到软件构件的。关于这个主题的大多数文章和文章都是基于Eric Evans的《领域驱动设计》一书,主要从概念和设计的角度覆盖了领域建模和设计方面。这些文章讨论了DDD的主要元素,如实体、价值对象、服务等,或者讨论了泛在语言、有界上下文和反腐败层等概念。
本文的目标是从一个实际的角度来讨论如何获取域模型并实际实现它,从而涵盖域建模和设计。我们将查看技术主管和架构师在实现工作中可以使用的指导方针、最佳实践、框架和工具。领域驱动的设计和开发还受到几个体系结构、设计和实现方面的影响,比如:
- 业务规则
- 持久性
- 缓存
- 事务管理
- 安全
- 代码生成
- 测试驱动开发
- 重构
本文讨论了这些不同的因素是如何在项目的整个生命周期中影响项目的实现的,以及架构师在实现一个成功的DDD实现时应该注意什么。我将从一个典型的域模型应该具有的特征列表开始,以及何时在企业中使用域模型(与完全不使用域模型或使用贫血域模型相比)。
本文包括一个示例贷款处理应用程序,以演示如何在实际的域驱动开发项目中使用这里讨论的设计方面和开发最佳实践。示例应用程序在实现贷款处理域模型时使用Spring、Dozer、Spring Security、JAXB、Arid pojo和Spring Dynamic模块等框架。示例代码将使用Java,但是对于大多数开发人员来说,无论其语言背景如何,都应该非常容易理解。
介绍
域模型提供了以下几个好处:
- 它帮助团队在公司的业务和It涉众之间创建一个公共模型,团队可以使用该模型来沟通业务需求、数据实体和流程模型。
- 模型是模块化的,可扩展的,易于维护,因为设计反映了业务模型。
- 它提高了业务域对象的可重用性和可测试性。
另一方面,让我们看看当IT团队不遵循用于开发大中型企业软件应用程序的域模型方法时会发生什么。
不投资域模型和开发工作将导致应用程序体系结构“臃肿的服务层”和“贫血的域模型”,其中facade类(通常是无状态会话bean)开始积累越来越多的业务逻辑,而域对象则变成只有getter和setter的数据载体。这种方法还会导致领域特定的业务逻辑和规则分散(在某些情况下还会重复)到几个不同的facade类中。
贫血的领域模型,在大多数情况下,是不划算的;它们不会给公司带来比其他公司更大的竞争优势,因为在此体系结构中实现业务需求更改需要很长时间才能开发和部署到生产环境中。
在查看DDD实现项目中的不同体系结构和设计注意事项之前,让我们先看看富域模型的特征。
- 域模型应该关注特定的业务操作域。它应该与业务模型、策略和业务流程保持一致。
- 它应该与业务中的其他域以及应用程序体系结构中的其他层隔离。
- 它应该是可重用的,以避免相同核心业务域元素的任何重复模型和实现。
- 模型应该与应用程序中的其他层松散耦合设计,这意味着不依赖于域层(即数据库层和facade层)任何一侧的层。
- 它应该是一个抽象的、干净的独立层,支持更容易的维护、测试和版本控制。域类应该在容器外部(和IDE内部)是单元可测试的。
- 它应该使用POJO编程模型进行设计,而不需要任何技术或框架依赖(我总是告诉我公司的项目团队,我们用于软件开发的技术是Java)。
- 域模型应该独立于持久性实现细节(尽管技术确实对模型施加了一些约束)。
- 它应该对任何基础架构框架具有最小的依赖性,因为它将比这些框架存在得更久,而且我们不希望任何外部框架上有任何紧密耦合。
为了在软件开发工作上获得更好的投资回报(ROI),业务部门和IT部门的高级管理人员必须致力于业务领域建模及其实现的投资(时间、金钱和资源)。让我们看看实现域模型所需的其他一些因素。
- 团队应该定期访问业务领域的主题专家。
- IT团队(建模人员、架构师和开发人员)应该具有良好的建模和设计技能。
- 分析师应该具有良好的业务流程建模技能。
- 架构师和开发人员应该具有很强的面向对象设计(OOD)和编程(OOP)经验。
领域驱动设计在企业架构中的角色
领域建模和DDD在企业架构(EA)中扮演着重要的角色。自从EA的目标之一是保持IT与业务的单位,业务实体的域模型的表示,变成一个EA的核心部分。这就是为什么大多数的EA组件(业务或基础设施)应该在域模型设计和实现。
领域驱动设计和SOA
面向服务的体系结构(Service Oriented Architecture, SOA)在最近获得了越来越多的动力,以帮助团队构建基于业务流程的软件组件和服务,并加快新产品的上市时间。域驱动设计是SOA体系结构的关键元素,因为它有助于将业务逻辑和规则封装到域对象中。域模型还提供了用于定义服务契约的语言和上下文。
如果还没有域模型,SOA工作应该包括域模型的设计和实现。如果我们过于强调SOA服务而忽略了域模型的重要性,那么我们最终将得到一个贫血的域模型和应用程序体系结构中膨胀的服务。
一个理想的场景是,DDD工作通过迭代实现,同时开发应用层和SOA组件,因为它们是域模型元素的直接消费者。有了丰富的域实现,通过向域对象提供shell(代理),SOA设计将变得相对简单。但是,如果我们过于关注SOA层,而在后端没有像样的域模型,则业务服务将调用不完整的域模型,这可能导致脆弱的SOA体系结构。
项目管理
域建模项目通常包括以下步骤:
- 首先对业务流程建模并编制文档。
- 选择一个候选业务流程,并与业务领域专家合作,使用通用语言对其进行文档化。
- 标识候选业务流程所需的所有服务。这些服务可以是原子的(单个步骤),也可以是协调的(多步骤,有或没有工作流)。它们也可以是业务(例如承销或融资)或基础设施(例如电子邮件或工作安排)。
- 标识并记录上一步中标识的服务使用的对象的状态和行为。
重要的是保持模型在高层次上,首先关注业务领域的核心元素。
从项目管理的角度来看,一个实际的DDD实施项目与任何其他软件开发项目包含相同的阶段。这些阶段包括:
- 模型的域
- 设计
- 发展
- 单元和集成测试
- 基于设计和开发(模型概念的持续集成(CI)),细化和重构域模型。
- 使用更新的域模型(域实现的CI)重复上述步骤。
敏捷软件开发方法非常适合这里,因为敏捷方法关注业务价值的交付,就像DDD关注软件系统与业务模型的一致性一样。而且,由于DDD的迭代性质,SCRUM或DSDM等敏捷方法是管理项目的更好框架。使用SCRUM(用于项目管理)和XP(用于软件开发)方法是管理DDD实现项目的良好组合。
DDD迭代周期的这个项目管理模型如下面的图1所示。
图1所示。DDD迭代周期图(单击屏幕快照打开全尺寸视图)。
域驱动设计工作从域建模结束的地方开始。Ramnivas Laddad介绍了如何实现域对象模型的以下步骤。他强调在域模型中更多地关注域对象而不是服务。
- 从域实体和域逻辑开始。
- 开始时不使用服务层,只添加逻辑不属于任何域实体或值对象的服务。
- 使用无所不在的语言、契约式设计(DbC)、自动化测试、CI和重构,使实现尽可能与域模型紧密一致。
从设计和实现的角度来看,一个典型的DDD框架应该支持以下特性。
- 它应该是一个基于POJO的框架(如果您的公司是一个. net商店,则应该是POCO)。
- 它应该支持使用DDD概念的业务领域模型的设计和实现。
- 它应该支持象依赖注入(DI)和面向方面编程(AOP)这样的开箱即用的概念。(注意:本文后面将更详细地解释这些概念)。
- 集成单元测试框架,如JUnit, TestNG, Unitils等。
- 与其他Java/Java EE框架如JPA、Hibernate、TopLink等的良好集成。
样例应用程序
本文使用的示例应用程序是一个住房贷款处理系统,业务用例是批准住房贷款(抵押贷款)的资金请求。当贷款申请被提交给抵押贷款公司时,首先要经过承销商根据客户的收入明细、信用记录和其他因素批准或拒绝贷款申请的承销过程。如果贷款申请被核保集团批准,则在贷款批准过程中要经历关闭和融资的步骤。
贷款处理系统中的资金模块自动处理向借款人发放资金的过程。融资过程通常从抵押贷款方(通常是银行)将贷款包转发给产权公司开始。然后,产权公司审查贷款包,并安排一个日期与卖方和买方的财产结束贷款。借款人和卖方与产权公司的交割代理人会面,签署转让产权的文件。
体系结构
一个典型的企业应用架构由以下四个概念层组成:
- 用户界面(表示层):负责向用户表示信息和解释用户命令。
- 应用层:这一层负责协调应用程序活动。它不包含任何业务逻辑。它不保存业务对象的状态,但可以保存应用程序任务进程的状态。
- 域层:此层包含有关业务域的信息。业务对象的状态保存在这里。业务对象的持久性及其状态可能被委托给基础结构层。
- 基础结构层:这一层作为所有其他层的支持库。它提供层之间的通信,实现业务对象的持久性,包含用户界面层的支持库,等等。
让我们更详细地研究一下应用程序和域层。
应用程序层:
- 负责在应用程序中的UI屏幕之间导航,以及与其他系统的应用程序层的交互。
- 还可以对用户输入数据执行基本的(与业务无关的)验证,然后再将其传输到应用程序的其他(较低的)层。
- 不包含任何业务或域相关逻辑或数据访问逻辑。
- 没有任何反映业务用例的状态,但它可以管理用户会话的状态或任务的进度。
领域层:
- 负责业务领域的概念、关于业务用例和业务规则的信息。域对象封装了业务实体的状态和行为。处理贷款申请的业务实体包括抵押贷款、财产和借款人。
- 还可以管理业务用例的状态(会话)如果用例跨多个用户请求(如贷款登记流程,由多个步骤组成:用户进入贷款细节,系统返回产品和基于贷款利率参数,用户选择一个特定的产品/率组合,最后系统锁定的贷款利率)。
- 包含仅具有定义的不属于任何域对象的操作行为的服务对象。服务封装了不适合域对象本身的业务域行为。
- 是业务应用程序的核心,应该与应用程序的其他层隔离。而且,它不应该依赖于其他层(JSP/JSF、Struts、EJB、Hibernate、XMLBeans等)中使用的应用程序框架。
下面的图2显示了应用程序中使用的不同架构层以及它们与DDD的关系。
图2。分层应用程序架构图(单击屏幕快照以打开全尺寸视图)。
以下设计方面被认为是当前DDD实现配方的主要成分:
- 面向对象编程(OOP)
- 依赖注入(DI)
- 面向方面编程(AOP)
OOP是域实现中最重要的元素。应该利用继承、封装和多态性等OOP概念,使用普通的Java类和接口设计域对象。大多数域元素都是同时具有状态(属性)和行为(作用于状态的方法或操作)的真对象。它们也符合现实世界的概念,并且能够很好地适应面向对象编程的概念。DDD中的实体和值对象是OOP概念的经典示例,因为它们同时具有状态和行为。
在一个典型的工作单元(UOW)中,域对象需要与其他对象协作,无论它们是服务、存储库还是工厂。域对象还需要管理其他关注点,如域状态更改跟踪、审计、缓存、事务管理(包括事务重试),这些实际上是横切的。这些是可重用的与域无关的关注点,通常会分散在整个代码(包括域层)中。将此逻辑嵌入到域对象中会导致域层与非域相关代码的纠缠和混乱。
在没有对象之间的紧密耦合和隔离横切关注点的情况下管理代码依赖项时,OOP本身无法为域驱动的设计和开发提供优雅的设计解决方案。在这里,像DI和AOP这样的设计概念可以用来补充OOP,从而最小化紧密耦合,增强模块化,更好地管理横切关注点。
依赖注入
DI是将配置和依赖项代码移出域对象的好方法。另外,域类对数据访问对象(DAO)类和服务类对域类的设计依赖性使得DI在DDD实现中成为“必须有的”。DI通过将其他对象(如存储库和服务)注入域对象,促进了更干净的松散耦合设计。
在样例应用程序中,服务对象(FundingServiceImpl)使用DI注入实体对象(贷款、借款人和FundingRequest)。另外,实体通过DI引用存储库。类似地,其他Java EE资源(如数据源、Hibernate会话工厂和事务管理器)也被注入到服务和存储库对象中。
面向方面的编程
AOP通过从域对象中删除审计、域状态变化跟踪等横切关注点代码来帮助更好的设计(即在域模型中减少混乱)。它可用于将协作对象和服务注入域对象,特别是未被容器实例化的对象(例如持久性对象)。域层中可以使用AOP的其他方面包括缓存、事务管理和基于角色的安全性(授权)。
贷款处理应用程序使用自定义方面将数据缓存引入服务对象。贷款产品和利率信息从数据库表中加载一次(客户端首先请求此信息),然后存储在对象缓存(JBossCache)中,用于后续产品和利率查找。Product和rate数据经常被访问,但是不经常更新,所以它是缓存数据而不是每次都命中后端数据库的好选择。
DI和AOP概念在DDD中的作用是最近一个讨论线程中的主要主题。这个讨论是基于Ramnivas Laddad的一个演讲,他在演讲中断言,没有AOP和DI的帮助,DDD是无法实现的。在演讲中,Ramnivas谈到了使用AOP使域对象重新获得智能行为的“细粒度DI”概念。他提到域对象需要访问其他细粒度对象来提供丰富的行为,对此的解决方案是将服务、工厂或存储库注入域对象(通过使用方面在构造函数或setter调用时注入依赖项)。
Chris Richardson还讨论了使用DI、对象和方面来通过减少耦合和增加模块化来改进应用程序设计。Chris谈到了“大型服务”反模式,它是应用程序代码耦合、纠缠和分散的结果,以及如何使用DI和AOP概念来避免它。
注释
定义和管理方面和DI的一个最新趋势是使用注释。注释有助于最小化实现远程服务(如EJB或Web服务)所需的构件。它们还简化了配置管理任务。Spring 2.5、Hibernate 3和其他框架充分利用了注释来在Java企业应用程序的不同层中配置组件。
我们应该利用注释来生成锅炉板代码,从而增加灵活性方面的价值。同时,应该谨慎使用注释。它们应该用于在理解实际代码时不会造成混淆或误导的地方。使用注释的一个很好的例子是Hibernate ORM映射,它增加了在类或属性名旁边指定SQL表名或列名的值。另一方面,像JDBC驱动程序配置(驱动程序名、JDBC url、用户名和密码)这样的细节更适合存储在XML文件中,而不是使用注释。这是基于数据库在相同上下文中的假设。如果需要在域模型和数据库表之间进行重要的转换,那么设计应该考虑这个问题。
Java EE 5提供了诸如@Entity、@PersistenceUnit、@PersistenceContext等JPA注释来为普通Java类添加持久性细节。在域建模的上下文中,实体、存储库和服务是使用注释的很好选择。
@ configured是Spring将存储库和服务注入域对象的方式。Spring框架将“域对象DI”的概念扩展到了@ configurationannotation之外。Ramnivas最近在博客中提到了即将发布的Spring 2.5.2版本的最新改进(从项目快照构建379开始可用)。有三个新的方面(AnnotationBeanConfigurerAspect、AbstractInterfaceDrivenDependencyInjectionAspect和AbstractDependencyInjectionAspect)为域对象DI提供了简单而灵活的选项。Ramnivas说,引入中间方面(AbstractInterfaceDrivenDependencyInjectionAspect)的主要原因是允许领域特定的注释和接口发挥作用。Spring还提供了@Repository、@Service和@Transactional等其他注释来帮助设计域类。
在示例应用程序中使用的一些注释,实体对象(贷款、借款人和FundingRequest)使用@Entity注释。这些对象还使用@ configurationannotation连接存储库对象。服务类使用@Transactional注释用事务行为装饰服务方法。
域模型和安全性
域层中的应用程序安全性确保只有经过授权的客户机(人类用户或其他应用程序)调用域操作并访问域状态。
Spring Security (Spring Portfolio中的子项目)在应用程序的表示层(基于URL)和域层(方法层)中提供了细粒度的访问控制。框架使用Spring的Bean代理拦截方法调用并应用安全约束。它使用MethodSecurityInterceptor类为Java对象提供了基于角色的声明性安全性。对于域对象,还存在以访问控制列表(ACL的)形式的实例级安全性,以便在实例级控制用户访问。
使用Spring Security来管理域模型中的授权需求的主要优点是,该框架具有非侵入性的体系结构,因此我们可以在域和安全方面进行清晰的分离。而且,业务对象不会与安全实现细节混淆。我们可以在一个地方编写通用的安全规则,并在需要实现它们的地方应用它们(使用AOP技术)。
在域和服务类中,授权是在类方法调用级别进行管理的。例如,“贷款批准”方法在承销域对象可以调用任何用户提供一个“保险人”的角色对贷款高达100万美元而审批方法在相同的域对象的贷款申请,贷款金额大于100万美元只能被一个用户“承销主管”角色。
下表总结了应用程序体系结构每一层中的各种应用程序安全问题。
表1 各种应用程序层中的安全问题
业务规则
业务规则是业务领域的重要组成部分。它们定义了需要应用于特定业务流程场景中的域对象的数据验证和其他约束。业务规则通常分为以下几类:
- 数据验证
- 数据转换
- 业务决策
- 流程路由(工作流逻辑)
语境在DDD世界中非常重要。上下文的特异性决定了域对象的协作以及其他运行时因素,如应用什么业务规则等。验证和其他业务规则总是在特定的业务上下文中处理。这意味着相同的域对象在不同的业务上下文中必须处理不同的业务规则集。例如,贷款域对象的某些属性(如贷款金额和利率)在贷款通过贷款审批流程中的审批步骤后不能更改。但是,在为特定利率注册和锁定贷款时,可以更改相同的属性。
尽管所有特定于域的业务规则都应该封装在域层中,但是一些应用程序设计将这些规则放在facade类中,这导致域类在业务规则逻辑方面变得“贫血”。在小型应用程序中,这可能是一个可接受的解决方案,但是对于包含复杂业务规则的中型到大型企业应用程序,不推荐使用这种解决方案。更好的设计选项是将规则放在它们所属的地方,即域对象中。如果业务规则逻辑跨越两个或多个实体对象,那么它应该成为服务类的一部分。
此外,如果我们不认真对待应用程序,设计业务规则最终将以代码中几个switch语句的形式编码。随着时间的推移,规则变得越来越复杂,开发人员不需要花时间重构代码来将“switch”语句转移到更易于管理的设计中。在类中硬编码复杂的路由或决策规则逻辑会导致类中的方法变长、代码重复,最终导致僵化的应用程序设计,从长远来看,这将成为维护的噩梦。一个好的设计是将所有的规则(特别是随着业务策略的变化而频繁变化的复杂规则)放到一个规则引擎中(使用JBoss规则、OpenRules或Mandarax之类的规则框架),并从域类中调用它们。
验证规则通常用不同的语言实现,如Javascript、XML、Java代码和其他脚本语言。但是由于业务规则的动态性,脚本语言(如Ruby、Groovy或领域特定语言(DSL))是定义和管理这些规则的更好选择。Struts(应用层)、Spring(服务)和Hibernate (ORM)都有自己的验证模块,我们可以在这些模块中对传入或传出的数据对象应用验证规则。在某些情况下,验证规则也可以作为方面来管理(链接AOP规则的文章),这些方面可以被编织到应用程序的不同层(例如服务和控制器)中。
在编写域类来管理业务规则时,一定要记住单元测试方面。规则逻辑中的任何更改都应该很容易在隔离状态下进行单元测试。
示例应用程序包含一个业务规则集,用于验证贷款参数是否在允许的产品和利率规范中。这些规则在脚本语言(Groovy)中定义,并应用于传递给FundingService对象的贷款数据。
设计
从设计的角度来看,域层应该有一个定义良好的边界,以避免非核心域层的破坏,比如特定于供应商的转换、数据过滤、转换等。应该设计域元素来正确地保存域状态和行为。基于状态和行为,不同的域元素有不同的结构。下面的表2显示了域元素及其包含的内容。
表2. 具有状态和行为的域元素
包含状态(数据)和行为(操作)的实体、值对象和聚合应该有明确定义的状态和行为。同时,这种行为不应该超出对象边界的限制。在用例中,实体应该根据它们的本地状态完成大部分工作。但是他们不应该知道太多不相关的概念。
好的设计实践是只包含用于封装域对象状态的属性的getter /setter。在设计域对象时,仅为那些可以更改的字段提供setter方法。另外,公共构造函数应该只包含必需的字段,而不是包含域类中所有字段的构造函数。
在大多数用例中,我们实际上不必能够直接更改对象的状态。因此,与其更改内部状态,不如使用更改后的状态创建一个新对象并返回新对象。在这些用例中,这就足够了,而且还减少了设计的复杂性。
聚合类向调用者隐藏协作类的用法。它们可用于在域类中封装复杂的、介入的和依赖于状态的需求。
支持DDD的设计模式
有几种设计模式可以帮助领域驱动的设计和开发。以下是这些设计模式的列表:
- 域对象(做)
- 数据传输对象(DTO)
- DTO汇编
- 存储库:存储库包含以域为中心的方法,并使用DAO与数据库交互。
- 泛型DAO的
- 时态模式:这些模式向丰富的域模型添加了时间维度。双时态框架基于Martin Fowler的时态模式,为处理域模型中的双时态问题提供了一种设计方法。可以使用诸如Hibernate之类的ORM产品来持久化核心域对象及其双时态属性。
DDD中使用的其他设计模式包括策略、外观和工厂。Jimmy Nilsson在他的书中将工厂作为一个域模式进行了讨论。
DDD反模式
在最佳实践和设计模式的反面,有一些DDD的味道是架构师和开发人员在实现域模型时应该注意的。由于这些反模式,域层成为应用程序体系结构中最不重要的部分,而facade类在模型中扮演更重要的角色。以下是一些反模式:
- 贫血的域对象
- 重复DAO的
- 胖服务层:这是服务类最终拥有所有业务逻辑的地方。
- 特性嫉妒:这是Martin Fowler关于重构的书中提到的一种典型的味道,其中类中的方法对属于其他类的数据太感兴趣了。
数据访问对象
DAO和存储库在域驱动设计中也很重要。DAO是关系数据库和应用程序之间的契约。它封装了来自web应用程序的数据库CRUD操作的细节。另一方面,存储库是一个单独的抽象,它与dao交互,并向域模型提供“业务接口”。
存储库使用域的通用语言,使用所有必要的dao,并以域所理解的语言为域模型提供数据访问服务。
DAO方法是细粒度的,更接近于数据库,而存储库方法是粗粒度的,更接近于域。另外,一个存储库类可能注入了多个DAO。存储库和DAO使域模型与处理数据访问和持久性细节分离。
域对象应该仅依赖于存储库接口。这就是为什么注入存储库而不是DAO会产生一个更干净的域模型的原因。不应该直接从客户机(服务和其他使用者类)调用DAO类。客户机应该总是调用域对象,而域对象又应该调用DAO来将数据持久化到数据存储中。
管理域对象之间的依赖关系(例如,实体及其存储库之间的依赖关系)是开发人员经常遇到的一个经典问题。此问题的通常设计解决方案是让服务或Facade类直接调用存储库,当调用存储库时,存储库将向客户端返回实体对象。这种设计最终导致了前面提到的贫血域模型,其中facade类开始积累更多的业务逻辑,域对象成为纯粹的数据载体。一个好的设计是使用DI和AOP技术将存储库和服务注入域对象。
样例应用程序在实现贷款处理域模型时遵循这些设计原则。
持久性
持久性是一个基础结构方面,应该对域层进行解耦。JPA通过对类隐藏持久性实现的细节来提供这种抽象。它是注释驱动的,因此不需要XML映射文件。但同时,表名和列名被嵌入到代码中,这在某些情况下可能不是一个灵活的解决方案。
使用提供数据网格解决方案的网格计算产品(如Oracle Coherence、WebSphere对象网格和GigaSpaces),开发人员在建模和设计业务域时甚至不需要考虑RDBMS。数据库层以内存对象/数据网格的形式从域层抽象出来。