Реализация прикладного уровня для микрослужб с помощью веб-API

Совет

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

.NET Microservices Architecture for Containerized .NET Applications eBook cover thumbnail.

Внедрение объектов инфраструктуры в прикладной уровень с помощью внедрения зависимостей

Как упоминалось ранее, прикладной уровень может быть реализован в рамках создаваемого артефакта (сборки), например проекта веб-интерфейса API или проекта веб-приложения MVC. При создании микрослужбы с помощью ASP.NET Core прикладным уровнем обычно является библиотека веб-интерфейсов API. Чтобы отделить ту часть, которая берется из ASP.NET Core (инфраструктуру и контроллеры), от пользовательского кода прикладного уровня, можно поместить прикладной уровень в отдельную библиотеку классов, хотя это необязательно.

Например, код прикладного уровня для микрослужбы размещения заказов реализуется непосредственно в рамках проекта Ordering.API (проекта веб-интерфейса API ASP.NET Core), как показано на рис. 7-23.

Screenshot of the Ordering.API microservice in the Solution Explorer.

Представление микрослужбы Ordering.API Обозреватель решений, в котором отображаются вложенные папки в папке приложения: "Поведение", "Команды", "ДоменEventHandlers", "IntegrationEvents", "Модели", "Запросы" и "Проверки".

Рис. 7-23. Прикладной уровень в проекте веб-интерфейса API ASP.NET Core Ordering.API

ASP.NET Core содержит простой встроенный контейнер IoC (представленный интерфейсом IServiceProvider), поддерживающий внедрение через конструктор по умолчанию, а ASP.NET предоставляет посредством внедрения зависимостей определенные службы. В ASP.NET Core под термином служба понимаются любые зарегистрированные типы, которые будут внедряться посредством внедрения зависимостей. Вы настраиваете встроенные службы контейнера в Program.cs файле приложения. Зависимости реализуются в службах, которые требуются типу и которые вы регистрируете в контейнере IoC.

Как правило, необходимо внедрить зависимости, реализующие объекты инфраструктуры. Типичной внедряемой зависимостью является репозиторий. Однако можно внедрять и любые другие имеющиеся зависимости инфраструктуры. Чтобы упростить реализацию, можно напрямую внедрить объект на основе шаблона "Единица работы" (объект EF DbContext), так как DBContext также является реализацией сохраняемых объектов инфраструктуры.

В приведенном ниже примере показано, как платформа .NET внедряет необходимые объекты репозитория посредством конструктора. Класс представляет собой обработчик команд, описание которого приводится в следующем разделе.

public class CreateOrderCommandHandler
        : IRequestHandler<CreateOrderCommand, bool>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IIdentityService _identityService;
    private readonly IMediator _mediator;
    private readonly IOrderingIntegrationEventService _orderingIntegrationEventService;
    private readonly ILogger<CreateOrderCommandHandler> _logger;

    // Using DI to inject infrastructure persistence Repositories
    public CreateOrderCommandHandler(IMediator mediator,
        IOrderingIntegrationEventService orderingIntegrationEventService,
        IOrderRepository orderRepository,
        IIdentityService identityService,
        ILogger<CreateOrderCommandHandler> logger)
    {
        _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository));
        _identityService = identityService ?? throw new ArgumentNullException(nameof(identityService));
        _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
        _orderingIntegrationEventService = orderingIntegrationEventService ?? throw new ArgumentNullException(nameof(orderingIntegrationEventService));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async Task<bool> Handle(CreateOrderCommand message, CancellationToken cancellationToken)
    {
        // Add Integration event to clean the basket
        var orderStartedIntegrationEvent = new OrderStartedIntegrationEvent(message.UserId);
        await _orderingIntegrationEventService.AddAndSaveEventAsync(orderStartedIntegrationEvent);

        // Add/Update the Buyer AggregateRoot
        // DDD patterns comment: Add child entities and value-objects through the Order Aggregate-Root
        // methods and constructor so validations, invariants and business logic
        // make sure that consistency is preserved across the whole aggregate
        var address = new Address(message.Street, message.City, message.State, message.Country, message.ZipCode);
        var order = new Order(message.UserId, message.UserName, address, message.CardTypeId, message.CardNumber, message.CardSecurityNumber, message.CardHolderName, message.CardExpiration);

        foreach (var item in message.OrderItems)
        {
            order.AddOrderItem(item.ProductId, item.ProductName, item.UnitPrice, item.Discount, item.PictureUrl, item.Units);
        }

        _logger.LogInformation("----- Creating Order - Order: {@Order}", order);

        _orderRepository.Add(order);

        return await _orderRepository.UnitOfWork
            .SaveEntitiesAsync(cancellationToken);
    }
}

