美文网首页
DDD的个人看法

DDD的个人看法

作者: Java全栈攻城狮 | 来源:发表于2020-02-26 17:56 被阅读0次

前言

从事技术多年,看了不少代码,写了不少代码,在如何设计一个优秀软件上也跟若干高手们做过各种讨论和pk。在DDD(领域驱动设计)理念上各路高手也是观点各异。

DDD只是一个流派,谈不上压倒性优势,更不是完美无缺。 我更想跟大家分享的是我们是否关注设计本身,不管什么流派的设计,有设计就是好的。 从我看到的代码上来讲,大部分代码都不属于DDD类型,有设计的也不多,更多的像“面条代码”,从端上一条线杀到数据库完成一个操作。设计集中在数据库(有时候数据库设计都没有,一堆字段也不知道是干嘛用的),代码更多是自我修养。我们依靠强大的测试保证了软件的外部质量(向苦逼的测试们致敬),而内部质量在紧张的项目周期中屡屡得不到重视,陷入日复一日的技术负债中。

为什么需要DDD?

随着业务逻辑的越来越复杂,然后一般他们的解决方案是不断的重构系统,让系统的设计随着业务成长也进行不断的演进。通过重构出一些独立的类来存放某些通用的逻辑解决混乱问题,但是我们很难给它一个业务上的含义,只能以技术纬度进行描述,那么带来的问题就是其他人接手这块代码的时候不知道这个的含义或者只能通过修改通用逻辑来达到某些需求。

领域模型的追本溯源

实际上,领域模型本身也不是一个陌生的单词,说直白点,在早期开发中,领域模型就是数据库设计。最后上线。在传统项目中,数据库是整个项目的根本,数据模型出来以后后续的开发都是围绕着数据展开,然后形成一个架构。很显然,这其中存在了问题,我们试想一下如果一个软件产品不依赖数据库存储设备,那我们怎么去设计这个软件呢?如果没有了数据存储,那么我们的领域模型就得基于程序本身来设计。那这个就是 DDD 需要去考虑的问题。领域模型是对业务模型的抽象,DDD是把业务模型翻译成系统架构设计的一种方式。

领域模型探讨

在service层通过我们非常喜欢的manager去manage大部分的逻辑,POJO(稍后章节里的失血模型)作为数据在manager手(上帝之手)里不停地变换和组合,service层在这里是一个巨大的加工工厂(很重的一层),围绕着数据库这份DNA,完成业务逻辑。举个不恰当的例子:假如有父亲和儿子这两个表,生成的POJO应该是:

这时候儿子犯了点什么错,老爸非常不爽的扇了儿子一个耳光,老爸手疼,儿子脸疼。Manager通常这么做:

这里,manager充当了上帝的角色,扇个耳光都得他老人家帮忙。

在聊到DDD的时候,我经常会做一个假设:假设你的机器内存无限大,永远不宕机,在这个前提假设下,我们是不需要持久化数据的,也就是我们可以不需要数据库,那么你将会怎么设计你的软件?这就是我们说的Persistence Ignorance:持久化无关设计。

没了数据库,领域模型就要基于程序本身来设计了,热爱设计模式的同学们可以在这里大显身手。在面向过程,面向函数,面向对象的编程语言中,面向对象无疑是领域建模最佳方式。类与表有点像(不少人认为表和类就是对应的,行row和对象object就是对应的),我个人强烈地不认同这种等同关系,这种认知直接导致了软件设计变得没有意义。类和表有以下几个显著区别,这些区别对领域建模的表达丰富度有显著的差别,有了封装、继承、多态,我们对领域模型的表达要生动得多,对SOLID原则的遵守也会严谨很多。

【引用】关系数据库表表示多对多的关系是第三张表来实现,这个领域模型表示不具象化, 业务同学看不懂。

【封装】类可以设计方法,数据并不能完整地表达领域模型,数据表可以知道一个人三维,并不知道“一个人是可以跑的”。

【继承、多态】类可以多态,数据上无法识别人与猪除了三维数据还有行为的区别,数据表不知道“一个人跑起来和一头猪跑起来是不一样的”。

再看看老子生气扇儿子的例子:

根据这个思路,慢慢地,我们在面向对象的世界里设计了栩栩如生的领域模型,service层就是基于这些模型做的业务操作(它变薄了,很多动作交给了domain objects去处理):领域模型并不完成业务,每个domain object都是完成属于自己应有的行为(single responsibility),就如同人跑这个动作,person.run是一个与业务无关的行为,但这个时候manger或者service在调用 some person.run的时候可能完成的100米比赛这个业务,也可能是完成跑去送外卖这个业务。这样的话形成了类似于如下的架构图:

