Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

command pattern

Tung Bui Thanh (TungDaDev)11 mins read

Command pattern.webp

Sách nhập môn hay giới thiệu Command Pattern qua ví dụ cái điều khiển từ xa bật/tắt bóng đèn (Remote Control & Light). Nhưng nhìn ở góc độ kiến trúc hệ thống thì ý nghĩa của nó lớn hơn nhiều: biến một động từ (hành động gọi hàm) thành một danh từ (đối tượng dữ liệu hạng nhất - First-Class Object). Khi hành động đã được đóng gói thành object độc lập, bạn có thể trì hoãn nó theo thời gian (Queueing / Delayed Execution), gửi nó qua mạng (RPC / Message Bus), ghi xuống đĩa (Write-Ahead Logging), đảo ngược khi lỗi (Undo / Compensating Transaction), và đó cũng là nền cho các mô hình như CQRS và Event Sourcing.

Từ Undo/Redo trong các trình soạn thảo cho tới giao dịch bù trừ trong Saga Orchestration của Microservices, Command Pattern là viên gạch cơ bản để kiểm soát các tác vụ làm thay đổi trạng thái (State Mutations).

Bài này đi qua quá trình tiến hóa của Command Pattern và dựng một Transaction Command Engine chuẩn production, hoàn tác được an toàn.


# bản chất kiến trúc: biến động từ thành danh từ (objectifying actions)

Bình thường, một hành động là một method được gọi và chạy ngay:

// Lập trình thủ tục: Thực thi gắn chặt với thời gian và không gian
accountService.transferMoney(fromAcc, toAcc, amount);

Với Command Pattern, hành động đó được "đóng băng" thành một object dữ liệu độc lập:

# command tách rời được những gì

  1. Tách theo thời gian (Temporal Decoupling): lệnh tạo lúc 8h sáng nhưng có thể nằm trong hàng đợi, đến 2h đêm mới chạy (Batch Job / Off-Peak processing).
  2. Tách theo không gian (Spatial Decoupling): lệnh tạo ở Frontend Web Server, serialize thành JSON, gửi qua RabbitMQ rồi chạy trên Worker Pod ở một Data Center khác.
  3. Đảo ngược được (Reversibility): mỗi lệnh mang đủ thông tin hoặc snapshot để tự hoàn tác chính nó.

# kiến trúc bộ động cơ hoàn tác: dual-stack undo/redo engine

Làm sao xây dựng tính năng Undo/Redo chuẩn cho phần mềm nghiệp vụ (như chỉnh sửa bảng tính tài chính hay bản vẽ kỹ thuật)?

Cách phổ biến là kết hợp Command Pattern với Memento Pattern, dùng hai stack: Undo Stack và Redo Stack:

# cài đặt undo/redo engine an toàn đa luồng

public interface TransactionCommand {
    void execute();
    void undo();
    String getDescription();
}
 
public class UndoRedoManager {
 
    private final int maxHistorySize;
    private final Deque<TransactionCommand> undoStack = new ArrayDeque<>();
    private final Deque<TransactionCommand> redoStack = new ArrayDeque<>();
 
    public UndoRedoManager(int maxHistorySize) {
        this.maxHistorySize = maxHistorySize;
    }
 
    public synchronized void executeCommand(TransactionCommand command) {
        command.execute();
 
        // Giới hạn dung lượng stack để tránh OOM Memory Leak
        if (undoStack.size() >= maxHistorySize) {
            undoStack.removeLast(); // Vứt bỏ lịch sử cũ nhất
        }
 
        undoStack.push(command);
        redoStack.clear(); // Bắt buộc xóa Redo stack khi người dùng phát sinh hành động mới
    }
 
    public synchronized boolean undo() {
        if (undoStack.isEmpty()) {
            return false;
        }
        // peek trước, chỉ pop khi undo() chạy xong không lỗi,
        // để command không bị "rơi mất" khỏi cả hai stack nếu undo() ném exception
        TransactionCommand command = undoStack.peek();
        command.undo();
        undoStack.pop();
        redoStack.push(command);
        return true;
    }
 
