Asynchrone Programmierung

Abfangen von asynchronen Methoden mithilfe der Unity-Abfangfunktion

Fernando Simonazzi
Grigori Melnik

Codebeispiel herunterladen

Unity (nicht zu verwechseln mit der Unity3D Game Engine) ist ein allgemeiner, erweiterbarer Container für die Injektion von Abhängigkeiten mit Abfangunterstützung zur Verwendung in allen denkbaren Microsoft .NET Framework-basierten Anwendungen. Unity wurde vom Microsoft Patterns & Practices-Team entwickelt und wird von diesem verwaltet (microsoft.com/practices). Sie können Unity Ihrer Anwendung einfach über NuGet hinzufügen. Die zentralen Lernressourcen zu Unity finden Sie unter msdn.com/unity.

Dieser Artikel konzentriert sich auf die Unity-Abfangfunktion. Die Abfangfunktion erweist sich als ein nützliches Verfahren, wenn Sie das Verhalten von individuellen Objekten ändern möchten, ohne das Verhalten von anderen Objekten derselben Klasse zu beeinträchtigen. Diese Funktion ist ähnlich der Verwendung des Decorator-Musters (die Wikipedia-Definition des Decorator-Musters finden Sie unter: bit.ly/1gZZUQu). Die Abfangfunktion bietet eine flexible Methode, um einem Objekt zur Laufzeit neue Verhaltensweisen hinzuzufügen. Diese Verhaltensweisen sprechen normalerweise einige Cross-Cutting Concerns (querschnittliche Belange) an, wie beispielsweise die Protokollierung oder Datenüberprüfung. Die Abfangfunktion wird häufig als zugrunde liegender Mechanismus für die aspektorientierte Programmierung (AOP) verwendet. Das Laufzeitabfangfeature in Unity ermöglicht es Ihnen, Methodenaufrufe von Objekten effektiv abzufangen und eine Vor- und Nachbearbeitung dieser Aufrufe durchzuführen.

Die Abfangfunktion im Unity-Container verfügt über zwei Hauptkomponenten: Interceptoren und Abfangverhaltensweisen. Interceptoren bestimmen den Mechanismus, mit dem die Aufrufe der Methoden im abgefangenen Objekt abgefangen werden, und die Abfangverhaltensweisen bestimmen die Aktionen, die für die abgefangenen Methodenaufrufe durchgeführt werden. Ein abgefangenes Objekt wird mit einer Pipeline der Abfangverhaltensweisen bereitgestellt. Wenn ein Methodenaufruf abgefangen wird, kann das jeweilige Verhalten in der Pipeline die Parameter des Methodenaufrufs überprüfen und sogar ändern, und schließlich wird die ursprüngliche Methodenimplementierung aufgerufen. Bei der Rückgabe kann das jeweilige Verhalten die zurückgegebenen Werte oder die Ausnahmen überprüfen oder ersetzen, die von der ursprünglichen Implementierung oder dem vorherigen Verhalten in der Pipeline ausgelöst wurden. Am Ende erhält der ursprüngliche Aufrufer den resultierenden Rückgabewert, sofern verfügbar, oder die resultierende Ausnahme. In Abbildung 1 wird der Abfangmechanismus dargestellt.

Unity Interception MechanismAbbildung 1: Unity-Abfangmechanismus

Es gibt zwei Arten von Abfangtechniken: Abfangen von Instanzen und Abfangen von Typen. Bei der Instanzabfangfunktion erstellt Unity dynamisch ein Proxyobjekt, das zwischen dem Client und dem Zielobjekt eingefügt wird. Das Proxyobjekt ist dann zuständig für die Übergabe der vom Client durchgeführten Aufrufe an das Zielobjekt. Die Übergabe erfolgt über die Verhaltensweisen. Sie können die Unity-Instanzabfangfunktion verwenden, um die Objekte abzufangen, die sowohl vom Unity-Container als auch außerhalb des Containers erstellt werden. Darüber hinaus können Sie mit der Instanzabfangfunktion virtuelle und nicht virtuelle Methoden abfangen. Sie können jedoch den Typ des dynamisch erstellten Proxys nicht in den Zielobjekttyp umwandeln. Bei der Typabfangfunktion erstellt Unity dynamisch einen neuen Typ, der vom Zielobjekttyp abgeleitet wird und die Verhaltensweisen enthält, die Cross-Cutting Concerns behandeln. Der Unity-Container instanziiert Objekte des abgeleiteten Typs zur Laufzeit. Mit der Instanzabfangfunktion können nur öffentliche Instanzmethoden abgefangen werden. Die Typabfangfunktion kann sowohl öffentliche als auch geschützte virtuelle Methoden abfangen. Bedenken Sie, dass die Unity-Abfangfunktion aufgrund von Plattformeinschränkungen die Entwicklung von Windows Phone- und Windows Store-Apps nicht unterstützt. Allerdings bietet der Unity-Hauptcontainer diese Unterstützung.