我们回到假设,假设你的机器内存无限大,永远不宕机,现在把假设去掉,没有谁的机器是内存无限大,永远不宕机的。去掉这个假设,我们需要数据库,但数据库的职责不再承载领域模型这个沉重的包袱了,数据库回归persistence的本质,完成以下两个事情:

【存】将对象数据持久化到存储介质中 【取】高效地把数据查询返回到内存中

由于不再承载领域建模这个特性,数据库的设计可以变得天马行空,任何可以加速存储和搜索的手段都可以用上,我们可以用column数据库,可以用document数据库,可以设计非常精巧的中间表去完成大数据的查询。这样我们再看看架构图:

这里我想跟大家强调的是:

领域模型是用于领域操作的,当然也可以用于查询(read),不过这个查询是代价的。在这个前提下,一个aggregate可能内含了若干数据,这些数据除了类似于getById这种方式,不适用多样化查询(query),领域驱动设计也不是为多样化查询设计的。

查询是基于数据库的,所有的复杂变态查询其实都应该绕过Domain层,直接与数据库打交道。

再精简一下:领域操作->objects, 数据查询->table rows。

领域模型:失血、贫血、充血模型

失血、贫血、充血、胀血模型应该是Martin Fowler提出的,讲述的是基于领域模型的丰满程度下如何定义一个模型,有点像:瘦、中等、健壮、胖。【胀血(胖)模型太胖,在这里我们不做讨论】。

失血模型:基于数据库的领域设计方式其实就是典型的失血模型,以java为例,POJO只有简单的基于field的setter,getter方法,POJO之间的关系隐藏在对象的某些ID里,由外面的manager解释,比如son.fatherId,Son并不知道他跟Father有关系,但manager会通过son.fatherId得到一个Father。

贫血模型:儿子不知道自己的父亲是谁是不对的,不能每次都通过中间机构(Manager)验DNA(son.fatherId)来找爸爸,领域模型可以更丰富一点,给son这个类修改一下:

son这个类变得丰富起来了,但还有一个小小的不方便,就是通过father无法获得son(爸爸怎么可以不知道儿子是谁),这样我们再给Father添加这个属性:

现在看着两个类就丰满多了,这也就是我们要说的贫血模型,在这个模型下家庭还算完美,父子相认。然而仔细研究这两个类我们会发现一点问题:通常一个object是通过一个repository(数据库查询),或者factory(内存新建)得到的:

这个方法可以将一个son object从数据库里取出来,为了构建完整的son对象,sonRepo里需要一个fatherRepo来构建一个father去赋值son.father。而fatherRepo在构建一个完整father的时候又需要sonRepo去构建一个son来赋值father.son。这形成了一个无向有环圈,这个循环调用问题是可以解决的,但为了解决这个问题,领域模型会变得有些恶心和将就。有向无环才是我们的设计目标,为了防止这个循环调用,我们是否可以在father和son这两个类里省略掉一个引用?修改一下Father这个类:

这样在构造Father的时候就不会再构造一个Son了,但代价是我们在Father这个类里引入了一个SonRepository, 也就是我们在一个domain对象里引用了一个持久化操作,这就是我们说的充血模型

充血模型:充血模型的存在让domain object失去了血统的纯正性,他不再是一个纯的内存对象,这个对象里埋藏了一个对数据库的操作,这对测试是不友好的,我们不应该在做快速单元测试的时候连接数据库,这个问题我们稍后来讲。为保证模型的完整性,充血模型在有些情况下是必然存在的,比如在一个门店里可以售卖好几千个商品,每个商品有好几百个属性。如果我在构建一个店的时候把所有商品都拿出来,这个效率就太差了:

领域模型下的依赖注入

简单地对依赖注入说一说:

依赖注入在runtime是一个singleton对象,只有在spring扫描范围内的对象(@Component)才能通过annotation(@Autowired)用上依赖注入,通过new出来的对象是无法通过annotation得到注入的。

个人推荐构造器依赖注入,这种情况下测试友好,对象构造完整性好,显式地告诉你必须mock/stub哪个对象。

说完依赖注入我们再看刚才的充血模型

新建一个Father的时候需要赋值一个SonRepository,这显然在写代码的时候是非常让人恼火的事情,那么我们是否希望可以通过依赖注入的方式把SonRepository注入进去呢?Father在这里不可能是一个singleton对象,它可能在两个场景下被new出来:新建、查询,从Father的构造过程,SonRepository是无法注入的。这时工厂模式就显示出其意义了(很多人认为工厂模式就是一摆设)

