Levelwise
فارسی
سیستم‌های توزیع‌شده

الگوی Event Sourcing

به جای ذخیره وضعیت آخر، همه اتفاق‌ها را به ترتیب به شکل رویداد ذخیره کن. وضعیت فعلی را با اجرای دوباره رویدادها می‌سازیم. تاریخچه کامل و قابل حسابرسی می‌گیری، ولی پیچیدگی سیستم خیلی بیشتر می‌شود.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۵ دقیقهمثال سفارش در فروشگاه اینترنتیکد C# و .NET 10

نویسنده: bezzad

مشکل: وضعیت آخر، تاریخچه را پاک می‌کند

در فروشگاه اینترنتی، جدول سفارش‌ها فقط وضعیت آخر هر سفارش را نگه می‌دارد: پرداخت‌شده، جمع ۹۰۰ هزار تومان، شهر شیراز.

یک روز مشتری شکایت می‌کند: «سفارش من به تهران رفت، ولی آدرسم شیراز است!» تیم پشتیبانی می‌خواهد بداند چه شد:

  1. آدرس کی عوض شد؟ قبل از ارسال یا بعد از آن؟
  2. آدرس قبلی چه بود؟
  3. چه کسی آن را عوض کرد؟

جدول جوابی ندارد. هر به‌روزرسانی، مقدار قبلی را پاک کرده است. فقط وضعیت آخر مانده است.

ایده: رویدادها را ذخیره کن، نه وضعیت را

در Event Sourcing، هر تغییر یک رویداد است: یک اتفاق که در گذشته افتاده و دیگر عوض نمی‌شود. مثل «کالا اضافه شد» یا «آدرس عوض شد». ما فقط همین رویدادها را ذخیره می‌کنیم.

روش معمول: فقط وضعیت آخرسفارش ۴۲وضعیت: پرداخت‌شدهجمع: ۹۰۰ هزار تومانشهر: شیرازکالای حذف‌شده و آدرس قبلی؟ از بین رفته‌اندEvent Sourcing۱. سفارش ساخته شد۲. هدفون اضافه شد، ۶۰۰ هزار تومان۳. کتاب اضافه شد، ۳۰۰ هزار تومان۴. آدرس از تهران به شیراز عوض شد۵. پرداخت شد، ۹۰۰ هزار تومانفقط اضافه می‌شود. هیچ چیز پاک یا عوض نمی‌شود.وضعیت آخر را همیشه می‌شود از روی رویدادها دوباره ساخت
روش معمول فقط عکس آخر را دارد. Event Sourcing کل فیلم را دارد.
  1. فقط اضافه کردن. رویداد جدید به ته لیست اضافه می‌شود. هیچ رویدادی پاک یا ویرایش نمی‌شود.
  2. وضعیت فعلی محاسبه می‌شود. برای دانستن وضعیت سفارش، همه رویدادهایش را از اول به ترتیب اجرا می‌کنیم.
  3. حساب بانکی یک مثال قدیمی است. بانک موجودی را فقط یک عدد نمی‌داند. موجودی، جمع همه واریزها و برداشت‌ها است. دفتر حسابداری هم همین‌طور است: اشتباه را پاک نمی‌کنند، یک سطر اصلاحی اضافه می‌کنند.
اسم رویداد به زمان گذشته: رویداد چیزی است که اتفاق افتاده. پس اسمش فعل گذشته است: OrderPlaced، ItemAdded، OrderPaid. فرمان (Command) چیزی است که می‌خواهیم اتفاق بیفتد و ممکن است رد شود: PlaceOrder، AddItem.

مثال زنده

اسلایدر را حرکت بده. وضعیت سفارش هر بار از اول، از روی رویدادها ساخته می‌شود:

