«1. Введение

В этом учебном пособии мы изучим основные понятия шаблонов проектирования Command Query Responsibility Segregation (CQRS) и Event Sourcing.

Хотя шаблоны часто называют взаимодополняющими, мы попытаемся понять их по отдельности и, наконец, посмотрим, как они дополняют друг друга. Существует несколько инструментов и сред, таких как Axon, которые помогают использовать эти шаблоны, но мы создадим простое приложение на Java, чтобы понять основы.

2. Основные понятия

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

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

2.1. Event Sourcing

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

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

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

2.2. CQRS

Проще говоря, CQRS — это разделение команд и запросов в архитектуре приложения. CQRS основан на принципе разделения команд и запросов (CQS), предложенном Бертраном Мейером. CQS предлагает разделить операции над объектами предметной области на две отдельные категории: Запросы и команды:

Запросы возвращают результат и не изменяют наблюдаемое состояние системы. Команды изменяют состояние системы, но не обязательно возвращают значение.

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

3. Простое приложение

Мы начнем с описания простого приложения на Java, которое строит модель предметной области.

Приложение будет предлагать операции CRUD для модели предметной области, а также будет обеспечивать постоянство для объектов предметной области. CRUD означает создание, чтение, обновление и удаление, которые являются основными операциями, которые мы можем выполнять с объектом домена.

Мы будем использовать это же приложение для представления Event Sourcing и CQRS в следующих разделах.

В процессе мы будем использовать в нашем примере некоторые концепции из Domain-Driven Design (DDD).

DDD предназначен для анализа и проектирования программного обеспечения, основанного на сложных предметно-ориентированных знаниях. Он основан на идее, что программные системы должны основываться на хорошо разработанной модели предметной области. DDD был впервые предложен Эриком Эвансом как каталог шаблонов. Мы будем использовать некоторые из этих шаблонов для построения нашего примера.

3.1. Обзор приложения

Создание профиля пользователя и управление им является типичным требованием во многих приложениях. Мы определим простую модель предметной области, фиксирующую профиль пользователя вместе с сохраняемостью:

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

3.2. Реализация приложения

«Во-первых, нам нужно создать классы Java, представляющие нашу модель предметной области. Это довольно простая модель предметной области, и она может даже не требовать сложности шаблонов проектирования, таких как Event Sourcing и CQRS. Однако мы не будем усложнять это, чтобы сосредоточиться на понимании основ:

public class User {
private String userid;
    private String firstName;
    private String lastName;
    private Set<Contact> contacts;
    private Set<Address> addresses;
    // getters and setters
}

public class Contact {
    private String type;
    private String detail;
    // getters and setters
}

public class Address {
    private String city;
    private String state;
    private String postcode;
    // getters and setters
}

Кроме того, мы определим простой репозиторий в памяти для сохранения состояния нашего приложения. Конечно, это не добавляет никакой ценности, но достаточно для нашей демонстрации позже:

public class UserRepository {
    private Map<String, User> store = new HashMap<>();
}

Теперь мы определим службу для предоставления типичных операций CRUD в нашей модели предметной области:

public class UserService {
    private UserRepository repository;
    public UserService(UserRepository repository) {
        this.repository = repository;
    }

    public void createUser(String userId, String firstName, String lastName) {
        User user = new User(userId, firstName, lastName);
        repository.addUser(userId, user);
    }

    public void updateUser(String userId, Set<Contact> contacts, Set<Address> addresses) {
        User user = repository.getUser(userId);
        user.setContacts(contacts);
        user.setAddresses(addresses);
        repository.addUser(userId, user);
    }

    public Set<Contact> getContactByType(String userId, String contactType) {
        User user = repository.getUser(userId);
        Set<Contact> contacts = user.getContacts();
        return contacts.stream()
          .filter(c -> c.getType().equals(contactType))
          .collect(Collectors.toSet());
    }

    public Set<Address> getAddressByRegion(String userId, String state) {
        User user = repository.getUser(userId);
        Set<Address> addresses = user.getAddresses();
        return addresses.stream()
          .filter(a -> a.getState().equals(state))
          .collect(Collectors.toSet());
    }
}

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

