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

Event Aggregator

ฮับ​กลาง​ที่​รวบรวม event จาก​หลาย​แหล่ง​แล้ว​ส่ง​ต่อ​ผู้​สนใจ

ลอง​นึก​ภาพ​หน้า​จอ dashboard ที่​มี widget สิบ​กว่า​ตัว แต่ละ​ตัว​ต้อง​รู้​เมื่อ​ผู้​ใช้​เปลี่ยนตัว​กรอง เมื่อ​ออเดอร์​ใหม่​เข้า​มา หรือ​เมื่อ​ผู้​ใช้​ล็อก​เอาต์ ถ้า​ใช้ Observer ตรง ๆ แต่ละ widget ต้อง​ไป​ลง​ทะเบียน (subscribe) กับ source object ทุก​ตัว​ที่​ตัวเอง​สนใจ เมื่อ​จำนวน source และ observer เพิ่ม​ขึ้น จำนวน​ความ​สัมพันธ์​แบบ many-to-many ก็​ระเบิด​ตาม — code ที่​ผูก event handler กระจาย​อยู่​ทั่ว​ระบบ และ​การ​จัดการ memory (ลืม unsubscribe แล้ว leak) ก็​ยุ่งยาก​ขึ้น​เรื่อย ๆ

Event Aggregator แก้​ปัญหา​นี้​ด้วย​การ​ใส่​ตัวกลาง​หนึ่ง​ตัว​คั่น​ระหว่าง publisher กับ subscriber ทั้งหมด แนวคิด​หลัก​คือ “รวม event จาก​หลาย​แหล่ง​เข้า​เป็น​แหล่ง​เดียว” — Martin Fowler อธิบาย​ไว้​ว่า Event Aggregator ทำ​หน้าที่​เป็น “single source of events for many objects” มัน​ลง​ทะเบียน​รับ event จาก source ทุก​ตัวแทน client แล้ว​ส่ง​ต่อ (หรือ​แปลง​ให้​เป็น event ที่ generic ขึ้น) ไป​ยัง subscriber ที่​สนใจ ผล​คือ publisher ไม่รู้จัก subscriber และ subscriber ไม่รู้จัก publisher เลย ทั้ง​สอง​ฝ่าย​รู้จัก​แค่ aggregator ตรง​กลาง ทำให้​ระบบ event-driven ที่ decouple สูง ขยาย​ง่าย และ​ลด boilerplate การ​ลง​ทะเบียน​ซ้ำ ๆ

โดย​ทั่วไป aggregator ถูก implement เป็น singleton หรือ scoped service ที่ inject ไป​ยัง​ทุก​จุด​ที่​ต้อง publish หรือ subscribe เช่น​ใน​ระบบ eCommerce การ​สั่ง​ซื้อ​หนึ่ง​ครั้ง​อาจ​จุด​ชนวน​หลาย​ปฏิกิริยา​อิสระ​ที่​ไม่​เกี่ยวข้อง​กัน (ส่ง​อีเมล อัปเดต​สต็อก บันทึก audit log) โดยที่ handler สั่ง​ซื้อ​ไม่​ต้อง​รู้จัก​และ​ไม่​ต้อง​เรียก​ใช้​ทั้ง​สาม​ระบบ​นั้น​โดยตรง

classDiagram
    class IEventAggregator {
      <<interface>>
      +Publish(evt)
      +Subscribe(handler)
      +Unsubscribe(handler)
    }
    class EventAggregator {
      -handlers Dictionary
      +Publish(evt)
      +Subscribe(handler)
      +Unsubscribe(handler)
    }
    class Publisher {
      -aggregator IEventAggregator
      +DoWork()
    }
    class SubscriberA {
      +Handle(evt)
    }
    class SubscriberB {
      +Handle(evt)
    }
    class DomainEvent {
      <<marker>>
    }
    IEventAggregator <|.. EventAggregator
    Publisher o-- IEventAggregator
    SubscriberA o-- IEventAggregator
    SubscriberB o-- IEventAggregator
    EventAggregator ..> DomainEvent

Publisher และ Subscriber ต่าง​ก็​ถือ reference ไป​ยัง IEventAggregator เท่านั้น ไม่มี​ฝ่าย​ไหน​อ้างอิง​ถึงกัน​โดยตรง — นี่​คือ​จุด​ต่าง​หลัก​จาก Observer แบบ​ดั้งเดิม​ที่ subject ต้อง​ถือ list ของ observer โดยตรง

