速来报名!AICon北京站鸿蒙专场~ 了解详情
写点什么

阿里盒马领域驱动设计实践

  • 2018-01-21
  • 本文字数:6140 字

    阅读完需:约 20 分钟

前言

设计是把双刃剑,没有最好的,也没有更好的,而是条条大路到杭州。同时不设计和过度设计都是有问题的,恰到好处的设计才是我们追求的极致。

DDD(Domain-Driven Design,领域驱动设计)只是一个流派,谈不上压倒性优势,更不是完美无缺。 我更想跟大家分享的是我们是否关注设计本身,不管什么流派的设计,有设计就是好的。

从我看到的代码上来讲,阿里集团内部大部分代码都不属于 DDD 类型,有设计的也不多,更多的像“面条代码”,从端上一条线杀到数据库完成一个操作,仅有的一些设计集中在数据库上。我们依靠强大的测试保证了软件的外部质量(向苦逼的测试们致敬),而内部质量在紧张的项目周期中屡屡得不到重视,陷入日复一日的技术负债中。

一直想写点什么唤起大家的设计意识,但不知道写点什么合适。去年转到盒马,有了更多的机会写代码,可以从无到有去构建一个系统。盒马跟集团大多数业务不同,盒马的业务更面向 B 端,从供应到配送链条,整体性很强,关系复杂,不整理清楚,谁也搞不明白发生什么了。所以这里设计很重要,不设计的代码今天不死也是拖到明天去死,不管我们在盒马待多久,不能给未来的兄弟挖坑啊。在我负责的模块里,我们完整地应用了 DDD 的方式去完成整个系统,其中有我们自己的思考和改变,在这里我想给大家分享一下,他山之石可以攻玉,大家可以借鉴。

领域模型探讨

1. 领域模型设计:基于数据库 vs 基于对象

设计上我们通常从两种维度入手:

  • Data Modeling: 通过数据抽象系统关系,也就是数据库设计
  • Object Modeling: 通过面向对象方式抽象系统关系,也就是面向对象设计大部分架构师都是从 Data Modeling 开始设计软件系统,少部分人通过 Object Modeling 方式开始设计软件系统。这两种建模方式并不互相冲突,都很重要,但从哪个方向开始设计,对系统最终形态有很大的区别。

Data Model

领域模型(在这里叫数据模型)对所有软件从业者来讲都不是一个陌生的名词,一个软件产品的内在质量好坏可能被领域模型清晰与否所决定,好的领域模型可以让产品结构清楚、修改更方便、演进成本更低。

在一个开发团队里,架构师很重要,他决定了软件结构,这个结构决定了软件未来的可读性、可扩展性和可演进性。通常来说架构师设计领域模型,开发人员基于这个领域模型进行开发。“领域模型”是个潮流名词,如果拉回到 10 几年前,这个模型我们叫“数据字典”,说白了,领域模型就是数据库设计

架构师们在需求讨论的过程中不停地演进更新这个数据字典,有些设计师会把这些字典写成 SQL 语句,这些语句形成了产品 / 项目数据库的发育史,就像人类胚胎发育:一个细胞(一个表),多个细胞(多个表),长出尾巴(设计有问题),又把尾巴缩掉(更新设计),最后哇哇落地(上线)。

传统项目中,架构师交给开发的一般是一本厚厚的概要设计文档,里面除了密密麻麻的文字就是分好了域的数据库表设计。言下之意:数据库设计是根本,一切开发围绕着这本数据字典展开,形成类似于下边的架构图:

在 service 层通过我们非常喜欢的 manager 去 manage 大部分的逻辑,POJO(后文失血模型会讲到)作为数据在 manager 手(上帝之手)里不停地变换和组合,service 层在这里是一个巨大的加工工厂(很重的一层),围绕着数据库这份 DNA,完成业务逻辑。

举个不恰当的例子:假如有父亲和儿子这两个表,生成的 POJO 应该是:

复制代码
public class Father{…}
public class Son{
   private String fatherId;//son 表里有 fatherId 作为 Father 表 id 外键
   public String getFatherId(){
       return fatherId;
   }
   ……
}

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

