Segregating Commands and Queries#

在前一個 module 中,我們將應用程式重構為 task-based interface。所有學生可以執行的操作現在都有明確定義,並擁有各自的 API endpoints。本模組的下一個目標是為這些 API endpoints 引入顯式的 commandsqueries,這將帶來 CQRS 的三大優勢:scalability、performance 和 simplicity。

Introducing a First Command#

首先,我們需要辨識 StudentController 中哪些 API endpoints 屬於 commands,哪些屬於 queries:

  • QuerygetList 方法 – 從資料庫查詢學生列表並回傳,不會改變狀態
  • CommandsRegisterUnregisterEnrollTransferDisenrollEditPersonalInfo – 這些方法都會改變資料庫狀態,且除了操作確認外不回傳資料

這個分類與 REST 最佳實務完美對齊:PUT/POST HTTP methods 對應 commands,GET HTTP method 對應 queries。REST 本質上遵循 Command-Query Separation 原則。

為了繼續走向 CQRS,我們需要為每個 command 和 query 建立獨立的 class。以 EditPersonalInfo 為例,建立一個 command class:

package logic.students;

public class EditPersonalInfoCommand {
    public long id;
    public String name;
    public String email;
    // setters & getters
}

Command 與 Handler 的分離#

Command 本身應該是意圖的宣告(declaration of intent),代表「需要做什麼」。而執行(execution)是另一回事,通常需要存取外部世界(如資料庫、第三方系統),因此不應把執行邏輯直接放在 command 中。應該建立一個獨立的 command handler class:

class EditPersonalInfoCommandHandler {
    public void handle(EditPersonalInfoCommand command) {
    }
}

Handler 包含一個 handle 方法,接受 command 並執行對應的業務邏輯。

引入共用 Interface#

為了之後能引入 decorators 來擴展 handler 的功能,我們需要一個共用的 interface。引入兩個新型別:

ICommand – 一個 marker interface,用於標記 codebase 中的 commands:

interface Command {}

public class EditPersonalInfoCommand implements ICommand {
    // ...
}

ICommandHandler – 定義 handler 的共用介面:

interface ICommandHandler {
    public Result handle(ICommand command);
}

使用 Result class 處理錯誤#

不建議讓 command handler 回傳 IActionResult 或拋出 exception 來處理驗證錯誤。更好的做法是使用一個 Result class,代表操作的成功或失敗:

class EditPersonalInfoCommandHandler implements ICommandHandler {
    private UnitOfWork unitOfWork;

    public EditPersonalInfoCommandHandler(UnitOfWork unitOfWork) {
        this.unitOfWork = unitOfWork;
    }

    public Result handle(EditPersonalInfoCommand command) {
        Repository studentRepository = new StudentRepository(unitOfWork);
        Student student = studentRepository.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();
    }
}

不要用 exception 來控制程式流程或做驗證。Exception 會讓程式碼變得複雜且難以追蹤,使用明確的回傳值(如 Result class)是更好的設計選擇。

Controller 此時的職責變得很簡單:建立 command、實例化 handler、呼叫 handle、解析結果:

@PutMapping("{id}")
public IActionResult editPersonalInfo(@PathParam("id") long id,
        @RequestBody StudentPersonalInfoDto dto) {
    EditPersonalInfoCommand command = new EditPersonalInfoCommand();
    command.setEmail(dto.getEmail());
    command.setName(dto.getName());
    command.setId(id);
    EditPersonalInfoCommandHandler handler = new EditPersonalInfoCommandHandler(unitOfWork);
    Result result = handler.handle(command);
    return result.isSuccess() ? ok() : Error(result.error());
}

Commands in CQS vs Commands in CQRS#

我們引入了第一個 command(EditPersonalInfoCommand)和它的 command handler。需要注意的是,Command 這個詞在兩種不同的語境下有不同的含義:

  • CQS 中的 Command:指的是一個 controller method,它會改變應用程式狀態且不回傳資料給 client
  • CQRS 中的 Command:指的是一個 class(如 EditPersonalInfoCommand),是需要在應用程式中執行的操作的顯式表示

你可以把 CQRS command 想像成 serializable method call – 一個可序列化的方法呼叫。Command handler 的 handle() 方法本身在 CQS 的意義上也是一個 command,因為它會改變狀態且除了操作確認外不回傳任何東西。這兩個概念是緊密相連的。