3.3. Проблемы в этом приложении

Прежде чем мы продолжим обсуждение источников событий и CQRS, стоит обсудить проблемы с текущим решением. В конце концов, мы будем решать одни и те же проблемы, применяя эти шаблоны!

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

    Модель предметной области: операции чтения и записи выполняются в одной и той же модели предметной области. Хотя это не проблема для такой простой модели предметной области, она может ухудшиться по мере усложнения модели предметной области. Возможно, нам потребуется оптимизировать нашу модель предметной области и базовое хранилище для них, чтобы они соответствовали индивидуальным потребностям операций чтения и записи. Постоянство: постоянство, которое мы имеем для наших объектов предметной области, хранит только последнее состояние модели предметной области. Хотя этого достаточно для большинства ситуаций, это усложняет некоторые задачи. Например, если нам нужно выполнить исторический аудит того, как объект предметной области изменил состояние, здесь это невозможно. Для этого нам нужно дополнить наше решение некоторыми журналами аудита.

4. Знакомство с CQRS

Мы начнем решать первую проблему, обсуждавшуюся в предыдущем разделе, с введения шаблона CQRS в наше приложение. В рамках этого мы разделим модель предметной области и ее постоянство для обработки операций записи и чтения. Давайте посмотрим, как шаблон CQRS реструктурирует наше приложение:

На приведенной здесь диаграмме показано, как мы намерены четко разделить архитектуру нашего приложения на стороны записи и чтения. Тем не менее, мы ввели здесь довольно много новых компонентов, которые мы должны лучше понять. Обратите внимание, что они не имеют прямого отношения к CQRS, но CQRS извлекает из них большую пользу: совокупный корень. Совокупный шаблон обеспечивает согласованность транзакций между сущностями.

    CQRS естественным образом выигрывает от агрегированного шаблона, который группирует модель домена записи, обеспечивая транзакционные гарантии. Агрегаты обычно хранят кэшированное состояние для повышения производительности, но могут прекрасно работать и без него.

Проекция/Проектор:

Проекция — это еще один важный шаблон, который дает большие преимущества CQRS. По сути, проекция означает представление объектов предметной области в различных формах и структурах.

    Эти проекции исходных данных доступны только для чтения и оптимизированы для обеспечения более удобного чтения. Мы можем снова решить кэшировать проекции для повышения производительности, но это не обязательно.

4.1. Реализация стороны записи приложения

Давайте сначала реализуем сторону записи приложения.

Мы начнем с определения необходимых команд. Команда — это намерение изменить состояние модели предметной области. Удастся это или нет, зависит от бизнес-правил, которые мы настроим.

Давайте посмотрим на наши команды:

Это довольно простые классы, содержащие данные, которые мы собираемся изменить.

Далее мы определяем агрегат, отвечающий за получение команд и их обработку. Агрегаты могут принять или отклонить команду:

public class CreateUserCommand {
    private String userId;
    private String firstName;
    private String lastName;
}

public class UpdateUserCommand {
    private String userId;
    private Set<Address> addresses;
    private Set<Contact> contacts;
}

«

«Агрегат использует репозиторий для получения текущего состояния и сохранения любых изменений в нем. Более того, он может сохранять текущее состояние локально, чтобы избежать затрат на обращение в репозиторий туда и обратно при обработке каждой команды.

public class UserAggregate {
    private UserWriteRepository writeRepository;
    public UserAggregate(UserWriteRepository repository) {
        this.writeRepository = repository;
    }

    public User handleCreateUserCommand(CreateUserCommand command) {
        User user = new User(command.getUserId(), command.getFirstName(), command.getLastName());
        writeRepository.addUser(user.getUserid(), user);
        return user;
    }

    public User handleUpdateUserCommand(UpdateUserCommand command) {
        User user = writeRepository.getUser(command.getUserId());
        user.setAddresses(command.getAddresses());
        user.setContacts(command.getContacts());
        writeRepository.addUser(user.getUserid(), user);
        return user;
    }
}

