ClientsOrleans

Un client permet à du code autre que celui d’un grain d’interagir avec un cluster Orleans. Les clients permettent au code d’application de communiquer avec les grains et les flux hébergés dans un cluster. Il existe deux façons d’obtenir un client, selon l’emplacement où le code du client est hébergé : dans le même processus qu’un silo ou dans un processus distinct. Cet article traite des deux options, en commençant par l’option recommandée : le co-hébergement du code de client dans le même processus que le code de grain.

Clients co-hébergés

Si le code de client est hébergé dans le même processus que le code de grain, le client peut être obtenu directement à partir du conteneur d’injection de dépendances de l’application d’hébergement. Dans ce cas, le client communique directement avec le silo auquel il est attaché et peut tirer parti des connaissances supplémentaires dont dispose le silo au sujet du cluster.

Cela offre plusieurs avantages, notamment la réduction de la surcharge réseau et du temps processeur, ainsi que la diminution de la latence et l’augmentation du débit et de la fiabilité. Le client utilise la connaissance par le silo de la topologie et de l’état du cluster et n’a pas besoin d’utiliser une passerelle distincte. Cela évite un tronçon réseau et un aller-retour de sérialisation/désérialisation. Cela augmente également la fiabilité en réduisant le nombre de nœuds requis entre le client et le grain. Si le grain est un grain worker sans état ou qu’il est activé sur le silo où le client est hébergé, aucune sérialisation ou communication réseau n’a besoin d’être effectuée et le client peut bénéficier des gains supplémentaires de performances et de fiabilité. Le co-hébergement du code de client et du code de grain simplifie également le déploiement et la topologie d’application en éliminant la nécessité de déployer et de surveiller deux fichiers binaires d’application distincts.

Il existe également des arguments contre cette approche, principalement que le code de grain n’est plus isolé du processus client. Par conséquent, des problèmes dans le code de client, tels que le blocage des E/S ou la contention de verrouillage, entraînant la privation de thread, peuvent affecter les performances du code de grain. Même sans défauts de code, tels que ceux mentionnés précédemment, les effets de voisin bruyant peuvent se traduire simplement par l’exécution du code de client sur le même processeur que le code de grain, plaçant une contrainte supplémentaire sur le cache processeur et une contention supplémentaire pour les ressources locales en général. En outre, l’identification de la source de ces problèmes est désormais plus difficile, car les systèmes de monitoring ne peuvent pas distinguer ce qui est logiquement le code de client du code de grain.

Malgré ces arguments contre, le co-hébergement du code de client avec le code de grain est une option populaire et constitue l’approche recommandée pour la plupart des applications. Pour développer, les arguments mentionnés ci-dessus sont peu signifiants dans la pratique pour les raisons suivantes :

  • Le code de client est souvent très mince, par exemple, traduisant les requêtes HTTP entrantes en appels de grain. Par conséquent, les effets de voisin bruyant sont minimes et comparables en matière de coûts à la passerelle requise par ailleurs.
  • Si un problème de performances se produit, le workflow typique d’un développeur implique des outils tels que des profileurs d’UC et des débogueurs, qui permettent encore d’identifier rapidement la source du problème, même si le code de client et le code de grain s’exécutent dans le même processus. En d’autres termes, les métriques deviennent plus grossières et moins aptes à identifier précisément la source d’un problème, mais des outils plus détaillés restent efficaces.

Obtenir un client à partir d’un hôte

Si vous hébergez à l’aide de l’hôte générique .NET, le client est automatiquement disponible dans le conteneur d’injection de dépendances de l’hôte et il peut être injecté dans des services tels que les contrôleurs ASP.NET ou les implémentations de IHostedService.

Vous pouvez également obtenir une interface client telle que IGrainFactory ou IClusterClient à partir de ISiloHost :

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

Clients externes

Le code de client peut s’exécuter en dehors du cluster Orleans où le code de grain est hébergé. Par conséquent, un client externe agit en tant que connecteur ou conduit vers le cluster et tous les grains de l’application. En général, les clients sont utilisés sur les serveurs web front-end pour se connecter à un cluster Orleans qui sert de niveau intermédiaire avec des grains exécutant la logique métier.

Dans une configuration typique, un serveur web frontal :

  • Reçoit une demande web.
  • Effectue la validation de l’authentification et des autorisations nécessaires.
  • Décide du ou des grains qui doivent traiter la demande.
  • Utilise le package NuGet Microsoft.Orleans.Client pour effectuer un ou plusieurs appels de méthode aux grains.
  • Gère la réussite ou les échecs des appels de grain et toutes les valeurs retournées.
  • Envoie une réponse à la demande web.

Initialisation du client de grain

Avant qu’un client de grain puisse être utilisé pour effectuer des appels aux grains hébergés dans un cluster Orleans, il doit être configuré, initialisé et connecté au cluster.

La configuration est fournie via UseOrleansClient et plusieurs classes d’options supplémentaires qui contiennent une hiérarchie de propriétés de configuration pour configurer programmatiquement un client. Pour plus d’informations, consultez Configuration de client.

Considérez l’exemple de configuration client suivant :

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

Lorsque host (hôte) est démarré, le client est configuré et disponible par le biais de son instance de fournisseur de services construite.

La configuration est fournie via ClientBuilder et plusieurs classes d’options supplémentaires qui contiennent une hiérarchie de propriétés de configuration pour configurer programmatiquement un client. Pour plus d’informations, consultez Configuration de client.

Exemple de configuration de 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();

Pour finir, vous devez appeler la méthode Connect() sur l’objet client construit afin de l’amener à se connecter au cluster Orleans. Il s’agit d’une méthode asynchrone qui retourne un objet Task. Vous devez donc attendre la fin de son exécution avec un await ou un .Wait().

