Event Sourcing بخش پنجم
سلام دنیا به روش Event Sourcing- بخش اول
لیست مطالب آموزشی Event Sourcing:
- بخش اول مقدمهای بر Event Sourcing
- بخش دوم آشنایی مقدماتی با ساختار داخلی Event Store
- مقایسه رویکردهای State-Oriented و State-Transition
- مزیتهای Event Sourcing
- سلام به دنیا به روش Event Sourcing
- سلام به دنیا به روش Event Sourcing-بخش دوم
- بخش هفتم Projection
- ویرایش event ها در EventSourcing
- Message، Command یا Event، کدوم رو انتخاب کنم؟
در پست قبلی در مورد برخی از مهمترین مزیتهای طراحی سیستم به روش Event Sourcing صحبت کردیم. در این پست با ارائه یک مثال ساده، پیاده سازی آن را در عمل میتوانیم مشاهده کنیم.
مقدمه
همانطور که در پستهای قبلی اشاره شد، Event Sourcing یکی از نمودهای اصلی State-Transition Oriented ها است. این بدین معنی است که اگر در هر لحظه رفتاری از یک آبجکت/ریسورس را تریگر کنیم، آن آبجکت/ریسورس به وضعیت جدید وارد میشود. در حقیقت ما این آبجکت ها را شبیه یک state machine نگاه میکنیم. با ارسال یک کامند به state machine، وضعیت تغییر کرده و به وضعیت جدید وارد میشود.
در Event Sourcing این تغییر stateها را بصورت event مدل میکنیم. پس میتوانیم اینطور به مسئله نگاه کنیم که با ارسال یک کامند به ریسورس مورد نظر، لیستی از eventها که نشان دهندهی تغییر وضعیت مورد نظر در آن ریسورس میباشد را دریافت میکنیم.
در ادامه با ارائه یک مثال پیادهسازی آن را ارائه میدهم.
- پیادهسازی
در طی پستهای قبلی مثال حساب بانکی از مثال حساب بانکی خیلی استفاده کردم. در این قسمت برای سادگی کار مجدد سراغ همین مثال میروم. شما به بانک مراجعه میکنید و یک حساب بانکی را افتتاح میکند. سپس تراکنشهای مالی خود را از طریق این حساب بانکی انجام میدهید.
در این مثال دو سناریو داریم. یک سناریو برای افتتاح اولیه حساب. داستان کاربری این سناریو بصورت زیر میتواند باشد.
اگر کارت به پشت کارت نگاه کنیم، condition یا شرایط پذیرش آن را نیز میتوانیم مشاهده کنیم.
خب برویم سراغ پیادهسازی این داستان کاربری. اولین کاری که باید انجام بدهیم تبدیل condition داستان کاربری به تست(ها) است.(درباره این موضوع مهم در پستی دیگری مفصلتر صحبت میکنم).
تست تبدیل کامند به دنبالهای از ایونتها
اگر به داستان کاربری بالا نگاه کنیم، مشاهده میکنیم که پس از افتتاح حساب، یکسری رویداد مهم در سیستم باید رخ بدهد که ما نسبت به آنها care هستیم.
- یک حساب باید افتتاح شود
- مبلغی از حساب افتتاح شده به عنوان کارمزد از حساب کسر شود
- مبلغی به عنوان کارمزد سالانه پیامک از حساب کسر شود
در مورد نحوهی نام گذاری ایونتها و همچنین چگونگی شکستن ایونتها نیز در پستهایی جداگانه صحبت خواهم کرد.
public class BankAccountShould
{
[Fact]
public void be_opened_successfully()
{
// Context
var command = new OpenANewBankAccountCommand
{
InitialBalance = 10000000
};
var initialBalanceThresholdService = new InitialBalanceThresholdService();
var bankAccountId = BankAccountId.New(Guid.NewGuid().ToString());
const decimal BankFees = 20000;
const decimal SmsFees = 80000;
// Action and Outcome
InTermsOfAggregateRoot<BankAccount, BankAccountId>
.IfICreate(() => new BankAccount(bankAccountId, command, initialBalanceThresholdService))
.ThenIWillExpectTheseEvents(new ANewBankAccountIsOpened(bankAccountId.Id, command.InitialBalance)
, new TheBankAccountIsDepositedDueToBankFees(bankAccountId.Id, BankFees)
, new TheBankAccountIsDepositedDueToSMSFees(bankAccountId.Id, SmsFees));
}
}
توجه کنید که برای سادگی مثال همه مقادیر پول بصورت decimal در نظر گرفته شده است. و فرض شده است همگی ریال هستند
همانطور که مشاهده میکنید منطق تست بسیار ساده است. اگر یک حساب بانکی با موجودی اولیه ۱ میلیون تومان ایجاد کنیم، انتظار داریم که حساب بانکی که System-under-test است سه ایونت مورد نظر را ایجاد کند.
ما به روش TDD پیش رفتیم. پس کد بالا قاعدتا کامپایل نخواهد شد. باید کلاسهای مورد نظر را ایجاد کنیم.
کلاس کامند OpenANewBankAccountCommand بصورت زیر است.
public class OpenANewBankAccountCommand
{
public decimal InitialBalance { get; set; }
}
خط بعدی یک آی دی برای حساب بانکی تعریف کردیم که کد آن بصورت زیر است.
public class BankAccountId : IsAnIdentity<BankAccountId>
{
public readonly string Id;
public static BankAccountId New(string id) => new(id);
private BankAccountId(string id) => Id = id;
public override IEnumerable<object> GetEqualityComponents()
{
yield return Id;
}
}
سپس حساب بانکی را با ارسال پارامترهای آی دی حساب، و کامند OpenANewBankAccountCommand ایجاد کردیم. در زیر کد حساب بانکی را مشاهده میکنید.
public class BankAccount : IsAnAggregateRoot<BankAccountId>
{
public BankAccount(BankAccountId identity, OpenANewBankAccountCommand cmd) : base(identity)
{
}
}
کد کلاس ایونتهای ANewBankAccountIsOpened و TheBankAccountIsDepositedDueToBankFees و TheBankAccountIsDepositedDueToSMSFees نیز بصورت زیر میباشد.
public class ANewBankAccountIsOpened : IsADomainEvent
{
public decimal InitialBalance { get; }
public ANewBankAccountIsOpened(string aggregateId, decimal initialBalance) : base(aggregateId)
{
InitialBalance = initialBalance;
}
public override IEnumerable<object> GetEqualityComponents()
{
yield return AggregateId;
}
}
public class TheBankAccountIsDepositedDueToBankFees : IsADomainEvent
{
public decimal Fees { get; }
public TheBankAccountIsDepositedDueToBankFees(string aggregateId, decimal fees) : base(aggregateId)
{
Fees = fees;
}
public override IEnumerable<object> GetEqualityComponents()
{
yield return AggregateId;
}
}
public class TheBankAccountIsDepositedDueToSMSFees : IsADomainEvent
{
public decimal Fees { get; }
public TheBankAccountIsDepositedDueToSMSFees(string aggregateId, decimal fees) : base(aggregateId)
{
Fees = fees;
}
public override IEnumerable<object> GetEqualityComponents()
{
yield return AggregateId;
}
}
به عنوان اولین مزیتی که در این طراحی میتوانیم به آن اشاره کنیم، این است که ما یک لاگ از تمامی تغییرات برنامه به ترتیب زمان وقوعشان داریم.
public class BankAccount : IsAnAggregateRoot<BankAccountId>
{
private readonly Queue<IsADomainEvent> _eventQueue;
public BankAccount(BankAccountId identity, OpenANewBankAccountCommand cmd
, InitialBalanceThresholdService service) : base(identity)
{
service.Check(cmd.InitialBalance);
var aNewBankAccountIsOpened = new ANewBankAccountIsOpened(identity.Id, cmd.InitialBalance);
var theBankAccountIsDepositedDueToBankFees = new TheBankAccountIsDepositedDueToBankFees(identity.Id, 20000);
var theBankAccountIsDepositedDueToSmsFees = new TheBankAccountIsDepositedDueToSMSFees(identity.Id, 80000);
Queue(aNewBankAccountIsOpened, theBankAccountIsDepositedDueToBankFees, theBankAccountIsDepositedDueToSmsFees);
}
public void Queue(params IsADomainEvent[] events)
{
foreach (var e in events)
_eventQueue.Enqueue(e);
}
}
برای سادگی، عددهای ۲۰۰۰۰ و ۸۰۰۰۰ بصورت ثابت در نظر گرفته شده اند. ولی در عمل نباید این عددها بصورت ثابت در نظر گرفته بشوند. در پست مربوط به چالشهای ایونت سورسینگ به این مثال دوباره اشاره می کنم.
تست وضعیت حساب بانکی
تست که در بالا نوشته شده، در مورد تست اعمال کردن کامند و دنبالهی صحیحی از ایونت ها است. باید یک تست دیگر نیز بنویسیم که در صورت اعمال کردن یک کامند، وضعیت حساب بانکی به درستی تغییر کند.
[Fact]
public void move_to_valid_state_after_being_opened()
{
// Context
var command = new OpenANewBankAccountCommand
{
InitialBalance = 10000000
};
var initialBalanceThresholdService = new InitialBalanceThresholdService();
var bankAccountId = BankAccountId.New(Guid.NewGuid().ToString());
const decimal BankFees = 20000;
const decimal SmsFees = 80000;
// Action and Outcome
InTermsOfAggregateRoot<BankAccount, BankAccountId>
.IfICreate(() => new BankAccount(bankAccountId, command, initialBalanceThresholdService))
.ThenIWillExpect(bankAccount=> bankAccount.Balance == command.InitialBalance - (BankFees + SmsFees));
}
خب همانطور که مشاهده میکنید منطق این تست نیز بسیار ساده است. اگر ما یک حساب بانکی با موجودی ۱۰۰۰۰۰۰۰ باز کنیم، انتظار داریم بالانس حساب پس از کسر کسورات قانونی مقدار
(۱۰۰۰۰۰۰۰ – (۲۰۰۰۰ + ۸۰۰۰۰)) باشد.
حال باید مقدار Balance را به حساب بانکی اضافه کنیم. به محاسبه Balance در کد حساب بانکی دقت کنید.
public class BankAccount : IsAnAggregateRoot<BankAccountId>
{
private readonly Queue<IsADomainEvent> _eventQueue;
public decimal Balance { get; set; }
public BankAccount(BankAccountId identity, OpenANewBankAccountCommand cmd
, InitialBalanceThresholdService service) : base(identity)
{
service.Check(cmd.InitialBalance);
var aNewBankAccountIsOpened = new ANewBankAccountIsOpened(identity.Id, cmd.InitialBalance);
var theBankAccountIsDepositedDueToBankFees = new TheBankAccountIsDepositedDueToBankFees(identity.Id, 20000);
var theBankAccountIsDepositedDueToSmsFees = new TheBankAccountIsDepositedDueToSMSFees(identity.Id, 80000);
Queue(aNewBankAccountIsOpened, theBankAccountIsDepositedDueToBankFees, theBankAccountIsDepositedDueToSmsFees);
}
public void Queue(params IsADomainEvent[] events)
{
foreach (var e in events)
{
_eventQueue.Enqueue(e);
On((dynamic)e);
}
}
private void On(ANewBankAccountIsOpened @event)
=> Balance = @event.InitialBalance;
private void On(TheBankAccountIsDepositedDueToBankFees @event)
=> Balance -= @event.Fees;
private void On(TheBankAccountIsDepositedDueToSMSFees @event)
=> Balance -= @event.Fees;
}
همانطور که در کد بالا میبینید مقدار Balance در سازنده کلاس تغییر نمیکند. اولین تغییر در متد Queue رخ داده است. این متد علاوه بر Queue کردن ایونت مورد نظر متد On را نیز صدا میزند. قسمت مهم دیگری که افزوده شده، متدهای On است. به ازای هر ایونت یک متد On اضافه شده است. این متدها reaction مناسب در قبال دریافت ایونت مورد نظر توسط حساب بانکی را هندل میکنند.
- Apply کردن ایونتها بر روی حساب بانکی
آخرین تستی که باید بنویسیم تست apply کردن ایونتها توسط حساب بانکی است. به این معنی که اگر سه ایونت بالا را به حساب بانکی بدهیم و سه ایونت مورد نظر به به ترتیب بر روی حساب بانکی اعمال کنیم، انتظار داریم آخرین وضعیت حساب بانکی را به درستی بدست بیاوریم. این تستها برای اطمینان از این است که با داشتن دنبالهای از ایونتها، آخرین وضعیت حساب بانکی به درستی محاسبه می شود. به عنوان مثال اگر حساب بانکی را با مقدار اولیه ۱۰۰۰۰۰۰۰ریال باز کنیم، سپس کارمزد بانکی ۲۰۰۰۰ ریالی و یک کازمرد پیامکی ۸۰۰۰۰ از آن کسر کرده باشیم، انتظار داریم که حساب بانکی مورد نظر دارای بالانس (۱۰۰۰۰۰۰۰ – ۲۰۰۰۰ – ۸۰۰۰۰) باشد.
[Fact]
public void move_to_last_state_after_applying_all_previews_domain_events()
{
// Context
const int InitialBalance = 10000000;
const decimal BankFees = 20000;
const decimal SmsFees = 80000;
var bankAccountId = BankAccountId.New(Guid.NewGuid().ToString());
var aNewBankAccountIsOpened = new ANewBankAccountIsOpened(bankAccountId.Id, InitialBalance);
var theBankAccountIsDepositedDueToBankFees = new TheBankAccountIsDepositedDueToBankFees(bankAccountId.Id, BankFees);
var theBankAccountIsDepositedDueToSmsFees = new TheBankAccountIsDepositedDueToSMSFees(bankAccountId.Id, SmsFees);
// Action and Outcome
InTermsOfAggregateRoot<BankAccount, BankAccountId>
.IfIApplied(aNewBankAccountIsOpened,
theBankAccountIsDepositedDueToBankFees,
theBankAccountIsDepositedDueToSmsFees)
.ThenIWillExpect(bankAccount => bankAccount.Balance == InitialBalance - (BankFees + SmsFees));
}
باید سازندهی دیگری به کلاس حساب بانکی اضافه کنیم. این سازنده لیست ایونتهایی که قبلا توسط حساب بانکی ایجاد شده بود را دریافت میکند. سپس متد On مربوط به هر ایونت صدا زده میشود.
public BankAccount(Queue<IsADomainEvent> @events)
{
foreach (var e in @events)
{
On((dynamic)e);
}
}
پایان بخش پنجم