Наконец, нам нужен репозиторий для хранения состояния модели предметной области. Обычно это база данных или другое долговременное хранилище, но здесь мы просто заменим их структурой данных в памяти:

На этом завершается запись нашего приложения.

public class UserWriteRepository {
    private Map<String, User> store = new HashMap<>();
    // accessors and mutators
}

4.2. Реализация стороны чтения приложения

Давайте теперь переключимся на сторону чтения приложения. Мы начнем с определения стороны чтения модели предметной области:

Если мы вспомним наши операции чтения, нетрудно увидеть, что эти классы идеально подходят для их обработки. В этом прелесть создания модели предметной области, основанной на имеющихся у нас запросах.

public class UserAddress {
    private Map<String, Set<Address>> addressByRegion = new HashMap<>();
}

public class UserContact {
    private Map<String, Set<Contact>> contactByType = new HashMap<>();
}

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

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

public class UserReadRepository {
    private Map<String, UserAddress> userAddress = new HashMap<>();
    private Map<String, UserContact> userContact = new HashMap<>();
    // accessors and mutators
}

Давайте посмотрим на наши запросы:

Опять же, это простые классы Java, содержащие данные для определения запроса.

public class ContactByTypeQuery {
    private String userId;
    private String contactType;
}

public class AddressByRegionQuery {
    private String userId;
    private String state;
}

Теперь нам нужна проекция, которая может обрабатывать следующие запросы:

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

public class UserProjection {
    private UserReadRepository readRepository;
    public UserProjection(UserReadRepository readRepository) {
        this.readRepository = readRepository;
    }

    public Set<Contact> handle(ContactByTypeQuery query) {
        UserContact userContact = readRepository.getUserContact(query.getUserId());
        return userContact.getContactByType()
          .get(query.getContactType());
    }

    public Set<Address> handle(AddressByRegionQuery query) {
        UserAddress userAddress = readRepository.getUserAddress(query.getUserId());
        return userAddress.getAddressByRegion()
          .get(query.getState());
    }
}

4.3. Синхронизация данных чтения и записи

Одна часть этой головоломки до сих пор не решена: нет ничего для синхронизации наших репозиториев записи и чтения.

Здесь нам понадобится нечто, известное как проектор. У проектора есть логика для проецирования модели домена записи в модель домена чтения.

Есть гораздо более изощренные способы справиться с этим, но мы будем делать это относительно просто:

Это довольно грубый способ сделать это, но дает нам достаточно понимания того, что необходимо для CQRS, чтобы Работа. Более того, нет необходимости размещать репозитории для чтения и записи в разных физических хранилищах. Распределенная система имеет свою долю проблем!

public class UserProjector {
    UserReadRepository readRepository = new UserReadRepository();
    public UserProjector(UserReadRepository readRepository) {
        this.readRepository = readRepository;
    }

    public void project(User user) {
        UserContact userContact = Optional.ofNullable(
          readRepository.getUserContact(user.getUserid()))
            .orElse(new UserContact());
        Map<String, Set<Contact>> contactByType = new HashMap<>();
        for (Contact contact : user.getContacts()) {
            Set<Contact> contacts = Optional.ofNullable(
              contactByType.get(contact.getType()))
                .orElse(new HashSet<>());
            contacts.add(contact);
            contactByType.put(contact.getType(), contacts);
        }
        userContact.setContactByType(contactByType);
        readRepository.addUserContact(user.getUserid(), userContact);

        UserAddress userAddress = Optional.ofNullable(
          readRepository.getUserAddress(user.getUserid()))
            .orElse(new UserAddress());
        Map<String, Set<Address>> addressByRegion = new HashMap<>();
        for (Address address : user.getAddresses()) {
            Set<Address> addresses = Optional.ofNullable(
              addressByRegion.get(address.getState()))
                .orElse(new HashSet<>());
            addresses.add(address);
            addressByRegion.put(address.getState(), addresses);
        }
        userAddress.setAddressByRegion(addressByRegion);
        readRepository.addUserAddress(user.getUserid(), userAddress);
    }
}

Обратите внимание, что неудобно проецировать текущее состояние домена записи на разные модели домена чтения. Пример, который мы здесь взяли, довольно прост, поэтому мы не видим проблемы.

