Шаблоны для асинхронных MVVM-приложений: сервисы

OSzone.net » Microsoft » Разработка приложений » Другое » Шаблоны для асинхронных MVVM-приложений: сервисы
Автор: Стивен Клири
Иcточник: MSDN Magazine
Опубликована: 14.07.2014

Это третья статья из серии по комбинированному использованию async и await с устоявшимся шаблоном Model-View-ViewModel (MVVM). В первой статье я разработал метод для связывания с асинхронной операцией через данные. Во второй было рассмотрено несколько возможных реализаций асинхронного ICommand. Теперь я переключусь на уровень сервисов и расскажу об асинхронных сервисах.

Я не намерен иметь дело ни с каким UI. По сути, шаблоны в этой статье не специфичны для MVVM; они в равной мере применимы в приложениях любых типов. Шаблоны асинхронного связывания с данными (asynchronous data binding) и команд, исследованные в моих предыдущих статьях, являются довольно новыми, но шаблоны асинхронных сервисов в этой статье — более устоявшимися. Тем не менее, даже устоявшиеся шаблоны — это просто шаблоны.

Асинхронные интерфейсы

«Программируйте с ориентацией на интерфейс, а не на реализацию.» Как и предполагает эта цитата из «Design Patterns: Elements of Reusable Object-Oriented Software» (Addison-Wesley, 1994, стр. 18), интерфейсы — важнейшая часть правильной объектно-ориентированной архитектуры. Интерфейсы позволяют вашему коду использовать абстракцию вместо конкретного типа и создают в вашем коде «точку подключения» для модульных тестов. Но можно ли создать интерфейс с асинхронными методами?

Ответ — да. В следующем коде определяется интерфейс с асинхронным методом:

public interface IMyService
{
  Task<int> DownloadAndCountBytesAsync(string url);
}

Реализация сервиса достаточно прямолинейна:

public sealed class MyService : IMyService
{
  public async Task<int> DownloadAndCountBytesAsync(string url)
  {
    await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
    using (var client = new HttpClient())
    {
      var data = await
        client.GetByteArrayAsync(url).ConfigureAwait(false);
      return data.Length;
    }
  }
}

На рис. 1 показано, как код, который использует данный сервис, вызывает асинхронный метод, определенный в этом интерфейсе.

Рис. 1. UseMyService.cs: вызов асинхронного метода, определенного в интерфейсе

public sealed class UseMyService
{
  private readonly IMyService _service;
  public UseMyService(IMyService service)
  {
    _service = service;
  }
  public async Task<bool> IsLargePageAsync(string url)
  {
    var byteCount =
      await _service.DownloadAndCountBytesAsync(url);
    return byteCount > 1024;
  }
}

Пример может показаться чрезмерно упрощенным, но из него вы извлечете пару важных уроков по асинхронным методам.

Первый урок. Методы не являются ожидаемыми (awaitable), ожидаемыми могут быть типы. Именно тип выражения определяет, является ли данное выражение ожидаемым. В частности, UseMyService.IsLargePageAsync ожидает результат от IMyService.DownloadAndCountBytesAsync. Метод интерфейса не помечен (и не может быть помечен) как async. IsLargePageAsync может использовать await, потому что метод интерфейса возвращает Task, а задачи являются ожидаемыми.

Второй урок: async — это деталь реализации. UseMyService не знает и не должен знать, реализованы ли методы интерфейса с применением async. Код-потребитель волнует лишь то, что метод возвращает задачу. Использование async и await — распространенный способ реализации метода, возвращающего задачу, но не единственный. Например, код на рис. 2 использует часто применяемый шаблон перегрузки асинхронных методов.

Рис. 2. AsyncOverloadExample.cs: использование распространенного шаблона перегрузки асинхронных методов

class AsyncOverloadExample
{
  public async Task<int>
    RetrieveAnswerAsync(CancellationToken cancellationToken)
  {
    await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
    return 42;
  }
  public Task<int> RetrieveAnswerAsync()
  {
    return RetrieveAnswerAsync(CancellationToken.None);
  }
}

Заметьте, что одна из перегруженных версий просто вызывает другую и напрямую возвращает свою задачу. Можно написать эту перегруженную версию так, чтобы она использовала async и await, но это только добавило бы издержек и не дало бы никакого выигрыша.

Асинхронное модульное тестирование

Есть и другие варианты реализации методов, возвращающих задачи. Частый выбор для заглушек в модульных тестах — Task.FromResult, поскольку это самый простой способ создать завершенную задачу. В следующем коде определяется реализация-заглушка для сервиса:

