异步编程

针对异步 MVVM 应用程序的模式:服务

Stephen Cleary

这是三个系列的文章中结合异步,等待与既定的模型-视图-ViewModel (MVVM) 模式。 在第一篇文章,我开发了一个数据绑定到一个异步操作的方法。 在第二个,我考虑了几个可能的实现的异步 ICommand。 现在,我会把对服务层和异步服务的地址。

我不会在所有处理一个用户界面。 事实上,这篇文章中的模式不是特定于使用 MVVM ; 他们同样适用于任何类型的应用程序。 异步数据绑定和命令模式探讨了在我以前的文章都很新 ; 这篇文章中的异步服务模式更多被建立。 甚至既定的模式还是只是模式。

异步接口

"到接口,不执行计划"。从这句话作为"设计模式:可复用的面向对象软件的元素"(艾迪生-韦斯利,1994 年,p。 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,类型是。 它是表达式的确定是否该表达式是表达式的 awaitable 的类型。 尤其是,UseMyService.IsLargePageAsync 等待着 IMyService.DownloadAndCountBytesAsync 的结果。 接口方法不是 (和不能) 标记的异步。 IsLargePageAsync 可以使用等待因为接口方法返回一个任务,任务是 awaitable。

第二个教训是:异步是实现详细信息。 UseMyService 既不知道也不会在乎使用异步或不来实现接口方法。 使用代码只在乎该方法返回一个任务。 使用异步和等待是一种共同的方法来实现任务返回的方法,但它不是唯一的方法。 例如,在代码图 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);
  }
}

请注意这一个重载只是调用其他的和直接返回它的任务。 它是可能写入该重载使用异步和等待,但这只会增加开销,提供没有任何好处。

异步单元测试

有执行任务返回方法的其他选项。 Task.FromResult 是单元测试存根,共同选择,因为它是最简单的方法来创建一个已完成的任务。 下面的代码定义了服务的存根 (stub) 实施:

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

您可以使用此存根 (stub) 实现测试 UseMyService,如中所示图 3

图 3 UseMyServiceUnitTests.cs:存根 (stub) 执行的测试 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,但大多数其他现代单元测试框架还支持异步单元测试。 只需确保您的单元测试返回的任务 ; 避免异步空的单元测试方法。 大多数单元测试框架不支持异步空的单元测试方法。

当单元测试的同步方法,它是重要的是要测试代码的行为方式都在成功和失败的条件。 异步方法添加皱:它是可能的一个异步服务成功或引发异常,同步或异步。 如果你想要却通常就足够了,测试至少异步成功和异步失败,再加上同步成功,如有必要,您可以测试这些组合的所有四个。 同步成功测试非常有用,因为等待操作员将以不同的方式行事,如果其操作已完成。 但是,我找不到同步失败测试作为有用的因为失败并不是立即与最异步操作。

写这篇文章,一些受欢迎的嘲弄和钳框架将返回 default (t) 除非你修改此行为。 默认的嘲弄行为不起作用以及与异步方法因为异步方法应从不返回空任务 (根据你会发现在基于任务的异步模式 bit.ly/1ifhkK2)。 正确的默认行为是返回 Task.FromResult(default(T))。 这是一个常见的问题当单元测试异步代码 ; 如果你看到意外的 NullReferenceExceptions 您在测试中,确保模拟类型正在执行的所有任务返回方法。 我希望嘲弄和钳框架将在未来成为更加意识到异步和实现更好地为异步方法的默认行为。

异步工厂

模式到目前为止已经说明了如何定义的接口,用一种异步的方法 ; 如何实现它的一项服务 ; 以及如何定义用于测试目的的存根 (stub)。 这些都是足够最异步服务,但有整另一个级别的当一个服务实现,必须做一些异步工作,才可以使用它时,应用的复杂性。 让我描述如何处理这种情况在您需要异步的构造函数。

构造函数不能是异步,但静态方法可以。 一种方法假装的感觉异步的构造函数是实现异步的工厂方法,如中所示图 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) 和依赖注入 (DI) 框架不明白任何异步工厂方法的约定。 如果你注射您使用 IoC/DI 容器的服务,您需要另一种方法。

异步资源