Однако по мере усложнения моделей записи и чтения проектировать будет все труднее. Мы можем решить эту проблему с помощью проекции на основе событий вместо проекции на основе состояния с помощью Event Sourcing. Мы увидим, как этого добиться позже в этом уроке.

4.4. Преимущества и недостатки CQRS

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

Давайте теперь обсудим некоторые другие преимущества, которые CQRS дает архитектуре приложения:

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

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

«Только сложная модель предметной области может извлечь выгоду из дополнительной сложности этого шаблона; простой моделью предметной области можно управлять без всего этого. Естественно, в некоторой степени приводит к дублированию кода, что является приемлемым злом по сравнению с выгодой, к которой оно нас приводит; однако рекомендуется индивидуальное суждение. Отдельные репозитории приводят к проблемам согласованности, и трудно постоянно поддерживать идеальную синхронизацию репозиториев записи и чтения; нам часто приходится довольствоваться окончательной согласованностью

    5. Знакомство с Event Sourcing

Далее мы рассмотрим вторую проблему, которую обсуждали в нашем простом приложении. Если мы помним, это было связано с нашим постоянным хранилищем.

Для решения этой проблемы мы представим Event Sourcing. Event Sourcing кардинально меняет наши представления о хранилище состояний приложений.

Давайте посмотрим, как это изменит наш репозиторий:

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

5.1. Реализация событий и хранилища событий

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

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

public abstract class Event {
    public final UUID id = UUID.randomUUID();
    public final Date created = new Date();
}

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

Далее давайте создадим несколько событий, специфичных для предметной области, наследуемых от этого базового события:

Это простые POJO в Java, содержащие детали события предметной области. Однако здесь важно отметить детализацию событий.

public class UserCreatedEvent extends Event {
    private String userId;
    private String firstName;
    private String lastName;
}

public class UserContactAddedEvent extends Event {
    private String contactType;
    private String contactDetails;
}

public class UserContactRemovedEvent extends Event {
    private String contactType;
    private String contactDetails;
}

public class UserAddressAddedEvent extends Event {
    private String city;
    private String state;
    private String postCode;
}

public class UserAddressRemovedEvent extends Event {
    private String city;
    private String state;
    private String postCode;
}

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

Теперь, естественно, нам нужен репозиторий для хранения событий домена:

Это простая структура данных в памяти для хранения событий домена. На самом деле существует несколько решений, специально созданных для обработки данных о событиях, таких как Apache Druid. Существует множество распределенных хранилищ данных общего назначения, способных обрабатывать источники событий, включая Kafka и Cassandra.

public class EventStore {
    private Map<String, List<Event>> store = new HashMap<>();
}

5.2. Генерация и потребление событий

Итак, теперь наш сервис, обрабатывающий все операции CRUD, изменится. Теперь вместо обновления состояния движущегося домена будут добавляться события домена. Он также будет использовать те же события домена для ответа на запросы.

Давайте посмотрим, как мы можем этого добиться:

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

public class UserService {
    private EventStore repository;
    public UserService(EventStore repository) {
        this.repository = repository;
    }

    public void createUser(String userId, String firstName, String lastName) {
        repository.addEvent(userId, new UserCreatedEvent(userId, firstName, lastName));
    }

    public void updateUser(String userId, Set<Contact> contacts, Set<Address> addresses) {
        User user = UserUtility.recreateUserState(repository, userId);
        user.getContacts().stream()
          .filter(c -> !contacts.contains(c))
          .forEach(c -> repository.addEvent(
            userId, new UserContactRemovedEvent(c.getType(), c.getDetail())));
        contacts.stream()
          .filter(c -> !user.getContacts().contains(c))
          .forEach(c -> repository.addEvent(
            userId, new UserContactAddedEvent(c.getType(), c.getDetail())));
        user.getAddresses().stream()
          .filter(a -> !addresses.contains(a))
          .forEach(a -> repository.addEvent(
            userId, new UserAddressRemovedEvent(a.getCity(), a.getState(), a.getPostcode())));
        addresses.stream()
          .filter(a -> !user.getAddresses().contains(a))
          .forEach(a -> repository.addEvent(
            userId, new UserAddressAddedEvent(a.getCity(), a.getState(), a.getPostcode())));
    }