class MyServiceStub : IMyService
{
  public int DownloadAndCountBytesAsyncResult { get; set; }
  public Task<int> DownloadAndCountBytesAsync(string url)
  {
    return Task.FromResult(DownloadAndCountBytesAsyncResult);
  }
}

Вы можете использовать эту реализацию-заглушку для тестирования UseMyService, как показано на рис. 3.

Рис. 3. UseMyServiceUnitTests.cs: реализация-заглушка для теста UseMyService

[TestClass]
public class UseMyServiceUnitTests
{
  [TestMethod]
  public async Task UrlCount1024_IsSmall()
  {
    IMyService service = new MyServiceStub {
      DownloadAndCountBytesAsyncResult = 1024
	};
    var logic = new UseMyService(service);
    var result = await
      logic.IsLargePageAsync("http://www.example.com/");
    Assert.IsFalse(result);
  }
  [TestMethod]
  public async Task UrlCount1025_IsLarge()
  {
    IMyService service = new MyServiceStub {
      DownloadAndCountBytesAsyncResult = 1025
	};
    var logic = new UseMyService(service);
    var result = await
      logic.IsLargePageAsync("http://www.example.com/");
    Assert.IsTrue(result);
  }
}

В этом примере кода используется MSTest, но большинство других современных инфраструктур модульного тестирования тоже поддерживает асинхронные модульные тесты. Просто удостоверьтесь, что ваши модульные тесты возвращают задачу; избегайте асинхронные void-методы в модульных тестах. Большинство инфраструктур модульного тестирования не поддерживает такие методы.

При модульном тестировании синхронных методов важно проверять, как код ведет себя при успешном и неудачном завершении. Асинхронные методы вносят свою специфику: асинхронный сервис может завершиться успешно или сгенерировать исключение — и все синхронно или асинхронно. Вы можете проверить все четыре комбинации, если хотите, но я считаю, что обычно достаточно проверить удачное и неудачное асинхронное завершение плюс удачное синхронное завершение, если это необходимо. Проверка удачного синхронного завершения полезна потому, что оператор await будет действовать по-разному, если его операция уже окончена. Однако тест на неудачное синхронное завершение я не нахожу полезным, так как ошибка в большинстве асинхронных операций не проявляется немедленно.

На момент написания статьи некоторые популярные инфраструктуры имитации (mocking) и создания заглушек (stubbing) будут возвращать default(T), пока вы не модифицируете это поведение. Поведение имитации по умолчанию не очень хорошо работает с асинхронными методами, поскольку они никогда не должны возвращать null-задачу (согласно шаблону Task-based Asynchronous Pattern, который вы найдете по ссылке bit.ly/1ifhkK2). Правильным поведением по умолчанию был бы возврат Task.FromResult(default(T)). Это распространенная проблема при модульном тестировании асинхронного кода; если вы видите неожиданные NullReferenceException в своих тестах, убедитесь, что имитирующие типы (mock types) реализуют все методы, возвращающие задачи. Надеюсь, что инфраструктуры имитации и создания заглушек в будущем станут лучше поддерживать асинхронность и что в них заложат правильное поведение по умолчанию для асинхронных методов.

Асинхронные фабрики

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

Конструкторы не могут быть асинхронными, а статические методы — могут. Один из способов имитировать асинхронный конструктор — реализовать метод асинхронной фабрики (рис. 4).

Рис. 4. Сервис с методом асинхронной фабрики

interface IUniversalAnswerService
{
  int Answer { get; }
}
class UniversalAnswerService : IUniversalAnswerService
{
  private UniversalAnswerService()
  {
  }
  private async Task InitializeAsync()
  {
    await Task.Delay(TimeSpan.FromSeconds(2));
    Answer = 42;
  }
  public static async Task<UniversalAnswerService> CreateAsync()
  {
    var ret = new UniversalAnswerService();
    await ret.InitializeAsync();
    return ret;
  }
  public int Answer { get; private set; }
}

Мне по-настоящему нравится подход с асинхронной фабрикой, так как его нельзя использовать неправильно. Вызывающий код не может напрямую запустить конструктор; он должен использовать метод фабрики, чтобы получить экземпляр, и этот экземпляр полностью инициализируется до возврата вызывающему коду. Однако этот подход в некоторых сценариях неприменим. На момент написания статьи инфраструктуры IoC (Inversion of Control) и DI (Dependency Injection) не понимали никаких соглашений по методам асинхронных фабрик. Если вы встраиваете свои сервисы, используя IoC/DI-контейнер, то должны применить альтернативный подход.

Асинхронные ресурсы