在某些情况下,异步初始化需要只有一次,要初始化的共享的资源。 斯蒂芬 Toub 开发异步­懒人 < T > 类型 (bit.ly/1cVC3nb),也是可用的我的 AsyncEx 图书馆一部分 (bit.ly/1iZBHOW)。 AsyncLazy < T > 结合了懒人 < T > < T > 的任务。 具体地说,它是 < < T >> 的任务,懒懒的类型,支持异步工厂方法。 懒的 < T > 层提供了线程安全的迟缓初始化,确保工厂方法只执行一次 ; < T > 任务 层提供异步支持,允许调用方以异步等待了工厂方法来完成。

图 5 介绍了 AsyncLazy < T > 略为简化的定义。 图 6 显示如何 AsyncLazy < T > 可以使用在一种类型内。

图 5 定义的 AsyncLazy < T >

// Provides support for asynchronous lazy initialization.
// This type is fully thread-safe.
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));
  }
  // Asynchronous infrastructure support.
// Permits instances of this type to be awaited directly.
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 > 的同步、 线程安全的一部分 上一篇: 行为­ior 由懒人 < T > 图层。 花时间阻塞是非常短的:每个线程只能等待了工厂方法来排队到线程池 ; 他们不要等待它执行。 一次任务 < T > 返回从工厂方法,然后懒 < T > 图层的工作就是结束。 同样的任务 < T > 每个等待共享的实例。 既不是异步的工厂方法,也不是异步的惰性初始化将过公开 T 的实例,直到其异步初始化已完成。 这样可以防止意外误操作的类型。

AsyncLazy < T > 是伟大的一种特殊:异步初始化的共享资源。 然而,它可以是尴尬,在其他方案中使用。 尤其是,如果一个服务实例需要异步的构造函数,您可以定义一个不会异步初始化的"内部"的服务类型和使用 AsyncLazy < T > 包装内"外"服务类型的内部实例。 但是,导致繁琐而冗长的代码,与根据相同的内部实例的所有方法。 在这种情况下,一个真正的"异步构造函数"会更优雅。

一个失足

我首选的解决方案之前,我想指出一个有些共同的失足。 当开发人员都面临着异步工作要做在一个构造函数 (不能是异步),此替代方法可能中的代码类似图 7

图 7 变通办法面对的构造函数中做异步工作时

class BadService
{
  public BadService()
  {
    InitializeAsync();
  }
  // BAD CODE!!
private async void InitializeAsync()
  {
    await Task.Delay(TimeSpan.FromSeconds(2));
    Answer = 42;
  }
  public int Answer { get; private set; }
}

但也有一些严重的问题,这种方法。 第一,没有办法告诉当初始化已完成 ; 第二,通常异步的无效方式,通常崩溃应用程序将处理从初始化任何异常。 如果 InitializeAsync 是而不是异步无效的异步任务,将几乎没有改善情况:仍然没有办法告诉当初始化完成,以及任何异常将被忽略。 有更好的方法 !

异步初始化模式

大多数基于反射的创建代码 (IoC/DI 框架、 Activator.CreateInstance 等等) 假定您的类型有一个构造函数,构造函数不能是异步的。 如果你在这种情况,让你不得不返回没有 (异步) 初始化一个实例。 异步初始化模式的目的是提供一种标准方式处理这种情况,减轻问题的未初始化的实例。

第一,我定义一个"标记"接口。 如果一种类型需要异步初始化,它实现了此接口:

/// <summary>
/// Marks a type as requiring asynchronous initialization and
/// provides the result of that initialization.
/// </summary>
public interface IAsyncInitialization
{
  /// <summary>
  /// The result of the asynchronous initialization of this instance.
/// </summary>
  Task Initialization { get; }
}

第一眼看着类型任务的属性感觉很奇怪。 我相信它是适当的不过,因为 (初始化实例) 的异步操作是实例级操作。 所以初始化属性属于该实例作为一个整体。

当我实现此接口时,我更愿意这样做与实际异步方法,我的名字 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),然后设置初始化属性。 该初始化属性提供 InitializeAsync 方法的结果:InitializeAsync 当完成时,初始化任务完成,并且如果有任何错误,通过初始化任务,将浮出水面。

当构造函数完成后时,在初始化可能还不会完成,所以使用代码一定要很小心。 使用该服务的代码有责任确保在调用任何其他方法之前初始化已完成。 下面的代码创建并初始化一个服务实例:

