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

Event Sourcing

ระบบ​ส่วน​ใหญ่​ทุก​วัน​นี้​เก็บ​แค่ สถานะ​ปัจจุบัน ของ Domain ModelDomain ModelModel ของ Domain ที่​ฝัง​กฎ​และ​กระบวนการ​ทาง​ธุรกิจ​ไว้​ใน code จริง หัวใจ​ของ DDD คือ​การ​พัฒนา domain model ที่ “เข้าใจ​ธุรกิจ​อย่าง​ลึกซึ้ง”Strategic Design เอา​ไว้ — ตาราง​หนึ่ง​แถว​บอกว่า “ตอน​นี้​ยอด​เงิน​เหลือ​เท่าไร” แต่​ไม่​เคย​บอกว่า มัน​มา​ถึง​สถานะ​นี้​ได้​อย่างไร Event SourcingEvent Sourcingเก็บ​ทุก​การ​เปลี่ยน​สถานะ​เป็น event ที่​เปลี่ยน​ไม่​ได้ แล้ว​สร้าง​สถานะ​ปัจจุบัน​ด้วย​การ​เล่น event ซ้ำ มัก “ต้อง​ใช้​คู่​กับ CQRS” (Greg Young) มี event stream ต่อ1 AggregateArchitecture พลิก​ความคิด​นี้​กลับ​ด้าน: แทนที่​จะ​เก็บ snapshot ของ​สถานะ เรา​เก็บ ทุก​เหตุการณ์​ที่​มี​นัย​สำคัญ เรียง​ตาม​เวลา แล้ว​คำนวณ​สถานะ​ปัจจุบัน​ด้วย​การ “เล่น​เหตุการณ์​ซ้ำ” (replay)

บท​นี้​จะ​พา​คุณ​เข้าใจ​ตั้งแต่​ปัญหา​ที่ event sourcing แก้ ไป​จนถึง​วิธี​สร้าง AggregateAggregateกลุ่ม​ของ Entity และ Value Object ที่​ถูก​มอง​เป็น​หนึ่ง​หน่วย​เดียว​เพื่อ​รักษา​ความ​ถูกต้อง​ของ​ข้อมูล มี​ขอบเขต​ชัดเจน และ​เป็น​หน่วย​ของ transactionTactical Design แบบ event-oriented, สร้าง Event StoreEvent Storeพื้นที่​จัด​เก็บ event แบบ append-only ที่​ใช้​เป็น source of truth ของ aggregate รองรับ​การ​สร้าง stream, append event และ​โหลด stream กลับ​มา replay สร้าง​เอง​บน SQL/document DB หรือ​ใช้​เครื่องมือ purpose-built อย่าง Greg Young's Event Store ก็ได้Architecture ของ​ตัวเอง, ใช้ Event Store สำเร็จรูป​ของ Greg Young และ​เหตุผล​ที่ event sourcing เข้า​กัน​ได้​ดี​อย่าง​น่า​ทึ่ง​กับ CQRSCQRSCommand Query Responsibility Segregation — แยก model สำหรับ “เขียน/แก้ไข” (command) ออก​จาก model สำหรับ “อ่าน” (query) เหมาะ​กับ domain ซับซ้อน แต่​เพิ่ม​ความ​ซับซ้อน ใช้​อย่าง​ระมัดระวังArchitecture — พร้อม​ข้อ​แลกเปลี่ยน​ที่​ต้อง​ชั่ง​น้ำหนัก​ก่อน​ตัดสิน​ใจ​ใช้


ถ้า​คุณ​เก็บ​แค่​สถานะ​ปัจจุบัน คุณ​จะ​ไม่มี​ทาง “ย้อน​ดู” ว่า​ระบบ​มา​ถึง​จุด​นี้​ได้​อย่างไร และ​นั่น​ทำให้การ​วิเคราะห์​กระบวนการ​ธุรกิจ (business process) ใน​อดีต​หรือ​การ​สืบหา bug แทบ​เป็น​ไป​ไม่​ได้ หนังสือ​ยก​ตัวอย่าง​ผู้​ให้​บริการ​มือถือ​แบบ เติม​เงิน (pay-as-you-go) ลูกค้า​โทร​เข้า​มา​ร้องเรียน​ว่า​เพิ่ง​เติม​เงิน​ไป แต่​โทร​ไม่​กี่​นาที​แล้ว​ยอด​กลับ​เป็น​ศูนย์

เมื่อ​เจ้าหน้าที่​เปิด​ดู​ฐาน​ข้อมูล ทั้งหมด​ที่​เห็น​คือ:

Customer IDAllowance (นาที)
1234567890

