Orleans -Clients

Ein Client ermöglicht die Interaktion von Nicht-Grain-Code mit einem Orleans-Cluster. Clients ermöglichen Anwendungscode die Kommunikation mit in einem Cluster gehosteten Grains und Streams. Je nachdem, wo der Client Code gehostet wird, gibt es zwei Möglichkeiten, einen Client zu erhalten: im selben Prozess wie ein Silo oder in einem separaten Prozess. In diesem Artikel werden beide Optionen beginnend mit der empfohlenen Option erläutert: den Client- und Grain-Code im selben Prozess gemeinsam hosten.

Gemeinsam gehostete Clients

Wenn der Clientcode im selben Prozess wie der Grain-Code gehostet wird, kann der Client direkt aus dem Dependency Injection-Container der Hostanwendung abgerufen werden. In diesem Fall kommuniziert der Client direkt mit dem Silo, an das er angefügt ist, und kann das zusätzliche Wissen nutzen, das das Silo über den Cluster hat.

Dies bietet mehrere Vorteile, darunter Reduzierung des Netzwerk- und CPU-Aufwands sowie Verringerung von Latenz und Erhöhung von Durchsatz und Zuverlässigkeit. Der Client nutzt das Wissen des Silos über Clustertopologie und -zustand und muss kein separates Gateway verwenden. Dadurch werden ein Netzwerkhop und ein Roundtrip für Serialisierung/Deserialisierung vermieden. Dadurch erhöht sich auch die Zuverlässigkeit, da die Anzahl der erforderlichen Knoten zwischen Client und Grain minimiert wird. Wenn es sich bei dem Grain um ein zustandsloses Worker-Grain handelt oder es für das Silo, in dem der Client gehostet wird, aktiviert ist, muss überhaupt keine Serialisierung oder Netzwerkkommunikation erfolgen, und der Client kann von den zusätzlichen Leistungs- und Zuverlässigkeitsoptimierungen profitieren. Das gemeinsame Hosten von Client- und Grain-Code vereinfacht auch die Bereitstellungs- und Anwendungstopologie, da nicht mehr zwei unterschiedliche Anwendungsbinärdateien bereitgestellt und überwacht werden müssen.

Es gibt auch Nachteile dieses Ansatzes, vor allem die Tatsache, dass der Grain-Code nicht mehr vom Clientprozess isoliert ist. Daher können Probleme im Clientcode, wie z. B. blockierende E/A-Vorgänge oder Sperrkonflikte, die einen Mangel an Threads verursachen, die Leistung von Grain-Code beeinträchtigen. Selbst ohne Codefehler wie die oben erwähnten können Noisy Neighbour-Auswirkungen einfach dadurch entstehen, dass der Clientcode auf demselben Prozessor wie der Grain-Code ausgeführt wird. Dies führt zu einer zusätzlichen Belastung des CPU-Zwischenspeichers und zu zusätzlichen Konflikten bei lokalen Ressourcen im Allgemeinen. Außerdem ist es jetzt schwieriger, die Ursache dieser Probleme herauszufinden, da die Überwachungssysteme nicht unterscheiden können, was logischerweise Client- und was Grain-Code ist.

Trotz dieser Nachteile ist das gemeinsame Hosten von Client- und Grain-Code eine beliebte Option und der empfohlene Ansatz für die meisten Anwendungen. In der Praxis sind die oben genannten Nachteile aus folgenden Gründen minimal:

  • Clientcode ist oft sehr schlank, z. B. bei der Übersetzung eingehender HTTP-Anforderungen in Grain-Aufrufe. Daher sind die Noisy Neighbour-Auswirkungen minimal und von den Kosten her vergleichbar mit dem ansonsten erforderlichen Gateway.
  • Wenn ein Leistungsproblem auftritt, umfasst der typische Workflow eines Entwicklers Tools wie CPU-Profiler und Debugger, mit denen sich die Ursache des Problems auch dann schnell ermitteln lässt, wenn sowohl Client- als auch Quellcode im selben Prozess ausgeführt werden. Mit anderen Worten, die Metriken werden zunehmend ungenauer und sind weniger in der Lage, die Ursache eines Problems genau zu bestimmen, aber detailliertere Tools sind nach wie vor effektiv.

Abrufen eines Clients von einem Host

Beim Hosten mit dem generischen .NET-Host ist der Client automatisch im Dependency Injection-Container des Hosts verfügbar und kann in Dienste wie ASP.NET-Controller oder IHostedService-Implementierungen eingefügt werden.

Alternativ kann eine Clientschnittstelle wie IGrainFactory oder IClusterClient von ISiloHost abgerufen werden:

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

Externe Clients

