Filtri delle chiamate granulari

I filtri delle chiamate granulari consentono di intercettare le chiamate granulari. I filtri possono eseguire codice sia prima che dopo una chiamata granulare. È possibile installare più filtri contemporaneamente. I filtri sono asincroni e possono modificare RequestContext, gli argomenti e il valore restituito del metodo richiamato. I filtri possono anche controllare l'oggetto MethodInfo del metodo richiamato sulla classe granulare e possono essere usati per generare o gestire le eccezioni.

Alcuni esempi di utilizzo dei filtri delle chiamate granulari sono:

  • Autorizzazione: un filtro può controllare il metodo richiamato e gli argomenti o alcune informazioni di autorizzazione in RequestContext per determinare se consentire o meno alla chiamata di proseguire.
  • Registrazione/telemetria: un filtro può registrare informazioni e acquisire dati di intervallo e altre statistiche sulla chiamata al metodo.
  • Gestione degli errori: un filtro può intercettare le eccezioni generate da una chiamata al metodo e trasformarla in un'altra eccezione o gestire l'eccezione mentre passa attraverso il filtro.

I filtri sono disponibili in due versioni:

  • Filtri delle chiamate in ingresso
  • Filtri delle chiamate in uscita

I filtri di chiamata in ingresso vengono eseguiti quando si riceve una chiamata. I filtri delle chiamate in uscita vengono eseguiti durante l'esecuzione di una chiamata.

Filtri delle chiamate in ingresso

I filtri delle chiamate granulari in ingresso implementano l'interfaccia IIncomingGrainCallFilter, che ha un metodo:

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

L'argomento IIncomingGrainCallContext passato al metodo Invoke ha la forma seguente:

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

Il metodo IIncomingGrainCallFilter.Invoke(IIncomingGrainCallContext) deve attendere o restituire il risultato di IIncomingGrainCallContext.Invoke() per eseguire il filtro configurato successivo ed eventualmente il metodo granulare stesso. La proprietà Result può essere modificata dopo l'attesa del metodo Invoke(). La proprietà ImplementationMethod restituisce il MethodInfo della classe di implementazione. È possibile accedere a MethodInfo del metodo di interfaccia usando la proprietà InterfaceMethod. I filtri delle chiamate granulari vengono chiamati per tutte le chiamate di metodo a un livello di granularità e ciò include le chiamate alle estensioni granulari (implementazioni di IGrainExtension) installate nel livello di granularità. Ad esempio, le estensioni granulari vengono usate per implementare flussi e token di annullamento. È quindi necessario prevedere che il valore di ImplementationMethod non sia sempre un metodo nella classe granulare stessa.

Configurare i filtri delle chiamate granulari in ingresso

Le implementazioni di IIncomingGrainCallFilter possono essere registrate come filtri a livello di silo tramite inserimento delle dipendenze oppure possono essere registrate come filtri a livello di granularità tramite un'implementazione granulare diretta di IIncomingGrainCallFilter.

Filtri delle chiamate a granularità a livello di silo

Un delegato può essere registrato come filtro di chiamata granulare a livello di silo usando Inserimento delle dipendenze come indicato di seguito:

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

Analogamente, una classe può essere registrata come filtro di chiamata granulare usando il metodo helper AddIncomingGrainCallFilter. Di seguito è riportato un esempio di filtro di chiamata granulare che registra i risultati di ogni metodo di granularità:

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

Questo filtro può quindi essere registrato usando il metodo di estensione AddIncomingGrainCallFilter:

siloHostBuilder.AddIncomingGrainCallFilter<LoggingCallFilter>();

In alternativa, il filtro può essere registrato senza il metodo di estensione:

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

Filtri per granularità delle chiamate granulari

Una classe granulare può registrarsi come filtro di chiamata granulare e filtrare tutte le chiamate effettuate implementando IIncomingGrainCallFilter come segue:

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

Nell'esempio precedente, tutte le chiamate al metodo GetFavoriteNumber restituiranno 38 anziché 7, perché il valore restituito è stato modificato dal filtro.

Un altro caso d'uso per i filtri è nel controllo di accesso, come in questo esempio:

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

Nell'esempio precedente il metodo SpecialAdminOnlyOperation può essere chiamato solo se "isAdmin" è impostato su true in RequestContext. In questo modo, i filtri delle chiamate granulari possono essere usati per l'autorizzazione. In questo esempio è responsabilità del chiamante assicurarsi che il valore "isAdmin" sia impostato correttamente e che l'autenticazione venga eseguita correttamente. Si noti che l'attributo [AdminOnly] viene specificato nel metodo della classe granulare. Ciò è dovuto al fatto che la proprietà ImplementationMethod restituisce l’oggetto MethodInfo dell'implementazione, non l'interfaccia. Il filtro può anche controllare la proprietà InterfaceMethod.

Ordinamento del filtro delle chiamate granulari