เจ้าหน้าที่​ทำได้​แค่ “เห็นด้วย” ว่ายอด​เป็น​ศูนย์​จริง — แต่​พิสูจน์​ไม่​ได้​เลย​ว่า​เกิด​อะไร​ขึ้น เพราะ​ฐาน​ข้อมูล​ไม่​ได้​เก็บ​ประวัติ ลูกค้า​ต้อง​เอา​ใบเสร็จ​และ log โทรศัพท์​มา​เอง​เพื่อ​ยืนยัน​ว่า​ควร​จะ​เหลือ​เครดิต ทั้ง​ที่​ความ​จริง​คือ ระบบ​มี bug ที่​หัก​นาที​เกิน แต่​ธุรกิจ​พิสูจน์​ไม่​ได้​เพราะ​ข้อมูล​บอก​แค่ “ตอน​นี้” ไม่​ได้​บอก “ที่​ผ่าน​มา”

ราคา​ที่​ต้อง​จ่าย​ของ​การ​เก็บ​แค่ snapshot

นอกจาก​จะ debug ยาก​แล้ว การ​เก็บ​แค่​สถานะ​ปัจจุบัน​ยัง​ทำให้​ธุรกิจ เสีย​โอกาส ใน​การ​วิเคราะห์​กระบวนการ​ธุรกิจ (business process) ของ​ลูกค้า — จับ​คู่​กับ​แคมเปญ​การ​ตลาด​ไม่​ได้ มอง​หา pattern เพื่อ​สร้าง​รายได้​ใหม่​ก็​ไม่​ได้ เพราะ​ประวัติ​ทั้งหมด​ถูก​เขียน​ทับ​หาย​ไป​ทุก​ครั้ง​ที่​อัปเดต


ได้​เปรียบ​การ​แข่งขัน​ด้วย​การ​เก็บ​สถานะ​เป็น “สาย​ของ event”

หัวข้อ​ที่​มีชื่อ​ว่า “ได้​เปรียบ​การ​แข่งขัน​ด้วย​การ​เก็บ​สถานะ​เป็น “สาย​ของ event””

ทางออก​คือ​เก็บ​ทุก event ที่​สำคัญ​พร้อม timestamp เรียง​ตาม​ลำดับ​เวลา แล้ว คำนวณ​สถานะ​ปัจจุบัน​ด้วย​การ replay เมื่อ​ทำ​เช่น​นี้ คุณ​จะ​ทำได้​มากกว่า​แค่​หาสถานะ​ปัจจุบัน — คุณ​สามารถ replay เฉพาะ​ช่วง​ย่อย​ของ event เพื่อ​หาสถานะ ณ จุด​ใด​ก็ได้​ใน​อดีต ความ​สามารถ​นี้​เรียก​ว่า Temporal QueryTemporal Queryการ query หาสถานะ​ของ domain model ณ จุด​ใด ๆ ใน​อดีต ด้วย​การ replay เฉพาะ​ช่วง​ย่อย​ของ event จนถึง​จุด​นั้น — เหมือน “กรอ​เวลา” ย้อน​กลับ เป็น​ความ​สามารถ​เด่น​ของ event sourcing ที่​ระบบ​เก็บ​แค่ snapshot ทำ​ไม่​ได้Architecture

Temporal QueryTemporal Queryการ query หาสถานะ​ของ domain model ณ จุด​ใด ๆ ใน​อดีต ด้วย​การ replay เฉพาะ​ช่วง​ย่อย​ของ event จนถึง​จุด​นั้น — เหมือน “กรอ​เวลา” ย้อน​กลับ เป็น​ความ​สามารถ​เด่น​ของ event sourcing ที่​ระบบ​เก็บ​แค่ snapshot ทำ​ไม่​ได้Architecture ก็​เหมือน​การ “กรอ​เทป​ย้อน​กลับ” ของ domain model ไป​ยัง​จุด​ใด​จุด​หนึ่ง​ใน​อดีต ใน​กรณี​ร้องเรียน​ข้าง​ต้น แผนก Customer Service สามารถ replay event ที​ละ​ช่วง​จน​เจอ event ที่​หัก​นาที​ผิดพลาด แล้ว​ชี้ bug ได้​อย่าง​แม่นยำ

รูปที่ 22-1: คำนวณสถานะ ณ จุดใดๆ ในอดีตด้วยการ replay event — สาย event ของลูกค้าหนึ่งคน ตั้งแต่เติมเงินจนถึงสายโทรออกแต่ละครั้ง เมื่อบวกลบตามลำดับจะได้ allowance ที่ถูกต้องของช่วงเวลานั้น

แนวคิด​นี้​ไม่​ได้​แปลก​ใหม่ — คุณ​เจอ​มัน​มา​ตลอด​ชีวิต​นัก​พัฒนา ทั้ง​บัญชี​ธนาคาร (statement คือ​สาย event) และ version control อย่าง Git หรือ Subversion ที่​ย้อน​สถานะ​ไป​จุด​ไหน​ก็ได้​ด้วย​การ replay commit ก่อนหน้า​ทั้งหมด

