Compartilhar via


Clientes doOrleans

Um cliente permite que o código sem granularidade interaja com um cluster do Orleans. Os clientes permitem que o código do aplicativo se comunique com granularidades e fluxos hospedados em um cluster. Há duas maneiras de obter um cliente, dependendo de onde o código do cliente está hospedado: no mesmo processo que um silo ou em um processo separado. Este artigo abordará as duas opções, começando com a opção recomendada: co-hospedar o código do cliente no mesmo processo que o do código de granularidade.

Clientes co-hospedados

Se o código do cliente estiver hospedado no mesmo processo que o do código de granularidade, o cliente poderá ser obtido diretamente 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 que o silo tem sobre o cluster.

Isso oferece vários benefícios, incluindo a redução da sobrecarga de rede e CPU, bem como a diminuição da latência e o aumento da taxa de transferência e confiabilidade. O cliente utiliza o conhecimento do silo sobre a topologia e o estado do cluster e não precisa usar um gateway separado. Isso evita um salto de rede e a viagem de ida e volta da serialização/desserialização. Isso também aumenta a confiabilidade, pois o número de nós necessários entre o cliente e a granularidade é minimizado. Se a granularidade for uma granularidade de trabalho sem estado ou de outra forma for ativada no silo onde o cliente está hospedado, nenhuma serialização ou comunicação de rede precisará ser executada e o cliente poderá colher os ganhos adicionais de desempenho e confiabilidade. A co-hospedagem do código do cliente e da granularidade também simplifica a implantação e a topologia do aplicativo, eliminando a necessidade de dois binários de aplicativo distintos a serem implantados e monitorados.

Há também detratores nessa abordagem, principalmente que o código de granularidade 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 granularidade. Mesmo sem defeitos de código, como os já mencionados, pode-se acarretar efeitos de vizinhos barulhentos simplesmente por fazer com que o código do cliente seja executado no mesmo processador que o do código de granularidade, assim aumentando a pressão no cache da CPU e gerando uma contenção extra para recursos locais em geral. Além disso, identificar a origem desses problemas torna-se mais difícil porque os sistemas de monitoramento não podem distinguir o que é logicamente o código do cliente do código de granularidade.

Apesar desses detratores, a co-hospedagem do código do cliente e do código de granularidade é uma opção popular e a abordagem recomendada para a maioria dos aplicativos. Para elaborar, os detratores mencionados acima são mínimos na prática pelos seguintes motivos:

  • O código do cliente geralmente é muito pequeno, por exemplo, traduzindo solicitações HTTP de entrada em chamadas de granularidade e, portanto, os efeitos de vizinhos barulhentos são mínimos e comparáveis, em termos de custo, ao normalmente necessário gateway.
  • Se ocorrer um problema de desempenho, o fluxo de trabalho típico para um desenvolvedor envolve ferramentas, como perfis de CPU e depuradores, que ainda são eficazes na identificação rápida da origem do problema, apesar da execução dos código do cliente e de granularidade no mesmo processo. Em outras palavras, as métricas tornam-se menos refinadas e capazes de identificar precisamente a origem de um problema, mas as ferramentas mais detalhadas ainda são eficazes.

Obter um cliente de um host

Se a hospedagem usar o .NET Generic Host, o cliente estará disponível no contêiner de injeção de dependência do host automaticamente e poderá ser injetado em serviços como controladores ASP.NET ou implementações IHostedService.

Como alternativa, uma interface do cliente, como IGrainFactory ou IClusterClient, pode ser obtida do 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 do Orleans em que o código de granularidade está hospedado. Portanto, um cliente externo atua como um conector ou conduíte para o cluster e todas as granularidades do aplicativo. Normalmente, os clientes são usados nos servidores Web de front-end para se conectar a um cluster do Orleans que serve como uma camada intermediária com granularidades 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ária.
  • Decide qual(is) granularidade(s) deve(m) processar a solicitação.
  • Usa o pacote NuGet Microsoft.Orleans.Client para fazer uma ou mais chamadas de método para as granularidades.
  • Manipula a conclusão bem-sucedida ou falhas das chamadas de grãos e quaisquer valores retornados.
  • Envia uma resposta para a solicitação da Web.

Inicialização do cliente de granularidade

Antes que possa ser usado para fazer chamadas para granularidades hospedadas em um cluster do Orleans, um cliente de granularidade precisa ser configurado, inicializado e conectado ao cluster.

A configuração é fornecida por meio de UseOrleansClient e de várias classes de opção complementares que contêm uma hierarquia de propriedades de configuração para configurar programaticamente um cliente. Para obter mais informações, consulte Configuração de cliente.

Considere o seguinte exemplo de uma configuração do 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 o host for iniciado, o cliente será configurado e estará disponível por meio de sua instância de provedor de serviços construída.