Этот класс использует внедренные репозитории для выполнения транзакции и сохранения изменений состояния. Не имеет значения, является ли класс обработчиком команд, методом контроллера веб-интерфейса API ASP.NET Core или службой приложения DDD. В любом случае он представляет собой простой класс, который использует репозитории, сущности домена и другие средства координации приложения подобно тому, как это делает обработчик команд. Внедрение зависимостей работает одинаково для всех перечисленных выше классов, как в этом примере, в котором внедрение зависимостей основано на конструкторе.

Регистрация типов и интерфейсов или абстракций для реализации зависимостей

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

Использование встроенного контейнера IoC, предоставляемого платформой ASP.NET Core

При использовании встроенного контейнера IoC, предоставленного ASP.NET Core, вы регистрируете типы, которые необходимо внедрить в файл Program.cs , как показано в следующем коде:

// Register out-of-the-box framework services.
builder.Services.AddDbContext<CatalogContext>(c =>
    c.UseSqlServer(Configuration["ConnectionString"]),
    ServiceLifetime.Scoped);

builder.Services.AddMvc();
// Register custom application dependencies.
builder.Services.AddScoped<IMyCustomRepository, MyCustomSQLRepository>();

Чаще всего типы регистрируются в контейнере IoC парами, состоящими из интерфейса и связанного с ним класса реализации. Затем при запросе объекта из контейнера IoC посредством любого конструктора вы запрашиваете объект определенного типа интерфейса. Например, в предыдущем примере в последней строке указано, что когда любой из конструкторов имеет зависимость от IMyCustomRepository (интерфейса или абстракции), контейнер IoC внедряет экземпляр класса реализации MyCustomSQLServerRepository.

Автоматическая регистрация типов с помощью библиотеки Scrutor

При использовании внедрения зависимостей в .NET может потребоваться проверить сборку и автоматически зарегистрировать ее типы в соответствии с соглашением. Эта возможность в настоящее время недоступна в ASP.NET Core. Однако для этого можно использовать библиотеку Scrutor. Такой подход удобен в случае, если имеются десятки типов, которые необходимо зарегистрировать в контейнере IoC.

Дополнительные ресурсы

Использование Autofac в качестве контейнера IoC

Вы также можете использовать дополнительные контейнеры IoC и подключить их к конвейеру ASP.NET Core, как в случае с микрослужбой размещения заказов в eShopOnContainers, которая использует Autofac. При использовании Autofac типы, как правило, регистрируются посредством модулей, что позволяет разделить типы регистрации на несколько файлов в зависимости от местонахождения типов, так же как типы приложения распределяются по нескольким библиотекам классов.

Например, ниже приведен модуль приложения Autofac для проекта веб-интерфейса API Ordering.API с типами, которые потребуется внедрить.

public class ApplicationModule : Autofac.Module
{
    public string QueriesConnectionString { get; }
    public ApplicationModule(string qconstr)
    {
        QueriesConnectionString = qconstr;
    }

    protected override void Load(ContainerBuilder builder)
    {
        builder.Register(c => new OrderQueries(QueriesConnectionString))
            .As<IOrderQueries>()
            .InstancePerLifetimeScope();
        builder.RegisterType<BuyerRepository>()
            .As<IBuyerRepository>()
            .InstancePerLifetimeScope();
        builder.RegisterType<OrderRepository>()
            .As<IOrderRepository>()
            .InstancePerLifetimeScope();
        builder.RegisterType<RequestManager>()
            .As<IRequestManager>()
            .InstancePerLifetimeScope();
   }
}

Autofac также может сканировать сборки и регистрировать типы в соответствии с соглашениями об именовании.

Принципы и процесс регистрации очень похожи на регистрацию типов с помощью встроенного контейнера IoC ASP.NET Core, но синтаксис при использовании Autofac немного иной.

В примере кода абстракция IOrderRepository регистрируется вместе с классом реализации OrderRepository. Это означает, что каждый раз, когда в конструкторе объявляется зависимость посредством абстракции или интерфейса IOrderRepository, контейнер IoC внедряет экземпляр класса OrderRepository.

Тип области экземпляра определяет то, как экземпляр используется совместно при запросах к одной и той же службе или зависимости. При запросе зависимости контейнер IoC может вернуть следующее:

  • один экземпляр для каждой области времени существования (в контейнере IoC ASP.NET Core такой экземпляр называется экземпляром с заданной областью);

  • новый экземпляр для каждой зависимости (в контейнере IoC ASP.NET Core такой экземпляр называется временным);

  • один общий экземпляр для всех объектов, использующих контейнер IoC (в контейнере IoC ASP.NET Core такой экземпляр называется единичным).

