Freigeben über


Orleans-Clients

Ein Client ermöglicht die Interaktion von Nicht-Grain-Code mit einem Orleans-Cluster. Clients erlauben Anwendungscode die Kommunikation mit Grains und Datenströmen, die in einem Cluster gehostet werden. Es gibt zwei Möglichkeiten, einen Client zu erhalten, je nachdem, wo Sie den Clientcode hosten: im selben Prozess wie ein Silo oder in einem separaten Prozess. In diesem Artikel werden beide Optionen erläutert, beginnend mit dem empfohlenen Ansatz: Gemeinsames Hosten von Clientcode im selben Prozess wie Getreidecode.

Gemeinsam gehostete Clients

Wenn Sie Clientcode im selben Prozess wie Korncode hosten, können Sie den Client direkt aus dem Abhängigkeitseinfügungscontainer der Hostanwendung abrufen. In diesem Fall kommuniziert der Kunde direkt mit dem Silo, dem es zugeordnet ist, und kann das zusätzliche Wissen des Silos über den Cluster nutzen.

Dieser Ansatz bietet mehrere Vorteile, einschließlich reduzierter Netzwerk- und CPU-Mehraufwand, verringerter Latenz und erhöhter Durchsatz und Zuverlässigkeit. Der Client verwendet das Wissen des Silos über die Clustertopologie und den Status und benötigt kein separates Gateway. Dadurch wird ein Netzwerksprung und eine Serialisierungs-/Deserialisierungsrunde vermieden, was die Zuverlässigkeit erhöht, indem die Anzahl der erforderlichen Knoten zwischen dem Client und dem Grain minimiert wird. Wenn es sich bei dem Getreide um ein zustandsloses Arbeitskorn handelt oder auf demselben Silo aktiviert wird, in dem der Client gehostet wird, ist überhaupt keine Serialisierung oder Netzwerkkommunikation erforderlich, sodass der Client zusätzliche Leistungs- und Zuverlässigkeitsgewinne erzielen kann. Das Co-Hosting von Client- und Grain-Code vereinfacht zudem die Bereitstellung und Anwendungsarchitektur, da die Bereitstellung und Überwachung von zwei separaten Anwendungsbinärdateien nicht mehr nötig ist.

Es gibt auch Nachteile für diesen Ansatz, in erster Linie, dass der Korncode nicht mehr vom Clientprozess isoliert ist. Daher können Probleme im Clientcode, z. B. Blockierung von E/A oder Sperrkonflikte, die Thread-Verhungern verursachen, die Leistung von Grain-Code beeinträchtigen. Auch ohne solche Codefehler können Noisy-Neighbor-Effekte einfach auftreten, da Clientcode auf demselben Prozessor wie der Grain-Code ausgeführt wird, was die Belastung des CPU-Caches erhöht und zu erhöhter Konkurrenz um lokale Ressourcen führt. Darüber hinaus wird die Identifizierung der Quelle dieser Probleme schwieriger, da Überwachungssysteme nicht logisch zwischen Clientcode und Korncode unterscheiden können.

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

  • Client-Code ist häufig sehr dünn (z. B. das Übersetzen eingehender HTTP-Anfragen in Grain-Aufrufe). Daher sind Noisy Neighbor-Effekte minimal und vergleichbar mit dem sonst notwendigen Gateway.
  • Wenn ein Leistungsproblem auftritt, umfasst Ihr typischer Workflow wahrscheinlich Tools wie CPU-Profilierer und Debugger. Diese Tools bleiben effektiv, um die Quelle des Problems schnell zu identifizieren, auch wenn Client- und Korncode im selben Prozess ausgeführt wird. Mit anderen Worten, während Metriken grober und weniger in der Lage sind, die Quelle des Problems präziser zu identifizieren, sind detailliertere Tools immer noch effektiv.

Abrufen eines Clients von einem Host

Wenn Sie den .NET Generic Host verwenden, ist der Client automatisch im Container zum Einfügen von Abhängigkeiten des Hosts verfügbar. Sie können sie in Dienste wie ASP.NET Controller oder IHostedService Implementierungen einfügen.

Alternativ können Sie eine Client-Schnittstelle wie IGrainFactory oder IClusterClient von ISiloHost erhalten.

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 Korncode gehostet wird. In diesem Fall fungiert ein externer Client als Verbinder oder Anschluss an den Cluster und alle Körner der Anwendung. In der Regel verwenden Sie Clients auf Frontend-Servern, um eine Verbindung mit einem Orleans Cluster herzustellen, der als Zwischenschicht dient, wobei Grains Geschäft logik ausgeführt wird.

In einem typischen Setup, einem Front-End-Webserver:

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

Initialisieren eines Getreideclients

Bevor Sie einen Getreideclient verwenden können, um Anrufe an Getreide zu tätigen, die in einem Orleans Cluster gehostet werden, müssen Sie sie konfigurieren, initialisieren und mit dem Cluster verbinden.

Stellen Sie die Konfiguration über UseOrleansClient und mehrere zusätzliche Optionsklassen bereit, 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 Sie den host Client starten, wird der Client über die erstellte Serviceanbieterinstanz konfiguriert und verfügbar.

Stellen Sie die Konfiguration über ClientBuilder und mehrere zusätzliche Optionsklassen bereit, die eine Hierarchie von Konfigurationseigenschaften für die programmgesteuerte Konfiguration eines Clients enthalten. Weitere Informationen finden Sie unter Clientkonfiguration.