由于FatheFactory是系统生成的singleton对象,SonRepository自然可以注入到Factory里,newFather方法隐藏了这个注入的sonRepo,这样new一个Father对象就变干净了。

领域模型:测试友好
失血模型和贫血模型是天然测试友好的(其实失血模型也没啥好测试的),因为他们都是纯内存对象。但实际应用中充血模型是存在的,要不就是把domain对象拆散,变得稍微不那么优雅(当然可以,贫血和充血的战争从来就没有断过)。那么在充血模型下,对象里带上了persisitence特性,这就对数据库有了依赖,mock/stub掉这些依赖是高效单元化测试的基本要求,我们再看Father这个例子:

把SonRepository放到构造函数的意义就是为了测试的友好性,通过mock/stub这个Repository,单元测试就可以顺利完成。

领域模型:repository的实现方式

按照object domain的思路,领域模型存在于内存对象里,这些对象最终都要落到数据库,由于摆脱了领域模型的束缚,数据库设计是灵活多变的。domain object是怎么进入到数据库的呢?

我们独特的设计了Tunnel这个接口,通过这个接口我们可以实现对domain对象在不同类型数据库的存取。Repository并没有直接进行持久化工作,而是将domain对象转换成POJO交给Tunnel去做持久化工作,Tunnel具体实现可以在任何包实现,这样,部署上,domain领域模型(domain objects+repositories)和持久化(Tunnels)完全的分开,domain包成为了单纯的内存对象集。

领域模型下的部署架构
原则上可以采用一个大而全的领域模型,也可以运用boundedContext方式拆分子域,并在交接处处理好数据传送,这里引用Martin Fowler的一幅图:

DDD中的基本概念

实体(Entity)

当一个对象由其标识(而不是属性)区分时,这种对象称为实体(Entity)。比如当两个对象的标识不同时,即使两个对象的其他属性全都相同,我们也认为他们是两个完全不同的实体。

值对象(Value Object)

当一个对象用于对事物进行描述而没有唯一标识时,那么它被称作值对象。因为在领域中并不是任何时候一个事物都需要有一个唯一的标识,也就是说我们并不关心具体是哪个事物,只关心这个事物是什么。比如下单流程中,对于配送地址来说,只要是地址信息相同,我们就认为是同一个配送地址。由于不具有唯一标示,我们也不能说"这一个"值对象或者"那一个"值对象。

领域服务(Domain Service)

一些重要的领域行为或操作,它们不太适合建模为实体对象或者值对象,它们本质上只是一些操作,并不是具体的事物,另一方面这些操作往往又会涉及到多个领域对象的操作,它们只负责来协调这些领域对象完成操作而已,那么我们可以归类它们为领域服务。它实现了全部业务逻辑并且通过各种校验手段保证业务的正确性。同时呢,它也能避免在应用层出现领域逻辑。理解起来,领域服务有点facade的味道。

聚合及聚合根(Aggregate,Aggregate Root)

聚合是通过定义领域对象之间清晰的所属关系以及边界来实现领域模型的内聚,以此来避免形成错综复杂的、难以维护的对象关系网。聚合定义了一组具有内聚关系的相关领域对象的集合,我们可以把聚合看作是一个修改数据的单元。

聚合根属于实体对象,它是领域对象中一个高度内聚的核心对象。(聚合根具有全局的唯一标识,而实体只有在聚合内部有唯一的本地标识,值对象没有唯一标识,不存在这个值对象或那个值对象的说法)

若一个聚合仅有一个实体,那这个实体就是聚合根;但要有多个实体,我们就要思考聚合内哪个对象有独立存在的意义且可以和外部领域直接进行交互。

工厂(Factory)

DDD中的工厂也是一种封装思想的体现。引入工厂的原因是:有时创建一个领域对象是一件相对比较复杂的事情,而不是简单的new操作。工厂的作用是隐藏创建对象的细节。事实上大部分情况下,领域对象的创建都不会相对太复杂,故我们仅需使用简单的构造函数创建对象就可以。隐藏创建对象细节的好处是显而易见的,这样就可以不会让领域层的业务逻辑泄露到应用层,同时也减轻应用层负担,它只要简单调用领域工厂来创建出期望的对象就可以了。

仓储(Repository)

资源仓储封装了基础设施来提供查询和持久化聚合操作。这样能够让我们始终关注在模型层面,把对象的存储和访问都委托给资源库来完成。它不是数据库的封装,而是领域层与基础设施之间的桥梁。DDD 关心的是领域内的模型,而不是数据库的操作。