Дополнительные ресурсы

Реализация шаблонов команд и обработчиков команд

В примере внедрения зависимостей через конструктор в предыдущем разделе контейнер IoC внедрял репозитории через конструктор в классе. Но куда именно они внедрялись? В простом веб-API (например, микрослужбе каталога в eShopOnContainers) они внедряются на уровне контроллеров MVC в конструкторе контроллера как часть конвейера запросов в ASP.NET Core. Однако в коде, приведенном в начале этого раздела (класс CreateOrderCommandHandler из службы Ordering.API в eShopOnContainers), внедрение зависимостей производится через конструктор определенного обработчика команд. Давайте рассмотрим, что такое обработчик команд и зачем его использовать.

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

Как показано на рис. 7-24, шаблон основан на принятии команд со стороны клиента, их обработке на основе правил модели предметной области и, наконец, сохранении состояний с помощью транзакций.

Diagram showing the high-level data flow from the client to database.

Рис. 7-24. Общее представление команд или "уровня транзакций" в шаблоне CQRS

На рис. 7-24 показано, как приложение пользовательского интерфейса отправляет команду через API в обработчик команд CommandHandler, зависящий от модели предметной области и инфраструктуры, для обновления базы данных.

Класс команд

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

Так как команды являются императивами, в их именах обычно есть глагол в повелительном наклонении (например, create или update) и может присутствовать тип агрегата, например CreateOrderCommand. В отличие от события, команда не связана с фактом в прошлом. Это всего лишь запрос, который может быть отклонен.

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

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

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

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

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

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

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

// DDD and CQRS patterns comment: Note that it is recommended to implement immutable Commands
// In this case, its immutability is achieved by having all the setters as private
// plus only being able to update the data just once, when creating the object through its constructor.
// References on Immutable Commands:
// http://cqrs.nu/Faq
// https://docs.spine3.org/motivation/immutability.html
// http://blog.gauffin.org/2012/06/griffin-container-introducing-command-support/
// https://learn.microsoft.com/dotnet/csharp/programming-guide/classes-and-structs/how-to-implement-a-lightweight-class-with-auto-implemented-properties

[DataContract]
public class CreateOrderCommand
    : IRequest<bool>
{
    [DataMember]
    private readonly List<OrderItemDTO> _orderItems;

    [DataMember]
    public string UserId { get; private set; }

    [DataMember]
    public string UserName { get; private set; }

    [DataMember]
    public string City { get; private set; }

    [DataMember]
    public string Street { get; private set; }

    [DataMember]
    public string State { get; private set; }

    [DataMember]
    public string Country { get; private set; }

    [DataMember]
    public string ZipCode { get; private set; }

    [DataMember]
    public string CardNumber { get; private set; }

    [DataMember]
    public string CardHolderName { get; private set; }

    [DataMember]
    public DateTime CardExpiration { get; private set; }

    [DataMember]
    public string CardSecurityNumber { get; private set; }

    [DataMember]
    public int CardTypeId { get; private set; }

    [DataMember]
    public IEnumerable<OrderItemDTO> OrderItems => _orderItems;

    public CreateOrderCommand()
    {
        _orderItems = new List<OrderItemDTO>();
    }

    public CreateOrderCommand(List<BasketItem> basketItems, string userId, string userName, string city, string street, string state, string country, string zipcode,
        string cardNumber, string cardHolderName, DateTime cardExpiration,
        string cardSecurityNumber, int cardTypeId) : this()
    {
        _orderItems = basketItems.ToOrderItemsDTO().ToList();
        UserId = userId;
        UserName = userName;
        City = city;
        Street = street;
        State = state;
        Country = country;
        ZipCode = zipcode;
        CardNumber = cardNumber;
        CardHolderName = cardHolderName;
        CardExpiration = cardExpiration;
        CardSecurityNumber = cardSecurityNumber;
        CardTypeId = cardTypeId;
        CardExpiration = cardExpiration;
    }


    public class OrderItemDTO
    {
        public int ProductId { get; set; }

        public string ProductName { get; set; }

        public decimal UnitPrice { get; set; }

        public decimal Discount { get; set; }

        public int Units { get; set; }

        public string PictureUrl { get; set; }
    }
}

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

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

Помните, что если вы планируете или предполагаете, что команды будут проходить через процесс сериализации или десериализации, свойства должны иметь закрытый метод задания, а также атрибут [DataMember] (или [JsonProperty]). В противном случае десериализатор не сможет воссоздать объект в месте назначения с необходимыми значениями. Также можно использовать свойства только для чтения, если класс содержит конструктор с параметрами для всех свойств, с обычным соглашением об именовании camelCase и закомментировать конструктор как [JsonConstructor]. Однако для этого варианта требуется больше кода.

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

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

