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

Decorator

เพิ่ม​ความ​สามารถ​ให้ object แบบ​ไดนามิก​ด้วย​การ​ห่อ

เวลา​ต้องการ​เพิ่ม​พฤติกรรม​ให้ object เช่น logging, caching, validation หรือ compression วิธี​แรก​ที่​หลาย​คน​นึกถึง​คือ​สร้าง subclass ใหม่ แต่​ถ้า​ต้อง​รองรับ​หลาย​พฤติกรรม​ที่​ผสม​กัน​ได้​อิสระ (เช่น จะ log อย่าง​เดียว, จะ cache อย่าง​เดียว, หรือ​จะ​ทั้ง log ทั้ง cache พร้อม​กัน) การ​ไล่​สร้าง subclass จะ​เกิด combinatorial explosion — จำนวน class ระเบิด​ขึ้น​แบบ exponential ตาม​จำนวน​พฤติกรรม​ที่​ต้อง​ผสม

Decorator แก้​ปัญหา​นี้​ด้วย​แนวคิด​ง่าย ๆ: สร้าง class wrapper ที่​มี interface เดียว​กับ object ต้นฉบับ แล้ว​ให้ wrapper นั้น​ถือ reference ไป​ยัง object ที่​มัน “ห่อ” อยู่ (เรียก​ว่า composition ไม่ใช่ inheritance) เวลา​ถูก​เรียก​ใช้งาน decorator จะ​ทำ​พฤติกรรม​เสริม​ของ​ตัวเอง​ก่อน​หรือ​หลัง แล้ว​ค่อย delegate การ​เรียก​ไป​ยัง object ข้าง​ใน​ต่อ

เพราะ decorator กับ object ต้นฉบับ​มี interface เดียวกัน ผู้​เรียก​จึง​ไม่รู้​และ​ไม่​จำเป็น​ต้อง​รู้​ว่า​กำลัง​คุย​กับ​ของ​จริง​หรือ​ของ​ที่​ถูก​ห่อ​อยู่ — และ​ที่​สำคัญ​คือ decorator หลาย​ตัว​สามารถ​ห่อ​ซ้อน​กัน​ได้​ไม่​จำกัด​ชั้น ทำให้​ผสม​พฤติกรรม​ต่าง ๆ เข้า​ด้วย​กัน​ได้​อย่าง​อิสระ​ใน​เวลา​รัน (runtime) โดย​ไม่​ต้อง​แตะ code เดิม​เลย

แนวคิด​นี้​เป็น​วิธี​ที่​ดี​เยี่ยม​ใน​การ​ทำ​ตาม SRP เพราะ cross-cutting concern อย่าง Logging, Validation, Authorization ถูก​ดึง​ออก​มา​ไว้​ใน decorator เฉพาะ​ทาง​แทนที่​จะ​ยัด​รวม​อยู่​ใน class หลัก และ​ช่วย​บรรลุ OCP เพราะ​เพิ่ม function ให้ class เดิม​ได้​โดย​ไม่​ต้อง​แก้ไขมัน

Decorator ประกอบ​ด้วย​องค์​ประกอบ​หลัก 4 ส่วน: Component (interface ร่วม), ConcreteComponent (ของ​จริง), Decorator (abstract class ที่​ถือ reference ไป​ยัง Component และ implement interface เดียวกัน) และ ConcreteDecorator (class decorator เฉพาะ​ทาง​แต่ละ​ตัว​ที่​เพิ่ม​พฤติกรรม​ของ​ตัวเอง)

classDiagram
    class IService {
      <<interface>>
      +Do()
    }
    class RealService {
      +Do()
    }
    class ServiceDecorator {
      <<abstract>>
      -IService inner
      +Do()
    }
    class LoggingDecorator {
      +Do()
    }
    class ValidationDecorator {
      +Do()
    }
    IService <|.. RealService
    IService <|.. ServiceDecorator
    ServiceDecorator o-- IService : inner
    ServiceDecorator <|-- LoggingDecorator
    ServiceDecorator <|-- ValidationDecorator

ข้อสังเกต​สำคัญ​ของ​โครงสร้าง​นี้​คือ ServiceDecorator implement IService เดียว​กับ​ที่​มัน​ถือ reference อยู่​ข้าง​ใน (inner) — ความ​สัมพันธ์​แบบ​วงกลม​นี้​เอง​ที่​ทำให้​ห่อ​ซ้อน​กัน​ได้​หลาย​ชั้น เช่น

new LoggingDecorator( new ValidationDecorator( new RealService() ) )

เวลา​เรียก Do() ที่​ตัว​นอก​สุด มัน​จะ​ไล่​ผ่าน​ทุก​ชั้น​เข้าไป​จนถึง RealService แล้ว​ผลลัพธ์​ก็​ไหล​ย้อน​กลับ​ออก​มา​ผ่าน​ทุก​ชั้น​เช่น​กัน

