Compartilhar via


Orleans Clientes

Um cliente permite que o código não granular interaja com um Orleans cluster. Os clientes permitem que o código do aplicativo se comunique com grãos e fluxos hospedados em um cluster. Há duas maneiras de obter um cliente, dependendo de onde você hospeda o código do cliente: no mesmo processo que um silo ou em um processo separado. Este artigo discute as duas opções, começando com a abordagem recomendada: co-hospedar código do cliente no mesmo processo que o código granular.

Clientes co-hospedados

Se você hospedar o código do cliente no mesmo processo que o código granular, poderá obter diretamente o cliente do contêiner de injeção de dependência do aplicativo de hospedagem. Nesse caso, o cliente se comunica diretamente com o silo ao qual está anexado e pode aproveitar o conhecimento extra do silo sobre o cluster.

Essa abordagem oferece vários benefícios, incluindo redução da sobrecarga de rede e CPU, diminuição da latência e aumento da taxa de transferência e confiabilidade. O cliente usa o conhecimento do silo sobre a topologia e o estado do cluster e não precisa de um gateway separado. Isso evita um salto de rede e uma viagem de ida e volta de serialização/desserialização, aumentando assim a confiabilidade minimizando o número de nós necessários entre o cliente e o grão. Se o grão for um grão de trabalho sem estado ou estiver ativado no mesmo silo em que o cliente está hospedado, nenhuma serialização ou comunicação de rede será necessária, permitindo que o cliente obtenha ganhos adicionais de desempenho e confiabilidade. A co-hospedagem de código de cliente e de grãos também simplifica a implantação e a topologia do aplicativo ao eliminar a necessidade de implantar e monitorar dois binários de aplicativos distintos.

Há também desvantagens para essa abordagem, principalmente que o código granulado não está mais isolado do processo do cliente. Portanto, problemas no código do cliente, como bloqueio de E/S ou contenção de bloqueio causando fome de thread, podem afetar o desempenho do código de granulação. Mesmo sem esses defeitos de código, efeitos de ‘vizinhança barulhenta’ podem ocorrer simplesmente porque o código do cliente é executado no mesmo processador que o código de grain, colocando tensão adicional no cache da CPU e aumentando a contenção de recursos locais. Além disso, identificar a origem desses problemas torna-se mais difícil porque os sistemas de monitoramento não podem distinguir logicamente entre código do cliente e código granulado.

Apesar dessas desvantagens, a co-hospedagem de código do cliente com código granulado é uma opção popular e a abordagem recomendada para a maioria dos aplicativos. As desvantagens mencionadas são geralmente mínimas na prática pelos seguintes motivos:

  • O código do cliente geralmente é muito fino (por exemplo, traduzindo solicitações HTTP de entrada em chamadas granuladas). Portanto, os efeitos de 'vizinho barulhento' são mínimos e comparáveis em custo ao gateway que seria necessário de outra forma.
  • Se surgir um problema de desempenho, seu fluxo de trabalho típico provavelmente envolverá ferramentas como perfiladores de CPU e depuradores. Essas ferramentas permanecem eficazes na identificação rápida da origem do problema, mesmo com o cliente e o código granulado em execução no mesmo processo. Em outras palavras, enquanto as métricas se tornam mais grosseiras e menos capazes de identificar com precisão a origem do problema, ferramentas mais detalhadas ainda são eficazes.

Obter um cliente a partir de um host

Se você hospedar usando o Host Genérico do .NET, o cliente estará disponível automaticamente no contêiner de injeção de dependência do host. Você pode injetá-lo em serviços como controladores de ASP.NET ou IHostedService implementações.

Como alternativa, você pode obter uma interface do cliente como IGrainFactory ou IClusterClient de ISiloHost:

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

Clientes externos

O código do cliente pode ser executado fora do cluster em que o Orleans código granular está hospedado. Nesse caso, um cliente externo atua como um conector ou canal para o cluster e todos os grãos do aplicativo. Normalmente, você utiliza clientes em servidores web de front-end para se conectar a um cluster Orleans que atua como camada intermediária, com grains executando a lógica de negócios.

Em uma configuração típica, um servidor Web de front-end:

  • Recebe uma solicitação da Web.
  • Executa a validação de autenticação e autorização necessárias.
  • Decide quais grãos devem processar a solicitação.
  • Usa o pacote NuGet do Microsoft.Orleans.Client para fazer uma ou mais chamadas de método para os grãos.
  • Lida com a conclusão ou falhas bem-sucedidas das chamadas de grãos e dos valores retornados.
  • Envia uma resposta à solicitação da Web.

Inicializar um cliente de grãos

Antes de poder usar um cliente de grãos para fazer chamadas a grãos hospedados em um Orleans cluster, você precisa configurá-lo, inicializar e conectá-lo ao cluster.

Forneça a configuração por meio de UseOrleansClient e de várias classes de opções complementares que contêm uma hierarquia de propriedades de configuração para configurar um cliente de forma programática. Para obter mais informações, consulte a configuração do cliente.

Considere o exemplo a seguir de uma configuração de 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();

Quando você inicia o host, o cliente é configurado e fica disponível por meio de sua instância do provedor de serviços construído.

Forneça a configuração por meio de ClientBuilder e de várias classes de opções complementares que contêm uma hierarquia de propriedades de configuração para configurar um cliente de forma programática. Para obter mais informações, consulte a configuração do cliente.

Exemplo de uma configuração de cliente:

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

