Filtros de llamadas de grano

Los filtros de llamadas de grano proporcionan un medio para interceptar llamadas de grano. Los filtros pueden ejecutar código antes y después de una llamada de grano. Se pueden instalar varios filtros simultáneamente. Los filtros son asincrónicos y pueden modificar RequestContext, argumentos y el valor devuelto del método que se invoca. Los filtros también pueden inspeccionar el objeto MethodInfo del método que se invoca en la clase de grano y pueden usarse para producir o controlar excepciones.

Estos son algunos ejemplos de uso de los filtros de llamadas de grano:

  • Autorización: un filtro puede inspeccionar el método que se invoca y los argumentos la información de autorización de RequestContext para determinar si se permitirá que la llamada continúe.
  • Registro o telemetría: un filtro puede registrar información y capturar datos de tiempo y otras estadísticas sobre la invocación del método.
  • Control de errores: un filtro puede interceptar las excepciones que genera una invocación de método y transformarlas en otra excepción, o bien controlar la excepción cuando se pasa por el filtro.

Los filtros son de dos tipos:

  • Filtros de llamadas entrantes
  • Filtros de llamadas salientes

Los filtros de llamadas entrantes se ejecutan cuando se recibe una llamada. Los filtros de llamadas salientes se ejecutan cuando se realiza una llamada.

Filtros de llamadas entrantes

Los filtros de llamadas de grano entrantes implementan la interfaz IIncomingGrainCallFilter, que tiene un método:

public interface IIncomingGrainCallFilter
{
    Task Invoke(IIncomingGrainCallContext context);
}

El argumento IIncomingGrainCallContext que se pasa al método Invoke tiene la forma siguiente:

public interface IIncomingGrainCallContext
{
    /// <summary>
    /// Gets the grain being invoked.
    /// </summary>
    IAddressable Grain { get; }

    /// <summary>
    /// Gets the <see cref="MethodInfo"/> for the interface method being invoked.
    /// </summary>
    MethodInfo InterfaceMethod { get; }

    /// <summary>
    /// Gets the <see cref="MethodInfo"/> for the implementation method being invoked.
    /// </summary>
    MethodInfo ImplementationMethod { get; }

    /// <summary>
    /// Gets the arguments for this method invocation.
    /// </summary>
    object[] Arguments { get; }

    /// <summary>
    /// Invokes the request.
    /// </summary>
    Task Invoke();

    /// <summary>
    /// Gets or sets the result.
    /// </summary>
    object Result { get; set; }
}

El método IIncomingGrainCallFilter.Invoke(IIncomingGrainCallContext) debe esperar o devolver el resultado de IIncomingGrainCallContext.Invoke() para ejecutar el siguiente filtro configurado y, finalmente, el propio método de grano. La propiedad Result se puede modificar después de esperar el método Invoke(). La propiedad ImplementationMethod devuelve el objeto MethodInfo de la clase de implementación. Para acceder al objeto MethodInfo del método de interfaz, se usa la propiedad InterfaceMethod. Los filtros de llamadas de grano se llaman para todas las llamadas de método a un grano, lo que incluye las llamadas a las extensiones de grano (implementaciones de IGrainExtension) que están instaladas en el grano. Por ejemplo, las extensiones de grano se usan para implementar secuencias y tokens de cancelación. Por lo tanto, debe esperarse que el valor de ImplementationMethod no siempre sea un método en la propia clase de grano.

Configuración de filtros de llamadas de grano entrantes

Las implementaciones de IIncomingGrainCallFilter pueden registrarse como filtros para todo el silo mediante la inserción de dependencias, o bien registrarse como filtros de nivel de grano mediante un grano que implementa IIncomingGrainCallFilter directamente.

Filtros de llamadas de grano para todo el silo

Un delegado puede registrarse como un filtro de llamadas de grano para todo el silo mediante la inserción de dependencias, como se indica a continuación:

siloHostBuilder.AddIncomingGrainCallFilter(async context =>
{
    // If the method being called is 'MyInterceptedMethod', then set a value
    // on the RequestContext which can then be read by other filters or the grain.
    if (string.Equals(
        context.InterfaceMethod.Name,
        nameof(IMyGrain.MyInterceptedMethod)))
    {
        RequestContext.Set(
            "intercepted value", "this value was added by the filter");
    }

    await context.Invoke();

    // If the grain method returned an int, set the result to double that value.
    if (context.Result is int resultValue)
    {
        context.Result = resultValue * 2;
    }
});

De forma similar, una clase puede registrarse como un filtro de llamadas de grano mediante el método auxiliar AddIncomingGrainCallFilter. Este es un ejemplo de un filtro de llamadas de grano que registra los resultados de cada método de grano:

public class LoggingCallFilter : IIncomingGrainCallFilter
{
    private readonly Logger _logger;

    public LoggingCallFilter(Factory<string, Logger> loggerFactory)
    {
        _logger = loggerFactory(nameof(LoggingCallFilter));
    }

    public async Task Invoke(IIncomingGrainCallContext context)
    {
        try
        {
            await context.Invoke();
            var msg = string.Format(
                "{0}.{1}({2}) returned value {3}",
                context.Grain.GetType(),
                context.InterfaceMethod.Name,
                string.Join(", ", context.Arguments),
                context.Result);
            _logger.Info(msg);
        }
        catch (Exception exception)
        {
            var msg = string.Format(
                "{0}.{1}({2}) threw an exception: {3}",
                context.Grain.GetType(),
                context.InterfaceMethod.Name,
                string.Join(", ", context.Arguments),
                exception);
            _logger.Info(msg);

            // If this exception is not re-thrown, it is considered to be
            // handled by this filter.
            throw;
        }
    }
}

A continuación, este filtro puede registrarse mediante el método de extensión AddIncomingGrainCallFilter:

siloHostBuilder.AddIncomingGrainCallFilter<LoggingCallFilter>();

Como alternativa, el filtro puede registrarse sin el método de extensión:

siloHostBuilder.ConfigureServices(
    services => services.AddSingleton<IIncomingGrainCallFilter, LoggingCallFilter>());

Filtros de llamadas de grano por grano

Una clase de grano puede registrarse como un filtro de llamada de grano y filtrar las llamadas que se realicen a él mediante la implementación de IIncomingGrainCallFilter de la manera siguiente:

public class MyFilteredGrain
    : Grain, IMyFilteredGrain, IIncomingGrainCallFilter
{
    public async Task Invoke(IIncomingGrainCallContext context)
    {
        await context.Invoke();

        // Change the result of the call from 7 to 38.
        if (string.Equals(
            context.InterfaceMethod.Name,
            nameof(this.GetFavoriteNumber)))
        {
            context.Result = 38;
        }
    }

    public Task<int> GetFavoriteNumber() => Task.FromResult(7);
}

En el ejemplo anterior, todas las llamadas al método GetFavoriteNumber devolverán 38 en lugar de 7, porque el filtro ha modificado el valor devuelto.

Otro caso de uso de los filtros es el control de acceso, como en este ejemplo:

[AttributeUsage(AttributeTargets.Method)]
public class AdminOnlyAttribute : Attribute { }

public class MyAccessControlledGrain
    : Grain, IMyFilteredGrain, IIncomingGrainCallFilter
{
    public Task Invoke(IIncomingGrainCallContext context)
    {
        // Check access conditions.
        var isAdminMethod =
            context.ImplementationMethod.GetCustomAttribute<AdminOnlyAttribute>();
        if (isAdminMethod && !(bool) RequestContext.Get("isAdmin"))
        {
            throw new AccessDeniedException(
                $"Only admins can access {context.ImplementationMethod.Name}!");
        }

        return context.Invoke();
    }

    [AdminOnly]
    public Task<int> SpecialAdminOnlyOperation() => Task.FromResult(7);
}

En el ejemplo anterior, solo se puede llamar al método SpecialAdminOnlyOperation si "isAdmin" se establece en true en RequestContext. De esta manera, se pueden usar filtros de llamadas de grano para la autorización. En este ejemplo, es responsabilidad del llamador asegurarse de que el valor "isAdmin" se establece correctamente y que la autenticación se realiza correctamente. Tenga en cuenta que el atributo [AdminOnly] se especifica en el método de la clase de grano. Esto se debe a que la propiedad ImplementationMethod devuelve el objeto MethodInfo de la implementación, no la interfaz. El filtro también puede comprobar la propiedad InterfaceMethod.

Ordenación del filtro de llamadas de grano

Los filtros de llamadas de grano siguen una ordenación definida:

  1. Implementaciones de IIncomingGrainCallFilter configuradas en el contenedor de inserción de dependencias, en el orden en el que se registran.
  2. Filtro de nivel de grano, si el grano implementa IIncomingGrainCallFilter.
  3. Implementación del método de grano o implementación del método de extensión de grano.

Cada llamada a IIncomingGrainCallContext.Invoke() encapsula el siguiente filtro definido para que cada filtro tenga la oportunidad de ejecutar código antes y después del siguiente filtro de la cadena y, finalmente, el propio método de grano.

Filtros de llamadas salientes