การ​ทำงาน​ของ Decorator คือ​ห่วง​โซ่​ของ​การ delegate ที​ละ​ชั้น แต่ละ​ชั้น​มี​โอกาส​แทรก​พฤติกรรม “ก่อน” (before) และ “หลัง” (after) การ​เรียก inner object

sequenceDiagram
    participant Client
    participant Logging as LoggingDecorator
    participant Validation as ValidationDecorator
    participant Real as RealService

    Client->>Logging: Do()
    Logging->>Logging: บันทึก log ก่อนเรียก
    Logging->>Validation: Do()
    Validation->>Validation: ตรวจสอบข้อมูลก่อนเรียก
    Validation->>Real: Do()
    Real-->>Validation: ผลลัพธ์
    Validation-->>Logging: ผลลัพธ์
    Logging->>Logging: บันทึก log หลังเรียก
    Logging-->>Client: ผลลัพธ์

จุด​ที่​ควร​สังเกต:

  1. ผู้​เรียก (Client) ไม่รู้​จำนวน​หรือ​ชนิด​ของ decorator ที่​ห่อ​อยู่ — มัน​เห็น​แค่ IService เดียว​เท่านั้น
  2. ลำดับ​การ​ห่อ​มี​ผล​ต่อ​พฤติกรรม — ห่อ Logging(Validation(...)) กับ Validation(Logging(...)) ให้​ผล​ต่าง​กัน เช่น ถ้า validation ล้มเหลว​และ throw exception การ​ห่อ​แบบ​แรก log จะ​ถูก​บันทึก​ก่อน​ที่ validation จะ​ทำงาน ส่วน​แบบ​หลัง log จะ​ไม่​ถูก​บันทึก​เลย​ถ้า validation ล้มเหลว​ก่อน
  3. แต่ละ decorator ควร​ทำ​หน้าที่​เดียว ตาม SRP — อย่า​ยัด logging กับ caching ไว้​ใน decorator ตัว​เดียวกัน ให้​แยก​เป็น​คนละ​ตัว​แล้ว​ค่อย​ห่อ​รวม​กัน

ตัวอย่าง​นี้​แสดง​การ​ห่อ IOrderService ด้วย decorator สอง​ชั้น คือ logging และ caching โดย​ไม่​แก้ code OrderService เดิม​เลย

public interface IOrderService
{
Order GetById(int id);
void Place(Order order);
}
// ConcreteComponent — logic ทางธุรกิจล้วน ๆ ไม่ปนกับ cross-cutting concern
public class OrderService : IOrderService
{
private readonly IOrderRepository _repository;
public OrderService(IOrderRepository repository) => _repository = repository;
public Order GetById(int id) => _repository.Find(id);
public void Place(Order order) => _repository.Save(order);
}
// Decorator ฐาน — ถือ inner object และ implement interface เดียวกัน
public abstract class OrderServiceDecorator : IOrderService
{
protected readonly IOrderService Inner;
protected OrderServiceDecorator(IOrderService inner) => Inner = inner;
public virtual Order GetById(int id) => Inner.GetById(id);
public virtual void Place(Order order) => Inner.Place(order);
}
// ConcreteDecorator #1 — เพิ่ม logging ก่อน/หลัง delegate
public class LoggingOrderService : OrderServiceDecorator
{
private readonly ILogger _log;
public LoggingOrderService(IOrderService inner, ILogger log) : base(inner) => _log = log;
public override void Place(Order order)
{
_log.Info($"กำลังสั่งซื้อออเดอร์ {order.Id}"); // พฤติกรรมเสริมก่อนเรียก
Inner.Place(order); // delegate ไปของเดิม
_log.Info($"สั่งซื้อออเดอร์ {order.Id} สำเร็จ"); // พฤติกรรมเสริมหลังเรียก
}
}
// ConcreteDecorator #2 — เพิ่ม caching เฉพาะฝั่งอ่าน
public class CachingOrderService : OrderServiceDecorator
{
private readonly IMemoryCache _cache;
public CachingOrderService(IOrderService inner, IMemoryCache cache) : base(inner) => _cache = cache;
public override Order GetById(int id)
{
// ถ้ามีใน cache แล้วไม่ต้อง delegate ไป inner เลย
if (_cache.TryGetValue(id, out Order cached))
return cached;
var order = Inner.GetById(id);
_cache.Set(id, order, TimeSpan.FromMinutes(5));
return order;
}
}
// ผู้เรียกใช้: ห่อซ้อนกันได้อิสระ — เปลี่ยนลำดับหรือถอดออกได้โดยไม่แตะ OrderService
IOrderService service = new LoggingOrderService(
new CachingOrderService(
new OrderService(repository),
cache),
logger);
service.Place(order); // ผ่าน Logging -> Caching (ไม่มีผลต่อ Place) -> OrderService จริง