Eine Einführung in Unity finden Sie im Artikel zur Abhängigkeitsinjektion mit Unity (Microsoft Patterns & Practices, 2013) unter amzn.to/16rfy0B. Weitere Informationen zur Abfangfunktion im Unity-Container finden Sie im MSDN-Bibliotheksartikel „Abfangen mithilfe von Unity“ unter bit.ly/1cWCnwM.

Abfangen von asynchronen TAP(Task-Based Asynchronous Pattern)-Methoden

Der Abfangmechanismus ist eigentlich ganz einfach. Aber was passiert, wenn die abgefangene Methode eine asynchrone Operation darstellt, die ein Task-Objekt zurückgibt? Eigentlich ändert sich nichts: Eine Methode wird aufgerufen und gibt einen Wert (das Task-Objekt) zurück oder löst eine Ausnahme aus. Daher kann sie genauso wie jede andere Methode abgefangen werden. Aber Sie sind wahrscheinlich eher an dem tatsächlichen Ergebnis der asynchronen Operation als am Task-Objekt interessiert, das diese darstellt. Sie möchten beispielsweise den Rückgabewert des Task-Objekts protokollieren oder eine beliebige Ausnahme behandeln, die das Task-Objekt möglicherweise auslöst.

Wenn ein tatsächliches Objekt das Ergebnis der Operation darstellt, ist das Abfangen dieses asynchronen Musters glücklicherweise relativ einfach. Andere asynchrone Muster lassen sich etwas schwerer abfangen: Im asynchronen Programmiermodell (bit.ly/ICl8aH) stellen zwei Methoden eine einzelne asynchrone Operation dar, und beim ereignisbasierten asynchronen Muster (bit.ly/19VdUWu) werden asynchrone Operationen durch eine Methode dargestellt, um die Operation und ein verknüpftes Ereignis zu initiieren, das den Abschluss signalisiert.

Damit das Abfangen der asynchronen TAP-Operation erfolgen kann, können Sie das von der Methode zurückgegebene Task-Objekt durch ein neues Task-Objekt ersetzen, das die erforderliche Nachbearbeitung nach Abschluss des ursprünglichen Task-Objekts durchführt. Aufrufer der abgefangenen Methode empfangen das neue Task-Objekt, das der Signatur der Methode entspricht, und überwachen das Ergebnis der Implementierung der abgefangenen Methode sowie eine beliebige zusätzliche Verarbeitung, die vom Abfangverhalten durchgeführt wird.

Wir entwickeln eine Beispielimplementierung der Standardmethode zum Abfangen von asynchronen TAP-Operationen, in der wir den Abschluss asynchroner Prozesse protokollieren möchten. Sie können dieses Beispiel anpassen, um Ihre eigenen Verhaltensweisen zu erstellen, die asynchrone Operationen abfangen können.

Einfacher Fall

Beginnen wir mit einem einfachen Fall: Abfangen von asynchronen Methoden, die ein nicht generisches Task-Objekt zurückgeben. Wir müssen erkennen können, ob die abgefangene Methode ein Task-Objekt zurückgibt, und dieses Task-Objekt durch ein neues ersetzen, das die entsprechende Protokollierung vornimmt.

Wir können das in Abbildung 2 dargestellte Abfangverhalten der „leeren Methode“ als Ausgangspunkt nehmen.

Abbildung 2: Einfache Abfangfunktion

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
}