[DataContract]
public class UpdateOrderStatusCommand
    :IRequest<bool>
{
    [DataMember]
    public string Status { get; private set; }

    [DataMember]
    public string OrderId { get; private set; }

    [DataMember]
    public string BuyerIdentityGuid { get; private set; }
}

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

Класс обработчика команд

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

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

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

Обработчик команд обычно выполняет следующие действия:

  • получает объект команды, например DTO (от медиатора или другого объекта инфраструктуры);

  • проверяет допустимость команды (если она не была проверена медиатором);

  • создает экземпляр корневой сущности агрегата, являющийся целевым для текущей команды;

  • выполняет метод экземпляра корневой сущности агрегата, получая необходимые данные из команды;

  • сохраняет новое состояние агрегата в связанной с ним базе данных. Эта последняя операция и является транзакцией.

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

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

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

В приведенном ниже коде в качестве примера класса обработчика команд показан тот же класс CreateOrderCommandHandler, который вы уже видели в начале этой главы. В этом случае также выделяется метод Handle и операции с объектами и агрегатами модели предметной области.

public class CreateOrderCommandHandler
        : IRequestHandler<CreateOrderCommand, bool>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IIdentityService _identityService;
    private readonly IMediator _mediator;
    private readonly IOrderingIntegrationEventService _orderingIntegrationEventService;
    private readonly ILogger<CreateOrderCommandHandler> _logger;

    // Using DI to inject infrastructure persistence Repositories
    public CreateOrderCommandHandler(IMediator mediator,
        IOrderingIntegrationEventService orderingIntegrationEventService,
        IOrderRepository orderRepository,
        IIdentityService identityService,
        ILogger<CreateOrderCommandHandler> logger)
    {
        _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository));
        _identityService = identityService ?? throw new ArgumentNullException(nameof(identityService));
        _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
        _orderingIntegrationEventService = orderingIntegrationEventService ?? throw new ArgumentNullException(nameof(orderingIntegrationEventService));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async Task<bool> Handle(CreateOrderCommand message, CancellationToken cancellationToken)
    {
        // Add Integration event to clean the basket
        var orderStartedIntegrationEvent = new OrderStartedIntegrationEvent(message.UserId);
        await _orderingIntegrationEventService.AddAndSaveEventAsync(orderStartedIntegrationEvent);

        // Add/Update the Buyer AggregateRoot
        // DDD patterns comment: Add child entities and value-objects through the Order Aggregate-Root
        // methods and constructor so validations, invariants and business logic
        // make sure that consistency is preserved across the whole aggregate
        var address = new Address(message.Street, message.City, message.State, message.Country, message.ZipCode);
        var order = new Order(message.UserId, message.UserName, address, message.CardTypeId, message.CardNumber, message.CardSecurityNumber, message.CardHolderName, message.CardExpiration);

        foreach (var item in message.OrderItems)
        {
            order.AddOrderItem(item.ProductId, item.ProductName, item.UnitPrice, item.Discount, item.PictureUrl, item.Units);
        }

        _logger.LogInformation("----- Creating Order - Order: {@Order}", order);

        _orderRepository.Add(order);

        return await _orderRepository.UnitOfWork
            .SaveEntitiesAsync(cancellationToken);
    }
}

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

  • использовать данные команды для взаимодействия с методами и поведением корневой сущности агрегата;

  • вызывать события предметной области внутри объектов предметной области во время выполнения транзакции прозрачным с точки зрения обработчика команд образом;

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

Дополнительные ресурсы

Конвейер обработки команд: активация обработчика команд

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

Другие два варианта, которые рекомендуются, представлены ниже:

  • посредством артефакта шаблона медиатора в памяти;

  • с помощью асинхронной очереди сообщений между контроллерами и обработчиками.

Использование шаблона медиатора (в памяти) в конвейере команд

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

Diagram showing a more detailed data flow from client to database.

Рис. 7-25. Использование шаблона медиатора в процессе обработки в отдельной микрослужбе CQRS

На схеме выше приведено увеличенное изображение фрагмента рис. 7-24: контроллер ASP.NET Core отправляет команду в конвейер команд MediatR, чтобы они попали в соответствующий обработчик.

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

Медиатор — это объект, который инкапсулирует методы выполнения этого процесса: он координирует выполнение на основе состояния, способы вызова обработчика команд и полезные данные, предоставляемые ему. С помощью медиатора вы можете применять сквозную функциональность централизованным и прозрачным образом, используя декораторы (или расширения функциональности конвейера, начиная с MediatR 3). Дополнительную информацию см. в статье Шаблон декоратора.