Beispiel für eine 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 erstellte Clientobjekt aufrufen, um sie mit dem Orleans Cluster zu verbinden. Es handelt sich um eine asynchrone Methode, die ein Task zurückgibt, sodass Sie warten müssen, bis der Vorgang mit await oder .Wait() abgeschlossen ist.

await client.Connect();

Richten von Aufrufen an Grains

Das Ausführen von Aufrufen an Grains von einem Client unterscheidet sich nicht von dem Ausführen solcher Aufrufe innerhalb des Grain-Codes. Verwenden Sie dieselbe IGrainFactory.GetGrain<TGrainInterface>(Type, Guid) Methode (bei der T es sich um die Zielkornschnittstelle handelt) in beiden Fällen , um Kornbezüge zu erhalten. Der Unterschied liegt darin, welches Fabrikobjekt IGrainFactory.GetGrain aufruft. Im Clientcode führen Sie dies über das verbundene Clientobjekt aus, wie im folgenden Beispiel gezeigt:

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

await joinGameTask;

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

Der Hauptunterschied zwischen Aufrufen von Grains im Clientcode und in einem anderen Grain ist das Singlethread-Ausführungsmodell der Grains. Die Orleans Laufzeitumgebung beschränkt Grains darauf, dass sie nur einzeln ausführbar sind, während Clients multithreaded sein können. Orleans bietet keine solche Garantie auf der clientseitigen Seite, daher liegt es an dem Client, seine Parallelität mithilfe geeigneter Synchronisierungskonstrukte für seine Umgebung zu verwalten – Sperren, Ereignisse usw Tasks.

Benachrichtigungen empfangen

Manchmal reicht ein einfaches Anforderungsantwortmuster nicht aus, und der Client muss asynchrone Benachrichtigungen empfangen. Beispielsweise kann ein Benutzer eine Benachrichtigung wünschen, wenn jemand, dem er folgt, eine neue Nachricht veröffentlicht.

Die Verwendung von Beobachtern ist ein Mechanismus, mit dem die Exponierung von Client-seitigen Objekten als Grain-ähnliche Ziele ermöglicht wird, die von Grains aufgerufen werden können. Aufrufe an Beobachter geben keinen Hinweis auf Erfolg oder Misserfolg, da sie als unidirektionale, best-effort-Nachrichten gesendet werden. Daher liegt es in der Verantwortung Ihres Anwendungscodes, bei Bedarf einen übergeordneten Zuverlässigkeitsmechanismus auf Basis von Beobachtern zu erstellen.

Ein weiterer Mechanismus zum Übermitteln asynchroner Nachrichten an Clients ist Streams. Datenströme machen Hinweise auf Erfolg oder Fehler für die einzelne Nachrichtenübermittlung verfügbar, wodurch eine zuverlässige Kommunikation mit dem Client ermöglicht wird.

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.
  • Beim Aufrufen von Kornbezügen, die von einem verbundenen Clusterclient abgerufen werden.

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, die angibt, was schief gelaufen ist. Sie können ein IClientConnectionRetryFilter registrieren, um die Ausnahme zu behandeln, und entscheiden, ob ein neuer Versuch unternommen werden soll. Wenn Sie keinen Wiederholungsfilter angeben oder wenn 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.
  • Beim Aufrufen von Kornbezügen, die von einem verbundenen Clusterclient abgerufen werden.

Im ersten Fall löst die Connect Methode eine Ausnahme aus, die angibt, 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. Sie können optional eine Wiederholungsfilterfunktion für die Connect Methode bereitstellen, die z. B. auf eine angegebene Dauer warten kann, bevor Sie einen anderen Versuch ausführen. Wenn Sie keinen Wiederholungsfilter angeben oder wenn der Wiederholungsfilter false zurückgibt, gibt der Client endgültig auf.

Wenn Connect erfolgreich ausgeführt wird, kann der Clusterclient bis zum Löschen verwendet werden. Dies bedeutet, dass der Client auch bei Verbindungsproblemen unablässig versucht, die Verbindung wiederherzustellen. Sie können das genaue Wiederherstellungsverhalten für ein GatewayOptions objekt konfigurieren, das von dem ClientBuilderangegebenen Objekt bereitgestellt wird, z. B.:

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

Im zweiten Fall, bei dem ein Verbindungsproblem während eines grain-Aufrufs auftritt, wird ein SiloUnavailableException Fehler client-seitig ausgelöst. Sie können dies wie folgt behandeln:

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

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

Der Kornbezug ist in dieser Situation nicht ungültig; Sie können den Anruf später erneut versuchen, wenn eine Verbindung möglicherweise erneut hergestellt wurde.

Abhängigkeitsinjektion

Die empfohlene Methode zum Erstellen eines externen Clients in einem Programm mit dem generischen .NET-Host besteht darin, eine IClusterClient Singleton-Instanz über die Abhängigkeitsinjektion zu injizieren. Diese Instanz kann dann als Konstruktorparameter in gehosteten Diensten, ASP.NET Controllern usw. akzeptiert werden.

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

Registrieren Sie den Dienst wie folgt:

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

Beispiel

Hier sehen Sie eine erweiterte Version des vorherigen Beispiels mit einer Clientanwendung, die zu Orleans eine Verbindung herstellt, das Spielerkonto findet, Updates für die Spielsitzung abonniert, an der der Spieler teilnimmt, und gibt Benachrichtigungen aus, 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.");

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