Разработчик SimpleInjector очень любит «декораторы», особенно в сочетании с дженериками вида
QueryHandler<TIn, TOut>, CommandHanler<TIn, TOut>.

Такой подход позволяет «навешивать» на обработчики то, что принято называть cross-cutting concerns без регистрации и смс interception и особой уличной магии вроде Fody или PostSharp.

CQRS не top level architecture, поэтому хочется иметь такие-же декораторы и для классических Application Service. Под катом я расскажу как это сделать.

Что такое сквозная функциональность (cross-cutting concern)


Сross-cutting concern — термин из АОП. К сквозной относится «вспомогательная» функциональность модуля, не относящаяся напрямую к выполняемой задаче, но необходимая, например:
  • синхронизация
  • обработка ошибок
  • валидация
  • управление транзациями
  • кеширование
  • логирование
  • мониторинг

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

Код без cross-cutting concern


public Book GetBook(int bookId)
  => dbContext.Books.FirstorDefault(x => x.Id == bookId);
 

Код с cross-cutting concern


public Book GetBook(int bookId)
{
  if (!SecurityContext.GetUser().HasRight("GetBook"))
    throw new AuthException("Permission Denied");

  Log.debug("Call method GetBook with id " + bookId);
  Book book = null;
  String cacheKey = "getBook:" + bookId;

  try
  {
    if (cache.contains(cacheKey))
    {
      book = cache.Get<Book>(cacheKey);
    }
    else
    {
      book = dbContext.Books.FirstorDefault(x => x.Id == bookId);
      cache.Put(cacheKey, book);
    }
  }
  catch(SqlException e)
  {
    throw new ServiceException(e);
  }

  Log.Debug("Book info is: " + book.toString());
  return book;
 }
}

Вместо одной строчки получилось больше двадцати. И главное, этот код придется повторять снова и снова. На помощь приходят декораторы.
Декоратор (англ. Decorator) — структурный шаблон проектирования, предназначенный для динамического подключения дополнительного поведения к объекту. Шаблон Декоратор предоставляет гибкую альтернативу практике создания подклассов с целью расширения функциональности.

Декораторы в CQRS


Например, хочется включить глобальную валидацию. Достаточно объявить вот такой декоратор:

public class ValidationCommandHandlerDecorator<TCommand> : ICommandHandler<TCommand>
{
    private readonly IValidator validator;
    private readonly ICommandHandler<TCommand> decoratee;

    public ValidationCommandHandlerDecorator(IValidator validator,
        ICommandHandler<TCommand> decoratee)
   {
        this.validator = validator;
        this.decoratee = decoratee;
    }

    void ICommandHandler<TCommand>.Handle(TCommand command)
    {
        // validate the supplied command (throws when invalid).
        this.validator.ValidateObject(command);

        // forward the (valid) command to the real command handler.
        this.decoratee.Handle(command);
    }
}

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

container.RegisterDecorator(
    typeof(ICommandHandler<>),
    typeof(ValidationCommandHandlerDecorator<>));


Теперь для всех реализаций интерфейса ICommandHandler валидация будет происходить в декораторе, а код обработчиков останется простым.
public interface ICommandHandler<in TInput, out TOutput>
{
    TOutput Handle(TInput command);
}

public class AddBookCommandHandler: ICommandHandler<BookDto, int>
{
    public bool Handle(BookDto dto)
    {
         var entity = Mapper.Map<Book>(dto);
         dbContext.Books.Add(entity);
         dbContext.SaveChanges();
         return entity.Id;
    }
}

Но тогда придется писать по набору декораторов для ICommandHandler и IQueryHandler. Можно конечно обойти эту проблему с помощью делегатов. Но получается не очень красиво и применимо только к CQRS, т.е. только в каком-то отдельном ограниченном контексте (bounded context) приложения, где CQRS оправдан.

Различие IHandler и Application Service


