Каждый раз, когда вы вносите изменения в состояние приложения, вы записываете это изменение как событие.
Вы можете воспроизводить события с начала записи до определенного времени. Затем вы воссоздали состояние приложения на тот момент.

Вот в чем суть Event Sourcing. Это похоже на то, что вы можете отправиться в прошлое во времени. Я нахожу это увлекательным.

Источники событий обеспечивают контрольный журнал, когда вам необходимо выполнить нормативные требования. Это может помочь с отладкой. И вы даже можете исследовать альтернативные реальности: что бы произошло, если бы…

Недавно я увидел отличный доклад Якуба Пилимона и Кенни Бастани о поиске событий.

Доклад представляет собой 1-часовой сеанс кодирования жизни. Два докладчика начинают с простого приложения, которое не связано с событиями. Затем они реорганизуют его, чтобы использовать события.

В конечном итоге они подключают приложение к Apache Kafka. Я пропущу эту часть в этой статье и сосредоточусь на концептуальной части поиска событий.

Резюме разговора

Как пользователь приложения для управления кредитными картами вы можете:

  • Назначьте лимит кредитной карте
  • Снять деньги со счета
  • Вернуть деньги

Для каждой из этих команд есть метод в классе CreditCard.
Вот исходный код метода assignLimit:

public void assignLimit(BigDecimal amount) { 
  if(limitAlreadyAssigned()) {  
    throw new IllegalStateException(); 
  }
  this.initialLimit = amount; 
}

Вот метод withdraw:

public void withdraw(BigDecimal amount) {
  if(notEnoughMoneyToWithdraw(amount)) {
     throw new IllegalStateException();
  }
  if(tooManyWithdrawalsInCycle()) {
     throw new IllegalStateException();
  }
  this.usedLimit = usedLimit.add(amount);
  withdrawals++;
}

Метод repay похож.

Помните, что для поиска событий вам необходимо записывать событие
каждый раз, когда приложение меняет свое состояние?
Таким образом, динамики извлекают каждое изменение состояния в свой собственный метод в классе CreditCard.

Вот обновленный метод withdraw:

public void withdraw(BigDecimal amount) {
  if(notEnoughMoneyToWithdraw(amount)) {
    throw new IllegalStateException();
  }
  if(tooManyWithdrawalsInCycle()) {
      throw new IllegalStateException();
  }
  cardWithdrawn(new CardWithdrawn(uuid, amount, Instant.now()));
}
private CreditCard cardWithdrawn(CardWithdrawn event) {
  this.usedLimit = usedLimit.add(event.getAmount());
  withdrawals++;
  pendingEvents.add(event);
  return this;
}

Экземпляр CardWithdrawn представляет событие, когда пользователь успешно снял деньги. После изменения состояния событие добавляется в список ожидающих событий.

Вы вызываете метод save класса CreditCardRepository, чтобы сбросить ожидающие события в поток событий. Затем слушатели событий могут обрабатывать события.

Помимо полезной нагрузки, каждое событие имеет свой уникальный идентификатор и временную метку. Таким образом, вы можете упорядочить и воспроизвести события позже.
Чтобы воспроизвести события для конкретной кредитной карты, репозиторий вызывает метод recreateFrom класса CreditCard, передавая идентификатор карты и события, сохраненные для нее. :

public static CreditCard recreateFrom(UUID uuid, List<DomainEvent> events) {
  return ofAll(events).foldLeft(new CreditCard(uuid),
    CreditCard::handle);
}
private CreditCard handle(DomainEvent event) {
   return Match(event).of(
      Case($(Predicates.instanceOf(LimitAssigned.class)),
        this::limitAssigned),
      Case($(Predicates.instanceOf(CardWithdrawn.class)),
        this::cardWithdrawn),
      Case($(Predicates.instanceOf(CardRepaid.class)),
        this::cardRepaid),
      Case($(Predicates.instanceOf(CycleClosed.class)),
        this::cycleWasClosed)
   );
}

Этот код использует библиотеку vavr.io для вызова метода handle для каждого события. Метод handle отправляет объект события соответствующему методу.
Например: для каждого события LimitAssigned метод handle вызывает метод limitAssigned с событием в качестве параметра.

Упрощение приложения

Для упрощения кода я использовал библиотеку Требования как код. Сначала я помещаю в модель все классы событий и методы обработки. Нравится:

this.eventHandlingModel = 
   Model.builder()
      .on(LimitAssigned.class).system(this::limitAssigned)
      .on(CardWithdrawn.class).system(this::cardWithdrawn)
      .on(CardRepaid.class).system(this::cardRepaid)
      .on(CycleClosed.class).system(this::cycleWasClosed)
  .build();

Мне пришлось изменить возвращаемый тип методов обработки (например, limitAssigned) на void. Кроме того, преобразование из vavr.io было прямым.