Commands and Queries in CQRS#

應用程式中的所有 messages 可以分為三類:

  • Commands:告訴應用程式去做某件事(Tell the application to do something)
  • Queries:向應用程式詢問某些資訊(Ask the application about something)
  • Events:通知外部應用程式某些變更(Inform external applications about some change)

Client 透過 commands 和 queries 與我們的應用程式互動;而應用程式則透過 events 與外部系統溝通。

三種訊息流:Client 透過 Commands 和 Queries 與應用程式互動,應用程式透過 Events 通知外部系統

命名規範#

三種 messages 各有各的命名規範:

  • Commands:使用祈使語氣(imperative tense),例如 EditPersonalInfoCommand,因為它們是在「命令」應用程式做某件事。這也暗示伺服器可以拒絕這個命令
  • Queries:通常以 “Get” 開頭,例如 GetListQuery,因為 queries 是在請求應用程式提供資料
  • Events:使用過去式(past tense),例如 PersonalInfoChangedEvent,因為它們描述的是已經發生的事實,應用程式無法拒絕一個 event

使用 ubiquitous language 來命名 commands,避免 CRUD-based thinking。例如,不要使用 CreateStudentCommand / UpdateStudentCommand / DeleteStudentCommand,而應使用更貼近業務語言的名稱。

後綴(CommandQueryEvent)並非必要。如果你遵循上述命名規範,光靠命名慣例就足以區分三種 messages。保留或省略後綴都是可以的。

Commands and Queries in the Onion Architecture#

在 Onion Architecture 中,應用程式分為三層:

  • Core domain(核心層):Entities、Aggregates、Value Objects、Domain Events、Pure Domain Services
  • Non-core domain(中間層):Repositories、Impure Domain Services
  • Non-domain(外層):Application Services、UI

Onion Architecture 三層:Core Domain(Entities、Aggregates 等)、Non-core Domain(Repositories)、Non-domain(Application Services、UI)

Messages 屬於 Core Domain#

所有 messages(commands、queries、events)都屬於 core domain 層,與 domain events 位於同一層。它們明確地表示:

  • Command = 對應用程式執行的操作
  • Query = 向應用程式提出的問題
  • Event = 給外部應用程式的結果

Push Model vs Pull Model#

Commands 和 events 位於同一個抽象層次,但有一個關鍵差異:

  • Commands 遵循 push model:是外部(其他人、其他系統)發送 commands 給我們的應用程式
  • Events 遵循 pull model:是我們的應用程式自己產生 events,通知外部系統

Commands 遵循 Push Model(外部推入),Events 遵循 Pull Model(內部產出)

Command Handlers 的位置#

由於 commands 和 queries 屬於 core domain,它們不應包含處理邏輯(handling logic)。Core domain 中的 classes 不應該引用外部世界的元件。

Command handlers 則應放在 Application Services 層,因為它們扮演的角色與 Application Services 相同 – 需要存取外部世界(如 repository、database)。將 handler 從 controller 搬到獨立的 handler class,只是在 Application Services 層內部的重新安排。

Command Handlers 位於 Application Services 層(Non-domain),Core Domain 中的 classes 應與外部世界隔離

Commands vs DTOs#

Commands 和 DTOs(Data Transfer Objects)是不同的東西,解決不同的問題:

  • Commands = serializable method calls(可序列化的方法呼叫)
  • DTOs = data contracts(資料契約),主要用於提供 backward compatibility

為什麼不該用 Command 取代 DTO#

許多人會跳過 DTO 到 command 的 mapping 階段,直接讓 controller 接受 command:

@PutMapping("{id}")
public IActionResult editPersonalInfo(@RequestBody EditPersonalInfoCommand command) {
    IHandler handler = new EditPersonalInfoCommandHandler(unitOfWork);
    Result result = handler.handle(command);
    return result.isSuccess() ? ok() : error(result.error);
}

這樣做的問題在於,command 直接暴露給外部 client。如果未來需要修改 command 的結構(例如將 name 拆分為 firstNamelastName),就無法在保持 backward compatibility 的同時修改 command。

使用 commands 取代 DTOs 類似於使用 domain entities 取代 DTOs,兩者都會妨礙你重構 domain model 的能力。

DTO 與 Command 各司其職#

正確的做法是保留兩層,讓它們各自負責:

  • DTOs = backward compatibility(向後相容性),可以有多個版本的 data contracts
  • Commands = 明確表達應用程式可以做什麼(actions upon the application)