Los filtros de llamadas de grano salientes son similares a los filtros de llamadas de grano entrantes, con la diferencia principal de que se invocan en el llamador (cliente), en lugar del destinatario (grano).

Los filtros de llamadas de grano salientes implementan la interfaz IOutgoingGrainCallFilter, que tiene un método:

public interface IOutgoingGrainCallFilter
{
    Task Invoke(IOutgoingGrainCallContext context);
}

El argumento IOutgoingGrainCallContext que se pasa al método Invoke tiene la forma siguiente:

public interface IOutgoingGrainCallContext
{
    /// <summary>
    /// Gets the grain being invoked.
    /// </summary>
    IAddressable Grain { get; }

    /// <summary>
    /// Gets the <see cref="MethodInfo"/> for the interface method being invoked.
    /// </summary>
    MethodInfo InterfaceMethod { get; }

    /// <summary>
    /// Gets the arguments for this method invocation.
    /// </summary>
    object[] Arguments { get; }

    /// <summary>
    /// Invokes the request.
    /// </summary>
    Task Invoke();

    /// <summary>
    /// Gets or sets the result.
    /// </summary>
    object Result { get; set; }
}

El método IOutgoingGrainCallFilter.Invoke(IOutgoingGrainCallContext) debe esperar o devolver el resultado de IOutgoingGrainCallContext.Invoke() para ejecutar el siguiente filtro configurado y, finalmente, el propio método de grano. La propiedad Result se puede modificar después de esperar el método Invoke(). Para acceder al objeto MethodInfo del método de interfaz al que se llama, se usa la propiedad InterfaceMethod. Los filtros de llamadas de grano salientes se invocan para todas las llamadas de método a un grano, lo que incluye las llamadas a métodos del sistema que realiza Orleans.

Configuración de filtros de llamadas de grano salientes

Las implementaciones de IOutgoingGrainCallFilter pueden registrarse en silos y clientes mediante la inserción de dependencias.

Un delegado puede registrarse como un filtro de llamadas de la manera siguiente:

builder.AddOutgoingGrainCallFilter(async context =>
{
    // If the method being called is 'MyInterceptedMethod', then set a value
    // on the RequestContext which can then be read by other filters or the grain.
    if (string.Equals(
        context.InterfaceMethod.Name,
        nameof(IMyGrain.MyInterceptedMethod)))
    {
        RequestContext.Set(
            "intercepted value", "this value was added by the filter");
    }

    await context.Invoke();

    // If the grain method returned an int, set the result to double that value.
    if (context.Result is int resultValue)
    {
        context.Result = resultValue * 2;
    }
});

En el código anterior, builder puede ser una instancia de ISiloHostBuilder o IClientBuilder.

De forma similar, una clase puede registrarse como un filtro de llamadas de grano salientes. Este es un ejemplo de un filtro de llamadas de grano que registra los resultados de cada método de grano:

public class LoggingCallFilter : IOutgoingGrainCallFilter
{
    private readonly Logger _logger;

    public LoggingCallFilter(Factory<string, Logger> loggerFactory)
    {
        _logger = loggerFactory(nameof(LoggingCallFilter));
    }

    public async Task Invoke(IOutgoingGrainCallContext context)
    {
        try
        {
            await context.Invoke();
            var msg = string.Format(
                "{0}.{1}({2}) returned value {3}",
                context.Grain.GetType(),
                context.InterfaceMethod.Name,
                string.Join(", ", context.Arguments),
                context.Result);
            _logger.Info(msg);
        }
        catch (Exception exception)
        {
            var msg = string.Format(
                "{0}.{1}({2}) threw an exception: {3}",
                context.Grain.GetType(),
                context.InterfaceMethod.Name,
                string.Join(", ", context.Arguments),
                exception);
            this.log.Info(msg);

            // If this exception is not re-thrown, it is considered to be
            // handled by this filter.
            throw;
        }
    }
}

A continuación, este filtro puede registrarse mediante el método de extensión AddOutgoingGrainCallFilter:

builder.AddOutgoingGrainCallFilter<LoggingCallFilter>();

Como alternativa, el filtro puede registrarse sin el método de extensión:

builder.ConfigureServices(
    services => services.AddSingleton<IOutgoingGrainCallFilter, LoggingCallFilter>());

Al igual que en el ejemplo del filtro de llamadas de delegado, builder puede ser una instancia de ISiloHostBuilder o IClientBuilder.

Casos de uso

Conversión de excepciones

Cuando una excepción generada en el servidor se deserializa en el cliente, a veces se puede obtener la excepción siguiente en lugar de la real: TypeLoadException: Could not find Whatever.dll.

