Pull to refresh

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

Reading time5 min
Views29K
Разработчик 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 не так, как методы, возвращающие необернутые значения.
Tags:
Hubs:
+10
Comments21

Articles

Change theme settings