Orleans

Un client consente al codice non granulare di interagire con un cluster Orleans. I client consentono al codice dell'applicazione di comunicare con grani e flussi ospitati in un cluster. Esistono due modi per ottenere un client, a seconda della posizione in cui il codice client è ospitato: nello stesso processo di un silo o in un altro processo. Questo articolo illustra entrambe le opzioni, a partire dall'opzione consigliata: il co-hosting del codice client nello stesso processo del codice granulare.

Client co-ospitati

Se il codice client è ospitato nello stesso processo del codice granulare, il client può essere ottenuto direttamente dal contenitore di inserimento delle dipendenze dell'applicazione host. In questo caso, il client comunica direttamente con il silo a cui è collegato e può sfruttare le conoscenze aggiuntive del silo sul cluster.

Ciò offre diversi vantaggi, tra cui la riduzione del sovraccarico della rete e della CPU, nonché la riduzione della latenza e l'aumento della velocità effettiva e dell'affidabilità. Il client fa uso della la conoscenza del silo della topologia e dello stato del cluster e non ha bisogno di usare un gateway separato. In questo modo, si evitano hop di rete e serializzazione/deserializzazione su round trip. Questo aumenta quindi anche l'affidabilità, poiché il numero di nodi necessari tra il client e il livello di granularità è ridotto al minimo. Se la grana consiste in una grana di lavoro senza stato o viene attivata sul silo in cui è ospitato il client, non è necessario eseguire alcuna serializzazione o comunicazione di rete e il client può beneficiare dei miglioramenti aggiuntivi di prestazioni e affidabilità. Il codice co-hosting granulare e di client semplifica anche la distribuzione e la topologia dell'applicazione, eliminando la necessità di due file binari di applicazione distinti da distribuire e monitorare.

Questo approccio ha anche alcuni svantaggi, principalmente il fatto che il codice granulare non sia più isolato dal processo client. Di conseguenza, problemi nel codice client (ad esempio, il blocco delle operazioni di I/O o la contesa di blocco che causano fame del thread) possono influire sulle prestazioni del codice granulare. Anche in assenza di difetti di codice come quello descritto in precedenza, gli effetti di vicini rumorosi possono comportare semplicemente l'esecuzione del codice client sullo stesso processore del codice granulare, sovraccaricando la cache della CPU e creando conflitti aggiuntivi per le risorse locali in generale. Inoltre, identificare l'origine di questi problemi è correntemente più difficile, poiché i sistemi di monitoraggio non sono in grado di distinguere logicamente il codice client dal codice granulare.

Nonostante questi svantaggi, il codice client di co-hosting con codice granulare è una soluzione diffusa e l'approccio consigliabile per la maggior parte delle applicazioni. Per approfondire, gli svantaggi menzionati in precedenza sono praticamente minimi per i seguenti motivi:

  • Il codice client è spesso molto sottile; ad esempio, traduce le richieste HTTP in ingresso in chiamate granulari e pertanto gli effetti di vicini rumorosi sono minimi e di costo comparabile a quello del gateway altrimenti richiesto.
  • Se si verifica un problema di prestazioni, il flusso di lavoro tipico di uno sviluppatore prevede l’uso di strumenti come i profiler della CPU e i debugger, comunque efficaci per identificare rapidamente l'origine del problema nonostante l'esecuzione sia del codice client che di quello granulare nello stesso processo. In altre parole, le metriche diventano più grossolane e meno efficaci per identificare con precisione l'origine di un problema, ma gli strumenti più dettagliati rimangono comunque efficaci.

Ottenere un client da un host

Se si usa l’host generico .NET per l’hosting, il client sarà automaticamente disponibile nel contenitore di inserimento delle dipendenze dell'host e potrà essere inserito in servizi come controller ASP.NET o implementazioni IHostedService.

In alternativa, un'interfaccia client come IGrainFactory o IClusterClient può essere ottenuta da ISiloHost:

var client = host.Services.GetService<IClusterClient>();
await client.GetGrain<IMyGrain>(0).Ping();

Client esterni

Il codice client può essere eseguito all'esterno del cluster Orleans che ospita il codice granulare. Di conseguenza, un client esterno funge da connettore o condotto verso il cluster e tutti i grani dell'applicazione. In genere, i client vengono usati nei server Web front-end per connettersi a un cluster Orleans che funge da livello intermedio con grani che eseguono la logica di business.

