今天,我们有幸邀请到盒马资深技术专家辉子,与我们分享他对领域驱动设计及实践经验的见解。在过去的多年中,辉子一直从事技术工作,阅读了大量的代码,也编写了许多代码。他曾与许多技术专家进行过关于如何设计优秀软件的讨论和辩论。在领域驱动设计(DDD)的理念方面,各位专家的观点也各不相同。
DDD只是一种流派,并没有压倒性的优势,也不是完美无缺的。辉子更关注的是我们是否注重设计本身,无论是哪种流派的设计,只要有设计就是好的。根据我所见的代码,大部分代码都不属于DDD类型,有设计的也不多,更多的像是“面条代码”,从前端一直到数据库,完成一个操作。设计主要集中在数据库上(有时候甚至没有数据库设计,只是一堆字段),而代码更多是自我修养。我们依靠强大的测试来保证软件的外部质量(向测试人员致敬),但在紧张的项目周期中,内部质量往往被忽视,导致技术债务不断累积。
盒马的业务主要面向B端,从供应到配送的整个供应链非常复杂,如果不进行清晰的整理,谁也无法理解发生了什么。因此,在这里设计非常重要,不要给未来的团队成员挖坑。在我负责的模块中,我们完全采用了DDD的方式来完成整个系统,其中包含了我们自己的思考和改进。在这里,我想与大家分享一下我们的经验,因为他山之石可以攻玉。
首先,让我们讨论领域模型的设计。在设计上,我们通常从两个维度入手:
a. 数据建模:通过数据抽象系统关系,也就是数据库设计。
b. 对象建模:通过面向对象方式抽象系统关系,也就是面向对象设计。
大多数架构师都是从数据建模开始设计软件系统的,只有少数人通过对象建模的方式来设计软件系统。这两种建模方式并不冲突,都非常重要,但从哪个方向开始设计会对系统的最终形态产生很大影响。
在数据建模中,领域模型对于所有从事软件开发的人来说都不陌生。一个软件产品的内在质量很大程度上取决于领域模型的清晰程度。良好的领域模型可以使产品结构清晰,修改更方便,演进成本更低。在一个开发团队中,架构师的角色非常重要,他们决定了软件的结构,而这个结构决定了软件的可读性、可扩展性和可演进性。通常情况下,架构师负责设计领域模型,开发人员基于这个领域模型进行开发。”领域模型”是一个时髦的术语,如果回到十几年前,我们称之为”数据字典”。换句话说,领域模型就是数据库设计。
在需求讨论的过程中,架构师会不断演进和更新这个数据字典。有些设计师会将这些字典写成SQL语句,这些语句形成了产品/项目数据库的发展历史,就像人类胚胎的发育过程:一个细胞(一个表),多个细胞(多个表),长出尾巴(设计问题),又缩掉尾巴(更新设计),最后诞生(上线)。在传统项目中,架构师通常会给开发人员交付一份厚厚的概要设计文档,里面除了大量的文字就是经过良好分区的数据库表设计。言下之意是:数据库设计是根本,所有开发都围绕着这个数据字典展开,形成了类似下面的架构图:
在service层,我们喜欢使用manager来管理大部分的逻辑,POJO(稍后章节中的失血模型)作为数据在manager手中不断变换和组合,service层在这里起到了一个巨大的加工工厂的作用(非常重要),围绕着数据库这个DNA完成业务逻辑。举个不太恰当的例子:假设有一个父亲和一个儿子的表,生成的POJO应该是:
当儿子犯了错误,父亲非常生气,给了儿子一个耳光,父亲手疼,儿子脸疼。Manager通常会这样处理:
在这里,manager充当了上帝的角色,甚至帮助扇耳光。这就是我们所说的失血模型。
2004年,Eric Evans发表了《Domain-Driven Design –Tackling Complexity in the Heart of Software》(领域驱动设计),简称Evans DDD。我在谈论DDD时经常会做一个假设:假设你的机器内存无限大,永远不会宕机,在这个前提下,我们不需要持久化数据,也就是说我们可以不需要数据库,那么你会如何设计你的软件?这就是我们所说的持久化无关设计。没有了数据库,领域模型就必须基于程序本身进行设计,对于热爱设计模式的同学们来说,这是一个展示自己才华的好机会。在面向过程、面向函数和面向对象的编程语言中,面向对象无疑是领域建模的最佳方式。类和表有一些相似之处(很多人认为表和类是一一对应的,行和对象是一一对应的),但我个人强烈不同意这种等同关系,这种认知导致了软件设计变得毫无意义。类和表有以下几个显著区别,这些区别对于丰富领域模型的表达能力有着显著影响,封装、继承和多态使得我们对领域模型的表达更加生动,也更加符合SOLID原则。再回到父亲扇儿子的例子:
根据这种思路,我们在面向对象的世界中设计了栩栩如生的领域模型,service层基于这些模型进行业务操作(变得更薄了,很多动作交给了domain objects来处理):领域模型本身并不完成业务,每个domain object都完成自己应有的行为(单一职责),就像人跑步这个动作,person.run是一个与业务无关的行为,但是当manager或service调用some person.run时,可能完成的是100米比赛这个业务,也可能是跑去送外卖这个业务。这样,我们的架构图变成了这样:
现在,让我们回到假设,假设你的机器内存无限大,永远不会宕机,现在去掉这个假设,我们需要数据库,但是数据库的职责不再是承载领域模型这个沉重的负担,数据库回归到持久化的本质,完成以下两个任务:
【存】将对象数据持久化到存储介质中
【取】高效地将数据查询返回到内存中
由于不再承载领域建模的特性,数据库的设计可以变得非常灵活,可以采用任何可以加速存储和搜索的手段,我们可以使用列数据库,可以使用文档数据库,可以设计非常精巧的中间表来完成大数据查询。总之,数据库设计的目标是尽可能高效地存取数据,而不是完美地表达领域模型(这个说法有点激进,大家可以理解就好),这样我们的架构图变成了这样:
在这里,我想强调一下:
- 领域模型:失血、贫血、充血模型
失血、贫血、充血和胀血模型是Martin Fowler提出的概念,用于描述基于领域模型的丰满程度,有点像”瘦、中等、健壮、胖”。在这里,我们不讨论”胀血”模型。失血模型是基于数据库的领域设计方式,实际上是典型的失血模型。以Java为例,POJO只有简单的基于字段的setter和getter方法,POJO之间的关系隐藏在对象的某些ID中,由外部的manager解释,例如son.fatherId,Son并不知道它与Father有关系,但是manager可以通过son.fatherId获取一个Father对象。
贫血模型:在盒马流程中心,儿子不知道自己的父亲是谁是不对的,不能每次都通过中间机构(Manager)的验DNA(son.fatherId)来找爸爸。领域模型可以更加丰富一些,对son这个类进行修改:
现在,son这个类变得更加丰富了,但是还有一个小小的不便之处,就是通过father无法获取son(父亲怎么可以不知道儿子是谁呢),所以我们再给Father添加这个属性:
现在,看起来这两个类更加丰满了,这就是我们所说的贫血模型,在这个模型下,家庭关系变得完美,父子相认。然而,仔细研究这两个类,我们会发现一个问题:通常情况下,一个对象是通过一个repository(数据库查询)或者factory(内存创建)得到的:
这个方法可以从数据库中获取一个son对象,为了构建完整的son对象,sonRepo需要一个fatherRepo来构建一个father对象并赋值给son.father。而fatherRepo在构建完整的father对象时又需要sonRepo来构建一个son对象并赋值给father.son。这形成了一个无向有环的循环调用问题,虽然这个问题是可以解决的,但为了解决这个问题,领域模型会变得有些复杂和牵强。有向无环才是我们的设计目标,为了避免这个循环调用,我们是否可以在father和son这两个类中省略一个引用?修改一下Father这个类:
这样,在构造Father对象时就不会再构造一个Son对象了,但代价是我们在Father这个类中引入了一个SonRepository,也就是在一个domain对象中引用了一个持久化操作,这就是我们所说的充血模型。
- 领域模型下的依赖注入
简单地说一下依赖注入:依赖注入后,我们再来看刚才的充血模型。在创建一个Father对象时,需要给它赋值一个SonRepository,这在编写代码时非常麻烦。那么,我们是否希望可以通过依赖注入的方式将SonRepository注入进去呢?Father在这里不可能是一个单例对象,它可能在两个场景下被new出来:新建和查询。从Father的构造过程中,SonRepository是无法注入的。这时,工厂模式就显示出了它的意义(很多人认为工厂模式只是摆设):
由于FatherFactory是系统生成的单例对象,SonRepository自然可以注入到Factory中,newFather方法隐藏了这个注入的sonRepo,这样创建Father对象就变得干净了。
- 领域模型:测试友好
失血模型和贫血模型天然就是测试友好的(实际上失血模型也没有什么好测试的),因为它们都是纯内存对象。但在实际应用中,充血模型是存在的,要么就是将domain对象拆散,变得稍微不那么优雅(当然可以,贫血和充血的战争从来没有停止过)。在充血模型下,对象带有持久化特性,这就对数据库有了依赖,对这些依赖进行mock/stub是高效单元测试的基本要求。让我们再看一下Father这个例子:
通过将SonRepository放入构造函数中,可以使测试更加友好,通过mock/stub这个Repository,可以顺利完成单元测试。
- 领域模型:盒马模式下repository的实现方式
在盒马,我们独特地设计了Tunnel这个接口,通过这个接口,我们可以在不同类型的数据库中实现对domain对象的存取。Repository并不直接进行持久化工作,而是将domain对象转换为POJO,并交给Tunnel进行持久化工作。Tunnel的具体实现可以在任何包中实现,这样,领域模型(domain objects+repositories)和持久化(Tunnels)完全分离,domain包成为纯粹的内存对象集合。
- 领域模型下的部署架构
盒马的业务具有很强的整体性:从供应商采购到商品快递到用户手中,对象之间的关系相对明确。原则上,可以采用一个大而全的领域模型,也可以使用boundedContext的方式将其拆分为子域,并在交接处处理好数据传输。这里引用Martin Fowler的一张图:
我个人倾向于使用大的domain模型,我倾向于(实际情况并非如此)的部署结构是:
在结束之前
盒马在架构设计方面仍在不断探索,在2B+互联网的全新业务模式下,有许多细节值得深入探讨。在盒马,DDD已经迈出了坚实的第一步,并且在业务扩展性和系统稳定性方面经受了实战的考验。基于互联网分布式的工作流引擎(Noble)和完全互联网的图形绘制引擎(Ivy)正在精心打磨中。期待未来,盒马的工程师们能够为大家带来更多的设计作品。
每天一篇技术文章,还不过瘾?