在 DDD 中,Repository 是一个非常重要的概念,它是领域层的一个组件,用来管理聚合根的生命周期和持久化。
DDD 是由领域对象承载业务逻辑,所有的业务操作均在模型对象上完成,同一聚合上不同的业务操作构成了聚合的生命周期。
我们以订单为例,如下图所示:
整个流程很好的体现了 Order 聚合根的生命周期。
假设,有一台非常牛逼的计算机,计算资源无限、内存大小无限、永不掉电、永不宕机,那最简单高效的方式便是将模型对象全部放在内存中。
但,现实不存在这样的机器,我们不得不将内存对象写入磁盘,下次使用时,在将其从磁盘读入到内存。
整体结构如下图所示:
和上图相比,具有如下特点:
相对全内存版本确实增加了不小的复杂性,为了更好的对这些复杂性进行管理,引入 Repository 模式。
在领域驱动设计(DDD)中,Repository 是一种设计模式,它是用来存储领域对象的容器。它提供了一种统一的方式来查询和存储领域对象。Repository提供了对底层数据存储的抽象,允许应用程序在没有直接与数据存储技术交互的情况下访问数据,同时该抽象允许在不修改应用程序代码的情况下更改数据存储技术。
【注】在 DDD 中,Repository 并不是一个 DAO,它的职责比 DAO 要多得多,它管理的是整个聚合根,而不是单个实体对象。同时,Repository 还需要提供一些查询接口,用来查询聚合根的状态。
Repository 主要用于完成对聚合根生命周期的管理,所以必须提供三组操作:
有人会说,这和 DAO 没啥区别吧!!!
DAO 是单表单实体操作,Repository 操作的是整个聚合甚至包括继承关系,这就是最大的区别。也就是Repository 必须能够:
既支持组合又支持继承,DAO 就没办法更好的承载了。
那就完了吗?并没有!!!
聚合根是一个对象组,包含各种关系,并不是每个业务操作都需要聚合根内的所有实体。
举个例子,在电商订单聚合根内,包括:
在改价流程里,需要修改 Order、OrderItem、Pay 三组实体。
在更新地址流程里,仅需要修改 Address 和 Order 两组实体。
为了满足不同的业务场景,Repository 需要具备两个高级特性:
总体来说,能够具备以下特性的 Repository 才是好的 Repository:
综合调研各类 ORM 框架,只有 JPA 具备上述特性,而且和 DDD 是绝配。
组合是一种面向对象编程的重要概念,指一个类的对象可以将其他类的对象作为自己的组成部分。组合在DDD中使用场景最为广泛,这也是聚合的主要工作方式。也就是将一组对象保存到存储引擎,然后在从存储引擎中获取完整的对象组。
从数据视角,组合关系存在两个维度:
两者组合,情况更加复杂,会产生:
聚合根是一组对象访问的入口,聚合内的所有操作都必须通过聚合根进行,所以,聚合根于其他实体的关系只能是 一对多 和 一对一;同时,所有的业务操作都是从聚合根发起,通过聚合根能关联到内部实体即可,因此也不存在双向。综上所述,DDD 对组合进行了大量简化,实际工作中主要涉及:
通过外键的方式实现单向一对一关系,需要在主表中添加一个指向另一个表的外键,通过外键信息获取关联数据。
实体如下:
// 聚合根实现
@Entity
@Table(name = "order_info")
public class Order{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 增加 @.NEToOne 注解
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private Pay pay;
// 增加 @OneToOne 注解
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private Address address;
// 忽略其他属性
}
// Pay 实体实现
@Entity
@Table(name = "pay_info")
public class Pay {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 忽略其他属性
}
// Address 实现
@Entity
@Table(name = "address")
public class Address {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 忽略其他属性
}
插入记录后,order_Infor 表数据如下
其中:
可见,执行时先插入 address 和 pay 获取主键后,在插入到 order_info 表,从而维护外键的有效性。
实体定义如下:
// 聚合根实体
@Entity
@Table(name = "order_info")
public class Order{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 添加 @OneToMany 注解
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
// 指定多端的关联列(如果不指定,会使用第三张表来保存关系
@JoinColumn(name = "order_id")
private List<OrderItem> orderItems = new ArrayList<>();
// 忽略其他属性
}
// OrderItem 实现
@Entity
@Table(name = "order_item")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 忽略其他属性
}
插入记录后,表数据如下:
order 表数据:
order+item表数据:
其中 order_item 表中的 order_id 指向 order_info 表的主键。
继承是面向对象编程的核心特性,但这一特性确与数据库的关系模型产生巨大阻抗。
JPA 中提供了三种继承模型,包括:
为了更好的对比各种策略,我们以一个业务场景为案例进行分析。
在优惠计算过程中,需要根据不同的配置策略对当前用户进行验证,以判断用户是否能够享受优惠,常见的验证策略有:
为了保障系统有良好的扩展性,引入策略模式,整体设计如下:
那接下来便是将这些实现类存储到数据库,然后在方便的查询出来。
单表继承非常简单,也最为实用,数据库表只有一张,通过一列辨别字段来区别不同类别的实体。
它的使用涉及几个关键注解:
相关实体代码如下:
// 父类
@Entity
// 单表表名
@Table(name = "activity_matcher")
// 当前策略为单表策略
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
// activity_type 列用于存储对应的类型
@DiscriminatorColumn(name = "activity_type")
@Data
public abstract class BaseActivityMatcher implements ActivityMatcher {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private ActivityMatcherStatus status = ActivityMatcherStatus.ENABLE;
}
// SpecifyUserMatcher 实现类
@Entity
// 使用 SpecifyUser 作为标记
@DiscriminatorValue("SpecifyUser")
public class SpecifyUserMatcher
extends BaseActivityMatcher
implements ActivityMatcher {
// 省略属性和方法
}
// SexMatcher 实现类
@Entity
// 使用 Sex 作为标记
@DiscriminatorValue("Sex")
public class SexMatcher
extends BaseActivityMatcher
implements ActivityMatcher {
// 省略属性和方法
}
@Entity
// 使用 VipLevel 作为标记
@DiscriminatorValue("VipLevel")
public class VipLevelMatcher
extends BaseActivityMatcher
implements ActivityMatcher {
// 省略属性和方法
}
每种策略保存一条数据后,数据库表activity_matcher数据如下图所示:
其中:
单表继承策略,最大的优点便是简单,但由于父类实体和子类实体共用一张表,因此表中会有很多空字段,造成浪费。
Joined策略,父类实体和子类实体分别对应数据库中不同的表,子类实体的表中只存在其扩展的特殊属性,父类的公共属性保存在父类实体映射表中。
它的使用涉及几个关键注解:
相关实体代码如下:
// 父类
@Entity
@Table(name = "activity_joined_matcher")
// 当前策略为Joined策略
@Inheritance(strategy = InheritanceType.JOINED)
@Data
public abstract class BaseActivityMatcher implements ActivityMatcher {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private ActivityMatcherStatus status = ActivityMatcherStatus.ENABLE;
}
// SpecifyUserMatcher 实现类
@Entity
@Table(name = "user_joined_matcher")
@PrimaryKeyJoinColumn(name = "matcher_id")
public class SpecifyUserMatcher
extends BaseActivityMatcher
implements ActivityMatcher {
// 省略属性和方法
}
// SexMatcher 实现类
@Entity(name = "JoinedSexMatcher")
@Table(name = "sex_joined_matcher")
@PrimaryKeyJoinColumn(name = "matcher_id")
public class SexMatcher
extends BaseActivityMatcher
implements ActivityMatcher {
// 省略属性和方法
}
// VipLevelMatcher 实现类
@Entity(name = "JoinedVipLevelMatcher")
@Table(name = "vip_joined_matcher")
@PrimaryKeyJoinColumn(name = "matcher_id")
public class VipLevelMatcher
extends BaseActivityMatcher
implements ActivityMatcher {
// 省略属性和方法
}
每种策略保存一条数据后,各个表数据如下:
activity_joined_matcher 如下:
user_joined_matcher 如下:
sex_joined_matcher 如下:
vip_joined_matcher 如下:
具有以下特点:
从表数据上可以看出,Joined策略可以减少冗余的空字段,但是查询时需要多表连接,效率较低。
TABLE_PER_CLASS 策略,父类实体和子类实体每个类分别对应一张数据库中的表,子类表中保存所有属性,包括从父类实体中继承的属性。
它的使用主要涉及以下几个点:
相关实体代码如下:
// 父类
@Entity
// 当前策略为 TABLE_PER_CLASS 策略
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
@Data
public abstract class BaseActivityMatcher implements ActivityMatcher {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
// 省略属性和方法
}
// SpecifyUserMatcher 实现类
@Entity
@Table(name = "user_per_class_matcher")
public class SpecifyUserMatcher
extends BaseActivityMatcher
implements ActivityMatcher {
// 省略属性和方法
}
// SexMatcher 实现类
@Entity
@Table(name = "sex_per_class_matcher")
public class SexMatcher
extends BaseActivityMatcher
implements ActivityMatcher {
// 省略属性和方法
}
// VipLevelMatcher 实现类
@Entity
@Table(name = "vip_per_class_matcher")
public class VipLevelMatcher
extends BaseActivityMatcher
implements ActivityMatcher {
// 省略属性和方法
}
每种策略保存一条数据后,各个表数据如下:
user_per_class_matcher 如下:
sex_per_class_matcher 如下:
vip_per_class_matcher 如下:
具有以下特点:
从表数据上可以看出,子类中有相同的属性,则每个子类都需要创建一遍,会导致表结构冗余,影响查询效率。
三种策略各具特色,都有最佳应用场景,简单如下:
子类的数据量不大,且与父类的属性差别不大;
ß可以使用单表继承策略来减少表的数量;
当子类过多或数据量过大时,Joined 和 table per class 在查询场景存在明显的性能问题,这个需要格外注意。
JPA提供了两种加载策略:立即加载和延迟加载。
如果默认策略不符合要求,可以通过手工设置注解上 fetch 配置,对默认策略进行重写。
立即加载会在查询主实体类的同时查询它所有关联实体类,并绑定到实体属性上。
立即加载的好处是能够提高查询效率,因为不需要额外的查询操作。但是,使用立即加载会增加数据库的查询负担,查询出所有关联实体类,会导致查询结果的数据量比较大。
实体配置如下:
@Entity
@Table(name = "order_info")
public class Order{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@JoinColumn(name = "order_id")
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private Pay pay;
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private Address address;
// 忽略其他属性和方法
}
测试脚本如下:
Order order = this.orderRepository.findById(this.order.getId()).get();
Assertions.assertNotNull(order);
System.out.println("访问 item");
Assertions.assertEquals(3, order.getOrderItems().size());
System.out.println("访问 address");
Assertions.assertNotNull(order.getAddress().getDetail());
System.out.println("访问 pay");
Assertions.assertNotNull(order.getPay().getPrice());
日志输出如下:
Hibernate: select order0_.id as id1_3_0_, order0_.address_id as address_6_3_0_, order0_.pay_id as pay_id7_3_0_, order0_.status as status2_3_0_, order0_.total_price as total_pr3_3_0_, order0_.total_selling_price as total_se4_3_0_, order0_.user_id as user_id5_3_0_, address1_.id as id1_2_1_, address1_.detail as detail2_2_1_, orderitems2_.order_id as order_id6_4_2_, orderitems2_.id as id1_4_2_, orderitems2_.id as id1_4_3_, orderitems2_.price as price2_4_3_, orderitems2_.product_id as product_3_4_3_, orderitems2_.quantity as quantity4_4_3_, orderitems2_.selling_price as selling_5_4_3_, pay3_.id as id1_5_4_, pay3_.price as price2_5_4_ from order_info order0_ left outer join address address1_ on order0_.address_id=address1_.id left outer join order_item orderitems2_ on order0_.id=orderitems2_.order_id left outer join pay_info pay3_ on order0_.pay_id=pay3_.id where order0_.id=?
访问 item
访问 address
访问 pay
从日志输出可见:
延迟加载是指在进行数据库查询时,并不会立即查询关联表数据,而是要等到使用时才会去查,这样可以避免不必要的数据库查询,提高查询效率。
延迟加载又分为两种情况:
在此,重点介绍表间关联的延迟加载:
实体代码如下所示:
@Entity
@Table(name = "order_info")
public class Order{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private Pay pay;
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private Address address;
// 忽略其他字段和方法
}
查询代码如下:
Order order = this.orderRepository.findById(this.order.getId()).get();
Assertions.assertNotNull(order);
System.out.println("访问 item");
Assertions.assertEquals(3, order.getOrderItems().size());
System.out.println("访问 address");
Assertions.assertNotNull(order.getAddress().getDetail());
System.out.println("访问 pay");
Assertions.assertNotNull(order.getPay().getPrice());
控制台输出如下:
Hibernate: select order0_.id as id1_3_0_, order0_.address_id as address_6_3_0_, order0_.pay_id as pay_id7_3_0_, order0_.status as status2_3_0_, order0_.total_price as total_pr3_3_0_, order0_.total_selling_price as total_se4_3_0_, order0_.user_id as user_id5_3_0_ from order_info order0_ where order0_.id=?
访问 item
Hibernate: select orderitems0_.order_id as order_id6_4_0_, orderitems0_.id as id1_4_0_, orderitems0_.id as id1_4_1_, orderitems0_.price as price2_4_1_, orderitems0_.product_id as product_3_4_1_, orderitems0_.quantity as quantity4_4_1_, orderitems0_.selling_price as selling_5_4_1_ from order_item orderitems0_ where orderitems0_.order_id=?
访问 address
Hibernate: select address0_.id as id1_2_0_, address0_.detail as detail2_2_0_ from address address0_ where address0_.id=?
访问 pay
Hibernate: select pay0_.id as id1_5_0_, pay0_.price as price2_5_0_ from pay_info pay0_ where pay0_.id=?
从日志输出可知,关联实体只有在属性被访问时才会触发自动加载。
延迟加载在聚合更新时极为重要,面对一个大聚合,每次修改只会涉及少量相关联的实体,由于延迟加载机制的保障,对于那些没有必要访问的实体并不会执行实际的加载操作,从而大幅提升性能。
简单理解按需更新,就是只有在有必要时才会对数据进行更新。
按需更新可以分为两个场景:
在数据保存时,JPA 会自动识别发生变更的实体,仅对变更实体执行 update 语句。
测试代码如下:
Order order = this.orderRepository.findById(this.order.getId()).get();
order.getOrderItems().size(); // 获取未更新
order.getPay().getPrice(); // 获取未更新
order.getAddress().setDetail("新地址"); // 获取并更新
System.out.println("更新数据");
this.orderRepository.save(order);
控制台输出如下:
Hibernate: select order0_.id as id1_3_0_, order0_.address_id as address_6_3_0_, order0_.pay_id as pay_id7_3_0_, order0_.status as status2_3_0_, order0_.total_price as total_pr3_3_0_, order0_.total_selling_price as total_se4_3_0_, order0_.user_id as user_id5_3_0_ from order_info order0_ where order0_.id=?
Hibernate: select orderitems0_.order_id as order_id6_4_0_, orderitems0_.id as id1_4_0_, orderitems0_.id as id1_4_1_, orderitems0_.price as price2_4_1_, orderitems0_.product_id as product_3_4_1_, orderitems0_.quantity as quantity4_4_1_, orderitems0_.selling_price as selling_5_4_1_ from order_item orderitems0_ where orderitems0_.order_id=?
Hibernate: select pay0_.id as id1_5_0_, pay0_.price as price2_5_0_ from pay_info pay0_ where pay0_.id=?
Hibernate: select address0_.id as id1_2_0_, address0_.detail as detail2_2_0_ from address address0_ where address0_.id=?
更新数据
Hibernate: update address set detail=? where id=?
从日志输出可见:
只更新变更字段,是指只更新实体类中有变化的字段,而不是全部字段。为了实现按需更新,需要在实体类中使用@DynamicUpdate注解,表示只更新有变化的字段。
实体代码见:
@Entity
@Table(name = "order_info")
@DynamicUpdate
public class Order{
// 其他忽略
}
测试代码如下:
Order order = this.orderRepository.findById(this.order.getId()).get();
order.setUserId(RandomUtils.nextLong()); // 仅更新 user id
System.out.println("更新数据");
this.orderRepository.save(order);
控制台输出如下:
Hibernate: select order0_.id as id1_3_0_, order0_.address_id as address_6_3_0_, order0_.pay_id as pay_id7_3_0_, order0_.status as status2_3_0_, order0_.total_price as total_pr3_3_0_, order0_.total_selling_price as total_se4_3_0_, order0_.user_id as user_id5_3_0_ from order_info order0_ where order0_.id=?
更新数据
Hibernate: update order_info set user_id=? where id=?
如果移除 @DynamicUpdate 注解,控制台输出如下:
Hibernate: select order0_.id as id1_3_0_, order0_.address_id as address_6_3_0_, order0_.pay_id as pay_id7_3_0_, order0_.status as status2_3_0_, order0_.total_price as total_pr3_3_0_, order0_.total_selling_price as total_se4_3_0_, order0_.user_id as user_id5_3_0_ from order_info order0_ where order0_.id=?
更新数据
Hibernate: update order_info set address_id=?, pay_id=?, status=?, total_price=?, total_selling_price=?, user_id=? where id=?
对比输出可知:使用@DynamicUpdate注解后,当修改实体类中的某个字段时,JPA会自动将该字段标记为“脏数据”,并只更新标记为“脏数据”的字段,这样可以减少数据库的IO操作,提高更新效率。
本章从 DDD 聚合生命周期讲起,当我们面对一组高内聚对象时,如何更好的对这一对象组进行维护。
从高内聚对象组视角需要支持:
从系统性能角度需要支持:
JPA 与 DDD 的==聚合写== 是绝配,但在 “读” 场景 往往会引发各种性能问题。这也是很多公司弃用 JPA 而选择 MyBatis 的主要原因,就其本质并不是框架的错,而是将框架用在了错误的场景。
对于 Command 和 Query 分离架构,最佳组合是: