使用Spring Boot和领域驱动设计实现模块化整体


用模块化整体架构编写的代码实际上是什么样的?借助 Spring Boot 和 DDD,我们踏上了编写可维护和可演化代码的旅程。

当谈论模块化整体代码时,我们的目标是以下几点:

  1. 应用程序被组织成模块。每个模块解决业务问题的不同部分。
  2. 模块是松散耦合的。不同模块之间没有循环依赖关系,因为它会导致代码难以维护。
  3. 完整的应用程序在运行时部署为单个单元。这是整体部分。
  4. 模块的公共接口(暴露给其他模块的行为)是灵活的并且可以原子地更改。与微服务不同,当我们需要更改模块的公共接口时,使用该接口的其他模块可以一起更改并推出。

边界的确定仍然很重要。不同之处在于,模块导致边界错误的成本比微服务要低得多。因此,在项目开始时,当对业务问题的共同理解较低时,从整体模块开始比从微服务开始更安全。

我们如何识别模块边界?根据我的经验,领域驱动设计的模式是解决这个问题的最佳工具之一。

业务问题
让我们来模拟图书馆和图书借阅流程。这里是需求:图书馆和图书借阅流程。这里是要求:图书借阅流程。以下是要求:

  • 图书馆有数千本书。图书馆有成千上万本书。同一本书可能有多个副本。同一本书可以有多个副本。
  • 在纳入图书馆之前,每本书的背面或其中一页尾页都会印上一个条形码。每本书的背面或其中一页尾部都有一个条形码。图书,每本书的背面或其中一页尾部都有一个条形码。该条形码编号可唯一标识书本背面或其中一页尾部的条形码。该条形码编号可唯一标识图书。
  • 图书馆读者可以在有书的情况下借阅图书。通常,读者在图书馆找到该书,然后到流通处借阅。有时,读者可以直接到服务台按书名借书。通常情况下,读者在图书馆找到图书,然后到流通处借阅。有时,读者可以直接到服务台按书名借书。通常情况下,读者在图书馆找到图书后到流通台借阅。有时,读者可以直接到服务台按书名查找图书,然后到流通台借阅。有时,读者可以直接到服务台按书名 "图书馆 "查找图书,然后到流通台借阅。有时,读者可以直接到服务台按书名.desk 要求借书,然后到流通台借出。有时,读者可以直接到服务台按书名要求借书,然后到流通台按书名借书。
  • 图书的借出期固定为两周。
  • 借书时,读者可以去借书处,也可以把书扔到图书投放区。

划分子域
让我们把这个图书馆域分解成几个子域。其中一个子域是图书的借阅过程。这个子域的主要行为者是想要借书的读者。

另一个子域是图书盘点子域,即图书盘点以及添加和删除带有条形码的图书。这个子域的主要角色是图书管理员或条形码管理员。该子域的主要参与者是图书管理员或管理员。

还可以确定更多的子域--如读者管理,在允许读者借阅图书前对读者进行身份识别和验证、图书报告和分析、向读者发出通知等。但由于我们没有这方面的要求,所以暂时不考虑这些子域。已确定的子域--如读者管理,在允许读者借阅图书前对读者进行身份识别和验证、图书报告和分析、向读者发出通知等。但由于我们没有这方面的需求,所以暂时不考虑。

请注意,这些子域是我们第一次尝试对需求进行细分。它可能是正确的,也可能是完全错误的。更重要的是,我们要根据目前对问题的理解进行尝试。随着时间的推移,我们会有更多的了解,我们可能需要重组子域。这可能是正确的,也可能是完全错误的。更重要的是,我们要根据目前对问题的理解进行尝试。随着时间的推移,我们会获得更多的见解,我们可能需要重组子域。

构建解决方案
对于我们发现的每个子域,我们通过设计一个有界上下文来逐个解决子域问题。这些有界上下文也就是我们的模块化单体应用中的模块。

src/main/javajava
└── example
    ├── borrow
    │   ├── LoanLoan
    │   ├── LoanController
    │   ├── (+) LoanDto
    │   ├── (+) LoanManagement
    │   ├── LoanMapper      
    │   ├── LoanRepository
    │   └── LoanWithBookDto
    └── inventoryinventory
        ├── Book
        ├── BookController
        ├── (+) BookDto
        ├── (+) BookManagement
        ├── BookMapper
        └── BookRepository

图书库存有界上下文图书库存有界上下文
让我们通过子域建模来设计图书库存的有界上下文。我们可以借助聚合模式来实现这一目的。

聚合是数据存储传输的基本要素--您需要加载或保存整个聚合。事务不应跨越聚合边界。

在这个子域中,最需要持久化的是 "图书"。在 Java 中,我们可以将聚合建模为 JPA 实体。

@Entity
@Getter
@NoArgsConstructor
@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"barcode"}))
class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @Embedded
    private Barcode inventoryNumber;

    private String isbn;

    @Embedded
    @AttributeOverride(name =
"name", column = @Column(name = "author"))
    private Author author;

    @Enumerated(EnumType.STRING)
    private BookStatus status;

    @Version
    private Long version;

    public Book(String title, Barcode inventoryNumber, String isbn, Author author) {
        this.title = title;
        this.inventoryNumber = inventoryNumber;
        this.isbn = isbn;
        this.author = author;
        this.status = BookStatus.AVAILABLE;
    }

    public boolean isAvailable() {
        return BookStatus.AVAILABLE.equals(this.status);
    }

    public boolean isIssued() {
        return BookStatus.ISSUED.equals(this.status);
    }

    public Book markIssued() {
        if (this.status.equals(BookStatus.ISSUED)) {
            throw new IllegalStateException(
"Book is already issued!");
        }
        this.status = BookStatus.ISSUED;
        return this;
    }

    public Book markAvailable() {
        this.status = BookStatus.AVAILABLE;
        return this;
    }

    public record Barcode(String barcode) {
    }

    public record Author(String name) {
    }

    public enum BookStatus {
        AVAILABLE, ISSUED
    }
}

源码: GitHub.

聚合
图书聚合由图书实体和三个值对象(条形码、BookStatus 和作者)组成。我们没有把作者变成另一个实体,因为我们没有围绕它的任何业务需求。在现实世界中,我们应该咨询领域专家,了解未来是否会有需求,并据此决定实体和值对象。

在这个聚合中,Book 也充当聚合根,这意味着对这个聚合的任何更改(如修改 Book 的状态)都必须只通过 Book 实体进行,并且仅限于模块本身。就代码而言,这意味着不应有一个公共设置器方法 setStatus() 可供应用程序的其他模块访问。

请注意,上述实现不仅包含状态,还包含行为--markIssued()、markAvailable()。在领域模型中包含行为非常重要,否则就会变成贫血模型。

接下来,我们需要一个存储库来与数据库交互。有了 Spring Data,这就变得轻而易举了:

interface BookRepository extends JpaRepository<Book, Long> {

    Optional<Book> findByIsbn(String isbn);

    Optional<Book> findByInventoryNumber(Book.Barcode inventoryNumber);

    List<Book> findByStatus(Book.BookStatus status);
}

添加了一些常用搜索方法,可通过国际标准书号、条形码和状态查找图书。请注意,该资源库接口的可见性是包私有的,而不是公共的。

接下来,我们将通过 BookManagement 服务创建模块的公共接口。

@Transactional
@Service
@RequiredArgsConstructor
public class BookManagement {

    private final BookRepository bookRepository;
    private final BookMapper mapper;

    public BookDto addToInventory(String title, Book.Barcode inventoryNumber, String isbn, String authorName) {
        var book = new Book(title, inventoryNumber, isbn, new Book.Author(authorName));
        return mapper.toDto(bookRepository.save(book));
    }

    public void removeFromInventory(Long bookId) {
        var book = bookRepository.findById(bookId)
                .orElseThrow(() -> new IllegalArgumentException("Book not found!"));
        if (book.issued()) {
            throw new IllegalStateException(
"Book is currently issued!");
        }
        bookRepository.deleteById(bookId);
    }

    public void issue(String barcode) {
        var inventoryNumber = new Book.Barcode(barcode);
        var book = bookRepository.findByInventoryNumber(inventoryNumber)
                .map(Book::markIssued)
                .orElseThrow(() -> new IllegalArgumentException(
"Book not found!"));
        bookRepository.save(book);
    }

    public void release(String barcode) {
        var inventoryNumber = new Book.Barcode(barcode);
        var book = bookRepository.findByInventoryNumber(inventoryNumber)
                .map(Book::markAvailable)
                .orElseThrow(() -> new IllegalArgumentException(
"Book not found!"));
        bookRepository.save(book);
    }

    @Transactional(readOnly = true)
    public Optional<BookDto> locate(Long id) {
        return bookRepository.findById(id)
                .map(mapper::toDto);
    }

    @Transactional(readOnly = true)
    public List<BookDto> issuedBooks() {
        return bookRepository.findByStatus(Book.BookStatus.ISSUED)
                .stream()
                .map(mapper::toDto)
                .toList();
    }
}