Декораторы и расширения функциональности похожи на аспектно-ориентированное программирование (АОП), но применяются к определенному конвейеру обработки, управляемому медиатором. Аспекты в АОП, которые реализуют сквозную функциональность, применяются на основе средств внедрения аспектов, внедряемых во время компиляции или с помощью перехвата вызовов объектов. Иногда говорят, что оба подхода АОП работают "волшебным образом", так как их применение трудно проследить. В случае серьезных проблем или ошибок отладка в АОП может вызывать трудности. С другой стороны, эти декораторы и расширения функциональности являются явными и применяются только в контексте медиатора, поэтому отладка является гораздо более предсказуемой и легкой.

Например, в микрослужбе размещения заказов eShopOnContainers реализованы два образца расширений функциональности: класс LogBehavior и класс ValidatorBehavior. Реализация поведения объясняется в следующем разделе, показывая, как eShopOnContainers использует поведение MediatR.

Использование очередей сообщений (внепроцессных) в конвейере команд

Еще один вариант — это использование асинхронных сообщений на основе брокеров или очередей сообщений, как показано на рис. 7-26. Его можно использовать в сочетании с компонентом медиатора непосредственно перед обработчиком команд.

Diagram showing the dataflow using an HA message queue.

Рис. 7-26. Использование очередей сообщений (внепроцессное и внутрипроцессное взаимодействие) с командами CQRS

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

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

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

Кроме того, асинхронные команды являются односторонними, что во многих случаях не требуется. Об этом говорят Алексей Бурцев (Burtsev Alexey) и Грег Янг (Greg Young) в этой интересной беседе в Интернете:

[Бурцев Алексей] Я нахожу много кода, где люди используют асинхронную обработку команд или одностороннюю обмен сообщениями команд без каких-либо причин для этого (они не выполняют некоторые длительные операции, они не выполняют внешний асинхронный код, они даже не пересекают границу приложения для использования шины сообщений). Зачем это излишнее усложнение? В действительности мне еще не встречался пример кода CQRS с блокирующими обработчиками команд, хотя в большинстве случаев они вполне применимы.

[Грег Янг] [...] асинхронная команда не существует; это на самом деле другое событие. Если я должен принять то, что вы отправляете мне и поднять событие, если я не согласен, это больше не вы говорите мне делать что-то [то есть, это не команда]. Вы сообщаете мне, что что-то было сделано. Сначала разница кажется небольшой, но она имеет множество последствий.

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

В первоначальной версии eShopOnContainers было решено использовать синхронную обработку команд, начиная с HTTP-запросов, на основе шаблона медиатора. Это позволяет легко возвращать результат процесса (успех или неудача), как в реализации CreateOrderCommandHandler.

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

Реализация конвейера обработки команд с помощью шаблона медиатора (MediatR)

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

Для реализации в .NET доступно множество библиотек с открытым кодом, которые реализуют шаблон медиатора. В этом руководстве применяется библиотека MediatR с открытым кодом (созданная Джимми Богардом (Jimmy Bogard)), но можно выбрать и другой подход. MediatR — это небольшая простая библиотека, которая позволяет обрабатывать сообщения в памяти как команды, применяя декораторы или расширения функциональности.

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

Еще одна причина для использования шаблона медиатора была раскрыта Джимми Богардом при рецензировании этого руководства:

"Я думаю, здесь стоит упомянуть тестирование — вы получаете ясное и согласованное представление о поведении системы". Запросы, ответы. Мы обнаружили, что аспект довольно ценен в создании последовательного поведения тестов.

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

public class MyMicroserviceController : Controller
{
    public MyMicroserviceController(IMediator mediator,
                                    IMyMicroserviceQueries microserviceQueries)
    {
        // ...
    }
}

Как можно видеть, медиатор обеспечивает простой контроллер веб-интерфейса API, в котором нет ничего лишнего. Кроме того, в методах контроллера код для отправки команды объекту медиатора ограничивается практически одной строкой:

[Route("new")]
[HttpPost]
public async Task<IActionResult> ExecuteBusinessOperation([FromBody]RunOpCommand
                                                               runOperationCommand)
{
    var commandResult = await _mediator.SendAsync(runOperationCommand);

    return commandResult ? (IActionResult)Ok() : (IActionResult)BadRequest();
}

Реализация идемпотентных команд

