非同期プログラミング
非同期 MVVM アプリケーションのパターン: サービス
Stephen Cleary
この記事は、確立されたモデル - ビュー - ビューモデル (MVVM: Model-View-ViewModel) パターンと async/await の組み合わせに関する連載の 3 回目です。1 回目の記事では、非同期操作にデータ バインドする手法を説明しました。2 回目の記事では、非同期 ICommand の実装例をいくつか検討しました。今回はサービス層に焦点を当て、非同期サービスに取り組みます。
UI はまったく扱いません。実のところ、この記事で扱うパターンは、MVVM 固有のものではなく、どのような種類のアプリケーションにも同様に適用できます。前回までの記事で検討した非同期データ バインドとコマンド パターンは非常に新しいものですが、今回説明する非同期サービス パターンは比較的確立されたパターンです。ただし、確立されているパターンも、やはり単なるパターンです。
非同期インターフェイス
『オブジェクト指向における再利用のためのデザインパターン』(ソフトバンククリエイティブ、1999 年、30 ページ目) に「インタフェースに対してプログラミングするのであって、実装に対してプログラミングするのではない」とあるように、インターフェイスは適切なオブジェクト指向設計に不可欠な要素です。インターフェイスを使用すると、コードで具象型ではなく抽象型を使用できるようになり、単体テストのために接続できる "接合部" をコードに追加できます。しかし、非同期メソッドを備えたインターフェイスの作成は可能でしょうか。
答えはイエスです。次のコードでは、非同期メソッドを備えたインターフェイスを定義しています。
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;
}
}
単純すぎる例に思えるかもしれませんが、このコードは非同期メソッドに関する重要な教訓をいくつか示しています。
教訓 1: メソッドは待機不可能だが、型は待機可能である。式が待機可能かどうかを決定するのは、式の型です。具体的には、UseMyService.IsLargePageAsync は IMyService.DownloadAndCountBytesAsync の結果を待機します。インターフェイス メソッドには、async キーワードを付けません (付けることができません)。インターフェイス メソッドは Task を返し、タスクは待機可能なので、IsLargePageAsync には await を使用できます。
教訓 2: 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);
}
}
2 つ目のオーバーロードは、最初のオーバーロードを呼び出してタスクをそのまま返しているだけです。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 を使用していますが、非同期単体テストは他の多くの最新の単体テスト フレームワークでもサポートされています。単体テストがタスクを返すよう、async void 単体テスト メソッドは使用しないでください。多くの単体テスト フレームワークでは、async void 単体テスト メソッドはサポートされていません。
同期メソッドの単体テストでは、成功条件と失敗条件の両方についてコードの動作をテストすることが重要です。非同期メソッドには、さらに条件が増えます。非同期サービスでは、成功と例外のスロー (失敗) が、それぞれ同期の場合と非同期の場合に区別されます。このような組み合わせを 4 つともテストしてもかまいませんが、通常は、少なくとも非同期成功と非同期失敗をテストし、必要に応じて同期成功もテストすれば十分です。このような組み合わせを 4 つともテストしてもかまいませんが、通常は、少なくとも非同期成功と非同期失敗をテストし、必要に応じて同期成功もテストすれば十分です。一方、ほとんどの非同期操作では失敗が即座に発生することはないため、私見では、同期失敗テストは役立ちません。
この記事の執筆時点では、一部のよく使われるモックおよびスタブ作成フレームワークは、動作を変更しない限り default(T) を返します。非同期メソッドは null タスクを返さないため、既定のモック作成動作は非同期メソッドとうまく連携しません (msdn.microsoft.com/ja-jp/library/hh873175(v=vs.110).aspxで「タスク ベースの非同期パターン」を参照してください)。適切な既定の動作は、Task.FromResult(default(T)) を返すことです。これは、非同期コードの単体テストでよく発生する問題です。テスト中に予期しない NullReferenceExceptions が発生する場合は、タスクを返すすべてのメソッドをモック型に実装していることを確認します。今後、モックおよびスタブ作成フレームワークの非同期対応が進み、非同期メソッドに適した既定の動作が実装されることを願っています。
非同期ファクトリ
ここまでのパターンでは、非同期メソッドを備えたインターフェイスの定義方法、そのインターフェイスをサービスに実装する方法、およびテスト用スタブの定義方法を紹介しました。ほとんどの非同期サービスにはこれらの方法だけで十分ですが、使用前に非同期処理を実行する必要があるサービス実装の場合は、手段がぐっと複雑になります。では、非同期コンストラクターが必要な状況に対処する方法を説明しましょう。
コンストラクターを非同期にすることはできませんが、静的メソッドなら非同期にできます。非同期コンストラクターに見せかける方法の 1 つは、非同期ファクトリ メソッドの実装です (図 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 コンテナーを使用してサービスを挿入する場合は、別のアプローチが必要になります。
非同期リソース
場合によっては、共有リソースを初期化するために、非同期初期化が 1 回だけ必要になります。Stephen Toub は、AsyncLazy 型 (bit.ly/1cVC3nb、英語) を開発しました。この型は、私の AsyncEx ライブラリ (bit.ly/1iZBHOW、英語) の一部としても提供しています。AsyncLazy は、Lazy と Task を組み合わせたものです。具体的には、これは Lazy> という、非同期ファクトリ メソッドをサポートする Lazy 型です。Lazy 層では、ファクトリ メソッドが 1 回だけ実行されるようにする、スレッド セーフな遅延初期化を実現します。Task 層では、呼び出し元がファクトリ メソッドの完了を非同期に待機できるようにする、非同期をサポートします。
図 5 は、少し簡略化した AsyncLazy の定義を示しています。図 6は、型内で AsyncLazy を使用する方法を示しています。
図 5 AsyncLazy の定義
// 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 の使用
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;
}
}
このサービスでは、非同期に作成する必要がある共有 "リソース" を 1 つ定義しています。このサービスのすべてのインスタンスにおけるすべてのメソッドで、この共有リソースを利用し、直接待機することができます。AsyncLazy インスタンスを初めて待機するときは、スレッド プールのスレッドで非同期ファクトリ メソッドを 1 回開始します。別のスレッドからこの同じインスタンスに対して他にも同時アクセスを行っている場合は、非同期ファクトリ メソッドがスレッド プールのキューに登録されるまで待機してから、そのアクセスを実行します。
AsyncLazy の動作のうち、同期的でスレッド セーフな部分は Lazy 層で処理します。ブロックにかかる時間はわずかです。これは、各スレッドではスレッド プールのキューへのファクトリ メソッドの登録だけを待機し、ファクトリ メソッドの実行までは待機しないためです。ファクトリ メソッドから Task が返されれば、Lazy 層の役割は終了です。待機のたびに共有する Task インスタンスは、毎回同じです。非同期ファクトリ メソッドでも非同期遅延初期化でも、非同期初期化が完了するまでは T のインスタンスが公開されません。このため、意図しない型の誤用が防止されます。
AsyncLazy は、共有リソースの非同期初期化という 1 種類の問題に対しては非常に有用です。しかし、他のシナリオで使用する場合は不便なことがあります。具体的には、サービス インスタンスに非同期コンストラクターが必要な場合、非同期初期化を行う "内部" サービス型を定義し、AsyncLazy を使用して内部インスタンスを "外部" サービス型内にラップすることができます。しかし、このようにすると、すべてのメソッドが同じ内部インスタンスに依存する、厄介で面倒なコードが生まれます。このようなシナリオでは、本物の "非同期コンストラクター" を使用する方が洗練されています。
間違い
お勧めの解決策を紹介する前に、よくある間違いを指摘しておきましょう。開発者は、コンストラクター内で非同期処理を行う必要に迫られると (コンストラクターを非同期にすることはできません)、図 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; }
}
しかし、このアプローチにはいくつかの重大な問題があります。まず、初期化が完了した際に通知する手段がありません。また、初期化時の例外は通常の async void と同じように処理されるため、多くの場合はアプリケーションがクラッシュします。InitializeAsync を async void ではなく async Task にしても、状況はほとんど改善しません。初期化終了時の通知手段はないままで、例外はすべて暗黙的に無視されます。しかし、もっと良い方法があります。
非同期初期化パターン
ほとんどのリフレクションベースの作成コード (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; }
}
一見すると、Task 型のプロパティが奇妙に感じられるでしょうが、それも当然です。非同期操作 (インスタンスの初期化) はインスタンス レベルの操作だからです。そのため、Initialization プロパティはインスタンス全体を対象としています。
このインターフェイスを実装する際は、個人的な好みにより、名前付け規則に基づいて 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();
// 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;
}
より現実的な 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;
}
非同期初期化パターンの説明を続ける前に、重要な代替パターンを紹介しましょう。サービス メンバーは、そのメンバー自体のオブジェクトの初期化を内部で待機する非同期メソッドとして、公開可能です。図 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のサービスは、依存関係として応答サービスの 2 つのインスタンスを受け取り、これらの結果を平均します。
図 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; }
}
サービスを複合化する際は、いくつか重要な点を念頭に置く必要があります。1 つ目は、非同期初期化は実装の細部なので、非同期初期化が必要な依存関係があるかどうか複合サービスでは把握できないことです。非同期初期化が必要な依存関係がない場合は、複合サービスでも非同期初期化は不要です。しかし、この点を把握できないので、複合サービスではそのサービス自体に非同期初期化が必要であると宣言する必要があります。
この宣言がパフォーマンスに及ぼす影響については、それほど心配は要りません。非同期構造体用に多少追加でメモリが割り当てられますが、スレッドの非同期動作は発生しないからです。await には、完了済みタスクをコードで待機している場合に役立つ "高速パス" 最適化機能があります。複合サービスの依存関係に非同期初期化が必要ない場合、Task.WhenAll に渡されるシーケンスが空になるので、Task.WhenAll は完了済みタスクを返します。タスクを CompoundService.InitializeAsync で待機している場合は、タスクが完了済みなので実行が明け渡されません。このシナリオでは、コンストラクターが完了する前に、InitializeAsync が同期的に完了します。
2 つ目の重要な点は、複合 InitializeAsync が結果を返す前に、すべての依存関係を初期化することです。このようにすると、複合型の初期化が完全に完了します。また、エラー処理も自然になります。依存サービスに初期化エラーが発生している場合、エラーが EnsureInitializedAsync から伝達され、複合型の InitializeAsync も同じエラーで失敗するためです。
最後の重要な点は、複合サービスが特殊な型ではないことです。複合サービスは、非同期初期化をサポートしているだけで、他のサービスと同じです。どの複合サービスについても、モックを作成して、非同期初期化をサポートしているかどうかをテストできます。
まとめ
今回の記事で説明したパターンは、あらゆる種類のアプリケーションに応用できます。私はこれらのパターンを MVVM アプリケーション以外にも、ASP.NET やコンソールで使用したことがあります。お気に入りの非同期構築パターンは、非同期ファクトリ メソッドです。これは非常に簡潔です。しかも初期化されていないインスタンスをまったく公開しないため、使用側コードで誤用されることがありません。ただし、非同期初期化パターンは、独自のインスタンスを作成できない (または作成しない) シナリオでも非常に便利です。また、非同期初期化が必要な共有リソースがある場合は、AsyncLazy パターンが有効です。
非同期サービス パターンは、この連載のこれまでの記事で紹介した MVVM パターンよりも確立されています。非同期データ バインドのパターンと非同期コマンドの各種アプローチは、どちらもきわめて歴史が浅く、確かに改善の余地があります。一方、非同期サービス パターンは幅広く使用されていますが、やはりいつもの注意事項が当てはまります。非同期サービス パターンは、絶対的に正しいわけではありません。私が便利だと感じ、記事で紹介したいと思った手法にすぎません。パターンの改良や、アプリケーションのニーズに合わせたカスタマイズが可能な場合は、ぜひ実行してください。この連載が非同期 MVVM パターンの紹介として役立ち、ひいてはパターンを拡張したり独自の UI 非同期パターンを模索したりするきっかけになれば、さいわいです。
Stephen Clearyはミシガン北部在住の夫、父親兼プログラマです。彼は、マルチスレッドと非同期プログラミングに 16 年間取り組み、最初の CTP から Microsoft .NET Framework の非同期サポートを使ってきました。彼のホーム ページとブログは、stephencleary.com(英語) から利用できます。
この記事のレビューに協力してくれた技術スタッフの James McCaffrey と Stephen Toub に心より感謝いたします。