    public Set<Contact> getContactByType(String userId, String contactType) {
        User user = UserUtility.recreateUserState(repository, userId);
        return user.getContacts().stream()
          .filter(c -> c.getType().equals(contactType))
          .collect(Collectors.toSet());
    }

    public Set<Address> getAddressByRegion(String userId, String state) throws Exception {
        User user = UserUtility.recreateUserState(repository, userId);
        return user.getAddresses().stream()
          .filter(a -> a.getState().equals(state))
          .collect(Collectors.toSet());
    }
}

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

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

5.3. Преимущества и недостатки источников событий

Теперь мы успешно применили альтернативный способ хранения объектов предметной области с использованием источников событий. Источник событий — это мощный шаблон, который при правильном использовании приносит много преимуществ архитектуре приложения:

«Делает операции записи намного быстрее, так как не требуется чтение, обновление и запись; запись — это просто добавление события в журнал. Устраняет объектно-реляционное сопротивление и, следовательно, потребность в сложных инструментах сопоставления; конечно, нам все еще нужно воссоздать объекты обратно. Бывает, чтобы предоставить журнал аудита в качестве побочного продукта, который полностью надежен; мы можем точно отладить, как изменилось состояние модели предметной области. Это позволяет поддерживать временные запросы и достигать путешествий во времени (состояние предметной области в какой-то момент в прошлом)! Это естественное решение для разработки слабосвязанных компонентов в архитектуре микросервисов, которые взаимодействуют асинхронно путем обмена сообщениями

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

Существует связанная с этим кривая обучения и изменение мышления, необходимое для принятия источников событий; во-первых, это не интуитивно понятно. Это затрудняет обработку типичных запросов, поскольку нам нужно воссоздавать состояние, если мы не сохраняем состояние в локальном кеше. модель в архитектуре, управляемой событиями

    6. CQRS с Event Sourcing

Теперь, когда мы увидели, как по отдельности внедрить Event Sourcing и CQRS в наше простое приложение, пришло время объединить их. Теперь должно быть достаточно интуитивно понятно, что эти шаблоны могут значительно выигрывать друг от друга. Однако в этом разделе мы сделаем это более явным.

Давайте сначала посмотрим, как архитектура приложения объединяет их:

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

Обратите внимание, что это не единственный способ использования Event Sourcing и CQRS в архитектуре приложения. Мы можем быть довольно новаторскими и использовать эти шаблоны вместе с другими шаблонами и придумать несколько вариантов архитектуры.

Здесь важно убедиться, что мы используем их для управления сложностью, а не просто для дальнейшего усложнения!

6.1. Объединение CQRS и Event Sourcing

После реализации Event Sourcing и CQRS по отдельности не должно быть сложно понять, как мы можем объединить их.

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

Есть только несколько изменений. Мы начнем с изменения агрегата для генерации событий вместо обновления состояния:

Единственное изменение, которое требуется, — это проектор, который теперь должен обрабатывать события, а не состояния объекта домена:

public class UserAggregate {
    private EventStore writeRepository;
    public UserAggregate(EventStore repository) {
        this.writeRepository = repository;
    }

    public List<Event> handleCreateUserCommand(CreateUserCommand command) {
        UserCreatedEvent event = new UserCreatedEvent(command.getUserId(), 
          command.getFirstName(), command.getLastName());
        writeRepository.addEvent(command.getUserId(), event);
        return Arrays.asList(event);
    }