Clientcode kann außerhalb des Orleans-Clusters ausgeführt werden, in dem Grain-Code gehostet wird. Ein externer Client fungiert also als Connector oder Leitung zum Cluster und allen Grains der Anwendung. Normalerweise werden Clients im Front-End-Webserver verwendet, um sich mit einem Orleans-Cluster zu verbinden, der als mittlere Ebene mit Grains dient, die die Geschäftslogik ausführen.

In einer typischen Einrichtung hat ein Front-End-Webserver diese Aufgaben:

  • Empfängt eine Webanforderung.
  • Führt die erforderliche Authentifizierungs- und Autorisierungsprüfung durch.
  • Entscheidet, welche Grains die Anforderung verarbeiten sollen.
  • Verwendet das NuGet-Paket Microsoft.Orleans.Client, um einen oder mehrere Methodenaufrufe an die Grains zu richten.
  • Behandelt den erfolgreichen Abschluss oder Fehler der Grain-Aufrufe und aller zurückgegebenen Werte.
  • Sendet eine Antwort auf die Webanforderung.

Initialisierung des Grain-Clients

Ehe ein Grain-Client zum Richten von Aufrufen an Grains verwendet werden kann, die in einem Orleans-Cluster gehostet werden, muss er konfiguriert, initialisiert und mit dem Cluster verbunden werden.

Die Konfiguration erfolgt über UseOrleansClient und mehrere ergänzende Optionsklassen, die eine Hierarchie von Konfigurationseigenschaften für die programmgesteuerte Konfiguration eines Clients enthalten. Weitere Informationen finden Sie unter Clientkonfiguration.

Betrachten Sie das folgende Beispiel einer Clientkonfiguration:

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

Wenn der host gestartet wird, wird der Client konfiguriert, der über seine eingerichtete Dienstanbieterinstanz verfügbar ist.

Die Konfiguration erfolgt über ClientBuilder und mehrere ergänzende Optionsklassen, die eine Hierarchie von Konfigurationseigenschaften für die programmgesteuerte Konfiguration eines Clients enthalten. Weitere Informationen finden Sie unter Clientkonfiguration.

Beispiel einer Clientkonfiguration:

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

Schließlich müssen Sie die Connect()-Methode für das eingerichtete Clientobjekt aufrufen, damit es sich mit dem Orleans-Cluster verbindet. Es handelt sich um eine asynchrone Methode, die Task zurückgibt. Sie müssen also warten, bis der Vorgang mit await oder .Wait() abgeschlossen wird.

await client.Connect();

Richten von Aufrufen an Grains

Das Richten von Aufrufen an ein Grain aus einem Client unterscheidet sich nicht vom Richten solcher Aufrufe aus Grain-Code. Die gleiche IGrainFactory.GetGrain<TGrainInterface>(Type, Guid)-Methode, bei der T die Schnittstelle des Ziel-Grains ist, wird in beiden Fällen verwendet, um Grain-Verweise zu erhalten. Der Unterschied besteht darin, in welchem Factoryobjekt IGrainFactory.GetGrain aufgerufen wird. Im Clientcode erfolgt dies über das verbundene Clientobjekt, wie das folgende Beispiel zeigt:

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

await joinGameTask;

Ein Aufruf einer Grain-Methode gibt Task oder Task<TResult> zurück, wie es die Regeln der Grain-Schnittstelle verlangen. Der Client kann das Schlüsselwort await verwenden, um asynchron auf die zurückgegebene Task-Methode zu warten, ohne den Thread zu blockieren, oder in einigen Fällen die Wait()-Methode, um den aktuellen Ausführungsthread zu blockieren.

Der Hauptunterschied zwischen Aufrufen von Grains im Clientcode und in einem anderen Grain ist das Singlethread-Ausführungsmodell der Grains. Grains werden von der Orleans-Runtime auf Singlethread beschränkt, während Clients Multithread sein können. Orleans bietet keine derartige Garantie auf Clientseite, sodass es dem Client überlassen bleibt, seine Parallelität mithilfe von Synchronisierungskonstrukten zu verwalten, die für seine Umgebung geeignet sind: Sperren, Ereignisse und Tasks.

Benachrichtigungen empfangen

Es gibt Situationen, in denen ein einfaches Anforderung-/Antwort-Muster nicht ausreicht und der Client asynchrone Benachrichtigungen empfangen muss. Ein Benutzer möchte zum Beispiel benachrichtigt werden, wenn jemand, dem er folgt, eine neue Nachricht veröffentlicht hat.

