Filtres d’appels de grain

Les filtres d’appels de grain offrent un moyen d’intercepter les appels de grain. Les filtres peuvent exécuter du code avant et après un appel de grain. Plusieurs filtres peuvent être installés simultanément. Les filtres sont asynchrones et peuvent modifier RequestContext, les arguments et la valeur de retour de la méthode appelée. Les filtres peuvent aussi inspecter l’élément MethodInfo de la méthode appelée au niveau de la classe de grain et peuvent être utilisés pour lever ou gérer des exceptions.

Voici quelques exemples d’utilisation des filtres d’appels de grain :

  • Autorisation : un filtre peut inspecter la méthode appelée et les arguments ou certaines informations d’autorisation dans RequestContext pour déterminer s’il convient d’autoriser ou non la poursuite de l’appel.
  • Journalisation/télémétrie : un filtre peut journaliser des informations et capturer des données de temporisation et autres statistiques à propos de l’appel de méthode.
  • Gestion des erreurs : un filtre peut intercepter les exceptions levées par un appel de méthode et les transformer en exceptions d’un autre type ou les gérer pendant qu’elles passent à travers le filtre.

Il existe deux types de filtres :

  • Les filtres d’appels entrants
  • Les filtres d’appels sortants

Les filtres d’appels entrants sont exécutés au moment de recevoir un appel. Les filtres d’appels entrants sont exécutés au moment d’effectuer un appel.

Les filtres d’appels entrants

Les filtres d’appels de grain entrants implémentent l’interface IIncomingGrainCallFilter, qui comporte une méthode :

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

L’argument IIncomingGrainCallContext transmis à la méthode Invoke présente la forme suivante :

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

La méthode IIncomingGrainCallFilter.Invoke(IIncomingGrainCallContext) doit attendre ou retourner le résultat de IIncomingGrainCallContext.Invoke() pour exécuter le filtre configuré suivant et par la suite la méthode de grain proprement dite. La propriété Result peut être modifiée après avoir attendu la méthode Invoke(). La propriété ImplementationMethod retourne l’élément MethodInfo de la classe d’implémentation. L’élément MethodInfo de la méthode d’interface est accessible à l’aide de la propriété InterfaceMethod. Les filtres d’appels de grain sont appelés pour tous les appels de méthode à un grain, ce qui inclut les appels aux extensions de grain (implémentations de IGrainExtension) qui sont installées dans le grain. Par exemple, des extensions de grain sont utilisées pour implémenter des flux et des jetons d’annulation. Par conséquent, la valeur de ImplementationMethod n’est pas nécessairement une méthode de la classe de grain proprement dite.

Configurer des filtres d’appels de grain entrants

Les implémentations de IIncomingGrainCallFilter peuvent être inscrites en tant que filtres à l’échelle d’un silo via l’injection de dépendances ou être inscrites en tant que filtres au niveau du grain via un grain implémentant directement IIncomingGrainCallFilter.

Filtres d’appel de grain à l’échelle d’un silo

Un délégué peut être inscrit en tant que filtre d’appels de grain à l’échelle d’un silo en utilisant l’injection de dépendances comme suit :

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 même, une classe peut être inscrite en tant que filtre d’appel de grain en utilisant la méthode d’assistance AddIncomingGrainCallFilter. Voici un exemple de filtre d’appels de grain qui journalise les résultats de chaque méthode de grain :

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

Ce filtre peut ensuite être inscrit à l’aide de la méthode d’extension AddIncomingGrainCallFilter :

siloHostBuilder.AddIncomingGrainCallFilter<LoggingCallFilter>();

Le filtre peut aussi être inscrit sans la méthode d’extension :

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

Filtres d’appels grain par grain

Une classe de grain peut s’inscrire en tant que filtre d’appels de grain et filtrer les appels qui lui sont adressés en implémentant IIncomingGrainCallFilter comme suit :

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

Dans l’exemple ci-dessus, tous les appels à la méthode GetFavoriteNumber retournent 38 au lieu de 7, car la valeur de retour a été modifiée par le filtre.

Un autre cas d’usage pour les filtres se situe au niveau du contrôle d’accès, comme dans cet exemple :

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

Dans l’exemple ci-dessus, la méthode SpecialAdminOnlyOperation ne peut être appelée que si "isAdmin" est défini sur true dans RequestContext. De cette façon, les filtres d’appels de grain peuvent être utilisés pour l’autorisation. Dans cet exemple, il incombe à l’appelant de vérifier que la valeur "isAdmin" est définie correctement et que l’authentification se déroule convenablement. Notez que l’attribut [AdminOnly] est spécifié au niveau de la méthode de la classe de grain. Cela est dû au fait que la propriété ImplementationMethod retourne l’élément MethodInfo de l’implémentation, et non l’interface. Le filtre peut aussi vérifier la propriété InterfaceMethod.

Ordre des filtres d’appels de grain

