Partager via


Orleans clients

Un client permet au code non grain d’interagir avec un Orleans cluster. 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ù vous hébergez le code client : dans le même processus qu’un silo ou dans un processus distinct. Cet article décrit les deux options, en commençant par l’approche recommandée : co-hébergement du code client dans le même processus que le code grain.

Clients co-hébergés

Si vous hébergez du code client dans le même processus que le code grain, vous pouvez obtenir directement le client à 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 du silo sur le cluster.

Cette approche offre plusieurs avantages, notamment une surcharge réseau et processeur réduite, une latence réduite et une augmentation du débit et de la fiabilité. Le client utilise les connaissances du silo sur la topologie et l’état du cluster et n’a pas besoin d’une passerelle distincte. Cela évite un saut réseau et un aller-retour de sérialisation/désérialisation, ce qui augmente la fiabilité en minimisant le nombre de nœuds nécessaires entre le client et le grain. Si le grain est un grain worker sans état ou qu’il est activé sur le même silo où le client est hébergé, aucune sérialisation ou communication réseau n’est nécessaire du tout, permettant ainsi au client d’obtenir des gains supplémentaires en termes de performances et de fiabilité. Le client de co-hébergement et le code 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 inconvénients à cette approche, principalement que le code grain n’est plus isolé du processus client. Par conséquent, les problèmes dans le code client, tels que le blocage des E/S ou la contention de verrou à l’origine du manque de threads, peuvent affecter les performances du code lié aux grains. Même sans ces défauts de code, des effets voisins bruyants peuvent se produire simplement parce que le code client s’exécute sur le même processeur que le code grain, en mettant une pression supplémentaire sur le cache du processeur et en augmentant la contention pour les ressources locales. En outre, l’identification de la source de ces problèmes devient plus difficile, car les systèmes de surveillance ne peuvent pas distinguer logiquement le code client et le code grain.

Malgré ces inconvénients, héberger conjointement le code client avec le code grain est une option populaire et c'est l'approche recommandée pour la plupart des applications. Les inconvénients mentionnés ci-dessus sont souvent minimes dans la pratique pour les raisons suivantes :

  • Le code client est souvent très mince (par exemple, la traduction de requêtes HTTP entrantes en appels de grain). Par conséquent, les effets du voisin bruyant sont minimes et comparables au coût de la passerelle nécessaire.
  • Si un problème de performances se produit, votre flux de travail classique implique probablement des outils tels que les profileurs de processeur et les débogueurs. Ces outils restent efficaces pour identifier rapidement la source du problème, même avec le code client et grain s’exécutant dans le même processus. En d’autres termes, alors que les métriques deviennent plus grossières et moins capables d’identifier précisément la source du problème, les outils plus détaillés sont toujours 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. Vous pouvez l’injecter dans des services tels que des contrôleurs ASP.NET ou IHostedService des implémentations.

Vous pouvez également obtenir une interface cliente comme IGrainFactory ou IClusterClient à partir de ISiloHost:

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

Clients externes

Le code client peut s’exécuter en dehors du Orleans cluster où le code grain est hébergé. Dans ce cas, un client externe agit en tant que connecteur ou conduit vers le cluster et tous les grains de l’application. En règle générale, vous utilisez des clients sur des serveurs web frontaux pour vous connecter à un Orleans cluster faisant office de couche intermédiaire, où des grains exécutent la logique métier.

Dans une configuration classique, un serveur web frontal :

  • Reçoit une requête web.
  • Effectue l’authentification et la validation d’autorisation nécessaires.
  • Détermine le ou les grains qui doivent traiter la demande.
  • Utilise Microsoft.Orleans. Package NuGet client pour effectuer un ou plusieurs appels de méthode aux grains.
  • Gère le succès ou l'échec des appels de grain et toutes les valeurs renvoyées.
  • Envoie une réponse à la requête web.

Initialiser un client de grain

Avant de pouvoir utiliser un client grain pour effectuer des appels aux grains hébergés dans un Orleans cluster, vous devez configurer, initialiser et connecter le cluster.

Fournissez la configuration via UseOrleansClient et plusieurs classes d’options supplémentaires contenant une hiérarchie de propriétés de configuration pour configurer par programme un client. Pour plus d’informations, consultez Configuration du client.

Prenons l’exemple suivant d’une configuration cliente :

// 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 vous démarrez le host, le client est configuré et disponible par le biais de son instance de fournisseur de services construite.

Fournissez la configuration via ClientBuilder et plusieurs classes d’options supplémentaires contenant une hiérarchie de propriétés de configuration pour configurer par programme un client. Pour plus d’informations, consultez Configuration du client.

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

