在DDD中采用Clean Arch的经验分享
传统的三层架构
传统的web开发都有典型的三层结构,从上到下依次是controller,service,dao。controller负责http的请求和响应,以前还负责调用jsp等模板渲染引擎来渲染页面返回html,现在基本上都前后端分离了,不再干渲染页面的活,而是返回json来扮演web api了(还有用rpc的,也是一样的承担api的职责)。
service负责业务逻辑,dao则是最下层负责与数据库交互。
问题
这三层看着很美,本来是想要解耦的但实际上确是耦合在一起的,主要是他们用的模型类,也叫实体类往往都是一套,这就是他们的耦合点。这么搞就很坑了,现代spring开发框架下,带来的就是各种annotation的滥用,json的annotation和持久化的annotation都标记在一个类上,将来想拆都不好拆,平时想改都不敢改,实现一个新的需求变化,因为背着各种关系和不同层的限制和约束,基本上就是带着镣铐跳舞。同样由于边界的划分不清,数据模型之间的循环引用和因此还要采用的trick手法来切断都是开发的负担。
抛开模型造成的耦合,三层自己也经常搞错。service层和dao层经常逻辑混在一块。业务逻辑跟sql混在一起,想换个nosql之类的实现都不好换。想做数据库迁移也不好迁移。通常项目刚开始的时候会觉得技术迁移是未来的事,等过两年就是每天都要考虑的事。controller里写业务逻辑也是很常见的,本来该在service的写在了controller,最后大量的重复代码还看不清操作的核心逻辑。同样也是迁移时的困难。
新的架构
在上面的问题章节里,我们聊到的问题基本都可以用边界不清来表述,如果用更精炼的词就是耦合。耦合大家都知道是不好的,然而却还是写出很多耦合的代码,到底是为什么呢?主要还是因为没有相应的概念帮我们区分代码都分为哪些类别,都有哪些更具体的概念来识别职责,这种概念越少,越模糊,越容易写出耦合的代码。
新的架构就要提供一些更细节的概念来帮助我们解耦。所以这里我们介绍一个基于clean arch的改版架构,它的主要思想都是源自于Clean Arch,但是有些地方也使用了六边形架构和洋葱头架构的名词,毕竟很多人都说,这三个架构本质上是一回事。
首先了解一下adapter、usecase和domain这三个大层,如下图(domain指的是最内核写着entities的部分,往外依次是use case,adapter,蓝色的部分就是实际的外部世界了)
image.png在最外面的controller那些属于adapter,它们主要负责表现层的渲染,跟外部程序的交互,比如响应HTTP请求。adapter自己也是分组的,有时候不同的协议接口都会算作不同的adapter,比如HTTP的API和RPC的API,通常用于一个服务以不同的方式对外提供服务;有时候场景不一样也算不同的,比如web api和web page会被我归成两个adapter,通常用于遗留系统里部分页面还是后端渲染的时候;有时候不同的技术也是不同的adapter,比如spring mvc和Jersey,通常用于技术迁移过程中;
有一种建议是adapter也要与具体的框架分离,这个我觉得大部分情况下adapter这层不是很复杂,全部重写可能更合适点,这么做最大的好处就是迁移adapter技术实现时更方便。这么做我觉得会适得其反,毕竟这种做法需要的设计要求太精细了,对于绝大多数团队来说做框架分离这种事把自己埋了的可能性远大于在将来技术迁移时成本降低的可能性。对于绝大多数团队,只要能做一个api一个api的替换,已经是不错了。要想做到这个,使用一般的开源框架,比如spring mvc或jersey并不难,但是要是使用公司内自己做的就不一定了,很可能它不支持运行时两种adapter同时对外提供服务,直接从根本上把你用其他框架对外提供服务的可能性就给掐死了,这样的内部框架使用前请三思。
不同于ddd里的分层架构,clean arch里面,负责做数据库存储的持久层也是一种adapter。 当我们做了这样的设计后,就可以把框架相关的annotation彻底清除出domain层,于是整个domain就真的变得干净了,配合上ddd里的一些概念解耦后,技术迁移也会变得容易。具体的内容我们后面聊。
usecase则是以前的service,通常是我们应用逻辑的编写处。service这个词不是用以表达一个分层的好词,因为我们做的都是商用软件,一切都是service。在ddd中分为了application service和domain service,通常是说领域相关的属于领域服务,应用场景相关的属于application service。这些描述太抽象了,还都叫service实在是容易混淆,所以我们现在习惯把application service这层都叫成了usecase,usecase比application service好的地方在于,service你会不自觉的用业务实体的名字做前缀,比如UserApplicationService(甚至有的人因为包名上已经有application了为由,干脆就叫UserService),而use case你会不自觉的用业务场景来做前缀,比如RegisterUseCase。这样的话我们就会容易关注到application service的本质:应用层业务场景。当然即便如此,当我们加入新的逻辑的时候依然不能很好的区分放在哪一层比较合适,这点我们稍后再深入聊。
接下来就是domain层了,这个概念最早见于ddd中介绍的分层架构,里面定义domain层是用来表达业务概念,业务状态信息和业务规则的。用极限编程派敏捷中的一个实践:代码即文档 来说,这一层的代码所作为文档指的是业务文档,表达的是业务逻辑而非技术细节。这点对于遗留系统尤为重要,我曾经在几个10年以上的遗留系统上工作过,那陈旧的技术和业务逻辑混在一块的味道可是不好受,那个时候是多么希望业务逻辑和技术细节是解耦的。技术的进步是一个永不停息的过程,据说受摩尔定律影响,每18个月就有一代,所以架构师必须在一开始就谋划技术细节与业务逻辑的解耦。那么作为业务文档,由于业务本身的复杂性,势必要面对上下文的切割,所以这里面使用ddd的战术概念来管理业务模型是最好的。
这就是这三层,这三层的要求是,外层依赖内层,内层不可以依赖外层。所谓依赖,在java中指的是import,如果A类import了B类,那就叫A类依赖了B类,外层依赖内层的意思就是外层的类可以import内层的类,反过来不行。这个本来我是觉得很基础的概念,不过由于工作中看到有人认为哪个包在哪个包下面是依赖,我觉得还是有必要澄清一下。
深入聊聊细节
PO、聚合与其他domain层概念
首先说一下实体耦合的问题,当各种标记都标注在实体类上的时候,势必不同上下文是会互相侵入的,也就变成了耦合的状态。虽然有人说可以通过xml的方式避免annotation的侵入,但实际上关键的耦合没有摆脱,你新建实体的时候还要考虑怎么更好的存取,这种耦合其实才是耦合的本质,上下文的耦合使得思考的时候不能专注的在一个上下文中思考,从而使得问题复杂化。类似的例子可以参考marsrover文中没有解耦的direction,虽然没有直接依赖,但是还是要跟command耦合了,跟此处虽然没有annotation但依然跟orm框架耦合是类似情况。
那我们的做法是什么呢,我们抽取出一套专门用于存储的对象我们称之为PO(Persistent Object的缩写),比如UserPO,这个对象只为存储使用,它的构造器负责把所有领域的实体对象转化为对应的PO,比如把domain的User对象转化为UserPO。
这样domain层的模型,像User这种,就不需要关心持久化的问题,不需要为了数据库表的结构扭曲自己本来应该表达的抽象含义。持久层也不需要各种高难度技术来弥合这其中的各种坑,大家都轻松了。
当持久层和领域层解耦了,我们就可以方便的使用ddd里的种种概念,最核心的便是聚合。
所谓聚合就是一组相关领域模型的集合。这一组领域模型的关系是非常紧密,他们聚合在一起统一响应外部操作。通常会有一个类来对外,这个类叫做聚合根。这一个聚合通常要遵守下面的规则:
-
聚合根负责执行业务规则。
-
聚合根有全局标识。这个标示说的是业务标识。
-
边界内的类只有局部标识,在聚合内唯一,这个标识也是业务标识。从业务上讲,一个聚合内的类确实对外理由拥有全局标识的必要,至于数据库id,那是为了存储方便,对于业务上没有意义。
-
聚合边界外的对象只能引用聚合根,不能持有聚合内对象的引用。这点通常比较麻烦,计算过程中用一下的,只要对象本身是immutable的,我觉得还好。但是作为属性确实是绝对不能允许的。
-
边界内的对象可以持有对其他聚合根的引用。
-
删除操作必须全部删除边界内的对象。
-
聚合边界内任一个对象发生改变,整个聚合的所有业务规则都不能违反。
-
只有聚合根能直接从持久化系统查询得到,边界内对象只能从聚合根导航。
具体长什么样,要看我们的样例代码里的样例。这里只说一件事,从第一条可以看出,ddd当中是比较推崇充血对象而不是贫血对象的,我也推荐我们采用充血对象,但是不推荐把数据访问操作也放到充血对象上,那就不是充血而是涨血了,毕竟ddd里还有个repository(这个我们在下面讲)专门干这个,聚合跟什么的就不要越俎代庖了。
前面用到所有原本习惯叫实体的地方,我们都用了类或对象来代替,为什么这样用呢?主要是因为在DDD里,实体的概念比其他设计方法里的实体概念要小一点,他把传统的实体分为了两类:实体和值对象。(当然我们日常交流时候,建议除非进入模型的细节讨论时,实体和值对象应该做一个区分)
那么实体和值对象分别是什么概念呢?
实体是符合下列条件的类:
-
有生命周期
-
有唯一标识
-
通过id判断相等性
-
可变
值对象则符合下列条件的类:
-
无唯一标识
-
通过属性判断是否相等
-
即时创建,用完就扔
-
不可变
我们这里说的可变是业务意义上的可变,比如一个用户,你可以修改他的email、username、password,你可以改变他的一切值,但只要id是他,那就还是同一个用户,那这个用户类就是一个实体。再比如一个订单的地址,尽管我们常上网购物,都知道有常用地址,但是用户的地址修改了,订单的地址是不可以跟着变的,那么订单上的地址就是一个值对象。而用户的常用地址则是一个实体,因为它可以改。
当然如果一个实体,你设计上就是让它不可变,例如他有历史,每次修改都是生成了一份新的历史,对于旧的实体来说,貌似没有变化,但是你仔细想想,你总要有一个根实体来连接所有的历史,并指明哪个是最新的,这个连接关系的改变,它也是改变。所以依然属于可变范畴。
围绕着这些核心概念,还有几个概念,分别是repository,factory和domain service。factory就是用来构造各种实体和值对象的类,一旦有了聚合之后,可能构造过程略复杂,就会引入factory,不复杂的通常就用不着。repository负责对聚合进行存储通常都是接口,而实现在持久化层,这便是依赖倒置,倒置之后,实际运行时,实现是由接近main的层次的类在构造依赖repository的类(比如某些service)的时候注入进去的。domain service通常就是调用repository的那个类,负责领域业务逻辑,这块有个小难点,就是怎么识别领域业务逻辑和应用业务逻辑呢?我们后面再聊。
聚合的划分,上下文有些时候容易识别,有些时候难以识别。就说博客的修改和发布与用户评论吧。最简单的做法可能是博客下面有评论,评论里有一个属性是用户。这个时候就有几个业务场景要考虑了。一个用户能看自己发过哪些评论,这个时候和给一个博客发表评论,到底是不是一套模型?另外编辑一个博客和展示一个博客及其评论又是不是一套模型?比起问题的答案,意识到这些问题可能更重要。而问题答案,在不同的时期,答案是不太一样的。固然存在客观的上下文,但是在发展的初期,我们通常会刻意的采用一些失真的方案来便于理解。kent beck提出了一个3X模型,按照一个软件的发展分成了3个阶段:Explorer、Expend、Extract,不同的阶段要采取不同的架构设计和开发方法。
一旦一开始比如在explorer阶段就采用不同上下文不同模型的做法,很容易让初学者疑惑,再加上大多数团队的沟通和能力建设都跟不上,反而写出一大堆奇怪的代码。但是如果团队能力比较强,或者我们的沟通和能力建设跟得上,从一开始就采用不同上下文不同模型的做法,其实对于我们平滑过渡到expend阶段是非常有帮助的,尤其是在稍微上点规模的公司,这种过度的速度可能非常快。
一旦两层解耦,那编写聚合的时候就可以不受数据库的限制。从domain层角度讲,一旦做到了这一步,持久层用关系型数据库还是nosql对domain层其实无感知了。可以很容易的做到聚合内数据必须通过聚合根操作,而不用额外操心乱七八糟其他上下文的约束。首先一个就是可以干掉setter,既然PO有专门的模型了,你就不需要为了update之类操作的建立起setter,一切修改权限都只暴露给聚合根,聚合根就可以接管一切业务操作。然后所有的getter都可以是返回一个immutable的对象,因为无法被修改,自然就保持了很好的封装性。最后是引入了上下文和聚合的概念后,可以很容易的消除掉循环依赖(所谓循环依赖就是A类依赖B类,B类又依赖A类,不管是直接的循环依赖,还是间接的循环依赖,比如A依赖B,B依赖C,C依赖A,都属于循环依赖)。
而从技术的角度,技术迁移的时候也可以一个聚合一个聚合的迁移。这里面后者尤为重要,我的经验就是,如果不能把一个大的变化分解为小步,一点点变,这个变化通常不会发生,或者积累到很晚的时候以很大的成本来换取这个变化。不仅仅是变化需要,优化也需要,既然聚合已经自然隔离了业务的边界,那么优化时确定影响的边界就变得容易了,同时优化本身也是一种变化,可以看作从一种技术方案迁移到另一种方案,这种迁移过程中边界的清晰也对于我们验证迁移前后是否逻辑等价有很大帮助。哪怕是日常开发,聚合的存在使得技术的实现也简化了,由于聚合之间通常是业务隔离的,不用存储时还要操心业务上的影响。只要做好技术角度的事务性自然可以保障业务上的事务性。
聊聊service
ddd中把服务分了三层,应用层的,领域层的和基础设施层的。
从这点来看,由于把基础设施层的服务通常是比较清晰的,而且访问数据库的通常不以服务为名,访问其他服务的桩倒是经常以服务为名,不过依然是容易区分的,所以这点上讲问题不大。最麻烦的就是前面讲过的,领域服务和application service的差别不好区分,当然首先是都叫service不是个好事,我们在整洁架构里都叫usecase了,这个问题算是解了。然而那些逻辑应该在usecase里,哪些应该在domain service这个缺乏一个足够清晰的指导原则。而且这里面还有一个聚合根,不是说聚合根也应该有一些业务逻辑嘛,这就更乱了。
目前来讲,我们从实践角度得出了一个被我们称之为两个凡是的指导原则:
-
凡是能移到聚合根的代码都不应该出现在domain service上
-
凡是能够移动到domain service的代码都不应该use case中出现
这几个凡是要成立是建立在一些约束之上的,这些约束是:
-
绝不能把跨上下文的逻辑放到domain service里
-
绝不能把表现层逻辑放到use case里
-
绝不能把repository放到聚合里
这里面约束都是比较极端的,你看我们用了绝不这样的词汇,这是因为我们在实践中发现,模棱两可的说法、模糊的边界只会造成工作中的混乱,绝大多数人不具备判断的能力,所以我们采用了极端的描述方式,这样当普通开发觉得无法实现的时候,就会自然的上升到技术负责人或架构师来决断,绝 大部分情况下,只是想错了,所以觉得无法实现,这个就是能力建设的契机。不需要专门的设立什么定期分享,只要靠这些东西就可以很自然的做到能力建设。
前面提到过访问外部服务的桩服务,我在实践过程中也发现这个接口应该放在哪里是个问题。经过我们内部的一些交流,我觉得放在use case层是比较合理的。首先讲,桩服务也是一种adapter,是被use case调用的,那么adapter不应该依赖use case,所以接口应该放在use case。不过我们中也有人认为,随着业务的复杂化,你有可能有些访问来的数据是要被映射为本领域上下文里的一组值对象,然后再参与计算的,这个时候放在use case就不合适了,有一部分接口就要下移到domain层。这点我个人觉得也可以按照我之前的策略处理,先放在use case层,然后用两个凡是的原则考问一下自己,是否要下移到domain去,然后再走use case层和domain层服务切分的那个逻辑就好了,所以并不矛盾。
接口处
接下来我们聊聊隔层之间的接口处,通常接口处都是问题的高发区。
首先说说adapter层和外面这层接口,我看过一些人,在adapter层使用map,只是为了不多写一个类。这种行为是有些糟糕的,我们今天所知道的整洁架构就是最完美的形态吗?不一定,我们过去采用的三层架构本身已经被证明有些问题了,未来也会有新的架构,所以内部结构的调整(也就是所谓的重构)是一个永恒的课题,不得不面对。当你用Map来进行adapter层的数据组合的时候,当时写的是方便了,但是事后重构可费了劲了。因为Map可以随便传来传去,你不知道中间哪里就改了,识别最终的API的长相比较困难,文档本身经常又与实际不符,你实在是不敢做完之后就相信没有问题。
而我们推荐做法是在Adapter层为输入和输出专门建模。输入搞一个类,输出搞一个类,从这两个类里就可以看出你API的数据的结构,那么就可以直接从模型类中搞明白API中数据的结构。这件事很多人嫌麻烦,然而既然代码即文档,文档你都不嫌麻烦,为什么代码嫌麻烦呢?就像前面所说,写的时候觉得技术迁移是未来的事一样,觉得架构调整也是未来的事情,几年之后就是天天都要干的事情,在早期稍微加入一些限制,就能为未来赢得一点转机,望三思。
然后我们看看Domain层与外围,通常来讲use case层直接使用domain层的模型对象,这样use case层会比较轻量。然后Adapter层的模型对象,自然可以依赖Domain 层的模型对象,然后通过构造函数来进行初始化。这样可能会带来一些坏味道,比如长参数列表。但是我们觉得这个坏味道是可以接受的,因为它提供了一个很好的检验机制,当你有一个参数没有传进去的时候,会报错,避免了产生构造时遗漏输入问题。但是有时候数据结构复杂了这种做法也会造成很多人会为了一点方便写出破坏封装的情况,我们前面说了当复杂的时候会引入Factory,但有时候引入Factory问题的本质没有解决,这种情况下为了隔离 可能还会引入专门的TO(transfer object ),放在domain层,外层和内层都依赖内层TO,这样就会更好的保护内层的封装性,同时还有些额外的收获,比如说隔离之后,内层结构就敢更灵活的调整,不会不敢随便调整结构只是因为外层依赖着自己。相应的缺点则是成本升高了,这里面的平衡就要大家自己把握了。