使用 Spring Modulith 改进模块化整体应用

在第一篇文章:使用Spring Boot和领域驱动设计实现模块化整体中,我们了解了如何使用 Spring Boot 和 DDD 构建和实现模块化整体应用程序。

本博客中,我们将尝试解决这些限制以创建更易于维护的实现。我们将看到 Spring Modulith如何发挥关键作用。


新业务需求
在上一篇博客中,我们实现了一个应用程序,使图书馆能够向顾客借书。该计划的实施受到了热烈欢迎,许多顾客转向了网上借贷。

但不久之后,图书馆就面临着挑战。虽然应用程序能够处理书籍的签出,但域建模中遗漏了一个关键方面。顾客借出一本书的请求会立即将该书标记为“已发行”。图书馆员无法得知所发行的书籍是否确实是由顾客收藏的。有时,赞助人永远不会来取书,这本书就会一直被错误地发行。

经过与领域专家的讨论,出现了一个新的拿着书的概念。不应根据读者的请求立即发行书籍,而应先将书籍搁置。一旦顾客收到该书,该书即被视为已发行。这将清楚地表明发行的书籍不在图书馆中,而是在读者那里。

重新思考领域模型
我们当前的域模型由两个子域Borrow 和 Inventory组成。它是使用相同的相应限界上下文来实现的。

两个有界上下文之间存在紧密耦合 - 当读者签出一本书时,库存中该书的状态被标记为“已发行”。我们希望使耦合变得松散,因为它会影响实现的测试和可维护性。

Inventory子域还负责跟踪图书馆中所有可用的书籍。这意味着它既管理图书馆中的图书目录,又跟踪随图书借出和借入而变化的可用性。

如果这些实际上是两个被建模为一个子域怎么办?

新的子域 Catalog将负责捕获书籍的元数据,而不必担心库存子域中保留的可用性。

这是否意味着我们现在也有 3 个有界上下文?让我们考虑一下。借用和库存有界上下文 (BC) 仍然共享相同的依赖关系。

如果我们使用领域事件对具有最终一致性的依赖关系进行建模会怎么样?那会是什么样子?

借书 BC 会生成 BOOK_CHECKOUT_REQUESTED 事件。库存 BC 监听该事件并检查图书是否可用,然后生成 BOOK_AVAILABLE 或 BOOK_UNAVAILABLE 事件。借阅 BC 根据库存 BC 的事件将借阅状态更新为 ACTIVE 或 REJECTED。

这里的事件看起来不像域事件,更像是请求-回复事件。在Inventory库存 BC 答复之前,"贷款 "状态不会变为 "激活 "或 "拒绝",这似乎表明耦合很紧。通过引入事件,我们所做的只是将通信转换为异步通信,但基本的紧密耦合仍然存在。

两个子域,一个有界上下文?
如果我们将 "Borrow 借用 "和 "Inventory库存 "两个子域放在同一个有界上下文中建模呢?

请记住,有界上下文是一种语言边界。在有界上下文中,模型的含义不会改变。图书 在 "借用 "和 "库存 "上下文中的含义是否不同?

  • 在Inventory库存 BC 中,"图书 "最重要的属性是其可用状态。
  • 在 "Borrow 借阅 "BC 中,图书的可用状态会随着图书的借阅和归还而受到影响。

这两种上下文的模型是一样的。看来,"借阅 "和 "库存 "应该在同一个有界上下文中建模!

当图书馆(目录Catalog)中添加新书时,Borrow BC 必须知道,以便及时更新库存。我们可以用下面的事件来模拟这个过程:

  • 目录 BC 生成 BookAddedToCatalog 事件,借阅 BC 利用该事件更新本地库存,以显示有新书可供借阅。
  • 在通过事件进行异步通信时,目录Catalog和借书 BC 是松耦合的,因为借书过程不再依赖于Catalog目录。

有了新的见解和新的领域模型,我们就有能力处理对图书进行搁置的新需求。只需在 "Borrow 借书 "BC 中就可以解决这个问题。让我们来详细了解一下。

“借阅”新有界上下文(新见解)
让我们放大借阅 BC。和以前一样,它仍然包含 "借阅 "聚合体,但此外还包含一个 "图书 "聚合体,该聚合体是目录 BC 所拥有图书的只读缓存模型。

通过在 "借阅 BC "中维护一个副本,在实现 "借阅 BC "的任何用例时都不会依赖于 "Catalog目录 BC"。