Als Nächstes fügen wir den Code hinzu, um die Methoden, die das Task-Objekt zurückgeben, zu erkennen und das zurückgegebene Task-Objekt durch ein neues Task-Wrapperobjekt zu ersetzen, das das Ergebnis protokolliert. Hierzu wird die CreateMethodReturn-Methode für das Eingabeobjekt aufgerufen, um ein neues IMethodReturn-Objekt zu erstellen. Dieses Objekt stellt ein Task-Wrapperobjekt dar, das von der neuen CreateWrapperTask-Methode im Verhalten erstellt wird, wie in Abbildung 3 angegeben ist.

Abbildung 3: Zurückgeben eines Task-Objekts

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

Die neue CreateWrapperTask-Methode gibt ein Task-Objekt zurück, das auf den Abschluss des ursprünglichen Task-Objekts wartet und dessen Ergebnis protokolliert, wie in Abbildung 4 dargestellt. Wenn das Task-Objekt eine Ausnahme zur Folge hat, wird sie nach dem Protokollieren von der Methode erneut ausgelöst. Diese Implementierung ändert nicht das Ergebnis des ursprünglichen Task-Objekts, jedoch kann ein unterschiedliches Verhalten die Ausnahmen ersetzen oder ignorieren, die das ursprüngliche Task-Objekt möglicherweise initialisiert.

Abbildung 4: Protokollieren des Ergebnisses

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

Umgang mit Generika

Der Umgang mit Methoden, die „Task<T>“ zurückgeben, ist komplizierter, besonders wenn Sie Leistungseinbußen vermeiden möchten. Aber versuchen wir erst einmal nicht herauszufinden, was „T“ ist, sondern gehen davon aus, dass es bereits bekannt ist. Wie aus Abbildung 5 ersichtlich ist, können wir eine generische Methode schreiben, die „Task<T>“ als bekanntes „T“ behandeln kann. Hierzu können wir die in C# 5.0 verfügbaren asynchronen Sprachfeatures nutzen.

Abbildung 5: Eine generische Methode zum Behandeln von „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;
  }
}

Genau wie beim einfachen Fall zeichnet diese Methode einfach auf, ohne das ursprüngliche Verhalten zu ändern. Da jedoch das eingebundene Task-Objekt nun einen Wert zurückgibt, könnte das Verhalten diesen Wert auch gegebenenfalls ersetzen.

 Wie können wir diese Methode aufrufen, um das Task-Ersatzobjekt zu erhalten? Wir müssen auf die Reflektion zurückgreifen. Hierbei extrahieren wir das „T“ aus dem generischen Rückgabetyp der abgefangenen Methode, erstellen eine geschlossene Version dieser generischen Methode für dieses „T“, erstellen hieraus einen Delegaten und rufen zum Schluss den Delegaten auf. Dieser Prozess kann sehr aufwendig sein, daher empfiehlt es sich, diese Delegaten zwischenzuspeichern. Wenn das „T“ Bestandteil der Methodensignatur ist, könnten wir einen Delegaten einer Methode nicht erstellen und aufrufen, ohne das „T“ zu kennen. Daher teilen wir unsere vorherige Methode in zwei Methoden auf: Eine mit der gewünschten Signatur und eine, die von den C#-Sprachfeatures profitiert, wie in Abbildung 6 dargestellt.

Abbildung 6: Aufteilen der Erstellungsmethode eines Delegaten

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

Als Nächstes ändern wir die Abfangmethode. Also verwenden wir den richtigen Delegaten, um das ursprüngliche Task-Objekt einzubinden, das wir erhalten, indem wir die neue GetWrapperCreator-Methode aufrufen und den erwarteten Task-Typ übergeben. Wir benötigen keinen speziellen Fall für das nicht generische Task-Objekt, da es für die Delegatmethode genau wie für den generischen „Task<T>“ geeignet ist. Abbildung 7 zeigt die aktualisierte Invoke-Methode.

Abbildung 7: Die aktualisierte Invoke-Methode

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

