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
не так, как методы, возвращающие необернутые значения. Комментарии (15)
MonkAlex
11.04.2018 13:28В начале был какой то пример про получение книжки. Потом пошел абстрактный код без реальных примеров и всё стало сложно и непонятно.
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); } }
MonkAlex
11.04.2018 14:38К сожалению, ни ваш код, ни описание поведения на вики — не понятны. К чему и куда оно надо?
Где код до и после то?Veikedo
11.04.2018 14:39+1Ну, в общем-то, в статье. Под заголовками "Код без cross-cutting concern" и "Код с cross-cutting concern"
MonkAlex
11.04.2018 16:42Так а к чему всё остальное? Где декораторы то? Как стало с декораторами?
CHROMIGO
11.04.2018 22:55Автор вначале приводит пример декоратора и как он регистрируется. После описывает проблему дублирования кода валидации в таких декораторах для Query/Command Handler-ов(хотим одинаково валидировать но придется дублировать и писать несколько декораторов, а не 1). Собственно предлагает обобщить ICommandHandler/QueryHandler до IRequestHandler(точнее унаследовать) и как продолжение идеи до IUseCaseHandler<T1,T2>. Далее мы просто так же как в начале регистрируем декоратор на этот generic интерфейс и все по идее должно заработать, без всякого дублирования. Но вот для сервисов чтобы все работало придется работать явно с IUseCaseHadnler, вместо простого IAppService интерфейса.
Ну т.е. просто делаем очень общий интферфейс и на него вешаем 1 декоратор.
Примеров декораторов статье больше нет, видимо подразумевается что он будет аналогичен начальному.
Я понял такmarshinov Автор
12.04.2018 02:06Спасибо большое за комментарий. Вы поняли все верно. Я теперь знаю, что донёс идею внятно:)
VolCh
12.04.2018 00:42Декораторы это хорошо, но как быть, если, например, в примере обычного кода логировать нужно внутри веток получения из базы или кеша, чтобы различать их? Инжектировать в метод декорированные логером методы получения из базы и кеша?
marshinov Автор
12.04.2018 02:09Добавьте дополнительное логирование в необходимых ветках. Можно явно с помощью императивного кода или с помощью декораторов для декораторов. Это как удобнее. Цель статьи — показать как можно очистить доменную логику от инфраструктурного кода. Взаимодействие инфраструктуры с инфраструктурой — другой вопрос.
VolCh
12.04.2018 08:40+1Пример "инфраструктуры с инфраструктурой" просто из вашего кода. Пускай надо логировать именно отдельные ветки доменной логики. Причём не ошибки, которые клиенту доменной логики надо как-то обрабатывать, а просто debug/info уровень, о том куда мы пошли, какая ветка бизнес-правила сработала. Приходит в голову возвращать Result или его наследника типа LoggableResult, в котором есть итерируемое поле типа logMessages, из которого декоратор, если он применён, может доставать сообщения и логировать их. Нет декоратора — они просто теряются или добавляются к LoggableResult самого клиента — может клиент клиента решит их залогировать.
Не пробовали такой подход? Из очевидных минусов — потенциально логируемые куски логики, юзкейсы из примеров, всегда должны возвращать Result, а не быть void или какого-то другого типа. Тогда добавить логирование можно будет с минимальным изменением кода, вплоть только в DI-контейнере декоратор добавить. Но логика клиента должна будет всегда учитывать, что ожидаемый результат упакован.
mayorovp
12.04.2018 14:23Как мне кажется, декораторы нужны для однотипного кода. Если логирование перестало быть однотипным и усложнилось — надо выкинуть декоратор логирования из цепочки и вести логи по месту.
VolCh
12.04.2018 15:07+1Очень не хочется в уровень доменной логики вводить логгеры, пускай даже какой-то NullLogger по умолчанию. Лучше уж полноценную систему доменных событий внедрять сразу, пускай по началу только логгеры на неё подписываться и будут или вообще в пустоту события эмитировать.
marshinov Автор
12.04.2018 15:23Думаю, доменные события для такого логирования подойдут лучше декораторов.
AgentFire
Было куда интереснее, если бы автор привел небольшое описание зачем вообще все это нужно. И для каких целей. Для тех людей, которые не в курсе терминов.
marshinov Автор
Дописал