Разработчик SimpleInjector очень любит «декораторы», особенно в сочетании с дженериками вида
Такой подход позволяет «навешивать» на обработчики то, что принято называть cross-cutting concerns безрегистрации и смс interception и особой уличной магии вроде Fody или PostSharp.
CQRS не top level architecture, поэтому хочется иметь такие-же декораторы и для классических Application Service. Под катом я расскажу как это сделать.
Сross-cutting concern — термин из АОП. К сквозной относится «вспомогательная» функциональность модуля, не относящаяся напрямую к выполняемой задаче, но необходимая, например:
Эту логику обычно сложно отделить от основной. Обратите внимание на два примера ниже.
Вместо одной строчки получилось больше двадцати. И главное, этот код придется повторять снова и снова. На помощь приходят декораторы.
Например, хочется включить глобальную валидацию. Достаточно объявить вот такой декоратор:
И зарегистрировать его для всех обработчиков команд:
Теперь для всех реализаций интерфейса
Но тогда придется писать по набору декораторов для
Различие
Основная проблема с глобальным применением декораторов для сервисного слоя в том, что интерфейсы сервисов сложнее, чем generic handler'ы. Если все обработчики реализуют вот такой generic-интерфейс:
То сервисы обычно реализуют по одному методу на каждый use case
Абстрактный декоратор с валидацией уже не применишь, придется писать по декоратору на каждый сервис, что убивает саму идею написать один раз код валидации и забыть про него. Более того, проще тогда писать код валидации внутри методов, чем декорировать их.
Для CQRS можно решить проблему дублирования декораторов, если ввести интерфейс
Почему бы не переименовать интерфейс
Теперь «общие» декораторы можно вешать на
Интерфейс
В прикладном коде необходимо использовать интерфейсы
Вернемся к примеру с валидацией. Валидатор в коде ниже выбрасывает исключение при получении неверной команды. Использовать исключения для обработки пользовательского ввода — вопрос дискуссионный.
Если вы предпочитаете явно указывать в сигнатуре метода, что выполнение может закончиться неудачей, пример выше можно переписать так:
Таким образом можно будет дополнительно разделить декораторы по типу возвращаемого значения. Например, логировать методы, возвращающие
QueryHandler<TIn, TOut>, CommandHanler<TIn, TOut>
.Такой подход позволяет «навешивать» на обработчики то, что принято называть cross-cutting concerns без
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
не так, как методы, возвращающие необернутые значения.