简介
浅谈我对DDD领域驱动设计的理解 很多项目(尤其是互联网项目,为了赶工)都是一开始模型没想清楚,一上来就开始建表写代码,代码写的非常冗余,完全是过程式的思考方式,最后导致系统非常难以维护。我们今天吐槽一下controller-service-dao的“坑”,挖一挖它的墙角。如果你觉得controller-service-dao 很不错,那说明你应对的场景还不够复杂,暂时还不适合谈论ddd。
很多项目的实际情况
- 用户或产品经理需求零零散散,不断变更
- 工程师在各处代码中寻找可以实现这些需求变更的代码,修修补补
- 软件只有需求分析,并没有真正的设计,系统没有一个统一的领域模型维持其内在逻辑一致性
- 功能特性并不是按照领域模型内在的逻辑设计,而是按照各色人等自己的主观想象设计。
CRUD/controller-service-dao的败笔
大部分Spring的Web应用程序,常见的错误的设计如下:
- 领域模型对象用来存储应用的数据(当作DTO使用),领域模型是贫血模型这样的反模式。
- 服务层每个实体有一个服务。
该应用程序有一个整体的服务层(Controller 仅负责绑定路由),它有太多的责任。更具体地,服务层有两个主要问题:
- 在服务层发现业务逻辑,业务逻辑被分散在各个服务层
- 每个领域模型一个服务。每一个类都应该有一个责任,不应将原属于领域模型的行为方法等划放在服务中实现,对象不但有属性还有行为。
领域驱动设计在互联网业务开发中的实践在我们习惯了J2EE的开发模式后,Action/Service/DAO这种分层模式,会很自然地写出过程式代码,而学到的很多关于OO理论的也毫无用武之地。使用这种开发方式,对象只是数据的载体,没有行为。以数据为中心,以数据库ER设计作驱动。 PS,对这句深有体会,此时一个系统最有含量的部分就是数据库设计,数据库表定了,剩下的就是腾挪数据了。
阿里盒马领域驱动设计实践 形容这类代码“面条代码”,从(客户)端上一条线杀到数据库完成一个操作,仅有的一些设计集中在数据库上。
领域驱动设计详解:是什么、为什么、怎么做?分层并没有问题,但是这种分层架构采用的是包的形式进行的层与层的隔离,需要每一位开发同学理解并且自觉遵守以上规范,但是在实际工作中我们发现很多同学对Service层和Manager层的区别并不是特别的清楚,即使清楚的同学大部分也并没有完全遵守手册中的规范。在实际的业务代码中Service层充斥了大量的第三方依赖,对系统的稳定性有很大的影响。每依赖一个第三方服务都要各种异常处理,这些异常处理的代码往往会和业务代码混在一起,当这种代码多了以后会使代码的可读性非常差。
领域驱动设计学习输出「CRUD工程师」认为自己没有创造任何东西,他们只是数据库表的搬运工。而如果不是「CRUD」,业务系统后端工程师的价值在哪里?理解并抽象出业务逻辑,建立满足需求的业务模型,以此设计实现出可靠的系统,并有效地控制复杂性。这才是大部分业务系统后端工程师的工作重点,也是解决他们工作中遇到的问题和难点的关键。
- 基于“Service + 贫血模型”的实现。业务逻辑复杂了,业务的逻辑、状态会散落到大量方法中,你没有抽象,就没有办法模块化,就不能区分核心和周边,需求越来越多,你就只能硬写,你的这种硬写,往往都是写到了核心模块里面了,之所以成为核心,不就是希望你不要总是改变它吗,要尽可能将其变为只读的,否则,你当初的快就是后来的慢。PS: 做过一段时间的技术leader,一个比较好的设计就是 小伙伴接手你代码时候,新需求他能看出来塞在哪里,能看出来大纲,预留空间大,小伙伴填充细节就可以了。你一篇文章从头写到尾,他还需要提炼关键步骤和思想。
- 为什么总是习惯用上面那种方式编写代码呢?可能是业务简单到就是基于SQL的CRUD。可能是在service层中可以定义任何操作。如何应对变化,如何不让当初的快,变成后面的慢呢。就是要千方百计地将核心模块和周边模块,变成正交性的设计,让核心模块变成只读,每次来一个需求只需要修改或增加周边模块就好了。那如何才能一步一步实现正交设计的代码呢,最原始的基础就是要用丰满的面向对象技术,用丰满的面向对象技术的基础方法又是充血模型。
- 应用基于充血模型的 DDD 的开发模式,需要事先理清楚所有的业务,定义领域模型所包含的属性和方法;领域模型相当于可复用的业务中间层;新功能需求的开发,都基于之前定义好的这些领域模型来完成;越复杂的系统,对代码的复用性、易维护性要求就越高,就越应该花更多的时间和精力在前期设计上;而基于充血模型的 DDD 开发模式,正好需要前期做大量的业务调研、领域模型设计,所以它更加适合这种复杂系统的开发;
- 第一次就需要考虑那么的周全吗?第一次就需要面向未来设计吗?我个人的建议,你可以被子弹打中一次,但是不要被打中第二次。如果你第二次,第三次依然没有抽象出领域模型,你的每一次以为的快,都是为后面每一次的慢,埋下了“因缘”。有没有好的策略,来指导如何判断要不要搞成所谓的领域形式呢。
- 判断是否你的程序只为一个业务方服务。比如财务人员要用到、营销人员要用到、运营人员要用到。如果是,就要提前考虑沉淀出业务领域模型。
- 判断是否你的程序只为一个业务模式服务。比如拼团业务要用到、国际业务要用到、健康业务要用到。如果是,就要提前考虑好业务身份的判断且抽象共享服务。
搞得好像一切为了持久化
笔者在一篇文章中看到一个问题:如果内存足够大,且永不宕机,你还会用数据库么?不会, 因为:
-
数据库表不支持继承和多态,表达能力有限。假设用户的联系方式可以是邮箱、电话(包括国家码,后续可以考虑扩展支持运营商信息)、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领域驱动设计正是一个详细帮助建立丰富的有行为的领域模型的方法学。
数据驱动SQL —->服务驱动SOA —–>领域驱动
聚合 >松耦合>重用 ==> 事件驱动>依赖注入>继承
过去系统分析和系统设计都是分离的,这样割裂的结果导致,需求分析的结果无法直接进行设计编程,而能够进行编程运行的代码却扭曲需求,导致客户运行软件后才发现很多功能不是自己想要的,而且软件不能快速跟随需求变化。
DDD最大的好处是:接触到需求第一步就是考虑领域模型,而不是将其切割成数据和行为,然后数据用数据库实现,行为使用服务实现,最后造成需求的首肢分离。DDD让你首先考虑的是业务语言,而不是数据。重点不同导致编程世界观不同。