Les filtres d’appels de grain suivent un classement défini :

  1. Implémentations IIncomingGrainCallFilter configurées dans le conteneur d’injection de dépendances, dans l’ordre de leur inscription.
  2. Filtre au niveau du grain, si le grain implémente IIncomingGrainCallFilter.
  3. Implémentation de méthode de grain ou implémentation de méthode d’extension de grain.

Chaque appel à IIncomingGrainCallContext.Invoke() encapsule le prochain filtre défini de telle sorte que chaque filtre ait la possibilité d’exécuter le code avant et après le filtre suivant dans la chaîne et par la suite la méthode de grain proprement dite.

Les filtres d’appels sortants

Les filtres d’appels de grain sortants sont similaires aux filtres d’appels de grain entrants, la principale différence étant qu’ils sont appelés au niveau de l’appelant (client) et non de l’appelé (grain).

Les filtres d’appels de grain sortants implémentent l’interface IOutgoingGrainCallFilter, qui comporte une méthode :

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

L’argument IOutgoingGrainCallContext transmis à la méthode Invoke présente la forme suivante :

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

La méthode IOutgoingGrainCallFilter.Invoke(IOutgoingGrainCallContext) doit attendre ou retourner le résultat de IOutgoingGrainCallContext.Invoke() pour exécuter le filtre configuré suivant et par la suite la méthode de grain proprement dite. La propriété Result peut être modifiée après avoir attendu la méthode Invoke(). L’élément MethodInfo de la méthode d’interface appelée est accessible à l’aide de la propriété InterfaceMethod. Les filtres d’appels de grain sortants sont appelés pour tous les appels de méthode à un grain, et cela inclut les appels aux méthodes système effectués par Orleans.

Configurer des filtres d’appels de grain sortants

Les implémentations de IOutgoingGrainCallFilter peuvent être inscrites au niveau des silos et des clients à l’aide de l’injection de dépendances.

Un délégué peut être inscrit en tant que filtre d’appel comme suit :

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

Dans le code ci-dessus, builder peut être une instance de ISiloHostBuilder ou IClientBuilder.

De la même manière, une classe peut être inscrite en tant que filtre d’appels de grain sortants. Voici un exemple de filtre d’appels de grain qui journalise les résultats de chaque méthode de grain :

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

Ce filtre peut ensuite être inscrit à l’aide de la méthode d’extension AddOutgoingGrainCallFilter :

builder.AddOutgoingGrainCallFilter<LoggingCallFilter>();

Le filtre peut aussi être inscrit sans la méthode d’extension :

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

Comme pour l’exemple de filtre d’appel délégué, builder peut être une instance de ISiloHostBuilder ou IClientBuilder.

Cas d'utilisation

Conversion d’exception

Quand une exception levée à partir du serveur est désérialisée sur le client, vous pouvez parfois obtenir l’exception suivante au lieu de l’exception réelle : TypeLoadException: Could not find Whatever.dll.

Cela se produit si l’assembly contenant l’exception n’est pas accessible au client. Par exemple, si vous utilisez Entity Framework dans vos implémentations de grain, une EntityException peut être levée. En revanche, le client ne fait pas référence à EntityFramework.dll (et ne le doit pas), car il ne connaît pas la couche d’accès aux données sous-jacente.

Quand le client tente de désérialiser EntityException, il échoue en raison de la DLL manquante ; par conséquent, une TypeLoadException est levée et masque l’EntityException d’origine.

On peut arguer que cela n’est pas un problème dans la mesure où le client ne gérerait jamais l’EntityException ; sinon, il devrait faire référence à EntityFramework.dll.

Mais que faire si le client souhaite au moins journaliser l’exception ? Le problème est que le message d’erreur d’origine est perdu. Une façon de contourner ce problème est d’intercepter les exceptions côté serveur et de les remplacer par des exceptions simples de type Exception si le type d’exception est sans doute inconnu côté client.

Cependant, il convient de garder à l’esprit un point important : l’exception ne doit être remplacée que si l’appelant est le client de grain. Nous ne voulons pas remplacer une exception si l’appelant est un autre grain (ou l’infrastructure Orleans qui effectue également des appels de grain ; par exemple, sur le grain GrainBasedReminderTable).

Côté serveur, cela peut se faire avec un intercepteur au niveau d’un 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));
        }
    }
}

Ce filtre peut alors être inscrit au niveau du silo :

siloHostBuilder.AddIncomingGrainCallFilter<ExceptionConversionFilter>();

Activez le filtre pour les appels effectués par le client en ajoutant un filtre d’appels sortants :

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

De cette façon, le client indique au serveur qu’il souhaite utiliser la conversion d’exceptions.

Appeler des grains à partir d’intercepteurs

Il est possible d’effectuer des appels de grain à partir d’un intercepteur en injectant IGrainFactory dans la classe d’intercepteur :

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