Giỏ hàng của một cửa hàng trực tuyến cần ba thứ mà việc gọi hàm trực tiếp không cho: nút "Hoàn tác" sau khi khách lỡ xoá sản phẩm, nút "Làm lại" khi khách đổi ý, và nhật ký mọi thao tác để đối soát. Ngoài ra, các việc sau đơn hàng (gửi email, đồng bộ kế toán, gọi hãng vận chuyển) nên được xếp hàng và thử lại khi lỗi. Cả hai nhu cầu có chung lời giải: biến mỗi yêu cầu thành một đối tượng. Đó là Command pattern. Bài này cài đặt Command pattern từ đầu bằng Java 21 (code chỉ cần Java 17 trở lên) với hai ngăn xếp undo/redo, thêm lệnh tổ hợp (macro), dựng hàng đợi lệnh có thử lại và dead-letter, rồi so với UndoManager của JDK. Mọi đoạn code đã chạy trên JDK 21, output được dán nguyên văn.

Hai bài trước trong series là Template Method: khung thuật toán và điểm mở rộng và Observer pattern: từ listener tới event bus và Flow API. Command pattern thường đi cùng hai pattern này: Observer báo "có việc xảy ra", Command là "việc cần làm" được đóng gói.

Command pattern giải quyết vấn đề gì

Theo refactoring.guru, Command pattern biến một yêu cầu thành một đối tượng độc lập chứa mọi thông tin cần để thực hiện nó. Nhờ vậy ta có thể truyền yêu cầu như tham số, trì hoãn hay xếp hàng, và hỗ trợ hoàn tác. Nhóm GoF mô tả cùng ý: tách đối tượng gọi thao tác khỏi đối tượng biết cách thực hiện. Bốn vai trò chính:

Vai tròNhiệm vụTrong ví dụ giỏ hàng
CommandInterface với execute() (và undo() nếu hỗ trợ hoàn tác)Command
Concrete commandGiữ receiver và tham số, chuyển lời gọi cho receiverAddItem, RemoveAll
ReceiverChứa logic nghiệp vụ thậtCart
InvokerKích hoạt lệnh, giữ lịch sử, không biết lệnh làm gìHistory

Client là nơi tạo lệnh, nối với receiver rồi đưa cho invoker. Lợi ích của Command pattern là Single Responsibility (invoker không chứa logic nghiệp vụ) và Open/Closed (thêm lệnh mới không sửa invoker). Chi phí là thêm một tầng trung gian, và với ứng dụng nhỏ đôi khi không đáng.

Cài đặt Command pattern cho giỏ hàng

Trong Command pattern, receiver là giỏ hàng chỉ biết thêm và bớt số lượng. Interface Command có execute, undo và describe (để hiển thị trong menu hoặc nhật ký):

static class Cart {
    private final Map<String, Integer> items = new TreeMap<>();
    void add(String sku, int qty) { items.merge(sku, qty, Integer::sum); }
    void remove(String sku, int qty) {
        int left = items.getOrDefault(sku, 0) - qty;
        if (left <= 0) items.remove(sku); else items.put(sku, left);
    }
    int qty(String sku) { return items.getOrDefault(sku, 0); }
}

interface Command {
    void execute();
    void undo();
    String describe();
}

record AddItem(Cart cart, String sku, int qty) implements Command {
    public void execute() { cart.add(sku, qty); }
    public void undo() { cart.remove(sku, qty); }
    public String describe() { return "thêm " + qty + " x " + sku; }
}

AddItem có nghịch đảo hiển nhiên: thêm n thì undo là bớt n. Không phải lệnh nào của Command pattern cũng may mắn như vậy. "Xoá hết một sản phẩm" không tự suy ra được phải khôi phục bao nhiêu, nên lệnh phải nhớ trạng thái trước khi chạy:

static final class RemoveAll implements Command {
    private final Cart cart; private final String sku; private int removed;
    RemoveAll(Cart cart, String sku) { this.cart = cart; this.sku = sku; }
    public void execute() { removed = cart.qty(sku); cart.remove(sku, removed); }
    public void undo() { if (removed > 0) cart.add(sku, removed); }
    public String describe() { return "xoá hết " + sku; }
}

Với undo trong Command pattern, refactoring.guru nêu đúng hai cách hoàn tác: lưu bản sao trạng thái (tốn bộ nhớ, khó khi trạng thái là private; thường kết hợp Memento) hoặc thực hiện thao tác ngược. RemoveAll là dạng lai: chỉ lưu đúng phần cần thiết, một số nguyên.

