ICommandHandler/IQueryHandler с асинхронностью/ожиданием

ЭДИТ говорит (tl;dr)

Я пошел с вариантом предложенного решения; сохраняя все ICommandHandlers и IQueryHandlers потенциально синхронными и возвращая решенную задачу в синхронных случаях. Тем не менее, я не хочу использовать Task.FromResult(...) повсюду, поэтому для удобства я определил метод расширения:

public static class TaskExtensions
{
    public static Task<TResult> AsTaskResult<TResult>(this TResult result)
    {
        // Or TaskEx.FromResult if you're targeting .NET4.0 
        // with the Microsoft.BCL.Async package
        return Task.FromResult(result); 
    }
}

// Usage in code ...
using TaskExtensions;
class MySynchronousQueryHandler : IQueryHandler<MyQuery, bool>
{
    public Task<bool> Handle(MyQuery query)
    {
        return true.AsTaskResult();
    }
}

class MyAsynchronousQueryHandler : IQueryHandler<MyQuery, bool>
{
    public async Task<bool> Handle(MyQuery query)
    {
        return await this.callAWebserviceToReturnTheResult();
    }
}

Жаль, что C# не Haskell... пока 8-). Действительно пахнет приложением Arrows. В любом случае, надеюсь, что это кому-нибудь поможет. Теперь к моему первоначальному вопросу :-)

Вступление

Привет!

Для проекта я в настоящее время разрабатываю архитектуру приложения на С# (.NET4.5, С# 5.0, ASP.NET MVC4). С этим вопросом я надеюсь получить некоторые мнения о некоторых проблемах, с которыми я столкнулся, пытаясь включить async/await. Примечание: это довольно длинное сообщение :-)

Структура моего решения выглядит так:

  • MyCompany.Contract (Команды/Запросы и общие интерфейсы)
  • MyCompany.MyProject (содержит бизнес-логику и обработчики команд/запросов)
  • MyCompany.MyProject.Web (веб-интерфейс MVC)

Я прочитал об обслуживаемой архитектуре и разделении команд-запросов и нашел эти сообщения очень полезными:

До сих пор я разбирался в концепциях ICommandHandler/IQueryHandler и внедрении зависимостей (я использую SimpleInjector - он действительно мертв просто).

Данный подход

Подход, описанный в приведенных выше статьях, предлагает использовать POCO в качестве команд/запросов и описывает их диспетчеры как реализации следующих интерфейсов обработчиков:

interface IQueryHandler<TQuery, TResult>
{
    TResult Handle(TQuery query);
}

interface ICommandHandler<TCommand>
{
    void Handle(TCommand command);
}

В контроллере MVC вы должны использовать это следующим образом:

class AuthenticateCommand
{
    // The token to use for authentication
    public string Token { get; set; }
    public string SomeResultingSessionId { get; set; }
}

class AuthenticateController : Controller
{
    private readonly ICommandHandler<AuthenticateCommand> authenticateUser;

    public AuthenticateController(ICommandHandler<AuthenticateCommand> authenticateUser) 
    {
        // Injected via DI container
        this.authenticateUser = authenticateUser;
    }

    public ActionResult Index(string externalToken)
    {
        var command = new AuthenticateCommand 
        { 
            Token = externalToken 
        };
        this.authenticateUser.Handle(command);

        var sessionId = command.SomeResultingSessionId;
        // Do some fancy thing with our new found knowledge
    }
}

Некоторые из моих наблюдений относительно этого подхода:

  1. В чистом CQS только запросы должны возвращать значения, а команды должны быть, ну только команды. На самом деле командам удобнее возвращать значения вместо того, чтобы выполнять команду, а затем выполнять запрос для того, что команда должна была вернуть в первую очередь (например, идентификаторы базы данных или тому подобное). Вот почему автор предложил помещать возвращаемое значение в команда ПОКО.
  2. Не очень очевидно, что возвращается из команды, на самом деле это выглядит так, как будто команда представляет собой нечто типа «огонь и забудь», пока вы в конечном итоге не столкнетесь со странным свойством результата, доступ к которому осуществляется после запуска обработчика плюс команда теперь знает о своем результате
  3. Чтобы это работало, обработчики должны быть синхронными — как запросы, так и команды. Как оказалось, в C# 5.0 вы можете внедрить обработчики с поддержкой async/await с помощью вашего любимого DI-контейнера, но компилятор не знает об этом во время компиляции, поэтому обработчик MVC с треском провалится с исключением, сообщающим вам, что метод возвращается до завершения выполнения всех асинхронных задач.