兩者之間透過 mapping 來連接,確保應用程式既能保持向後相容,又能自由重構。

如果你不需要 backward compatibility(例如你只有一個自己開發的 client,且可以同時部署 API 和 client),那麼直接使用 commands 取代 DTOs 是可以的。將其視為一種程式碼複雜度的優化手段。

Leveraging Spring to Resolve Command and Handlers Pattern#

手動在每個 controller action 中實例化 handlers 是不方便的。我們可以利用 Spring 的 dependency injection 來自動解析 command handlers。

使用 @CommandHandler annotation 標記 handler,並透過 @Autowired 注入依賴:

@CommandHandler
public class EditPersonalInfoCommandHandler
        implements ICommandHandler<EditPersonalInfoCommand, Result> {
    @Autowired
    UnitOfWork unitOfWork;

    @Override
    public Result handle(final EditPersonalInfoCommand command) throws Exception {
        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();
    }
}

透過注入 Gate dependency,controller 變得非常簡潔:

@RestController
public class StudentController {
    @Autowired
    private Gate gate;

    @PutMapping("{id}")
    public IActionResult editPersonalInfo(@RequestBody EditPersonalInfoCommand command) {
        Result result = gate.dispatch(command);
        return result.isSuccess() ? ok() : error(result.error);
    }
}

傳送 command 有兩種方式:

  • 同步gate.dispatch(command);
  • 非同步gate.dispatchAsync(command);

Introducing a Query#

利用 Spring 的 dependency injection 和 Command-Handler pattern 非常方便。接下來引入 query 的部分。

ICommand 類似,建立 IQuery interface 和 IQueryHandler interface:

public interface IQuery {}

public interface IQueryHandler {
    IResult handle(IQuery query);
}

差別在於 command handler 通常只回傳操作成功或失敗的確認(Result),而 query handler 需要回傳具體的資料,且資料型別會因 query 不同而不同。

GetListQuery 為例:

public class GetListQuery implements IQuery {
    private String enrolledIn;
    private int numberOfCourses;
    // getters, setters and constructor
}

@QueryHandler
public class GetListQueryHandler implements IQueryHandler<GetListQuery, List<StudentDto>> {
    @Autowired
    private UnitOfWork unitOfWork;

    public List<StudentDto> handle(GetListQuery query) {
        Repository studentRepo = new StudentRepository(unitOfWork);
        List<StudentDto> dtos = convertToDtos(studentRepo.getList(enrolled, number));
        return dtos;
    }
}

Controller 中使用 Gate 來 dispatch query,與 command 保持一致的模式:

public class StudentController {
    @Autowired
    private Gate gate;

    @GetMapping()
    public IActionResult getList(String enrolled, int number) {
        GetListQuery query = new GetListQuery(enrolled, number);
        Result result = gate.dispatch(query);
        return result.isSuccess() ? ok() : error(result.error);
    }
}

Query handler 不需要知道 StudentDto 的存在(理想情況下,所有 DTOs / data contracts 應該放在獨立的 assembly 中),API 和 Logic 兩個 project 都引用這個獨立的 data contracts project。

小結#

本模組的核心內容是將應用程式重構為使用顯式的 commands 和 queries,以下是關鍵要點:

  • CQS vs CQRS 中的 Command:在 CQS 中,command 是一個改變狀態的 method;在 CQRS 中,command 是一個代表「應用程式可以做什麼」的 class
  • Command 是 serializable method call,command handler 相當於只有一個 CQS command method 的 Spring controller
  • 三種 Messages:commands(告訴應用程式做事)、queries(向應用程式詢問)、events(通知外部系統)
  • 命名規範:commands 用祈使語氣、events 用過去式、queries 以 “Get” 開頭,務必使用 ubiquitous language
  • Onion Architecture 中的位置:commands、queries 和 events 屬於 core domain 層;command handlers 屬於 Application Services 層
  • Commands vs DTOs:DTOs 負責 backward compatibility,commands 負責明確表達應用程式的操作。除非不需要向後相容,否則不應混用
  • 利用 Spring DI:透過 Spring 的 dependency injection 和 Gate pattern,自動解析 handlers,讓 controller 保持簡潔
  • 下一個模組將討論如何在 command 和 query handlers 之上實作 decorators,以簡單而強大的方式引入 cross-cutting concerns