用模块化整体架构编写的代码实际上是什么样的?借助 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 和服务类)使用的特定类。它们是上下文的接口。这可以通过控制类的可见性来实现。
遗憾的是,这需要仔细和持续的监督。一不小心就会忘记并破坏规则(例如,新开发人员加入项目),最终导致接口扩展。如果任其发展,代码很快就会变得一团糟,无法维护。使用类可见性还可以限制每个上下文的子包。
在理想情况下,如果我们能使用测试来自动防止跨边界上下文包的非法访问,那就再好不过了。