領域設計:Spring-Data-JDBC對DDD的支持

Spring在2018年9月發佈了Spring-Data-JDBC子項目的1.0.0.RELEASE版本(目前版本爲1.0.6-RELEASE),Spring-Data-JDBC設計借鑑了DDD,提供了對DDD的支持,包括:html

  • 聚合與聚合根
  • 倉儲
  • 領域事件

在前面領域設計:聚合與聚合根一文中,經過列子介紹了聚合與聚合根;而在領域設計:領域事件一文中,經過例子介紹了領域事件。spring

本文結合Spring-Data-JDBC來重寫這兩個例子,來看一下Spring-Data-JDBC如何對DDD進行支持。sql

環境搭建

Spring-Data-JDBC項目還較新,文檔並不齊全(Spring-Data-JDBC的文檔仍是以Spring-Data-JPA爲基礎編寫的,依賴仍是Spring-Data-JPA,實際不須要Spring-Data-JPA依賴),因此這裏給出搭建過程當中的注意點。數據庫

新建一個maven項目,pom.xml中配置markdown

<!--這裏須要引入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支持app

@SpringBootApplication
@EnableAutoConfiguration
@EnableJdbcRepositories // 主要是這個註解,來啓動spring-data-jdbc支持@EnableTransactionManagement
public class TestConfig {
}
複製代碼

聚合與聚合根

領域設計:聚合與聚合根中舉了兩個列子:dom

  • Order與OrderDetail之間的關係
  • Product與ProductComment之間的關係
  • 咱們經過Spring-Data-JDBC來實現這兩個例子,來看一下Spring-Data-JDBC對聚合和聚合根的支持。

咱們先看Order與OrderDetail。數據庫設計

訂單與詳情

Order與OrderDetail組成了一個聚合,其中Order是聚合根,聚合中的操做都是經過聚合根來完成的。maven

領域設計:Spring-Data-JDBC對DDD的支持

在Spring-Data-JDBC中如何表示這一層關係呢?spring-boot

@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

有興趣可自行參考文檔。

參考資料

相關文章
相關標籤/搜索