背景
域驱动设计(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。数据库层以内存对象/数据网格的形式从域层抽象出来。
缓存
当我们讨论域层的状态(数据)时,我们必须讨论缓存的方面。频繁访问的域数据(如按揭贷款处理应用程序中的产品和利率)是很好的缓存候选者。缓存可以提高性能并减少数据库服务器上的负载。服务层是缓存域状态的理想选择。像TopLink和Hibernate这样的ORM框架也提供了数据缓存。
贷款处理示例应用程序使用JBossCache框架来缓存产品和费率细节,以最小化数据库调用并提高应用程序性能。
事务管理
事务管理对于保持数据完整性和提交或回滚UOW非常重要。关于在应用程序体系结构层中应该在何处管理事务,一直存在争议。还有跨实体事务(跨越同一UOW中的多个域对象),它们影响应该在何处管理事务的设计决策。
有些开发人员喜欢在DAO类中管理事务,这是一个糟糕的设计。这导致了过于细粒度的事务控制,这没有提供管理事务跨多个域对象的用例的灵活性。服务类应该处理事务;这样,即使事务跨越多个域对象,服务类也可以管理事务,因为在大多数用例中,服务类处理控制流。
示例应用程序中的FundingServiceImpl类管理资金请求的事务,并通过调用存储库执行多个数据库操作,并在单个事务中提交或回滚所有数据库更改。
数据传输对象
DTO也是SOA环境中设计的一个重要部分,在SOA环境中,域对象模型在结构上与从业务服务接收和发送的消息不兼容。消息通常在XML模式定义文档(XSD)中定义和维护,从XSD中编写(或代码生成)DTO对象并将其用于域和SOA服务层之间的数据(消息)传输是一种常见的实践。在分布式应用程序中,将数据从一个或多个域对象映射到一个DTO将成为一个必要的麻烦,因为从性能和安全角度来看,通过网络发送域对象可能并不实际。
从DDD的角度来看,DTO还有助于维护服务层和UI层之间的分离,其中DO用于域,服务层用于表示层,DTO用于表示层。
Dozer框架用于将一个或多个域对象组装到一个DTO对象中。它是双向的,这节省了大量额外的代码和时间转换域对象到DTO的,反之亦然。DO和DTO对象之间的双向映射有助于消除单独的DO -> DTO和DTO -> DO转换逻辑。框架还正确处理类型和数组转换。
当请求进入资金处理时,样例应用程序使用Dozer映射文件(XML)将FundingRequestDTO对象分割为贷款、借款人和FundingRequest实体对象。该映射还负责将来自实体的资金响应数据聚合到返回客户端的单个DTO对象中。
DDD实施框架
Spring和Real Object Oriented (ROO)、Hibernate等框架有助于设计和实现域模型。其他支持DDD实现的框架有JMatter、Naked Objects、Ruby On Rails、Grails和Spring Modules XT框架。
Spring负责实例化和连接域类(如服务、工厂和存储库)。它还使用@ configurationannotation将服务注入实体。该注释是特定于Spring的,因此实现此注入的其他选项是使用诸如Hibernate拦截器之类的东西。
ROO是一个建立在“领域第一,基础设施第二”理念上的DDD实现框架。开发该框架是为了减少web应用程序开发中模式的样板代码。在使用ROO时,我们定义域模型,然后框架(基于Maven原型)为模型-视图-控制器(MVC)、DTO、业务层Facade和DAO层生成代码。它甚至为单元测试和集成测试生成存根。
ROO有一些非常实用的实现模式。例如,它区分状态管理的字段,持久层使用字段级访问,公共构造函数只反映强制字段。
开发
没有实际的实现,模型是没有用的。实现阶段应该包括尽可能多地自动化开发任务。要查看哪些任务可以自动化,让我们来看一个涉及域模型的典型用例。以下是用例中的步骤列表:
请求:
- 客户端调用Facade类,以XML文档的形式发送数据(与XSD兼容);Facade类为UOW启动一个新的事务。
- 对输入的数据运行验证。这些验证包括主要的(基本的/数据类型/字段级别的检查)和业务验证。如果存在任何验证错误,则提出适当的异常。
- 将描述翻译成代码(对域友好)。
- 使数据格式更改对域模型友好。
- 对属性进行任何分离(例如将客户名拆分为customer实体对象中的first和last name属性)。
- 将DTO数据分解为一个或多个域对象。
- 持久化域对象的状态。
响应:
- 从数据存储中获取域对象的状态。
- 必要时缓存状态。
- 将域对象组装到应用程序友好的数据对象(DTO)中。
- 对数据元素进行任何合并或分离(例如将姓和名合并到单个客户名属性中)。
- 把代码翻译成描述。
- 对数据格式进行必要的更改,以满足客户端数据使用需求。
- 必要时缓存DTO状态
- 当控制流退出时,事务提交(或回滚)。
下表显示了在应用程序中将数据从一个层传送到另一个层的不同对象。
表3. 数据流经应用程序层
正如您所看到的,在应用程序架构中有几个层,其中相同的数据以不同的形式(DO、DTO、XML等)流动。这些包含数据和其他类(如DAO、DAOImpl和DAOTest)的大多数对象(Java或XML)本质上都是基础结构。这些具有样板代码和结构的类和XML文件非常适合用于代码生成。
代码生成
ROO之类的框架还为新项目创建了一个标准的、一致的项目模板(使用Maven插件)。使用预先生成的项目模板,我们可以在目录结构中实现一致性,在哪里存储源和测试类、配置文件,以及内部和外部(第三方)组件库的依赖性。
当我们考虑到开发一个典型的企业软件应用程序需要大量的类和配置文件时,这可能会让人难以承受。代码生成是解决这个问题的最佳方法。代码生成工具通常使用某种模板框架来定义模板或映射,代码生成器可以从这些模板或映射生成代码。Eclipse Modeling Framework (EMF)有几个子项目,可以帮助生成web应用程序项目中所需的各种构件的代码。像AndroMDA这样的模型驱动架构(Model Driven Architecture, MDA)工具使用EMF根据架构模型生成代码。
当涉及到在域层中编写委托类时,我看到开发人员手动编写这些类(主要是从头开始编写第一个类,然后按照“复制和粘贴”模式为其他域对象创建所需的委托类。由于大多数这些类基本上都是域类的外观,所以它们是代码生成的良好候选对象。代码生成选项是一个很好的长期解决方案,即使它涉及一些初始投资(在编码和时间方面)来构建和测试代码生成器(引擎)。
对于生成的测试类,一个好的选择是为需要进行单元测试的主类中具有复杂业务逻辑的方法创建抽象方法。通过这种方式,开发人员可以扩展生成的基本测试类,并实现不能自动生成的自定义业务逻辑。对于任何具有不能自动创建的测试逻辑的测试方法都是一样的。
脚本语言是编写代码生成器的更好选择,因为它们的开销更少,并且支持模板创建和自定义选项。如果我们利用DDD项目中的代码生成,我们只需要从头开始编写几个类。必须从头创建的工件包括:
- XSD
- 域对象
- 服务
一旦我们定义了XSD和Java类,我们就可以通过代码生成以下所有或大部分类和配置文件:
- DAO接口和实现类
- 工厂
- 存储库
- 域委托(如果需要)
- Facade(包括EJB和web服务类)
- DTO的
- 以上类的单元测试(包括测试类和测试数据)
- Spring配置文件
下面的表4列出了web应用程序体系结构中的不同层,以及可以在该层生成什么工件(Java类或XML文件)。
表4:DDD实现项目中的代码生成
委托层是唯一同时具有领域对象和DTO知识的层。其他层,如持久层,应该不知道DTO的。
重构
重构是在不改变应用程序的功能或行为的情况下改变或重组应用程序代码。重构可以与设计或代码相关。进行设计重构是为了不断地细化模型并重构代码以改进域模型。
重构在DDD项目中扮演着重要的角色,因为它具有领域建模的迭代和进化性质。将重构任务集成到项目中的一种方法是在调用迭代完成之前将其添加到项目的每个迭代中。理想情况下,重构应该在每个开发任务之前和之后进行。
重构应该严格遵守规则。结合使用重构、CI和单元测试来确保代码更改不会破坏任何功能,同时这些更改确实有助于预期的代码或性能改进。
自动化测试在重构应用程序代码中起着至关重要的作用。如果没有良好的自动化开发人员测试和测试驱动开发(TDD)实践,重构可能会适得其反,因为没有自动的方法来验证作为重构工作一部分的设计和代码更改不会改变行为或破坏功能。
Eclipse之类的工具可以帮助以迭代的方式实现域模型,并将重构作为开发工作的一部分。Eclipse具有诸如提取或将方法移动到不同的类或将方法下推到子类等特性。还有一些Eclipse的代码分析插件可以帮助管理代码依赖项和识别DDD反模式。当我对项目进行设计和代码评审时,我依赖JDepend、Classycle和Metrics等插件来评估应用程序中域和其他模块的质量。
Chris Richardson谈到了使用Eclipse提供的重构特性,应用代码重构将过程设计转换为面向对象设计。
单元测试/持续集成
我们前面谈到的目标之一是,域类应该是单元可测试的(在初始开发期间以及稍后重构现有代码时),而不需要对容器或其他基础结构代码有太多依赖。TDD方法帮助团队在项目的早期发现任何设计问题,并验证代码是否与域模型一致。DDD对于测试优先的开发是理想的,因为状态和行为包含在域类中,并且应该很容易对它们进行隔离测试。重要的是测试域模型的状态和行为,而不是过多地关注数据访问或持久性的实现细节。
像JUnit或TestNG这样的单元测试框架是实现和管理域模型的好工具。其他测试框架,如DBUnit和Unitils,也可以用来测试域层,特别是将测试数据注入到DAO类中。这将最小化为在单元测试类中填充测试数据而编写的额外代码。
模拟对象还有助于在隔离状态下测试域对象。但是重要的是不要在域层中疯狂地使用模拟对象。如果有其他测试域类的简单方法,您应该使用这些选项,而不是使用模拟对象。例如,如果您可以使用后端中真实的DAO类(而不是模拟DAO实现)和内存中的HSQL数据库(而不是真实数据库)来测试实体类;它将使域层单元测试运行得更快,这是使用模拟对象背后的主要思想。这样,您将测试域对象之间的协作(交互)以及它们之间交换的状态(数据)。对于模拟对象,我们将只测试域对象之间的交互。
一旦开发任务完成,在开发阶段创建的所有单元和集成测试(使用或不使用TDD实践)都将成为自动化测试套件的一部分。应该在本地和更高的开发环境中频繁地维护和执行这些测试,以确定新代码更改是否将任何bug引入了域类。
Eric Evans在他的书中谈到了CI,他说CI工作应该总是在有限的上下文中应用,它应该包括人和代码的同步。CI工具比如CruiseControl和哈德逊可以用来建立一个自动构建和测试环境中运行应用程序构建脚本(使用Ant或Maven这样的构建工具创建)检出代码从SCM存储库(如CVS, Subversion等),编译域类(以及其他类的应用程序),如果没有构建错误,然后自动运行所有的测试(单元测试和集成)。如果有任何构建或测试错误,也可以设置CI工具来通知项目团队(通过电子邮件或RSS提要)。
部署
域模型从不是静态的;它们随着项目生命周期中业务需求的演进和新项目中出现的新需求而变化。另外,在开发和实现域模型时,您需要不断地学习和改进,并希望将新知识应用到现有的模型中。
在打包和部署域类时,隔离是关键。由于域层的一端依赖于DAO层,另一端依赖于服务Facade层(参见图2中的应用程序体系结构关系图),因此将域类打包并部署为一个或多个模块以优雅地管理这些依赖关系非常有意义。
虽然DI、AOP和工厂等设计模式在设计时最小化了对象之间的耦合并使应用程序模块化,但OSGi(以前称为开放服务网关计划)在运行时解决了模块化问题。OSGi正在成为打包和分发企业应用程序的标准机制。它很好地处理了模块之间的依赖关系。我们还可以使用OSGi进行域模型版本控制。
我们可以将DAO类打包在一个OSGi包中(DAO包),将服务facade类打包在另一个包中(服务包),因此当修改DAO或服务实现或部署应用程序的不同版本时,由于OSGi,不需要重新启动应用程序。如果为了向后兼容而必须支持某些域对象的现有版本和新版本,我们还可以部署同一个域类的两个不同版本。
为了利用OSGi的功能,应用程序对象在被使用之前必须在OSGi平台上注册(也就是说,在客户端对它们进行查找之前)。这意味着我们必须使用OSGi api来进行注册,但是我们还必须在服务启动和停止使用OSGi容器时处理故障场景。Spring Dynamic Modules框架通过允许在应用程序中导出和导入任何类型的对象而不需要修改任何代码,在这方面提供了帮助。
Spring DM还提供了在容器外运行OSGi集成测试的测试类。例如,AbstractOsgiTests可用于直接从IDE运行集成测试。设置由测试基础结构处理,因此我们不必编写清单。MF文件进行测试,或做任何打包或部署。该框架支持当前可用的大多数OSGi实现(Equinox、Knopflerfish和Apache Felix)。
贷款处理应用程序使用OSGi、Spring DM和Equinox容器来管理模块级依赖项以及域和其他模块的部署。LoanAppDeploymentTests展示了Spring DM测试模块的使用。
示例应用程序设计
贷款处理样本申请中使用的域类如下:
实体:
- Loan
- Borrower
- UnderwritingDecision
- FundingRequest
值对象:
- ProductRate
- State
服务:
- FundingService
存储库:
- LoanRepository
- BorrowerRepository
- FundingRepository
图3显示了示例应用程序的域模型图。
图3。分层应用程序域模型(单击屏幕快照打开全尺寸视图)。
本文中讨论的大多数DDD设计概念和技术都应用于示例应用程序。使用了诸如DI、AOP、注释、域级安全性和持久性等概念。此外,我还使用了几个开源框架来帮助完成DDD开发和实现任务。这些框架如下:
- Spring
- Dozer
- Spring Security
- JAXB (Spring-WS for marshalling and unmarshalling the data)
- Spring Testing (for unit and integration testing)
- DBUnit
- Spring Dynamic Modules
样例应用程序中的域类使用Equinox和Spring DM框架部署为一个OSGi模块。下表显示了示例应用程序的模块打包细节。
表5所示。打包和部署细节
结论
DDD是一个强大的概念,它将改变建模人员、架构师、开发人员和测试人员在团队接受了DDD培训并开始应用“领域第一,基础设施第二”的理念之后看待软件的方式。不同利益相关者(从IT和业务单位)与不同背景和领域的专业知识参与域建模、设计和实现工作,引用Eric Evans,“重要的是不要模糊的哲学之间的线路设计(DDD)和技术工具框,帮助我们完成它(OOP, DI和AOP)”。
推进前沿
本节介绍一些影响DDD设计和开发的新方法。其中一些概念仍在发展中,看看它们将如何影响DDD将是很有趣的。
体系结构规则和契约实施设计在域模型标准和实现最佳实践的治理和策略实施中扮演重要角色。Ramnivas谈到了使用方面来执行只通过工厂创建存储库对象的规则;这是一个容易违反设计规则在领域层。
领域特定语言(DSL)和业务自然语言(BNL)近年来受到越来越多的关注。可以使用这些语言表示域类中的业务逻辑。BNL的强大之处在于,它们可以用来捕获业务规范、记录业务规则,以及作为可执行代码。它们还可以用来创建测试用例,以验证系统是否按预期工作。
行为驱动开发(BDD)是最近讨论的另一个有趣的概念。BDD通过提供跨越业务和技术之间的鸿沟的公共词汇表(普遍存在的语言),帮助将开发重点放在交付优先级高的、可验证的业务价值上。通过使用关注于系统的行为方面而不是测试方面的术语,BDD试图帮助开发人员将注意力集中在TDD最成功的地方的真正价值上。如果正确地实践,BDD可以成为DDD的一个很好的补充,在DDD中,领域对象的开发受到BDD概念的积极影响;毕竟,所有的域对象都应该封装状态和行为。
事件驱动架构(EDA)是另一个可以在领域驱动设计中发挥作用的领域。例如,用于通知域对象实例中的任何状态更改的事件模型将有助于处理需要在域对象的状态更改时触发的事件后处理任务。EDA有助于封装基于事件的逻辑,从而避免嵌入到核心域逻辑中。Martin Fowler记录了关于域事件设计模式的内容。
资源
- 领域驱动设计,解决软件核心的复杂性,Eric Evans, Addison Wesley
- 应用领域驱动的设计和模式,Jimmy Nilsson, Addison Wesley
- 《重构到模式》,Joshua Kerievsky, Addison Wesley著
- DDD可以在没有DI和AOP的情况下充分实现吗?
原文:https://www.infoq.com/articles/ddd-in-practice/
本文:https://pub.intelligentx.net/node/823
讨论:请加入知识星球【首席架构师圈】或者飞聊小组【首席架构师智库】