复制代码
public class SomeManager{
   public void fatherSlapSon(Father father, Son son){
       // 如果逻辑上说不通,大家忍忍
       father.setPainOnHand();
       son.setPainOnFace();// 假设 painOnHand, painOnFace 都是数据库字段
   }
}

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

Object Model

2004 年,Eric Evans 发表了《Domain-Driven Design –Tackling Complexity in the Heart of Software》(领域驱动设计),简称 Evans DDD,先在这里给大家推荐这本书,书里对领域驱动做了开创性的理论阐述。

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

没了数据库,领域模型就要基于程序本身来设计了,热爱设计模式的同学们可以在这里大显身手。在面向过程、面向函数、面向对象的编程语言中,面向对象无疑是领域建模最佳方式

类与表有点像,但不少人认为表和类就是对应的,行 row 和对象 object 就是对应的,我个人强烈不认同这种等同关系,这种认知直接导致了软件设计变得没有意义。

类和表有以下几个显著区别,这些区别对领域建模的表达丰富度有显著的差别,有了封装、继承和多态,我们对领域模型的表达要生动得多,对 SOLID 原则的遵守也会严谨很多:

  • 引用:关系数据库表表示多对多的关系是用第三张表来实现,这个领域模型表示不具象化, 业务同学看不懂。
  • 封装:类可以设计方法,数据并不能完整地表达领域模型,数据表可以知道一个人的三维,但并不知道“一个人是可以跑的”。
  • 继承、多态:类可以多态,数据上无法识别人与猪除了三维数据还有行为的区别,数据表不知道“一个人跑起来和一头猪跑起来是不一样的”。

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

复制代码
public class Father{
   // 教训儿子是自己的事情,并不需要别人帮忙,上帝也不行
   public void slapSon(Son son){
       this.setPainOnHand();
       son.setPainOnFace();
   }
}

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

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

  • :将对象数据持久化到存储介质中。
  • :高效地把数据查询返回到内存中。

由于不再承载领域建模这个特性,数据库的设计可以变得天马行空,任何可以加速存储和搜索的手段都可以用上,我们可以用 column 数据库,可以用 document 数据库,可以设计非常精巧的中间表去完成大数据的查询。总之数据库设计要做的事情就是尽可能高效存取,而不是完美表达领域模型(此言论有点反动,大家看看就好),这样我们再看看架构图:

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

  • 领域模型是用于领域操作的,当然也可以用于查询(read),不过这个查询是有代价的。在这个前提下,一个 aggregate 可能内含了若干数据,这些数据除了类似于 getById 这种方式,不适用多样化查询(query),领域驱动设计也不是为多样化查询设计的。
  • 查询是基于数据库的,所有的复杂变态查询其实都应该绕过 Domain 层,直接与数据库打交道。
  • 再精简一下:领域操作 ->objects,数据查询 ->table rows

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

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

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

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

复制代码
public class Son{
   private Father father;
   public Father getFather(){return this.father;}
}

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

复制代码
public class Father{
   private Son son;
   private Son getSon(){return this.son;}
}

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

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

复制代码
public class Father{
   //private Son son; 删除这个引用
   private SonRepository sonRepo;// 添加一个 Son 的 repo
   private getSon(){return sonRepo.getByFatherId(this.id);}
}

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

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

复制代码
public class Shop{
   //private List<Product> products; 这个商品列表在构建时太大了
   private ProductRepository productRepo;
   public List<Product> getProducts(){
       //return this.products;
       return productRepo.getShopProducts(this.id);
   }
}

3. 领域模型:依赖注入

简单说一说依赖注入:

  • 依赖注入在 runtime 是一个 singleton 对象,只有在 spring 扫描范围内的对象(@Component)才能通过 annotation(@Autowired)用上依赖注入,通过 new 出来的对象是无法通过 annotation 得到注入的。
  • 个人推荐构造器依赖注入,这种情况下测试友好,对象构造完整性好,显式地告诉你必须 mock/stub 哪个对象。

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

复制代码
public class Father{
   private SonRepository sonRepo;
   private Son getSon(){return sonRepo.getByFatherId(this.id);}
   public Father(SonRepository sonRepo){this.sonRepo = sonRepo;}
}

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