    public synchronized boolean redo() {
        if (redoStack.isEmpty()) {
            return false;
        }
        TransactionCommand command = redoStack.peek();
        command.execute();
        redoStack.pop();
        undoStack.push(command);
        return true;
    }
}

Chữ "an toàn đa luồng" ở đây chỉ có nghĩa là các thao tác trên hai stack không giẫm lên nhau, nhờ synchronized. Nó hợp với một phiên làm việc của một người dùng (một tài liệu đang mở). Nếu undo() hay execute() ném exception thì command vẫn nằm nguyên ở stack cũ, người dùng thử lại được, thay vì bị mất khỏi lịch sử như khi pop() trước rồi mới chạy.


# tiến hóa lên kiến trúc CQRS (Command query responsibility segregation)

Trong hệ thống phân tán quy mô lớn (High-Load Architecture), ý tưởng "hành động là một object" phát triển thành hẳn một phong cách kiến trúc: CQRS. Nói cho đúng gốc gác thì CQRS do Greg Young đặt tên, phát triển từ nguyên tắc CQS (Command-Query Separation) của Bertrand Meyer, chứ không phải từ GoF. Command trong CQRS cũng khác GoF một chút: nó thường chỉ là dữ liệu (một record CreateOrderCommand), còn logic nằm ở CommandHandler riêng, thay vì command tự có execute().

# nguyên tắc của CQRS

  1. Commands: thể hiện ý định thay đổi trạng thái (Create, Update, Delete). Đặt tên theo động từ mệnh lệnh (CreateOrderCommand, CancelSubscriptionCommand). Không trả về dữ liệu nghiệp vụ, chỉ trả về trạng thái (Success / Failure / TransactionId).
  2. Queries: tra cứu dữ liệu (GetOrderByIdQuery). Không được làm thay đổi trạng thái (Side-Effect Free).

# Saga pattern: compensating transactions (giao dịch bù trừ)

Trong Microservices, mỗi service giữ database riêng, nên thường không dùng được (hoặc không nên dùng) Distributed Two-Phase Commit để rollback khi một bước bị lỗi: 2PC khoá tài nguyên suốt lúc chờ coordinator, coordinator chết là cả hệ thống treo, và nhiều message broker hay NoSQL không hỗ trợ XA. Saga (khái niệm từ paper của Garcia-Molina và Salem năm 1987) xử lý bằng Giao dịch bù trừ (Compensating Transaction), và mỗi bước lẫn mỗi bước bù đều là một command:

# compensating Command là gì

  • Không phải một phép "Rollback DB" vật lý.
  • Đó là một Command nghiệp vụ mới, có nhiệm vụ đảo ngược tác động logic của lệnh trước đó:
    • Lệnh trước: ChargeCreditCardCommand($100)
    • Lệnh bù trừ: RefundCreditCardCommand($100)
  • Bù trừ không xoá được dấu vết: khách vẫn thấy một lần trừ tiền rồi một lần hoàn tiền trên sao kê. Vì vậy trong thực tế người ta hay xếp các bước khó bù (trừ tiền, gửi email) về sau các bước dễ bù hoặc dễ thất bại. Ví dụ ở sơ đồ trên, giữ hàng trong kho trước rồi mới trừ tiền thì ca hết hàng sẽ không phải hoàn tiền.

# cài đặt: ledger balance Command có khả năng hoàn tác

Ví dụ trong hệ thống Core Banking: lệnh chuyển tiền có thể hoàn tác. Chỗ này khác hẳn trình soạn thảo ở trên. Tài khoản là dữ liệu dùng chung, giữa lúc chuyển và lúc hoàn tác có thể đã có giao dịch khác cộng trừ vào. Nếu undo bằng cách ghi đè lại số dư cũ (Memento) thì mọi giao dịch xen giữa sẽ bị xoá sạch. Nên undo ở đây là một bút toán ngược (chuyển lại đúng amount), đúng tinh thần compensating command:

public class TransferMoneyCommand implements TransactionCommand {
 