ข้อ​จำกัด​ของ event store บาง​ตัว​คือ​การ query แบบ ad hoc ข้าม​หลาย​สาย​พร้อม​กัน (สิ่ง​ที่​ใน SQL ทำได้​ง่าย​ด้วย JOIN) ทาง​แก้​คือ ProjectionProjectionfunction ที่​รับ event stream หลาย​สาย​เป็น input แล้ว map/คัดเลือก event ออก​เป็น stream ใหม่​หนึ่ง​สาย​หรือ​มากกว่า ใช้​รวม​ข้อมูล​ข้าม​สาย (คล้าย JOIN ใน SQL) และ​เป็น​พื้นฐาน​ใน​การ​สร้าง view cache สำหรับ CQRSArchitecture — function ที่​รับ event stream หลาย​สาย​เป็น input แล้ว map ออก​เป็น stream ใหม่​หนึ่ง​สาย​หรือ​มากกว่า

รูปที่ 22-2: สร้าง projection จากหลาย event stream — function projection เลือกเฉพาะ event ที่ตรงเงื่อนไข (เช่น ลูกค้าอายุ 16–22 ระหว่าง 1–5 มีนาคม) แล้วรวมลงใน stream ใหม่ที่ query ต่อได้ง่าย

ด้วย projection ผู้​ให้​บริการ​มือถือ​ถาม​คำถาม​ที่​เคย​ตอบ​ไม่​ได้​ได้​ทันที เช่น “กลุ่ม​อายุ​นี้​ใช้​กี่​นาที​รวม​ใน​วัน​ที่​มี event กีฬา​ใหญ่” หรือ “โปรโมชัน​พิเศษ​ทำให้การ​ใช้งาน​ของ​กลุ่ม​เป้าหมาย​เพิ่ม​หรือ​ลด” — คำ​ตอบ​ที่​ขับ​เคลื่อน​การ​ตัดสิน​ใจ​ทางการ​ตลาด​และ​การ​พัฒนา​สินค้า​ด้วย​ข้อมูล​เชิง​ปริมาณ

ผล​ข้าง​เคียง​ของ​การ​เก็บ​สถานะ​เป็น event คือ stream อาจ ยาว​ขึ้น​เรื่อยๆ จน​เวลา​ที่​ใช้ replay พุ่ง​สูง ทาง​แก้​คือ SnapshotSnapshotสถานะ ณ จุด​กลาง​ทาง​ของ event stream ที่​เทียบ​เท่ากับ​การ replay event ทั้งหมด​ก่อนหน้า ใช้​เป็น​ทาง​ลัด​ด้าน​ประสิทธิภาพ เพื่อ​ให้​โหลด aggregate ได้​โดย replay เฉพาะ event ที่​เกิด​หลัง snapshot ล่าสุดArchitecture — สถานะ ณ จุด​กลาง​ทาง​ที่​เทียบ​เท่ากับ​การ replay event ทั้งหมด​ก่อนหน้า​นั้น

รูปที่ 22-3: ฟื้นสถานะอย่างมีประสิทธิภาพด้วย snapshot — เมื่อต้องโหลด aggregate app แค่หา snapshot ล่าสุดมาเป็นสถานะตั้งต้น แล้ว replay เฉพาะ event ที่เกิดหลังจากนั้น

หัวใจ​ของ event sourcing

อย่า​เก็บ​แค่ “ตอน​นี้​เป็น​อย่างไร” แต่​จง​เก็บ “มัน​เป็น​มา​อย่างไร” — บันทึก​ทุก event ที่​เปลี่ยน​สถานะ​ไว้​เป็น​ข้อมูล​ที่​เปลี่ยนแปลง​ไม่​ได้ (immutable) แล้ว​สร้าง​สถานะ​ปัจจุบัน​ขึ้น​ใหม่​ด้วย​การ replay สิ่ง​ที่​ได้​กลับ​มา​คือ temporal query, projection สำหรับ​การ​วิเคราะห์ และ​พลัง​การ debug ที่​ระบบ​เก็บ snapshot ไม่มี​วัน​ให้​ได้


เพื่อ​ให้​เข้า​กับ event sourcing AggregateAggregateกลุ่ม​ของ Entity และ Value Object ที่​ถูก​มอง​เป็น​หนึ่ง​หน่วย​เดียว​เพื่อ​รักษา​ความ​ถูกต้อง​ของ​ข้อมูล มี​ขอบเขต​ชัดเจน และ​เป็น​หน่วย​ของ transactionTactical Design ต้อง คิด​แบบ event คือ​ต้อง​คำนวณ​สถานะ​ของ​ตัวเอง​ได้​จาก​การ apply ลำดับ event ผลพลอยได้​ที่​ผู้​ปฏิบัติ DDD ชอบ​มาก​คือ aggregate กลาย​เป็น behavior-oriented มาก​ขึ้น สื่อ​เจตนา​ทาง​ธุรกิจ​ชัดเจน​ขึ้น และ​ยัง​ผูก​ติด​กับ persistence แบบหลวมๆ ลง