In una configurazione tipica, un server Web front-end:

  • Riceve una richiesta web.
  • Esegue l'autenticazione e la convalida dell'autorizzazione necessarie.
  • Decide quale/i grana/i devono elaborare la richiesta.
  • Usa il pacchetto Microsoft.Orleans.ClientNuGet per effettuare una o più chiamate di metodo ai grani.
  • Gestisce correttamente il completamento o gli errori delle chiamate granulari ed eventuali valori restituiti.
  • Invia una risposta alla richiesta Web.

Inizializzazione del client granulare

Prima di poter usare un client granulare per effettuare chiamate ai grani ospitati in un cluster Orleans, è necessario effettuarne la configurazione, l’inizializzazione, e connettersi al cluster.

La configurazione viene fornita tramite UseOrleansClient e diverse classi di opzioni supplementari che contengono una gerarchia di proprietà di configurazione per configurare un client programmaticamente. Per ulteriori informazioni, consultare Configurazione client.

Considerare il seguente esempio di una configurazione client:

// Alternatively, call Host.CreateDefaultBuilder(args) if using the 
// Microsoft.Extensions.Hosting NuGet package.
using IHost host = new HostBuilder()
    .UseOrleansClient(clientBuilder =>
    {
        clientBuilder.Configure<ClusterOptions>(options =>
        {
            options.ClusterId = "my-first-cluster";
            options.ServiceId = "MyOrleansService";
        });

        clientBuilder.UseAzureStorageClustering(
            options => options.ConfigureTableServiceClient(connectionString))
    })
    .Build();

All'avvio di host, il client verrà configurato e reso disponibile tramite l'istanza del provider di servizi costruita.

La configurazione viene fornita tramite ClientBuilder e diverse classi di opzioni supplementari che contengono una gerarchia di proprietà di configurazione per configurare un client programmaticamente. Per ulteriori informazioni, consultare Configurazione client.

Esempio di una configurazione client:

var client = new ClientBuilder()
    .Configure<ClusterOptions>(options =>
    {
        options.ClusterId = "my-first-cluster";
        options.ServiceId = "MyOrleansService";
    })
    .UseAzureStorageClustering(
        options => options.ConnectionString = connectionString)
    .ConfigureApplicationParts(
        parts => parts.AddApplicationPart(typeof(IValueGrain).Assembly))
    .Build();

Infine, è necessario chiamare il metodo Connect() sull'oggetto client costruito per connetterlo al cluster Orleans. Si tratta di un metodo asincrono che restituisce un Task. È quindi necessario attendere il completamento con un await o .Wait().

await client.Connect();

Effettuare chiamate ai grani

L'esecuzione di chiamate a grani da parte di un client non differisce dall'esecuzione di tali chiamate dall’interno del codice granulare. Lo stesso metodo IGrainFactory.GetGrain<TGrainInterface>(Type, Guid), in cui T è l'interfaccia di granularità di destinazione, viene usato in entrambi i casi per ottenere riferimenti granulari. La differenza consiste in quale oggetto factory viene richiamato IGrainFactory.GetGrain. Nel codice client, questa operazione viene eseguita tramite l'oggetto client connesso, come illustrato nel seguente esempio:

IPlayerGrain player = client.GetGrain<IPlayerGrain>(playerId);
Task joinGameTask = player.JoinGame(game)

await joinGameTask;

Una chiamata a un metodo granulare restituisce un Task o un Task<TResult>, come richiesto dalle norme dell'interfaccia granulare. Il client può usare la parola chiave await per attendere in modo asincrono il Task restituito senza bloccare il thread o, in alcuni casi, il metodo Wait() per bloccare il thread di esecuzione corrente.

La differenza principale tra l'esecuzione di chiamate ai grani dal codice client e dall'interno di un'altra grana è il modello di esecuzione di gani a thread singolo. I grani sono vincolati a thread singolo dal runtime Orleans, mentre i client possono essere a thread multipli. Orleans non fornisce alcuna garanzia di questo genere per il lato del client e, pertanto, è responsabilità del client gestire la sua concorrenza usando i costrutti di sincronizzazione appropriati per il proprio ambiente (blocchi, eventi e Tasks).

Ricevere notifiche

In alcune situazioni, un semplice criterio richiesta-risposta non è sufficiente e il client necessita di ricevere notifiche asincrone. Ad esempio, un utente potrebbe voler ricevere una notifica quando un utente seguito pubblica un nuovo messaggio.

L'uso degli Osservatori è un meccanismo che consente di esporre oggetti del lato del client come destinazioni simili a grani perché siano chiamati dai grani. Le chiamate agli osservatori non forniscono alcuna indicazione di esito positivo o negativo, poiché vengono inviate come messaggio unidirezionale più efficientemente possibile. È quindi responsabilità del codice dell'applicazione creare, se necessario, un meccanismo di affidabilità di livello superiore su osservatori.

