Отсрочка создания и отправки событий домена

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

В настоящее время мы используем статический класс, который наши объекты домена могут вызывать для повышения событий:

static class DomainEvents
{
    public static IEventDispatcher Dispatcher { get; set; }

    public static void Raise<TEvent>(TEvent e)
    {
        if (e != null)
        {
            Dispatcher.Dispatch(e);
        }
    }
}

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

Наша реализация диспетчера просто использует наш контейнер IoC (StructureMap) для размещения обработчиков событий для указанного типа события.

public void Dispatch<TEvent>(TEvent e)
{
    foreach (var handler in container.GetAllInstances<IHandler<TEvent>>())
    {
        handler.Handle(e);
    }
}

В большинстве случаев это нормально. Однако с этим подходом существует несколько проблем:

События должны отправляться только в том случае, если объект успешно сохраняется

Возьмем следующий класс:

public class Order
{
    public string Id { get; private set; }
    public decimal Amount { get; private set; }

    public Order(decimal amount)
    {
        Amount = amount;
        DomainEvents.Raise(new OrderRaisedEvent { OrderId = Id });
    }
}

В конструкторе Order поднимем OrderRaisedEvent. На нашем прикладном уровне мы, скорее всего, создадим экземпляр заказа, добавим его в нашу базу данных "session", а затем зафиксируем/сохраним изменения:

var order = new Order(amount: 10);
session.Store(order);

session.SaveChanges();

Проблема заключается в том, что событие домена возникает до того, как мы успешно сохранили объект Order (совершая транзакцию). Если сбой не удалось, мы все равно отправили бы события.

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

События не должны создаваться до тех пор, пока объект не будет сохранен

Еще одна проблема, с которой я сталкиваюсь, заключается в том, что наши идентификаторы объектов не установлены/не назначены до сохранения объекта (RavenDB - session.Store). Это означает, что в приведенном выше примере идентификатор заказа, переданный этому событию, на самом деле null.

Так как я не уверен, как можно на самом деле генерировать идентификаторы RavenDB, одно решение может заключаться в том, чтобы задержать создание событий до тех пор, пока сущность не будет сохранена, но опять же я не так лучше ее реализую - возможно, в очереди на сбор Func<TEntity, TEvent>?

Ответ 1

Одно из решений (как предложено @synhershko) заключается в перемещении диспетчеризации событий домена вне домена. Таким образом, мы можем гарантировать, что наша сущность сохраняется до того, как мы поднимем какие-либо события.

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

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

public class DeferredEventDispatcher : IEventDispatcher
{
    private readonly IEventDispatcher inner;
    private readonly ConcurrentQueue<Action> events = new ConcurrentQueue<Action>();

    public DeferredEventDispatcher(IEventDispatcher inner)
    {
        this.inner = inner;
    }

    public void Dispatch<TEvent>(TEvent e)
    {
        events.Enqueue(() => inner.Dispatch(e));
    }

    public void Resolve()
    {
        Action dispatch;
        while (events.TryDequeue(out dispatch))
        {
            dispatch();
        }
    }
}

public class UnitOfWork
{
    public void Commit()
    {
        session.SaveChanges();
        dispatcher.Resolve(); // raise events
    }
}

По сути, это достигается тем же, что предложено @synhershko, но сохраняет "повышение" событий внутри моего домена.

Что касается событий, которые не должны создаваться до тех пор, пока сущность не будет сохранена, основная проблема заключается в том, что идентификаторы объектов устанавливались извне RavenDB. Решение, которое постоянно сохраняет мой домен, невежественный и простой в тестировании, - это просто передать идентификатор в качестве параметра конструктора. Это то, что я сделал бы, если бы использовал базу данных SQL (обычно передавая подсказку).

К счастью, RavenDB предоставляет вам возможность генерировать идентификаторы с использованием стратегии hilo (чтобы мы могли сохранять идентификаторы RESTful). Это из проекта RavenDB Contrib:

public static string GenerateIdFor<T>(this IAdvancedDocumentSessionOperations session)
{
    // An entity instance is required to generate a key, but we only have a type.
    // We might not have a public constructor, so we must use reflection.
    var entity = Activator.CreateInstance(typeof(T), true);

    // Generate an ID using the commands and conventions from the current session
    var conventions = session.DocumentStore.Conventions;
    var databaseName = session.GetDatabaseName();
    var databaseCommands = session.GetDatabaseCommands();
    return conventions.GenerateDocumentKey(databaseName, databaseCommands, entity);
}

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

var orderId = session.GenerateIdFor<Order>();
var order = new Order(orderId, 1.99M);

Ответ 2

После нашего обсуждения в Twitter, вместо того, чтобы делать это из конструктора Order, я просто сделаю следующее:

var order = new Order(amount: 10);
session.Store(order);
DomainEvents.Raise(new OrderRaisedEvent { OrderId = order.Id });

session.SaveChanges();

Еще лучше - вы можете создать IDocumentStoreListener и сделать это оттуда в зависимости от сохраняемого типа. Я не вижу причин для этого изнутри конструктора - вы хотите поднять событие, когда заказ был сохранен (или отправлен для сохранения), а не при создании его в памяти.

Так как это будет использовать генератор HiLo внутри, вам гарантировано, что он не получит доступ к БД для каждого вызова Store() и что идентификаторы будут уникальными. Поэтому, если что-то пойдет не так, будет получено сообщение с идентификатором заказа, и когда после нескольких попыток не будет найден заказ с этим идентификатором, вы можете предположить, что что-то пошло не так с его сохранением. Или вы можете попробовать и поймать исключения и поднять другое событие с подробными сведениями об этом идентификаторе заказа.

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