Partilhar via


Orleans

Um cliente permite que código não granulado 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 o código do cliente está hospedado: no mesmo processo que um silo ou em um processo separado. Este artigo discutirá ambas as opções, começando com a opção recomendada: co-hospedar o código do cliente no mesmo processo que o código grain.

Clientes co-hospedados

Se o código do cliente estiver hospedado no mesmo processo que o código grain, o cliente poderá ser obtido diretamente do contêiner de injeção de dependência do aplicativo de hospedagem. Neste caso, o cliente comunica diretamente com o silo ao qual está ligado e pode tirar partido do 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 da 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 uma viagem de ida e volta de serialização/desserialização. Portanto, isso também aumenta a confiabilidade, uma vez que o número de nós necessários entre o cliente e o grão é minimizado. Se o grão for um grão de trabalhador sem estado ou for ativado 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. O cliente de co-hospedagem e o código grain também simplifica a implantação e a topologia do aplicativo, eliminando a necessidade de dois binários de aplicativos distintos a serem implantados e monitorados.

Há também detratores dessa abordagem, principalmente que o código de grãos 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 inanição de thread podem afetar o desempenho do código grain. Mesmo sem defeitos de código como os mencionados acima, efeitos vizinhos barulhentos podem resultar simplesmente por ter o código do cliente executado no mesmo processador que o código grain, colocando pressão adicional no cache da CPU e contenção adicional para recursos locais em geral. Além disso, identificar a origem desses problemas agora é mais difícil porque os sistemas de monitoramento não conseguem distinguir o que é logicamente código cliente de código grain.

Apesar desses detratores, co-hospedar código de cliente com código grain é uma opção popular e a abordagem recomendada para a maioria dos aplicativos. Para elaborar, os detratores acima mencionados são mínimos na prática pelas seguintes razões:

  • O código do cliente geralmente é muito fino, por exemplo, traduzindo solicitações HTTP de entrada em chamadas grain e, portanto, os efeitos de vizinhança barulhentos são mínimos e comparáveis em custo ao gateway necessário.
  • Se surgir um problema de desempenho, o fluxo de trabalho típico para um desenvolvedor envolve ferramentas como criadores de perfil e depuradores de CPU, que ainda são eficazes na identificação rápida da origem do problema, apesar de terem o cliente e o código grain em execução no mesmo processo. Em outras palavras, as métricas se tornam mais grosseiras e menos capazes de identificar com precisão a fonte de um problema, mas ferramentas mais detalhadas ainda são eficazes.

Obter um cliente de um host

Se hospedar usando 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 de ASP.NET ou IHostedService implementações.

Alternativamente, uma interface de cliente como IGrainFactory ou IClusterClient pode ser obtida em 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 onde o Orleans código grain está hospedado. Assim, um cliente externo atua como um conector ou canal para o cluster e todos os grãos do aplicativo. Normalmente, os clientes são usados nos servidores Web frontend para se conectar a um Orleans cluster que serve como uma camada intermediária com grãos executando lógica de negócios.

Em uma configuração típica, um servidor web frontend:

  • Recebe uma solicitação da Web.
  • Executa a autenticação necessária e a validação de autorização.
  • Decide qual(is) grão(s) deve(m) processar o pedido.
  • Usa o Microsoft.Orleans. Pacote NuGet cliente para fazer uma ou mais chamadas de método para o(s) grão(s).
  • Lida com a conclusão bem-sucedida ou falhas das chamadas de grão e quaisquer valores retornados.
  • Envia uma resposta à solicitação da Web.

Inicialização do cliente de grãos

Antes que um cliente de grãos possa ser usado para fazer chamadas para grãos hospedados em um Orleans cluster, ele precisa ser configurado, inicializado e conectado ao cluster.

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

Considere o seguinte exemplo 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 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 ClientBuilder de várias classes de opção suplementares que contêm uma hierarquia de propriedades de configuração para configurar programaticamente um cliente. Para obter mais informações, consulte 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 Connect() o método no objeto cliente construído para fazer com que ele se conecte ao Orleans cluster. É um método assíncrono que retorna um Taskarquivo . Então você precisa esperar por sua conclusão com um await ou .Wait().