我们选择用事件来模拟 "贷款 "聚合和 "账本 "聚合之间的通信,以实现最终的一致性。这是为了避免在同一事务中更新两个聚合。它使聚合保持松散耦合,便于独立测试。

在执行用例时,LoanManagement 服务会触发事件。

@Transactional
@Service
@RequiredArgsConstructor
public class LoanManagement {

    private final LoanRepository loans;
    private final BookRepository books;
    private final ApplicationEventPublisher events;
    private final LoanMapper mapper;

    public LoanDto hold(String barcode) {
        var book = books.findByInventoryNumber(new Barcode(barcode))
                .orElseThrow(() -> new IllegalArgumentException("Book not found!"));

        if (!book.available()) {
            throw new IllegalStateException(
"Book not available!");
        }

        var dateOfHold = LocalDate.now();
        var loan = Loan.of(book.getId(), dateOfHold);
        var dto = mapper.toDto(loans.save(loan));
        events.publishEvent(
                new BookPlacedOnHold(
                        book.getId(),
                        book.getIsbn(),
                        book.getInventoryNumber().barcode(),
                        loan.getPatronId(),
                        dateOfHold));
        return dto;
    }
}

事件 BookPlacedOnHold 被定义为 Java 记录。我们包含了图书的所有相关信息、被搁置图书的读者以及上下文(搁置日期)。

public record BookPlacedOnHold(Long bookId, 
                               String isbn, 
                               String inventoryNumber,
                               Long patronId, 
                               LocalDate dateOfHold) {
}

InventoryManagement 服务会监听该事件,并将图书标记为搁置Hold。

@Transactional
@Service
@RequiredArgsConstructor
public class InventoryManagement {

    private final BookRepository books;

    @ApplicationModuleListener
    public void on(BookPlacedOnHold event) {
        var book = books.findById(event.bookId())
                .map(Book::markOnHold)
                .orElseThrow(() -> new IllegalArgumentException("Book not found!"));
        books.save(book);
    }
}

请注意注解 @ApplicationModuleListener。它来自 Spring Modulith 库。它等同于 @TransactionalEventListener、@Async 和 @Transactional(propagation = Propagation.REQUIRES_NEW)。这可确保事件监听器方法在不同的线程和独立事务中执行。触发事件的代码始终会完成,与监听器无关。

这就带来了一个新的挑战。如果监听器执行失败(如数据库事务失败),而触发事件的代码已经完成,会发生什么情况?在这种情况下,Loan 聚合会处于 HOLDING 状态,而 Book 聚合永远不会被标记为 ON_HOLD。我们需要一种方法来持久化事件并重新触发监听器方法的执行。

Spring Modulith 事件发布注册表
Spring Modulith 依赖关系可如下添加到 pom.xml 中。

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.modulith</groupId>
      <artifactId>spring-modulith-bom</artifactId>
      <version>1.1.1</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-core</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-jpa</artifactId>
</dependency>

spring-modulith-starter-jpa 依赖关系可启用事件发布注册表。该注册中心由底层持久化技术(本例中为 H2)提供支持。它为 H2 创建了一个具有以下模式的表 EVENT_PUBLICATION:

CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION
(
  ID               UUID NOT NULL,
  COMPLETION_DATE  TIMESTAMP(9) WITH TIME ZONE,
  EVENT_TYPE       VARCHAR(512) NOT NULL,
  LISTENER_ID      VARCHAR(512) NOT NULL,
  PUBLICATION_DATE TIMESTAMP(9) WITH TIME ZONE NOT NULL,
  SERIALIZED_EVENT VARCHAR(4000) NOT NULL,
  PRIMARY KEY (ID)
)

其他支持数据库的模式可在此处找到:schemas>https://docs.spring.io/spring-modulith/reference/appendix.htmlschemas

当 Spring ApplicationEventPublisher 发布事件时,注册表会查找所有预期接收该事件的事务事件监听器,并在上表中写入一个条目。默认情况下,该条目被视为不完整(COLMPLETION_DATE 列为空)。当事务事件监听器成功完成后,条目就会被标记为已完成。这样可以确保在监听器执行失败的情况下,事件不会丢失,并会再次触发,直到监听器执行成功为止。

模块的独立测试
采用事件作为模块间的通信模式有助于独立测试。让我们来看看是如何实现的。