Die Verwendung von Beobachtern ist ein solcher Mechanismus, der es ermöglicht, Objekte auf der Clientseite als mit Grains vergleichbare Ziele verfügbar zu machen, die von Grains aufgerufen werden können. Aufrufe an Beobachter liefern keinen Hinweis auf Erfolg oder Fehler, da sie als unidirektionale Best-Effort-Nachricht gesendet werden. Es liegt also in der Zuständigkeit des Anwendungscodes, bei Bedarf für mehr Zuverlässigkeit einen auf Beobachtern aufsetzenden Mechanismus zu implementieren.

Ein weiterer Mechanismus für die Übermittlung asynchroner Nachrichten an Clients sind Streams. Streams machen Angaben zum Erfolg oder Misserfolg der Zustellung einzelner Nachrichten verfügbar und ermöglichen so eine zuverlässige Kommunikation zurück zum Client.

Clientkonnektivität

Es gibt zwei Szenarien, in denen bei einem Clusterclient Konnektivitätsprobleme auftreten können:

  • Wenn der Client versucht, eine Verbindung mit einem Silo herzustellen.
  • Wenn Grain-Verweise aufgerufen werden, die von einem verbundenen Clusterclient abgerufen wurden.

Im ersten Fall versucht der Client, eine Verbindung mit einem Silo herzustellen. Wenn der Client keine Verbindung mit einem Silo herstellen kann, löst er eine Ausnahme aus, um anzugeben, was schief gelaufen ist. Sie können einen IClientConnectionRetryFilter registrieren, um die Ausnahme zu behandeln und zu entscheiden, ob eine Wiederholung erfolgen soll oder nicht. Wenn kein Wiederholungsfilter bereitgestellt wird oder der Wiederholungsfilter false zurückgibt, gibt der Client endgültig auf.

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

Es gibt zwei Szenarien, in denen bei einem Clusterclient Konnektivitätsprobleme auftreten können:

  • Wenn die IClusterClient.Connect()-Methode anfänglich aufgerufen wird.
  • Wenn Grain-Verweise aufgerufen werden, die von einem verbundenen Clusterclient abgerufen wurden.

Im ersten Fall löst die Connect-Methode eine Ausnahme aus, um anzugeben, was schief gelaufen ist. Dies ist in der Regel (aber nicht unbedingt) eine SiloUnavailableException. In diesem Fall ist die Clusterclientinstanz unbrauchbar und sollte verworfen werden. Der Connect-Methode kann optional eine Wiederholungsfilterfunktion bereitgestellt werden, die z. B. eine bestimmte Zeitspanne abwartet, ehe ein neuer Versuch unternommen wird. Wenn kein Wiederholungsfilter bereitgestellt wird oder der Wiederholungsfilter false zurückgibt, gibt der Client endgültig auf.

Wenn Connect erfolgreich zurückgegeben wird, ist der Clusterclient garantiert nutzbar, bis er verworfen wird. Das bedeutet, dass der Client selbst bei Verbindungsproblemen versucht, die Verbindung auf unbestimmte Zeit wiederherzustellen. Das genaue Wiederherstellungsverhalten kann für ein von ClientBuilder bereitgestelltes GatewayOptions-Objekt konfiguriert werden, z. B.:

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

Im zweiten Fall, wenn während eines Grain-Aufrufs ein Verbindungsproblem auftritt, wird eine SiloUnavailableException clientseitig ausgelöst. Diese kann wie folgt gehandhabt werden:

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

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

Der Grain-Verweis wird in dieser Situation nicht ungültig gemacht. Der Aufruf kann später mit demselben Verweis erneut versucht werden, nachdem eine Verbindung wiederhergestellt wurde.

Dependency Injection

Der empfohlene Weg zum Erstellen eines externen Clients in einem Programm, das den generischen .NET-Host verwendet, besteht darin, eine Instanz eines IClusterClient-Singletons über Dependency Injection einzufügen, die dann als Konstruktorparameter in gehosteten Diensten, ASP.NET-Controllern usw. akzeptiert werden kann.

Hinweis

Beim gemeinsamen Hosten eines Orleans-Silos im Prozess, der eine Verbindung damit herstellt, ist es nicht notwendig, einen Client manuell zu erstellen. Orleans stellt automatisch einen bereit und verwaltet seine Lebensdauer entsprechend.

Wenn Sie in einem anderen Prozess (auf einem anderen Computer) eine Verbindung mit einem Cluster herstellen, ist es üblich, einen gehosteten Dienst wie diesen zu erstellen:

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

Der Dienst wird dann wie folgt registriert:

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

Beispiel

Hier ist eine erweiterte Version des obigen Beispiels einer Clientanwendung, die sich mit Orleans verbindet, das Konto des Spielers findet, Aktualisierungen der Spielsitzung, an der der Spieler teilnimmt, mithilfe eines Beobachters abonniert und Benachrichtigungen ausgibt, bis das Programm manuell beendet wird.

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