Более сложным примером по сравнению с приведенным выше в приложении eShopOnContainers является отправка объекта CreateOrderCommand из микрослужбы размещения заказов. Однако поскольку бизнес-процесс размещения заказов является немного более сложным и в данном случае он фактически начинается в микрослужбе Basket, действие отправки объекта CreateOrderCommand выполняется из обработчика событий интеграции с именем UserCheckoutAcceptedIntegrationEventHandler, а не из простого контроллера WebAPI, вызываемого из клиентского приложения, как в предыдущем более простом примере.

Тем не менее действие отправки команды в MediatR по многом схоже, как показано в приведенном ниже коде.

var createOrderCommand = new CreateOrderCommand(eventMsg.Basket.Items,
                                                eventMsg.UserId, eventMsg.City,
                                                eventMsg.Street, eventMsg.State,
                                                eventMsg.Country, eventMsg.ZipCode,
                                                eventMsg.CardNumber,
                                                eventMsg.CardHolderName,
                                                eventMsg.CardExpiration,
                                                eventMsg.CardSecurityNumber,
                                                eventMsg.CardTypeId);

var requestCreateOrder = new IdentifiedCommand<CreateOrderCommand,bool>(createOrderCommand,
                                                                        eventMsg.RequestId);
result = await _mediator.Send(requestCreateOrder);

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

Для этого бизнес-команда (в данном случае CreateOrderCommand) заключается в оболочку и внедряется в универсальный класс IdentifiedCommand, отслеживаемый по идентификатору каждого поступающего по сети сообщения, которое должно быть идемпотентным.

В приведенном ниже коде видно, что IdentifiedCommand — это просто объект DTO с идентификатором и объектом упакованной бизнес-команды.

public class IdentifiedCommand<T, R> : IRequest<R>
    where T : IRequest<R>
{
    public T Command { get; }
    public Guid Id { get; }
    public IdentifiedCommand(T command, Guid id)
    {
        Command = command;
        Id = id;
    }
}

Затем обработчик CommandHandler для IdentifiedCommand с именем IdentifiedCommandHandler.cs проверяет, имеется ли уже идентификатор, полученный в сообщении, в таблице. Если он имеется, команда не будет обработана повторно, то есть она является идемпотентной. Этот код инфраструктуры выполняется в результате вызова метода _requestManager.ExistAsync, приведенного ниже.

// IdentifiedCommandHandler.cs
public class IdentifiedCommandHandler<T, R> : IRequestHandler<IdentifiedCommand<T, R>, R>
        where T : IRequest<R>
{
    private readonly IMediator _mediator;
    private readonly IRequestManager _requestManager;
    private readonly ILogger<IdentifiedCommandHandler<T, R>> _logger;

    public IdentifiedCommandHandler(
        IMediator mediator,
        IRequestManager requestManager,
        ILogger<IdentifiedCommandHandler<T, R>> logger)
    {
        _mediator = mediator;
        _requestManager = requestManager;
        _logger = logger ?? throw new System.ArgumentNullException(nameof(logger));
    }

    /// <summary>
    /// Creates the result value to return if a previous request was found
    /// </summary>
    /// <returns></returns>
    protected virtual R CreateResultForDuplicateRequest()
    {
        return default(R);
    }

    /// <summary>
    /// This method handles the command. It just ensures that no other request exists with the same ID, and if this is the case
    /// just enqueues the original inner command.
    /// </summary>
    /// <param name="message">IdentifiedCommand which contains both original command & request ID</param>
    /// <returns>Return value of inner command or default value if request same ID was found</returns>
    public async Task<R> Handle(IdentifiedCommand<T, R> message, CancellationToken cancellationToken)
    {
        var alreadyExists = await _requestManager.ExistAsync(message.Id);
        if (alreadyExists)
        {
            return CreateResultForDuplicateRequest();
        }
        else
        {
            await _requestManager.CreateRequestForCommandAsync<T>(message.Id);
            try
            {
                var command = message.Command;
                var commandName = command.GetGenericTypeName();
                var idProperty = string.Empty;
                var commandId = string.Empty;

                switch (command)
                {
                    case CreateOrderCommand createOrderCommand:
                        idProperty = nameof(createOrderCommand.UserId);
                        commandId = createOrderCommand.UserId;
                        break;

                    case CancelOrderCommand cancelOrderCommand:
                        idProperty = nameof(cancelOrderCommand.OrderNumber);
                        commandId = $"{cancelOrderCommand.OrderNumber}";
                        break;

                    case ShipOrderCommand shipOrderCommand:
                        idProperty = nameof(shipOrderCommand.OrderNumber);
                        commandId = $"{shipOrderCommand.OrderNumber}";
                        break;

                    default:
                        idProperty = "Id?";
                        commandId = "n/a";
                        break;
                }

                _logger.LogInformation(
                    "----- Sending command: {CommandName} - {IdProperty}: {CommandId} ({@Command})",
                    commandName,
                    idProperty,
                    commandId,
                    command);

                // Send the embedded business command to mediator so it runs its related CommandHandler
                var result = await _mediator.Send(command, cancellationToken);

                _logger.LogInformation(
                    "----- Command result: {@Result} - {CommandName} - {IdProperty}: {CommandId} ({@Command})",
                    result,
                    commandName,
                    idProperty,
                    commandId,
                    command);

                return result;
            }
            catch
            {
                return default(R);
            }
        }
    }
}