@Transactional
@ApplicationModuleTest
class LoanIntegrationTests {

    @DynamicPropertySource
    static void initializeData(DynamicPropertyRegistry registry) {
        registry.add("spring.sql.init.data-locations", () -> "classpath:borrow.sql");
    }

    @Autowired
    LoanManagement loans;

    @Test
    void shouldCreateLoanOnPlacingHold(Scenario scenario) {
        scenario.stimulate(() -> loans.hold(
"13268510"))
                .andWaitForEventOfType(BookPlacedOnHold.class)
                .toArriveAndVerify((event, dto) -> {
                    assertThat(event.inventoryNumber()).isEqualTo(
"13268510");
                    assertThat(dto.status()).isEqualTo(LoanStatus.HOLDING);
                });
    }
}

Spring Modulith 提供了一个注解 @ApplicationModuleTest,它与 @SpringBootTest 类似,但会自动将 Spring Application 上下文限制为正在测试的包(代表模块),而不是其他。它允许我们测试 Borrow 模块,而无需引导 Catalog 模块中的任何代码。

上面的代码段通过调用 LoanManagement::hold 测试了搁置的场景,并验证了事件 BookPlacedOnHold 的正确有效载荷。

利用 Spring Modulith 强化模块边界
以前,我们依靠类的可见性来确保只有特定的类才能从不同的包(模块)中访问。这严重限制了模块内包结构的灵活性。此外,要长期保持这种做法也几乎是不可能的。

Spring Modulith 提供了一种不依赖于类可见性的解决方案。在 Spring Modulith 中,每个顶级包都被视为一个模块。这个包被称为 API 包。该包中的任何类都将自动提供给其他模块(顶级包)。顶级包的子包是内部的,其他模块无法访问。

让我们来看看我们的软件包结构,以便更好地理解这一点。

src/main/java
└── example
    ├── borrow
    │   ├── book
    │   │   ├── Book
    │   │   ├── BookCollected
    │   │   ├── BookPlacedOnHold
    │   │   ├── BookRepository
    │   │   ├── BookReturned
    │   │   └── InventoryManagement
    │   └── loan
    │       ├── BorrowController
    │       ├── Loan        
    │       ├── LoanDto
    │       ├── LoanManagement
    │       ├── LoanMapper      
    │       └── LoanRepository      
    ├── catalog
    │   ├── internal
    │   │   ├── BookDto
    │   │   ├── BookMapper
    │   │   ├── CatalogBook
    │   │   ├── CatalogController
    │   │   ├── CatalogManagement
    │   │   └── CatalogRepository
    │   └── BookAddedToCatalog
    └── LibraryApplication


顶级包 example.borrow 和 example.catalog 是 API 包或模块。example.catalog 包只包含一个类 BookAddedToCatalog,它是 "借阅 "模块使用的域事件。

example.catalog 包中的类无法访问 example.borrow.book 和 example.borrow.loan 包中的所有类。

可以通过下面的测试来执行这一限制。

class SpringModulithTests {

    ApplicationModules modules = ApplicationModules.of(SpringModulithWithDddApplication.class);

    @Test
    void verifyPackageConformity() {
        modules.verify();
    }
}

如果尝试从不同的模块访问模块的内部类,测试将失败,并出现类似下面的错误。

org.springframework.modulith.core.Violations: - Module 'borrow' depends on non-exposed type example.catalog.internal.BookAddedToCatalog within module 'catalog'!
BookAddedToCatalog declares parameter BookAddedToCatalog.on(BookAddedToCatalog) in (InventoryManagement.java:0)

现在,团队可以放松下来,不必担心在代码审查中发现此类违规行为。

记录模块
随着时间的推移,应用程序会不断增加新模块,并在现有模块中实现新用例。利用 Spring Modulith,可以生成文档片段和 C4 图,描述模块之间的关系。

class SpringModulithTests {

    ApplicationModules modules = ApplicationModules.of(SpringModulithWithDddApplication.class);

    @Test
    void createModulithsDocumentation() {
        new Documenter(modules).writeDocumentation();
    }
}

结论
在本博客中,我们改进了领域模型,并重组了有界上下文。
我们探讨了 Spring Modulith 如何帮助构建自文档化、易于测试和维护的松散耦合模块化单体应用程序。我们能够解决第一篇博客中提到的所有限制。

源码:github