小结:
1、
前言 -
要想深入掌握和了解 DDD 领域驱动设计的核心,那无论如何也绕不开两大较为抽象的概念——“贫血模型”、“充血模型”:
- 贫血模型即事务脚本模式。
- 充血模型即领域模型模式。
- 贫血模型 -
贫血模型最早广泛应用源于EJB2,最强盛时期则是由Spring创造,将:
- “行为”(逻辑、过程);
- “状态”(数据,对应到语言就是对象成员变量)。
分离到不同的对象中:
- 只有状态的对象就是所谓的“贫血对象”(常称为VO——Value Object);
- 只有行为的对象就是,我们常见的N层结构中的Logic/Service/Manager层(对应到EJB2中的Stateless Session Bean)。
——曾经Spring的作者Rod Johnson也承认,Spring不过是在沿袭EJB2时代的“事务脚本”,也就是面向过程编程。
贫血领域模型是一个存在已久的反模式,目前仍有许多拥趸者。
Martin Fowler 曾经和 Eric Evans 聊天谈到它时,都觉得这个模型似乎越来越流行了。作为领域模型的推广者,他们觉得这不是一件好事。
贫血领域模型的基本特征是:它第一眼看起来还真像这么回事儿。项目中有许多对象,它们的命名都是根据领域来的。对象之间有着丰富的连接方式,和真正的领域模型非常相似。但当你检视这些对象的行为时,会发现它们基本上没有任何行为,仅仅是一堆getter/setter。
其实,这些对象在设计之初就被定义为只能包含数据,不能加入领域逻辑;逻辑要全部写入一组叫Service的对象中;而Service则构建在领域模型之上,需要使用这些模型来传递数据。
这种反模式的恐怖之处在于:它完全和面向对象设计背道而驰。面向对象设计主张将数据和行为绑定在一起,而贫血领域模型则更像是一种面向过程设计,Martin Fowler和Eric在Smalltalk时就极力反对这种做法。更糟糕的是,很多人认为这些贫血领域对象是真正的对象,从而彻底误解了面向对象设计的涵义。
如今,面向对象的概念已经传播得很广泛了,而要反对这种贫血领域模型的做法,还需要更多论据。贫血领域模型的根本问题是,它引入了领域模型设计的所有成本,却没有带来任何好处。最主要的成本是将对象映射到数据库中,从而产生了一个O/R(对象关系)映射层。
只有当你充分使用了面向对象设计来组织复杂的业务逻辑后,这一成本才能够被抵消。如果将所有行为都写入到Service对象,那最终你会得到一组事务处理脚本,从而错过了领域模型带来的好处。正如martin在企业应用架构模式一书中说到的,领域模型并不一定是最好的工具。
将行为放入领域模型,这点和分层设计(领域层、持久化层、展现层等)并不冲突。因为领域模型中放入的是和领域相关的逻辑——验证、计算、业务规则等。如果你要讨论能否将数据源或展现逻辑放入到领域模型中,这就不在本文论述范围之内了。
一些面向对象专家的观点有时会让人产生疑惑,他们认为的确应该有一个面向过程的服务层。但是,这并不意味着领域模型就不应该包含行为。事实上,service层需要和一组富含行为的领域模型结合使用。
Eric Evans的Domain Driven Design一书中提到:
应用层(即Service层)
描述应用程序所要做的工作,并调度丰富的领域模型来完成它。这个层次的任务是描述业务逻辑,或和其它项目的应用层做交互。这层很薄,不包含任何业务规则或知识,仅用于调度和派发任务给下一层的领域模型。这层没有业务状态,但可以为用户或程序提供任务状态。
领域层(或者叫模型层)
表示业务逻辑、业务场景和规则。该层次会控制和使用业务状态,即使这些状态最终会交由持久化层来存储。总之,该层是软件核心。
服务层很薄——所有重要的业务逻辑都写在领域层。他在服务模式中复述了这一观点:如今人们常犯的错误是不愿花时间将业务逻辑放到合适的领域模型中,从而逐渐形成面向过程的程序设计。
我不清楚为什么这种反模式会那么常见。我怀疑是因为大多数人并没有使用过一个设计良好的领域模型,特别是那些以数据为中心的开发人员。此外,有些技术也会推动这种反模式,比如J2EE的Entity Bean,这会让我更倾向于使用POJO领域模型。
总之,如果你将大部分行为都放置在服务层,那么你就会失去领域模型带来的好处。如果你将所有行为都放在服务层,那你就无可救药了。
优点
简单:
- 对于只有少量业务逻辑的应用来说,使用起来非常自然;
- 开发迅速,易于理解;
- 注意:也不能完全排斥这种方式。
缺点
无法良好的应对复杂逻辑:
- 比如收入确认规则发生变化,例如在4月1号之前签订的合同要使用某规则……
- 和欧洲签订的合同使用另外一个规则……
- 充血模型 -
面向对象设计的本质是:“一个对象是拥有状态和行为的”。
比如一个人:
- 他眼睛什么样鼻子什么样这就是状态;
- 人可以去打游戏或是写程序,这就是行为。
为什么要有一个“人Manager”这样的东西存在去帮人“打游戏”呢?举个简单的J2EE案例,设计一个与用户(User)相关功能。
传统的设计一般是:
- 类:User+UserManager;
- 保存用户调用:userManager.save(User user)。
充血的设计则可能会是:
- 类:User;
- 保存用户调用:user.save();
- User有一个行为是:保存它自己。
其实它们没有什么特别适用的方向,个人更倾向于总是使用充血模型,因为OOP总是比面向过程编程要有更丰富的语义、更合理的组织、更强的可维护性—当然也更难掌握。
因此实际工程场景中,是否使用,如何使用还依赖于设计者以及团队充血模型设计的理解和把握,因为现在绝大多数J2EE开发者都受贫血模型影响非常深。另外,实际工程场景中使用充血模型,还会碰到很多很多细节问题,其中最大的难关就是“如何设计充血模型”或者说“如何从复杂的业务中分离出恰到好处且包含语义的逻辑放到VO的行为中”。
如果一个对象包含其他对象,那就将职责继续委托下去,由具体的 POJO 执行业务逻辑,将策略模式更加细粒度,而不是写 ifelse。
领域驱动设计系列(1)通过现实例子显示领域驱动设计的威力_知识库_博客园 https://kb.cnblogs.com/page/522125/
领域驱动设计系列(2)浅析VO、DTO、DO、PO的概念、区别和用处_知识库_博客园 https://kb.cnblogs.com/page/522348/
领域驱动设计系列(3)有选择性的使用领域驱动设计_知识库_博客园 https://kb.cnblogs.com/page/521969/
领域驱动设计系列(1)通过现实例子显示领域驱动设计的威力
曾经参与过系统维护或是在现有系统中进行迭代开发的软件工程师们,你们是否有过这样的痛苦经历:当需要修改一个Bug的时候,面对一个类中成百上千行的代码,没有注释,千奇百怪的方法和变量名字,层层嵌套的方法调用,混乱不堪的结构,不要说准确找到Bug所在的位置,就是要清晰知道一段代码究竟是做了什么也非常困难。最终,改对了一个Bug,却多冒出N个新Bug。同样的情况,当你拿到一份新的需求,需要在现有系统中添加功能的时候,面对一行行完全过程式的代码,需要使用一个功能时,不知道是应该自己编写,还是应该寻找是否已经存在的方法,编写一个非常简单的新、删、改功能,却要费尽九牛二虎之力。最终发现,系统存在着太多的重复逻辑,阅读、测试、修改非常困难。在经历了这些痛苦之后,你们是否会不约而同的发出一个感慨:与其进行系统维护和迭代开发,还不如重新设计开发一个新的系统来得痛快?
面对这一系列让软件陷入无底泥潭的问题,基于面向对象思想的领域驱动设计方法是一个很好的解决方法。从事过系统设计的富有经验的设计师们,对职责单一原则、信息专家、充血/贫血模型、模型驱动设计这些名词或概念应该不会感到陌生。面向对象的设计大师Martin Fowler不止一次的在他的Blog和著作《企业应用架构模式》中倡导过上述概念在设计中的巨大威力,而另外一位领域模型的出色专家Eric Evans的著作《领域驱动设计》也为我们提供了不少宝贵的经验和方法。
笔者从事系统设计多年,将会在本系列文章中把本人对领域驱动设计的理解,结合工作过程中积累的实际项目经验进行浅析,希望与大家交流学习。
在本系列博文的开篇中,我将会拿出一个例子,先用传统的面向过程方式,使用贫血模型进行设计,然后再逐步加入需求变更。让读者发现,随着系统的不断变更,基于贫血模型的设计将会让系统慢慢陷入泥潭,越来越难于维护。然后再用基于面向对象的领域驱动设计重新上述过程,通过对比展示领域驱动设计对于复杂的业务系统的威力。
假设现在有一个银行支付系统项目,其中的一个重要的业务用例是账户转账业务。系统使用迭代的方式进行开发,在1.0版本中,该用例的功能需求非常简单,事件流描述如下:
主事件流:
1)用户登录银行的在线支付系统
2)选择用户在该银行注册的网上银行账户
3)选择需要转账的目标账户,输入转账金额,申请转账
4)银行系统检查转出账户的金额是否足够
5)从转出账户中扣除转出金额(debit),更新转出账户的余额
6)把转出金额加入到转入账户中(credit),更新转入账户的余额
备选事件流:
4a)如果转出账户中的余额不足,转账失败,返回错误信息
面向过程的设计方式(贫血模型)
设计方案如下(忽略展示层部分):
1)设计一个账户交易服务接口AccountingService,设计一个服务方法transfer(),并提供一个具体实现类AccountingServiceImpl,所有账户交易业务的业务逻辑都置于该服务类中。
2)提供一个AccountInfo和一个Account,前者是一个用于与展示层交换账户数据的账户数据传输对象,后者是一个账户实体(相当于一个EntityBean),这两个对象都是普通的JavaBean,具有相关属性和简单的get/set方法。
下面是AccountingServiceImpl.transfer()方法的实现逻辑(伪代码):
public class AccountingServiceImpl implements AccountingService { public void transfer(Long srcAccountId, Long destAccountId, BigDecimal amount) throws AccountingServiceException { Account srcAccount = accountRepository.getAccount(srcAccountId); Account destAccount = accountRepository.getAccount(destAccountId); if(srcAccount.getBalance().compareTo(amount)<0){ throw new AccountingServiceException(AccountingService.BALANCE_IS_NOT_ENOUGH); } srcAccount.setBalance(srcAccount.getBalance().sbustract(amount)); destAccount.setBalance(destAccount.getBalance().add(amount)); } } public class Account implements DomainObject { private Long id; private Bigdecimal balance; /** * getter/setter */ }
可以看到,由于1.0版本的功能需求非常简单,按面向过程的设计方式,把所有业务代码置于AccountingServiceImpl中完全没有问题。
这时候,新需求来了,在1.0.1版本中,需要为账户转账业务增加如下功能,在转账时,首先需要判断账户是否可用,然后,账户的余额还要分成两部分:冻结部分和活跃部分,处于冻结部分的金额不能用于任何交易业务,我们来看看变更后的代码:
public class AccountingServiceImpl implements AccountingService { public void transfer(Long srcAccountId,Long destAccountId,BigDecimal amount) throws AccountingServiceException { Account srcAccount = accountRepository.getAccount(srcAccountId); Account destAccount = accountRepository.getAccount(destAccountId); if(!srcAccount.isActive() || !destAccount.isActive()) throw new AccountingServiceException(AccountingService.ACCOUNT_IS_NOT_AVAILABLE); BigDecimal availableAmount = srcAccount.getBalance().substract(srcAccount.getFrozenAmount()); if(availableAmount.compareTo(amount)<0) throw new AccountingServiceException(AccountingService.BALANCE_IS_NOT_ENOUGH); srcAccount.setBalance(srcAccount.getBalance().sbustract(amount)); destAccount.setBalance(destAccount.getBalance().add(amount)); } } public class Account implements DomainObject { private Long id; private BigDecimal balance; private BigDecimal frozenAmount; /** * getter/setter */ }
可以看到,情况变得稍微复杂了,这时候,1.0.2的需求又来了,需要在每次交易成功后,创建一个交易明细账,于是,我们又必须在transfer()方面里面增加创建并持久化交易明细账的业务逻辑:
AccountTransactionDetails details= new AccountTransactionDetails(…); accountRepository.save(details);
业务需求不断复杂化:账户每笔转账的最大额度需要由其信用指数确定、需要根据银行的手续费策略计算并扣除一定的手续费用……,随着业务的复杂化,transfer()方法的逻辑变得越来越复杂,逐渐形成了上文所述的成百上千行代码。有经验的程序员可能会做出类此“方法抽取”的重构,把转账业务按逻辑划分成若干块:判断余额是否足够、判断账户的信用指数以确定每笔最大转账金额、根据银行的手续费策略计算手续费、记录交易明细账……,从而使代码更加结构化。这是一个好的开始,但还是显然不足。
假设某一天,系统需求增加一个新的模块,为系统增加一个网上商城,让银行用户可以进行在线购物,而在线购物也存在着很多与账户贷记借记业务相同或相似的业务逻辑:判断余额是否足够、对账户进行借贷操作(credit/debit)以改变余额、收取手续费用、产生交易明细账……
面对这种情况,有两种解决办法:
1) 把AccountingServiceImpl中的相同逻辑拷贝到OnlineShoppingServiceImplementation中
2) 让OnlineShoppingServiceImpl调用AccountingServiceImpl的相同服务
显然,第二种方法比第一种方法更好,结构更清晰,维护更容易。但问题在于,这样就会形成网上商城服务模块与账户收支服务模块的不必要的依赖关系,系统的耦合度高了,如果系统为了更灵活的伸缩性,让每个大业务模块独立进行部署,还需要因为两者的依赖关系建立分布式调用,这无疑增加了设计、开发和运维的成本。
有经验的设计人员可能会发现第三种解决办法:把相同的业务逻辑抽取成一个新的服务,作为公共服务同时供上述两个业务模块使用。这就是笔者将会马上讨论的方案——使用领域驱动设计。
面向对象的领域驱动设计方式(充血模型)
为了节省篇幅,这里就直接以最复杂的业务需求来进行设计。
领域驱动设计的一个重要的概念是领域模型,首先,我们根据业务领域抽象出以下核心业务对象模型:
Account:账户,是整个系统的最核心的业务对象,它包括以下属性:对象标识、账户号、是否有效标识、余额、冻结金额、账户交易明细集合、账户信用等级。
AccountTransactionDetails:账户交易明细,它从属于账户,每个账户有多个交易明细,它包括以下属性:对象标识、所属账户、交易类型、交易发生金额、交易发生时间。
AccountCreditDegree:账户信用等级,它用于限制账户的每笔交易发生金额,包含以下属性:对象标识、对应账户、信用指数。
BankTransactionFeeCalculator:银行交易手续费用计算器,它包含一个常量:每笔交易的手续费上限。
我们知道,领域对象除了具有自身的属性和状态之外,它的一个很重要的标志是,它具有属于自己职责范围之内的行为,这些行为封装了其领域内的领域业务逻辑。于是,我们进行进一步的建模,根据业务需求为领域对象设计业务方法:
根据职责单一的原则,我们把功能需求中描述的功能合理的分配到不同的领域对象中:
Account:
- credit:向银行账户存入金额,贷记
- debit:从银行账户划出金额,借记
- transferTo:把固定金额转入指定账户
- createTransactionDetails:创建交易明细账
- updateCreditIndex:更新账户的信用指数
(我们可以看到,后两个业务方法被声明为protected,具体原因见后述)
AccountCreditDegree:
- getMaxTransactionAmount:获取所属账户的每笔交易最大金额
BankTransactionFeeCalculator:
- calculateTransactionFee:根据交易信息计算该笔交易的手续费
经过这样的设计,前例中所有放置在服务对象的业务逻辑被分别划入不同的负责相关职责的领域对象当中,下面的时序图描述了AccountingServiceImpl的转账业务的实现逻辑(为了简化逻辑,我们忽略掉事物、持久化等逻辑):
再看看AccountingServiceImpl.transfer()的实现逻辑:
public class AccountingServiceImpl implements AccountingService { public void transfer(Long srcAccountId,Long destAccountId,BigDecimal amount) throws AccountDomainException { Account srcAccount = accountRepository.getAccount(srcAccountId); Account destAccount = accountRepository.getAccount(destAccountId); srcAccount.transferTo(destAccount,amount); } }
我们可以看到,上例那些复杂的业务逻辑:判断余额是否足够、判断账户是否可用、改变账户余额、计算手续费、判断交易额度、产生交易明细账……,都不再存在于AccountingServiceImplementation的transfer方法中,它们被委派给负责这些业务的领域对象的业务方法中去,现在应该猜到为什么Account中有两个方法被声明为protected了吧,因为他们是在debit和credit方法被调用时,由这两个方法调用的,对于AccountingServiceImpl来说,由于产生交易明细(createTransactionDetails)和更新账户信用指数(updateCreditIndex)都不属于其职责范围,它不需要也无权使用这些逻辑。
我们可以看到,使用领域驱动设计至少会带来下述优点:
- 业务逻辑被合理的分散到不同的领域对象中,代码结构更加清晰,可读性,可维护性更高。
- 对象职责更加单一,内聚度更高。
- 复杂的业务模型可以通过领域建模(UML是一种主要方式)清晰的表达,开发人员甚至可以在不读源码的情况下就能了解业务和系统结构,这有利于对现存的系统进行维护和迭代开发。
再看看如果这时需要加入网上商城的一个新的模块,开发人员需要怎么去做,还记得上面提过的第三种方案吗?就是把账户贷记和借记的相关业务抽取到成一个公共服务,同时供银行在线支付系统和网上商城系统服务,其实这个公共的服务,本质上就是这些具有领域逻辑的领域对象:Account、AccountCreditDegree……,由此我们又可以发现领域驱动设计的一大优点:
- 系统高度模块化,代码重用度高,不会出现太多的重复逻辑。
笔者经验尚浅,而且文笔拙劣,希望通过这样的一个场景的分析比较,能让读者初步认识到基于面向对象的领域驱动设计的威力,并在实际项目中尝试应用。本篇是领取驱动设计系列博文的第一篇,在系列文章的第二篇博文中,笔者将会浅析VO、DTO、DO、PO的概念、用处和区别,敬请各位对本系列博文感兴趣的读者关注并给予指导修正。
领域驱动设计系列(2)浅析VO、DTO、DO、PO的概念、区别和用处
上一篇文章作为一个引子,说明了领域驱动设计的优势,从本篇文章开始,笔者将会结合自己的实际经验,谈及领域驱动设计的应用。本篇文章主要讨论一下我们经常会用到的一些对象:VO、DTO、DO和PO。
由于不同的项目和开发人员有不同的命名习惯,这里我首先对上述的概念进行一个简单描述,名字只是个标识,我们重点关注其概念:
概念:
VO(View Object):视图对象,用于展示层,它的作用是把某个指定页面(或组件)的所有数据封装起来。
DTO(Data Transfer Object):数据传输对象,这个概念来源于J2EE的设计模式,原来的目的是为了EJB的分布式应用提供粗粒度的数据实体,以减少分布式调用的次数,从而提高分布式调用的性能和降低网络负载,但在这里,我泛指用于展示层与服务层之间的数据传输对象。
DO(Domain Object):领域对象,就是从现实世界中抽象出来的有形或无形的业务实体。
PO(Persistent Object):持久化对象,它跟持久层(通常是关系型数据库)的数据结构形成一一对应的映射关系,如果持久层是关系型数据库,那么,数据表中的每个字段(或若干个)就对应PO的一个(或若干个)属性。
模型:
下面以一个时序图建立简单模型来描述上述对象在三层架构应用中的位置:
- 用户发出请求(可能是填写表单),表单的数据在展示层被匹配为VO。
- 展示层把VO转换为服务层对应方法所要求的DTO,传送给服务层。
- 服务层首先根据DTO的数据构造(或重建)一个DO,调用DO的业务方法完成具体业务。
- 服务层把DO转换为持久层对应的PO(可以使用ORM工具,也可以不用),调用持久层的持久化方法,把PO传递给它,完成持久化操作。
- 对于一个逆向操作,如读取数据,也是用类似的方式转换和传递,略。
VO与DTO的区别
大家可能会有个疑问(在笔者参与的项目中,很多程序员也有相同的疑惑):既然DTO是展示层与服务层之间传递数据的对象,为什么还需要一个VO呢?对!对于绝大部分的应用场景来说,DTO和VO的属性值基本是一致的,而且他们通常都是POJO,因此没必要多此一举。但不要忘记这是实现层面的思维,对于设计层面来说,概念上还是应该存在VO和DTO,因为两者有着本质的区别,DTO代表服务层需要接收的数据和返回的数据,而VO代表展示层需要显示的数据。
用一个例子来说明可能会比较容易理解:例如服务层有一个getUser的方法返回一个系统用户,其中有一个属性是gender(性别),对于服务层来说,它只从语义上定义:1-男性,2-女性,0-未指定,而对于展示层来说,它可能需要用“帅哥”代表男性,用“美女”代表女性,用“秘密”代表未指定。说到这里,可能你还会反驳,在服务层直接就返回“帅哥美女”不就行了吗?对于大部分应用来说,这不是问题,但设想一下,如果需求允许客户可以定制风格,而不同风格对于“性别”的表现方式不一样,又或者这个服务同时供多个客户端使用(不同门户),而不同的客户端对于表现层的要求有所不同,那么,问题就来了。再者,回到设计层面上分析,从职责单一原则来看,服务层只负责业务,与具体的表现形式无关,因此,它返回的DTO,不应该出现与表现形式的耦合。
理论归理论,这到底还是分析设计层面的思维,是否在实现层面必须这样做呢?一刀切的做法往往会得不偿失,下面我马上会分析应用中如何做出正确的选择。
VO与DTO的应用
上面只是用了一个简单的例子来说明VO与DTO在概念上的区别,本节将会告诉你如何在应用中做出正确的选择。
在以下才场景中,我们可以考虑把VO与DTO二合为一(注意:是实现层面):
- 当需求非常清晰稳定,而且客户端很明确只有一个的时候,没有必要把VO和DTO区分开来,这时候VO可以退隐,用一个DTO即可,为什么是VO退隐而不是DTO?回到设计层面,服务层的职责依然不应该与展示层耦合,所以,对于前面的例子,你很容易理解,DTO对于“性别”来说,依然不能用“帅哥美女”,这个转换应该依赖于页面的脚本(如JavaScript)或其他机制(JSTL、EL、CSS)。
- 即使客户端可以进行定制,或者存在多个不同的客户端,如果客户端能够用某种技术(脚本或其他机制)实现转换,同样可以让VO退隐。
以下场景需要优先考虑VO、DTO并存:
- 上述场景的反面场景
- 因为某种技术原因,比如某个框架(如Flex)提供自动把POJO转换为UI中某些Field时,可以考虑在实现层面定义出VO,这个权衡完全取决于使用框架的自动转换能力带来的开发和维护效率提升与设计多一个VO所多做的事情带来的开发和维护效率的下降之间的比对。
- 如果页面出现一个“大视图”,而组成这个大视图的所有数据需要调用多个服务,返回多个DTO来组装(当然,这同样可以通过服务层提供一次性返回一个大视图的DTO来取代,但在服务层提供一个这样的方法是否合适,需要在设计层面进行权衡)。
DTO与DO的区别
首先是概念上的区别,DTO是展示层和服务层之间的数据传输对象(可以认为是两者之间的协议),而DO是对现实世界各种业务角色的抽象,这就引出了两者在数据上的区别,例如UserInfo和User(对于DTO和DO的命名规则,请参见笔者前面的一篇博文),对于一个getUser方法来说,本质上它永远不应该返回用户的密码,因此UserInfo至少比User少一个password的数据。而在领域驱动设计中,正如第一篇系列文章所说,DO不是简单的POJO,它具有领域业务逻辑。
DTO与DO的应用
从上一节的例子中,细心的读者可能会发现问题:既然getUser方法返回的UserInfo不应该包含password,那么就不应该存在password这个属性定义,但如果同时有一个createUser的方法,传入的UserInfo需要包含用户的password,怎么办?在设计层面,展示层向服务层传递的DTO与服务层返回给展示层的DTO在概念上是不同的,但在实现层面,我们通常很少会这样做(定义两个UserInfo,甚至更多),因为这样做并不见得很明智,我们完全可以设计一个完全兼容的DTO,在服务层接收数据的时候,不该由展示层设置的属性(如订单的总价应该由其单价、数量、折扣等决定),无论展示层是否设置,服务层都一概忽略,而在服务层返回数据时,不该返回的数据(如用户密码),就不设置对应的属性。
对于DO来说,还有一点需要说明:为什么不在服务层中直接返回DO呢?这样可以省去DTO的编码和转换工作,原因如下:
- 两者在本质上的区别可能导致彼此并不一一对应,一个DTO可能对应多个DO,反之亦然,甚至两者存在多对多的关系。
- DO具有一些不应该让展示层知道的数据
- DO具有业务方法,如果直接把DO传递给展示层,展示层的代码就可以绕过服务层直接调用它不应该访问的操作,对于基于AOP拦截服务层来进行访问控制的机制来说,这问题尤为突出,而在展示层调用DO的业务方法也会因为事务的问题,让事务难以控制。
- 对于某些ORM框架(如Hibernate)来说,通常会使用“延迟加载”技术,如果直接把DO暴露给展示层,对于大部分情况,展示层不在事务范围之内(Open session in view在大部分情况下不是一种值得推崇的设计),如果其尝试在Session关闭的情况下获取一个未加载的关联对象,会出现运行时异常(对于Hibernate来说,就是LazyInitiliaztionException)。
- 从设计层面来说,展示层依赖于服务层,服务层依赖于领域层,如果把DO暴露出去,就会导致展示层直接依赖于领域层,这虽然依然是单向依赖,但这种跨层依赖会导致不必要的耦合。
对于DTO来说,也有一点必须进行说明,就是DTO应该是一个“扁平的二维对象”,举个例子来说明:如果User会关联若干个其他实体(例如Address、Account、Region等),那么getUser()返回的UserInfo,是否就需要把其关联的对象的DTO都一并返回呢?如果这样的话,必然导致数据传输量的大增,对于分布式应用来说,由于涉及数据在网络上的传输、序列化和反序列化,这种设计更不可接受。如果getUser除了要返回User的基本信息外,还需要返回一个AccountId、AccountName、RegionId、RegionName,那么,请把这些属性定义到UserInfo中,把一个“立体”的对象树“压扁”成一个“扁平的二维对象”。笔者目前参与的项目是一个分布式系统,该系统不管三七二十一,把一个对象的所有关联对象都转换为相同结构的DTO对象树并返回,导致性能非常的慢。
DO与PO的区别
DO和PO在绝大部分情况下是一一对应的,PO是只含有get/set方法的POJO,但某些场景还是能反映出两者在概念上存在本质的区别:
- DO在某些场景下不需要进行显式的持久化,例如利用策略模式设计的商品折扣策略,会衍生出折扣策略的接口和不同折扣策略实现类,这些折扣策略实现类可以算是DO,但它们只驻留在静态内存,不需要持久化到持久层,因此,这类DO是不存在对应的PO的。
- 同样的道理,某些场景下,PO也没有对应的DO,例如老师Teacher和学生Student存在多对多的关系,在关系数据库中,这种关系需要表现为一个中间表,也就对应有一个TeacherAndStudentPO的PO,但这个PO在业务领域没有任何现实的意义,它完全不能与任何DO对应上。这里要特别声明,并不是所有多对多关系都没有业务含义,这跟具体业务场景有关,例如:两个PO之间的关系会影响具体业务,并且这种关系存在多种类型,那么这种多对多关系也应该表现为一个DO,又如:“角色”与“资源”之间存在多对多关系,而这种关系很明显会表现为一个DO——“权限”。
- 某些情况下,为了某种持久化策略或者性能的考虑,一个PO可能对应多个DO,反之亦然。例如客户Customer有其联系信息Contacts,这里是两个一对一关系的DO,但可能出于性能的考虑(极端情况,权作举例),为了减少数据库的连接查询操作,把Customer和Contacts两个DO数据合并到一张数据表中。反过来,如果一本图书Book,有一个属性是封面cover,但该属性是一副图片的二进制数据,而某些查询操作不希望把cover一并加载,从而减轻磁盘IO开销,同时假设ORM框架不支持属性级别的延迟加载,那么就需要考虑把cover独立到一张数据表中去,这样就形成一个DO对应多个PO的情况。
- PO的某些属性值对于DO没有任何意义,这些属性值可能是为了解决某些持久化策略而存在的数据,例如为了实现“乐观锁”,PO存在一个version的属性,这个version对于DO来说是没有任何业务意义的,它不应该在DO中存在。同理,DO中也可能存在不需要持久化的属性。
DO与PO的应用
由于ORM框架的功能非常强大而大行其道,而且JavaEE也推出了JPA规范,现在的业务应用开发,基本上不需要区分DO与PO,PO完全可以通过JPA,Hibernate Annotations/hbm隐藏在DO之中。虽然如此,但有些问题我们还必须注意:
- 对于DO中不需要持久化的属性,需要通过ORM显式的声明,如:在JPA中,可以利用@Transient声明。
- 对于PO中为了某种持久化策略而存在的属性,例如version,由于DO、PO合并了,必须在DO中声明,但由于这个属性对DO是没有任何业务意义的,需要让该属性对外隐藏起来,最常见的做法是把该属性的get/set方法私有化,甚至不提供get/set方法。但对于Hibernate来说,这需要特别注意,由于Hibernate从数据库读取数据转换为DO时,是利用反射机制先调用DO的空参数构造函数构造DO实例,然后再利用JavaBean的规范反射出set方法来为每个属性设值,如果不显式声明set方法,或把set方法设置为private,都会导致Hibernate无法初始化DO,从而出现运行时异常,可行的做法是把属性的set方法设置为protected。
- 对于一个DO对应多个PO,或者一个PO对应多个DO的场景,以及属性级别的延迟加载,Hibernate都提供了很好的支持,请参考Hibnate的相关资料。
到目前为止,相信大家都已经比较清晰的了解VO、DTO、DO、PO的概念、区别和实际应用了。通过上面的详细分析,我们还可以总结出一个原则:分析设计层面和实现层面完全是两个独立的层面,即使实现层面通过某种技术手段可以把两个完全独立的概念合二为一,在分析设计层面,我们仍然(至少在头脑中)需要把概念上独立的东西清晰的区分开来,这个原则对于做好分析设计非常重要(工具越先进,往往会让我们越麻木)。第一篇系列博文抛砖引玉,大唱领域驱动设计的优势,但其实领域驱动设计在现实环境中还是有种种的限制,需要选择性的使用,正如我在《田七的智慧》博文中提到,我们不能永远的理想化的去选择所谓“最好的设计”,在必要的情况下,我们还是要敢于放弃,因为最合适的设计才是最好的设计。本来,系列中的第二篇博文应该是讨论领取驱动设计的限制和如何选择性的使用,但请原谅我的疏忽,下一篇系列博文会把这个主题补上,敬请关注。
领域驱动设计系列(3)有选择性的使用领域驱动设计
本系列的第一篇博文抛砖引玉,大谈领域驱动设计的优势,这里笔者还是希望以客观的态度,谈谈领域驱动设计的缺点及其不适合使用的场景,以让读者可以有选择性的使用领域驱动设计。
我们知道,没有最好,只有最合适,设计也是一样。因此,所谓设计,就是以你和你的团队的知识、经验和智慧,全面充分的考虑各种内外因素后,在你们的设计方案中作出合理的选择的过程。而这些影响你们选择的因素主要有:
- 技术框架的特征和约束(如果你的项目决定使用C语言进行开发,那么首先在设计方法上,就需要使用面向过程而非面向对象的设计方法)。
- 时间的压力和约束(你永远不可能告诉你的老板,给我10年时间,我和我的团队将为你设计出世界上最优秀的软件)。
- 你的团队的能力、经验、性格、价值观等因素的约束(你不能期望一个长期从事遗留系统维护项目或大部分成员是缺乏经验的高校毕业生的团队能很好的按照你的设计意图去实现你的高度抽象的优秀设计,同时你也别指望一帮家里经济条件不错,本着过来熬时间的家伙会乐意与你一起刻苦钻研、精益求精)。
- 你的系统的特征(如果你想把一个足够简单而且在可以预计的将来都不存在很大规模的需求变更的系统设计得很复杂,很精妙,具有很好的扩展性,但要为此付出巨大的时间、人力成本,这显然是一种不理智的过度设计(Over design))。
- 其他外在因素的约束(你的项目需要参与投标,你必须压缩人力、时间等以让你的项目成本成为巨大的竞争资本)。
当然,上述的考虑因素站在比较高的角度,通常是项目经理、架构师需要考虑的问题,但这当中你应该会得到一些启发。回到我们的主题,我们首先看看,领域驱动设计相对于传统的面向过程式的设计,有什么缺点:
- 复杂化:面向过程思维之所以一直很受欢迎,是因为它很直观,非常符合大部分人的思维习惯。大部分人拿到一个问题,通常都是会很直观的想第一步做什么、第二步做什么,如果怎样,应该怎样,否则怎样……,可以说,任何水平的程序员,都能很好的使用面向过程的方法进行设计和开发。同时,由于我们教育水平的落后和整体IT环境的制约,可以这样说,真正掌握面向对象思维和设计方法的程序员的比例非常低(虽然绝大部分都使用面向对象的语言和工具),而本身面向对象思维要求人有很好的抽象思维能力,因为你需要把一个复杂的系统一层层的抽象为简单的部分,需要把现实世界的事物(有些是可见的,但有些是不可见的)合理的抽象为计算机世界的不同元素。这些都不是一些很容易做的事情,要做得好,就更难。
- 团队的抗拒:如果你的团队(很大可能)大部分人都习惯于用很直观的面向过程的方式进行设计和开发,你需要推动你的团队转换思维来采用面向对象的方式进行领域驱动设计,通常会遭到多数人的抗拒。因为人都是有惰性的,他们习惯安于现状,而改变是痛苦的,他们要付出额外的努力,需要进行学习,但以笔者的经验,相当一部分程序员,特别是有一定工作年限的程序员,他们从事IT工作都只是为了获得一份不错的报酬,因此他们的学习动力非常有限,而且,他们都相当自负,被说服的难度比较大。
- 管理、开发和维护的成本高:复杂度更高,意味着你需要花更多的时间进行设计,同时需要花出额外的时间进行必要的培训,而且需要有更完善的文档(设计文档,API文档,培训文档等)。领域驱动设计的抽象程度比较高,因此必需有良好的文档,否则,随着项目的不断迭代、升级和维护,它很容易因为后来者的误解而慢慢回归面向过程的设计,甚至会变得“四不象”,领域驱动设计的成本优势是随着时间的推移慢慢体现的(见下图),如果出现这种情况,所有前面付出的努力都会付诸东流。
系统的初始阶段,领域驱动设计需要付出更大的成本,但随着时间的推移,领域驱动设计的成本效益优势会逐步显现
- 性能比较低:使用纯面向对象的方式进行领域驱动设计的程序,其系统开销通常要比面向过程设计的程序高,从而性能相对较低(关于具体的例子,后续的博文会提及)。
那么,假设我们在时间、团队能力及各种资源都允许的情况下,是否就可以麻木的全盘使用领域驱动设计呢?正如本博文的标题一样,答案是否定的,我们需要有选择性的使用。让我们来看看一些指导性原则:
- 使用领域驱动设计,并不代表整个系统的方方面面都必须遵从领域驱动设计的原则,需要根据实际情况,让适合的部分使用领域驱动设计,让不适合的部分使用面向过程的设计。
- 对于那些业务规则非常简单,通常只有增、删、改、查的简单操作,而且也不大可能发生大规模需求变更的模块,可以让业务实体成为一个“贫血模型”,例如一些基础数据:系统参数、商品类型、国家、地址信息(注:对于这点,本人持保留态度,因为这些业务虽然非常简单,但既然选择了领取驱动设计,即使把这些业务实体设计为“充血模型”,即把极其简单的业务逻辑也封装在业务实体中,也并不比使用“贫血模型”成本高,而它却带来了统一设计风格的好处)。
- 对于查询操作,特别是复杂的查询操作,出于性能的考虑,可以用结构化查询逻辑一次性完成,并把这些逻辑封装在Repository(即技术上的DAO)中(这方面的具体例子,后面关于“查询通道”和“领域对象仓库”的博文会更具体的阐述)。我们可以看到,对于一些业务视图,以及报表模块,很明显不适合使用面向对象的方式设计,因为这些“视图”和“报表”,本质上就不是业务实体。
- 同样出于性能的考虑,在业务实体的实现逻辑中,某些操作不适合过度偏执的使用面向对象方式。例如,在“订单”的“新增订单明细”(order.addOrderItem(orderItem))中,如果业务逻辑规定一张订单中包含优惠商品的明细数目不能超过20条,使用面向对象的方式,就需要把订单中的所有订单明细全部加载,然后逐个明细判断其对应的商品是否是优惠商品,再统计出优惠商品的数目,这样很明显是低效率和高开销的,这里只需要使用Repository提供的一个统计方法,用一个结构化查询逻辑返回统计结果即可,而这就是非面向对象的方式。
本博文给有志于领域驱动设计的读者泼了一下冷水,提出一些“反模式”(Bitter),是为了让读者冷静一下,在领域驱动设计过程中作出更灵活和更合理的选择。关于这方面的论述,笔者在这里浅尝则止,限于水平、经验和表达能力,不敢胡乱卖弄,建议读者可以参考阅读Martin Fowler的《Patterns of Enterprise Application Architecture》一书的相关观点。