Un altro meccanismo che può essere usato per recapitare messaggi asincroni ai client è Streams. I flussi indicano l'esito positivo o negativo del recapito dei singoli messaggi e quindi consentono una comunicazione affidabile con il client.

Connettività client

Esistono due scenari in cui un client del cluster può riscontrare problemi di connettività:

  • Quando il client tenta di connettersi a un silo.
  • Quando si effettuano chiamate a riferimenti granulari ottenuti da un client del cluster connesso.

Nel primo caso, il client tenterà di connettersi a un silo. Se il client non è in grado di connettersi a un silo, genererà un'eccezione per illustrare la natura dell’errore. È possibile registrare un IClientConnectionRetryFilter per gestire l'eccezione e decidere se ritentare o meno l’operazione. Se non viene fornito alcun filtro di ripetizione del tentativo o se il filtro restituisce false, il client rinuncerà all’operazione.

using Orleans.Runtime;

internal sealed class ClientConnectRetryFilter : IClientConnectionRetryFilter
{
    private int _retryCount = 0;
    private const int MaxRetry = 5;
    private const int Delay = 1_500;

    public async Task<bool> ShouldRetryConnectionAttempt(
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (_retryCount >= MaxRetry)
        {
            return false;
        }

        if (!cancellationToken.IsCancellationRequested &&
            exception is SiloUnavailableException siloUnavailableException)
        {
            await Task.Delay(++ _retryCount * Delay, cancellationToken);
            return true;
        }

        return false;
    }
}

Esistono due scenari in cui un client del cluster può riscontrare problemi di connettività:

  • Quando il metodo IClusterClient.Connect() viene chiamato inizialmente.
  • Quando si effettuano chiamate a riferimenti granulari ottenuti da un client del cluster connesso.

Nel primo caso, il metodo Connect genererà un'eccezione per indicare la natura dell’errore. Si tratta in genere (ma non necessariamente) di un SiloUnavailableException. In questo caso, l'istanza del client del cluster non è utilizzabile e deve essere eliminata. Una funzione del filtro di ripetizione del tentativo può essere fornita facoltativamente al metodo Connect, che potrebbe ad esempio attendere un periodo di tempo specificato prima di eseguire un altro tentativo. Se non viene fornito alcun filtro di ripetizione del tentativo o se il filtro restituisce false, il client rinuncerà all’operazione.

Se Connect viene restituito correttamente, è garantito che il client del cluster sarà utilizzabile fino alla sua eliminazione. Ciò significa che, anche se il client riscontra problemi di connessione, tenterà di eseguire il ripristino a tempo indeterminato. L’esatto comportamento di recupero può essere configurato in un GatewayOptions fornito da ClientBuilder, ad esempio:

var client = new ClientBuilder()
    // ...
    .Configure<GatewayOptions>(
        options =>                         // Default is 1 min.
        options.GatewayListRefreshPeriod = TimeSpan.FromMinutes(10))
    .Build();

Nel secondo caso, in cui si verifica un problema di connessione durante una chiamata granulare, un’eccezione SiloUnavailableException verrà generata sul lato client. Quest’evenienza può essere gestita come illustrato di seguito:

IPlayerGrain player = client.GetGrain<IPlayerGrain>(playerId);

try
{
    await player.JoinGame(game);
}
catch (SiloUnavailableException)
{
    // Lost connection to the cluster...
}

In questa situazione, il riferimento granulare non viene invalidato; potrebbe essere effettuato un secondo tentativo di chiamata sullo stesso riferimento in un secondo momento, una volta ristabilita una connessione.

Inserimento delle dipendenze

Il metodo consigliato per creare un client esterno in un programma che usa l'host generico .NET consiste nell'inserire un'istanza singleton IClusterClient tramite inserimento delle dipendenze, che può quindi essere accettata come parametro del costruttore in servizi ospitati, controller ASP.NET, e così via.

Nota

Quando si esegue il co-hosting di un silo Orleans nello stesso processo che verrà connesso ad esso, non è necessario creare manualmente un client; Orleans ne fornirà automaticamente uno e ne gestirà appropriatamente la durata.

Quando ci si connette a un cluster in un altro processo (in un altro computer), un criterio di uso comune consiste nel creare un servizio ospitato in questo modo:

using Microsoft.Extensions.Hosting;

namespace Client;

public sealed class ClusterClientHostedService : IHostedService
{
    private readonly IClusterClient _client;

    public ClusterClientHostedService(IClusterClient client)
    {
        _client = client;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        // Use the _client to consume grains...

        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
        => Task.CompletedTask;
}
public class ClusterClientHostedService : IHostedService
{
    private readonly IClusterClient _client;