DDD中的模型

Model与传统的POJO(DTO、DO、DAO)类等对比,都是一个类中有属性、属性有Get/Set方法,并且做传输对象。

Model与传统MVC三层架构层的业务逻辑层中的Service对比,都是处理业务行为(Action)层。

模型(Model)承载着业务的属性和具体的行为,是业务表达的方式、是DDD的内核。是一个类中有属性、属性有Get/Set方法,并且业务的行为(Action)操作也是在模型类中(充血模型)即做业务逻辑处理,又做数据传输对象,模型分为Entity、Value Object、Service这三种类型。

Entity (实体)

有特定的标识,标识着这个Model在系统中全局唯一

内部值可以是变化的,可能存在生命周期 (比如订单对象,状态值是连续变化的)

有状态的Value Object

Value Object (值对象)

内部值是不变的,不存在生命周期 (比如地址对象不存在生命周期)

无状态对象

Service (服务)

无状态对象

当一个属性或行为放在Entity、Value Object中模棱两可或不合适的时候就需要以Service的形式来呈现

三种模型的复杂度是不一样的,在领域建模选Model模棱两可时,优先选择简单模型原则。模型复杂度顺序 Service > Entity > ValueObject

DDD模型的生命周期

Factory (工厂)

用来创建Model,以及帮助Repository (数据源)注入到Model中

Aggreagte (聚合根)

封装Model,一个Mode中l可能包含其他Model(类似一个对象中包含其他对象的引用,实际概念更为复杂些)

Repository (数据源)

数据源的访问网关层、通过Repository来对接不同的数据源

DDD模型的边界

限界上下文,领域边界上下文

域的拆分

按业务抽象进行划分

一个业务拆分成几个独立的域,每个域又可细拆成不同子域

防腐

一个域在访问其他域的模型时,把获取到的模型做层转换映射到自己域的模型中(不直接使用别的域模型作为自己域模型中的一部分)

防止源域模型发生变更,依赖源域模型的调用方,在需要源域模型新功能时,必须要全局依赖修改,才在能兼容

防止域上下文不一致产生的冲突

DDD设计的特点

根据业务模型设计系统

不是通过数据库等数据源驱动设计,是根据业务语义抽象梳理设计成领域模型

数据模型统一

通过真实业务背景,梳理出业务域模型自然会形成出参、入参、中间临时属性收口统一为域模型

业务模型与数据源无关

数据源数据结构无论怎么变、数据源无论怎么换,领域模型统一无感知,无须变更。

一个域模型底层对应的数据源可以是1个或n个不同类型数据源

系统升级底层数据源结构改造时,变更对业务层是透明,域模型可无缝对接,可达到开着飞机换引擎的效果

业务属性字段命名统一、引用唯一

在现在MVC模式开发中,入参model、数据传输model、数据源model 同一个业务属性含义可能有多种不同的命名,引用情况很难直接排除,当丰富某个业务字段值时,很难直接判断对原有业务的影响范围

业务行为Action收口

在原有开发模式下,一个Model类是一个POJO、DTO、DO,仅做数据传输,没有任何业务相关Action,属于典型的贫血模型。在DDD中一个Model就表述一个业务的域(可能是子域),这个Model不仅有属性,还有业务行为Action,并且这个域的所有操作都在这个Model中,这个Model不仅是数据传输的作用也是一个具体的Service,是属于充血模型。开发人员可以通过关注这个域模型就可以cover负责领域的全部,更不会出现大量的复制-粘贴重复代码。

业务操作高内聚、低耦合

所有这个域的操作都内聚在这个Model中,不会存在同一个相同业务行为在多个Service中存在现象。很多时候一个业务行为功能变更,在原有开发模式下需要把所有的service中有这个业务行为的地方都要变更(粘贴复制代码更为严重)

系统更能直观体现业务逻辑

产品是一直在演进的,PRD、技术方案都很难准确的表明现在产品的真实逻辑,很多时候大家都会遇见这种现象,产品经理不确定当前某个业务点的准确逻辑,需要开发阅读代码翻译给产品经理业务逻辑。

铁打的代码、流水的产研,产品经理、开发流动性都很大,新人仅看文档cover整个系统,很难做到。每个业务准确的细节点,还是要看系统代码实际的逻辑规则。

领域驱动架构

领域驱动设计没有特定的架构风格,它的核心是域模型驱动业务的思想,常见的领域驱动设计架构有经典的三层架构、REST架构、事件驱动架构、CQRS架构、六边形架构等。

领域驱动设计优势和劣势