Por fim, você precisa chamar o Connect() método no objeto cliente construído para conectá-lo ao Orleans cluster. É um método assíncrono que retorna um Task, portanto, você precisa aguardar sua conclusão usando await ou .Wait().

await client.Connect();

Fazer chamadas para grãos

Fazer chamadas para grãos de um cliente não é diferente de fazer essas chamadas de dentro do código granular. Use o método IGrainFactory.GetGrain<TGrainInterface>(Type, Guid) (onde a interface alvo é T) em ambos os casos para obter referências de grãos. A diferença está em qual objeto de fábrica invoca IGrainFactory.GetGrain. No código do cliente, você faz isso por meio do objeto cliente conectado, como mostra o exemplo a seguir:

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

await joinGameTask;

Uma chamada para um método de grão retorna um Task ou Task<TResult>, conforme exigido pelas regras de interface de grãos. O cliente pode usar a await palavra-chave para aguardar assíncronamente o retornado Task sem bloquear o thread ou, em alguns casos, usar o Wait() método para bloquear o thread atual de execução.

A principal diferença entre fazer chamadas para grãos do código do cliente e dentro de um outro grão é o modelo de execução de thread único dos grãos. O Orleans runtime restringe os grãos a serem de thread único, enquanto os clientes podem ser multi-threaded. Orleans não fornece nenhuma garantia desse tipo no lado do cliente, portanto, cabe ao cliente gerenciar sua simultaneidade usando construções de sincronização apropriadas para seu ambiente — bloqueios, eventos etc Tasks.

Receber notificações

Às vezes, um padrão simples de solicitação-resposta não é suficiente e o cliente precisa receber notificações assíncronas. Por exemplo, um usuário pode querer notificação quando alguém que segue publicar uma nova mensagem.

O uso de Observadores é um mecanismo que permite expor objetos do lado do cliente como alvos semelhantes a grãos para serem invocados por grãos. As chamadas aos observadores não fornecem nenhuma indicação de êxito ou falha, pois são enviadas como mensagens unidirecionais e de melhor esforço. Portanto, é responsabilidade do código do aplicativo desenvolver um mecanismo de confiabilidade de nível superior com base nos observadores, quando necessário.

Outro mecanismo para fornecer mensagens assíncronas aos clientes é o Streams. Os fluxos expõem indicações de êxito ou falha para entrega de mensagens individuais, permitindo a comunicação confiável de volta ao cliente.

Conectividade de cliente

Há dois cenários em que um cliente de cluster pode enfrentar problemas de conectividade:

  • Quando o cliente tenta se conectar a um silo.
  • Ao fazer chamadas em referências de grãos obtidas de um cliente de cluster conectado.

No primeiro caso, o cliente tenta se conectar a um silo. Se o cliente não puder se conectar a nenhum silo, ele gerará uma exceção indicando o que deu errado. Você pode registrar um IClientConnectionRetryFilter para lidar com a exceção e decidir se deseja tentar novamente. Se você não fornecer nenhum filtro de repetição ou se o filtro de repetição retornar false, o cliente desistirá permanentemente.

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

Há dois cenários em que um cliente de cluster pode enfrentar problemas de conectividade:

  • Quando o IClusterClient.Connect() método é chamado inicialmente.
  • Ao fazer chamadas em referências de grãos obtidas de um cliente de cluster conectado.

No primeiro caso, o Connect método gera uma exceção indicando o que deu errado. Normalmente, isso é (mas não necessariamente) um SiloUnavailableException. Se isso acontecer, a instância do cliente do cluster será inutilizável e deverá ser descartada. Opcionalmente, você pode fornecer uma função de filtro de repetição para o método Connect, que, por exemplo, pode aguardar por um período especificado antes de fazer outra tentativa. Se você não fornecer nenhum filtro de repetição ou se o filtro de repetição retornar false, o cliente desistirá permanentemente.

Se Connect retorna com êxito, o cliente do cluster tem a garantia de ser utilizável até que seja descartado. Isso significa que, mesmo que o cliente tenha problemas de conexão, ele tentará se recuperar indefinidamente. Você pode configurar o comportamento exato de recuperação em um objeto GatewayOptions fornecido pelo ClientBuilder, por exemplo:

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

No segundo caso, em que ocorre um problema de conexão durante uma chamada de grain, um SiloUnavailableException é lançado no lado do cliente. Você poderia lidar com isso da seguinte maneira:

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

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

A referência de grain não é invalidada nessa situação; você pode tentar novamente a chamada na mesma referência mais tarde, caso uma conexão tenha sido restabelecida.

Injeção de dependência

A maneira recomendada de criar um cliente externo em um programa usando o .NET Generic Host é injetar uma instância do tipo IClusterClient singleton por meio de injeção de dependência. Essa instância pode ser aceita como um parâmetro de construtor em serviços hospedados, controladores de ASP.NET etc.

Observação

Ao hospedar um Orleans silo no mesmo processo que será conectado a ele, não é necessário criar manualmente um cliente; Orleans fornecerá automaticamente um e gerenciará seu ciclo de vida adequadamente.

Ao se conectar a um cluster em um processo diferente (em um computador diferente), um padrão comum é criar um serviço hospedado como este:

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

Registre o serviço assim:

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

Exemplo

Aqui está uma versão estendida do exemplo anterior mostrando um aplicativo cliente que se conecta a Orleans, localiza a conta do jogador e assina atualizações para a sessão do jogo da qual o jogador participa usando um observador, imprimindo notificações até que o programa seja encerrado 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.");

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