    private final AccountRepository accountRepo;
    private final TransactionTemplate txTemplate;
    private final String sourceAccId;
    private final String targetAccId;
    private final BigDecimal amount;
 
    private boolean executed = false;
 
    public TransferMoneyCommand(
            AccountRepository accountRepo,
            TransactionTemplate txTemplate,
            String sourceAccId,
            String targetAccId,
            BigDecimal amount) {
        this.accountRepo = accountRepo;
        this.txTemplate = txTemplate;
        this.sourceAccId = sourceAccId;
        this.targetAccId = targetAccId;
        this.amount = amount;
    }
 
    @Override
    public void execute() {
        // Command được tạo bằng new, không phải Spring bean, nên @Transactional
        // đặt trên method này sẽ không có tác dụng. Dùng TransactionTemplate.
        txTemplate.executeWithoutResult(status -> {
            move(sourceAccId, targetAccId, amount);
            this.executed = true;
        });
    }
 
    @Override
    public void undo() {
        if (!executed) return;
 
        // Bút toán ngược: chuyển lại đúng số tiền, không ghi đè số dư cũ
        txTemplate.executeWithoutResult(status -> {
            move(targetAccId, sourceAccId, amount);
            this.executed = false;
        });
    }
 
    private void move(String fromId, String toId, BigDecimal value) {
        var from = accountRepo.findById(fromId).orElseThrow();
        var to = accountRepo.findById(toId).orElseThrow();
 
        if (from.getBalance().compareTo(value) < 0) {
            throw new InsufficientBalanceException("Số dư không đủ");
        }
 
        from.setBalance(from.getBalance().subtract(value));
        to.setBalance(to.getBalance().add(value));
 
        accountRepo.save(from);
        accountRepo.save(to);
    }
 
    @Override
    public String getDescription() {
        return "Chuyển tiền: " + amount + " từ " + sourceAccId + " sang " + targetAccId;
    }
}

Hai chỗ cần để ý. Thứ nhất, @Transactional của Spring chỉ chạy khi method được gọi qua proxy của một bean; command tự new ra thì annotation đó bị bỏ qua âm thầm, nên ở đây truyền TransactionTemplate vào. Thứ hai, findById rồi save như trên vẫn dính lost update nếu hai giao dịch cùng chạm một tài khoản: production cần khoá dòng (@Lock(PESSIMISTIC_WRITE) trên query của repository) hoặc optimistic lock bằng cột @Version. Undo cũng có thể thất bại thật (người nhận đã tiêu hết tiền), và đó là chuyện nghiệp vụ phải quyết, không phải chuyện kỹ thuật.


# bảng kiểm tra sẵn sàng vận hành (architect's checklist)

  • 1. Giới hạn kích thước Undo Stack: cấu hình dung lượng tối đa cho undoStack (ví dụ: 50 phần tử) để tránh rò rỉ Heap khi giữ hàng ngàn command quá lâu.
  • 2. Command bất biến (Immutability): tham số đầu vào của Command nên bất biến (dùng Java Record) để không bị can thiệp giữa lúc tạo và lúc thực thi.
  • 3. Compensating Command phải Idempotent: chạy 1 lần hay nhiều lần đều an toàn, phòng khi bị retry do mạng.
  • 4. Serialization Support: nếu Command được lưu vào Queue hoặc Database, class phải chuyển đổi JSON hai chiều được qua Jackson. Muốn vậy thì tách phần dữ liệu (record chỉ gồm id tài khoản, số tiền) khỏi phần xử lý (handler giữ repository); command như TransferMoneyCommand ở trên giữ cả AccountRepository nên không serialize được.
  • 5. Audit Logging: mọi Command làm thay đổi trạng thái nhạy cảm phải được ghi vào bảng Audit Trail kèm người dùng và thời gian.

# lời kết

Command Pattern là một trong số ít pattern đi được từ code OOP trong một process lên tới kiến trúc hệ thống phân tán.

Đóng gói hành động thành object độc lập thì từ đó đi được khá xa: từ Undo/Redo, tách đọc/ghi với CQRS, cho tới khả năng tự phục hồi của Saga Compensating Transactions.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.