数据模型和领域模型
数据模型比较好理解,我们常用 E-R 图来表示数据模型。数据模型表示存下来的实体的结构和关系,例如数据库里面的表 (实体结构)和外键 (关系)是数据模型的一种实现方式。
领域模型则是业务上面使用的模型,表示的也是业务对象的结构和对象之间的关系,但它和技术无关,只和领域有关,意味着不一定是程序员,只要对领域了解的人也能看得懂。领域模型是面向对象的,可以通过类图来表示,支持对象间的多种关系, 例如关联,组合,聚合,实现,继承等。
领域模型最好的实现方式就是通过内存实现了,不用管存的事情。我们程序员的价值之一就是要通过和领域专家沟通挖掘出领域模型。<分析模型> 里面有讲到,表面上是用杆撞击白球,白球就会沿轨迹撞击其他球或者到了边缘反弹。但如果是程序员,则需要了解球是怎么运动的,加速度怎么变化,撞击后力的传递是如何的。这就是领域模型,有了领域模型才能更容易应对用例的变化。
程序员除了挖掘出领域模型,还要把这个领域模型实现了,并基于这个领域模型去完成业务用例。用内存实现是不太可能的,因为内存有限,并且不可持久化,因此需要用其他技术存下来。最常用的就是 mysql 这种关系型数据库,关系型数据库有自己的约束,例如不支持继承,不支持数组,不支持直接的对象关联,有自己一套的设计方法,例如范式的设计,sql 的编写和优化等等。基于存储方式来设计的模型就是数据模型。
数据模型可以表示领域模型
领域模型和数据模型的关系是先设计出领域模型,再通过考虑如何存储领域模型而设计出数据模型。而现在常常为了减少模型转换而用数据模型替代领域模型,导致面向对象被异化成了面向过程,同时设计也受制于数据模型。
在前一篇文章中介绍了DDD 聚合的作用,里面没有用到聚合的例子就是直接用数据模型来替代领域模型。DDD 中的聚合是新东西吗? 有什么用?
java
@Data
public class Device {
private String deviceId;
private String deviceName;
private String schoolId;
private String bindTime;
}
使用数据模型来替代领域模型的问题在于
- 数据模型会导致更多的平铺属性,而非抽取出更多对象,导致可维护性降低。例如上面的 schoolId 和 bindTime 可以单独设计成值对象,但映射到数据库只能是通过另一个表或者 json来存,为了查询性能一般也不会弄太多表,如果是json 则需要上层业务自己序列化。
- 数据模型关心性能和效率,会适当冗余数据,增加缓存,冷热属性分离等,从而导致领域模型变复杂。
- 领域模型拓展困难,例如绑定的逻辑越发复杂,从领域模型上需要拆成两个聚合,设备和设备绑定。但因为数据库变更涉及到数据迁移,会更困难,导致聚合难以拆分。
当设计成聚合,也就是领域模型之后
java
@Getter
public class Device {
private String deviceId;
private String deviceName;
private DeviceBindInfo bindingInfo;
public void updateDeviceName(String changeDeviceName) {
if (changeDeviceName.length() > 10) {
throw new RuntimeException("设备名长度过长");
}
if (deviceName.equals(changeDeviceName)) {
throw new RuntimeException("设备名没变");
}
deviceName = changeDeviceName;
}
public void bindSchool(String bindSchoolId) {
if (bindingInfo != null) {
throw new RuntimeException("设备已绑定学校");
}
bindingInfo = DeviceBindInfo.bind(bindSchoolId);
}
}
@Getter
public class DeviceBindInfo {
private String schoolId;
private LocalDateTime bindTime;
private DeviceBindInfo(String schoolId, LocalDateTime bindTime) {
this.schoolId = schoolId;
this.bindTime = bindTime;
}
public static DeviceBindInfo bind(String schoolId) {
return new DeviceBindInfo(schoolId, LocalDateTime.now());
}
}
- 对象更加有层次,更清晰。
- 领域模型着重考虑如何更好的表达业务,无需考虑性能问题。
- 领域模型和数据模型各自演进,领域模型可以走的更快,只需要中间加转换器即可。
因此强烈建议领域模型和数据模型分开。在业务开始的时候可能领域模型和数据模型差异并不大,但经过几次迭代之后差异会越来越大。不分开会被数据模型拖着走,代码很难写好,也很难重构。
领域模型的实现方式
- 用数据模型来实现领域模型之外,这种前面提了并不推荐。小项目或者临时的项目可以使用。
- 领域模型和数据模型动态转换。例如使用 hibernate 框架。代码上面是领域模型,但可以使用各种注解,让领域模型可以通过配置转换成数据模型。对 hibernate 这类框架比较熟悉,领域模型比较简单的可以用。但使用门槛会比较高。
- 在仓储层手动进行模型转换。推荐使用这种。虽然代码会更多一些,相对方案2 的转换没那么清晰,但使用简单,不管多复杂的转换都能轻松拿捏。领域模型查询和保存都是基于聚合,因此大部分场景只需要 2个方法做相互转换即可,代码不会多太多。如果担心让仓储实现变复杂,可以增加模型转换器工具类。在分层模型中,仓储是用来隔离业务代码和技术实现的,数据模型就是一种技术实现,不管数据模型如何改变,从关系型变非关系型,都不影响上层业务。
性能如何解决
对于复杂的查询一般都需要结合几个聚合的数据,但如果都要整个聚合拿出来,性能肯定受影响。但又希望能充分利用面向对象的优势。有 2个方案。
- 懒加载。类似 hibernate 的懒加载,拿到的聚合的属性并非完全填充了,而是在 getX/setX 的方法做了代理,在调用 getX/setX 的方法的时候再去调数据库。这种方案缺点是要用好门槛较高,而且并不容易能优化到理想的性能。
- CQRS 命令查询职责分离。
CQRS 命令查询职责分离
分离的原因在于命令和查询的要求不同
- 复杂度:命令的业务流程会比较复杂,例如下单,涉及到非常多聚合的行为和交互,容易出问题。而读一般就是 sql 的优化和数据的组装,逻辑不算特别复杂。
- 重构难度:要对命令进行重构比较困难,因为命令涉及到多个状态的修改,只要一个状态改错了,就会产生脏数据。而查询的重构相对比较简单,只要查询出的数据和之前查的是一样的就可以了,就算有问题也不产生脏数据。
- 性能:一般系统读多写少,命令的性能因为 qps较低,所以不需要做太多考虑。而读的 qps 一般会比较高,需要优化sql,增加冗余的列或者加缓存等等。
基于上面的考虑,我们期望命令涉及更偏可维护性,而查询则更偏向于性能。CQRS 和 CQS 的区别是多了个R,这个 Responsibility 我理解就是模型的意思,命令和查询是两个模型。刚好领域模型就适合命令,而数据模型则适合查询。
总结
领域模型和数据模型的演进方向和进度都不同,还是分开更好。领域模型和数据模型的转换用最简单的代码转换效果最好。性能问题可以使用 CQRS 解决。