Compartir a través de


Programación asincrónica

Intercepción de métodos asincrónicos con la intercepción de Unity

Fernando Simonazzi
Grigori Melnik

Descargar el ejemplo de código

Unity (no confundir con el motor de juego Unity3D) es un contenedor de inserción de dependencias de uso general y extensible, con compatibilidad para su uso con cualquier aplicación basada en Microsoft .NET Framework. El equipo de modelos y prácticas de Microsoft diseñó y se encarga de mantener a Unity (microsoft.com/practices). Se puede agregar fácilmente a su aplicación a través de NuGet y encontrará el concentrador principal de los recursos de aprendizaje relacionados con Unity en msdn.com/unity.

Este artículo se enfoca en la intercepción de Unity. La intercepción es una técnica útil cuando se quiere modificar el comportamiento de objetos individuales sin afectar el comportamiento de los otros objetos de la misma clase, es bastante similar a lo que hace cuando usa el patrón Decorator (la definición que proporciona Wikipedia para el patrón Decorator se puede encontrar aquí: bit.ly/1gZZUQu). La intercepción proporciona un enfoque flexible para agregar comportamientos nuevos a un objeto en tiempo de ejecución. Por lo general, estos comportamientos abordan algunas inquietudes transversales, como el registro o la validación de datos. La intercepción se suele usar como el mecanismo subyacente para la programación orientada a aspectos (AOP). La característica de intercepción en tiempo de ejecución de Unity le permite interceptar de forma eficaz las llamadas de los métodos a los objetos y realizar un procesamiento previo y posterior de estas llamadas.

La intercepción en el contenedor de Unity tiene dos componentes principales: los interceptores y los comportamientos de la intercepción. Los interceptores determinan el mecanismo que se usa para interceptar las llamadas a los métodos en el objeto interceptado, mientras que los comportamientos de intercepción determinan las acciones que se realizan en las llamadas de métodos interceptadas. Un objeto interceptado se entrega con una canalización de comportamientos de intercepción. Cuando se intercepta a una llamada del método, cada comportamiento de la canalización puede inspeccionar e incluso modificar los parámetros de dicha llamada y, a la larga, se invoca a la implementación original del método. A su regreso, cada comportamiento puede inspeccionar o reemplazar los valores regresados, las excepciones que inicia la implementación original o el comportamiento anterior en la canalización. Finalmente, el autor original de la llamada obtiene el valor devuelto resultante, si lo hay, o la excepción resultante. La Figura 1 ilustra el mecanismo de intercepción.

Unity Interception MechanismFigura 1 Mecanismo de intercepción de Unity

Hay dos tipos de técnicas de intercepción: la intercepción de instancias y la intercepción de tipos. Con la intercepción de instancias, Unity crea de forma dinámica un objeto proxy que se inserta entre el cliente y el objeto de destino. El objeto proxy es el responsable de usar los comportamientos para pasar las llamadas que realiza el cliente al objeto de destino. Puede usar la intercepción de instancias de Unity para interceptar los objetos que se crean en el contenedor de Unity y los que se crean fuera de este. Además, puede usarla para interceptar métodos virtuales y no virtuales. Sin embargo, no puede convertir el tipo de proxy que se crea de forma dinámica al tipo del objeto de destino. Con la intercepción de tipo Unity crea de manera dinámica un nuevo tipo, que se deriva del tipo del objeto de destino y que incluye a los comportamientos que controlan las inquietudes transversales. El contenedor de Unity crea las instancias de los objetos del tipo derivado en tiempo de ejecución. La intercepción de instancias solo puede interceptar métodos de instancia públicos. La intercepción de tipos puede interceptar los métodos virtuales públicos y protegidos. Tenga en cuenta que, debido a las limitaciones de la plataforma, la intercepción de Unity no es compatible con el desarrollo de aplicaciones para Windows Phone y para la Tienda Windows, aunque esto sí es posible en el contenedor núcleo de Unity.

Para ver una introducción a Unity, consulte "Dependency Injection with Unity" (“Inserción de dependencias con Unity”) (modelos y prácticas de Microsoft, 2013) en amzn.to/16rfy0B. Para obtener más información sobre la intercepción en el contenedor de Unity, consulte el artículo de la MSDN Library, "Interception using Unity" ("Intercepción con Unity") en bit.ly/1cWCnwM.