    public ClusterClientHostedService(IClusterClient client)
    {
        _client = client;
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        // A retry filter could be provided here.
        await _client.Connect();
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        await _client.Close();

        _client.Dispose();
    }
}

Il servizio viene quindi registrato come segue:

await Host.CreateDefaultBuilder(args)
    .UseOrleansClient(builder =>
    {
        builder.UseLocalhostClustering();
    })
    .ConfigureServices(services => 
    {
        services.AddHostedService<ClusterClientHostedService>();
    })
    .RunConsoleAsync();

Esempio

Ecco una versione estesa del precedente esempio di un'applicazione client che si connette a Orleans, trova l'account del lettore, sottoscrive per aggiornamenti alla sessione di gioco di cui lettore fa parte con un osservatore, e stampa notifiche fino a quando il programma non viene terminato manualmente.

try
{
    using IHost host = Host.CreateDefaultBuilder(args)
        .UseOrleansClient((context, client) =>
        {
            client.Configure<ClusterOptions>(options =>
            {
                options.ClusterId = "my-first-cluster";
                options.ServiceId = "MyOrleansService";
            })
            .UseAzureStorageClustering(
                options => options.ConfigureTableServiceClient(
                    context.Configuration["ORLEANS_AZURE_STORAGE_CONNECTION_STRING"]));
        })
        .UseConsoleLifetime()
        .Build();

    await host.StartAsync();

    IGrainFactory client = host.Services.GetRequiredService<IGrainFactory>();

    // Hardcoded player ID
    Guid playerId = new("{2349992C-860A-4EDA-9590-000000000006}");
    IPlayerGrain player = client.GetGrain<IPlayerGrain>(playerId);
    IGameGrain? game = null;
    while (game is null)
    {
        Console.WriteLine(
            $"Getting current game for player {playerId}...");

        try
        {
            game = await player.GetCurrentGame();
            if (game is null) // Wait until the player joins a game
            {
                await Task.Delay(TimeSpan.FromMilliseconds(5_000));
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Exception: {ex.GetBaseException()}");
        }
    }

    Console.WriteLine(
        $"Subscribing to updates for game {game.GetPrimaryKey()}...");

    // Subscribe for updates
    var watcher = new GameObserver();
    await game.ObserveGameUpdates(
        client.CreateObjectReference<IGameObserver>(watcher));

    Console.WriteLine(
        "Subscribed successfully. Press <Enter> to stop.");
}
catch (Exception e)
{
    Console.WriteLine(
        $"Unexpected Error: {e.GetBaseException()}");
}
await RunWatcherAsync();

// Block the main thread so that the process doesn't exit.
// Updates arrive on thread pool threads.
Console.ReadLine();

static async Task RunWatcherAsync()
{
    try
    {
        var client = new ClientBuilder()
            .Configure<ClusterOptions>(options =>
            {
                options.ClusterId = "my-first-cluster";
                options.ServiceId = "MyOrleansService";
            })
            .UseAzureStorageClustering(
                options => options.ConnectionString = connectionString)
            .ConfigureApplicationParts(
                parts => parts.AddApplicationPart(typeof(IValueGrain).Assembly))
            .Build();

            // Hardcoded player ID
            Guid playerId = new("{2349992C-860A-4EDA-9590-000000000006}");
            IPlayerGrain player = client.GetGrain<IPlayerGrain>(playerId);
            IGameGrain game = null;
            while (game is null)
            {
                Console.WriteLine(
                    $"Getting current game for player {playerId}...");

                try
                {
                    game = await player.GetCurrentGame();
                    if (game is null) // Wait until the player joins a game
                    {
                        await Task.Delay(TimeSpan.FromMilliseconds(5_000));
                    }
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"Exception: {ex.GetBaseException()}");
                }
            }

            Console.WriteLine(
                $"Subscribing to updates for game {game.GetPrimaryKey()}...");

            // Subscribe for updates
            var watcher = new GameObserver();
            await game.SubscribeForGameUpdates(
                await client.CreateObjectReference<IGameObserver>(watcher));

            Console.WriteLine(
                "Subscribed successfully. Press <Enter> to stop.");
        }
        catch (Exception e)
        {
            Console.WriteLine(
                $"Unexpected Error: {e.GetBaseException()}");
        }
    }
}

/// <summary>
/// Observer class that implements the observer interface.
/// Need to pass a grain reference to an instance of
/// this class to subscribe for updates.
/// </summary>
class GameObserver : IGameObserver
{
    public void UpdateGameScore(string score)
    {
        Console.WriteLine("New game score: {0}", score);
    }
}