В некоторых случаях асинхронная инициализация требуется лишь раз — для общих ресурсов. Стефен Тауб (Stephen Toub) разработал тип AsyncLazy<T> (bit.ly/1cVC3nb), который также доступен как часть моей библиотеки AsyncEx (bit.ly/1iZBHOW). AsyncLazy<T> объединяет Lazy<T> с Task<T>. Точнее, это Lazy<Task<T>>, «ленивый» тип (lazy type) (тип с отложенной инициализацией), который поддерживает методы асинхронной фабрики. Уровень Lazy<T> обеспечивает безопасную в многопоточной среде отложенную инициализацию (lazy initialization), гарантируя, что метод фабрики будет выполнен только один раз; уровень Task<T> предоставляет поддержку асинхронности, позволяя вызывающему коду асинхронно ожидать завершения метода фабрики.

На рис. 5 представлено слегка упрощенное определение AsyncLazy<T>, а на рис. 6 показано, как AsyncLazy<T> можно использовать в каком-либо типе.

Рис. 5. Определение AsyncLazy<T>

// Обеспечивает поддержку асинхронной отложенной инициализации.
// Этот тип полностью безопасен в многопоточной среде.
public sealed class AsyncLazy<T>
{
  private readonly Lazy<Task<T>> instance;
  public AsyncLazy(Func<Task<T>> factory)
  {
    instance = new Lazy<Task<T>>(() => Task.Run(factory));
  }
  // Поддержка асинхронной инфраструктуры.
  // Позволяет напрямую ожидать экземпляры этого типа.
  public TaskAwaiter<T> GetAwaiter()
  {
    return instance.Value.GetAwaiter();
  }
}

Рис. 6. AsyncLazy<T>, используемый в некоем типе

class MyServiceSharingAsyncResource
{
  private static readonly AsyncLazy<int> _resource =
    new AsyncLazy<int>(async () =>
    {
       await Task.Delay(TimeSpan.FromSeconds(2));
       return 42;
    });
  public async Task<int> GetAnswerTimes2Async()
  {
    int answer = await _resource;
    return answer * 2;
  }
}

Этот сервис определяет единственный общий «ресурс», который должен конструироваться асинхронно. Любые методы любых экземпляров этого сервиса могут зависеть от этого ресурса и ожидать его напрямую. При первом ожидании экземпляр AsyncLazy<T> запускает один раз метод асинхронной фабрики в потоке из пула. Любые другие попытки параллельного доступа к этому экземпляру из другого потока приведут к ожиданию до тех пор, пока метод асинхронной фабрики не будет поставлен в очередь пула потоков.

Синхронная, безопасная в многопоточной среде часть поведения AsyncLazy<T> обрабатывается уровнем Lazy<T>. Время, проводимое в блокировке, очень короткое: каждый поток ждет только постановки метода фабрики в очередь пула потоков, а не его выполнения. Как только Task<T> возвращается методом фабрики, работа уровня Lazy<T> закончена. Тот же экземпляр Task<T> является общим для всех последующих вызовов с await. Ни методы асинхронной фабрики, ни асинхронная отложенная инициализация никогда не предоставляют доступа к экземпляру T, пока не будет завершена его асинхронная инициализация. Это защищает вас от случайного неправильного использования типа.

AsyncLazy<T> отлично подходит для одной конкретной разновидности задач: для асинхронной инициализации общего ресурса. Однако в других сценариях он может оказаться неудобным в использовании. В частности, если экземпляру сервиса нужен асинхронный конструктор, вы можете определить «внутренний» тип сервиса, который выполняет асинхронную инициализацию, и использовать AsyncLazy<T> для обертывания внутреннего экземпляра во «внешний» тип сервиса. Но это вынуждает писать громоздкий и утомительный код, в котором все методы зависят от одного и того же внешнего экземпляра. В таких сценариях более элегантным был бы настоящий «асинхронный конструктор».

Ошибочный шаг

Прежде чем перейти к решению, предпочитаемому мной, я хочу указать на довольно распространенный ошибочный шаг. Когда разработчики сталкиваются с необходимостью выполнения асинхронной работы в конструкторе (который не может быть асинхронным), они могут прибегнуть к обходному пути, как в коде на рис. 7.

Рис. 7. Обходной путь при необходимости выполнения асинхронной работы в конструкторе

class BadService
{
  public BadService()
  {
    InitializeAsync();
  }
  // ПЛОХОЙ КОД!!!
  private async void InitializeAsync()
  {
    await Task.Delay(TimeSpan.FromSeconds(2));
    Answer = 42;
  }
  public int Answer { get; private set; }
}

