ข้าม​ไป​ยัง​เนื้อหา

Command

ห่อ​คำขอ​ให้​เป็น object เดี่ยว ที่​บรรจุ​ข้อมูล​ของ​คำขอ​ไว้​ครบ

ลอง​นึก​ภาพ app แก้ไข​ข้อความ​ที่​มี​ปุ่ม toolbar, เมนู, และ keyboard shortcut ที่​ทำงาน​เดียวกัน — เช่น “บันทึก file” หาก​เขียน logic การ​บันทึก​ซ้ำ​ไว้​ใน event handler ของ​ทั้ง​สาม​จุด เมื่อ logic เปลี่ยน ต้อง​แก้​สาม​ที่ ยิ่ง​ไป​กว่า​นั้น ถ้า​ต้องการ undo, จัด​คิว​คำ​สั่ง​ไว้​ทำ​ทีหลัง, หรือ​บันทึก log ว่า​ใคร​สั่ง​อะไร​ไป​บ้าง code ที่​ผูก UI เข้า​กับ business logic ตรง ๆ แบบ​นี้​จะ​ยิ่ง​พัน​กัน​ยุ่งเหยิง

Command เป็น pattern เชิง​พฤติกรรม​ที่ เปลี่ยน​คำขอ (request) ให้​เป็น object เดี่ยว ที่​บรรจุ​ข้อมูล​ทั้งหมด​ของ​คำขอ​นั้น​ไว้​ครบ — ทั้ง​ว่า​จะ​เรียก method ไหน บน object อะไร ด้วย parameter อะไร แนวคิด​หลัก​คือ​แยก “ผู้​สั่ง” (invoker) ออก​จาก “ผู้​ทำ” (receiver) โดย​มี object คำ​สั่ง​เป็น​ตัวกลาง ผลลัพธ์​คือ:

  • Parameterize ปุ่ม/เมนู/shortcut ด้วย​คำ​สั่ง​ที่​ต่าง​กัน​ได้ โดย​ไม่​ต้อง subclass ปุ่ม​แยก​ตาม​พฤติกรรม
  • จัด​คิว หรือ​หน่วง​เวลา การ​ทำงาน เพราะ​คำ​สั่ง​เป็น object ที่​เก็บ​ไว้​ก่อน​แล้ว​ค่อย​เรียก Execute() ทีหลัง​ได้
  • Undo/Redo ได้ เพราะ​คำ​สั่ง​รู้​วิธี​ย้อน​กลับ​ตัวเอง (Undo()) และ​เก็บ history เป็น stack ได้
  • Log และ replay คำ​สั่ง​ที่​เกิด​ขึ้น เพื่อ audit trail หรือ macro recording

invoker (เช่น CommandHistory) ไม่​จำเป็น​ต้อง​รู้​ว่า​คำ​สั่ง​ทำ​อะไร​ข้าง​ใน มัน​แค่​เรียก Execute()/Undo() ผ่าน interface กลาง ทำให้​เพิ่ม​คำ​สั่ง​ชนิด​ใหม่​ได้​โดย​ไม่​ต้อง​แก้ invoker เลย — สอดคล้อง​กับ​หลัก Open-Closed โดยตรง

classDiagram
    class Client
    class Invoker {
      -ICommand command
      +SetCommand(cmd)
      +ExecuteCommand()
    }
    class ICommand {
      <<interface>>
      +Execute()
      +Undo()
    }
    class ConcreteCommand {
      -Receiver receiver
      -State backup
      +Execute()
      +Undo()
    }
    class Receiver {
      +Action()
      +ReverseAction()
    }
    Client --> Invoker
    Client --> ConcreteCommand
    Invoker o-- ICommand
    ICommand <|.. ConcreteCommand
    ConcreteCommand --> Receiver
  • Client — สร้าง ConcreteCommand แล้ว​ผูก​เข้า​กับ Receiver ที่​ถูกต้อง จาก​นั้น​ส่ง​คำ​สั่ง​ให้ Invoker
  • Invoker — ถือ reference ของ ICommand และ​เรียก Execute()/Undo() เมื่อ​ถึง​เวลา โดย​ไม่รู้จัก concrete type เลย (เช่น CommandHistory, ปุ่ม UI, หรือ job scheduler)
  • ICommand — interface กลาง​ที่​ทุก​คำ​สั่ง​ต้อง​มี อย่าง​น้อย​คือ Execute() ถ้า​ต้องการ undo ก็​เพิ่ม Undo()
  • ConcreteCommand — เก็บ reference ของ Receiver และ parameter ที่​จำเป็น รวม​ถึง state ที่​ต้อง​ใช้​ตอน undo (เช่น ค่า​ก่อน​แก้ไข)
  • Receiver — class ที่​มี business logic จริง ๆ ที่​ถูก​เรียก​ใช้งาน (เช่น Document, Light, BankAccount)

