领域设计:Spring-Data-JDBC对DDD的支持

1,553 阅读7分钟

Spring在2018年9月发布了Spring-Data-JDBC子项目的1.0.0.RELEASE版本(目前版本为1.0.6-RELEASE),Spring-Data-JDBC设计借鉴了DDD,提供了对DDD的支持,包括:

  • 聚合与聚合根
  • 仓储
  • 领域事件

在前面领域设计:聚合与聚合根一文中,通过列子介绍了聚合与聚合根;而在领域设计:领域事件一文中,通过例子介绍了领域事件。

本文结合Spring-Data-JDBC来重写这两个例子,来看一下Spring-Data-JDBC如何对DDD进行支持。

环境搭建

Spring-Data-JDBC项目还较新,文档并不齐全(Spring-Data-JDBC的文档还是以Spring-Data-JPA为基础编写的,依赖还是Spring-Data-JPA,实际不需要Spring-Data-JPA依赖),所以这里给出搭建过程中的注意点。

新建一个maven项目,pom.xml中配置

<!--这里需要引入spring-boot 2.1.0以上,2.0的boot还没有spring-data-jdbc--><parent>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-parent</artifactId>
 <version>2.1.4.RELEASE</version></parent><dependencies>
 <!--引入spring-data-jdbc-->
 <dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-data-jdbc</artifactId>
 </dependency>
</dependencies>

开启jdbc支持

@SpringBootApplication
@EnableAutoConfiguration
@EnableJdbcRepositories // 主要是这个注解,来启动spring-data-jdbc支持@EnableTransactionManagement
public class TestConfig {
}

聚合与聚合根

领域设计:聚合与聚合根中举了两个列子:

  • Order与OrderDetail之间的关系
  • Product与ProductComment之间的关系
  • 我们通过Spring-Data-JDBC来实现这两个例子,来看一下Spring-Data-JDBC对聚合和聚合根的支持。

我们先看Order与OrderDetail。

订单与详情

Order与OrderDetail组成了一个聚合,其中Order是聚合根,聚合中的操作都是通过聚合根来完成的。

领域设计:Spring-Data-JDBC对DDD的支持

在Spring-Data-JDBC中如何表示这一层关系呢?

@Getter // 1
@Table("order_info") // 2
public class Order {
 @Id // 3
 private Long recId;
 private String name;
 private Set<OrderDetail> orderDetailList = new HashSet<>(); // 4
 public Order(String name) { // 5
 this.name = name;
 }
 // 其它字段略
 public void addDetail(String prodName) { // 6
 orderDetailList.add(new OrderDetail(prodName));
 }
}

@Getter // 1
public class OrderDetail {
 @Id // 3
 private Long recId;
 private String prodName;
 // 其它字段略
 OrderDetail(String prodName) { // 7
 this.prodName = prodName;
 }
}
  1. lombok注解,这里只提供了get方法,封装操作
  2. 默认情况下,类名与表名的映射关系是
  • 类名的首字母小写
  • 驼峰式转下划线
  • 这里order在数据库中是关键字,所以使用Table注解进行映射,映射到order_info表
  1. 通过@Id注解,标明这个类是个实体
  2. Order中持有一个OrderDetail的Set集合,标明Order与OrderDetail组成了一个聚合,且Order是聚合根
  • 聚合关系由spring-data-jdbc默认维护
  • 如果是Set集合,则order_detail表中,需要有个order_info字段,保存订单主键
  • 如果是List集合,则order_detail表中,需要有两个字段:order_info保存订单主键,order_info_key保存顺序
  1. 两个类都没有提供set方法,通过构造方法来赋值
  2. Order是聚合根,所有操作通过聚合根来操作,这里提供addDetail方法来新增订单详情
  3. 因为OrderDetail的操作都是通过Order来进行的,所以设置OrderDetail构造方法包级可见,限制了外部对OrderDetail的构建

根据上面的说明,我们的sql结构如下:

DROP TABLE IF EXISTS `order_info`;
CREATE TABLE `order_info` (
 `rec_id` BIGINT(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
 `name` varchar(11) NOT NULL COMMENT '订单名称',
 PRIMARY KEY (`rec_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;

DROP TABLE IF EXISTS order_detail;
CREATE TABLE `order_detail` (
 `rec_id` BIGINT(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
 `order_info` BIGINT(11) NOT NULL COMMENT '订单主键,由spring-data-jdbc自动维护',
 `prod_name` varchar(11) NOT NULL COMMENT '产品名称',
 PRIMARY KEY (`rec_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;

对聚合的操作,Spring-Data-JDBC提供了Repository接口,直接实现即可,提供了类似RubyOnRails那样的动态查询方法,不过需要通过Query注解自行编写sql,详见下文。

@Repository
public interface OrderRepository extends CrudRepository<Order, Long> {
}
  • 这里编写一个接口,继承CrudRepository接口,里面提供了基本的查询,直接使用即可

这就搞定了,我们编写一个测试,来测试一下:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = TestConfig.class)
public class OrderTest {
 @Autowired
 private OrderRepository orderRepository;
 @Test
 public void testInit() {
 Order order = new Order("测试订单");
 order.addDetail("产品1");
 order.addDetail("产品2");
 Order info = orderRepository.save(order); // 1
 Optional<Order> orderInfoOptional = orderRepository.findById(info.getRecId()); // 2
 assertEquals(2, orderInfoOptional.get().getOrderDetailList().size()); // 3
 }
}
  1. 直接使用提供的save方法进行保存操作,自动处理聚合关系,也就是说这里自动保存了order及里面的两个order_detail
  2. 通过提供的findById查询出Order,这里返回的是个Optional类型
  3. 返回的Order中,自动组装了其中的order_detail。对应的删除操作,也会自动删除其关联的order_detail

产品与评论

产品与产品评论的关系如下:

领域设计:Spring-Data-JDBC对DDD的支持

  • 产品和产品评论没有业务上的一致性需求,所以是两个「聚合」
  • 产品评论通过productId与「产品聚合」进行关联

代码表示就是简单的通过id进行关联。代码如下:

@Getter
public class Product { // 1
 @Id
 private Long recId;
 private String name;
 public Product(String name) {
 this.name = name;
 }
 // 其它字段略
}

@Getter
public class ProductComment {
 @Id
 private Long recId;
 private Long productId; // 2
 private String content;
 // 其它字段略
 public ProductComment(Long productId, String content) {
 this.productId = productId;
 this.content = content;
 }
}
  1. Product中不再持有对应的集合
  2. 相应的,ProductComment中持有了产品主键字段

对应的sql如下:

DROP TABLE IF EXISTS `product`;
CREATE TABLE `product` (
 `rec_id` BIGINT(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
 `name` varchar(11) NOT NULL COMMENT '产品名称',
 PRIMARY KEY (`rec_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;

DROP TABLE IF EXISTS product_comment;
CREATE TABLE `product_comment` (
 `rec_id` BIGINT(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
 `product_id` BIGINT(11) NOT NULL COMMENT '产品主键,手动赋值',
 `content` varchar(11) NOT NULL COMMENT '评论内容',
 PRIMARY KEY (`rec_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;

产品和评论都是聚合根,所以都有各自的仓储类:

@Repository
public interface ProductRepository extends CrudRepository<Product, Long> {
}

@Repository
public interface ProductCommentRepository extends CrudRepository<ProductComment, Long> {

 @Query("select count(1) from product_comment where product_id = :productId") // 1
 int countByProductId(@Param("productId") Long productId); // 2

}
  1. 通过Query注解来绑定sql与方法的关系,参数以:开头。(Spring-Data-JDBC目前还不支持自动sql绑定)
  2. Param注解来标明参数名,或者使用jdk8的-parameters编译方式,来根据参数名自动绑定

熟悉Mybatis的朋友对这段代码应该很眼熟吧!

测试如下:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = TestConfig.class)
public class ProductTest {
 @Autowired
 private ProductRepository productRepository;
 @Autowired
 private ProductCommentRepository productCommentRepository;

 @Test
 public void testInit() {
 Product prod = new Product("产品名称");
 Product info = productRepository.save(prod);
 ProductComment comment1 = new ProductComment(info.getRecId(), "评论1"); // 1
 ProductComment comment2 = new ProductComment(info.getRecId(), "评论2");
 productCommentRepository.save(comment1);
 int num = productCommentRepository.countByProductId(info.getRecId());
 assertEquals(1, num);
 productCommentRepository.save(comment2);
 num = productCommentRepository.countByProductId(info.getRecId());
 assertEquals(2, num);
 productRepository.delete(info); // 2
 num = productCommentRepository.countByProductId(info.getRecId());
 assertEquals(2, num);
 }
}
  1. 产品和评论各自保存
  2. 删除产品后,评论并不会跟着一起删除。如果需要一并删除,需要手动处理。

聚合小节

从上面的两个例子可以看出:

  • 对于同一个聚合中的多个实体,可以通过在聚合根中引用对应的实体对象,来实现聚合操作。Spring-Data-JDBC会自动处理这层关系

  • 对于不同的聚合,通过id的方式进行引用,手动处理两者的关系。这也是领域设计里推荐的做法

  • 如果实体中需要引用其他实体,但是并不想保持一致的操作,那么使用Transient注解

  • 被聚合根引用的实体对象,对应的数据库表中需要一个与聚合根同名的字段,用于保存聚合根的id。这就可以用来区分数据表之间是聚合根与实体的关系,还是聚合根与聚合根之间的关系

  • 如果表中有一个字段,字段名与另一张数据表的表名相同,其中保存的是对应的id,那么这张表是对应字段表的实体,对应字段表是聚合根

  • 如果表中的字段是「表名+id」形式,那么两张表都是聚合根,分属于不同的聚合

  • 如果两个实体之间是多对多的关系,则可以引入一个「关系值对象」,引用方持有这个「关系值对象」来维护关系。对应数据库设计,就是引入一个mapping表,代码如下:

    // 来自spring示例 class Book { ...... private Set authors = new HashSet<>(); }

    @Table("book_author") class AuthorRef { Long authorId; }

    class Author { ...... String name; }

领域事件

领域设计:领域事件一文中使用Spring提供的ApplicationEvent演示了领域事件,这里通过对Order聚合根的扩展,来看看Spring-Data-JDBC对领域事件的支持。

假设上面的Order创建后,需要发送一个领域事件,该如何处理呢?

Spring-Data-JDBC默认提供了5个事件:

  • BeforeDeleteEvent:聚合根在被删除之前触发
  • AfterDeleteEvent:聚合根在被删除之后触发
  • BeforeSaveEvent:聚合根在被保存之前触发
  • AfterSaveEvent:聚合根在被保存之后触发
  • AfterLoadEvent:聚合根在被从仓储恢复后触发

那么对于上面的需求,我们不需要创建什么事件,只需要创建一个监听器,来监听AfterSaveEvent事件就可以了。

@Bean
public ApplicationListener<AfterSaveEvent> afterSaveEventListener() {
 return event -> {
 Object entity = event.getEntity();
 if (entity instanceof Order) {
 Order order = (Order) entity;
 System.out.println("订单[" + order.getName() + "]保存成功");
 }
 };
}

重新执行上面的OrderTest的测试方法,会得到如下输出:

订单[测试订单]保存成功

如果我们需要自定义事件,该如何处理呢?Spring-Data-JDBC提供了DomainEvents和AfterDomainEventPublication注解:

  • 被DomainEvents注解的无参方法,可以返回一个或多个事件

  • 被AfterDomainEventPublication注解的方法,可以用于事件发布后的后续处理工作

  • 这两个方法在repository.save方法执行时被调用

    @Getter public class OrderCreateEvent extends ApplicationEvent { // 1 private String name; public OrderCreateEvent(Object source, String name) { super(source); this.name = name; } }

    @Getter @Table("order_info") public class Order { ...... @DomainEvents public ApplicationEvent domainEvent() { // 2 return new OrderCreateEvent(this, this.name); } @AfterDomainEventPublication public void postPublish() { // 3 System.out.println("Event published"); } }

    public class TestConfig { ...... @Bean public ApplicationListener orderCreateEventListener() { // 4 return event -> { System.out.println("订单[" + event.getName() + "]保存成功"); }; } }

  1. 自定义一个事件,具体可见领域设计:领域事件
  2. DomainEvents注解的方法,会在repository.save方法调用时创建一个OrderCreateEvent事件,传入订单名称作为参数
  3. AfterDomainEventPublication注解的方法在事件发布完成后,进行回调,可以处理事件发布后的一些处理,这里只是简单的打印
  4. OrderCreateEvent事件监听对象,监听事件进行处理

再次执行上面的OrderTest的测试方法,会得到如下输出:

订单[测试订单]保存成功 // 这是AfterSaveEvent事件触发的
订单[测试订单]保存成功 // 这是自定义事件触发的
Event published

事件小节

Spring-Data-JDBC在原来Spring事件的基础上进行了增强:

  • 新增了5个聚合根操作相关的事件
  • 通过DomainEvents注解简化了事件的发布(只在repository.save时触发)
  • 通过AfterDomainEventPublication注解处理事件发布后的回调(只在repository.save时触发)
  • 提供了AbstractAggregateRoot抽象类来进一步简化事件处理

总结

Spring-Data-JDBC的设计借鉴了DDD。本文演示了Spring-Data-JDBC如何对DDD进行支持:

  • 自动处理聚合根与实体之间的关系
  • 默认仓储接口,简化聚合存储
  • 通过注解来简化领域事件的发布

Spring-Data-JDBC还提供了如下功能:

  • MyBatis support
  • Id generation
  • Auditing
  • CustomConversions

有兴趣可自行参考文档。

参考资料