DDD不是银弹,它只是复杂性业务的一种解决方式。DDD解决了系统设计的‘复杂性’,DDD设计思想本身又存在复杂性。

优势

系统演进更方便

随着业务的变化、系统设计也要演进升级。好的架构设计一定演化来的,不是一开始就设计出来的,但系统演进过程中的成本,一定是最开始的设计决定的。一个健康公司的成长,业务横向、纵向会发展的会越来越复杂,支持业务的系统也一定会越来越复杂。在领域驱动设计中,域模型对应的是业务模型,是系统架构的内核,通过域模型来驱动与外界的交互。

业务复杂性变化的演进

域模型可能是简单新增属性或action就能支撑整体的业务发展。企业订餐的业务系统要同时有用户端、运营端、企业端、商户端的数据展示和操作,当业务演进出一个新功能时,这四端系统可能都要同时改造支撑。在领域驱动中,系统的域模型是同一套,只需在领域层进行改造,即可同时支撑四端。

业务数据量变化的演进

公司业务数据量的变化后,现有的架构往往很难支持业务的发展,一定会进行新的技术选型支持业务。在DDD中,域模型为内核,在内核外的一层是代理层,通过这层代理来抽象透明化掉业务模型对系统底层设计的感知。比如原本数据量很小,一个简单的搜索直接使用MySQL like 模糊查询即可满足,在数据量巨大这种方式无法满足的时候,需要使用ES这种专业的搜索技术来实现,这时候仅需要在数据源层把原本指向数据源MySQL改成ES即可,业务代码全程透明无感知,可以达到给正在飞行的飞机换引擎的效果。

更方便测试

对于测试(包含开发自测)来说,流程跑不通是痛苦的,由于IO造成阻塞而非系统逻辑是更痛苦。测试的时候最喜欢的纯函数测试,不依赖任何IO(不包含机器内存层面),DDD设计思想是天然的在代码上把纯函数和普通函数区分开,Repository层是非纯函数,在Repository层Mock掉,整体系统就成了纯函数系统,对测试在Mock数据、切换数据源是非常方便和友好的。

劣势

系统改造成DDD复杂

我们常用的架构基本都是MVC三层架构方式,在常用的MVC三层架构中基本所有的业务逻辑都在service层中,并且是按service功能属性设计的Service层,现在要进行DDD思想开发,需要打破原有的设计,有些严重的还必需要进行重构设计。

团队开发熟悉DDD思想困难

改变自己比较困难,对别人产生影响更加困难。一个开发团队如果之前对DDD都没有了解,要推进和对团队产生影响是一个艰难的过程。

最后

DDD不仅是统一语言、以业务驱动系统设计,在熟悉新业务和系统重构时,领域驱动设计思想更能很好快速梳理业务。如下图领域驱动设计是以领域(业务模型)为核心,通过数据代理层(Repository)来与其他系统交互,来驱动整个系统架构设计。

相关文章

  • DDD的个人看法

    前言 从事技术多年,看了不少代码,写了不少代码,在如何设计一个优秀软件上也跟若干高手们做过各种讨论和pk。在DDD...

  • MarkDown

    ###fff ..aa..... ddd ddd ddd ddd ddddddasdf

  • test1

    *title* dddd ddd ddd ddd

  • 第一节 DDD相关概念

    什么是DDD DDD相关概念 1.1什么是DDD 领域驱动设计,即Domain Driven Design(DDD...

  • 2018-02-09

    # aaa ## aaa ### ddd ddd

  • XML之XPath

    /AAA/AAA/CCC/AAA/DDD/BBB //BBB//DDD/BBB /AAA/CCC/DDD/*/*/...

  • DDD领域驱动设计浅见

    目录 DDD简介DDD是啥DDD能给微服务带来什么不用DDD的常见设计方式DDD整洁架构常见三层架构设计整洁架构D...

  • DDD分层架构的三种模式

    引言 在讨论DDD分层架构的模式之前,我们先一起回顾一下DDD和分层架构的相关知识。 DDD DDD(Domain...

  • 还在搞三层架构?了解下 DDD 分层架构的三种模式吧

    引言 在讨论DDD分层架构的模式之前,我们先一起回顾一下DDD和分层架构的相关知识。 DDD DDD(Domain...

  • DDD在社交网络的实战

    DDD在社交网络的实战 一个没有落地的DDD,不是DDD。如何落地,本文通过一个社交网络的例子展示我们DDD实施的...

网友评论

      本文标题:DDD的个人看法

      本文链接:https://www.haomeiwen.com/subject/bwtichtx.html