Конечно, вы можете пометить обработчик MVC как async, и именно об этом этот вопрос.

Команды, возвращающие значения

Я обдумал данный подход и внес изменения в интерфейсы для решения проблем 1 и 2, добавив ICommandHandler с явным типом результата - точно так же, как IQueryHandler. Это по-прежнему нарушает CQS, но, по крайней мере, очевидно, что эти команды возвращают какое-то значение с дополнительным преимуществом, заключающимся в том, что не нужно загромождать объект команды свойством результата:

interface ICommandHandler<TCommand, TResult>
{
    TResult Handle(TCommand command);
}

Естественно, кто-то может возразить, что если у вас одинаковый интерфейс для команд и запросов, зачем беспокоиться? Но я думаю, стоит назвать их по-другому - просто чище на мой взгляд.

Мое предварительное решение

Затем я серьезно задумался над третьей проблемой... некоторые из моих обработчиков команд/запросов должны быть асинхронными (например, выдача WebRequest другой веб-службе для аутентификации), другие - нет. Поэтому я решил, что лучше всего с нуля разработать обработчики для async/await, которые, конечно же, доходят до обработчиков MVC даже для обработчиков, которые на самом деле синхронны:

interface IQueryHandler<TQuery, TResult>
{
    Task<TResult> Handle(TQuery query);
}

interface ICommandHandler<TCommand>
{
    Task Handle(TCommand command);
}

interface ICommandHandler<TCommand, TResult>
{
    Task<TResult> Handle(TCommand command);
}

class AuthenticateCommand
{
    // The token to use for authentication
    public string Token { get; set; }

    // No more return properties ...
}

Контроллер аутентификации:

class AuthenticateController : Controller
{
    private readonly ICommandHandler<AuthenticateCommand, string> authenticateUser;

    public AuthenticateController(ICommandHandler<AuthenticateCommand, 
        string> authenticateUser) 
    {
        // Injected via DI container
        this.authenticateUser = authenticateUser;
    }

    public async Task<ActionResult> Index(string externalToken)
    {
        var command = new AuthenticateCommand 
        { 
            Token = externalToken 
        };
        // It's pretty obvious that the command handler returns something
        var sessionId = await this.authenticateUser.Handle(command);

        // Do some fancy thing with our new found knowledge
    }
}

Хотя это решает мои проблемы - очевидные возвращаемые значения, все обработчики могут быть асинхронными - мне больно ставить async на штуковину, которая не является асинхронной, просто потому что. Я вижу в этом несколько недостатков:

  • Интерфейсы обработчиков не такие опрятные, как мне хотелось бы - штуки Task<...> на мой взгляд очень многословны и на первый взгляд скрывают тот факт, что я хочу вернуть что-то только из запроса/команды.
  • Компилятор предупреждает вас об отсутствии соответствующего await в реализациях синхронного обработчика (я хочу иметь возможность компилировать свой Release с Warnings as Errors) - вы можете перезаписать это с помощью прагмы... да... ну...
  • Я мог бы опустить ключевое слово async в этих случаях, чтобы сделать компилятор счастливым, но для реализации интерфейса обработчика вам пришлось бы явно возвращать какое-то Task - это довольно некрасиво.
  • Я мог бы предоставить синхронную и асинхронную версии интерфейсов обработчика (или поместить их все в один интерфейс, раздувая реализацию), но я понимаю, что в идеале потребитель обработчика не должен знать о том, что команда/запрос обработчик является синхронным или асинхронным, поскольку это сквозная проблема. Что, если мне нужно сделать ранее синхронную команду асинхронной? Мне пришлось бы изменить каждого потребителя обработчика, потенциально нарушая семантику на моем пути через код.
  • С другой стороны, потенциально-async-handlers-подход даже дал бы мне возможность изменить обработчики синхронизации на асинхронные, украсив их с помощью моего контейнера внедрения зависимостей.

Сейчас я не вижу лучшего решения для этого... Я в растерянности.

У кого-нибудь есть похожая проблема и элегантное решение, о котором я не подумал?


person mfeineis    schedule 08.01.2014    source источник


Ответы (4)


Асинхронность и ожидание не идеально сочетаются с традиционным ООП. У меня есть серия блогов на эту тему; вы можете найти сообщение об асинхронных интерфейсах в частности полезно (хотя я не рассказываю о том, чего вы еще не обнаружили).