async Task<int> AnswerTimes2Async()
{
  var service = new UniversalAnswerService();
  // Danger!
The service is uninitialized here; "Answer" is 0!
await service.Initialization;
  // OK, the service is initialized and Answer is 42.
return service.Answer * 2;
}

在更现实的政府间海洋学委员会/DI 方案中,使用代码只获取执行 IUniversalAnswerService,一个实例,并已测试是否它实现了 IAsyncInitialization。 这是一个非常有用的技术 ; 它允许异步初始化该类型的实现细节。 例如,存根 (stub) 类型可能不会使用异步初始化 (除非你在实际测试使用代码将等待要进行初始化的服务)。 下面的代码是我的答案服务更切合实际使用:

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

在继续之前异步初始化模式,我要指出一个重要选择。 它是可以将服务成员公开为内部等待着他们自己的对象的初始化的异步方法。 图 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 服务,因为任何取决于初始化的成员必须公开为异步方法。 在前面的示例中,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,然后异步等待所有的初始化任务,要完成的任何。

与这些帮助器方法在的地方,创建复合服务是简单的。 在服务图 10 采用答案服务的两个实例作为依赖项,并计算其结果的平均值。

图 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; }
}

有几个重要的优点,在撰写服务时要牢记。 第一,因为异步初始化是实现细节,组成的服务不能知道是否其依赖项的任何需要异步初始化。 如果没有任何依赖关系需要异步初始化,然后既不会复合服务。 但是,因为它无法知道的复合服务必须声明本身作为需要异步初始化。

别太担心这 ; 性能影响 会有一些额外的内存分配对于异步结构,但该线程的行为将不以异步方式。 等待了一进来,每当代码等待任务已经完成的"快速通道"优化。 如果依赖关系的复合服务不需要异步初始化,传递给 Task.WhenAll 的序列为空,从而导致 Task.WhenAll 返回一个已完成的任务。 当这项任务正在等待 CompoundService.InitializeAsync 时,它不会产生执行,因为任务已完成。 在此方案中,InitializeAsync 同步,完成构造函数完成之前。

第二个外卖是重要的是要复合 InitializeAsync 返回之前初始化所有依赖项。 这可确保该复合类型的初始化是完全完成。 另外,错误处理是自然 — — 如果依赖的服务已初始化错误,这些错误会传播了从 EnsureInitializedAsync,造成的复合类型 InitializeAsync,同样的错误而失败。

最后的外卖是类型的复合服务不是类型的一种特殊。 它是服务的只是服务的一种服务,支持异步初始化,就像任何其他类型。 这些服务中的任何可以进行测试,是否支持或不支持异步初始化嘲弄。

总结

这篇文章中的模式可以适用于任何类型的应用程序 ; 在 ASP.NET 和控制台,以及使用 MVVM 应用程序中已经使用过。 我自己最喜欢异步建设模式是异步的工厂方法 ; 它非常简单,不能被滥用,消费的代码,因为它从来没有公开未初始化的实例。 然而,我也发现了异步初始化模式很有用在哪里我不能 (或不想) 的方案中工作时创建我自己的实例。 AsyncLazy < T > 模式也有它的地方,当有需要异步初始化的共享的资源。

异步服务模式就是比我早些时候在这个系列介绍的使用 MVVM 模式更成熟。 为异步数据绑定模式和异步命令的各种方法都是相当新的,他们肯定有改进的余地。 异步服务模式,相比之下,已使用更广泛。 然而,通常一些注意事项:这些模式并不是福音 ; 他们只是技术,我已经发现有用,想要分享。 如果您可以改善对他们或他们根据您应用程序的需要来定制,请去做吧 ! 我希望这些文章有帮助把你介绍给异步使用 MVVM 模式,和甚至更多,他们都鼓励你它们进行扩展和教科文组织统计研究所为探索自己的异步模式。

Stephen Cleary 生活在密歇根州北部,他是一位丈夫、父亲和程序员。他已从事了 16 年的多线程和异步编程工作,自第一个 CTP 以来便在使用 Microsoft .NET Framework 中的异步支持。他的主页(包括博客)位于 stephencleary.com

衷心感谢以下 Microsoft 技术专家对本文的审阅:James McCaffrey 和 Stephen Toub