A configuração é fornecida por meio de ClientBuilder e de várias classes de opção complementares que contêm uma hierarquia de propriedades de configuração para configurar programaticamente um cliente. Para obter mais informações, consulte Configuração de 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 método Connect() no objeto cliente construído para que ele se conecte ao cluster do Orleans. Trata-se de um método assíncrono que retorna uma Task. Portanto, você precisa aguardar sua conclusão com um await ou .Wait().

await client.Connect();

Fazer chamadas para granularidades

Fazer chamadas para a granularidade de um cliente não é diferente de fazer essas chamadas de dentro do código de granularidade. O mesmo método IGrainFactory.GetGrain<TGrainInterface>(Type, Guid), em que T é a interface de granularidade de destino, é usado em ambos os casos para obter referências de granularidade. A diferença está em qual objeto de fábrica é invocado 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 granularidade retorna uma Task ou um Task<TResult>, conforme exigido pelas regras de interface de granularidade. O cliente pode usar a palavra-chave await para aguardar de modo assíncrono a Task retornada sem bloquear o thread ou, em alguns casos, o método Wait() para bloquear o thread atual de execução.

A principal diferença entre fazer chamadas para granularidade do código do cliente e de dentro de outra granularidade é o modelo de execução de thread único de granularidades. As granularidades são obrigadas a terem um único thread pelo runtime do Orleans, enquanto os clientes podem ter diversos threads. O Orleans não fornece tal garantia no lado cliente e, portanto, cabe ao cliente gerenciar sua simultaneidade usando quaisquer constructos de sincronização apropriados ao seu ambiente: bloqueios, eventos e Tasks.

Receber notificações

Há situações em que um padrão de solicitação/resposta simples não é suficiente e o cliente precisa receber notificações assíncronas. Por exemplo, um usuário pode querer ser notificado quando uma nova mensagem foi publicada por alguém que ela esteja seguindo.

O uso de Observadores é um desses mecanismos que permite expor objetos do lado cliente como destinos semelhantes a granularidades para serem invocados por granularidades. As chamadas aos observadores não fornecem nenhuma indicação de sucesso ou falha, pois são enviadas como mensagem de melhor esforço unidirecional. Portanto, é responsabilidade do código do aplicativo criar um mecanismo de confiabilidade de nível mais alto além dos observadores, quando necessário.

Outro mecanismo que pode ser usado para fornecer mensagens assíncronas a clientes é o Streams. Os fluxos expõem indicações de êxito ou falha na entrega de mensagens individuais e, portanto, permitem o retorno de uma comunicação confiável 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 granularidade obtidas de um cliente de cluster conectado.

No primeiro caso, o cliente tentará se conectar a um silo. Se o cliente não conseguir se conectar a nenhum silo, ele lançará uma exceção para indicar o que deu errado. Você pode registrar um IClientConnectionRetryFilter para lidar com a exceção e decidir se deseja tentar novamente ou não. Se nenhum filtro de repetição for fornecido ou se o filtro de repetição retornar false, o cliente desistirá de vez.

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 método IClusterClient.Connect() é chamado inicialmente.
  • Ao fazer chamadas em referências de granularidade obtidas de um cliente de cluster conectado.

No primeiro caso, o método Connect lançará uma exceção para indicar o que deu errado. Normalmente (não necessariamente), isso é uma SiloUnavailableException. Se isso acontecer, a instância do cliente de cluster será inutilizável, devendo ser descartada. Opcionalmente, uma função de filtro de repetição pode ser fornecida ao método Connect, o qual pode, por exemplo, aguardar um tempo especificado antes de fazer outra tentativa. Se nenhum filtro de repetição for fornecido ou se o filtro de repetição retornar false, o cliente desistirá de vez.

Se Connect retornar com êxito, o cliente do cluster tem a garantia de ser utilizável até que ele seja descartado. Isso significa que, mesmo que o cliente tenha problemas de conexão, ele tentará se recuperar indefinidamente. O comportamento exato de recuperação pode ser configurado 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 granularidade, um SiloUnavailableException será gerado no lado cliente. Isso pode ser tratado 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 granularidade não é invalidada nessa situação; a chamada poderá ser novamente repetida na mesma referência, posteriormente, assim que uma conexão tiver sido restabelecida.

Injeção de dependência

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

Observação

Ao hospedar um silo do Orleans no mesmo processo que se conectará a ele, não é necessário criar manualmente um cliente; o Orleans fornecerá um automaticamente e gerenciará seu tempo de vida de forma adequada.

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

Em seguida, o serviço é registrado da seguinte maneira:

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

Exemplo

Aqui está uma versão estendida do exemplo fornecido acima de um aplicativo cliente que se conecta ao Orleans, localiza a conta do player, assina atualizações na sessão de jogo da qual o player faz parte com um observador e imprime notificações até que o programa seja terminado 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);
    }
}