await client.Connect();

Fazer chamadas para grãos

Fazer chamadas para grão de um cliente não é diferente de fazer essas chamadas de dentro do código de grão. O mesmo IGrainFactory.GetGrain<TGrainInterface>(Type, Guid) método, onde T é a interface de grão de destino, é usado em ambos os casos para obter referências de grãos. 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 grain retorna a Task ou a, Task<TResult> conforme exigido pelas regras da interface grain. O cliente pode usar a await palavra-chave para aguardar de forma assíncrona o retorno Task sem bloquear o thread ou, em alguns casos, o Wait() método para bloquear o thread atual de execução.

A principal diferença entre fazer chamadas para grãos a partir do código do cliente e de dentro de outro grão é o modelo de execução single-threaded de grãos. Os grãos são restritos a serem single-threaded pelo tempo de execução, enquanto os Orleans clientes podem ser multi-threaded. Orleans não fornece nenhuma garantia desse tipo no lado do cliente e, portanto, cabe ao cliente gerenciar sua simultaneidade usando quaisquer construções de sincronização apropriadas para seu ambiente — bloqueios, eventos e Tasks.

Receber notificações

Há situações em que 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 ser notificado quando uma nova mensagem for publicada por alguém que ela está seguindo.

O uso de Observadores é um desses mecanismos que permite expor objetos do lado do cliente como alvos semelhantes a grãos para serem invocados por grãos. As chamadas para observadores não fornecem qualquer indicação de sucesso ou fracasso, uma vez que são enviadas como uma mensagem unidirecional de melhor esforço. Portanto, é responsabilidade do código do aplicativo construir um mecanismo de confiabilidade de alto nível em cima dos observadores, quando necessário.

Outro mecanismo que pode ser usado para entregar mensagens assíncronas aos clientes é o Streams. Os fluxos expõem indícios de sucesso ou fracasso na entrega de mensagens individuais e, portanto, permitem uma comunicação confiável de volta ao cliente.

Conectividade do 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ão que foram 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 qualquer 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 repetir ou não. Se nenhum filtro de repetição for fornecido, ou se o filtro de repetição retornar false, o cliente desiste definitivamente.

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ão que foram obtidas de um cliente de cluster conectado.

No primeiro caso, o Connect método lançará uma exceção para indicar o que deu errado. Este é tipicamente (mas não necessariamente) um SiloUnavailableExceptionarquivo . Se isso acontecer, a instância do cliente de cluster não poderá ser utilizada e deverá ser descartada. Uma função de filtro de repetição pode opcionalmente ser fornecida ao Connect método, que pode, por exemplo, esperar por uma duração especificada antes de fazer outra tentativa. Se nenhum filtro de repetição for fornecido, ou se o filtro de repetição retornar false, o cliente desiste definitivamente.

Se Connect retornar com êxito, o cliente de cluster terá a garantia de ser utilizável até ser 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 GatewayOptions objeto 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, quando ocorre um problema de conexão durante uma chamada de grão, um SiloUnavailableException será lançado no lado do cliente. Isso poderia ser tratado da seguinte forma:

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

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

A referência de cereais não é invalidada nesta situação; A chamada pode ser repetida na mesma referência mais tarde, quando uma conexão pode ter sido restabelecida.

Injeção de dependência

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

Nota

Ao co-hospedar um Orleans silo no mesmo processo que estará se conectando a ele, não é necessário criar manualmente um cliente, Orleans irá automaticamente fornecer um e gerenciar sua vida útil adequadamente.

Ao se conectar a um cluster em um processo diferente (em uma máquina 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();
    }
}

O serviço é então registado da seguinte forma:

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

Exemplo

Aqui está uma versão estendida do exemplo dado acima de um aplicativo cliente que se conecta ao Orleans, encontra a conta do jogador, assina atualizações para a sessão de jogo da qual o jogador faz parte com um observador e imprime 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);
    }
}