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

1. 基于数据库 vs 基于对象
设计上我们通常从两种维度入手:
 

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

通常来说架构师设计 “领域模型” ( 在这里叫 数据模型 ) ,开发人员基于这个领域模型进行开发。
『领域模型』是个潮流名词,如果拉回到 10 几年前,这个模型我们叫 “数据字典” ,说白了,领域模型
就是数据库设计 。
传统项目中,架构师交给开发的一般是一本厚厚的概要设计文档,里面除了密密麻麻的文字就是分好了
域的数据库表设计。言下之意:数据库设计是根本,一切开发围绕着这本数据字典展开,形成类似于下
边的架构图:

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

public class Father { ... }
    public class Son {
         private Long fatherId; // son 表里有 fatherId 作为 Father 表 id 外键
    
         public Long getFatherId() {
            return fatherId;
         }
        ...
}

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

public class XxxManager {
    // 如果逻辑上说不通,大家忍忍 ... 体会体会这个意思
    public void fatherSlapSon(Father father, Son son) {
        // 假设 painOnHand, painOnFace 都是数据库字段
        father.setPainOnHand();
        son.setPainOnFace();
    }
}

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

1.2 Object Model

在聊到 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 的本质,完成以下两个事情:

操作 说明
将对象数据持久化到存储介质中。
取    高效地把数据查询返回到内存中。

由于不再承载领域建模这个特性,数据库设计要做的事情就是尽可能高效存取,而不是完美表达领域模
型 (此言论有点反动,大家看看就好) ,这样我们再看看架构图:

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

2. 失血、贫血、充血

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

2.1 失血模型

基于数据库的领域设计方式其实就是典型的失血模型,以 Java 为例,POJO 只有简单的基于 field 的
setter、getter 方法,POJO 之间的关系隐藏在对象的某些 ID 里,由外面的 manager / service 解释。
简单来说,就是 son “持有” fatherId ,而没有 “持有” father 的引用。需要通过 manager / service 的介
入,从而才能得到 son 的 father ( 哪怕是 son 他自己 ) 。

class Son {
    private Long faterId;
    public Long getFatherId() {
        return this.fatherId;
    }    
}
2.2 贫血模型

贫血模型比失血模型 “强一点” :通过 son 自己就能 “得到” 他的 Father ,而不需要 manager / service的干预。

class Son {
    private Father fater;
    public Father getFather() {
        return this.father;
    }
}

同样,Father 类可以以同样的思路是现成贫血模型:

class Father {
    private Son son;
    public Son getSon() {
        return this.son;
    }
}

贫血模型中,家庭还算完美,父子相认。
但是上面的 Father 类和 Son 类有循环引用问题。另外,考虑到一个对象通常是通过一个 repository (数据库查询) ,或者 factory (内存新建) 得到的:

Son someSon = sonRepo.getById(123);

我们是否可以借助 “持有” repo 对象,从而在 Father 和 Son 这两个类里省略掉一个引用?
修改一下 Father 这个类:

public class Father {
    // private Son son; Son 的引用 “进化” 成了 Son 的 repo
    private SonRepository sonRepo;
    private Son getSon() { // “等” getSon 的时候,再去查询
        return sonRepo.getByFatherId(this.id);
    }
}

这样我们就解决了循环引用问题,但是代价是我们在 Father 这个类里引入了一个 SonRepository ,也就是我们在一个 domain 对象里引用了一个持久化操作,这就是变成了 “充血模型” 。

2.3 充血模型

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

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

其实这就有一点像 “延迟加载” 的思路,直到调用到了  getProducts()  方法,才去查询商店下的所有商品信息,而不是一开始查询商店的时候就查。

3. 依赖注入

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

public class Father {
    private final SonRepository sonRepo;

    // 构造器注入
    public Father(SonRepository sonRepo) {
        this.sonRepo = sonRepo;
    }

    private Son getSon() {
    return sonRepo.getByFatherId(this.id);
    }
}

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

@Component
public class FatherFactory {
    private final SonRepository sonRepo;
    @Autowired
    public FatherFactory(SonRepository sonRepo) { }
    public Father createFather() {
        return new Father(sonRepo);
    }
}

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

4. 测试友好

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

public class Father {
    private final SonRepository sonRepo; // = new SonRepository() 这里不能构造
   
     // 放到构造函数里
    public Father(SonRepository sonRepo) {
        this.sonRepo = sonRepo;
    }

    private Son getSon() {
        return sonRepo.getByFatherId(this.id);
    }
}

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

5. 盒马模式下 repository 的实现方式

在盒马,domain object 是怎么进入到数据库的呢。

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

6. 部署架构

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

7. 总结

在 “贫血模型” 和 “充血模型” 中,作者倾向于使用充血模型。
对于可能会出现的循环引用问题,作者推荐使用domain 持有 Repository的方式解决。
对于测试友好性问题,作者推荐使用由构造工厂构建 domain ,并注入 Repository 的方式解决。
对于复杂变态查询,作者建议绕过 Domain 层,直接与数据库打交道。
在 domain 对象关系明确的情况下,作者推荐大 domain 的做法。

  • 7
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值