Lịch sử undo/redo của Command pattern bằng hai ngăn xếp

Invoker History giữ hai ngăn xếp. Khi chạy lệnh mới: thực thi, đẩy vào undo, và xoá sạch redo. Khi undo: lấy lệnh trên cùng, gọi undo(), chuyển sang redo. Redo làm ngược lại:

static class History {
    private final Deque<Command> undo = new ArrayDeque<>();
    private final Deque<Command> redo = new ArrayDeque<>();

    void run(Command c) {
        c.execute();
        undo.push(c);
        redo.clear();             // lệnh mới làm mất nhánh redo
    }
    boolean undo() {
        if (undo.isEmpty()) return false;
        Command c = undo.pop(); c.undo(); redo.push(c); return true;
    }
    boolean redo() {
        if (redo.isEmpty()) return false;
        Command c = redo.pop(); c.execute(); undo.push(c); return true;
    }
}

Kịch bản trong CartDemo.java đi qua mọi nhánh, gồm cả lệnh tổ hợp mà phần sau giải thích:

thêm 2 ao-thun                     giỏ={ao-thun=2}  undo=1 redo=0
thêm 1 giay                        giỏ={ao-thun=2, giay=1}  undo=2 redo=0
xoá hết ao-thun                    giỏ={giay=1}  undo=3 redo=0
undo                               giỏ={ao-thun=2, giay=1}  undo=2 redo=1
undo                               giỏ={ao-thun=2}  undo=1 redo=2
redo                               giỏ={ao-thun=2, giay=1}  undo=2 redo=1
lệnh mới (mất nhánh redo)          giỏ={ao-thun=2, giay=1, non=3}  undo=3 redo=0
redo được không? false
macro: macro 'combo-ha-noi' (2 bước) giỏ={ao-thun=3, giay=1, non=4}  undo=4 redo=0
undo cả macro                      giỏ={ao-thun=2, giay=1, non=3}  undo=3 redo=1

Lần undo đầu tiên cho thấy undo khôi phục đúng 2 áo thun đã bị xoá, nhờ RemoveAll nhớ số lượng. Dòng redo là điểm hay bị hiểu sai: sau hai lần undo và một redo, vẫn còn một lệnh trong ngăn redo (redo=1). Thêm "lệnh mới (mất nhánh redo)" làm ngăn này bị xoá về 0, nên dòng "redo được không?" trả false. Đây là hành vi chuẩn của hầu hết trình soạn thảo: lịch sử là một đường thẳng, không phải cây. Muốn giữ nhánh phải dùng cấu trúc cây lịch sử phức tạp hơn, hiếm khi đáng làm.

Macro: Command pattern kết hợp Composite

Vì lệnh là đối tượng, một lệnh có thể chứa các lệnh khác. Đó là Composite áp dụng cho Command, giúp một thao tác của người dùng ("thêm combo") thành một bước undo duy nhất:

record Macro(String name, List<Command> steps) implements Command {
    public void execute() { steps.forEach(Command::execute); }
    public void undo() { for (int i = steps.size() - 1; i >= 0; i--) steps.get(i).undo(); }
    public String describe() { return "macro '" + name + "' (" + steps.size() + " bước)"; }
}

Hai chi tiết quan trọng: undo phải chạy theo thứ tự ngược (bước sau phụ thuộc kết quả bước trước), và nếu một bước giữa chừng ném lỗi thì macro ở trạng thái nửa vời. Bản demo không xử lý chỗ đó; hệ thống thật cần quyết định rõ: hoàn tác các bước đã chạy, hay bọc cả macro trong một giao dịch của receiver. Output ở trên cho thấy một lần undo gỡ cả hai món, đưa giỏ về {ao-thun=2, giay=1, non=3}.

Hàng đợi lệnh: thực thi trễ, thử lại và dead-letter

Command pattern tách thời điểm tạo khỏi thời điểm chạy, nên có thể đặt lệnh vào hàng đợi cho worker xử lý sau. Đây là nền của hầu hết hệ thống job nền: ExecutorService trong JDK nhận Runnable hay Callable chính là Command. Ví dụ QueueDemo.java dựng worker một luồng lấy lệnh từ BlockingQueue, thử tối đa n lần, và đưa lệnh thất bại hẳn vào danh sách dead-letter:

interface Job { void run() throws Exception; }
record NamedJob(String name, Job job, int maxAttempts) {}

