الگوی Event Sourcing
به جای ذخیره وضعیت آخر، همه اتفاقها را به ترتیب به شکل رویداد ذخیره کن. وضعیت فعلی را با اجرای دوباره رویدادها میسازیم. تاریخچه کامل و قابل حسابرسی میگیری، ولی پیچیدگی سیستم خیلی بیشتر میشود.
نویسنده: bezzad
مشکل: وضعیت آخر، تاریخچه را پاک میکند
در فروشگاه اینترنتی، جدول سفارشها فقط وضعیت آخر هر سفارش را نگه میدارد: پرداختشده، جمع ۹۰۰ هزار تومان، شهر شیراز.
یک روز مشتری شکایت میکند: «سفارش من به تهران رفت، ولی آدرسم شیراز است!» تیم پشتیبانی میخواهد بداند چه شد:
- آدرس کی عوض شد؟ قبل از ارسال یا بعد از آن؟
- آدرس قبلی چه بود؟
- چه کسی آن را عوض کرد؟
جدول جوابی ندارد. هر بهروزرسانی، مقدار قبلی را پاک کرده است. فقط وضعیت آخر مانده است.
ایده: رویدادها را ذخیره کن، نه وضعیت را
در Event Sourcing، هر تغییر یک رویداد است: یک اتفاق که در گذشته افتاده و دیگر عوض نمیشود. مثل «کالا اضافه شد» یا «آدرس عوض شد». ما فقط همین رویدادها را ذخیره میکنیم.
- فقط اضافه کردن. رویداد جدید به ته لیست اضافه میشود. هیچ رویدادی پاک یا ویرایش نمیشود.
- وضعیت فعلی محاسبه میشود. برای دانستن وضعیت سفارش، همه رویدادهایش را از اول به ترتیب اجرا میکنیم.
- حساب بانکی یک مثال قدیمی است. بانک موجودی را فقط یک عدد نمیداند. موجودی، جمع همه واریزها و برداشتها است. دفتر حسابداری هم همینطور است: اشتباه را پاک نمیکنند، یک سطر اصلاحی اضافه میکنند.
مثال زنده
اسلایدر را حرکت بده. وضعیت سفارش هر بار از اول، از روی رویدادها ساخته میشود:
وضعیت سفارش ۴۲
ببین که «گوشی» یک بار اضافه و بعد حذف شده است. در وضعیت آخر اثری از آن نیست، ولی در رویدادها هست. تیم فروش شاید بخواهد بداند چند مشتری گوشی را به سبد اضافه کردند و بعد پشیمان شدند. با جدول معمولی، این داده از بین رفته بود.
چه چیزی به دست میآوریم؟
- تاریخچه کامل و قابل حسابرسی. هر تغییر با زمان و دلیلش ثبت است. برای حوزههای مالی و حقوقی خیلی ارزشمند است.
- سفر در زمان. میشود پرسید «این سفارش دیروز ساعت ۱۰ چه وضعیتی داشت؟». کافی است رویدادها را تا آن لحظه اجرا کنی.
- مدلهای خواندن جدید از داده قدیمی. اگر فردا یک گزارش جدید لازم شد، آن را از روی همه رویدادهای گذشته میسازی. دادهای از دست نرفته است.
- پیدا کردن باگ. میشود رویدادهای یک سفارش مشکلدار را برداشت و دقیقاً همان مسیر را دوباره اجرا کرد.
کد
رویدادها و Aggregate
رویدادها رکوردهای ساده و تغییرناپذیر هستند:
public abstract record OrderEvent;
public sealed record OrderPlaced(Guid OrderId, Guid CustomerId, string City) : OrderEvent;
public sealed record ItemAdded(string Sku, int Quantity, decimal Price) : OrderEvent;
public sealed record ItemRemoved(string Sku) : OrderEvent;
public sealed record AddressChanged(string City) : OrderEvent;
public sealed record OrderPaid(decimal Amount) : OrderEvent;
کلاس Order دو کار جدا دارد. متدهای عمومی قانون را چک میکنند و رویداد میسازند. متد Apply فقط وضعیت را از رویداد بهروز میکند و هیچ قانونی را چک نمیکند. چون رویداد قبلاً اتفاق افتاده و نمیشود ردش کرد.
public sealed class Order
{
private readonly Dictionary<string, decimal> _lines = new();
private readonly List<OrderEvent> _newEvents = [];
public Guid Id { get; private set; }
public string City { get; private set; } = "";
public bool IsPaid { get; private set; }
public int Version { get; private set; } // events already stored
public decimal Total => _lines.Values.Sum();
public IReadOnlyList<OrderEvent> NewEvents => _newEvents;
public static Order FromHistory(IEnumerable<OrderEvent> history)
{
var order = new Order();
foreach (var e in history)
{
order.Apply(e);
order.Version++;
}
return order;
}
public void AddItem(string sku, int quantity, decimal price)
{
if (IsPaid) throw new InvalidOperationException("A paid order cannot change.");
Raise(new ItemAdded(sku, quantity, price));
}
public void Pay()
{
if (IsPaid) throw new InvalidOperationException("Order is already paid.");
if (_lines.Count == 0) throw new InvalidOperationException("Order is empty.");
Raise(new OrderPaid(Total));
}
private void Raise(OrderEvent e)
{
Apply(e);
_newEvents.Add(e);
}
// Only changes state. No rules here: the event already happened.
private void Apply(OrderEvent e)
{
switch (e)
{
case OrderPlaced p: Id = p.OrderId; City = p.City; break;
case ItemAdded a: _lines[a.Sku] = a.Quantity * a.Price; break;
case ItemRemoved r: _lines.Remove(r.Sku); break;
case AddressChanged c: City = c.City; break;
case OrderPaid: IsPaid = true; break;
}
}
}
ذخیره با چک نسخه
انبار رویداد (Event Store) دو کار اصلی دارد: خواندن همه رویدادهای یک جریان (Stream)، و اضافه کردن رویدادهای جدید به شرط اینکه نسخه عوض نشده باشد:
public interface IEventStore
{
Task<IReadOnlyList<OrderEvent>> ReadAsync(Guid streamId, CancellationToken ct);
// Fails if someone else appended to the stream after expectedVersion.
Task AppendAsync(Guid streamId, int expectedVersion,
IReadOnlyList<OrderEvent> events, CancellationToken ct);
}
public sealed class PayOrderHandler(IEventStore store)
{
public async Task HandleAsync(Guid orderId, CancellationToken ct)
{
var order = Order.FromHistory(await store.ReadAsync(orderId, ct));
order.Pay();
await store.AppendAsync(orderId, order.Version, order.NewEvents, ct);
}
}
چرا چک نسخه لازم است؟
- دو درخواست همزمان، سفارش را در نسخه ۵ میخوانند.
- درخواست اول «پرداخت شد» را به عنوان رویداد ششم اضافه میکند.
- درخواست دوم میخواهد «کالا اضافه شد» را اضافه کند. ولی نسخه دیگر ۵ نیست.
- انبار رویداد آن را رد میکند. درخواست دوم دوباره میخواند و میبیند سفارش پرداخت شده است. پس قانون «سفارش پرداختشده تغییر نمیکند» حفظ میشود.
در یک دیتابیس رابطهای، این کار با یک کلید یکتا روی شناسه جریان و شماره نسخه انجام میشود. لازم نیست اینها را از صفر بسازی. کتابخانه Marten روی PostgreSQL و دیتابیس KurrentDB (که قبلاً EventStoreDB نام داشت) این امکانات را آماده دارند.
مدلهای خواندن (Projection)
خواندن همه رویدادها برای هر صفحه، کند است. مثلاً صفحه «سفارشهای من» نمیتواند برای هر سفارش صدها رویداد را اجرا کند. پس مدل خواندن میسازیم:
- یک کار پسزمینه به رویدادهای جدید گوش میدهد.
- با هر رویداد، یک جدول ساده و آماده نمایش را بهروز میکند.
- صفحهها فقط از این جدول میخوانند. سریع است.
- اگر مدل خواندن خراب شد یا شکلش باید عوض شود، آن را پاک میکنی و از روی همه رویدادها دوباره میسازی.
این همان جدا کردن خواندن و نوشتن است (CQRS). Event Sourcing تقریباً همیشه با CQRS میآید.
سختیها
الگوی Event Sourcing رایگان نیست. این سختیها را قبل از انتخاب بشناس:
- رویداد قدیمی عوض نمیشود. اگر شکل رویداد ItemAdded تغییر کند، رویدادهای سه سال پیش هنوز شکل قدیمی را دارند. کد باید همه نسخهها را بفهمد. یک راه این است که هنگام خواندن، رویداد قدیمی را به شکل جدید تبدیل کنی (Upcasting).
- جریانهای طولانی کند میشوند. اگر یک Aggregate هزاران رویداد دارد، اجرای همه آنها طول میکشد. راه حل، عکس لحظهای (Snapshot) است: هر چند صد رویداد، وضعیت را ذخیره کن و از آنجا ادامه بده.
- پاک کردن داده شخصی سخت است. قانونهایی مثل GDPR میگویند داده شخصی کاربر باید قابل پاک کردن باشد. ولی رویداد پاک نمیشود. راههای رایج: داده شخصی را در رویداد نگذار، فقط شناسه را بگذار. یا داده شخصی را با یک کلید مخصوص هر کاربر رمز کن و برای «پاک کردن»، کلید را از بین ببر.
- کوئری گرفتن مستقیم سخت است. «همه سفارشهای بالای یک میلیون تومان» را نمیشود مستقیم از رویدادها پرسید. برای هر سؤال، یک مدل خواندن لازم است.
- تیم باید طرز فکر جدیدی یاد بگیرد. اشتباه در طراحی رویدادها گران است، چون رویدادها برای همیشه میمانند.
قانونهای مهم
- رویدادها را هیچ وقت ویرایش یا پاک نکن. برای اصلاح، یک رویداد جبرانی اضافه کن.
- اسم رویداد، زبان کسبوکار است. «آدرس عوض شد»، نه «ردیف بهروز شد».
- همیشه با چک نسخه ذخیره کن. وگرنه دو درخواست همزمان قانونها را میشکنند.
- متد Apply هیچ قانونی را چک نکند. قانون فقط قبل از ساختن رویداد چک میشود.
- رویدادها کامل باشند. هر رویداد باید اطلاعات کافی برای ساختن وضعیت داشته باشد، بدون نیاز به داده بیرونی.
- از اول برای تغییر شکل رویدادها برنامه داشته باش. نسخهبندی و Upcasting.
- فقط جایی که ارزش دارد. معمولاً برای یک یا دو بخش مهم سیستم، نه همه آن.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| Event Sourcing برای همه بخشهای سیستم | پیچیدگی زیاد برای بخشهای ساده. | فقط بخشهایی که تاریخچه برایشان ارزش دارد. |
| رویدادهای فنی مثل «ردیف عوض شد» | تاریخچه هیچ معنای کسبوکاری ندارد. | رویداد با زبان کسبوکار. |
| ذخیره بدون چک نسخه | دو درخواست همزمان قانون را میشکنند. | ذخیره با نسخه مورد انتظار. |
| چک قانون داخل متد Apply | رویدادهای قدیمی هنگام بارگذاری رد میشوند. | قانون فقط قبل از ساخت رویداد. |
| ویرایش رویدادهای قدیمی برای رفع باگ | تاریخچه دیگر قابل اعتماد نیست. | رویداد جبرانی یا Upcasting. |
| داده شخصی کامل داخل رویداد | پاک کردن داده کاربر ممکن نیست. | فقط شناسه، یا رمزنگاری با کلید هر کاربر. |
| خواندن صفحهها مستقیم از رویدادها | صفحهها کند میشوند. | مدل خواندن (Projection). |
چه وقت Event Sourcing؟
مناسب
- تاریخچه و حسابرسی بخشی از کسبوکار است: پول، حسابداری، بیمه.
- سؤالهایی مثل «وضعیت در فلان تاریخ چه بود؟» مهم است.
- دامنه پیچیده است و رویدادها همین الان هم زبان کسبوکار هستند.
نامناسب
- یک برنامه ساده ثبت و ویرایش داده (CRUD).
- تیم تجربهای با رویداد، CQRS و سازگاری نهایی ندارد.
- فقط یک جدول تاریخچه تغییرات کافی است. آن را بساز، نه Event Sourcing.
خلاصه در شش خط
- در Event Sourcing، رویدادها منبع حقیقت هستند، نه وضعیت آخر.
- رویدادها فقط اضافه میشوند و هیچ وقت عوض یا پاک نمیشوند.
- وضعیت فعلی با اجرای دوباره رویدادها ساخته میشود.
- ذخیره همیشه با چک نسخه است تا درخواستهای همزمان قانونها را نشکنند.
- صفحهها از مدلهای خواندن میخوانند که از روی رویدادها ساخته میشوند.
- هزینهاش بالاست: نسخهبندی رویداد، Snapshot و داده شخصی. فقط جایی استفاده کن که تاریخچه واقعاً ارزش دارد.