Основная проблема с глобальным применением декораторов для сервисного слоя в том, что интерфейсы сервисов сложнее, чем generic handler'ы. Если все обработчики реализуют вот такой generic-интерфейс:

public interface ICommandHandler<in TInput, out TOutput>
{
    TOutput Handle(TInput command);
}

То сервисы обычно реализуют по одному методу на каждый use case

public interface IAppService
{
    ResponseType UseCase1(RequestType1 request);

    ResponseType UseCase2(RequestType2 request);

    ResponseType UseCase3(RequestType3 request);

    //...

    ResponseType UseCaseN(RequestTypeN request);
}

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

MediatR


Для CQRS можно решить проблему дублирования декораторов, если ввести интерфейс IRequestHandler и использовать его для Command и Query. Разделение на подсистемы чтения и записи в этом случае ложится на naming conventions. SomeCommandRequestHandler: IRequestHandler — очевидно, обработчик команд, а SomeQueryRequestHandler: IRequestHandler — запросов. такой подход реализован в MediatR. В качестве альтернативы декораторам библиотека предоставляет механизм behaviors.

IRequestHandler > IUseCaseHandler


Почему бы не переименовать интерфейс IRequestHandler в IUseCaseHandler. Обработчики запросов и комманд — холистические абстракции, значит каждый из них обрабатывает use case целиком. Тогда можно переписать архитектуру CQRS следующим образом:

public interface IUseCaseHandler<in TInput, out TOutput>
{
    TOutput Handle(TInput command);
}

public interface IQueryHandler<in TInput, out TOutput>
    : IUseCaseHandler<in TInput, out TOutput>
    where TInput: IQuery<TOutput>
{
}

public interface ICommandHandler<in TInput, out TOutput>
    : IUseCaseHandler<in TInput, out TOutput>
    where TInput: ICommand<TOutput>
{
}

Теперь «общие» декораторы можно вешать на IUseCaseHandler. При этом отдельно написать декораторы для ICommandHandler и IQueryHandler, например для независимого управления транзакциями.

Декораторы для Application Service


Интерфейс IUseCaseHandler мы сможем использовать и в Application Services, если воспользуемся явной реализацией.

public class AppService
    : IAppService
    : IUseCaseHandler<RequestType1 , ResponseType1>
    : IUseCaseHandler<RequestType2 , ResponseType2>
    : IUseCaseHandler<RequestType3, ResponseType3>
    //...
    : IUseCaseHandler<RequestTypeN, RequestTypeN>
{
    public ResponseType1 UseCase1(RequestType1 request) 
    {
        //...
    }
    
    IUseCaseHandler<RequestType1 , ResponseType1>.Handle(RequestType1 request)
        => UseCase1(request);
    
    //...

    ResponseTypeN UseCaseN(RequestTypeN request)
    {
        //...
    }

    IUseCaseHandler<RequestTypeN , ResponseTypeN>.Handle(RequestTypeN request)
        => UseCaseN(request);
    
    //...
}

В прикладном коде необходимо использовать интерфейсы IUseCaseHandler, а не IAppService, потому что декораторы будут применены только к generic-интерфейсу.

Обработка ошибок


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

void ICommandHandler<TCommand>.Handle(TCommand command)
{
    // validate the supplied command (throws when invalid).
    this.validator.ValidateObject(command);

    // forward the (valid) command to the real command handler.
    this.decoratee.Handle(command);
}

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

Result ICommandHandler<TCommand>.Handle(TCommand command)
{
    return this.validator.ValidateObject(command) && this.decoratee.Handle(command);
}

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