有几点需要注意。BookManagement 服务返回的是 DTO 而不是图书实体。它使用 MapStruct 驱动的映射器将实体转换为 DTO,反之亦然。通过在服务层只返回 DTO,我们保护了领域模型(实体)不会泄漏到控制器层和表现层。对于小型项目来说,这似乎有些矫枉过正,但对于相当大的项目来说,未来的自己会感谢你将域限制在服务层内。

其次,除了 DTO 之外,BookManagement 是其他模块唯一可以访问的类。为此,我们将所有其他类都封装为私有类。还有其他方法可以实现这一点,我们稍后再讨论。

最后,我们可以通过为客户端创建 REST API 来完成有界上下文的实现。这就是 BookController 类。我们只依赖服务层,而不注入存储库。这样可以确保 API 始终按照服务层的保证返回 DTO。

@RestController
@RequiredArgsConstructor
class BookController {

    private final BookManagement books;

    @PostMapping("/books")
    ResponseEntity<BookDto> addBookToInventory(@RequestBody AddBookRequest request) {
        var bookDto = books.addToInventory(request.title(), new Barcode(request.inventoryNumber()), request.isbn(), request.author());
        return ResponseEntity.ok(bookDto);
    }

    @DeleteMapping(
"/books/{id}")
    ResponseEntity<Void> removeBookFromInventory(@PathVariable(
"id") Long id) {
        books.removeFromInventory(id);
        return ResponseEntity.ok().build();
    }