Esto sucede si el ensamblado que contiene la excepción no está disponible para el cliente. Por ejemplo, supongamos que usa Entity Framework en las implementaciones de grano; en este caso, puede producirse una excepción EntityException. Por otro lado, el cliente no hace referencia a EntityFramework.dll (ni debe hacerlo) porque no conoce la capa de acceso a datos subyacente.

Cuando el cliente intente deserializar EntityException, se producirá un error debido al archivo DLL que falta; como consecuencia, se producirá una excepción TypeLoadException que oculta la excepción EntityException original.

Podría argumentarse que esto es correcto, ya que el cliente nunca controlaría la excepción EntityException; de lo contrario, tendría que hacer referencia a EntityFramework.dll.

Pero ¿y si el cliente quiere al menos registrar la excepción? El problema es que se pierde el mensaje de error original. Una manera de solucionar este problema es interceptar las excepciones del lado servidor y reemplazarlas por excepciones sin formato de tipo Exception, si el tipo de excepción es supuestamente desconocido en el lado cliente.

Aun así, hay que tener en cuenta una cuestión importante: solo interesa reemplazar una excepción si el llamador es el cliente de grano. No conviene reemplazar una excepción si el autor de la llamada es otro grano (o la infraestructura de Orleans que también realiza llamadas de grano (por ejemplo, en el grano GrainBasedReminderTable).

En el lado servidor, esto se puede hacer con un interceptor de nivel de silo:

public class ExceptionConversionFilter : IIncomingGrainCallFilter
{
    private static readonly HashSet<string> KnownExceptionTypeAssemblyNames =
        new HashSet<string>
        {
            typeof(string).Assembly.GetName().Name,
            "System",
            "System.ComponentModel.Composition",
            "System.ComponentModel.DataAnnotations",
            "System.Configuration",
            "System.Core",
            "System.Data",
            "System.Data.DataSetExtensions",
            "System.Net.Http",
            "System.Numerics",
            "System.Runtime.Serialization",
            "System.Security",
            "System.Xml",
            "System.Xml.Linq",
            "MyCompany.Microservices.DataTransfer",
            "MyCompany.Microservices.Interfaces",
            "MyCompany.Microservices.ServiceLayer"
        };

    public async Task Invoke(IIncomingGrainCallContext context)
    {
        var isConversionEnabled =
            RequestContext.Get("IsExceptionConversionEnabled") as bool? == true;

        if (!isConversionEnabled)
        {
            // If exception conversion is not enabled, execute the call without interference.
            await context.Invoke();
            return;
        }

        RequestContext.Remove("IsExceptionConversionEnabled");
        try
        {
            await context.Invoke();
        }
        catch (Exception exc)
        {
            var type = exc.GetType();

            if (KnownExceptionTypeAssemblyNames.Contains(
                type.Assembly.GetName().Name))
            {
                throw;
            }

            // Throw a base exception containing some exception details.
            throw new Exception(
                string.Format(
                    "Exception of non-public type '{0}' has been wrapped."
                    + " Original message: <<<<----{1}{2}{3}---->>>>",
                    type.FullName,
                    Environment.NewLine,
                    exc,
                    Environment.NewLine));
        }
    }
}

Después, este filtro puede registrarse en el silo:

siloHostBuilder.AddIncomingGrainCallFilter<ExceptionConversionFilter>();

Habilite el filtro para las llamadas que realiza el cliente mediante la adición de un filtro de llamadas salientes:

clientBuilder.AddOutgoingGrainCallFilter(context =>
{
    RequestContext.Set("IsExceptionConversionEnabled", true);
    return context.Invoke();
});

De este modo, el cliente indica al servidor que quiere usar la conversión de excepciones.

Llamadas de grano desde interceptores

Es posible realizar llamadas de grano desde un interceptor mediante la inserción de IGrainFactory en la clase interceptora:

private readonly IGrainFactory _grainFactory;

public CustomCallFilter(IGrainFactory grainFactory)
{
    _grainFactory = grainFactory;
}

public async Task Invoke(IIncomingGrainCallContext context)
{
    // Hook calls to any grain other than ICustomFilterGrain implementations.
    // This avoids potential infinite recursion when calling OnReceivedCall() below.
    if (!(context.Grain is ICustomFilterGrain))
    {
        var filterGrain = _grainFactory.GetGrain<ICustomFilterGrain>(
            context.Grain.GetPrimaryKeyLong());

        // Perform some grain call here.
        await filterGrain.OnReceivedCall();
    }

    // Continue invoking the call on the target grain.
    await context.Invoke();
}