Управление бизнес-логикой с потоками событий: Гибкие и отслеживаемые приложения с Event Sourcing
Введение: Почему стоит записывать события, а не только состояние?
Традиционные подходы к базам данных обычно хранят текущее состояние системы. Когда пользователь выполняет действие, данные в соответствующей таблице обновляются, и старое состояние теряется. Однако во многих сценариях знание того, почему и как что-то изменилось, более ценно, чем просто текущее состояние. Именно здесь вступает в игру паттерн Event Sourcing. Этот паттерн записывает каждое изменение состояния как событие и реконструирует текущее состояние системы как накопление этих событий.
Основные принципы Event Sourcing
Суть Event Sourcing заключается в том, что каждая бизнес-транзакция записывается как 'событие'. Эти события являются неизменяемыми записями, представляющими изменение в системе, и добавляются в хранилище событий (Event Store). Текущее состояние выводится путем последовательного применения этих событий.
- Неизменяемые события: После создания событие никогда не может быть изменено или удалено.
- Хранилище событий: База данных, где все события хранятся в хронологическом порядке.
- Реконструкция состояния: Текущее состояние объекта всегда может быть восстановлено путем последовательного воспроизведения всех соответствующих событий.
Преимущества и варианты использования
- Полная аудируемость: Предоставляет полную историю каждого изменения в системе.
- Временные запросы: Легко запрашивать состояние системы в любой момент времени (например, баланс банка три дня назад).
- Отладка и откат: Потоки событий могут быть исследованы для выявления проблем или отката нежелательных изменений.
- Интеграция с CQRS: Естественным образом согласуется с паттерном разделения ответственности команд и запросов (CQRS), разделяя модели чтения и записи.
- Богатый бизнес-анализ: Позволяет лучше понять причины и следствия изменений в бизнес-процессах.
Практический пример: Простой банковский счет
Давайте смоделируем приложение для банковского счета с использованием Event Sourcing. Создание счета, пополнение и снятие средств будут записываться как события.
1. Определения событий
Каждое событие будет классом, содержащим определенное действие и соответствующие данные.
public interface IEvent {}public class AccountCreatedEvent : IEvent{ public Guid AccountId { get; } public string Owner { get; } public DateTime CreatedAt { get; } public AccountCreatedEvent(Guid accountId, string owner, DateTime createdAt) { AccountId = accountId; Owner = owner; CreatedAt = createdAt; }}public class MoneyDepositedEvent : IEvent{ public Guid AccountId { get; } public decimal Amount { get; } public DateTime DepositedAt { get; } public MoneyDepositedEvent(Guid accountId, decimal amount, DateTime depositedAt) { AccountId = accountId; Amount = amount; DepositedAt = depositedAt; }}public class MoneyWithdrawnEvent : IEvent{ public Guid AccountId { get; } public decimal Amount { get; } public DateTime WithdrawnAt { get; } public MoneyWithdrawnEvent(Guid accountId, decimal amount, DateTime withdrawnAt) { AccountId = accountId; Amount = amount; WithdrawnAt = withdrawnAt; }}2. Агрегат и применение событий
Агрегат BankAccount получает команды, генерирует события, а затем применяет эти события к своему собственному состоянию.
public class BankAccount{ public Guid Id { get; private set; } public string Owner { get; private set; } public decimal Balance { get; private set; } private List<IEvent> _changes = new List<IEvent>(); public BankAccount() { Balance = 0; } public static BankAccount CreateNew(Guid accountId, string owner) { var account = new BankAccount(); account.ApplyChange(new AccountCreatedEvent(accountId, owner, DateTime.UtcNow)); return account; } public void Deposit(decimal amount) { if (amount <= 0) throw new ArgumentException("Сумма депозита должна быть больше нуля."); ApplyChange(new MoneyDepositedEvent(Id, amount, DateTime.UtcNow)); } public void Withdraw(decimal amount) { if (amount <= 0) throw new ArgumentException("Сумма снятия должна быть больше нуля."); if (Balance < amount) throw new InvalidOperationException("Недостаточный баланс."); ApplyChange(new MoneyWithdrawnEvent(Id, amount, DateTime.UtcNow)); } public IEnumerable<IEvent> GetUncommittedChanges() => _changes; public void MarkChangesAsCommitted() => _changes.Clear(); public void LoadFromHistory(IEnumerable<IEvent> history) { foreach (var e in history) { ApplyChange(e, isNew: false); } } private void ApplyChange(IEvent e, bool isNew = true) { When((dynamic)e); if (isNew) { _changes.Add(e); } } private void When(AccountCreatedEvent e) { Id = e.AccountId; Owner = e.Owner; Balance = 0; } private void When(MoneyDepositedEvent e) { Balance += e.Amount; } private void When(MoneyWithdrawnEvent e) { Balance -= e.Amount; }}3. Хранилище событий (Event Store)
Хранилище событий сможет сохранять события и воспроизводить события для агрегата.
public interface IEventStore{ void SaveEvents(Guid aggregateId, IEnumerable<IEvent> events, int expectedVersion); List<IEvent> GetEventsForAggregate(Guid aggregateId);}// Простая реализация In-Memory Event Storepublic class InMemoryEventStore : IEventStore{ private readonly Dictionary<Guid, List<IEvent>> _storage = new Dictionary<Guid, List<IEvent>>(); public void SaveEvents(Guid aggregateId, IEnumerable<IEvent> events, int expectedVersion) { if (!_storage.ContainsKey(aggregateId)) { _storage.Add(aggregateId, new List<IEvent>()); } var existingEvents = _storage[aggregateId]; // Оптимистическая проверка параллелизма if (expectedVersion != -1 && existingEvents.Count != expectedVersion) { throw new InvalidOperationException("Конфликт параллелизма. Ожидаемая версия: " + expectedVersion + ", Текущая версия: " + existingEvents.Count); } existingEvents.AddRange(events); } public List<IEvent> GetEventsForAggregate(Guid aggregateId) { return _storage.ContainsKey(aggregateId) ? _storage[aggregateId] : new List<IEvent>(); }}4. Выполнение событий и восстановление состояния
// Логика приложения (пример использования)var eventStore = new InMemoryEventStore();var accountId = Guid.NewGuid();var newAccount = BankAccount.CreateNew(accountId, "Иван Петров");newAccount.Deposit(100);newAccount.Withdraw(30);eventStore.SaveEvents(accountId, newAccount.GetUncommittedChanges(), -1);newAccount.MarkChangesAsCommitted();// Загрузить счет из другого места и выполнить операцииvar events = eventStore.GetEventsForAggregate(accountId);var existingAccount = new BankAccount();existingAccount.LoadFromHistory(events);Console.WriteLine($"Владелец счета: {existingAccount.Owner}, Баланс: {existingAccount.Balance}"); // Вывод: Владелец счета: Иван Петров, Баланс: 70existingAccount.Deposit(50);eventStore.SaveEvents(accountId, existingAccount.GetUncommittedChanges(), events.Count);existingAccount.MarkChangesAsCommitted();var updatedEvents = eventStore.GetEventsForAggregate(accountId);var updatedAccount = new BankAccount();updatedAccount.LoadFromHistory(updatedEvents);Console.WriteLine($"Обновленный владелец счета: {updatedAccount.Owner}, Обновленный баланс: {updatedAccount.Balance}"); // Вывод: Обновленный владелец счета: Иван Петров, Обновленный баланс: 120Заключение
Event Sourcing предоставляет совершенно новую перспективу на архитектуру программного обеспечения. Сохраняя последовательность событий, которые составляют каждое состояние, а не просто снимки состояний, мы можем сделать наши системы более прозрачными, аудируемыми и гибкими. Хотя существует начальная кривая обучения, Event Sourcing предлагает мощный инструмент для разработчиков, особенно в сценариях сложной бизнес-логики, где исторические данные имеют решающее значение, и в микросервисной архитектуре. Этот паттерн позволяет понять не только 'как' выглядят ваши системы, но и 'почему' они выглядят именно так.
Комментарии (0)
Пока нет комментариев. Будьте первым!