มี 3 รายละเอียด​สำคัญ​ที่ aggregate แบบ​นี้​ต้อง​มี: (1) apply Domain EventDomain Eventสิ่ง​ที่ “เกิด​ขึ้น​แล้ว” ใน domain ซึ่ง​ส่วน​อื่น​สนใจ แทน​ด้วย​อ็อบเจ็กต์ที่​เปลี่ยนแปลง​ไม่​ได้ ตั้ง​ชื่อ​เป็น​อดีต เช่น CargoWasRouted ใช้​สื่อสาร​ข้าม AggregateTactical Design แล้ว​อัปเดต​สถานะ​ตาม​กฎ​ธุรกิจ (2) เก็บ​รายการ event ที่​ยัง​ไม่ commit เพื่อ​รอ​บันทึก​ลง Event Store และ (3) จด version ของ​ตัวเอง​ไว้ พร้อม​ความ​สามารถ​สร้าง/ฟื้น​จาก snapshot

EventSourcedAggregate.cs
public abstract class EventSourcedAggregate : Entity
{
// event ที่ยังไม่ถูกบันทึก รอ persist ลง Event Store
public List<DomainEvent> Changes { get; private set; }
// เลข version = ลำดับของ event ตัวล่าสุดที่ถูก apply
public int Version { get; protected set; }
public EventSourcedAggregate() { Changes = new List<DomainEvent>(); }
// aggregate ลูกต้อง apply event แล้วเปลี่ยนสถานะตามกฎธุรกิจ
public abstract void Apply(DomainEvent change);
}

หัวใจ​ของ aggregate ลูก​คือ method When() แต่ละ overload อ่าน​ได้​เกือบ​เหมือน​ประโยค​ภาษา​คน: “เมื่อ event นี้​เกิด ให้ เปลี่ยน​สถานะ​แบบ​นี้” ส่วน Apply() แค่​ส่ง event ไป​ยัง When() ที่​ตรง​ชนิด​แล้ว​เลื่อน version

PayAsYouGoAccount.cs
public override void Apply(DomainEvent @event)
{
When((dynamic)@event); // เลือก When() ที่ตรงชนิด event
Version++;
}
// เมื่อมีการเติมเครดิต -> บวกเครดิต
private void When(CreditAdded e) => _credit = _credit.Add(e.Credit);
// เมื่อมีการคิดค่าโทร -> หักเครดิต และหักนาทีฟรีถ้ามี
private void When(PhoneCallCharged e)
{
_credit = _credit.Subtract(e.CostOfCall);
_freeCallAllowance?.Subtract(e.CoveredByAllowance);
}

Apply() เป็น​เพียง​รายละเอียด​ภายใน (เหตุผล​ที่​มัน​เป็น public คือ​เพื่อ​ให้ repository นำมา “rehydrate” ได้) จาก​ภายนอก service ยัง​คุย​กับ aggregate ผ่าน API ที่​สื่อ​ภาษา​ธุรกิจ เช่น TopUp() โดย ไม่รู้​เลย ว่า​เบื้องหลัง​ใช้ event sourcing เคล็ด​ลับ​คือ method Causes() ที่​ทั้ง​เก็บ event ลง Changes และ feed เข้า Apply() ใน​คราว​เดียว

PayAsYouGoAccount.cs
public void TopUp(Money credit, IClock clock)
{
if (_inclusiveMinutesOffer.IsSatisfiedBy(credit))
Causes(new CreditSatisfiesFreeCallAllowanceOffer(
this.Id, clock.Time(), _inclusiveMinutesOffer.FreeMinutes));
Causes(new CreditAdded(this.Id, credit));
}
private void Causes(DomainEvent @event)
{
Changes.Add(@event); // เข้าคิวรอ persist
Apply(@event); // เปลี่ยนสถานะทันที
}
🛒 มอง​เป็น​ตะกร้า​สินค้า

ลอง​นึกถึง ShoppingCart แบบ event-sourced แทนที่​จะ​เก็บ “ตอน​นี้​ใน​ตะกร้า​มี​อะไร​บ้าง” เรา​เก็บ​สาย​ของ event: ItemAdded, ItemRemoved, QuantityChanged, CouponApplied สถานะ​ตะกร้า​ปัจจุบัน​คือ​ผล​ของ​การ replay event เหล่า​นี้​ทั้งหมด ข้อดี​คือ​เรา​ตอบ​ได้​ทันที​ว่า “ลูกค้า​หยิบ​สินค้า​ใส่​แล้ว​เอา​ออก​กี่​ครั้ง” — ข้อมูล​กระบวนการ​ธุรกิจ (business process) ที่​ระบบ​เก็บ​แค่ snapshot ทิ้ง​ไป​จน​หมด


การ persist aggregate แบบ event-sourced คือ​แค่​เก็บ Changes ลง event store ส่วน​การ​โหลด (เรียก​ว่า rehydrating) คือ​ดึง event ทั้งหมด​กลับ​มา replay โดย​เลือก​ใช้ snapshot เป็น​ทาง​ลัด​ได้ RepositoryRepositoryตัว​ที่​ทำให้​เรา​เข้าถึง Aggregate ราวกับ​เป็น collection ใน​หน่วย​ความ​จำ ซ่อน​รายละเอียด​ฐาน​ข้อมูล มี1 repository ต่อ1 Aggregate RootTactical Design จึง​มี 3 งาน​หลัก: สร้าง stream, append ลง stream และ​โหลด stream