ลำดับ​การ​ทำงาน​ทั่วไป​มี​สาม​ขั้น: (1) subscriber ลง​ทะเบียน handler กับ aggregator ล่วงหน้า (2) publisher เรียก Publish เมื่อ​มี​เหตุการณ์​เกิด​ขึ้น (3) aggregator วนหา handler ที่​ตรง type แล้ว​เรียก​ที​ละ​ตัว โดย​ปกติ aggregator “ไม่มี logic ทาง​ธุรกิจ” เลย — มัน​แค่ forward event จาก publisher ไปหา subscriber แบบ fire-and-forget เท่านั้น ถ้า​ตัวกลาง​เริ่ม​มี​การ​ตัดสิน​ใจ​หรือ orchestrate ลำดับ​ขั้นตอน​ทาง​ธุรกิจ นั่น​คือ​สัญญาณ​ว่า​กำลัง​กลาย​เป็น Mediator ไป​แล้ว ไม่ใช่ Event Aggregator อีก​ต่อ​ไป

sequenceDiagram
    participant P as Publisher
    participant A as EventAggregator
    participant S1 as SubscriberA
    participant S2 as SubscriberB
    S1->>A: Subscribe(Handle)
    S2->>A: Subscribe(Handle)
    P->>A: Publish(OrderPlaced)
    A->>S1: Handle(OrderPlaced)
    A->>S2: Handle(OrderPlaced)

หมายเหตุ​เรื่อง memory management: เพราะ subscriber ลง​ทะเบียน​กับ aggregator เพียง​จุด​เดียว การ unsubscribe (เช่น​ตอน dispose ของ ViewModel) ก็​ทำ​ที่​จุด​เดียว​เช่น​กัน ต่าง​จาก Observer ตรง ๆ ที่​ต้อง unsubscribe จาก source object ทุก​ตัว​แยก​กัน ซึ่ง​ลืม​ง่าย​และ​เป็น​สาเหตุ memory leak บ่อย​ครั้ง

