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

相关推荐
来恩10034 小时前
jQuery选择器
前端·javascript·jquery
前端繁华如梦4 小时前
树上挂苹果还是挂玻璃球?Three.js 程序化果实的完整实现指南
前端·javascript
CDwenhuohuo5 小时前
优惠券组件直接用 uview plus
前端·javascript·vue.js
川冰ICE5 小时前
TypeScript装饰器与元编程实战
前端·javascript·typescript
AI砖家5 小时前
Vue3组件传参大全,各种传参方式的对比
前端·javascript·vue.js
希望永不加班5 小时前
var局部变量类型推断的利弊
java·服务器·前端·javascript·html
threelab6 小时前
Three.js 3D 地图可视化 | 三维可视化 / AI 提示词
前端·javascript·人工智能·3d·着色器
失眠的咕噜7 小时前
PDA 安卓设备上传多张图片
android·前端·javascript
掰头战士7 小时前
深入了解JS原型及原型继承链机制
javascript
一只叁木Meow7 小时前
电商 SKU 选择器:用算法实现优雅的用户交互
前端·javascript·算法