PayAsYouGoAccountRepository.cs
public PayAsYouGoAccount FindBy(Guid id)
{
var streamName = StreamNameFor(id);
var stream = _eventStore.GetStream(streamName, 0, int.MaxValue);
var account = new PayAsYouGoAccount();
foreach (var @event in stream)
account.Apply(@event); // ที่เดียวที่ service ภายนอกควรเรียก Apply()
return account;
}
// 1 stream ต่อ1 aggregate: {AggregateType}-{AggregateId}
private string StreamNameFor(Guid id) =>
$"{typeof(PayAsYouGoAccount).Name}-{id}";
ทำไม​ต้อง “1 stream ต่อ1 aggregate”

การ​ตั้ง​ชื่อ stream เป็น {AggregateType}-{AggregateId} ทำให้​ตอน​โหลด aggregate ตัว​หนึ่ง เรา replay เฉพาะ event ของ​มัน​เท่านั้น — ไม่ใช่​ของ​ทุก aggregate ชนิด​เดียวกัน นี่​คือ​กุญแจ​สำคัญ​ของ performance และ scalability และ​ยัง​ทำให้​ระบบ snapshot ง่าย​ขึ้น​มาก

เมื่อ​หลาย​ผู้​ใช้​แก้ aggregate เดียวกัน​พร้อม​กัน บาง​ครั้ง​เรา​ต้องการ​ให้การ​บันทึก ล้มเหลว หาก​มี​คน​อื่น​เปลี่ยน​ข้อมูล​ไป​ก่อน​แล้ว นี่​คือ Optimistic ConcurrencyOptimistic Concurrencyกลไก​จัดการ​การ​แก้​ข้อมูล​พร้อม​กัน​โดย​ไม่​ล็อก: จด version ตั้งต้น​ตอน​โหลด aggregate แล้ว​ก่อน append event ใหม่ ตรวจ​ว่า version ล่าสุด​ใน stream ยัง​ตรง​กัน​อยู่ ถ้า​ไม่​ตรง​แปล​ว่า​มี​คน​แก้​ไป​ก่อน ให้​ยกเลิก transactionArchitecture ทำได้​สอง​ขั้น: (1) ตอน​โหลด aggregate ให้​จำ version ตั้งต้น​ไว้ (InitialVersion) และ (2) ก่อน append event ใหม่ ให้​ตรวจ​ว่า version ล่าสุด​ใน stream ยัง​ตรง​กับ version ตั้งต้น​นั้น​อยู่ ถ้า​ไม่​ตรง แสดง​ว่า​มี​คน​แทรก​การ​เปลี่ยนแปลง​เข้า​มา​ก่อน​แล้ว จึง​โยน exception เพื่อ​ยกเลิก​การ​บันทึก


คุณ​จะ​ใช้ event store สำเร็จรูป​หรือ​สร้าง​เอง​ก็ได้ — การ​สร้าง​เองจริงๆ แล้ว​คือ​การ​เอา​เครื่องมือ​ที่​มี​อยู่ (เช่น document database หรือ SQL) มา​ใช้​ใน​มุม​ใหม่ และ​เป็น​วิธี​ที่​ดี​ใน​การ​เข้าใจ​แนวคิด​แกน​กลาง interface ที่ repository ใช้​คือ IEventStore:

IEventStore.cs
public interface IEventStore
{
void CreateNewStream(string streamName, IEnumerable<DomainEvent> events);
void AppendEventsToStream(string streamName,
IEnumerable<DomainEvent> events, int? expectedVersion);
IEnumerable<DomainEvent> GetStream(string streamName, int from, int to);
void AddSnapshot<T>(string streamName, T snapshot);
T GetLatestSnapshot<T>(string streamName) where T : class;
}

หนังสือ​สาธิต​การ implement บน RavenDB (document DB) โดย​ใช้ 3 document type: EventStream (เก็บ meta เช่น Id และ Version), EventWrapper (ห่อ domain event หนึ่ง​ตัว​พร้อม meta บอกว่า​เป็น​ของ stream ไหน ลำดับ​ที่​เท่าไร) และ SnapshotWrapper แต่ละ event เก็บ​เป็น document แยก แล้ว​ใช้ EventStreamId ผูก​กลับ​เข้า stream — “stream” จึง​เป็น​แนวคิด​เชิง​ตรรกะ ไม่ใช่​ตาราง​จริง

อีก​ทาง​คือ​ใช้ SQL Server ซึ่ง​คำ​แนะนำ​คือ ออกแบบ schema ให้ generic และ domain-agnostic เก็บ event เป็น blob ของ JSON/XML (ดู project อย่าง Ncqrs และ NEventStore เป็น​ตัวอย่าง​อ้างอิง):