// สัญญาที่ publisher และ subscriber ต่างพึ่งพา — ไม่มีใครรู้จักกันโดยตรง
public interface IEventAggregator
{
void Publish<TEvent>(TEvent evt);
void Subscribe<TEvent>(Action<TEvent> handler);
void Unsubscribe<TEvent>(Action<TEvent> handler);
}
// การ implement แบบง่าย เก็บ handler แยกตาม type ของ event
public sealed class EventAggregator : IEventAggregator
{
private readonly Dictionary<Type, List<Delegate>> _handlers = new();
public void Subscribe<TEvent>(Action<TEvent> handler)
{
var eventType = typeof(TEvent);
if (!_handlers.TryGetValue(eventType, out var list))
{
list = new List<Delegate>();
_handlers[eventType] = list;
}
list.Add(handler);
}
public void Unsubscribe<TEvent>(Action<TEvent> handler)
{
if (_handlers.TryGetValue(typeof(TEvent), out var list))
{
list.Remove(handler);
}
}
public void Publish<TEvent>(TEvent evt)
{
if (!_handlers.TryGetValue(typeof(TEvent), out var list))
{
return;
}
// ทำสำเนา list ก่อนวน loop กันปัญหา handler unsubscribe ตัวเองระหว่าง publish
foreach (var handler in list.ToArray())
{
((Action<TEvent>)handler).Invoke(evt);
}
}
}
// event ที่ใช้เป็น payload — เป็น record แบบ immutable
public sealed record OrderPlaced(Guid OrderId, decimal Total);
// Publisher: OrderService รู้จักแค่ IEventAggregator ไม่รู้จัก subscriber เลย
public sealed class OrderService
{
private readonly IEventAggregator _events;
public OrderService(IEventAggregator events) => _events = events;
public void PlaceOrder(Guid orderId, decimal total)
{
// ... บันทึกออเดอร์ลงฐานข้อมูลจริง ...
_events.Publish(new OrderPlaced(orderId, total));
}
}
// Subscriber ตัวอย่าง: แต่ละตัวทำงานอิสระ ไม่ผูกกับ OrderService หรือกันเอง
public sealed class InventoryUpdater
{
public InventoryUpdater(IEventAggregator events)
=> events.Subscribe<OrderPlaced>(OnOrderPlaced);
private void OnOrderPlaced(OrderPlaced evt)
{
// ตัดสต็อกตามออเดอร์ที่เพิ่งเข้ามา
}
}
public sealed class OrderEmailNotifier
{
public OrderEmailNotifier(IEventAggregator events)
=> events.Subscribe<OrderPlaced>(evt => SendReceiptEmail(evt.OrderId));
private void SendReceiptEmail(Guid orderId) { /* ส่งอีเมลใบเสร็จ */ }
}
  • ระบบ​มี publisher และ subscriber จำนวน​มาก​ที่​ไม่​ควร​รู้จัก​กัน​โดยตรง (เช่น UI แบบ modular ที่​แต่ละ module พัฒนา​แยก​ทีม​กัน)
  • ต้องการ​ลด boilerplate การ​ลง​ทะเบียน event เมื่อ source object มี​จำนวน​มาก​และ​เปลี่ยนแปลง​บ่อย
  • ต้องการ​จุด​เดียว​สำหรับ unsubscribe ทั้งหมด เพื่อ​ลด​ความ​เสี่ยง memory leak จาก event handler ที่​ค้าง
  • งาน UI (WPF, MAUI, SPA) ที่ ViewModel หลาย​ตัว​ต้อง​สื่อสาร​กัน​แบบ loosely coupled โดย​ไม่​ผ่าน parent-child hierarchy
  • มี publisher/subscriber เพียง​คู่​เดียว​หรือ​สอง​สาม​คู่ — ใช้ event ปกติ​ของ​ภาษา (C# event keyword) หรือ Observer ตรง ๆ ก็​เพียงพอ​และ​อ่าน​ง่าย​กว่า
  • ต้องการ orchestrate ลำดับ​ขั้นตอน​ทาง​ธุรกิจ​ที่​ซับซ้อน (เช่น step A ต้อง​เสร็จ​ก่อน step B ถึง​จะ​เริ่ม step C) — นั่น​คือ​งาน​ของ Mediator ไม่ใช่ event aggregator ที่​ควร​ไม่มี logic เลย
  • ทีม​ยัง​ไม่มี​วินัย​ใน​การ​ตั้ง​ชื่อ​และ​จัด​หมวด event — เพราะ​ทุก​อย่าง​ถูก​ยิง​ผ่าน​ฮับ​เดียว การ debug ว่า “ใคร publish เมื่อไหร่ ใคร subscribe อะไร” จะ​ทำได้​ยาก​ถ้า​ไม่มี convention หรือ tooling ช่วย
  • ต้องการ delivery guarantee ข้าม​กระบวนการ/เครื่อง (durable, retry, ordering) — นั่น​คือ​งาน​ของ message broker หรือ Domain Events ที่ persist แล้ว dispatch แบบ​มี transaction ไม่ใช่​ใน​กระบวนการ​เดียว​แบบ in-memory
ด้านรายละเอียด
ข้อดีdecouple publisher กับ subscriber อย่าง​สมบูรณ์ ไม่มี​ฝ่าย​ไหน​อ้างอิง​กัน
ข้อดีลด​จำนวน​จุด​ลง​ทะเบียน​จาก many-to-many เหลือ many-to-one
ข้อดีunsubscribe รวม​ศูนย์​ที่​จุด​เดียว ลด​ความ​เสี่ยง memory leak
ข้อดีเพิ่ม/ลบ subscriber ใหม่​ได้​โดย​ไม่​กระทบ publisher เลย เหมาะ​กับ​ระบบ​ที่​ขยาย​บ่อย
ข้อ​เสียเพิ่ม indirection — ตาม​รอย flow ของ event ยาก​ขึ้น เพราะ​ไม่​เห็น “ใคร​เรียก​ใคร” ตรง ๆ ใน code
ข้อ​เสียถ้า​ตั้ง​ชื่อ event ไม่​ดี​หรือ​ไม่มี convention จะ​กลาย​เป็น global event bus ที่ debug ยาก​มาก
ข้อ​เสียโดย​ธรรมชาติ​เป็น in-memory, synchronous ใน​กระบวนการ​เดียว — ไม่​เหมาะ​กับ​งาน​ข้าม​ระบบ/ข้าม process
ข้อ​เสียตัว aggregator เอง​มัก​ถูก inject ไป​ทั่ว​ระบบ เสี่ยง​กลาย​เป็น service locator แฝง ถ้า​ใช้​พร่ำเพรื่อ
  • Observer — Event Aggregator เป็น​กรณี​พิเศษ​ที่​รวม Observer เข้า​กับ Mediator บาง​ส่วน
  • Mediator — คล้าย​กัน​ตรง​เป็น​ตัวกลาง แต่ Mediator มี logic ทาง​ธุรกิจ ส่วน Event Aggregator ไม่​ควร​มี
  • Domain Events — รูปแบบ event ที่​เจาะจง​กับ domain model และ​มัก​ถูก dispatch ผ่าน​กลไก​คล้าย aggregator
  • Singleton — aggregator มัก​ถูก implement เป็น singleton หรือ scoped service เดียว​ต่อ app
  • Dependency Inversion Principle — publisher และ subscriber พึ่งพา IEventAggregator ซึ่ง​เป็น abstraction ไม่ใช่​กัน​และ​กัน​โดยตรง
  • Service Locator — antipattern ที่​ใกล้​เคียง​กัน​ถ้า inject aggregator แบบ​ไม่มี​วินัย​จน​กลาย​เป็น​จุด​เข้าถึง service ทุก​อย่าง