簡介#
在前一個模組中,我們引入了明確的 commands 和 queries 以及對應的 handlers。本模組將展示如何以簡單但強大的方式來豐富(enrich)這些 handlers,利用先前建立的基礎架構,輕鬆地在應用程式中加入 cross-cutting concerns。
新需求:Database Retries#
假設我們收到一個新需求:資料庫連線不穩定,時常斷線,因此需要實作 retry 機制。
天真的做法#
最直覺的做法是在 EditPersonalInfoCommandHandler 的 handle 方法中,將 unitOfWork.commit() 包在 try-catch 迴圈裡重試三次:
public Result handle(EditPersonalInfoCommand command) {
Repository studentRepo = new StudentRepository(unitOfWork);
Student student = studentRepo.getById(command.getId());
if (student == null)
return Result.fail("No student found with Id '{id}'");
student.setName(command.getName());
student.setEmail(command.getEmail());
for (int i = 0; i < 3; i++) {
try { unitOfWork.commit(); }
catch (Exception e) { continue; }
}
return Result.oK();
}然而這個做法行不通,原因有二:
- 當資料庫連線中斷時,原本持有的 connection 會變得無法使用,即使資料庫恢復上線,也無法直接重新呼叫
commit(),必須建立新的資料庫連線 commit()不是唯一存取資料庫的地方,getById()同樣會存取資料庫,如果在兩次嘗試之間資料發生變化,也需要重新讀取
正確的做法:重新執行整個 Handler#
唯一可靠的方式是重新執行整個 command handler:
public Result handle(EditPersonalInfoCommand command) {
for (int i = 0; i < 3; i++) {
try {
Repository studentRepo = new StudentRepository(unitOfWork);
Student student = studentRepo.getById(command.getId());
if (student == null)
return Result.fail("No student found with Id '{id}'");
student.setName(command.getName());
student.setEmail(command.getEmail());
unitOfWork.commit();
return Result.oK();
} catch (Exception e) {
continue;
}
}
}但這帶來兩個問題:
- 程式碼冗長且混亂
- 如果要在其他 command handler 也實作 retry,只能複製貼上這段 try-catch 迴圈,造成大量重複程式碼
幸運的是,我們可以利用前一模組中所有 command 和 query handlers 共同實作的統一介面,在其上引入 decorators。
引入 Database Retry Decorator#
建立一個 DatabaseRetryDecorator 類別,實作 ICommandHandler 介面:
package logic.decorators;
public class DatabaseRetryDecorator implements ICommandHandler {
private ICommandHandler handler;
private Config config;
public DatabaseRetryDecorator(ICommandHandler handler, Config config) {
this.config = config;
this.handler = handler;
}
public Result handle(ICommand command) {
for (int i = 0; ; i++) {
try {
Result result = handler.handle(command);
return result;
} catch (Exception ex) {
if (i >= config.getNumberOfDatabaseRetries()
|| !isDatabaseException(ex))
throw new Exception();
}
}
}
private boolean isDatabaseException(Exception exception) {
String message = exception.getInnerException().getMessage();
if (message == null)
return false;
return message.contains("The connection is broken and recovery is not possible")
|| message.contains("error occurred while establishing a connection");
}
}這個 decorator 的運作方式:
- 實作與所有 command handlers 相同的
ICommandHandler介面 - 透過建構子注入一個被包裝的 handler 和設定檔
- 在
handle方法中,用 for 迴圈搭配 try-catch 來執行被包裝的 handler - 捕獲到 exception 後,檢查是否為資料庫相關的例外,以及是否超過最大重試次數
- 若非資料庫例外或超過次數,則重新拋出例外
當資料庫連線中斷時,原有的連線不可重用。因此 command handler 需要注入
SessionFactory(而非UnitOfWork),在每次重試時建立新的UnitOfWork實例。
Decorator Pattern#
Decorator 是一個修改既有 class 或 method 行為的 class 或 method,但不改變其 public interface,因此不影響該 class 的使用者。
在範例中,decorator 實作了與所有 command handlers 相同的 ICommandHandler 介面,但它不是真正的 command handler,而是以額外邏輯增強 handler 的行為,且不需要修改任何原有的 command handler 程式碼。
Decorator 的優點#
- 引入 cross-cutting concerns,不產生重複程式碼
- 遵守 Single Responsibility Principle
- 可以串聯多個 decorators,組合出複雜功能
Decorator 與 Handler 的職責分離#
- Decorators 負責處理技術性問題(如 retry、logging)
- Handlers 負責處理業務邏輯(business use cases)
可以使用 Spring AOP 以更宣告式的方式來套用 decorators。
引入另一個 Decorator#
假設又收到新需求:為 EditPersonalInfo 實作 audit logging。利用已建立的 decorator 機制,只需新增一個 AuditLoggingDecorator:
package logic.decorators;
public class AuditLoggingDecorator implements ICommandHandler {
private ICommandHandler handler;
public AuditLoggingDecorator(ICommandHandler handler) {
this.handler = handler;
}
public Result handle(ICommand command) {
String commandJson = JsonConvert.serializeObject(command);
Console.writeLine("Command of type " + command.getType().getName() + ": " + commandJson);
return handler.handle(command);
}
}這個 decorator 的運作方式:
- 同樣實作
ICommandHandler介面 - 在呼叫被包裝的 handler 之前,先將 command 序列化為 JSON 並記錄下來
- 然後委派給實際的 handler 執行
在實際應用中,應該注入一個 logger 取代
Console.writeLine,就像在DatabaseRetryDecorator中注入 config 一樣。
多個 decorators 可以串聯起來,例如:先經過 AuditLoggingDecorator 記錄日誌,再經過 DatabaseRetryDecorator 處理重試,最後才到真正的 handler。
Command 和 Query Handlers 最佳實踐#
將 Handler 嵌套在 Command/Query 類別中#
隨著應用程式的發展和重構,可能會刪除某些 handlers,卻忘記刪除對應的 command 或 query,留下孤立的(orphaned)類別。
解決方法是將 handler 類別嵌套在對應的 command 或 query 類別內部:
- 刪除 command 時,handler 也會一起被刪除
- 提升可發現性(discoverability)和內聚性(cohesion)
- 可以將 handler 設為 non-public,它們不需要成為組件的公開 API
不要重用 Command Handlers#
當兩個類似的 use case 出現時,可能會想從一個 command handler 呼叫另一個。例如在 UnregisterCommandHandler 中 dispatch DisenrollCommand:
class UnregisterCommandHandler implements ICommandHandler {
public Result handle(UnregisterCommand command) {
UnitOfWork unitOfWork = new UnitOfWork(sessionFactory);
Repository repository = new StudentRepository(unitOfWork);
Student student = repository.getById(command.Id);
if (student == null)
return Result.fail("No student found for Id " + command.getId());
gate.dispatch(new DisenrollCommand(command.getId(), 0, "Unregistering"));
gate.dispatch(new DisenrollCommand(command.getId(), 1, "Unregistering"));
repository.delete(student);
unitOfWork.commit();
return Result.ok();
}
}這是不正確的做法,因為:
- Commands 不應該產生其他 commands – command 代表的是 client 可以對應用程式做的操作,只有 client 才能發起 commands
- 系統本身應該回應 commands 並產生 domain events,而非自行建立後續的 commands
從一個 command handler 中 dispatch 另一個 command 是對 command 概念的誤用(misuse)。Commands 應由外部 client 觸發,系統內部的反應應透過 domain model 來處理。
正確的重用方式#
如果需要在多個 handlers 之間共用邏輯,應該將共用程式碼抽取到 domain model 中(例如 domain service),然後在各個 handler 中使用。這樣既達到程式碼重用,又不會產生 command handler 互相呼叫的問題。
小結#
- 使用 Decorator Pattern 來擴展 command 和 query handlers 的功能,無需修改 handler 本身
- Decorator 是一個修改另一個 class 行為但不改變其 public interface 的 class,本質上是保持介面不變的 wrapper
- Decorator 可以引入 cross-cutting concerns(如 database retry、audit logging),避免程式碼重複,並遵守 Single Responsibility Principle
- 多個 decorators 可以串聯,組合出複雜功能,同時保持每個 decorator 小巧且專注
- 最佳實踐:將 handler 嵌套在對應的 command/query 類別內部,避免孤立類別
- 最佳實踐:不要重用 command handlers,共用邏輯應抽取到 domain model(如 domain service)中