Enfin, vous devez appeler la Connect() méthode sur l’objet client construit pour le connecter au Orleans cluster. Il s’agit d’une méthode asynchrone retournant un Task, vous devez donc attendre son achèvement à l’aide await ou .Wait().

await client.Connect();

Effectuer des appels aux grains

L’exécution d’appels à des grains à partir d’un client n’est pas différente de l’exécution de ces appels à partir du code grain. Utilisez la même IGrainFactory.GetGrain<TGrainInterface>(Type, Guid) méthode (où T est l’interface grain cible) dans les deux cas pour obtenir des références de grain. La différence réside dans quel objet de fabrique appelle IGrainFactory.GetGrain. Dans le code client, vous effectuez cette opération via 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 une Task ou Task<TResult>, comme requis par les règles d’interface de grain. Le client peut utiliser le await mot clé pour attendre de manière asynchrone le retour Task sans bloquer le thread, ou dans certains cas, utiliser la Wait() méthode pour bloquer le thread actuel d’exécution.

La principale différence entre le fait d’appeler des grains à partir du code client et de l’intérieur d’un autre grain est le modèle d’exécution monothread des grains. Le Orleans runtime limite les grains à un monothread, alors que les clients peuvent être multi-thread. Orleans ne fournit aucune garantie de ce type côté client. Il est donc à l’utilisateur de gérer sa concurrence à l’aide de constructions de synchronisation appropriées pour son environnement : verrous, événements, Tasksetc.

Recevoir des notifications

Parfois, un modèle de demande-réponse simple n’est pas suffisant et le client doit recevoir des notifications asynchrones. Par exemple, un utilisateur peut souhaiter une notification lorsqu’une personne qu’il suit publie un nouveau message.

L’utilisation d’Observateurs est un mécanisme permettant d’exposer des objets côté client en tant que cibles de type grain à appeler par les grains. Les appels aux observateurs ne fournissent aucune indication du succès ou de l’échec, car ils sont envoyés sous forme de messages unidirectionnel et de meilleurs efforts. Par conséquent, il incombe à votre code d’application de créer un mécanisme de fiabilité de niveau supérieur en plus des observateurs si nécessaire.

Un autre mécanisme de remise de messages asynchrones aux clients est Stream. Les flux exposent des indications de réussite ou d’échec pour la remise de messages individuelle, ce qui permet une communication fiable au client.

Connectivité client

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

  • Lorsque le client tente de se connecter à un silo.
  • Lorsque vous effectuez des 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 peut pas se connecter à un silo, il lève une exception indiquant ce qui s’est passé. Vous pouvez inscrire un IClientConnectionRetryFilter pour gérer l’exception et décider s’il faut réessayer. Si vous ne fournissez pas de filtre de réessai ou si le filtre de réessai 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é :

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

Dans le premier cas, la Connect méthode lève une exception indiquant ce qui s’est passé. Il s’agit généralement (mais pas nécessairement) d’un SiloUnavailableException. Si cela se produit, l’instance du client de cluster est inutilisable et doit être supprimée. Vous pouvez éventuellement fournir une fonction de filtre de nouvelle tentative à la Connect méthode, ce qui peut, par exemple, attendre une durée spécifiée avant d’effectuer une autre tentative. Si vous ne fournissez pas de filtre de réessai ou si le filtre de réessai retourne false, le client abandonne définitivement.

Si Connect renvoie avec succès, le client de cluster est garanti d'être utilisable jusqu'à sa suppression. Cela signifie que même si le client rencontre des problèmes de connexion, il essaie de se rétablir de façon continue. Vous pouvez configurer le comportement de récupération exact sur un GatewayOptions objet fourni par le 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 pendant un appel de grain, un SiloUnavailableException est levé côté client. Vous pouvez gérer cela 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 ; vous pouvez réessayer l’appel sur la même référence ultérieurement lorsqu’une connexion a peut-être été rétablie.

Injection de dépendances

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

Remarque

Lors de l'hébergement conjoint d'un Orleans silo dans le même processus que celui qui lui sera connecté, il n'est pas nécessaire de créer manuellement un client ; Orleans un client sera automatiquement fourni et sa durée de vie sera gérée de manière appropriée.

Lors de la connexion à un cluster dans un autre processus (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();
    }
}

Inscrivez le service comme suit :

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

Exemple :

Voici une version étendue de l’exemple précédent montrant une application cliente qui se connecte à Orleans, recherche le compte du joueur, s’abonne aux mises à jour de la session de jeu dont ce joueur fait partie en utilisant 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.");

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