await client.Connect();

Passer des appels aux grains

Passer des appels à un grain depuis un client n’est pas différent du fait de passer de tels appels depuis le code de grain. La même méthode IGrainFactory.GetGrain<TGrainInterface>(Type, Guid), où T est l’interface du grain cible, est utilisée dans les deux cas pour obtenir les références du grain. La différence réside dans l’objet de fabrique dans lequel IGrainFactory.GetGrain est appelé. Dans le code client, vous procédez par le biais de l’objet client connecté, comme l’illustre l’exemple suivant :

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

await joinGameTask;

Un appel à une méthode de grain retourne un élément Task ou Task<TResult>, tel que requis par les règles d’interface de grain. Le client peut utiliser le mot clé await pour attendre de manière asynchrone l’élément Task retourné sans bloquer le thread ou, dans certains cas, la méthode Wait() pour bloquer le thread d’exécution actuel.

La principale différence entre la réalisation d’appels aux grains depuis le code de client et depuis un autre grain est le modèle d’exécution monothread des grains. Les grains sont contraints d’être monothreads par le runtime Orleans, tandis que les clients peuvent être multithreads. Orleans ne fournit aucune garantie de ce type côté client. Par conséquent, il incombe au client de gérer sa concurrence à l’aide des constructions de synchronisation appropriées pour son environnement : verrous, événements et Tasks.

Recevoir des notifications

Il existe certaines situations où un modèle simple de demande/réponse ne suffit pas et où le client a besoin de recevoir des notifications asynchrones. Par exemple, un utilisateur peut souhaiter être averti quand un nouveau message a été publié par une personne qu’il suit.

L’utilisation des observateurs est un mécanisme de ce type qui permet d’exposer des objets côté client en tant que cibles de type grain pour être appelés par des grains. Les appels aux observateurs ne fournissent aucune indication de réussite ou d’échec, car ils sont envoyés sous la forme d’un message de meilleur effort unidirectionnel. Ainsi, il incombe au code d’application de créer un mécanisme de fiabilité de niveau supérieur sur les observateurs, si nécessaire.

Les flux constituent un autre mécanisme permettant de remettre des messages asynchrones aux clients. Les flux exposent des indications de réussite ou d’échec de la remise des messages individuels, et permettent donc une communication fiable en retour au client.

Connectivité client

Il existe deux scénarios dans lesquels un client de cluster peut rencontrer des problèmes de connectivité :

  • Quand le client tente de se connecter à un silo.
  • Lors de la réalisation d’appels sur des références de grain obtenues à partir d’un client de cluster connecté.

Dans le premier cas, le client tente de se connecter à un silo. Si le client ne parvient pas à se connecter à un silo, une exception est levée pour indiquer le problème. Vous pouvez inscrire un IClientConnectionRetryFilter pour gérer l’exception et décider de réessayer ou non. Si aucun filtre de nouvelle tentative n’est fourni ou si le filtre de nouvelle tentative retourne false, le client abandonne définitivement.

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

Il existe deux scénarios dans lesquels un client de cluster peut rencontrer des problèmes de connectivité :

  • Quand la méthode IClusterClient.Connect() est appelée initialement.
  • Lors de la réalisation d’appels sur des références de grain obtenues à partir d’un client de cluster connecté.

Dans le premier cas, la méthode Connect lève une exception pour indiquer le problème qui est survenu. Il s’agit généralement (mais pas nécessairement) d’une exception SiloUnavailableException. Dans ce cas, l’instance de client de cluster est inutilisable et doit être supprimée. Une fonction de filtre de nouvelle tentative peut éventuellement être fournie à la méthode Connect, qui peut, par exemple, attendre un temps spécifié avant d’effectuer une autre tentative. Si aucun filtre de nouvelle tentative n’est fourni ou si le filtre de nouvelle tentative retourne false, le client abandonne définitivement.

Si Connect est retourné correctement, le client de cluster est garanti comme utilisable jusqu’à ce qu’il soit supprimé. Cela signifie que même si le client rencontre des problèmes de connexion, il tentera indéfiniment d’être récupéré. Le comportement de récupération exact peut être configuré sur un objet GatewayOptions fourni par ClientBuilder. Par exemple :

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

Dans le deuxième cas, lorsqu’un problème de connexion se produit au cours d’un appel de grain, une exception SiloUnavailableException est levée côté client. Elle peut être gérée comme suit :

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

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

La référence de grain n’est pas invalidée dans cette situation. L’appel peut être retenté ultérieurement sur la même référence quand une connexion peut avoir été rétablie.

Injection de dépendances

La méthode recommandée pour créer un client externe dans un programme qui utilise l’hôte générique .NET consiste à injecter une instance singleton IClusterClient via l’injection de dépendances, qui peut ensuite être acceptée comme paramètre de constructeur dans les services hébergés, les contrôleurs ASP.NET, etc.

Notes

Lors du co-hébergement d’un silo Orleans dans le processus qui s’y connectera, il n’est pas nécessaire de créer manuellement un client. Orleans en fournira un automatiquement et gérera sa durée de vie de manière appropriée.

Lors de la connexion à un cluster dans un processus différent (sur un autre ordinateur), un modèle courant consiste à créer un service hébergé comme suit :

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

Ce service est ensuite inscrit comme suit :

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

Exemple

Voici une version étendue de l’exemple fourni ci-dessus d’une application cliente qui se connecte à Orleans, recherche le compte de joueur, s’abonne aux mises à jour de la session de jeu à laquelle le joueur participe avec un observateur, et imprime les notifications jusqu’à ce que le programme soit arrêté manuellement.

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