Проблемы проектирования вокруг async очень похожи на проблемы вокруг IDisposable; добавление IDisposable к интерфейсу является критическим изменением, поэтому вам нужно знать, может ли какая-либо возможная реализация когда-либо быть одноразовой (деталь реализации). Параллельная проблема существует с async; вам нужно знать, может ли любая возможная реализация когда-либо быть асинхронной (деталь реализации).

По этим причинам я рассматриваю методы, возвращающие Task, в интерфейсе как «возможно асинхронные» методы, точно так же, как интерфейс, наследующий от IDisposable, означает, что он «возможно владеет ресурсами».

Лучший подход, о котором я знаю, это:

  • Определите любые методы, которые могут быть асинхронными с асинхронной подписью (возвращая Task/Task<T>).
  • Верните Task.FromResult(...) для синхронных реализаций. Это более правильно, чем async без await.

Этот подход почти точно соответствует тому, что вы уже делаете. Для чисто функционального языка может существовать более идеальное решение, но я не вижу его для C#.

person Stephen Cleary    schedule 08.01.2014
comment
Спасибо, понятия на самом деле довольно похожи... Я добавил свое личное решение вопроса для удобства - person mfeineis; 17.01.2014

Я создал проект именно для этого - я не стал разделять команды и запросы, вместо этого использовал запрос/ответ и pub/sub - https://github.com/jbogard/MediatR

public interface IMediator
{
    TResponse Send<TResponse>(IRequest<TResponse> request);
    Task<TResponse> SendAsync<TResponse>(IAsyncRequest<TResponse> request);
    void Publish<TNotification>(TNotification notification) where TNotification : INotification;
    Task PublishAsync<TNotification>(TNotification notification) where TNotification : IAsyncNotification;
}

Для случая, когда команды не возвращают результатов, я использовал базовый класс, возвращающий тип Void (модуль для функциональных людей). Это позволило мне иметь унифицированный интерфейс для отправки сообщений с ответами, при этом нулевой ответ был явным возвращаемым значением.

Как кто-то, предоставляющий команду, вы явно соглашаетесь на асинхронность в своем определении запроса, а не заставляете всех быть асинхронными.

person Jimmy Bogard    schedule 08.05.2014

Вы заявляете:

потребитель обработчика не должен знать о том, что обработчик команды/запроса является синхронным или асинхронным, поскольку это сквозная проблема

Стивен Клирли уже немного коснулся этого, но асинхронность не является сквозной проблемой (или, по крайней мере, не так, как она реализована в .NET). Асинхронность — это архитектурная проблема, поскольку вы должны заранее решить, использовать ее или нет, и она полностью подвергает риску весь код вашего приложения. Он изменяет ваши интерфейсы, и поэтому невозможно «проникнуть» в него без того, чтобы приложение не узнало об этом.

Хотя .NET упростил асинхронность, как вы сказали, она все равно вредит вашим глазам и разуму. Возможно, это просто требует умственной подготовки, но мне действительно интересно, стоит ли вообще идти на асинхронность для большинства приложений.

В любом случае не используйте два интерфейса для обработчиков команд. Вы должны выбрать один, потому что наличие двух отдельных интерфейсов заставит вас дублировать все ваши декораторы, которые вы хотите применить к ним, и дублирует вашу конфигурацию DI. Так что либо используйте интерфейс, который возвращает Task и использует выходные свойства, либо используйте Task<TResut> и возвращайте какой-то тип Void в случае отсутствия возвращаемого типа.

Как вы можете себе представить (статьи, на которые вы указываете, являются моими), лично я предпочитаю иметь метод void Handle или Task Handle, поскольку с командами основное внимание уделяется не возвращаемому значению, а при наличии возвращаемого значения вы в конечном итоге получите повторяющаяся структура интерфейса, поскольку запросы имеют:

public interface ICommand<TResult> { }

public interface ICommandHandler<TCommand, TResult> 
    where TCommand : ICommand<TResult> 
{ 
    Task<TResult> Handle(TCommand command);
}

Без интерфейса ICommand<TResult> и ограничения универсального типа вам не будет поддержки во время компиляции. Это то, что я объяснил в Тем временем... на стороне запросов моей архитектуры