Intercepción de los métodos asincrónicos del modelo asincrónico basado en tareas (TAP, por sus siglas en inglés)

El mecanismo de intercepción es bastante sencillo, pero ¿qué ocurre si el método que se intercepta representa una operación asincrónica que regresa un objeto Task? De cierta forma, no cambia nada: se invoca a un método y este regresa un valor (el objeto Task) o arroja una excepción, para que se pueda interceptar tal como cualquier otro método. Seguramente está más interesado en el resultado real la operación asincrónica que en la representación de la misma que realiza la tarea. Por ejemplo, tal vez quiera registrar el valor devuelto de la tarea o manipular cualquier excepción que este podría producir.

Afortunadamente, cuando un objeto real representa el resultado de la operación , la intercepción de este patrón asincrónico es relativamente sencilla. Otros patrones asincrónicos son un poco más difíciles de interceptar: En el modelo de programación asincrónica (bit.ly/ICl8aH) hay dos métodos que representan una operación asincrónica única, mientras que en el patrón asincrónico basado en eventos (bit.ly/19VdUWu) las operaciones asincrónicas se representan con un método para iniciar la operación y con un evento asociado para señalar su finalización.

Para lograr la intercepción de la operación TAP asincrónica, puede reemplazar la tarea que regresa el método por una nueva, que realiza el posprocesamiento necesario una vez que finaliza la tarea original. Los autores de la llamada del método interceptado recibirán la nueva tarea, que coincide con la firma del método, y observarán el resultado de la implementación del método interceptado, además de cualquier procesamiento adicional que realice el comportamiento de intercepción.

Desarrollaremos una implementación de muestra del enfoque básico para interceptar las operaciones asincrónicas TAP en las que se desea registrar la finalización de las operaciones asincrónicas. Puede adaptar esta muestra para crear sus propios comportamientos que puedan interceptar operaciones asincrónicas.

Caso sencillo

Comencemos con un caso sencillo: interceptar métodos asincrónicos que regresan una tarea no genérica. Tenemos que darnos cuenta de que el método interceptado regresa una tarea y la reemplaza por una nueva que realice el registro adecuado.

Podemos usar el comportamiento de intercepción “sin efecto” que se muestra en la Figura 2 como punto de partida.

Figura 2 Intercepción sencilla

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
}

A continuación, agregamos el código para detectar los métodos que devuelven tareas y para reemplazar la tarea que se devuelve por una tarea contenedora que registra el resultado. Para conseguirlo, se llama a CreateMethodReturn en el objeto de entrada para crear un nuevo objeto IMethodReturn, que representa a la tarea contenedora que crea el nuevo método CreateWrapperTask en el comportamiento, como se muestra en la Figura 3.

Figura 3 Devolución de una tarea

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

El nuevo método CreateWrapperTask devuelve una tarea que espera a que la tarea original finaliza y registre el resultado, como se aprecia en la Figura 4. Si el resultado de la tarea es una excepción, el método lo volverá a producir después de registrarlo. Tenga en cuenta que esta implementación no modifica el resultado original de la tarea, sino que un comportamiento diferente podría reemplazar o pasar por alto las excepciones que se podrían presentar en ella.

Figura 4 Registro del resultado

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

Cómo administrar los genéricos

Tratar con métodos que regresan Task<T> es un poco más complicado, en especial si quiere evitar un impacto en el rendimiento. Por ahora, no nos fijemos en el problema de averiguar qué es "T" y supongamos que ya sabemos. Como se aprecia en la Figura 5, podemos escribir un método genérico capaz de controlar Task<T> para un "T" conocido, gracias a las características del lenguaje asincrónico disponible en C# 5.0.

Figura 5 Un método genérico para controlar 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;
  }
}