Но с этим подходом можно наткнуться на некоторые серьезные проблемы. Во-первых, нет возможности сообщить об окончании инициализации, а во-вторых, любое исключение при инициализации будет обрабатываться в обычном стиле async void, что скорее всего приведет к краху приложения. Если бы InitializeAsync был асинхронной задачей, а не async void, то ситуация едва ли улучшилась бы: все равно у вас не было бы никакого способа сообщить об окончании инициализации, и любые исключения «молча» игнорировались бы. Есть подход получше!

Шаблон асинхронной инициализации

В большей части кода, создающего что-либо через отражение (IoC/DI-инфраструктуры, Activator.CreateInstance и др.), предполагается, что у вашего типа есть конструктор, и этот конструктор не может быть асинхронным. Если вы попали в такую ситуацию, то вынуждены возвращать экземпляр, который не был (асинхронно) инициализирован. Цель шаблона асинхронной инициализации — предоставить стандартный способ обработки этой ситуации, что смягчить остроту проблемы с неинициализированными экземплярами.

Для начала я определяю интерфейс-«маркер». если некоему типу нужна асинхронная инициализация, он реализует этот интерфейс:

/// <summary>
/// Помечает тип как требующий асинхронной инициализации
/// и предоставляет результат такой инициализации
/// </summary>
public interface IAsyncInitialization
{
  /// <summary>
  /// Результат асинхронной инициализации этого экземпляра
  /// </summary>
  Task Initialization { get; }
}

На первый взгляд, свойство Initialization типа Task кажется странным. Однако я считаю это уместным, потому что асинхронная инициализация экземпляра является операцией на уровне экземпляра. Поэтому свойство Initialization относится к экземпляру в целом.

Реализуя этот интерфейс, я предпочитаю делать это с использованием реального async-метода, который по соглашению я называю InitializeAsync (рис. 8).

Рис. 8. Сервис, реализующий метод InitializeAsync

class UniversalAnswerService :
  IUniversalAnswerService, IAsyncInitialization
{
  public UniversalAnswerService()
  {
    Initialization = InitializeAsync();
  }
  public Task Initialization { get; private set; }
  private async Task InitializeAsync()
  {
    await Task.Delay(TimeSpan.FromSeconds(2));
    Answer = 42;
  }
  public int Answer { get; private set; }
}

Конструктор довольно прост; он начинает асинхронную инициализацию (вызовом InitializeAsync), а затем задает свойство Initialization. Это свойство Initialization предоставляет результаты метода InitializeAsync: по завершении InitializeAsync завершается задача Initialization, и, если есть какие-то ошибки, они станут доступными через задачу Initialization.

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

async Task<int> AnswerTimes2Async()
{
  var service = new UniversalAnswerService();
  // Опасность! Здесь сервис еще не инициализирован: Answer=0!
  await service.Initialization;
  // Все в порядке, сервис инициализирован и Answer=42
  return service.Answer * 2;
}

В более реалистичном сценарии IoC/DI код-потребитель лишь получает экземпляр, реализующий IUniversalAnswerService, и должен проверить, реализует ли он IAsyncInitialization. Это полезный прием; он позволяет сделать асинхронную инициализацию деталью реализации типа. Например, типы-заглушки скорее всего не будут реализовать асинхронную инициализацию (если только вы не тестируете, что код-потребитель действительно ожидает инициализации сервиса). Следующий код более реалистично использует мой сервис:

async Task<int>
  AnswerTimes2Async(IUniversalAnswerService service)
{
  var asyncService = service as IAsyncInitialization;
  if (asyncService != null)
    await asyncService.Initialization;
  return service.Answer * 2;
}

Прежде чем продолжить обсуждение шаблона асинхронной инициализации, я должен обратить ваше внимание на важную альтернативу. Можно предоставлять члены сервиса как асинхронные методы, которые на внутреннем уровне ожидают (await) инициализации своих объектов. На рис. 9 показано, как мог бы выглядеть объект такого рода.

Рис. 9. Сервис, который ожидает собственной инициализации

class UniversalAnswerService
{
  private int _answer;
  public UniversalAnswerService()
  {
    Initialization = InitializeAsync();
  }
  public Task Initialization { get; private set; }
  private async Task InitializeAsync()
  {
    await Task.Delay(TimeSpan.FromSeconds(2));
    _answer = 42;
  }
  public Task<int> GetAnswerAsync()
  {
    await Initialization;
    return _answer;
  }
}

Мне нравится этот подход, так как в этом случае нельзя ошибочно использовать еще не инициализированный объект. Однако он ограничивает API сервиса, поскольку любой член, который зависит от инициализации, должен предоставляться как асинхронный метод. В предыдущем примере свойство Answer было заменено методом GetAnswerAsync.

