领先一步
VMware 提供培训和认证,助您加速进步。
了解更多在我之前的博客文章中,我描述了如何设置和使用 Spring Data JDBC。我还描述了让 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;
}
此外,考虑一个定义如下的存储库:
interface OrderRepository extends CrudRepository<PurchaseOrder, Long> {
@Query("select count(*) from order_item")
int countItems();
}
如果你创建一个带商品的订单,你可能会期望所有这些都得到持久化。而这正是实际发生的情况:
@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);
…
同样,如果你删除 PurchaseOrder,它的所有商品也应该被删除。再次,这正是实际发生的情况。
…
repository.delete(saved);
assertThat(repository.count()).isEqualTo(0);
assertThat(repository.countItems()).isEqualTo(0);
}
但是,如果我们考虑一个语法相同但语义不同的关系呢?
class Book {
// …
Set<Author> authors = new HashSet<>();
}
当一本书绝版时,你删除它。所有的作者也随之消失了。这肯定不是你想要的,因为有些作者可能还写了其他书。现在,这说不通。或者说得通?我认为说得通。
为了理解为什么这说得通,我们需要退后一步,看看存储库实际上持久化了什么。这与一个反复出现的问题密切相关:你是否应该在 JPA 中每个表都创建一个存储库?
而正确且权威的答案是“不”。存储库持久化和加载聚合。聚合是形成一个单元的对象集群,它应该始终保持一致。此外,它应该始终一起被持久化(和加载)。它有一个单一的对象,称为聚合根,它是唯一允许接触或引用聚合内部的对象。聚合根是传递给存储库以持久化聚合的对象。
这引出了一个问题:Spring Data JDBC 如何确定什么是聚合的一部分,什么不是?答案非常简单:通过非瞬态引用从聚合根可以到达的一切都是聚合的一部分。
考虑到这一点,OrderRepository 的行为是完全合理的。OrderItem 实例是聚合的一部分,因此会被删除。相反,Author 实例不是 Book 聚合的一部分,因此不应该被删除。所以它们不应该简单地从 Book 类中引用。
问题解决了。嗯,……不完全是。我们仍然需要存储和访问有关 Book 和 Author 之间关系的信息。答案再次可以在领域驱动设计(DDD)中找到,它建议使用 ID 而不是直接引用。这适用于各种多对 X 关系。
如果多个聚合引用同一个实体,那么该实体不能是引用它的那些聚合的一部分,因为它只能是恰好一个聚合的一部分。因此,任何多对一和多对多关系都必须通过只引用 ID 来建模。
如果你应用这个,你会实现多重效果:
你清楚地标示了聚合的边界。
你还在应用程序的领域模型中完全解耦了所涉及的两个聚合。
这种分离可以在数据库中以不同的方式表示:
保持数据库的正常状态,包括所有外键。这意味着你必须确保以正确的顺序创建和持久化聚合。
使用延迟约束,这些约束仅在事务的提交阶段才会被检查。这可能会实现更高的吞吐量。它还编码了一种最终一致性版本,其中“最终”与事务的结束绑定。这也允许引用从未存在的聚合,只要它只发生在事务期间。这对于避免大量基础设施代码以仅仅满足外键和非空约束可能很有用。
完全删除外键,实现真正的最终一致性。
将引用的聚合持久化到不同的数据库中,甚至可能是 NoSQL 存储。
无论你将分离推到多远,即使是 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.author = author.id;
return authorRef;
}
}
@Table("Book_Author")
class AuthorRef {
Long author;
}
class Author {
@Id Long id;
String name;
}
请注意额外的类 (AuthorRef),它表示 Book 聚合关于作者的知识。它可能包含关于作者的额外聚合信息,这些信息实际上会在数据库中重复。考虑到作者数据库可能与图书数据库完全不同,这在很多方面都很有意义。
另外,请注意作者集是一个私有字段,并且 AuthorRef 实例的实例化发生在私有方法中。因此,聚合外部的任何东西都无法直接访问它。这绝不是 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);
}
总结一下:Spring Data JDBC 不支持多对一或多对多关系。为了建模这些关系,请使用 ID。这鼓励了领域模型的清晰模块化。它还消除了如果这种映射可能存在时,人们必须解决和学习推理的整类问题。
同样地,避免双向依赖。聚合内部的引用从聚合根指向元素。聚合之间的引用通过 ID 以一个方向表示。此外,如果你需要反向导航,请在存储库中使用查询方法。这使得哪个聚合负责维护引用变得一目了然。
以下是示例使用的数据库结构。
Purchase_Order (
id
shipping_address
)
Order_Item (
purchase_order
quantity
product
);
Book (
id
title
)
Author (
id
name
)
Book_Author (
book
author
)