复制代码
@Component
public class FatherFactory{
   private SonRepository sonRepo;
   @Autowired
   public FatherFactory(SonRepository sonRepo){}
   public Father createFather(){
       return new Father(sonRepo);
   }
}

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

4. 领域模型:测试友好

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

复制代码
public class Father{
   private SonRepository sonRepo;//=new SonRepository() 这里不能构造
   private getSon(){return sonRepo.getByFatherId(this.id);}
   // 放到构造函数里
   public Father(SonRepository sonRepo){this.sonRepo = sonRepo;}
}

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

5. 领域模型:盒马模式下 repository 的实现方式

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

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

6. 领域模型:部署架构

盒马业务具有很强的整体性:从供应商采购,到商品快递到用户手上,对象之间关系是比较明确的,原则上可以采用一个大而全的领域模型,也可以运用 boundedContext 方式拆分子域,并在交接处处理好数据传送,这里引用老马的一幅图:

我个人倾向于大 domain 的做法,我倾向(所以实际情况不是这样的)的部署结构是:

结语

盒马在架构设计上还在做更多的探索,在 2B+ 互联网的崭新业务模式下,有很多可以深入探讨的细节。DDD 在盒马已经迈出了坚实的第一步,并且在业务扩展性和系统稳定性上经受了实战的考验。基于互联网分布式的工作流引擎(Noble),完全互联网的图形绘制引擎(Ivy)都在精心打磨中,期待在未来的就几个月里,盒马工程师们给大家奉献更多的设计作品。

作者介绍

张群辉,阿里盒马架构总监。10 多年技术及管理实战经验,前阿里基础机构事业部工程效率总监,长期在一线指导大型复杂系统的架构设计。DevOps、微服务架构及领域驱动设计国内最早的实践者一员。崇尚实践出真知,一直奋斗在技术一线。

感谢雨多田光对本文的策划和审校。

2018-01-21 17:0058579

评论 21 条评论

发布
用户头像
在你的FatherFactory里Father的构造就已经充满Bad smell了
2021-11-18 10:19
回复
为什么?
2021-11-18 14:27
回复
为什么这么说?
2022-05-04 22:37
回复
用户头像
面向领域设计从另一个角度来说其实就是面向 service 层设计?毕竟只是把 service 层的工作转移到领域模型了。
2021-03-17 17:23
回复
用户头像
看了文章很有收获,但是有点疑惑,如果邻域对象的构建都需要在类中持有一个持久层,通过构造参数传入,我们现在就是这种,感觉还是太重了,在创建这个领域对象的地方都要传入,而有些领域方法不需要这些持久化的动作。
2019-11-12 10:59
回复
感觉最好不要在领域模型中加入持久层,上边的列子中,如果用的是MyBatis,FatherRepository中引入FatherMapper和SonMapper,就可以获取Father和Son,SonRepository也引入FatherMapper和SonMapper也可以解决同样的问题。这里的Mapper是持久层类。
2021-02-09 15:45
回复
这会不会把逻辑又下沉到Repository 中了
2021-08-12 23:38
回复
用户头像
这篇文章关于贫血和充血的描述是错误的,没人看出来吗?
贫血和充血后面还有两个字--模型。
2019-09-24 17:59
回复
????
2020-08-27 16:02
回复
我也没看明白他这里讲的贫血模型、充血模型
2021-04-07 00:13
回复
用户头像
谢谢分享,最近一个电商项目,正在实践DDD,希望有机会多交流
2019-09-06 16:29
回复
用户头像
我也在实践,发现 entity中都需要注入 repository,今天看到此文终于发现不是我一个人这么干。
而且调用 domain.xxcommand()时,需要进行存储,因为不好跟踪 domain对象的变化状态(mybatis,不是hibernate)。
2019-09-02 11:54
回复
用户头像
06年开始实践DDD,spring1开始,我的思路是RepoFactory,而不是EntityFactory,用hibernate的话,domain全局可以获得repository。spring是DDD的天然敌人,教导的那种模式,无状态的service随着时间推移越来越臃肿丑陋,最完美的方案是再包装一个容器,加载entity注入repository
2019-05-22 07:52
回复
spring 是 ddd的天然敌人,怎么理解?
2019-09-02 11:55
回复
Spring这种controller-service-repo天生就是贫血模型,就不是面向对象的,你没看这篇文章都要先搞个factory才能搞进去spring吗
2023-01-03 11:36 · 浙江
回复
确实提供了一个不错的思路,RepoFactory注入自身,再ApplicationContext获取RepoFactory的Instance
2023-02-27 14:54 · 广东
回复
用户头像
好文,隔段时间来看又有新的收获。
2019-03-21 17:55
回复
用户头像
我最近在想一个问题:DDD显然是基于面向对象的设计的。而目前大热的 AWS Lambda 是基于EDA和函数,看样子是要和面向对象说再见了。对此,作者有何看法?
2018-12-21 14:27
回复
个人理解:数据处理方向的函数式编程,复杂业务系统的DDD
2019-04-11 15:54
回复
同意
2019-09-06 16:26
回复
用户头像
学习了, 文档应该没有反应出实际项目的结构,也许是哥想做的事情,在这里勾勒出来了。哈哈
2018-11-08 14:50
回复
没有更多了
发现更多内容