EventStore schema (SQL Server)
Table Events: Id, TimeStamp, Name, Version,
EventSourceId, Sequence, Data (nvarchar max)
Table EventSources: Id, Type, Version
Table Snapshots: EventSourceId, Version, TimeStamp, Type, Data
การ abstract data access ก็​มี​ต้นทุน

IEventStore ให้​ความ​ยืดหยุ่น​ใน​การ​สลับ​เทคโนโลยี แต่​ขณะ​เดียวกัน​ก็ บีบ ให้​คุณ​ยึด​ตาม interface ที่​ตายตัว ซึ่ง​อาจ​ปิด​กั้น feature ขั้น​สูง​ของ​เทคโนโลยี​แต่ละ​ตัว abstraction นี้​ยัง “รั่ว” (leaky) เล็กน้อย​ด้วย เช่น กติกา​การ​ตั้ง​ชื่อ stream ของ Event Store ที่​อิง hyphen ตัว​สุดท้าย — ไม่มี​อะไร​รับประกัน​ว่าการ​สลับ provider จะ​ราบรื่น​เต็ม​ร้อย

ใน​ยุค​แรกๆ ของ event sourcing ไม่มี​เครื่องมือ​เชิง​พาณิชย์ นัก​พัฒนา​ต้อง​สร้าง​เอง​บน SQL/document DB ซึ่ง ทำได้​จริง แต่​พอ​ขยับ​ไป​สู่​เรื่อง​ขั้น​สูง​อย่าง projection, temporal query ที่​ซับซ้อน หรือ scalability คุณ​อาจ​เสีย​เวลา​เบน​ออก​จาก​การ​สร้าง​คุณค่า​ทาง​ธุรกิจ จึง​น่า​พิจารณา​เครื่องมือ​ที่​ออกแบบ​มา​เพื่อ​งาน​นี้​โดย​เฉพาะ (purpose-built)


การ​เลือก event store สำเร็จรูป​อย่าง Greg Young’s Event Store ลด​งาน​ตั้งต้น​และ​ให้ feature ขั้น​สูง​มา​ใน​กล่อง ทั้ง projection และ multinode clustering สิ่ง​ที่​เห็น​ได้​ชัด​คือ​มัน​รองรับ​แนวคิด “stream” แบบ native (ไม่ใช่​จำลอง​ด้วย query ซับซ้อน​เหมือน RavenDB) ดังนั้น​ทั้ง​การ query event และ optimistic concurrency จึง​เขียน​สั้น​กว่า​มาก — แค่​ส่ง​ชื่อ stream กับ version ที่​คาด​หวัง​เข้าไป ตัว Event Store จัดการ​ตรวจ concurrency ให้​เอง

Greg Young เลือก​ใช้ JavaScript เป็น​เครื่องมือ​เขียน query และ projection ตัวอย่าง temporal query หา​ว่า​ลูกค้า​หนึ่ง​คนใช้​ไป​กี่​นาที​ใน​วัน​ที่​กำหนด:

temporal-query.js
fromStream('PayAsYouGoAccount-5b3415c8...')
.when({
$init: function (s, e) { return { minutes: 0 }; }, // สถานะตั้งต้น
"PhoneCallCharged": function (s, e) {
if (e.data.PhoneCall.StartTime.substring(0, 10) === "2014-06-04")
s.minutes += e.data.PhoneCall.Minutes.Number;
}
});

ผล​ของ query คือ object JavaScript ที่​เรียก​ว่า state ซึ่ง​ถูก​ส่ง​เข้า when() พร้อม​ทุก event แล้ว​อัปเดต​ไป​เรื่อยๆ ตาม​ตรรกะ​ที่​เรา​เขียน หาก​ต้องการ​รวม​ข้าม​หลาย​สาย (คล้าย JOIN) ก็​เปลี่ยน​จาก fromStream() เป็น fromCategory('PayAsYouGoAccount') ซึ่ง​รวม​ทุก stream ที่​ชื่อ​ขึ้น​ต้น​เหมือน​กัน

บาง​ครั้ง​เรา​ไม่​ได้​แค่​อยาก​คำนวณ state แต่​อยาก หยิบ event บาง​ส่วน​มาสร้าง stream ใหม่ ใน​ระบบ​ที่​มี event เป็น​พัน​ล้าน​ตัว การ query ทุก​ตัว​ย่อม​ไม่​คุ้ม projection แก้​ปัญหา​นี้​ด้วย​การ​คัด​เฉพาะ event ที่​สนใจ​ไป​ไว้​ใน stream ใหม่ แล้ว​ค่อย query stream นั้น เครื่องมือ​หลัก​คือ linkTo():

projection.js
fromCategory('PayAsYouGoAccount')
.when({
"CreditAdded": function (s, event) {
linkTo('AllTopUps', event); // คัด CreditAdded ทุกตัวไปไว้ใน stream เดียว
}
});

projection ตั้ง​เป็น One-Time (รัน​ครั้ง​เดียว) หรือ Continuous (ใช้​กับ event ใหม่​แบบ near-real-time) ก็ได้ — แบบ continuous นี่เอง​ที่​เป็น​พื้นฐาน​ของ CQRS