ลำดับ​การ​ทำงาน​ทั่วไป​คือ client ประกอบ​คำ​สั่ง​ขึ้น​มา​ก่อน (bind receiver + parameter) แล้ว​ส่ง​ให้ invoker เก็บ​หรือ​เรียก​ทันที เมื่อ invoker เรียก Execute() คำ​สั่ง​จะ​ไป​สั่ง​งาน receiver จริง และ​ถ้า​ต้องการ​ย้อน​กลับ คำ​สั่ง​เดิม​นั่นเอง​จะ​รู้​วิธี Undo() เพราะ​มัน​เก็บ state ที่​จำเป็น​ไว้​ตั้งแต่​ก่อน execute

sequenceDiagram
    participant C as Client
    participant I as Invoker
    participant Cmd as ConcreteCommand
    participant R as Receiver
    C->>Cmd: new ConcreteCommand(receiver, param)
    C->>I: SetCommand(cmd)
    I->>Cmd: Execute()
    Cmd->>R: Action(param)
    Note over I,Cmd: เก็บ cmd ไว้ใน history stack
    I->>Cmd: Undo()
    Cmd->>R: ReverseAction()

จุด​สำคัญ​คือ invoker ผูก​กับ ICommand เท่านั้น ไม่​ผูก​กับ ConcreteCommand หรือ Receiver โดยตรง — เพิ่ม​คำ​สั่ง​ใหม่​กี่​ชนิด​ก็ได้​โดย​ไม่​ต้อง​แตะ code invoker เลย​สัก​บรรทัด ใน​บาง​ระบบ​ยัง​เอา​หลาย​คำ​สั่ง​มา​รวม​เป็น MacroCommand เดียว (composite ของ​คำ​สั่ง) เพื่อ execute/undo เป็น​ชุด​เดียวกัน​ได้​ด้วย

ตัวอย่าง​นี้​จำลอง text editor ง่าย ๆ ที่​รองรับ undo/redo ผ่าน​คำ​สั่ง​สอง​แบบ พร้อม CommandHistory ที่​ทำ​หน้าที่​เป็น invoker:

// Receiver — เก็บ business logic จริงของเอกสาร
public class Document
{
private readonly StringBuilder _content = new();
public string Content => _content.ToString();
public void Append(string text) => _content.Append(text);
public void RemoveLast(int length) =>
_content.Remove(_content.Length - length, length);
}
// ICommand — สัญญากลางที่ invoker รู้จักแค่นี้
public interface ICommand
{
void Execute();
void Undo();
}
// ConcreteCommand #1 — เพิ่มข้อความ พร้อมจำความยาวไว้ใช้ตอน undo
public class AddTextCommand : ICommand
{
private readonly Document _doc;
private readonly string _text;
public AddTextCommand(Document doc, string text)
{
_doc = doc;
_text = text;
}
public void Execute() => _doc.Append(_text);
public void Undo() => _doc.RemoveLast(_text.Length);
}
// ConcreteCommand #2 — ลบข้อความท้ายสุด พร้อมสำรอง state ไว้ก่อน execute
public class DeleteLastCommand : ICommand
{
private readonly Document _doc;
private readonly int _length;
private string _removedText = string.Empty;
public DeleteLastCommand(Document doc, int length)
{
_doc = doc;
_length = length;
}
public void Execute()
{
// สำรองข้อความที่กำลังจะถูกลบไว้ก่อน เพื่อให้ Undo คืนค่าได้
_removedText = _doc.Content[^_length..];
_doc.RemoveLast(_length);
}
public void Undo() => _doc.Append(_removedText);
}
// Invoker — ไม่รู้จักคำสั่งชนิดใด ๆ เจาะจง รู้แค่ ICommand
public class CommandHistory
{
private readonly Stack<ICommand> _history = new();
public void Execute(ICommand command)
{
command.Execute();
_history.Push(command);
}
public void UndoLast()
{
if (_history.Count == 0) return;
var last = _history.Pop();
last.Undo();
}
}
// การใช้งานฝั่ง Client
var document = new Document();
var history = new CommandHistory();
history.Execute(new AddTextCommand(document, "สวัสดี "));
history.Execute(new AddTextCommand(document, "โลก"));
Console.WriteLine(document.Content); // "สวัสดี โลก"
history.UndoLast();
Console.WriteLine(document.Content); // "สวัสดี "