I filtri delle chiamate granulari seguono un ordinamento definito:

  1. Implementazioni di IIncomingGrainCallFilter configurate nel contenitore di inserimento delle dipendenze, nell'ordine in cui vengono registrate.
  2. Filtro a livello di granularità, se la granularità implementa IIncomingGrainCallFilter.
  3. Implementazione del metodo granulare o implementazione del metodo di estensione granulare.

Ogni chiamata a IIncomingGrainCallContext.Invoke() incapsula il filtro definito successivo in modo che ogni filtro abbia la possibilità di eseguire codice prima e dopo il filtro successivo nella catena, ed eventualmente il metodo granulare stesso.

Filtri delle chiamate in uscita

I filtri delle chiamate granulari in uscita sono simili ai filtri delle chiamate granulari in ingresso, con la differenza principale che vengono richiamati sul chiamante (client) anziché sul chiamato (granularità).

I filtri delle chiamate granulari in uscita implementano l'interfaccia IOutgoingGrainCallFilter, che ha un metodo:

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

L'argomento IOutgoingGrainCallContext passato al metodo Invoke ha la forma seguente:

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

Il metodo IOutgoingGrainCallFilter.Invoke(IOutgoingGrainCallContext) deve attendere o restituire il risultato di IOutgoingGrainCallContext.Invoke() per eseguire il filtro configurato successivo e infine il metodo granulare stesso. La proprietà Result può essere modificata dopo l'attesa del metodo Invoke(). È possibile accedere all’oggetto MethodInfo del metodo di interfaccia chiamato usando la proprietà InterfaceMethod. I filtri di chiamata granulare in uscita vengono richiamati per tutte le chiamate al metodo a un livello di granularità e ciò include le chiamate ai metodi di sistema eseguiti da Orleans.

Configurare i filtri delle chiamate granulari in uscita

Le implementazioni di IOutgoingGrainCallFilter possono essere registrate sia in silo che nei client usando l'inserimento delle dipendenze.

Un delegato può essere registrato come filtro di chiamata in questo modo:

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

Nel codice precedente, builder può essere un'istanza di ISiloHostBuilder o IClientBuilder.

Analogamente, una classe può essere registrata come filtro di chiamata granulare in uscita. Di seguito è riportato un esempio di filtro di chiamata granulare che registra i risultati di ogni metodo di granularità:

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

Questo filtro può quindi essere registrato usando il metodo di estensione AddOutgoingGrainCallFilter:

builder.AddOutgoingGrainCallFilter<LoggingCallFilter>();

In alternativa, il filtro può essere registrato senza il metodo di estensione:

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

Come per l'esempio di filtro di chiamata delegato, builder può essere un'istanza di ISiloHostBuilder o IClientBuilder.

Utilizzare casi

Conversione delle eccezioni

Quando nel client viene deserializzata un'eccezione generata dal server, a volte si ottiene l'eccezione seguente anziché quella effettiva: TypeLoadException: Could not find Whatever.dll.

Ciò si verifica se l'assembly contenente l'eccezione non è disponibile per il client. Si supponga, ad esempio, di usare Entity Framework nelle implementazioni granulari; può quindi essere generata un'eccezione EntityException. Il client non fa invece riferimento a EntityFramework.dll (e non deve) perché non conosce il livello di accesso ai dati sottostante.

Quando il client tenta di deserializzare EntityException, l'operazione avrà esito negativo a causa della DLL mancante. Di conseguenza, viene generato un TypeLoadException che nasconde l’oggetto EntityException originale.

Si potrebbe sostenere che questo è piuttosto corretto perché il client non gestiva mai EntityException; in caso contrario, avrebbe dovuto fare riferimento a EntityFramework.dll.

Ma cosa succede se il client vuole almeno registrare l'eccezione? Il problema è che il messaggio di errore originale viene perso. Un modo per risolvere questo problema consiste nell'intercettare le eccezioni lato server e sostituirle con eccezioni semplici di tipo Exception se il tipo di eccezione è presumibilmente sconosciuto sul lato client.

Tuttavia, c'è un aspetto importante da tenere presente: si vuole sostituire un'eccezione solo se il chiamante è il client granulare. Non si vuole sostituire un'eccezione se il chiamante è un altro tipo di granularità (o l'infrastruttura Orleans che sta effettuando chiamate granulari, ad esempio nel livello di granularità GrainBasedReminderTable).

Sul lato server questa operazione può essere eseguita con un intercettore a livello di 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));
        }
    }
}

Questo filtro può quindi essere registrato nel silo:

siloHostBuilder.AddIncomingGrainCallFilter<ExceptionConversionFilter>();

Abilitare il filtro per le chiamate effettuate dal client aggiungendo un filtro di chiamata in uscita:

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

In questo modo il client indica al server che desidera utilizzare la conversione delle eccezioni.

Chiamare granularità da intercettori

È possibile effettuare chiamate granulari da un intercettore inserendo IGrainFactory nella classe intercettore:

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