Объект IdentifiedCommand выступает в роли конверта для бизнес-команды, поэтому когда бизнес-команда должна быть обработана, так как отсутствует повторяющийся идентификатор, этот объект берет внутреннюю бизнес-команду и повторно передает ее в медиатор. Это продемонстрировано в последней части приведенного выше кода, в которой выполняется _mediator.Send(message.Command) из IdentifiedCommandHandler.cs.

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

// CreateOrderCommandHandler.cs
public class CreateOrderCommandHandler
        : IRequestHandler<CreateOrderCommand, bool>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IIdentityService _identityService;
    private readonly IMediator _mediator;
    private readonly IOrderingIntegrationEventService _orderingIntegrationEventService;
    private readonly ILogger<CreateOrderCommandHandler> _logger;

    // Using DI to inject infrastructure persistence Repositories
    public CreateOrderCommandHandler(IMediator mediator,
        IOrderingIntegrationEventService orderingIntegrationEventService,
        IOrderRepository orderRepository,
        IIdentityService identityService,
        ILogger<CreateOrderCommandHandler> logger)
    {
        _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository));
        _identityService = identityService ?? throw new ArgumentNullException(nameof(identityService));
        _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
        _orderingIntegrationEventService = orderingIntegrationEventService ?? throw new ArgumentNullException(nameof(orderingIntegrationEventService));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async Task<bool> Handle(CreateOrderCommand message, CancellationToken cancellationToken)
    {
        // Add Integration event to clean the basket
        var orderStartedIntegrationEvent = new OrderStartedIntegrationEvent(message.UserId);
        await _orderingIntegrationEventService.AddAndSaveEventAsync(orderStartedIntegrationEvent);

        // Add/Update the Buyer AggregateRoot
        // DDD patterns comment: Add child entities and value-objects through the Order Aggregate-Root
        // methods and constructor so validations, invariants and business logic
        // make sure that consistency is preserved across the whole aggregate
        var address = new Address(message.Street, message.City, message.State, message.Country, message.ZipCode);
        var order = new Order(message.UserId, message.UserName, address, message.CardTypeId, message.CardNumber, message.CardSecurityNumber, message.CardHolderName, message.CardExpiration);

        foreach (var item in message.OrderItems)
        {
            order.AddOrderItem(item.ProductId, item.ProductName, item.UnitPrice, item.Discount, item.PictureUrl, item.Units);
        }

        _logger.LogInformation("----- Creating Order - Order: {@Order}", order);

        _orderRepository.Add(order);

        return await _orderRepository.UnitOfWork
            .SaveEntitiesAsync(cancellationToken);
    }
}

Регистрация типов, используемых библиотекой MediatR

Чтобы сообщить библиотеке MediatR об используемых классах обработчиков команд, необходимо зарегистрировать классы медиаторов и обработчиков команд в контейнере IoC. По умолчанию библиотека MediatR использует Autofac в качестве контейнера IoC, но вы также можете использовать встроенный контейнер IoC в ASP.NET Core или любой другой контейнер, поддерживаемый MediatR.

В приведенном ниже коде показано, как зарегистрировать типы и команды медиатора при использовании модулей Autofac.

public class MediatorModule : Autofac.Module
{
    protected override void Load(ContainerBuilder builder)
    {
        builder.RegisterAssemblyTypes(typeof(IMediator).GetTypeInfo().Assembly)
            .AsImplementedInterfaces();

        // Register all the Command classes (they implement IRequestHandler)
        // in assembly holding the Commands
        builder.RegisterAssemblyTypes(typeof(CreateOrderCommand).GetTypeInfo().Assembly)
                .AsClosedTypesOf(typeof(IRequestHandler<,>));
        // Other types registration
        //...
    }
}

Именно в нем творится вся магия MediatR.

Поскольку каждый обработчик команд реализует универсальный интерфейс IRequestHandler<T>, при регистрации сборок с помощью метода RegisteredAssemblyTypes все типы, помеченные как IRequestHandler, также регистрируются со своими Commands. Например:

