О декораторах, сквозной функциональности, CQRS и слоеной архитектуре

    Разработчик 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.

    IRequestHandlerIUseCaseHandler


    Почему бы не переименовать интерфейс 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 не так, как методы, возвращающие необернутые значения.

    Similar posts

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 21

      +2

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

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

        Всё просто, на самом деле. Смотрите здесь 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
          К сожалению, ни ваш код, ни описание поведения на вики — не понятны. К чему и куда оно надо?

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

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

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

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

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

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

            +1

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


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

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

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

                  0
                  Думаю, доменные события для такого логирования подойдут лучше декораторов.
          0
          marshinov, подскажите, пожалуйста, как у вас в итоге хэндлеры верхнего уровня дергают хэндлеры AppService? Связь идет через Mediatr по типу команды?
          Аналогичный вопрос для контроллера, как он запускает пайплайн?

          Начал копать эту тему, счас движусь от «быстрорастворимого» в ширину, так что извиняюсь за элементарные вопросы.
            0
            public IActionResult Method(
                [FromServices] IHander<CommandOrQuery,...> handler,
                CommandOrQuery obj) 
                    => handler
                       .Handle(obj)
                       .PipeTo(Ok)
            
          0
          marshinov, еще вопрос, насчет AppService-ов. В этой статье предлагается превращать его в композитный обработчик всех кейсов, которые покрываются публичными методами. Но Богард и остальные отцы-основатели не советуют вызывать хэндлеры из хэндлеров, используя их лишь на самом верхнем уровне архитектуры. Но как быть, когда аппсервис для своей задачи вызывает еще несколько компонентов, которым для работы нужны еще компоненты. Превращать все это в команды-обработчики? Или же оставлять как есть, но тогда мы получаем слабую связанность за счет, например, МедиатРа только на уровнях контроллер и контроллер-1 (где и так логика довольно проста и прямолинейна), а хотелось бы разрубить связи между компонентами, которые делают основную работу.
            0
            Нужен способ разделять холистические абстракции и потоврно-используемые. Это может быть, например, так:
            ICommandHanler<T1,T2>: IHandler<T1,T2> where T1: ICommand // холистическая
            IHandler<T1,T2> // без констрейнтов - обычная

            Или не использовать внутри IHandler
            другие IHandler.
            0
            marshinov, cорри, промахнулся ответом. То есть, ниже уровня контроллера имеем чтото вроде (суть задачи синтетическая, главное иерархия вызовов):

            public class CreateUserHandler
                : ICommandHanler<CreateUserRequest, CreateUserResponse>
            {
                CreateUserResponse Handle(CreateUserRequest request)
                {
                    var hash = _mediator.Send(new HashPasswordRequest(request.Password));
            
                   // save user logic
                  
                   _mediator.Publish(new UserCreatedRequest(user.Email));
                };
            }
            
            public class SecurityService
                : ISecurityService
                : IUseCaseHandler<HashPasswordRequest, HashPasswordResponse>
            {
                public HashPasswordResponse HashPassword(HashPasswordRequest request) 
                {
                    //...
                }
                
                HashPasswordResponse IUseCaseHandler<HashPasswordRequest, HashPasswordResponse>.Handle(HashPasswordRequest request)
                    => HashPassword(request);
            }
            
            public class EmailService
                : IEmailService
                : IUseCaseHandler<UserCreatedEvent>
            {
                public void SendUserCreatedEmail(UserCreatedEvent event) 
                {
                    //...
                }
                
                IUseCaseHandler<UserCreatedRequest>.Handle(UserCreatedEvent event)
                    => SendUserCreatedEmail(event);
            }
            

            Only users with full accounts can post comments. Log in, please.