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

    Ну. И что?
    Реклама
    Комментарии 15
      +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
                  Думаю, доменные события для такого логирования подойдут лучше декораторов.

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

        Самое читаемое