ปัญหา​เกิด​เมื่อ​หลาย​หน้า​เว็บ​ต่าง​รัน query ของ​ตัวเอง​ใส่ event stream เดียวกัน เมื่อ load เพิ่ม​ขึ้น stream เดียว​นั้น​รับ​ภาระ​หนัก จน​หน้าที่​ไม่​สำคัญ​อาจ​ฉุด​ประสิทธิภาพ​ของ​หน้าที่​สำคัญ

รูปที่ 22-7: event stream เดียวต้องรองรับหลาย query/use case — แต่ละหน้าเว็บยิง query ของตัวเองใส่สายเดียวกัน ทำให้เกิดการแย่งทรัพยากร

ทางออก​คือ CQRSCQRSCommand Query Responsibility Segregation — แยก model สำหรับ “เขียน/แก้ไข” (command) ออก​จาก model สำหรับ “อ่าน” (query) เหมาะ​กับ domain ซับซ้อน แต่​เพิ่ม​ความ​ซับซ้อน ใช้​อย่าง​ระมัดระวังArchitecture: ฝั่ง command (เขียน) ไหล​ผ่าน domain model ลง event stream ส่วน​ฝั่ง query (อ่าน) ยิง​ใส่ Materialized ViewMaterialized Viewมุมมอง​ข้อมูล​ที่​คำนวณ​และ denormalize ไว้​ล่วงหน้า (เรียก​อีก​ชื่อ​ว่า view cache) สำหรับ​ฝั่ง​อ่าน​ของ CQRS สร้าง​จาก event stream ด้วย projection เพื่อ​ให้ query เร็ว​และ​ลด​ภาระ​บน event stream กลางArchitecture (หรือ view cache) ที่​คำนวณ​ไว้​ล่วงหน้า​สำหรับ​แต่ละ use case โดย​เฉพาะ การ​แย่ง​ทรัพยากร​บน stream เดียว​จึง​ลด​ลง​มาก

รูปที่ 22-8: สร้าง materialized/denormalized view ของ event stream เพื่อรองรับแต่ละ use case — แต่ละหน้าเว็บอ่านจาก view cache ของตัวเอง ลดภาระบน event stream กลาง

วิธี​สร้าง view cache เหล่า​นี้​ก็​คือ projection ที่​เพิ่ง​เรียน​ไป​นั่นเอง ถ้า​ใช้​เครื่องมือ purpose-built คุณ​มี​ความ​สามารถ​นี้​พร้อม​อยู่​แล้ว

🚢 CQRS + event sourcing ใน​ระบบ​ขนส่ง

ลอง​นึกถึง การ​ติดตาม​สินค้าTracking IDรหัส​ติดตาม​ที่​ไม่​ซ้ำ​ของ Cargo ใน​ตัวอย่าง​การ​ขนส่ง ใช้​เป็น identity ของ Cargo ตั้งแต่​ตอน​จอง​จน​ส่ง​มอบTactical Design: ฝั่ง​เขียน​บันทึก Handling EventHandling Eventการ​บันทึก​การ​จัดการ​สินค้า​จริง (LOAD, UNLOAD, RECEIVE, CLAIM) เป็น Aggregate “แยก​ต่างหาก” จาก Cargo เพราะ​มี​ปริมาณ​มาก​และ​ต้อง​ประมวล​ผล​แบบ asyncTactical Design (Loaded, Unloaded, Customs, Claimed) ลง event stream ของ Cargo แต่ละ​ชิ้น ส่วน​หน้า​จอ​ลูกค้า​ที่​ถาม​ว่า “ตอน​นี้​ของ​อยู่​ที่ไหน” อ่าน​จาก materialized view ที่ projection อัปเดต​แบบ continuous ทุก​ครั้ง​ที่​มี handling event ใหม่ — ฝั่ง​เขียน​กับ​ฝั่ง​อ่าน​แยก​กัน​ชัดเจน ต่าง​ฝ่าย​ต่าง scale ได้​อย่าง​อิสระ

นอกจาก​สร้าง view cache ได้​ง่าย การ​รวม CQRS กับ event sourcing ยัง​ให้​ประโยชน์​อื่น:

  • Event stream เป็น​คิว​ใน​ตัว — Event Store เปิด stream เป็น Atom feed ได้ จึง​ไม่​ต้อง​พึ่ง message bus แยก​ต่างหาก
  • ไม่​ต้อง​มี two-phase commit — เพราะ Event Store เป็น​ทั้ง​แหล่ง​ข้อมูล​หลัก เครื่องมือ​ทำ projection และ​คิว อยู่​ใน​ที่​เดียว เมื่อ event เข้า stream แล้ว​ถือว่า “เข้า​คิว​สำเร็จ” และ “บันทึก​สำเร็จ” ไป​พร้อม​กัน — ไม่​ต้อง​กังวล​เรื่อง distributed transaction ที่​ต้อง rollback ข้าม​หลาย​ระบบ