مثال زنده: سفارش را از روی رویدادها بساز

    وضعیت سفارش ۴۲

    ببین که «گوشی» یک بار اضافه و بعد حذف شده است. در وضعیت آخر اثری از آن نیست، ولی در رویدادها هست. تیم فروش شاید بخواهد بداند چند مشتری گوشی را به سبد اضافه کردند و بعد پشیمان شدند. با جدول معمولی، این داده از بین رفته بود.

    چه چیزی به دست می‌آوریم؟

    1. تاریخچه کامل و قابل حسابرسی. هر تغییر با زمان و دلیلش ثبت است. برای حوزه‌های مالی و حقوقی خیلی ارزشمند است.
    2. سفر در زمان. می‌شود پرسید «این سفارش دیروز ساعت ۱۰ چه وضعیتی داشت؟». کافی است رویدادها را تا آن لحظه اجرا کنی.
    3. مدل‌های خواندن جدید از داده قدیمی. اگر فردا یک گزارش جدید لازم شد، آن را از روی همه رویدادهای گذشته می‌سازی. داده‌ای از دست نرفته است.
    4. پیدا کردن باگ. می‌شود رویدادهای یک سفارش مشکل‌دار را برداشت و دقیقاً همان مسیر را دوباره اجرا کرد.

    کد

    رویدادها و 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);
        }
    }

    چرا چک نسخه لازم است؟

    جریان رویدادهای سفارش ۴۲، الان در نسخه ۵۱۲۳۴۵۶درخواست ۱: پرداختنسخه ۵ را خوانده بودنسخه هنوز ۵ است: قبول شددرخواست ۲: افزودن کالانسخه ۵ را خوانده بودنسخه الان ۶ است: رد شددرخواست دوم دوباره می‌خواند و می‌بیند سفارش پرداخت شده است
    هر دو درخواست نسخه ۵ را خوانده بودند. فقط اولی می‌تواند رویداد ششم را بنویسد.
    1. دو درخواست همزمان، سفارش را در نسخه ۵ می‌خوانند.
    2. درخواست اول «پرداخت شد» را به عنوان رویداد ششم اضافه می‌کند.
    3. درخواست دوم می‌خواهد «کالا اضافه شد» را اضافه کند. ولی نسخه دیگر ۵ نیست.
    4. انبار رویداد آن را رد می‌کند. درخواست دوم دوباره می‌خواند و می‌بیند سفارش پرداخت شده است. پس قانون «سفارش پرداخت‌شده تغییر نمی‌کند» حفظ می‌شود.

    در یک دیتابیس رابطه‌ای، این کار با یک کلید یکتا روی شناسه جریان و شماره نسخه انجام می‌شود. لازم نیست این‌ها را از صفر بسازی. کتابخانه Marten روی PostgreSQL و دیتابیس KurrentDB (که قبلاً EventStoreDB نام داشت) این امکانات را آماده دارند.

    مدل‌های خواندن (Projection)

    خواندن همه رویدادها برای هر صفحه، کند است. مثلاً صفحه «سفارش‌های من» نمی‌تواند برای هر سفارش صدها رویداد را اجرا کند. پس مدل خواندن می‌سازیم:

    فرمانپرداخت کنسفارشقانون‌ها را چک می‌کندانبار رویدادEvent Storeفقط اضافه کردنرویداد جدیدلیست سفارش‌های مشتریگزارش فروش روزانهایندکس جستجومدل‌های خواندن
    رویدادها یک بار ذخیره می‌شوند. هر صفحه یا گزارش، مدل خواندن مخصوص خودش را دارد.
    1. یک کار پس‌زمینه به رویدادهای جدید گوش می‌دهد.
    2. با هر رویداد، یک جدول ساده و آماده نمایش را به‌روز می‌کند.
    3. صفحه‌ها فقط از این جدول می‌خوانند. سریع است.
    4. اگر مدل خواندن خراب شد یا شکلش باید عوض شود، آن را پاک می‌کنی و از روی همه رویدادها دوباره می‌سازی.

    این همان جدا کردن خواندن و نوشتن است (CQRS). Event Sourcing تقریباً همیشه با CQRS می‌آید.

    سازگاری نهایی: مدل خواندن کمی از رویدادها عقب است. یعنی کاربر بعد از پرداخت، شاید برای یک لحظه هنوز «پرداخت‌نشده» ببیند. رابط کاربری باید برای این آماده باشد.

    سختی‌ها

    الگوی Event Sourcing رایگان نیست. این سختی‌ها را قبل از انتخاب بشناس:

    1. رویداد قدیمی عوض نمی‌شود. اگر شکل رویداد ItemAdded تغییر کند، رویدادهای سه سال پیش هنوز شکل قدیمی را دارند. کد باید همه نسخه‌ها را بفهمد. یک راه این است که هنگام خواندن، رویداد قدیمی را به شکل جدید تبدیل کنی (Upcasting).
    2. جریان‌های طولانی کند می‌شوند. اگر یک Aggregate هزاران رویداد دارد، اجرای همه آن‌ها طول می‌کشد. راه حل، عکس لحظه‌ای (Snapshot) است: هر چند صد رویداد، وضعیت را ذخیره کن و از آن‌جا ادامه بده.
    3. پاک کردن داده شخصی سخت است. قانون‌هایی مثل GDPR می‌گویند داده شخصی کاربر باید قابل پاک کردن باشد. ولی رویداد پاک نمی‌شود. راه‌های رایج: داده شخصی را در رویداد نگذار، فقط شناسه را بگذار. یا داده شخصی را با یک کلید مخصوص هر کاربر رمز کن و برای «پاک کردن»، کلید را از بین ببر.
    4. کوئری گرفتن مستقیم سخت است. «همه سفارش‌های بالای یک میلیون تومان» را نمی‌شود مستقیم از رویدادها پرسید. برای هر سؤال، یک مدل خواندن لازم است.
    5. تیم باید طرز فکر جدیدی یاد بگیرد. اشتباه در طراحی رویدادها گران است، چون رویدادها برای همیشه می‌مانند.

    قانون‌های مهم

    1. رویدادها را هیچ وقت ویرایش یا پاک نکن. برای اصلاح، یک رویداد جبرانی اضافه کن.
    2. اسم رویداد، زبان کسب‌وکار است. «آدرس عوض شد»، نه «ردیف به‌روز شد».
    3. همیشه با چک نسخه ذخیره کن. وگرنه دو درخواست همزمان قانون‌ها را می‌شکنند.
    4. متد Apply هیچ قانونی را چک نکند. قانون فقط قبل از ساختن رویداد چک می‌شود.
    5. رویدادها کامل باشند. هر رویداد باید اطلاعات کافی برای ساختن وضعیت داشته باشد، بدون نیاز به داده بیرونی.
    6. از اول برای تغییر شکل رویدادها برنامه داشته باش. نسخه‌بندی و Upcasting.
    7. فقط جایی که ارزش دارد. معمولاً برای یک یا دو بخش مهم سیستم، نه همه آن.

    اشتباه‌های رایج

    اشتباه نتیجه راه درست
    Event Sourcing برای همه بخش‌های سیستم پیچیدگی زیاد برای بخش‌های ساده. فقط بخش‌هایی که تاریخچه برایشان ارزش دارد.
    رویدادهای فنی مثل «ردیف عوض شد» تاریخچه هیچ معنای کسب‌وکاری ندارد. رویداد با زبان کسب‌وکار.
    ذخیره بدون چک نسخه دو درخواست همزمان قانون را می‌شکنند. ذخیره با نسخه مورد انتظار.
    چک قانون داخل متد Apply رویدادهای قدیمی هنگام بارگذاری رد می‌شوند. قانون فقط قبل از ساخت رویداد.
    ویرایش رویدادهای قدیمی برای رفع باگ تاریخچه دیگر قابل اعتماد نیست. رویداد جبرانی یا Upcasting.
    داده شخصی کامل داخل رویداد پاک کردن داده کاربر ممکن نیست. فقط شناسه، یا رمزنگاری با کلید هر کاربر.
    خواندن صفحه‌ها مستقیم از رویدادها صفحه‌ها کند می‌شوند. مدل خواندن (Projection).

    چه وقت Event Sourcing؟

    مناسب

    • تاریخچه و حسابرسی بخشی از کسب‌وکار است: پول، حسابداری، بیمه.
    • سؤال‌هایی مثل «وضعیت در فلان تاریخ چه بود؟» مهم است.
    • دامنه پیچیده است و رویدادها همین الان هم زبان کسب‌وکار هستند.

    نامناسب

    • یک برنامه ساده ثبت و ویرایش داده (CRUD).
    • تیم تجربه‌ای با رویداد، CQRS و سازگاری نهایی ندارد.
    • فقط یک جدول تاریخچه تغییرات کافی است. آن را بساز، نه Event Sourcing.

    خلاصه در شش خط

    1. در Event Sourcing، رویدادها منبع حقیقت هستند، نه وضعیت آخر.
    2. رویدادها فقط اضافه می‌شوند و هیچ وقت عوض یا پاک نمی‌شوند.
    3. وضعیت فعلی با اجرای دوباره رویدادها ساخته می‌شود.
    4. ذخیره همیشه با چک نسخه است تا درخواست‌های همزمان قانون‌ها را نشکنند.
    5. صفحه‌ها از مدل‌های خواندن می‌خوانند که از روی رویدادها ساخته می‌شوند.
    6. هزینه‌اش بالاست: نسخه‌بندی رویداد، Snapshot و داده شخصی. فقط جایی استفاده کن که تاریخچه واقعاً ارزش دارد.