Затем я создал бегунок и запустил его для модели:

this.modelRunner = new ModelRunner();
modelRunner.run(eventHandlingModel);

После этого я изменил методы recreateFrom и handle на следующие:

public static CreditCard recreateFrom(UUID uuid, List<DomainEvent> events) {
    CreditCard creditCard = new CreditCard(uuid);
    events.forEach(ev -> creditCard.handle(ev));
    return creditCard;
}
private void handle(DomainEvent event) {
    modelRunner.reactTo(event);
}

На этом этапе я мог избавиться от зависимости от vavr.io.
Переход завершен. Теперь я мог бы сделать еще несколько упрощений.

Я снова обратился к методу withdraw:

public void withdraw(BigDecimal amount) {
    if(notEnoughMoneyToWithdraw(amount)) {
        throw new IllegalStateException();
    }
    if(tooManyWithdrawalsInCycle()) {
        throw new IllegalStateException();
    }
    cardWithdrawn(new CardWithdrawn(uuid, amount, Instant.now()));
}

Проверка tooManyWithdrawalsInCycle() не зависела от данных события. Это зависело только от состояния CreditCard.
Подобные проверки состояния могут быть представлены в модели как when условия.

После того, как я перенес все проверки состояния для всех методов в модель, это выглядело так:

this.eventHandlingModel = 
  Model.builder()
    .when(this::limitNotAssigned)
        .on(LimitAssigned.class).system(this::limitAssigned)
    .when(this::limitAlreadyAssigned)
        .on(LimitAssigned.class).system(this::throwsException)
    .when(this::notTooManyWithdrawalsInCycle)
        .on(CardWithdrawn.class).system(this::cardWithdrawn)
    .when(this::tooManyWithdrawalsInCycle)
        .on(CardWithdrawn.class).system(this::throwsException)
    .on(CardRepaid.class).system(this::cardRepaid)
    .on(CycleClosed.class).system(this::cycleWasClosed)
.build();

Чтобы это сработало, мне нужно было заменить прямые вызовы методов, изменяющих состояние, методом handle. После этого методы assignLimit и withdraw выглядели так:

public void assignLimit(BigDecimal amount) { 
    handle(new LimitAssigned(uuid, amount, Instant.now()));
}
private void limitAssigned(LimitAssigned event) {
    this.initialLimit = event.getAmount(); 
    pendingEvents.add(event);
}
public void withdraw(BigDecimal amount) {
    if(notEnoughMoneyToWithdraw(amount)) {
        throw new IllegalStateException();
    }
    handle(new CardWithdrawn(uuid, amount, Instant.now()));
}
private void cardWithdrawn(CardWithdrawn event) {
    this.usedLimit = usedLimit.add(event.getAmount());
    withdrawals++;
    pendingEvents.add(event);
}

Как видите, большая часть условной логики переехала из методов в модель. Это упрощает понимание методов.

Меня беспокоило то, что вы не должны забывать добавлять событие к ожидающим событиям. Каждый раз. Или ваш код не будет работать.

Требования как код позволяет контролировать, как система обрабатывает события. Итак, я извлек pendingEvents.add(event) из методов:

modelRunner.handleWith(this::addingPendingEvents);
...
public void addingPendingEvents(StandardEventHandler eventHandler) {
    eventHandler.handleEvent();
    DomainEvent domainEvent = (DomainEvent)eventHandler.getEvent();
    pendingEvents.add(domainEvent);
}

Я мог бы пойти дальше и извлечь логику проверки.
Но я оставляю это тебе в качестве упражнения на размышления, дорогой читатель.

В чем смысл?

Я пытался добиться четкого разделения проблем:

  • Выполнение методов в зависимости от состояния определяется в модели.
  • Проверка данных и изменения состояния находятся в реализациях методов
  • События автоматически добавляются к ожидающим событиям. В целом: код инфраструктуры четко отделен от бизнес-логики.

Упрощение и без того очень простого примера полезно для объяснения.
Но я хочу не об этом говорить.

Дело в том, что на практике такое четкое разделение проблем окупается.
Особенно, если вы работаете с несколькими командами. О сложных проблемах.

Разделение проблем помогает изменять разные части кода с разной скоростью. У вас есть простые правила, где что-то искать. Код легче понять. И проще изолировать блоки для целей тестирования.

Заключение

Надеюсь, вам понравилась моя статья. Пожалуйста, оставляйте отзывы в комментариях.

Вы работали над приложениями для поиска событий?
Каковы были ваши впечатления?
Можете ли вы относиться к тому, что я написал в этой статье?

Я также хочу предложить вам посмотреть мою библиотеку, которую я использовал на протяжении всей статьи. Я буду очень рад, если вы попробуете это на практике и расскажете, что думаете.