    public List<Event> handleUpdateUserCommand(UpdateUserCommand command) {
        User user = UserUtility.recreateUserState(writeRepository, command.getUserId());
        List<Event> events = new ArrayList<>();

        List<Contact> contactsToRemove = user.getContacts().stream()
          .filter(c -> !command.getContacts().contains(c))
          .collect(Collectors.toList());
        for (Contact contact : contactsToRemove) {
            UserContactRemovedEvent contactRemovedEvent = new UserContactRemovedEvent(contact.getType(), 
              contact.getDetail());
            events.add(contactRemovedEvent);
            writeRepository.addEvent(command.getUserId(), contactRemovedEvent);
        }
        List<Contact> contactsToAdd = command.getContacts().stream()
          .filter(c -> !user.getContacts().contains(c))
          .collect(Collectors.toList());
        for (Contact contact : contactsToAdd) {
            UserContactAddedEvent contactAddedEvent = new UserContactAddedEvent(contact.getType(), 
              contact.getDetail());
            events.add(contactAddedEvent);
            writeRepository.addEvent(command.getUserId(), contactAddedEvent);
        }

        // similarly process addressesToRemove
        // similarly process addressesToAdd

        return events;
    }
}

Если мы вспомним проблемы, которые мы обсуждали при работе с проекцией на основе состояния, это потенциальное решение.

public class UserProjector {
    UserReadRepository readRepository = new UserReadRepository();
    public UserProjector(UserReadRepository readRepository) {
        this.readRepository = readRepository;
    }

    public void project(String userId, List<Event> events) {
        for (Event event : events) {
            if (event instanceof UserAddressAddedEvent)
                apply(userId, (UserAddressAddedEvent) event);
            if (event instanceof UserAddressRemovedEvent)
                apply(userId, (UserAddressRemovedEvent) event);
            if (event instanceof UserContactAddedEvent)
                apply(userId, (UserContactAddedEvent) event);
            if (event instanceof UserContactRemovedEvent)
                apply(userId, (UserContactRemovedEvent) event);
        }
    }

    public void apply(String userId, UserAddressAddedEvent event) {
        Address address = new Address(
          event.getCity(), event.getState(), event.getPostCode());
        UserAddress userAddress = Optional.ofNullable(
          readRepository.getUserAddress(userId))
            .orElse(new UserAddress());
        Set<Address> addresses = Optional.ofNullable(userAddress.getAddressByRegion()
          .get(address.getState()))
          .orElse(new HashSet<>());
        addresses.add(address);
        userAddress.getAddressByRegion()
          .put(address.getState(), addresses);
        readRepository.addUserAddress(userId, userAddress);
    }

    public void apply(String userId, UserAddressRemovedEvent event) {
        Address address = new Address(
          event.getCity(), event.getState(), event.getPostCode());
        UserAddress userAddress = readRepository.getUserAddress(userId);
        if (userAddress != null) {
            Set<Address> addresses = userAddress.getAddressByRegion()
              .get(address.getState());
            if (addresses != null)
                addresses.remove(address);
            readRepository.addUserAddress(userId, userAddress);
        }
    }

    public void apply(String userId, UserContactAddedEvent event) {
        // Similarly handle UserContactAddedEvent event
    }

    public void apply(String userId, UserContactRemovedEvent event) {
        // Similarly handle UserContactRemovedEvent event
    }
}

Проекция на основе событий достаточно удобна и проще в реализации. Все, что нам нужно сделать, это обработать все происходящие события домена и применить их ко всем моделям домена чтения. Как правило, в приложении, основанном на событиях, проектор будет прослушивать интересующие его события предметной области и не будет полагаться на то, что кто-то вызовет его напрямую.

Это почти все, что нам нужно сделать, чтобы объединить Event Sourcing и CQRS в нашем простом приложении.

7. Заключение

В этом руководстве мы обсудили основы шаблонов проектирования Event Sourcing и CQRS. Мы разработали простое приложение и применили к нему эти шаблоны по отдельности.

В процессе мы поняли преимущества, которые они приносят, и недостатки, которые они представляют. Наконец, мы поняли, почему и как объединить оба эти шаблона в наше приложение.

«Простое приложение, которое мы обсуждали в этом руководстве, даже близко не оправдывает необходимость CQRS и Event Sourcing. Нашей целью было понять основные концепции, поэтому пример был тривиальным. Но, как упоминалось ранее, преимущества этих шаблонов могут быть реализованы только в приложениях с достаточно сложной моделью предметной области.

Как обычно, исходный код этой статьи можно найти на GitHub.

«