Комментарии (15)


  1. AgentFire
    11.04.2018 12:22

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


    1. marshinov Автор
      11.04.2018 13:06

  1. MonkAlex
    11.04.2018 13:28

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


    1. Veikedo
      11.04.2018 13:35

      Всё просто, на самом деле. Смотрите здесь https://github.com/jbogard/MediatR/wiki/Behaviors


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


      DataAnnotation валидация
          public class DataAnnotationsValidationPreProcessor<TRequest> : IRequestPreProcessor<TRequest>
          {
              public Task Process([NotNull] TRequest request, CancellationToken cancellationToken)
              {
                  var context = new ValidationContext(request);
                  var results = new List<ValidationResult>();
      
                  if (Validator.TryValidateObject(request, context, results))
                  {
                      return Task.CompletedTask;
                  }
      
                  var errors = results.Select(x => new ValidationFailure(x.MemberNames.First(), x.ErrorMessage));
                  throw new ValidationException(errors);
              }
          }
      


      1. MonkAlex
        11.04.2018 14:38

        К сожалению, ни ваш код, ни описание поведения на вики — не понятны. К чему и куда оно надо?

        Где код до и после то?


        1. Veikedo
          11.04.2018 14:39
          +1

          Ну, в общем-то, в статье. Под заголовками "Код без cross-cutting concern" и "Код с cross-cutting concern"


          1. MonkAlex
            11.04.2018 16:42

            Так а к чему всё остальное? Где декораторы то? Как стало с декораторами?


            1. CHROMIGO
              11.04.2018 22:55

              Автор вначале приводит пример декоратора и как он регистрируется. После описывает проблему дублирования кода валидации в таких декораторах для Query/Command Handler-ов(хотим одинаково валидировать но придется дублировать и писать несколько декораторов, а не 1). Собственно предлагает обобщить ICommandHandler/QueryHandler до IRequestHandler(точнее унаследовать) и как продолжение идеи до IUseCaseHandler<T1,T2>. Далее мы просто так же как в начале регистрируем декоратор на этот generic интерфейс и все по идее должно заработать, без всякого дублирования. Но вот для сервисов чтобы все работало придется работать явно с IUseCaseHadnler, вместо простого IAppService интерфейса.
              Ну т.е. просто делаем очень общий интферфейс и на него вешаем 1 декоратор.
              Примеров декораторов статье больше нет, видимо подразумевается что он будет аналогичен начальному.
              Я понял так


              1. marshinov Автор
                12.04.2018 02:06

                Спасибо большое за комментарий. Вы поняли все верно. Я теперь знаю, что донёс идею внятно:)


  1. VolCh
    12.04.2018 00:42

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


    1. marshinov Автор
      12.04.2018 02:09

      Добавьте дополнительное логирование в необходимых ветках. Можно явно с помощью императивного кода или с помощью декораторов для декораторов. Это как удобнее. Цель статьи — показать как можно очистить доменную логику от инфраструктурного кода. Взаимодействие инфраструктуры с инфраструктурой — другой вопрос.


      1. VolCh
        12.04.2018 08:40
        +1

        Пример "инфраструктуры с инфраструктурой" просто из вашего кода. Пускай надо логировать именно отдельные ветки доменной логики. Причём не ошибки, которые клиенту доменной логики надо как-то обрабатывать, а просто debug/info уровень, о том куда мы пошли, какая ветка бизнес-правила сработала. Приходит в голову возвращать Result или его наследника типа LoggableResult, в котором есть итерируемое поле типа logMessages, из которого декоратор, если он применён, может доставать сообщения и логировать их. Нет декоратора — они просто теряются или добавляются к LoggableResult самого клиента — может клиент клиента решит их залогировать.


        Не пробовали такой подход? Из очевидных минусов — потенциально логируемые куски логики, юзкейсы из примеров, всегда должны возвращать Result, а не быть void или какого-то другого типа. Тогда добавить логирование можно будет с минимальным изменением кода, вплоть только в DI-контейнере декоратор добавить. Но логика клиента должна будет всегда учитывать, что ожидаемый результат упакован.


        1. mayorovp
          12.04.2018 14:23

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


          1. VolCh
            12.04.2018 15:07
            +1

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


            1. marshinov Автор
              12.04.2018 15:23

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