โดย​พื้นฐาน​แล้ว CQRS คือ​เรื่อง​ของ​การ denormalization แต่​พลัง​เสริม (synergy) ที่​เกิด​ขึ้น​เมื่อ​รวม​กับ event sourcing นั้น​แรง​จน​ก่อ​ให้​เกิด​ทั้ง​คอมมูนิ​ตี​รอบๆ การ​รวม​สอง​สิ่ง​นี้​เข้า​ด้วย​กัน

— Scott Millett & Nick Tune


เมื่อไร​ควร​ใช้ event sourcing

ข้อดี​หลัก: ได้​เปรียบ​การ​แข่งขัน จาก​การ​วิเคราะห์​ประวัติ​กระบวนการ​ธุรกิจ (business process) ได้​ทั้งหมด, aggregate ที่​สื่อ​ความหมาย​ชัด (When-event อ่าน​ได้​เหมือน​ประโยค), persistence ที่​ง่าย​ขึ้น (ไม่มี impedance mismatch แบบ ORM) และ debug เหนือ​ชั้น เพราะ replay ลำดับ event ช่วง​ใด​ก็ได้​เพื่อ​หา​ต้นตอ​ของ bug

แต่​ต้อง​สมจริง​กับ ต้นทุน ด้วย — แม้แต่​ผู้​ขาย event store เอง​ก็​เตือน​ให้​คิด​ให้​ดี​ว่า​จะ​ใช้​ตรง​ไหน:

  • Versioning — เมื่อ​เรียนรู้ domain มาก​ขึ้น คุณ​อาจ​ต้อง​เปลี่ยน​โครงสร้าง event แต่ stream เก่า​ยัง​เต็ม​ไป​ด้วย event รูปแบบ​เดิม มี​ทาง​แก้ แต่​ถ้า​ไม่​ใส่ใจ versioning จะ​กลาย​เป็น​ปัญหา​ใหญ่
  • แนวคิด​และ​ทักษะ​ใหม่ — projection, temporal query, snapshot ล้วน​ใหม่​สำหรับ​ทีม​ที่​เพิ่ง​เริ่ม ความเร็ว​ใน​ระยะ​สั้น​จะ​ช้า​ลง ต้อง​เผื่อ​เวลา​เรียนรู้
  • เทคโนโลยี​ใหม่​ที่​ต้อง master — การนำ event store ขึ้น production จริง (รัน, monitor, ดู resource) กิน​ชั่วโมง​คน​ไม่​น้อย
  • พื้นที่​เก็บ​ข้อมูล​มาก​ขึ้น — เก็บ​ประวัติ​ทั้งหมด​ย่อม​กิน disk มากกว่า​เก็บ​แค่​สถานะ (แม้​วัน​นี้ storage จะ​ถูก แต่​ก็​ควร​เฝ้า​ระวัง)
อย่า​ใช้​เพราะ​กระแส

event sourcing ไม่ใช่​กระสุน​เงิน (silver bullet) — บ่อย​ครั้ง​มัน “ลงแรง​เยอะ​แต่​ผล​ตอบแทน​ไม่​คุ้ม” อย่า​นำ​มา​ใช้​โดย​ไม่​พิจารณา​ให้​รอบคอบ มัน​จะ​เปล่ง​ประกาย​ก็​ต่อ​เมื่อ​ใช้​กับ domain ที่​ประวัติ​ของ​กระบวนการ​ธุรกิจ (business process) มี​คุณค่า​ทาง​ธุรกิจ​จริง ไม่ใช่​กับ CRUD ธรรมดา


🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • Aggregate — หลักการ​ออกแบบ aggregate โดย​ทั่วไป ที่ event-sourced aggregate ใน​บท​นี้​ยัง​ต้อง​ยึด​ตาม (consistency boundary, invariant) แม้​จะ​เปลี่ยน​วิธี​คำนวณ​สถานะ​เป็นการ replay event
  • Domain Events — รูปแบบ event ที่ aggregate ปล่อย​ออก​มา​เมื่อ​สถานะ​เปลี่ยน ซึ่ง​เป็น​หน่วย​พื้นฐาน​ที่ event sourcing เก็บ​ลง stream แทน snapshot
  • CQRS — อธิบาย​การ​แยก​ฝั่ง​เขียน/อ่าน​โดย​ละเอียด เสริม​ส่วน “CQRS ผสาน​กับ Event Sourcing” ของ​บท​นี้ ว่า​ทำไม materialized view ถึง​ช่วย​ลด​การ​แย่ง​ทรัพยากร​บน event stream
  • Repository — รูปแบบ​ที่​บท​นี้​ใช้​ห่อ​การ persist/rehydrate aggregate จาก Event Store ให้ service ภายนอก​ไม่​ต้อง​รู้เรื่อง event stream เลย

เช็กความเข้าใจ — บทที่ 22

ข้อ 1 / 4

แก่นของ event sourcing ต่างจากการเก็บ snapshot อย่างไร?