public class CreateOrderCommandHandler
  : IRequestHandler<CreateOrderCommand, bool>
{

Этот код сопоставляет команды с обработчиками команд. Обработчик представляет собой простой класс, но он наследуется от RequestHandler<T>, где T обозначает тип команды, и библиотека MediatR обеспечивает его вызов с надлежащими полезными данными (командой).

Применение сквозной функциональности при обработке команд с помощью расширений функциональности в MediatR

Есть еще одна возможность: применение сквозной функциональности к конвейеру медиатора. В конце кода модуля регистрации Autofac можно заметить, что он регистрирует тип расширения функциональности, а именно: пользовательский класс LoggingBehavior и класс ValidatorBehavior. Вы также можете добавить другие пользовательские расширения функциональности.

public class MediatorModule : Autofac.Module
{
    protected override void Load(ContainerBuilder builder)
    {
        builder.RegisterAssemblyTypes(typeof(IMediator).GetTypeInfo().Assembly)
            .AsImplementedInterfaces();

        // Register all the Command classes (they implement IRequestHandler)
        // in assembly holding the Commands
        builder.RegisterAssemblyTypes(
                              typeof(CreateOrderCommand).GetTypeInfo().Assembly).
                                   AsClosedTypesOf(typeof(IRequestHandler<,>));
        // Other types registration
        //...
        builder.RegisterGeneric(typeof(LoggingBehavior<,>)).
                                                   As(typeof(IPipelineBehavior<,>));
        builder.RegisterGeneric(typeof(ValidatorBehavior<,>)).
                                                   As(typeof(IPipelineBehavior<,>));
    }
}

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

public class LoggingBehavior<TRequest, TResponse>
         : IPipelineBehavior<TRequest, TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger) =>
                                                                  _logger = logger;

    public async Task<TResponse> Handle(TRequest request,
                                        RequestHandlerDelegate<TResponse> next)
    {
        _logger.LogInformation($"Handling {typeof(TRequest).Name}");
        var response = await next();
        _logger.LogInformation($"Handled {typeof(TResponse).Name}");
        return response;
    }
}

Если реализовать этот класс расширения функциональности и зарегистрировать его в конвейере (в MediatorModule в примере выше), в журнал будут записываться сведения о выполнении всех команд, обрабатываемых посредством MediatR.

В микрослужбе размещения заказов eShopOnContainers применяется еще одно расширение функциональности для проведения базовых проверок. Это класс ValidatorBehavior, основанный на библиотеке FluentValidation и показанный в следующем коде:

public class ValidatorBehavior<TRequest, TResponse>
         : IPipelineBehavior<TRequest, TResponse>
{
    private readonly IValidator<TRequest>[] _validators;
    public ValidatorBehavior(IValidator<TRequest>[] validators) =>
                                                         _validators = validators;

    public async Task<TResponse> Handle(TRequest request,
                                        RequestHandlerDelegate<TResponse> next)
    {
        var failures = _validators
            .Select(v => v.Validate(request))
            .SelectMany(result => result.Errors)
            .Where(error => error != null)
            .ToList();

        if (failures.Any())
        {
            throw new OrderingDomainException(
                $"Command Validation Errors for type {typeof(TRequest).Name}",
                        new ValidationException("Validation exception", failures));
        }

        var response = await next();
        return response;
    }
}

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

Затем на основе библиотеки FluentValidation реализуется проверка данных, передаваемых с помощью команды CreateOrderCommand, как показано в следующем коде:

public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(command => command.City).NotEmpty();
        RuleFor(command => command.Street).NotEmpty();
        RuleFor(command => command.State).NotEmpty();
        RuleFor(command => command.Country).NotEmpty();
        RuleFor(command => command.ZipCode).NotEmpty();
        RuleFor(command => command.CardNumber).NotEmpty().Length(12, 19);
        RuleFor(command => command.CardHolderName).NotEmpty();
        RuleFor(command => command.CardExpiration).NotEmpty().Must(BeValidExpirationDate).WithMessage("Please specify a valid card expiration date");
        RuleFor(command => command.CardSecurityNumber).NotEmpty().Length(3);
        RuleFor(command => command.CardTypeId).NotEmpty();
        RuleFor(command => command.OrderItems).Must(ContainOrderItems).WithMessage("No order items found");
    }

    private bool BeValidExpirationDate(DateTime dateTime)
    {
        return dateTime >= DateTime.UtcNow;
    }

    private bool ContainOrderItems(IEnumerable<OrderItemDTO> orderItems)
    {
        return orderItems.Any();
    }
}

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

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

Дополнительные ресурсы

Шаблон медиатора
Шаблон декоратора
MediatR (Джимми Богард (Jimmy Bogard))
Плавная проверка