Композиция шаблона асинхронной инициализации

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

public static class AsyncInitialization
{
  public static Task
    EnsureInitializedAsync(IEnumerable<object> instances)
  {
    return Task.WhenAll(
      instances.OfType<IAsyncInitialization>()
        .Select(x => x.Initialization));
  }
  public static Task EnsureInitializedAsync(params object[] instances)
  {
    return EnsureInitializedAsync(instances.AsEnumerable());
  }
}

Вспомогательные методы принимают любое количество экземпляров любого типа, отфильтровывают те из них, которые не реализуют IAsyncInitialization, а затем асинхронно ждут завершения всех задач Initialization.

С помощью этих вспомогательных методов создание составного сервиса достаточно прямолинейно. Сервис на рис. 10 принимает два экземпляра сервиса ответа (answer service) как зависимости и усредняет их результаты.

Рис. 10. Сервис, усредняющий результаты двух экземпляров сервиса ответа

interface ICompoundService
{
  double AverageAnswer { get; }
}
class CompoundService : ICompoundService, IAsyncInitialization
{
  private readonly IUniversalAnswerService _first;
  private readonly IUniversalAnswerService _second;
  public CompoundService(IUniversalAnswerService first,
    IUniversalAnswerService second)
  {
    _first = first;
    _second = second;
    Initialization = InitializeAsync();
  }
  public Task Initialization { get; private set; }
  private async Task InitializeAsync()
  {
    await AsyncInitialization.EnsureInitializedAsync(_first, _second);
    AverageAnswer = (_first.Answer + _second.Answer) / 2.0;
  }
  public double AverageAnswer { get; private set; }
}

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

Не стоит слишком сильно волноваться о последствиях этого для производительности; для асинхронных структур понадобится выделить некий объем дополнительной памяти, но поток не станет вести себя асинхронно. Ключевое слово await обеспечивает оптимизацию «быстрый путь» («fast path» optimization), которая вступает в игру всякий раз, когда код ожидает уже завершенную задачу. Если зависимости составного сервиса не требуют асинхронной инициализации, последовательность, переданная в Task.WhenAll, пуста, что заставляет Task.WhenAll вернуть уже завершенную задачу. Когда задача ожидается CompoundService.InitializeAsync, он не передает управление из-за того, что задача уже выполнена. В этом сценарии InitializeAsync выполняется синхронно до завершения работы конструктора.

Во-вторых, важно инициализировать все зависимости до возврата составного InitializeAsync. Это гарантирует, что инициализация составного типа полностью закончена. Кроме того, естественно, следует предусмотреть обработку ошибок: если в зависимом сервисе возникла ошибка инициализации, она распространяется вверх от EnsureInitializedAsync, и InitializeAsync составного типа терпит неудачу с той же ошибкой.

И в-третьих, составной сервис не является особой разновидностью типа. Это просто сервис, который поддерживает асинхронную инициализацию, как и сервис любого другого вида. Все эти сервисы можно имитировать для тестирования независимо от того, поддерживают они асинхронную инициализацию или нет.

Заключение

Шаблоны в этой статье применимы к приложению любого типа; я использовал их в ASP.NET и консольных программах, а также в MVVM-приложениях. Мой любимый шаблон для асинхронного конструирования — метод асинхронной фабрики; он очень прост и не может неправильно использоваться кодом-потребителем, так как никогда не предоставляет неинициализированный экземпляр. Однако я также нахожу весьма полезным шаблон асинхронной инициализации, имея дело со сценариями, где не могу (или не хочу) создавать собственные экземпляры. Шаблон AsyncLazy<T> тоже занимает свою нишу — при наличии общих ресурсов, требующих асинхронной инициализации.

Шаблоны асинхронных сервисов являются более устоявшимися, чем шаблоны MVVM, о которых я рассказывал в предыдущих статьях. Шаблон для асинхронного связывания с данными и различные подходы к асинхронным командам сравнительно новы, и в них явно есть что совершенствовать. Шаблоны асинхронных сервисов, напротив, используются заметно шире. Но следует помнить о стандартных подвохах: эти шаблоны — вовсе не священные коровы; это просто методики, которые я нахожу полезными и которыми я хотел поделиться с вами. Если вы можете улучшить их или адаптировать под требования своего приложения, пожалуйста, карты вам в руки! Надеюсь, что эти статьи были полезны, дали вам хорошее представление об асинхронных шаблонах MVVM и, что важнее, подтолкнули вас к их расширению и исследованию своих асинхронных шаблонов для UI.


Ссылка: http://www.oszone.net/24586/