// worker
NamedJob nj = queue.take();
boolean ok = false;
for (int attempt = 1; attempt <= nj.maxAttempts() && !ok; attempt++) {
    try {
        nj.job().run();
        System.out.println("  [" + nj.name() + "] OK sau " + attempt + " lần");
        ok = true;
    } catch (Exception e) {
        System.out.println("  [" + nj.name() + "] lần " + attempt + " lỗi: " + e.getMessage());
    }
}
if (!ok) deadLetters.add(nj.name());
producer đẩy 4 lệnh vào hàng đợi:
  gửi email DH-1
  [gui-email-DH1] OK sau 1 lần
  [goi-ke-toan-DH1] lần 1 lỗi: timeout kế toán
  [goi-ke-toan-DH1] lần 2 lỗi: timeout kế toán
  đã đồng bộ kế toán DH-1
  [goi-ke-toan-DH1] OK sau 3 lần
  [goi-van-chuyen-DH1] lần 1 lỗi: 503 từ hãng vận chuyển
  [goi-van-chuyen-DH1] lần 2 lỗi: 503 từ hãng vận chuyển
  in hoá đơn DH-1
  [in-hoa-don-DH1] OK sau 1 lần
dead-letter: [goi-van-chuyen-DH1]

Lệnh kế toán thất bại hai lần rồi thành công ở lần ba. Lệnh vận chuyển hết lượt thử nên vào dead-letter, còn lệnh in hoá đơn phía sau vẫn chạy bình thường: một lệnh hỏng không chặn hàng đợi. Có ba điều cần lưu ý khi đem mô hình này vào sản phẩm:

  • Idempotent. Lệnh có thể chạy lại nên phải an toàn khi chạy hai lần (ví dụ gửi kèm mã đơn để bên nhận loại trùng). Gửi email hai lần thì khách khó chịu, ghi nhận thanh toán hai lần thì mất tiền.
  • Hàng đợi trong bộ nhớ không bền. Tiến trình chết là mất lệnh. Muốn bền, lưu lệnh dưới dạng dữ liệu (tên lệnh cộng tham số, ví dụ JSON) vào database hoặc message broker, và dựng lại đối tượng lệnh khi đọc ra. Lambda không tuần tự hoá thành dữ liệu như thế được; vì vậy hệ thống job bền thường dùng lớp lệnh có tên rõ ràng.
  • Backoff. Demo thử lại ngay; thực tế cần chờ tăng dần để không dồn thêm tải lên dịch vụ đang lỗi.

Command pattern trong JDK: UndoableEdit và UndoManager

Swing đã có sẵn Command pattern với undo/redo. Theo Javadoc của UndoManager, lớp này quản lý danh sách các UndoableEdit cùng một chỉ số trỏ tới thao tác kế tiếp để undo hoặc redo, an toàn với đa luồng, giới hạn mặc định 100 thao tác (setLimit đổi được), và thêm thao tác mới sẽ huỷ phần redo. Ta kiểm chứng đúng các điều đó bằng UndoManagerDemo.java với giới hạn 2:

giới hạn mặc định của UndoManager là 100, ở đây đặt 2
sau 3 thao tác: 16, Undo cộng 1
undo: 15, canUndo=true
undo: 10, canUndo=false
thao tác 'cộng 10' đã bị đẩy khỏi lịch sử nên không hoàn tác được, giá trị còn lại 10
redo: 15, canRedo=true
thao tác mới: 115, canRedo=false (nhánh redo bị huỷ)

Ba thao tác cộng 10, 5, 1 cho tổng 16. Giới hạn 2 làm thao tác đầu bị loại khỏi lịch sử, nên hai lần undo chỉ lùi được tới 10 chứ không về 0. Đây là đánh đổi bộ nhớ có chủ ý mà lịch sử vô hạn không có. Trong code tự viết, History ở trên nên có cùng giới hạn (ví dụ bỏ phần tử cuối của ArrayDeque khi vượt trần), đặc biệt nếu mỗi lệnh giữ ảnh chụp trạng thái lớn.

Ở cấp ứng dụng, Runnable và Callable là Command pattern tối giản (chỉ execute, không undo), còn ExecutorService là invoker kèm hàng đợi. Trong Spring, sự kiện ứng dụng thường được dùng để kích hoạt lệnh. Muốn lệnh sống qua restart thì hệ thống job nền cần lưu lệnh dưới dạng dữ liệu vào kho bền, như đã nói ở mục hàng đợi.

Command pattern so với Strategy và Memento