Tal como en el caso sencillo, el método realiza el registro sin modificar el comportamiento original. Pero como ahora la tarea contenida regresa un valor, puede que el comportamiento lo reemplace de ser necesario.

 ¿Cómo podemos invocar a este método para obtener la tarea de reemplazo? Tenemos que recurrir a la reflexión y extraer T del tipo de devolución genérico del método que se ha interceptado, lo que crea una versión cerrada del método genérico para ese T y la creación de un delegado a partir del mismo y, finalmente, tenemos que invocar al delegado. Este proceso podría ser bastante costoso, por lo que es una buena idea almacenar a estos delegados en caché. Si T forma parte de la firma del método, no podremos crear el delegado de un método e invocarlo sin saber el significado de T, así que dividiremos el método anterior en dos: uno con la firma deseada y otro que se beneficie de las características del lenguaje C#, como se ve en la Figura 6.

Figura 6 División del método de creación de delegados

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

A continuación, cambiamos el método de intercepción para que el delegado adecuado encapsule a la tarea original, la que se obtiene al invocar al nuevo método GetWrapperCreator y pasar el tipo de tarea esperada. No necesitamos un caso especial para la tarea no genérica, ya que se puede adaptar al enfoque del delegado igual que el Task<T> genérico. La Figura 7 muestra la actualización del método Invoke.

Figura 7 La actualización del método 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;
}

Lo único que falta es implementar el método GetWrapperCreator. Este método llevará a cabo las costosas llamadas de reflexión para crear delegados y usará ConcurrentDictionary para almacenarlas en caché. Los delegados de este creador contenedor son de tipo Func<Task, IMethodInvocation, Task>; queremos que la tarea original y el objeto IMethodInvocation representen a la llamada de invocación para el método asincrónico y que regresen una tarea contenedora. Esto se muestra en la Figura 8.

Figura 8 Implementación del método 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;
      }
    });
}

Para el caso de la tarea no genérica, no se necesita la reflexión y se puede usar el método no genérico como delegado deseado. Cuando nos enfrentamos a Task<T>, se realizan las llamadas de reflexión necesarias para crear el delegado correspondiente. Finalmente, no podemos admitir otro tipo de tarea, ya que no sabríamos cómo crearla, por lo que se regresa un delegado sin efecto que regresa la tarea original.

Ahora se puede usar este comportamiento en un objeto interceptado. Registrará los resultados de las tareas que regresan los métodos del objeto interceptado, para los casos en los que se regrese un valor y para cuando se genere una excepción. El ejemplo de la Figura 9 muestra cómo se puede configurar un contenedor para que intercepte un objeto y para que use este nuevo comportamiento, y el resultado, cuando se invoquen diferentes métodos.

Figura 9 Configuración de un contenedor para que intercepte a un objeto y use el nuevo comportamiento

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

Cubrir las huellas

Como puede ver en el resultado de la Figura 9, el enfoque que se usa en esta implementación produce un ligero cambio en el seguimiento de pila de la excepción, esto refleja la forma en que la que se vuelve a producir la excepción cuando espera por la tarea. Para evitar este problema, un enfoque alternativo puede usar el método ContinueWith y TaskCompletionSource<T> en vez de la palabra clave await, esto supone tener una implementación más compleja (y posiblemente más cara) como la que se aprecia en la Figura 10.

Figura 10 Uso de ContinueWith en vez de la palabra Await

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

En resumen

Analizamos varias estrategias para interceptar los métodos asincrónicos y las demostramos en un ejemplo que registra la finalización de las operaciones asincrónicas. Puede adaptar esta muestra para crear sus propios comportamientos de intercepción que puedan admitir operaciones asincrónicas. El código fuente completo para el ejemplo está disponible en msdn.microsoft.com/magazine/msdnmag0214.

Fernando Simonazzi desarrollador de software y arquitecto con más de 15 años de experiencia profesional. Ha aportado en proyectos de modelos y prácticas de Microsoft, incluidas varias versiones de Enterprise Library, Unity, CQRS Journey y Prism. Simonazzi también es asociado en Clarius Consulting.

El Dr. Grigori Melnik es el administrador principal del programa en el equipo de modelos y prácticas en Microsoft. Hoy por hoy impulsa los proyectos de Microsoft Enterprise Library, Unity, CQRS Journey y patrones de NUI. Antes de eso, fue investigador e ingeniero en software lo bastante como para recordar la felicidad de programar en Fortran. El Dr. Melnik mantiene un blog en blogs.msdn.com/agile.

Gracias al siguiente experto técnico por su ayuda en la revisión de este artículo: Stephen Toub (Microsoft)