person Steven    schedule 08.05.2014
comment
Вместо того, чтобы возвращать Task‹TResult› из обработчика команд, мы не можем просто вернуть TResult ? именно то, что вы сделали в своей статье. Но для поддержки asyn мы можем спроектировать Command как public interface SaveUserDetailCommand:ICommand<Task<UserProfile>>{ }, чтобы обработчик выглядел SaveUserDetailCommandHandler:ICommandHandler<SaveUserDetailCommand,Task<UserProfile>> { public Task<UserProfile> Handle(SaveUserDetailCommand cmd) { } } от контроллера, который мы можем затем сделать, await mediator.execute(command); Как вы думаете, есть ли проблемы с этим подходом ?? - person crypted; 08.05.2014
comment
@ Стивен, я думаю, ты прав с единым интерфейсом ICommandHandler. Я реализовал довольно много с этим шаблоном, и действительно есть много дублированных вещей для поддержки обеих абстракций... может быть, я все делаю неправильно, и мои команды, которые возвращают данные (и, кроме того, нуждаются в проверке этих возвращаемых данных ) на самом деле являются запросами. Я не уверен в этом: вы бы подумали о преобразовании чего-либо (извлечение данных из БД с учетными данными пользователя->Сохранить в Excel-Filestream->Загрузить файл) в команду или запрос? спасибо - person mfeineis; 08.05.2014
comment
@Int3ὰ: С точки зрения потребления это будет работать нормально, но с таким подходом наверняка возникнут проблемы. Вы по-прежнему не можете применять общий декоратор вокруг этих обработчиков, потому что декоратор должен знать, является ли возвращаемый тип асинхронным или нет. Просто представьте себе простой декоратор ведения журнала, который оборачивает декорацию в try-finally и регистрирует продолжительность операции. При применении к асинхронному обработчику продолжительность операции всегда становится близкой к нулю, поскольку декоратор будет работать правильно только тогда, когда он применит делегат ContinueWith к возвращаемому Task. - person Steven; 08.05.2014
comment
@vanhelgen: получить данные из БД с учетными данными пользователя->Сохранить в Excel-Filestream->Загрузить файл, безусловно, будет запросом; не команда. Там нет бизнес-операций, ничего не меняется в вашем приложении (кроме временного сохранения какого-то файла excel). - person Steven; 08.05.2014
comment
@Steven: я только что сделал рефакторинг, и теперь это просто очевидно ... я сделал глупость, и спасибо! Присутствует некоторое ведение журнала, но это не является жизненно важным для самой бизнес-операции. - person mfeineis; 08.05.2014
comment
@vanhelgen: Это совсем не глупо. Требуется время, чтобы научить свой мозг видеть эти закономерности. Но как только вы освоите это, вы станете как Нео в Матрице. Это декораторы... декораторы повсюду ;-) - person Steven; 08.05.2014
comment
@Стивен, ты потрясающий! - person crypted; 09.05.2014
comment
@Steven На самом деле, я сначала не мог понять, о чем вы говорите. Затем я попытался использовать украшение Duration Logger, оно дало результат, близкий к 0 секундам, хотя операция на самом деле заняла 3 секунды. Я реорганизовал свой код, чтобы вернуть Task‹TResponse› Я просто работал как освобожденный. Но один вопрос. Создание задачи Для каждого синхронизированного результата не влияет на производительность? - person crypted; 09.05.2014
comment
@Int3ὰ: Извините за задержку. Мне пришлось проконсультироваться по этому поводу с экспертом по этому вопросу. Использование Task.FromResult создаст дополнительную нагрузку на сборщик мусора, но в большинстве систем, насколько мы можем судить, не должно оказывать заметного влияния. - person Steven; 14.05.2014

Не совсем ответ, но чего бы это ни стоило, я пришел к тем же выводам и очень похожей реализации.

Мои ICommandHandler<T> и IQueryHandler<T> возвращают Task и Task<T> соответственно. В случае синхронной реализации я использую Task.FromResult(...). У меня также были некоторые декораторы *handler (например, для ведения журнала), и, как вы можете себе представить, их также нужно было изменить.

На данный момент я решил сделать «все» потенциально ожидаемым и привык использовать await в сочетании с моим диспетчером (находит обработчик в ядре ninject и вызывает его дескриптор).

Я полностью асинхронизировался, также в моих контроллерах webapi/mvc, за некоторыми исключениями. В тех редких случаях я использую Continuewith(...) и Wait() для переноса вещей в синхронный метод.

Другое, связанное с этим разочарование, которое у меня есть, заключается в том, что MR рекомендует называть методы с суффиксом * Async в случае, если они (да) асинхронные. Но поскольку это решение о реализации, я (на данный момент) решил придерживаться Handle(...), а не HandleAsync(...).

Это определенно не удовлетворительный результат, и я также ищу лучшее решение.

person Geoffrey Braaf    schedule 23.01.2014