    @GetMapping(
"/books/{id}")
    ResponseEntity<BookDto> viewSingleBook(@PathVariable(
"id") Long id) {
        return books.locate(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @GetMapping(
"/books")
    ResponseEntity<List<BookDto>> viewIssuedBooks() {
        return ResponseEntity.ok(books.issuedBooks());
    }

    record AddBookRequest(String title, String inventoryNumber,
                          String isbn, String author) {
    }
}

通过 "库存有界上下文",我们已经满足了前面列出的前两个要求。

下面"借阅有界上下文BC"将满足其余要求。

借阅BC
借阅BC处理图书馆读者借出和借入图书的事务。它依赖于 "库存 "绑定上下文来检查图书的可用性,并在图书可用的情况下发放读者所需的图书。

在这个子域中需要建模的概念是借书。领域专家告诉我们,这个概念的术语是 "借阅"(Loan)。它是一个长期存在的实体,会随着时间的推移经历不同的状态,并且必须遵循业务规则。因此,它将是这个有界上下文的聚合集合体。

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Loan {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String bookBarcode;

    private Long patronId;

    private LocalDate dateOfIssue;

    private int loanDurationInDays;

    private LocalDate dateOfReturn;

    @Enumerated(EnumType.STRING)
    private LoanStatus status;

    @Version
    private Long version;

    Loan(String bookBarcode) {
        this.bookBarcode = bookBarcode;
        this.dateOfIssue = LocalDate.now();
        this.loanDurationInDays = 14;
        this.status = LoanStatus.ACTIVE;
    }

    public static Loan of(String bookBarcode) {
        return new Loan(bookBarcode);
    }

    public boolean isActive() {
        return LoanStatus.ACTIVE.equals(this.status);
    }

    public boolean isOverdue() {
        return LoanStatus.OVERDUE.equals(this.status);
    }

    public boolean isCompleted() {
        return LoanStatus.COMPLETED.equals(this.status);
    }

    public void complete() {
        if (isCompleted()) {
            throw new IllegalStateException("Loan is not active!");
        }
        this.status = LoanStatus.COMPLETED;
        this.dateOfReturn = LocalDate.now();
    }

    public enum LoanStatus {
        ACTIVE, OVERDUE, COMPLETED
    }
}

请注意,图书实体没有外键关系。相反,我们在 "借阅 "模型中存储了分配给每本书的图书馆库存编号(条形码)。这是一个唯一标识符,因此可以安全地用作参考。

这是允许领域模型驱动实体模型而不是相反的结果。通过不使用外键关系,我们还避免了取值策略(懒惰/急迫)和级联策略带来的无数问题。在 Loan 和 Book 之间没有 JPA 多对一关系模型。它是在领域模型中直观定义的,并由聚合不变式强制执行。

当然,缺点是数据库不再能保护我们免受数据损坏。因此,需要对应用层的实现进行测试。

让我们抵制寻求实体建模的冲动,转而将领域建模作为构建解决方案的第一步。

接下来,我们将看看借阅管理服务(LoanManagement service),有趣的事情就在这里发生。

@Transactional
@Service
@RequiredArgsConstructor
public class LoanManagement {

    private final LoanRepository loanRepository;
    private final BookManagement books;
    private final LoanMapper mapper;

    public LoanDto checkout(String barcode) {
        books.issue(barcode);
        var loan = Loan.of(barcode);
        var savedLoan = loanRepository.save(loan);
        return mapper.toDto(savedLoan);
    }

    public LoanDto checkin(Long loanId) {
        var loan = loanRepository.findById(loanId)
                .orElseThrow(() -> new IllegalArgumentException("No loan found"));
        books.release(loan.getBookBarcode());
        loan.complete();
        return mapper.toDto(loanRepository.save(loan));
    }

    @Transactional(readOnly = true)
    public List<LoanWithBookDto> activeLoans() {
        return loanRepository.findLoansWithStatus(LoanStatus.ACTIVE);
    }

    @Transactional(readOnly = true)
    public Optional<LoanDto> locate(Long loanId) {
        return loanRepository.findById(loanId)
                .map(mapper::toDto);
    }
}

首先要注意的是,LoanManagement 服务依赖于 BookManagement 服务。在借出操作中,需要发放图书。在签到操作中,需要释放已签发的图书。

其次,checkout 和 checkin 的实现根本不执行任何不变式检查。它们只需调用贷款聚合或图书管理服务的方法,然后由这些方法执行不变性检查。这样,LoanManagement 服务的实现就非常清晰易懂了。

最后,与 BookManagement 类似,该服务只返回 Loan DTO,而不返回实体本身。

Borrow 边界上下文还包含在 LoanController 中实现的 REST API。实现过程非常简单,可直接在 GitHub 上查看。

该项目包含 Springdoc 依赖项,用于生成基于 Swagger 的文档,可访问 http://localhost:8080/swagger-ui.html。

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>${springdoc-openapi-starter-webmvc-ui.version}</version>
</dependency>


要启动应用程序,请运行 mvn spring-boot:run。

源码: GitHub.


局限性
在讨论我们实施方案的局限性之前,让我们先回顾一下我们的实施方案。

  • 我们应用了 DDD 原则来构建模块化解决方案。
  • 领域模型是包含数据和行为的真正聚合体。它们负责验证不变式。
  • 代码是可测试的,结构是模块化的,希望也是易于理解的。

但还有一些地方可以改进。

有界上下文BC之间的紧密耦合
如前所述,"借用 "BC与 "库存 "BC之间存在紧密耦合。如果 "库存 "BC "不可用"(在单体中不太可能),那么 "借用 "BC就无法运行。

此外,结账请求在一次事务中更新了 Loan 和 Book 两个聚合。这违反了在一个事务中只更新一个聚合的推荐做法。

和其他事情一样,这也是一种权衡。作为一个单体应用程序,我们处理的是单个数据库,这允许我们更新多个聚合,并保持实现简单。在下一篇博客中,我们将看到一组新的需求将如何迫使我们尝试不同的解决方案。

有界上下文BC的独立测试
紧密耦合的直接后果是,测试单个受限上下文BC(借用)需要处理所有从属上下文(库存)。

这一点在《借阅管理》(LoanManagement)的集成测试中很明显。借出测试必须断言借出图书的状态已更新为 ISSUED。同样,签入测试也必须断言已归还图书的状态已更新为 AVAILABLE。不需要模拟或注入 BookManagement 服务就能测试签出行为,这不是很好吗?

@Transactional
@SpringBootTest
class LoanManagementIT {

    @Autowired
    LoanManagement loans;

    @Autowired
    BookManagement books;

    @Test
    void shouldCreateLoanAndIssueBookOnCheckout() {
        var loanDto = loans.checkout("13268510");
        assertThat(loanDto.status()).isEqualTo(LoanStatus.ACTIVE);
        assertThat(loanDto.bookBarcode()).isEqualTo(
"13268510");
        assertThat(books.locate(1L).get().status()).hasToString(
"ISSUED");
    }

    @Test
    void shouldCompleteLoanAndReleaseBookOnCheckin() {
        var loan = loans.checkin(10L);
        assertThat(loan.status()).isEqualTo(LoanStatus.COMPLETED);
        assertThat(books.locate(2L).get().status()).hasToString(
"AVAILABLE");
    }
}

控制受限上下文BC的接口
如前所述,每个有界上下文BC只公开供其他有界上下文BC(DTO 和服务类)使用的特定类。它们是上下文的接口。这可以通过控制类的可见性来实现。

遗憾的是,这需要仔细和持续的监督。一不小心就会忘记并破坏规则(例如,新开发人员加入项目),最终导致接口扩展。如果任其发展,代码很快就会变得一团糟,无法维护。使用类可见性还可以限制每个上下文的子包。

在理想情况下,如果我们能使用测试来自动防止跨边界上下文包的非法访问,那就再好不过了。