ใน project จริง​ที่​ใช้ ASP.NET Core DI container มัก​จะ​ประกอบ decorator ตอน register service (เช่น​ด้วย library Scrutor) แทนที่​จะ new ต่อ​กัน​ด้วย​มือ​แบบ​นี้ แต่​หลักการ​ห่อ​ซ้อน​ยัง​เหมือน​เดิม

  • ต้องการ​เพิ่ม​พฤติกรรม​ให้ object บาง​ตัว แบบ​ไดนามิก​ใน​เวลา​รัน โดย​ไม่​กระทบ instance อื่น​ของ class เดียวกัน
  • มี cross-cutting concern อย่าง logging, caching, validation, authorization, retry, compression ที่​อยาก​แยก​ออก​จาก logic หลัก​ตาม SRP
  • ต้องการ​ผสม​พฤติกรรม​หลาย​แบบ​เข้า​ด้วย​กัน​ได้​อย่าง​อิสระ โดย​ไม่​อยาก​ให้​จำนวน class ระเบิด​จาก​การ​ทำ subclass ทุก​ชุด​ค่า​ผสม
  • class ต้นฉบับ​ถูก seal หรือ final แก้ไข/สืบทอด​ไม่​ได้ แต่​ยัง​ต้องการ​เพิ่ม​พฤติกรรม
  • ต้องการ​รักษา OCP — เพิ่ม​ความ​สามารถ​โดย​ไม่​แก้ code เดิม​ที่​ผ่าน​การ​ทดสอบ​แล้ว
  • พฤติกรรม​ที่​จะ​เพิ่ม​นั้น​คงที่​และ​รู้​ล่วงหน้า​ตั้งแต่ compile-time ไม่​ได้​เปลี่ยน​ตาม instance — การ​แก้ class เดิม​หรือ​ใช้ inheritance ธรรมดา​อาจ​ง่าย​และ​อ่าน​ง่าย​กว่า
  • ลำดับ​การ​ห่อ decorator ส่ง​ผล​ต่อ​ความ​ถูกต้อง​ของ​ระบบ​จน​ทำให้ debug ยาก และ​ทีม​ไม่มี​วินัย​จัดการ​ลำดับ​ให้​ชัดเจน
  • ต้องการ “ถอด” decorator เฉพาะ​ตัว​ออก​จาก stack ที่​ห่อ​ไว้​แล้ว — Decorator ไม่​ได้​ออกแบบ​มา​ให้ inspect หรือ​ถอด​ตัวกลาง ๆ ออก​ได้​ง่าย
  • จำนวน​ชั้น​การ​ห่อ​เยอะ​เกิน​ไป​จน​ตาม stack trace หรือ debug ยาก ควร​พิจารณา middleware pipeline หรือ Chain of Responsibility ที่​ให้​ภาพ​รวม​ของ​ลำดับ​ชัดเจน​กว่า
  • โจทย์​จริง​คือ​การ​ควบคุม​การ​เข้าถึง (access control, lazy loading, remote proxy) มากกว่า​การ​เพิ่ม​พฤติกรรม — กรณี​นั้น Proxy ตรง​จุด​ประสงค์​กว่า
ด้านรายละเอียด
ข้อดีเพิ่ม/ถอด​พฤติกรรม​ได้​ใน​เวลา​รัน​โดย​ไม่​แก้ class เดิม
ข้อดีหลีก​เลี่ยง combinatorial explosion ของ subclass จาก​การ​ผสม​พฤติกรรม
ข้อดีแต่ละ decorator ทำ​หน้าที่​เดียว ทดสอบ​และ​อ่าน​แยก​กัน​ได้​ตาม SRP
ข้อดีผู้​เรียก​ไม่​ต้อง​รู้​ว่า​กำลัง​คุย​กับ​ของ​จริง​หรือ​ของ​ที่​ถูก​ห่อ (transparent)
ข้อ​เสียมี​หลาย object ขนาด​เล็ก​เกิด​ขึ้น ทำให้ debug และ​ตาม stack ยาก​ขึ้น
ข้อ​เสียลำดับ​การ​ห่อ​มี​ผล​ต่อ​พฤติกรรม ต้อง​เข้าใจ​ก่อน​ประกอบ stack
ข้อ​เสียcode ตอน​ประกอบ (composition root) อาจ​ดู​รก​ถ้า​ห่อ​หลาย​ชั้น​ด้วย​มือ
ข้อ​เสียถอด decorator ตัวกลาง​ออก​จาก stack ที่​ห่อ​ไว้​แล้ว​ทำได้​ยาก