Zum Schluss muss lediglich die GetWrapperCreator-Methode implementiert werden. Diese Methode führt die aufwendigen Reflektionsaufrufe durch, um die Delegaten zu erstellen, und verwendet eine ConcurrentDictionary-Klasse, um diese zwischenzuspeichern. Diese Wrapper-Erstellerdelegaten sind vom Typ „Func<Task, IMethodInvocation, Task>“. Wir möchten das ursprüngliche Task-Objekt und das IMethodInvocation-Objekt abrufen, das den Aufruf der asynchronen Methode darstellt und ein Task-Wrapperobjekt zurückgibt. Dies wird in Abbildung 8 gezeigt.

Abbildung 8: Implementieren der GetWrapperCreator-Methode

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

Für den Fall des nicht generischen Task-Objekts ist keine Reflektion erforderlich, und die vorhandene nicht generische Methode kann unverändert als gewünschter Delegat verwendet werden. Beim Umgang mit „Task<T>“ werden die erforderlichen Reflektionsaufrufe durchgeführt, um den entsprechenden Delegaten zu erstellen. Letztendlich können wir keinen anderen Task-Typ unterstützen, da wir nicht wissen, wie er erstellt wird. Daher wird ein leerer Methodendelegat zurückgegeben, der soeben das ursprüngliche Task-Objekt zurückgegeben hat.

Dieses Verhalten kann nun für ein abgefangenes Objekt verwendet werden und zeichnet die Ergebnisse der Task-Objekte auf, die von den Methoden des abgefangenen Objekts für die Fälle zurückgegeben werden, bei denen ein Wert zurückgegeben und eine Ausnahme ausgelöst wird. Das Beispiel in Abbildung 9 zeigt, wie ein Container zum Abfangen eines Objekts und zum Verwenden dieses neuen Verhaltens konfiguriert werden kann. Darüber hinaus wird das resultierende Ergebnis gezeigt, wenn unterschiedliche Methoden aufgerufen werden.

Abbildung 9: Konfigurieren eines Containers zum Abfangen eines Objekts und zum Verwenden des neuen Verhaltens

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

Verwischen unserer Spuren

Wie aus dem resultierenden Ergebnis in Abbildung 9 ersichtlich ist, führt die in dieser Implementierung verwendete Methode zu einer geringfügigen Änderung in der Stapelüberwachung der Ausnahme, was sich in der Art widerspiegelt, in der die Ausnahme beim Warten auf das Task-Objekt erneut ausgelöst wird. Alternativ können die ContinueWith-Methode und „TaskCompletionSource<T>“ anstatt des Await-Schlüsselworts verwendet werden, um dieses Probleme zu vermeiden. Dies geht jedoch auf Kosten einer komplexeren (und möglichen aufwendigeren) Implementierung, wie zum Beispiel in Abbildung 10 dargestellt ist.

Abbildung 10: Verwenden der ContinueWith-Methode anstatt des Await-Schlüsselworts

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

Zusammenfassung

Wir haben mehrere Strategien zum Abfangen von asynchronen Methoden besprochen und sie auf ein Beispiel angewendet, das den Abschluss asynchroner Operationen protokolliert. Sie können dieses Beispiel anpassen, um Ihre eigenen Abfangverhaltensweisen zu erstellen, die asynchrone Operationen unterstützen. Sie können den vollständigen Quellcode für das Beispiel unter msdn.microsoft.com/magazine/msdnmag0214 abrufen.

Fernando Simonazziist Softwareentwickler und -architekt mit mehr als 15 Jahren Erfahrung. Er hat an Patterns & Practices-Projekten von Microsoft mitgearbeitet. Dazu zählen verschiedene Versionen von Enterprise Library, Unity, CQRS Journey und Prism. Simonazzi ist auch ein Mitarbeiter bei Clarius Consulting.

Dr. Grigori Melnikist leitender Programm-Manager im Patterns & Practices-Team bei Microsoft. Im Moment kümmert er sich um die Microsoft Enterprise Library-, Unity-, CQRS Journey und NUI-Musterprojekte. Lange zuvor hat er als Forscher und Softwareingenieur noch die Freude gehabt, in Fortran zu programmieren. Dr. Melnik führt einen Blog unter blogs.msdn.com/agile.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Stephen Toub (Microsoft)