《DDD with TLA+》(4) Transaction Commit

陈皓07

Elasticsearch Fetch Phase

escray

elastic 七日更 28天写作 死磕Elasticsearch 60天通过Elastic认证考试

元宵节元宵钱,不买元宵买云资源! | 2核4G低至0.79元/天

京东科技开发者

云主机 云服务器 云存储 云硬盘

构建一套适合微服务的高可用架构

环信

QA视角看数据匿名化

BY林子

数据安全 测试右移 用户数据 数据脱敏

《DDD with TLA+》(3) DEBUG & MODELING

陈皓07

GaussDB(DWS):非侵入式备份及其在NBU上的应用

华为云开发者联盟

架构 GaussDB 集群 备份 NBU

搭建一个 802.1x 的 web 测试服务

冯骐

网络 监控系统 Open-Falcon radius eduroam

这段时间的记录有点太水了

Nydia

Wiki.js 配置 LDAP 认证

东风微鸣

wiki

刷屏洗脑的“吗咿呀嘿”,到底是个啥?

架构精进之路

商业模式 28天写作 3月日更

与前端训练营的日子 -- Week17

SamGo

学习

如何写好一份解决方案

数列科技杨德华

28天写作

树莓派上的家庭监控中心

冯骐

运维 树莓派 监控系统 Open-Falcon 物联网,

工作两三年了,整不明白架构图都画啥?

小傅哥

Java 后端 小傅哥 架构设计 画架构图

架构师训练营第九周作业 - 命题作业

阿德儿

海豚调度dolphinscheduler SQL脚本初始化流程

cloudcoder

海豚调度 调度引擎 分布式任务调度

android布局优化!Android屏幕适配很难嘛?其实也就那么回事,内含福利

欢喜学安卓

android 程序员 面试 移动开发

android程序开发!2021Android精选面试实战总结整理,大厂直通车!

欢喜学安卓

android 程序员 面试 移动开发

MySQL字段默认值设置详解

Simon

MySQL 数据库

树莓派上的温湿度环境监控

冯骐

运维 树莓派 物联网 监控告警

MongoDB 在评论中台的实践

vivo互联网技术

数据库 mongodb 分布式 集群

《A Tour of TLA+》

陈皓07

无利不起早——聊聊学习动机

Justin

心理学 激励 28天写作 游戏设计

华为云举办AI经典论文复现活动,打造领先AI开发者学习社区

华为云开发者联盟

AI 华为云 modelarts 论文 AI Gallery

华为云原生数据仓库GaussDB(DWS)深度技术解读:融、快、大、稳、易

华为云开发者联盟

数据库 云原生 华为云 GaussDB 数仓

看完你就明白什么是图神经网络

华为云开发者联盟

神经网络 深度学习 节点 图神经网络 图结构

程序员专属“灯谜”大挑战,答对六题算你赢!

京东科技开发者

编程语言 集群

产品训练营 - 作业 5

简小一

更新啦!第 59 期《HelloGitHub》开源月刊

HelloGitHub

GitHub 开源

Linux入门篇 —— Linux 磁盘管理之磁盘理论篇

若尘

Linux linux编程 磁盘

阿里盒马领域驱动设计实践_阿里巴巴_张群辉_InfoQ精选文章