จุด​สังเกต: CommandHistory (invoker) ไม่มี if command is AddTextCommand เลย​สัก​บรรทัด — เพิ่ม​คำ​สั่ง​ใหม่ เช่น ReplaceTextCommand ก็​ทำได้​ทันที​โดย​ไม่​แตะ invoker

  • ต้องการ parameterize object ด้วย​การกระทำ (action) แทนที่​จะ hardcode logic ไว้​ใน​ตัว​เรียก เช่น ปุ่ม UI, menu item, keyboard shortcut ที่​ใช้ action เดียวกัน
  • ต้องการ undo/redo แบบ​หลาย​ระดับ โดย​ให้​แต่ละ​คำ​สั่ง​รู้​วิธี​ย้อน​กลับ​ตัวเอง
  • ต้องการ จัด​คิว​งาน เพื่อ​ทำ​ภายหลัง หรือ​ส่ง​ข้าม​กระบวนการ/เครื่อง (เช่น job queue, ส่ง​คำ​สั่ง​ผ่าน network)
  • ต้องการ log/replay คำ​สั่ง​ที่​เกิด​ขึ้น เพื่อ audit trail หรือ​ทำ macro recording
  • กำลัง​สร้าง​ระบบ​แบบ CQRS ที่​ต้อง​แยก “คำ​สั่ง​เปลี่ยน​สถานะ” ออก​จาก query อย่าง​ชัดเจน — Command object คือ​หน่วย​พื้นฐาน​ของ​ฝั่ง write
  • การกระทำ​มี​แค่​ไม่​กี่​แบบ เรียบ​ง่าย ไม่มี​แนวโน้ม​จะ undo/queue/log — เพิ่ม layer ของ command object เข้าไป​มี​แต่​ทำให้ code อ่าน​ยาก​ขึ้น​โดย​ไม่​ได้​อะไร​คืน
  • ต้องการ​แค่​เรียก method ตรง ๆ แบบ synchronous ครั้ง​เดียว​จบ ไม่มี​ความ​จำเป็น​ต้อง decouple invoker กับ receiver
  • ทีม​ยัง​ไม่มี pattern การ​จัดการ state สำหรับ undo (เช่น memento หรือ snapshot) การ​ทำ Undo() แบบ​สุ่ม​เดา​อาจ​ทำให้ state เพี้ยน​ได้​ง่าย​กว่า​ประโยชน์​ที่​ได้
ด้านรายละเอียด
ข้อดีแยก invoker ออก​จาก receiver อย่าง​สมบูรณ์ (Single Responsibility)
ข้อดีเพิ่ม​คำ​สั่ง​ใหม่​ได้​โดย​ไม่​แก้ code invoker (Open/Closed)
ข้อดีรองรับ undo/redo, queue, log, macro composition ได้​เป็น​ธรรมชาติ
ข้อดีทดสอบ​ง่าย​ขึ้น เพราะ​แต่ละ​คำ​สั่ง​เป็น object แยก​ที่ mock receiver ได้
ข้อ​เสียเพิ่ม​จำนวน class ใน​ระบบ (คำ​สั่ง​ละ1 class) ทำให้ code verbose ขึ้น
ข้อ​เสียเพิ่ม indirection อีก​ชั้น อาจ​ทำให้​ตาม​รอย flow การ​ทำงาน​ยาก​ขึ้น​สำหรับ​คน​อ่าน​ใหม่
ข้อ​เสียถ้า​ออกแบบ Undo() ไม่​รอบคอบ (ไม่​ได้ backup state ให้​ครบ) การ​ย้อน​กลับ​อาจ​ทำให้​ระบบ​เข้า​สู่ state ที่​ไม่​ถูกต้อง
  • Mediator — มัก​ใช้​คู่​กัน: mediator ประสาน​งาน​ระหว่าง object หลาย​ตัว ส่วน command ห่อ​คำขอ​แต่ละ​อัน​ที่ mediator ส่ง​ต่อ
  • CQRS — Command object คือ​หน่วย​พื้นฐาน​ของ​ฝั่ง write ใน CQRS
  • Memento — ใช้​เก็บ snapshot ของ state เพื่อ​ให้ Undo() ของ command คืน​ค่า​ได้​แม่นยำ
  • Strategy — โครงสร้าง​คล้าย​กัน (ห่อ behavior ไว้​หลัง interface) แต่ Strategy เน้น​สลับ “วิธี​ทำ” ส่วน Command เน้น​ห่อ “คำขอ​ที่​จะ​ทำ” พร้อม undo
  • Open-Closed — invoker เปิด​รับคำ​สั่ง​ใหม่​ได้​โดย​ไม่​ต้อง​แก้ code เดิม