简介
浅谈我对DDD领域驱动设计的理解 很多项目(尤其是互联网项目,为了赶工)都是一开始模型没想清楚,一上来就开始建表写代码,代码写的非常冗余,完全是过程式的思考方式,最后导致系统非常难以维护。我们今天吐槽一下controller-service-dao的“坑”,挖一挖它的墙角。如果你觉得controller-service-dao 很不错,那说明你应对的场景还不够复杂,暂时还不适合谈论ddd。
很多项目的实际情况
- 用户或产品经理需求零零散散,不断变更
- 工程师在各处代码中寻找可以实现这些需求变更的代码,修修补补
- 软件只有需求分析,并没有真正的设计,系统没有一个统一的领域模型维持其内在逻辑一致性
- 功能特性并不是按照领域模型内在的逻辑设计,而是按照各色人等自己的主观的对系统的部分理解设计。
CRUD/controller-service-dao的败笔
在日常开发中,开发主要涉及到与前端交互、业务逻辑处理和数据存储,这样就可以分成三层:「视图层」、「业务逻辑层」和「数据访问层」。MVC 架构迫使我们是面向视图来开发的,我们知道视图的变化最是不可控的,越是偏向于用户的东西,越是容易受到用户主观的影响。我们知道复杂系统必然存在的纷繁复杂的依赖,依赖不可能存在于视图部分,肯定会表现为接口的依赖。对于复杂系统,我们要强迫我们转换思维,强迫我们面向接口进行设计。结合着业务系统的复杂性,如果想要系统未来具有长期价值,不得不把大的系统进行拆分,用统一的业务语言进行描述,把不可识别的问题,拆分成可识别的问题域进行解决,这也就是现在又逐渐盛行起来的领域驱动设计的方法。领域驱动设计,强迫我们不再用数据进行驱动,而是使用领域进行驱动。遇到问题,我们先进性领域上的划分和拆解。这个问题到底属于哪个问题域,或者需要拆解到哪些问题域,然后再通过领域的组合、依赖完成最终问题的解决。
Spring Web 应用的最大败笔大部分Spring的Web应用程序,常见的错误的设计如下:
- 领域模型对象用来存储应用的数据(当作DTO使用),领域模型是贫血模型这样的反模式。
- 服务层每个实体有一个服务。
该应用程序有一个整体的服务层(Controller 仅负责绑定路由),它有太多的责任。更具体地,服务层有两个主要问题:
- 在服务层发现业务逻辑,业务逻辑被分散在各个服务层
- 每个领域模型一个服务。每一个类都应该有一个责任,不应将原属于领域模型的行为方法等划放在服务中实现,对象不但有属性还有行为。
领域驱动设计在互联网业务开发中的实践在我们习惯了J2EE的开发模式后,Action/Service/DAO这种分层模式,会很自然地写出过程式代码,而学到的很多关于OO理论的也毫无用武之地。使用这种开发方式,对象只是数据的载体,没有行为。以数据为中心,以数据库ER设计作驱动。 PS,对这句深有体会,此时一个系统最有含量的部分就是数据库设计,数据库表定了,剩下的就是腾挪数据了。
阿里盒马领域驱动设计实践 形容这类代码“面条代码”,从(客户)端上一条线杀到数据库完成一个操作,仅有的一些设计集中在数据库上。一文详谈领域驱动设计实践
- 在公司看到过的大部分代码中的对象只有两种类型:服务类(Service Object)和数据类(Data Object),所有的数据对象,被无脑的开放了所有的Getter和Setter方法,加之lombok等语法糖的推波助澜,对象的封装变得更加困难了。而所有的业务逻辑都被堆在了各种Service中,当然好的团队依然会对这些Service类做很好的分层设计,使代码的演进还能够正常的进行。
- 我们大部分应用使用的ORM框架,基本上都是用Mybatis,因此我们往往都需要有一个对象来映射数据库表结构,这里我将它命名为数据库对象,我们在代码中一般会通过DO、BO等后缀来进行区分。也正因为这个原因,我们很多时候都会直接将数据库模型作为代码设计的目标,代码逻辑也是为了操作数据库对象来写,导致代码中缺失真实业务场景的还原。
- 实际上我并不是要说这种开发方式不好,相反它能够在程序员中被广泛认可,其优势不言而喻,它能够让一个只要掌握编程语言的新手,快速的承接需求并交付,无需在代码设计和怎么写的问题上花费更多的精力和学习成本。大部分情况下,团队内的架构师只需要做好接口设计和数据库的设计,这个需求就可以完全交给一个新人去实现了。我把这种方式看作是一种通过确定【输入】和【输出】来控制软件开发确定性的方式。输入即程序对外提供的可以执行程序的入口,我们常见的像RPC接口、HTTP接口、消息监听、定时任务等。输出是程序对外部环境或者状态的影响,可以是数据库的写入、消息的广播推送、外部系统的调用等等。
- 在一个系统刚开始的阶段,这种方式能够以非常高的效率完成交付,这个阶段业务的本质复杂性低,技术的复杂性也低,程序的输入和输出链路比较单一。更重要的是在人的方面,每个人都能够很好的理解这种开发方式,只要从输入到输出的转换没有问题,程序员们不会去关注其中潜在的设计问题,无论是新人还是老手,开发这样的软件都能得心应手。相比于使用领域驱动设计的思维进行开发,面向过程的这种开发方式更简单直接,对人和团队的要求更低,在人员变动频繁的现状中,它能带来更快速的交付。
- 然而随着系统逐渐的演进,业务的核心复杂性变高,系统之间的联系逐渐变多,面向过程的这种开发方式就显得捉襟见肘了。不知道大家能否在自己团队中找到这样的代码:上千行的方法;上百个属性的类;循环依赖的Service;无法控制的数据一致性;越来越多的分支逻辑…。这些问题本质上并不是我们采用哪种开发方式就能解决的,但它们一定能说明我们当前的代码设计是存在问题的,这就像埋在我们系统中的一个个定时炸弹,如果你足够小心,团队的质量保障足够充足,这颗炸弹在你工作的期间可能并不会引爆,但根据墨菲定律,它们早晚是会引爆的。潜在的风险是一方面,另一方面是我们的交付速度,理解成本,沟通成本,知识的传递,都会因为这些混乱的代码而变得缓慢和困难。应对软件复杂度的方法有很多,即使是使用面向过程的开发方式,也有很多设计模式和方法论能够去解决这些问题。
- 在进行领域驱动设计落地的过程中,我感觉到最大的一个困难点是面向对象思维的转变,领域驱动设计实际上是基于面向对象设计的一种更高维度的设计模式,但我们之中大部分的开发者,已经习惯于按照面向过程的方式来进行开发,即使我们在很多场合都在强调我们在使用面向对象,但实际上却事与愿违。经验越丰富,越资深的工程师,越无法跳出之前长期积累的认知,这种先入为主的思维定式改变起来尤为困难。还有源源不断的新人逐渐开始进入这个行业,成为一个软件工程师,他们被要求能够尽快的开始交付和产出,他们也只能去模仿团队现在的代码,逐渐熟练以后,也只会把这种开发方式奉为圣经,再次传承下去。之前提到,我们现在的开发现状是通过【输入】和【输出】来进行设计,而领域驱动设计则是在其基础上增加了一层:【领域模型】。即所有的输入都要转换为领域模型,所有的输出也都要通过领域模型去完成。领域驱动设计的所有模块、模式、方法都是基于领域对象,为领域对象服务的。领域模型本身作为对现实世界中我们所解决问题空间的抽象,它的演进与问题空间的演进原则上是一致的,之所以使用面向对象来作为领域模型的承载,主要原因还是面向对象更加符合当下人们对现实世界的认知,理解和使用都更加简单。现实世界中大部分的“系统”,都是可以用对象,以及对象之间的关系来描述,认识、理解。描述现实世界中的客观事物是人类哲学最早开始思考的问题,先秦时期的名家,古希腊的形而上学,都是基于此目的建立的。今天我们的工作又何尝不是在混乱复杂的世界中,寻找规律,将其通过有限的模型表达出来,再转换为机器可以理解的语言,形成软件或者系统,简化人与人,人与物,物与物之间的交互过程。
- 一定要将领域模型和数据库模型分离开,这样我们的业务代码仅需要关注领域模型,到需要持久化的时候再去关心如何将领域模型转换为数据库模型。如此,即使之后数据库的选型发生变化,对代码的改动也仅限于对象转换的那部分逻辑;领域模型的迭代演进也可以更加自由,不受数据库设计的约束。领域模型到数据库模型转换的过程中需要注意几个细节:
- 不要将数据库关注的属性,无脑添加到领域对象中去,比如id、gmt_created、gmt_modified等。
- 实体间的关联,在数据库中经常会通过关系表来表达,但在领域对象中,完全可以通过类的引用关系来表示,不需要将关系抽象为实体(除非这个关系有特殊的业务意义)。
领域驱动设计详解:是什么、为什么、怎么做?分层并没有问题,但是这种分层架构采用的是包的形式进行的层与层的隔离,需要每一位开发同学理解并且自觉遵守以上规范,但是在实际工作中我们发现很多同学对Service层和Manager层的区别并不是特别的清楚,即使清楚的同学大部分也并没有完全遵守手册中的规范。在实际的业务代码中Service层充斥了大量的第三方依赖,对系统的稳定性有很大的影响。每依赖一个第三方服务都要各种异常处理,这些异常处理的代码往往会和业务代码混在一起,当这种代码多了以后会使代码的可读性非常差。
领域驱动设计学习输出「CRUD工程师」认为自己没有创造任何东西,他们只是数据库表的搬运工。而如果不是「CRUD」,业务系统后端工程师的价值在哪里?理解并抽象出业务逻辑,建立满足需求的业务模型,以此设计实现出可靠的系统,并有效地控制复杂性。这才是大部分业务系统后端工程师的工作重点,也是解决他们工作中遇到的问题和难点的关键。
- 基于“Service + 贫血模型”的实现。业务逻辑复杂了,业务的逻辑、状态会散落到大量方法中,你没有抽象,就没有办法模块化,就不能区分核心和周边,需求越来越多,你就只能硬写,你的这种硬写,往往都是写到了核心模块里面了,之所以成为核心,不就是希望你不要总是改变它吗,要尽可能将其变为只读的,否则,你当初的快就是后来的慢。PS: 做过一段时间的技术leader,一个比较好的设计就是 小伙伴接手你代码时候,新需求他能看出来塞在哪里,能看出来大纲,预留空间大,小伙伴填充细节就可以了。你一篇文章从头写到尾,他还需要提炼关键步骤和思想。
- 为什么总是习惯用上面那种方式编写代码呢?可能是业务简单到就是基于SQL的CRUD。可能是在service层中可以定义任何操作。如何应对变化,如何不让当初的快,变成后面的慢呢。就是要千方百计地将核心模块和周边模块,变成正交性的设计,让核心模块变成只读,每次来一个需求只需要修改或增加周边模块就好了。那如何才能一步一步实现正交设计的代码呢,最原始的基础就是要用丰满的面向对象技术,用丰满的面向对象技术的基础方法又是充血模型。
- 应用基于充血模型的 DDD 的开发模式,需要事先理清楚所有的业务,定义领域模型所包含的属性和方法;领域模型相当于可复用的业务中间层;新功能需求的开发,都基于之前定义好的这些领域模型来完成;越复杂的系统,对代码的复用性、易维护性要求就越高,就越应该花更多的时间和精力在前期设计上;而基于充血模型的 DDD 开发模式,正好需要前期做大量的业务调研、领域模型设计,所以它更加适合这种复杂系统的开发;
- 第一次就需要考虑那么的周全吗?第一次就需要面向未来设计吗?我个人的建议,你可以被子弹打中一次,但是不要被打中第二次。如果你第二次,第三次依然没有抽象出领域模型,你的每一次以为的快,都是为后面每一次的慢,埋下了“因缘”。有没有好的策略,来指导如何判断要不要搞成所谓的领域形式呢。
- 判断是否你的程序只为一个业务方服务。比如财务人员要用到、营销人员要用到、运营人员要用到。如果是,就要提前考虑沉淀出业务领域模型。
- 判断是否你的程序只为一个业务模式服务。比如拼团业务要用到、国际业务要用到、健康业务要用到。如果是,就要提前考虑好业务身份的判断且抽象共享服务。
- “贫血”模型将数据与操作分离,破坏了面向对象编程的封装特性,属于典型的面向过程编程风格。
- 做好领域驱动设计的关键是对业务的熟悉程度,而并不是对领域驱动设计这个概念本身的理解程度。即便我们非常清楚领域驱动设计这个概念,但是,如果我们对业务不熟悉,那么也不能得到合理的领域设计。
- 基于“充血”模型的 DDD 开发模式实现的代码一般是按照 MVC 三层架构分层的。Controller 层还是负责暴露接口,Repository 层还是负责数据访问,Service 层负责业务逻辑。它与基于“贫血”模型的传统开发模式的主要区别在 Service 层。
- 在基于“贫血”模型的传统开发模式中,Service 层包含 Service 类和 BO 类两部分,BO 类是“贫血”模型,只包含数据,不包含具体的业务逻辑。业务逻辑集中在 Service 类中。
- 在基于“充血”模型的 DDD 开发模式中,Service 层包含 Service 类和 Domain 类两部分。Domain 类相当于“贫血”模型中的 BO 类。与 BO 类的区别在于,Domain 类是基于“充血”模型开发的,既包含数据,又包含业务逻辑。而 Service 类变得非常“单薄”。 总结一下,基于“贫血”模型的传统的开发模式,重 Service 类,轻 BO 类;基于“充血”模型的 DDD 开发模式, 轻 Service 类,重 Domain 类。service层方法一定要有业务概念,提取、显式化用户、作者的意图,比如AService.BusinessFunc ==> BService.CrudFunc ==> ADao.CrudFunc,则业务意图在BService.CrudFunc 就丢失了,可能会被其他伙伴随意引用,BService.CrudFunc中的代码就不敢乱动,也无法根据意图做优化。一个良好拆分的系统,体现业务意图的方法不会有很多个。
- DDD和充血模型没有必然关系,并不是DDD才引入了充血模型。DDD首先是一个思想(战略),强调面向业务领域建模与统一语言,而非首先关注技术实现。有了业务模型之后才有落地(战术),而落地的方式有很多,与是否使用MVC,是否一定要充血模型都不必然,事实上,较适合的应该是菱形架构,过分强调充血模型也常常会带来落地的复杂度(性能、依赖、各种纠结)。
大佬总结的
我一直秉持的态度就是『知其然,知其所以然』。如果我们一直不知道为什么要这么做,那很有可能在模仿的时候会变形。
- dao: 数据通道
- service: 提供业务能力 controller, service , dao通过 对于数据通道的操作来实现业务逻辑的,简单来说就是通过sql的组合,来实现业务逻辑的。假设我们有三个 service (serviceA、serviceB、serviceC) 有三个dao (daoA、daoB、daoC) 他们目前是意义对应的,很美好是吧。但是当业务逻辑开始演进的时候,慢慢的问题就开始出现了。比如serviceA 需要用到daoB的一部分数据(假设是1-1的场景),于是我们开始向serviceA中加入daoB。有一天产品形态衍生出反向关联的场景的时候,我们又会往serviceB里加入daoA,这就出现了 serviceA和serviceB 中同时出现了 daoA和daoB ,当一个新人需要改动这段代码的时候他往往会有一个疑惑我到底把daoA相关的业务逻辑写在serviceA中还是serviceB中呢?这时候serviceC 出现了,说我希望复用一段业务serviceA中的业务逻辑,于是serviceC中出现了serviceA,产品这时候又提出了需求说,我希望serviceC对应的业务逻辑能显示daoA对应数据的一部分数据,之后serviceC中又出现了daoA 。现在如果是一个完全不知道演进的人已经完全看不懂这个依赖了。这有解法吗?有 在软件工程中永远能通过加一个中间层去解决问题,于是在阿里巴巴的java 开发手册中有了manager 的角色,目的是聚合service 让serviceA和serviceB的逻辑通过manager聚合而不是通过dao共享的方式去解决,但是实现随着演进仍然是会出现上面的问题的,只是把时间拖后的而已。这里个人有一个粗浅的认知就是分层结构的目的的理想状态是一个层次分明的树状结构,如果出现了网状结构的时候,去靠分层来避免unknown,将会变得困难,简单来说分层结构不停迭代一定会遇到的问题:1.无法梳理依赖(依赖混乱) 2.职责模糊(新方法不知道加哪里)。
单纯的层次模型在飞速迭代的今天在抗unknow的能力不是很强,那还有办法来减缓这种unknow的发生呢?我们来剖析一下分层模型的意图,分层模型通过操作sql来组合数据,将职责分为两个部分,即我上面提到的操作sql和sql入口。发现了Unknown的源头的业务的多变导致的unknown,那我们能不能换个角度去划分职责,将变化的东西更集中起来呢?大胆的提出一个想法把业务逻辑和操作sql的逻辑分开是不是一个好想法?再大胆一点,在面向对象编程的今天,我们能不能利用好面向对象的思想去解决这个问题呢?我们发现,在传统的service + dao 解决方案中所有的所有编程方式的其实都是面向流程编程(个人认为面向流程编程也是非常优秀的解决方案)。但是面向流程对工程师的要求非常高(模块化,组件化的)对工程师要求非常了解业务,需要合理将一个或者几个流程编程一个可执行的函数,不然代码就会陷入unknown状态。所以面向对象编程该怎么解这个问题呢?利用一个对象的天然特性 1-N。也就是对象天然是有层次的,如果我们将业务逻辑包装在对象的方法中,靠父对象去操作子对象的业务逻辑,是不是完全不会混乱了? 等等那层次模型也可以做啊,为什么不选用service 套service 的方案呢?等等是不是发现serviceA依赖serviceB,serviceB依赖serviceC,serviceC又依赖了serviceA?开始循环依赖了,又开始有点unknown的味道了?所以我们斗胆提出一个假设,有一个对象是不可以被其他对象依赖,这样就可以靠对象的从属关系去解service 的unknown 了。巧了,在ddd中这种对象叫做聚合根,有了他我们就有严格的层次模型了。现在我们演进出的方案是一个service 套用一个service 并且有个service 是不能被依赖的,解决了依赖复杂的问题,那还有一个问题:方法要写哪里?当我们发现我们称呼一个人为总管的时候,这个人给人的形象就是无所不能的,但是当我们把一个人称为一个财务,或者设计的时候,我们就已经知道他什么事情能干什么事情不能干了,所以当我们把StudentService 改名为 Student 这个时候我们就知道,我们就知道显示考试成绩的方法应该放在 Student里面的ExamRecord里面,如果再演进出一个获取平均分的方法我们也自然知道不应该直接加在student里面而应该ExamRecord里面。再回头一想,这不就是面相对象的设计方式吗?这就是ddd中的另外一个思想,弱化service 换言之就是,把业务逻辑写到业务对象里面。还有很多想法是可以从三层的劣势推到出来的包括事件驱动,限界上下文等等的。
一个收音机你看知道知道是一个收音机,知道xx按钮大概是调节音量的,但是如果把收音机的外壳拆了,给你看电路板,你还认识嘛?有没有想过为什么同一个东西,在不同的形态下,大家会对同一个东西有不同的感受呢?其实仅仅是封装是一个合理的封装而已。当我们接到制作收音机的需求时,根据传统的三层模型(controller/handler&service&DAO),我们实现的第一步可能会有以下几种情况:
- 从壳体设计(接口设计)开始;
- 从元器件设计(数据库设计)开始;
- 从布线设计(服务设计)开始。 然而,每种设计的出发点都会带来一些问题。接下来,我们将分别演绎这三种设计可能会遇到的问题。
- 从壳开始设计(从接口开始设计),根据需求,按钮能调节大小音量,按钮能调节无线电频率,但不知道不知道我的最后的大小,最后按钮的排布。在我们代码开发的情况下,就是发现入参不够,或者返回值不能表达所有的情况。
- 从元件开始设计(从数据库设计),当从元件开始设计的时候看上去好像挺美好的,因为关注的细节非常多,但是就是由于关注的细节特别多,被忽视的也会特别多,比如搞了两个非常完美的元件,但是他们的电压不同或者是电流不同,于是我就加了个电阻进来,发现这个电阻放进来之后会导致其他地方的电流电压不对,然后又花了非常大的力气调节。在我们的代码开发的情况这个就是发现我们在这个表里面少了一个字段,或者又少了个 map 表等等的。
- 从布线开始设计(从service设计)/ Start designing from wiring (from service design),当首先从布线设计的角度出发,大家会发现另外一个问题,具体那个元件通了哪几根线,那几个是公用的,那几个是独立的好混乱啊,因为元器件太多了,可能加一个电阻或者扣一个电阻,会对已经设计好的布线有极大的影响。在我们的代码开发的时候,其实就是数据库的字段多了一个或者少了一个,大家会被搞的毫无头绪。
为什么我们做不好收音机(服务)?总结一下,在我们利用自己的思维惯性设计代码的时候,通常会一下问题
- 错误的开始,设计的时候不先梳理依赖和拆分(模块化)
- 混乱的依赖导致不得不复制代码,导致代码更加混乱
- 代码难以传递依赖和业务逻辑,导致业务逻辑不会统一写在统一个地方,导致更加混乱
从接口设计的关注点着重行为,不关注实体之间的关联关系,所以一旦设计到关联关系,就很容易忽视。从数据库设计的关注点是数据和关联关系,但不关注到底有多少个业务对象,有多少种业务行为。从 service 设计其实规避了从接口和从数据库设计所带来的问题,看上去没什么大问题,还是不可避免的提到我上面说的混乱情况呢?没有模块化。事实上在自动化这种偏爱模块化设计的行业内,他们第一步是定义模块,和模块和模块之间的交互。而不是直接开始设计线路,收音机的模块划分如下图。所以他们的第一步确实是在设计线路但不完全是在设计线路。那为什么我们写 service 的时候那么会那么容易出问题的一部分原因找到了,应该从拆分模块开始,然后才是service的设计。由于我们错误的开始,然后会导致我们遇到一系列问题,然后我们又会用一些不合理的方式解决,这些不合理的方式导致我们的service陷入一个更加混乱的状态,导致我们的代码无法维护。究竟是哪些原因导致我们代码不能维护呢?复制代码,总是有一些东西不那么容易提取到函数中(比如一个函数两个参数,但是这两个参数也有约束关系,此时函数参数最好是一个对象,包含这两个参数)。导致我们开发的时候需要复制代码我把他归结为一下的两点
- 1-N 、1-1 的依赖关系;
- 业务逻辑 我们复制的代码大部分都是这部分的逻辑,基本上没人复制三层模型中 controller 的逻辑(这里指的是严格执行了 MVC的 C 中不放业务逻辑的代码)这里我们做一个假设,依赖关系和业务逻辑都可以传递,那我们是不是就不需要复制代码了呢?可以让实体实体能表达出完整的依赖关系,但实体和数据库的数据对不上,所以很多复制代码都是要不停的 query 对象来使得自己和数据库的数据保持一致(更新的时候担心实体中的字段是否是最新的值)。这个有解法吗? 有解法的,如果我们能把请求更新的所有逻辑都放在业务实体中是不是就有了一致的保证了?PS:感觉传递是次要的,主要是数据和逻辑都放在一起了。
如果我们尽量使用充血模型会得到什么好处呢? 业务逻辑和依赖关系都可以传递了,我们回头看看失血模型下我们的代码,是不是相当于当我们的合作伙伴要调用我们的时候,我们丢出去了一堆电子元器件并且和他说『自己去把线连起来』是不是多少有那么一点点不合理?在充血模型下我们的代码是由什么组成的呢?
- Entity,这里面写的是关于这个实体的业务逻辑和他的关联的对象,并且触发关联对象的业务逻辑,只要有业务逻辑优先是写在 entity 里面的
- ValueObject,业务实体 这是业务实体的另一种体现,和 entity 的区别就是有没有 id
- Repository,用来获取业务实体,或者是保存业务实体,首先声明这一定是一个接口,用来获取 entity, 和我们认知的 dao 有什么区别呢?DAO 返回的对象不一定是 entity, 但是 repository 返回的对象一定是 entity。同时因为是一个接口所以可以用来屏蔽到底是怎么取数据的(localcache, redis, mysql)。此外,repository 的接口会写在 entity 里面, 因为Entity 是不能依赖非 entity 包下的东西的,在充血模型下,实际上 repository 是一部分 entity 的,entity 禁止依赖 redis 禁止依赖一个 xxxclient。
- Service,entity 处理不了的逻辑,比如说从 repository 请求实体,将错误转换等等的
- http/rpc handler,只能做简单的数据转换 & 触发,转换成service 能处理的内部指令,交给 entity 执行
- Eventbus/timer handler,只能做简单的数据转换 & 触发,转换成 service 能处理的内部指令交给 entity 执行
测试阶段。如果是普通的三层模型,从数据库取数据的地方是绕不开的,直接把单测的的模式升级为非常难。贫血/充血对象都可以在内存里面构建出来,所以校验一段业务逻辑写的在内存里面构建一个对象,就可以直接校验自己的业务逻辑写的对不对了。
什么是高内聚,底耦合呢?我解释不出来,但是在众多解释高内聚低耦合的一个关键词是『模块』,模块内高内聚,模块外低耦合。那问题来了你写的代码有模块吗?如果你写的代码没有模块的概念的话,是不是就没有办法区分什么时候应该提高内聚,什么时候应该降低耦合呢?首先,模块是非常主观的意识,基本上是随着事务发展的情况下认为划定的。那问题来了,我们有什么方法能更方便的划归模块吗?一味的靠着经验积累将会大大这种效率太低了,甚至在没有正确实践的情况下,经验都没有办法积累。有没有办法比较简单的来划分所谓的模块这个概念呢?其实是有的这里可以引入ddd 的概念限界上下文。那这个限界上下文应该如何划分呢?首先是一个明确的点,能产生依赖的才有可能是一个限界上下文中。注意这里是依赖,而非关联。如何高内聚低耦合呢?
- 同一限界上下文中,尽量使用函数依赖或者实体依赖,这样方便梳理逻辑和追踪代码,也更容易拿到聚合根这个上下文
- 不同限界上下文中,尽量只依赖数据或者消息,消灭实体依赖,防止改动的蔓延和放大。所以我们一般一层单独的桥接层(特别注明非业务逻辑层)来将外部的数据转换为我们内部依赖的实体。
在 DDD 的过程中,第一步叫做消化知识。这一步的核心是产出两张图(类 图和流程图),核心是为了两件事情:
- 到底有多少个实体、实体间的依赖关系,每个实体的行为和数据 (类图)
- 实体的行为是怎么串联起来的 (流程图) 三层模型下,这两个图就是作用不大的,因为我们的依赖关系是通过 service 和 dao 的组合来展示的,而 er 图展示依赖实体关系是通过实体间关系来表达的,这里天生有 gap。而且在三层模型下,实体是不带任何行为的,任何的行为都是通过 xxxService.xxxx() 方法来提现的,所以 er图的利用度一直不高。
在六边形架构下我们怎么写代码呢?
- 先按照需求期间产品和产品讨论出来的 uml 图 一一建立好业务实体,要和 uml 图一模一样字段和行为。当发现没法实现的时候先去和相关人员讨论 uml 图上的哪个节点发生了变化
- 为业务实体写单测代码,这一步的 repository 是个接口,所以直接 mock 就行
- 完成 service ,通过对N聚合根的操作编排来组装业务逻辑
- 实现 repository 接口,怼到数据库/cache上就行
- 实现 handler,怼个适配器就行 我们是按照洋葱架构一步步由内向外写代码的,并且大部分情况通过内存对象就可以完成测试
ddd 有显著的缺点
- 代码量大(越1.5-2倍)
- 要求开发人员对业务有了解,不能上来就干
- 业务逻辑和技术逻辑分开有有些需要技术逻辑参与的业务逻辑就不好搞(转账的事务)
业务层臃肿,能力层单薄
业务系统架构实践总结 最初设计时做四层划分,也比较清晰。最核心的设计点是,biz层编排“可复用的”service层,完成一个场景逻辑表达。问题在于:
- Service本身的划分、定位,相对随意,从第一感觉而来,并未经过领域划分这样的设计。所以这里特意叫service,从而区别于domain的表达,我采用的领域划分方法下文会阐述;
- Service本身不可扩展,多态业务冲击下,为适配此service能力而存在的个性向共性的转换逻辑上浮。 因为这些问题的存在随更多业务接入的架构演进下,组织扩大后的人员差异扩大下,会导致几个问题:
- biz层越发的膨胀,service层越发的萎缩。biz层里充斥了各种本该往下沉淀的可复用业务逻辑,service层则几乎萎缩为dao;
- 人员的差异下,service实例颗粒度不一(有萎缩有膨胀),有重复相似的。biz实例亦是如此;
- 由于biz膨胀,层内会发展为两小层,上小层为面向单一业务场景的“业务biz层”,下小层为通用可复用场景的“通用biz层”,且这两层隐约存在,随研发个体认知差异或隐或现。由于service的萎缩,biz层会干脆直接调用dao,且因人而异的随机性差异,导致biz往下的调用关系呈乱麻态。
service间网状调用
核心的问题还是对service这层的颗粒度、职责定位不清晰,对增量service的架构监管不足,业务压力下(祸起于常见的倒排需求,常以需求完整度妥协+架构方案妥协追赶deadline死线,挖下损害长期利益的坑),一线研发同学就容易凭感觉去新增service。
基于原本bizA -> serviceA 的实现链路下,随新增业务逻辑新起的serviceB(事是A的事,但不合适放serviceA,所以新建),链路演变成了bizA -> serviceA -> serviceB。这样的趋势持续发展下去,会发现bizA下的service调用链路越发的复杂,呈现为一颗深度调用树,而biz层失去了业务编排的作用退化为一个业务场景入口的标志符。一个新同学除非步履维艰的推演完成全串调用代码,否则不能知晓bizA业务场景真实完全的意义。步履维艰在于,这是一颗深度调用树,当你阅读到serviceE时,很可能你已经忘记了最初是从哪个biz入口看过来的,以及接下来你将还会遇到哪些service讲述剩余的业务逻辑。而一个写的并不那么好的代码,还常将这些service的调用关系,隐藏在极其隐晦的角落,给阅读者增加复杂。譬如通过一个叫util的类,完成了service B -> C这一主干链路的传递。一个service的改动,几乎无法评估其产生的影响面。而因为无法评估,又怕改出故障,在业务需求压力下,会出现各类fork行为加剧加速腐化。这是恶性循环。
搞得好像一切为了持久化
笔者在一篇文章中看到一个问题:如果内存足够大,且永不宕机,你还会用数据库么?不会, 因为:
-
数据库表不支持继承和多态,表达能力有限。假设用户的联系方式可以是邮箱、电话(包括国家码,后续可以考虑扩展支持运营商信息)、qq任意一种,则用对象表示
class User{ Contact contact; setter getter } class Contact{ int contactType } class QQ extends Contact{ String qq; } class Phone extends Contact{ String country; String phone; }
用数据库表示就很尴尬了,因为多态的感觉不太好弄,你只能:
- 建一个contact表,所有的字段都放在里面
- 建一个contact表,一种联系方式建一个表
-
表达一对多关系要额外加字段,表达多对多关系要额外建一个表
我们回想一下controller-service-dao的实现过程
- model + dao 借助自动化工具生成
- 有一个添加地址的需求
- 然后controller实现,进而在UserService 里加一个addAddress方法,进而自然地 逻辑就写在
UserService.addAddress
里了,直到调用dao 为止。
搞得我们一切操作像是为了持久化,持久化是编程的目的么?有时候不是
还以上文的User为例,对每一个新来的用户,我们需要保存用户身份信息(身份证号、性别等)、收货地址信息、画像信息等。为了用户操作友好
- 用户信息 按类别 在不同的页面上输入。比如填完身份信息,点击下一步,让用户填写收货地址信息。
- 用户可以添加任意多个收货地址,可以让用户在地图上选择地址,考虑到页面空间有限,一个页面只添加一个收货地址。一个收货地址添加完毕后, 用户可以选择下一步(添加兴趣信息)或者 新增下一个收货地址。
- 每一个操作 都可以上一步,以便用户修改
针对这个需求,有几个实现方式
- 每一步操作都保存到数据库,回显时从数据库中读取数据。这涉及到 用户请求对象 和 数据库对象的 相互转换。
-
内存中有一个User 充血对象,在最后一步保存到db之前,其它所有的步骤只操作User 即可,包括但不限于
- 添加/回显身份证信息
- 添加/回显收货地址
- 添加/回显联系方式
为简单起见,你甚至可以将每一个步骤中页面发你的请求 数据直接保存在user 中,回显时原封不动直接返回给页面(用户的修改类似)。只有在最后保存的时刻, user.sync
同步到数据库。
持久化就是持久化,本身不是业务逻辑的一部分(用户才不关心,甚至上层逻辑也不关心你将数据保存在msyql还是文件里,也不关心你是否做了分库分表),因此
-
尽量的集中,对于整个User数据(包括n个收货地址和某种类型的联系方式)
- 执行的时间集中
- 代码的位置集中
-
不要干预业务逻辑的处理过程,比如回显的时候不用从数据库获取。
面向功能的组件化
阿里玄难:面向不确定性的软件设计几点思考 是一篇读多少遍都不过分的文章,其中就提到“面向功能的组件化设计到面向业务的对象化设计”。controller-service-dao 中包含大量的service,也是面向功能的组件化设计,“因此按抽象归纳,组件化设计的软件系统,随着业务发展,补丁越来越多,运行几年就会被推倒重来是它的宿命”
大量的service 有几个问题
- 多了之后,经常出现互相引用的情况。因为按领域划分的话,一定是大概念调用(多个)小概念,从上到下发散式的调用。而对于面向功能的组件化设计,以班级-学生为例,ClassService 可能要获取班级内所有学生姓名的接口, StudentService 有获取班级 班主任老师姓名的需求,必定会彼此相互依赖。
- 以京东业务类,既有自营也有第三方店铺,既有京东配送也有第三方配送。假设有一个订单服务,按传统设计会有OrderService,其尴尬之处是 自营非自营的订单对其来说都是一个Order对象,当然会有一个类似type的字段来标记其是否自营订单。但因为自营非自营订单的处理逻辑不同, 这时if else就不可避免了。在这个例子中,“面向功能的组件化” 对多态的表达能力不足,对能力的复用是服务化 而不是 “继承”(面向对象理念在架构设计上的延伸)的方式。“面向业务的对象化设计” 则会有Order、自营Order、第三方Order 等对象。称呼、行为 与代码的实际表现是一致的,阿里玄难:面向不确定性的软件设计几点思考 甚至提到 阿里以后真的会有一个类 叫天猫、淘宝等。
领域知识的丢失
你或许以为你不需要领域驱动设计我们或许很容易就能设想到一个毫无规划设计的城市,纵横交错的路网、杂乱无章式的建筑布局、各种凌乱的棚户区设计,恰好象征着软件设计的无序性,也恰好体现了软件企业在经费不足、组织缺乏管理、开发者能力不足、软件随时随地想改就改时的行业现状,只能说这样的软件是最能符合当时实际劳动生产力水平的产品。
程序员们掌控系统的方式,就是靠数据库建模来驱动软件开发的古老模式,而且几乎都是面向过程式的代码,这些代码的流程几乎一模一样,只需简单的按照步骤,一步步套模式,轻易就能学会。
- 查看用户界面,定义需要绑定到界面的模型和层级结构。
- 设计数据库,不管什么类型的项目,先根据客户提供的业务表单、将其转化成实体关系(ER图)、然后建立对应的代码模型。有可能使用专业软件设计ER图,也有可能会使用Navicat软件设计ER图。
- 设计接口,然后把数据拼凑成用户界面层所需的对象。
- 代码层次结构为传统的三层架构,严格按照用户界面层、业务逻辑层、数据访问层进行设计,有时候会引入依赖注入框架,实现不同层次间的解耦。但是有时候程序员不会严格区分需要编写的代码,究竟是属于哪个层次应该囊括的内容。
三层架构的问题:
- 与用户行为相关的操作割裂的存放在不同层。有的可能放在用户界面层、有的可能放在数据访问层、有的可能放在业务逻辑层,造成了领域知识的丢失。
- 用户界面层使用接口作为外观或者一种行为、开发者会使用自己独立的风格习惯来定义这种行为,就容易造成术语和规则不统一,也会为后期产品的维护迭代造成问题。PS:也就是同一个业务系统,可能因为ui界面设计不同就导致 代码上差别很大
正如“罗马不是一天建成的”,屎山也同样如此。这样的写法在代码刚刚编写之初并没有问题,只是随着业务变化、时间的积累、程序员的水平、方法重构、新技术新组件的引入,代码将成为屎山。
毋庸置疑,数据库建模驱动软件开发具有速度快、学习成本低的显著特点,在许多项目中,能在短期内可以给开发者带来许多便利;而应用领域驱动设计,则可以在更长的维护周期内,给软件维护带来实质性好处。
从整体组织的角度看待技术债,避免技术破产对软件进行合理的变更需要花费不合理的时间来实现,技术债是沟通不畅的三阶效应。这是缺乏适当抽象的症状,而这反过来又源于对问题领域建模的不足。这意味着没有进行充分的沟通。
- 软件肮脏的秘密在于,我们可以对我们无法清晰表达的问题实施解决方案。如果我们的软件是“错误的”,那么正确的行为总是只需一个 if 分支。通过使用 if 分支来补偿我们糟糕的领域模型。
- 只要我们缺乏正确的概念,我们的思维以及我们与他人的交流就会变得笨拙而迂回。想象一下,在不知道狗(dog)这个单词或者甚至不知道动物(animal)这个单词的情况下,试图给某人讲一个关于狗的故事。“它是一种急切的、摇尾巴的、有四条腿的生物”。这听起来很傻,但我在项目中多次遇到这种情况。试图“修复”没有正确概念的代码很可能会失败,因为错误的概念没有优雅或干净的组织。
- 我们所说的技术债实际上是源于业务领域建模的不足,并且最终是由沟通和协作问题引起的,那么这不是开发人员可以自行解决的问题。事实上,认为开发人员能够并且应该单独处理技术债是导致技术债的另种思维症状。对于开发人员来说,认为他们所需要的只是一点时间来把事情做好就更舒服了,是错误的。
DDD之于业务支撑的意义研发效能降低的重要原因在于:更多的时候,我们还是按照“业务能跑起来,怎么快怎么来“的逻辑去做相关工作,遇水搭桥,遇山开洞,然后直达目的地,进行信息的传达、数据库字段的操作。这样的过程,违背了我们”希望通过业务场景,丰富平台能力,同时保证内核干净“的初衷。能力应该是基于相对多的用例、相对完善的思考进行抽象,是横向统一看,有更深刻的理解,但是垂直的交付,让我们更加纵向地处理问题,往往只是“窥探”了链路,在交付时长和业务节点的限制下,很难想得更加全面、深刻,难以做出更通用的设计。那么,为什么关注DDD?如果说DDD直击了软件复杂度的核心——“问题域”,那可能还是比较抽象。具体来说,因为这的确符合我们追求的价值观【提升长期的生产效率】:
- 细分领域,培养专业的人、事:因为DDD的核心是要求让各个领域做好理解和封装,把一个业务需求拆解、安放在各自合理的地方,通过这样的分解与沉淀,保证了领域的输入,能够得到长期可持续的发展,形成竞争力。
- 机制保障,不依赖易变的事物:DDD其实在总结很多通用的技巧和经验,能够让这样的实施更具有确定性。无论是聚合根对领域实体的管控能力、限界上下文的交互策略、领域内核的抽象地位…等等,一旦选择尊奉,确定下来,就能够落在代码结构、组织关系、团队文档中,形成共识,不会因为人员等因素的变化而剧烈波动。
个人认为核心有2个点:
- 起步时的domain设计:有理论支撑的domain划分和凭经验感觉划分的service,对整个架构生命力的影响是完全不同的;
- 过程中的架构原则:必须有几个易记牢记并坚持贯彻的架构原则,在实操层面上,对整个架构性命的维持是完全不同的。
- 厚domain薄biz: 从系统架构内观的角度看,必须要先有domain再有biz,domain承载了该应用最核心的业务能力,好的架构里,依domain代码逐个阅读便能鸟瞰该应用的核心能力,厚domain薄biz,domain要敦实、包容、开放;biz是面向场景的,核心是复用下面的domain搭建出一个业务场景,讲究灵活。biz是面向场景的一个动词视角,domain是面向一簇模型的名词视角,场景必发散,模型需收敛,动词表灵动,名词代沉稳,这是核心的差异。
- 领域设计,最核心的是划分,切的位置、切出来的大小,极大的决定了一个架构的生命长度。
碎碎念
只有架构分层是不够的,还需要更详细的逻辑分层,DDD领域驱动设计正是一个详细帮助建立丰富的有行为的领域模型的方法学。
数据驱动SQL ---->服务驱动SOA ----->领域驱动
聚合 >松耦合>重用 ==> 事件驱动>依赖注入>继承
过去系统分析和系统设计都是分离的,这样割裂的结果导致,需求分析的结果无法直接进行设计编程,而能够进行编程运行的代码却扭曲需求,导致客户运行软件后才发现很多功能不是自己想要的,而且软件不能快速跟随需求变化。DDD最大的好处是:接触到需求第一步就是考虑领域模型,而不是将其切割成数据和行为,然后数据用数据库实现,行为使用服务实现,最后造成需求的首肢分离。DDD让你首先考虑的是业务语言,而不是数据。重点不同导致编程世界观不同。