使用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 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

相关推荐
john_hjy18 分钟前
11. 异步编程
运维·服务器·javascript
风清扬_jd41 分钟前
Chromium 中JavaScript Fetch API接口c++代码实现(二)
javascript·c++·chrome
yanlele1 小时前
前瞻 - 盘点 ES2025 已经定稿的语法规范
前端·javascript·代码规范
It'sMyGo1 小时前
Javascript数组研究09_Array.prototype[Symbol.unscopables]
开发语言·javascript·原型模式
xgq1 小时前
使用File System Access API 直接读写本地文件
前端·javascript·面试
李是啥也不会1 小时前
数组的概念
javascript
无咎.lsy2 小时前
vue之vuex的使用及举例
前端·javascript·vue.js
fishmemory7sec2 小时前
Electron 主进程与渲染进程、预加载preload.js
前端·javascript·electron
fishmemory7sec2 小时前
Electron 使⽤ electron-builder 打包应用
前端·javascript·electron
JUNAI_Strive_ving3 小时前
番茄小说逆向爬取
javascript·python