使用Spring Data JDBC實現DDD聚合

banq發表於2018-09-30
本文討論了Spring Data JDBC如何實現DDD中聚合根儲存的設計思路,其中主要討論了是不是每個實體都需要一個對應資料表,這種問題需要根據具體情況而定。

Spring Data JDBC比JPA更容易理解,比如物件引用特性會很有趣。作為第一個示例,請考慮以下領域模型:

class PurchaseOrder {

  private @Id Long id;
  private String shippingAddress;
  private Set<OrderItem> items = new HashSet<>();

  void addItem(int quantity, String product) {
    items.add(createOrderItem(quantity, product));
  }

  private OrderItem createOrderItem(int quantity, String product) {

    OrderItem item = new OrderItem();
    item.product = product;
    item.quantity = quantity;
    return item;
  }
}
class OrderItem {
  int quantity;
  String product;
}
<p class="indent">

另外,考慮如下定義的儲存庫:

interface OrderRepository extends CrudRepository<PurchaseOrder, Long> {

  @Query("select count(*) from order_item")
  int countItems();
}
<p class="indent">


如果使用商品建立訂單,希望所有商品都能儲存:

@Autowired OrderRepository repository;

@Test
public void createUpdateDeleteOrder() {

  PurchaseOrder order = new PurchaseOrder();
  order.addItem(4, "Captain Future Comet Lego set");
  order.addItem(2, "Cute blue angler fish plush toy");

  PurchaseOrder saved = repository.save(order);

  assertThat(repository.count()).isEqualTo(1);
  assertThat(repository.countItems()).isEqualTo(2);
  …
<p class="indent">


此外,如果刪除PurchaseOrder,它的所有專案也應該被刪除。

  …
  repository.delete(saved);

  assertThat(repository.count()).isEqualTo(0);
  assertThat(repository.countItems()).isEqualTo(0);
}
<p class="indent">


如果我們需要一個語法上相同但語義上不同的關係呢?上述訂單中包含訂單條目OrderItem , 當訂單刪除時,包含的OrderItem 都刪除了,但是看看看看下面案例,也是使用包含一個集合:

class Book {
  // …
  Set<Author> authors = new HashSet<>();
}
<p class="indent">

當書籍絕版時,將Book刪除。所有的作者Author也都丟失了。這當然不是你想要的,因為一些作者可能也寫過其他書。

怎麼辦?

讓我們看一看儲存庫實際存在的內容。這與一遍又一遍的問題密切相關:是否應該在JPA中為每個表建立一個儲存庫?

而正確和權威的答案是“不”。儲存庫持久聚合並載入聚合。聚合是一個包含各種物件的群,它應始終保持一致。此外,它應始終保持(和載入)在一起。它有一個物件,稱為聚合根,它是唯一允許外部訪問或引用聚合內部的代理或管理者。聚合根是傳遞給儲存庫的,以便持久化聚合裡面的物件群。

這提出了一個問題:Spring Data JDBC如何確定什麼是聚合的一部分,哪些不是?答案非常簡單:非瞬態non-transient 引用都是聚合的一部分,這樣就可從聚合根到達聚合內部所有內容。

OrderItem例項是聚合的一部分,因此被刪除; Author正好相反,例項不是Book聚合的一部分,因此不應刪除。所以不應該從Book內部去引用那些作者Author物件。

問題解決了。好吧,......不是真的。我們仍然需要儲存和訪問有關Book和Author之間的關係資訊。答案可以在領域驅動設計(DDD)中找到,它建議使用ID而不是直接引用。這適用於各種多對X關係。

如果多個聚合引用同一個實體,則該實體不能成為引用它的多個聚合的一部分,因為它只能是其中一個聚合的一部分。因此,任何“多對一”和“多對多”關係都只能透過引用id來建模實現了。

這樣可以實現多種目的:

1. 清楚地表示了聚合的邊界。

2. 還完全解耦(至少在應用程式的領域模型中)所涉及的兩個聚合。

3. 這種分離可以用不同的方式在資料庫中表示:

a. 以通常的方式保留資料庫,包括所有外來鍵。這意味著必須確保以正確的順序建立和保留聚合。

b. 使用延遲約束,僅在事務的提交階段進行檢查。這可能會提高吞吐量。它還編纂了最終一致性的版本,其中“最終”與交易結束相關聯。這也允許引用從未存在的聚合,只要它僅在事務期間發生。這對於避免大量基礎結構程式碼只是為了滿足外來鍵和非空約束可能是有用的。

c. 完全刪除外來鍵,實現真正的最終一致性。

d. 將引用的聚合保留在不同的資料庫中,甚至可能是No SQL儲存。

無論如何,即使Spring Data JDBC也鼓勵應用模組化。此外,如果嘗試遷移一個具有10年曆史的單體,你就會明白它的價值。

使用Spring Data JDBC,您可以建模多對多關係,如下所示:

class Book {

  private @Id Long id;
  private String title;
  private Set<AuthorRef> authors = new HashSet<>();

  public void addAuthor(Author author) {
    authors.add(createAuthorRef(author));
  }

  private AuthorRef createAuthorRef(Author author) {

    Assert.notNull(author, "Author must not be null");
    Assert.notNull(author.id, "Author id, must not be null");

    AuthorRef authorRef = new AuthorRef();
    authorRef.authorId = author.id;
    return authorRef;
  }
}

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

class Author {
  @Id Long id;
  String name;
}
<p class="indent">

請注意額外的類:AuthorRef,它表示有關某個作者的Book聚合的知識。它可能包含有關作者的其他聚合資訊,然後實際上會在資料庫中重複。考慮到Author資料庫可能與Book資料庫完全不同,這會產生很多問題。

另請注意,authors是Book 私有欄位,AuthorRef例項化在私有方法createAuthorRef中發生。因此聚合之外的任何內容都不能直接訪問它。Spring Data JDBC絕不需要這樣做,但DDD鼓勵這麼做。

下面是測試:

@Test
public void booksAndAuthors() {

  Author author = new Author();
  author.name = "Greg L. Turnquist";

  author = authors.save(author);

  Book book = new Book();
  book.title = "Spring Boot";
  book.addAuthor(author);

  books.save(book);

  books.deleteAll();

  assertThat(authors.count()).isEqualTo(1);
}
<p class="indent">

上述完成了我們設想功能:刪除書籍後,並沒有將書籍作者資料表資料全部刪除,雖然作者是書籍的一個私有欄位。

總結一下:
Spring Data JDBC不支援多對一或多對多關係。要對這些進行建模,請使用ID。

這鼓勵了領域模型的清晰模組化。

透過類似的思路,避免雙向依賴。聚合內部的引用是從聚合根到元素。聚合之間的引用使用只在一個關聯方向上使用ID表示。此外,如果需要反向導航,請在儲存庫中使用查詢方法。這樣能清楚確定哪個聚合負責維護引用。

banq注:是不是每個實體都需要一個對應資料表?根據具體情況,Order和OrderItem之間生命週期是一致的,刪除訂單,訂單條目也沒有存在意義;而Book和Author則不是生命週期一致的,Book可能是當前有界上下文的聚合根,而Author是另外一個有界上下文如作者管理系統的聚合根,如果刪除Book同時,也將Author刪除,其實是不符合要求的,這時候應該將Author作為值物件看待,Author的Id就是一個值,然後建立一個類AuthorRef ,包含這個值,作為被Book引用的物件,這樣就不是整個Author實體聚合物件被Book引用了。

Spring Data JDBC, References, and Aggregates

相關文章