用模塊化整體架構編寫的代碼實際上是什么樣的?借助 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