PatternĐóng gói cái gìKhác biệt chính
Command patternMột yêu cầu cụ thể kèm tham sốCó vòng đời: tạo, xếp hàng, chạy, hoàn tác, ghi log
Strategy patternMột thuật toán thay thế đượcKhông giữ yêu cầu cụ thể; xem Strategy pattern với lambda
Memento patternẢnh chụp trạng thái nội bộChỉ để khôi phục; Command pattern có thể dùng nó để undo

Về hình thức, một lambda () -> cart.add("giay", 1) vừa là Strategy vừa là Command. Điều phân biệt là ý định: nếu đối tượng đại diện cho "việc cần làm" có thể lưu, trì hoãn hay hoàn tác thì đó là Command pattern.

Lỗi thường gặp khi cài Command pattern

Command pattern đơn giản về cấu trúc nhưng có nhiều bẫy khi chạy thật:

  • Quên xoá ngăn redo khi có lệnh mới. Kết quả là redo áp dụng một thao tác không còn khớp trạng thái hiện tại. Dòng redo.clear() ở History.run không phải tuỳ chọn.
  • Undo không đối xứng với execute. Mỗi lệnh phải có test: chạy rồi undo thì trạng thái bằng ban đầu. Lệnh nào dựa vào trạng thái trước phải lưu lại, như RemoveAll.
  • Lệnh giữ trạng thái và bị chạy hai lần song song. Một đối tượng lệnh có trường ghi nhớ (như removed) không an toàn khi dùng chung giữa các luồng; hãy tạo lệnh mới cho mỗi lần dùng.
  • Lịch sử phình bộ nhớ. Đặt giới hạn như UndoManager và quyết định rõ khi vượt ngưỡng.
  • Lỗi giữa chừng trong macro. Quy định trước: dừng và hoàn tác các bước đã làm, hay bỏ qua và tiếp tục.
  • Lệnh có tác dụng phụ không hoàn tác được. Email đã gửi thì không rút lại được. Đánh dấu những lệnh này không hỗ trợ undo hoặc dùng lệnh bù (compensating command), ví dụ gửi email đính chính.

Khi nào không nên dùng Command pattern

Nếu chỉ cần gọi một hàm ngay lập tức, không cần hoàn tác, ghi log hay xếp hàng, thì Command pattern, tức đối tượng lệnh, chỉ làm code dài thêm. Với một hành động đơn giản, truyền thẳng một Runnable hoặc một method reference là đủ. Command pattern cũng không thay thế được giao dịch cơ sở dữ liệu: nếu cần "tất cả hoặc không có gì" trên dữ liệu bền, hãy dùng giao dịch của database thay vì tự cuộn ngược bằng undo(). Cuối cùng, khi chuỗi hoàn tác cần phản ánh thao tác của nhiều người dùng đồng thời (soạn thảo cộng tác), hai ngăn xếp cục bộ không đủ; bài toán đó cần kỹ thuật riêng như operational transformation.

Checklist khi áp dụng Command pattern

  • Trong Command pattern, đối tượng lệnh chỉ chứa receiver, tham số và lời gọi; logic nghiệp vụ nằm ở receiver.
  • Mỗi lệnh có thể undo phải được kiểm thử bằng cặp execute và undo.
  • Lệnh nhớ trạng thái trước khi chạy khi nghịch đảo không tự suy ra được.
  • Thêm lệnh mới thì xoá ngăn redo.
  • Giới hạn kích thước lịch sử.
  • Lệnh vào hàng đợi phải idempotent và có chính sách thử lại cùng dead-letter.
  • Muốn lệnh sống sót qua restart, lưu dưới dạng dữ liệu, không lưu lambda.

Tóm tắt

Command pattern biến yêu cầu thành đối tượng, nhờ đó ta xếp hàng, ghi log, thử lại và hoàn tác được. Phần cốt lõi chỉ là interface có execute và undo, cộng với một invoker giữ hai ngăn xếp. Phần khó nằm ở các chi tiết: lệnh nhớ trạng thái trước, nhánh redo bị xoá khi có lệnh mới, thứ tự undo của macro, và tính idempotent khi lệnh được chạy lại. JDK đã có sẵn dạng này ở Runnable, ExecutorService và UndoManager. Mã nguồn đầy đủ nằm trong code/s6-15/; lưu ý run.sh biên dịch ở mức ngôn ngữ 17 (--source 17) nhưng chạy trên JDK 21.

Bài tiếp theo

Khi một yêu cầu cần đi qua nhiều bước xử lý, mỗi bước quyết định xử lý hay chuyển tiếp, ta dùng một pattern khác: Chain of Responsibility: filter chain trong Servlet và Spring Security. Nếu muốn xem lại cách tách khung và bước thay đổi, đọc Template Method trong Java.