Share via


非同期プログラミング

Unity インターセプトを使用して非同期メソッドをインターセプトする

Fernando Simonazzi
Grigori Melnik

コード サンプルをダウンロードする

Unity (Unity3D ゲーム エンジンと混同しないでください) は、汎用の拡張可能な依存関係挿入コンテナーで、すべての種類の Microsoft .NET Framework ベース アプリケーションで使用できるインターセプトをサポートします。Microsoft patterns & practices チーム (microsoft.com/practices) が設計および管理している Unity は、NuGet 経由でアプリケーションに簡単に追加できます。Unity 関連のさまざまな学習リソースについては、msdn.com/unity (英語) を参照してください。

今回は Unity のインターセプトに注目します。インターセプトとは、個々のオブジェクトの動作を変更するときに、同じクラスの他のオブジェクトの動作に影響を与えずに変更できる便利なテクニックです。このような変更は、Decorator パターンを使用しているときによく行われます (Decorator パターンの定義については、Wikipedia (http://ja.wikipedia.org/wiki/Decorator_%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3) を参照してください)。インターセプトは、実行時に新しい機能をオブジェクトに追加する柔軟な手法です。追加するのは、主に、ログ記録やデータ検証などの横断的な懸念事項に対処する機能です。インターセプトは、アスペクト指向プログラミング (AOP) の基盤となるメカニズムとしてよく使用されます。Unity の実行時インターセプト機能により、オブジェクトへのメソッド呼び出しを事実上インターセプトし、その呼び出しの前後で処理を実行します。

Unity コンテナーのインターセプトには、インターセプターとインターセプトの動作という 2 つの主要コンポーネントがあります。インターセプターとはインターセプトの対象となるオブジェクトに含まれるメソッドへの呼び出しをインターセプトするのに使用するメカニズムのことで、インターセプトの動作はインターセプトするメソッド呼び出しで実行するアクションを決定します。インターセプトの対象となるオブジェクトは、インターセプトの動作のパイプラインと共に提供します。メソッド呼び出しをインターセプトするときに、パイプライン内の各動作は、メソッド呼び出しのパラメーターの検査や変更を実行でき、最終的には元のメソッド実装を呼び出すことができます。元のメソッド実装の呼び出しから戻るときに、パイプライン内の各動作は、元の実装またはパイプライン内の前の動作から返される値やスローされる例外を検査したり置き換えたりできます。最終的には、本来の呼び出し元が、結果の戻り値 (もしあれば) や例外を受け取ります。図 1 に、このインターセプトのメカニズムを示します。

Unity Interception Mechanism図 1 Unity インターセプトのメカニズム

インターセプトのテクニックには、インスタンスのインターセプトと型のインターセプトの 2 種類があります。インスタンスのインターセプトでは、Unity はクライアントと対象オブジェクトの間に挿入するプロキシ オブジェクトを動的に作成します。挿入したプロキシ オブジェクトの役割は、クライアントが行った呼び出しを自身の動作を経由して対象オブジェクトに渡すことです。Unity のインスタンスのインターセプトを使用してインターセプトするオブジェクトは、Unity コンテナーが作成したものでも、コンテナー外で作成されたものでもかまいません。また、仮想メソッドでも非仮想メソッドでもインターセプトできます。ただし、動的に作成したプロキシ型を対象オブジェクトの型にキャストすることはできません。型のインターセプトでは、Unity は、横断的な懸念事項に対処する動作を含める新しい型を対象オブジェクトの型から派生して動的に作成します。Unity コンテナーは、派生型のオブジェクトのインスタンスを実行時に作成します。インスタンスのインターセプトは、パブリック インスタンス メソッドしかインターセプトできません。型のインターセプトは、パブリック仮想メソッドと保護された仮想メソッドの両方をインターセプトできます。プラットフォームの制約により、Unity インターセプトは Windows Phone と Windows ストアのアプリ開発はサポートしません。ただし、中核の Unity コンテナーはサポートします。

Unity の入門書については、『Dependency Injection with Unity』(Microsoft patterns & practices、2013 年) (amzn.to/16rfy0B、英語) を参照してください。Unity コンテナーでのインターセプトの詳細については、MSDN ライブラリの記事「Unity を使用したインターセプト」(bit.ly/1cWCnwM、英語) を参照してください。

タスク ベースの非同期パターン (TAP) の非同期メソッドをインターセプトする

インターセプトのメカニズムは十分シンプルですが、インターセプトするメソッドが Task オブジェクトを返す非同期操作を表す場合はどうなるでしょう。ある意味では実質何も変わりません。メソッドが呼び出され、値 (Task オブジェクト) を返したり、例外をスローするので、他のあらゆるメソッドと同様にインターセプトできます。しかし、おそらく開発者が興味を持つのは、非同期操作の結果を表す Task ではなく、実際の結果への対処でしょう。たとえば、Task の戻り値をログに記録したり、Task が生成する可能性のある例外を処理したりすることです。

さいわい、操作の結果を表す実際のオブジェクトがあるので、このような非同期パターンのインターセプトが比較的シンプルになります。他の非同期パターンのインターセプトはかなり複雑です。非同期プログラミング モデル (bit.ly/ICl8aH、英語) では、2 つのメソッドが 1 つの非同期操作を表します。一方、イベント ベースの非同期パターン (https://msdn.microsoft.com/ja-jp/library/ms228969(v=vs.110).aspx) では、操作を開始するメソッドと操作の完了を通知する関連イベントによって非同期操作が表されます。

非同期 TAP 操作のインターセプトを実現するには、メソッドが返す Task を、元の Task の完了後に必要なポストプロセスを実行する新しい Task に置き換えます。インターセプトされたメソッドの呼び出し元は、メソッドのシグネチャと一致する新しい Task を受け取り、インターセプトしたメソッドの実装の結果と、インターセプトの動作の追加処理の実行を監視します。

ここでは、TAP 非同期操作をインターセプトし、その非同期操作の完了をログ記録する基本的な方法のサンプル実装を開発します。このサンプルを利用して、非同期操作をインターセプトする独自の動作を作成できます。

簡単なケース

非ジェネリックの Task を返す非同期メソッドをインターセプトする簡単なケースから始めます。そのためには、インターセプトするメソッドが Task を返すことを検出し、検出した Task を適切にログ記録を行う新しい Task に置き換えることができる必要があります

出発点となるのは "何も操作を実行しない" インターセプト動作です (図 2 参照)。

図 2 簡単なインターセプト

 

public class LoggingAsynchronousOperationInterceptionBehavior 
  : IInterceptionBehavior
{
  public IMethodReturn Invoke(IMethodInvocation input,
    GetNextInterceptionBehaviorDelegate getNext)
  {
    // Execute the rest of the pipeline and get the return value
    IMethodReturn value = getNext()(input, getNext);
    return value;
  }
  #region additional interception behavior methods
  public IEnumerable<Type> GetRequiredInterfaces()
  {
    return Type.EmptyTypes;
  }
  public bool WillExecute
  {
    get { return true; }
  }
  #endregion
}

次に、Task を返すメソッドを検出して、返された Task を、出力をログ記録する新しいラッパー Task に置き換えるコードを追加します。これを実現するには、入力オブジェクトの CreateMethodReturn を呼び出して新しい IMethodReturn オブジェクトを作成します。この新しいオブジェクトは、インターセプト動作の新しい CreateWrapperTask メソッドが作成するラッパー Task を表します (図 3 参照)。

図 3 Task を返す

public IMethodReturn Invoke(IMethodInvocation input,
  GetNextInterceptionBehaviorDelegate getNext)
{
  // Execute the rest of the pipeline and get the return value
  IMethodReturn value = getNext()(input, getNext);
  // Deal with tasks, if needed
  var method = input.MethodBase as MethodInfo;
  if (value.ReturnValue != null
    && method != null
    && typeof(Task) == method.ReturnType)
  {
    // If this method returns a Task, override the original return value
    var task = (Task)value.ReturnValue;
    return input.CreateMethodReturn(this.CreateWrapperTask(task, input),
      value.Outputs);
  }
  return value;
}

新しい CreateWrapperTask メソッドは、元の Task の完了を待機し、その結果をログに記録する Task を返します (図 4 参照)。Task で例外が発生した場合、このメソッドはログ記録後にその例外を再スローします。この実装では、元の Task の結果は変更していませんが、別の動作を用意して、元の Task が発生するかもしれない例外を置き換えたり、無視することもできます。

図 4 結果をログに記録する

private async Task CreateWrapperTask(Task task,
  IMethodInvocation input)
{
  try
  {
    await task.ConfigureAwait(false);
    Trace.TraceInformation("Successfully finished async operation {0}",
      input.MethodBase.Name);
  }
  catch (Exception e)
  {
    Trace.TraceWarning("Async operation {0} threw: {1}",
      input.MethodBase.Name, e);
    throw;
  }
}

ジェネリックを処理する

Task<T> を返すメソッドを処理する場合はやや複雑になります。パフォーマンスへの影響を避ける場合は特に複雑になります。"T" の内容は今のところ問題にしないで、既にわかっているものとします。図 5 に示すように、C# 5.0 で利用可能な非同期言語機能を利用して、既知の T の Task<T> を処理するジェネリック メソッドを作成できます。

図 5 Task<T> を処理するジェネリック メソッド

private async Task<T> CreateGenericWrapperTask<T>(Task<T> task,
  IMethodInvocation input)
{
  try
  {
    T value = await task.ConfigureAwait(false);
    Trace.TraceInformation("Successfully finished async operation {0} with value: {1}",
      input.MethodBase.Name, value);
    return value;
  }
  catch (Exception e)
  {
    Trace.TraceWarning("Async operation {0} threw: {1}", input.MethodBase.Name, e);
    throw;
  }
}

簡単なケースと同様、メソッドは元の動作を変更しないでログに記録するだけです。しかし、今回はラップした Task が値を返すようになるので、インターセプトの動作では必要に応じて返される値を置き換えることもできます。

どうすれば、このメソッドを呼び出して、置き換える Task を手に入れることができるでしょう。それにはリフレクションを用いる必要があります。つまり、インターセプトしたメソッドのジェネリック戻り値の型から T を抽出し、この T 用にこのジェネリック メソッドのクローズ バージョンを作成して、そのメソッドからデリゲートを作成し、最終的にそのデリゲートを呼び出します。このプロセスは非常に高い負荷がかかる場合があるため、これらのデリゲートをキャッシュすることをお勧めします。T がメソッド シグネチャの一部の場合、T が既知でなければメソッドのデリゲートを作成して呼び出すことはできません。そのため先ほどのメソッドを 2 つのメソッドに分割します。1 つは目的のシグネチャを持つメソッドで、もう 1 つは C# 言語機能のメリットを活かすメソッドです (図 6 参照)。

図 6 デリゲート作成メソッドを分割する

private Task CreateGenericWrapperTask<T>(Task task, IMethodInvocation input)
{
  return this.DoCreateGenericWrapperTask<T>((Task<T>)task, input);
}
private async Task<T> DoCreateGenericWrapperTask<T>(Task<T> task,
  IMethodInvocation input)
{
  try
  {
    T value = await task.ConfigureAwait(false);
    Trace.TraceInformation("Successfully finished async operation {0} with value: {1}",
      input.MethodBase.Name, value);
    return value;
  }
  catch (Exception e)
  {
    Trace.TraceWarning("Async operation {0} threw: {1}", input.MethodBase.Name, e);
    throw;
  }
}

次に、インターセプト メソッドを変更して、元の Task をラップする正しいデリゲートを使用します。ここでは、想定する Task 型を渡して新しい GetWrapperCreator メソッドを呼び出すことで、このデリゲートを取得します。非ジェネリックの Task に対しても特別なケースは必要なく、ジェネリックの Task<T> とまったく同じデリゲート手法を当てはめることができます。図 7 に、新しい Invoke メソッドを示します。

図 7 新しい Invoke メソッド

public IMethodReturn Invoke(IMethodInvocation input,
  GetNextInterceptionBehaviorDelegate getNext)
{
  IMethodReturn value = getNext()(input, getNext);
  var method = input.MethodBase as MethodInfo;
  if (value.ReturnValue != null
    && method != null
    && typeof(Task).IsAssignableFrom(method.ReturnType))
  {
    // If this method returns a Task, override the original return value
    var task = (Task)value.ReturnValue;
    return input.CreateMethodReturn(
      this.GetWrapperCreator(method.ReturnType)(task, input), value.Outputs);
  }
  return value;
}

残っているのは、GetWrapperCreator メソッドを実装することだけです。このメソッドは、負荷の高いリフレクション呼び出しを実行してデリゲートを作成し、ConcurrentDictionary を使用してそのデリゲートをキャッシュします。これらのラッパー クリエーター デリゲートの型は Func<Task, IMethodInvocation, Task> です。非同期メソッドを開始するための呼び出しを表す IMethodInvocation オブジェクトと元の Task を取得し、ラッパー Task を返します。これを図 8 に示します。

図 8 GetWrapperCreator メソッドを実装する

private readonly ConcurrentDictionary<Type, Func<Task, IMethodInvocation, Task>>
  wrapperCreators = new ConcurrentDictionary<Type, Func<Task,
  IMethodInvocation, Task>>();
private Func<Task, IMethodInvocation, Task> GetWrapperCreator(Type taskType)
{
  return this.wrapperCreators.GetOrAdd(
    taskType,
    (Type t) =>
    {
      if (t == typeof(Task))
      {
        return this.CreateWrapperTask;
      }
      else if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Task<>))
      {
        return (Func<Task, IMethodInvocation, Task>)this.GetType()
          .GetMethod("CreateGenericWrapperTask",
             BindingFlags.Instance | BindingFlags.NonPublic)
          .MakeGenericMethod(new Type[] { t.GenericTypeArguments[0] })
          .CreateDelegate(typeof(Func<Task, IMethodInvocation, Task>), this);
      }
      else
      {
        // Other cases are not supported
        return (task, _) => task;
      }
    });
}

非ジェネリック Task の場合、リフレクションは必要なく、既存の非ジェネリック メソッドを目的のデリゲートとしてそのまま使用できます。Task<T> を処理するときは、必要なリフレクション呼び出しを実行して、対応するデリゲートを作成します。最終的には、作成方法がわからないと他の Task 型をサポートできないので、元の Task を返すだけの "何も処理を行わない" デリゲートが返されます。

これでこのインターセプト動作を、インターセプトするオブジェクトで使用できるようになります。値が返される場合や例外がスローされる場合に、インターセプトしたオブジェクトのメソッドが返す Task の結果がログに記録されます。図 9 の例は、オブジェクトをインターセプトして、この新しいインターセプト動作を使用するためのコンテナーの構成方法と、異なるメソッドを呼び出したときの結果出力を示しています。

図 9 オブジェクトをインターセプトして新しいインターセプト動作を使用するようにコンテナーを構成する

using (var container = new UnityContainer())
{
  container.AddNewExtension<Interception>();
  container.RegisterType<ITestObject, TestObject>(
    new Interceptor<InterfaceInterceptor>(),
    new InterceptionBehavior<LoggingAsynchronousOperationInterceptionBehavior>());
  var instance = container.Resolve<ITestObject>();
  await instance.DoStuffAsync("test");
  // Do some other work
}

Output:
vstest.executionengine.x86.exe Information: 0 : ­
  Successfully finished async operation ­DoStuffAsync with value: test
vstest.executionengine.x86.exe Warning: 0 : ­
  Async operation DoStuffAsync threw: ­
    System.InvalidOperationException: invalid
   at AsyncInterception.Tests.AsyncBehaviorTests2.TestObject.<­
     DoStuffAsync>d__38.MoveNext() in d:\dev\interceptiontask\­
       AsyncInterception\­AsyncInterception.Tests\­
         AsyncBehaviorTests2.cs:line 501
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(­Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.­
     HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at AsyncInterception.LoggingAsynchronousOperationInterceptionBehavior.<­
     CreateWrapperTask>d__3.MoveNext() in d:\dev\interceptiontask\­
       AsyncInterception\AsyncInterception\­
         LoggingAsynchronousOperationInterceptionBehavior.cs:line 63

追跡に対応する

図 9 の結果出力からわかるように、この実装で使用する手法では、Task の待機時に例外が再スローされる方法を反映して、例外のスタック トレースにやや違いが出ます。この問題を回避する代替の手法として、await キーワードの代わりに ContinueWith メソッドと TaskCompletionSource<T> を使用します (図 10 参照)。この手法は実装が複雑になり、負荷が高くなる可能性があります。

図 10 Await キーワードの代わりに ContinueWith を使用する

 

private Task CreateWrapperTask(Task task, IMethodInvocation input)
{
  var tcs = new TaskCompletionSource<bool>();
  task.ContinueWith(
    t =>
    {
      if (t.IsFaulted)
      {
        var e = t.Exception.InnerException;
        Trace.TraceWarning("Async operation {0} threw: {1}",
          input.MethodBase.Name, e);
        tcs.SetException(e);
      }
      else if (t.IsCanceled)
      {
        tcs.SetCanceled();
      }
      else
      {
        Trace.TraceInformation("Successfully finished async operation {0}",
          input.MethodBase.Name);
        tcs.SetResult(true);
      }
    },
    TaskContinuationOptions.ExecuteSynchronously);
  return tcs.Task;
}

まとめ

今回は、非同期メソッドをインターセプトするためのいくつかの手法を説明し、非同期操作の完了をログに記録する例を使ってその手法をデモしました。このサンプルを利用して、非同期操作をサポートする独自のインターセプト動作を作成できます。例で使用したソース コード一式は msdn.microsoft.com/magazine/msdnmag0214 から入手できます。

Fernando Simonazzi はソフトウェア開発者兼アーキテクトで、プロフェッショナルとして 15 年以上のキャリアがあります。彼は、Enterprise Library、Unity、CQRS Journey、Prism の複数のリリースなど、Microsoft patterns & practices のプロジェクトに貢献しています。Simonazzi は Clarius Consulting の共同経営者でもあります。

Dr. Grigori Melnik は、Microsoft patterns & practices チームの主席プログラム マネージャーです。最近は、Microsoft Enterprise Library、Unity、CQRS Journey、および NUI パターンのプロジェクトを担当しています。その前は、はるか昔の Fortran でプログラミングを行っていた時代から研究員やソフトウェア エンジニアを務めていました。Dr. Melnik のブログは blogs.msdn.com/agile (英語) です。

この記事のレビューに協力してくれた技術スタッフの Stephen Toub (マイクロソフト) に心より感謝いたします。