用模块化整体架构编写的代码实际上是什么样的?借助 Spring Boot 和 DDD,我们踏上了编写可维护和可演化代码的旅程。
当谈论模块化整体代码时,我们的目标是以下几点:
- 应用程序被组织成模块。每个模块解决业务问题的不同部分。
- 模块是松散耦合的。不同模块之间没有循环依赖关系,因为它会导致代码难以维护。
- 完整的应用程序在运行时部署为单个单元。这是整体部分。
- 模块的公共接口(暴露给其他模块的行为)是灵活的并且可以原子地更改。与微服务不同,当我们需要更改模块的公共接口时,使用该接口的其他模块可以一起更改并推出。
边界的确定仍然很重要。不同之处在于,模块导致边界错误的成本比微服务要低得多。因此,在项目开始时,当对业务问题的共同理解较低时,从整体模块开始比从微服务开始更安全。
我们如何识别模块边界?根据我的经验,领域驱动设计的模式是解决这个问题的最佳工具之一。
业务问题
 让我们来模拟图书馆和图书借阅流程。这里是需求:图书馆和图书借阅流程。这里是要求:图书借阅流程。以下是要求:
- 图书馆有数千本书。图书馆有成千上万本书。同一本书可能有多个副本。同一本书可以有多个副本。
- 在纳入图书馆之前,每本书的背面或其中一页尾页都会印上一个条形码。每本书的背面或其中一页尾部都有一个条形码。图书,每本书的背面或其中一页尾部都有一个条形码。该条形码编号可唯一标识书本背面或其中一页尾部的条形码。该条形码编号可唯一标识图书。
- 图书馆读者可以在有书的情况下借阅图书。通常,读者在图书馆找到该书,然后到流通处借阅。有时,读者可以直接到服务台按书名借书。通常情况下,读者在图书馆找到图书,然后到流通处借阅。有时,读者可以直接到服务台按书名借书。通常情况下,读者在图书馆找到图书后到流通台借阅。有时,读者可以直接到服务台按书名查找图书,然后到流通台借阅。有时,读者可以直接到服务台按书名 "图书馆 "查找图书,然后到流通台借阅。有时,读者可以直接到服务台按书名.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 findByIsbn(String isbn);
Optional findByInventoryNumber(Book.Barcode inventoryNumber);
    List 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 locate(Long id) { 
 return bookRepository.findById(id) 
 .map(mapper::toDto); 
 } 
    @Transactional(readOnly = true)
 public List 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 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 removeBookFromInventory(@PathVariable("id") Long id) { 
 books.removeFromInventory(id); 
 return ResponseEntity.ok().build(); 
 } 
    @GetMapping("/books/{id}")
 ResponseEntity viewSingleBook(@PathVariable("id") Long id) { 
 return books.locate(id) 
 .map(ResponseEntity::ok) 
 .orElse(ResponseEntity.notFound().build()); 
 } 
    @GetMapping("/books")
 ResponseEntity<List > 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 activeLoans() { 
 return loanRepository.findLoansWithStatus(LoanStatus.ACTIVE); 
 } 
    @Transactional(readOnly = true)
 public Optional 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。
org.springdoc springdoc-openapi-starter-webmvc-ui ${springdoc-openapi-starter-webmvc-ui.version}要启动应用程序,请运行 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 和服务类)使用的特定类。它们是上下文的接口。这可以通过控制类的可见性来实现。
遗憾的是,这需要仔细和持续的监督。一不小心就会忘记并破坏规则(例如,新开发人员加入项目),最终导致接口扩展。如果任其发展,代码很快就会变得一团糟,无法维护。使用类可见性还可以限制每个上下文的子包。
在理想情况下,如果我们能使用测试来自动防止跨边界上下文包的非法访问,那就再好不过了。
https://www.jdon.com/70712.html