Orleans 클라이언트

클라이언트를 사용하면 비조직 코드가 Orleans 클러스터와 상호 작용할 수 있습니다. 클라이언트를 사용하면 애플리케이션 코드가 클러스터에서 호스트되는 조직 및 스트림과 통신할 수 있습니다. 클라이언트 코드가 호스트되는 위치에 따라 사일로와 동일한 프로세스 또는 별도의 프로세스에서 클라이언트를 얻는 두 가지 방법이 있습니다. 이 문서에서는 권장되는 옵션, 즉 클라이언트 코드를 조직 코드와 동일한 프로세스에서 공동 호스팅하는 두 옵션에 대해 설명합니다.

공동 호스팅 클라이언트

클라이언트 코드가 조직 코드와 동일한 프로세스에서 호스트되는 경우 호스팅 애플리케이션의 종속성 주입 컨테이너에서 클라이언트를 직접 가져올 수 있습니다. 이 경우 클라이언트는 연결된 사일로와 직접 통신하며 사일로가 클러스터에 대해 가지고 있는 추가 정보를 활용할 수 있습니다.

이렇게 하면 네트워크 및 CPU 오버헤드를 줄이고, 대기 시간을 줄이고, 처리량 및 안정성을 높이는 등 여러 가지 이점이 있습니다. 클라이언트는 클러스터 토폴로지 및 상태에 대한 사일로의 지식을 활용하며 별도의 게이트웨이를 사용할 필요가 없습니다. 이렇게 하면 네트워크 홉 및 직렬화/역직렬화 왕복이 방지됩니다. 따라서 클라이언트와 조직 사이에 필요한 노드 수가 최소화되므로 안정성도 향상됩니다. 조직이 상태 비저장 작업자 조직이거나 클라이언트가 호스팅되는 사일로에서 활성화되는 경우 직렬화 또는 네트워크 통신을 전혀 수행할 필요가 없으며 클라이언트는 추가 성능 및 안정성 향상을 얻을 수 있습니다. 또한 클라이언트 및 조직 코드를 공동 호스팅하면 두 개의 고유한 애플리케이션 이진 파일을 배포하고 모니터링할 필요가 없도록 하여 배포 및 애플리케이션 토폴로지를 간소화합니다.

주로 조직 코드가 클라이언트 프로세스에서 더 이상 격리되지 않는다는 점으로 인해 이 접근 방식에 비판도 있습니다. 따라서 IO 차단 또는 스레드 고갈을 일으키는 잠금 경합과 같은 클라이언트 코드의 문제는 조직 코드의 성능에 영향을 줄 수 있습니다. 앞서 언급한 것과 같은 코드 결함이 없더라도 노이즈 네이버 효과는 클라이언트 코드가 조직 코드와 동일한 프로세서에서 실행되어 CPU 캐시에 추가적인 부담을 주고 일반적으로 로컬 리소스에 대한 추가 경합을 유발함으로써 발생할 수 있습니다. 또한 모니터링 시스템에서 논리적으로 클라이언트 코드와 조직 코드를 구분할 수 없기 때문에 이러한 문제의 원인을 식별하기가 더 어려워졌습니다.

이러한 비판에도 불구하고, 조직 코드를 사용하여 클라이언트 코드를 공동 호스팅하는 것이 가장 인기 있는 옵션이며 대부분의 애플리케이션에 권장되는 방법입니다. 자세히 설명하자면 앞서 언급한 비판은 다음과 같은 이유로 실제로 미미합니다.

  • 예를 들어 클라이언트 코드는 들어오는 HTTP 요청을 조직 호출로 변환하는 것과 같이 매우 적기때문에 노이즈 네이버 효과는 최소화되고 필요한 게이트웨이와 비교할 수 있습니다.
  • 성능 문제가 발생하는 경우 개발자의 일반적인 워크플로에는 CPU 프로파일러 및 디버거와 같은 도구가 포함됩니다. 이 도구는 클라이언트 코드와 조직 코드가 모두 동일한 프로세스에서 실행되고 있음에도 불구하고 문제의 원인을 신속하게 식별하는 데 여전히 효과적입니다. 즉, 메트릭은 더 거칠어지고 문제의 원인을 정확하게 식별할 수 없게 되지만 더 자세한 도구는 여전히 효과적입니다.

호스트에서 클라이언트 가져오기

.NET 제네릭 호스트를 사용하여 호스팅하는 경우 클라이언트는 호스트의 종속성 주입 컨테이너에서 자동으로 사용할 수 있으며 ASP.NET 컨트롤러 또는 IHostedService 구현과 같은 서비스에 주입할 수 있습니다.

또한 IGrainFactory 또는 IClusterClient와 같은 클라이언트 인터페이스는 ISiloHost에서 가져올 수 있습니다.

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

외부 클라이언트

클라이언트 코드는 조직 코드가 호스트되는 Orleans 클러스터 외부에서 실행할 수 있습니다. 따라서 외부 클라이언트는 클러스터 및 애플리케이션의 모든 조직에 대한 커넥터 또는 도관 역할을 합니다. 일반적으로 클라이언트는 프런트 엔드 웹 서버에서 비즈니스 논리를 실행하는 조직을 사용하는 중간 계층 역할을 하는 Orleans 클러스터에 연결하는 데 사용됩니다.

일반적인 설정에서 프런트 엔드 웹 서버는 다음과 같습니다.

  • 웹 요청을 수신합니다.
  • 필요한 인증 및 권한 부여 유효성 검사를 수행합니다.
  • 요청을 처리할 조직을 결정합니다.
  • Microsoft.Orleans.Client NuGet 패키지를 사용하여 조직에 대해 하나 이상의 메서드 호출을 수행합니다.
  • 조직 호출 및 반환된 값의 성공적인 완료 또는 실패를 처리합니다.
  • 웹 요청에 대한 응답을 보냅니다.

조직 클라이언트 초기화

조직 클라이언트를 Orleans 클러스터에서 호스팅되는 조직을 호출하는 데 사용하려면 먼저 구성하고, 초기화하고, 클러스터에 연결해야 합니다.

구성은 프로그래밍 방식으로 클라이언트를 구성하기 위한 구성 속성의 계층 구조를 포함하는 여러 추가 옵션 클래스 및 UseOrleansClient를 통해 제공됩니다. 자세한 내용은 클라이언트 구성을 참조하세요.

클라이언트 구성의 다음 예제를 고려합니다.

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

host가 시작되면 클라이언트는 구성된 서비스 공급자 인스턴스를 통해 구성되고 사용할 수 있습니다.

구성은 프로그래밍 방식으로 클라이언트를 구성하기 위한 구성 속성의 계층 구조를 포함하는 여러 추가 옵션 클래스 및 ClientBuilder를 통해 제공됩니다. 자세한 내용은 클라이언트 구성을 참조하세요.

클라이언트 구성의 예:

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

마지막으로, 생성된 클라이언트 개체에서 Connect() 메서드를 호출하여 Orleans 클러스터에 연결해야 합니다. Task를 반환하는 비동기 메서드입니다. 따라서 await 또는 .Wait()로 완료될 때까지 기다려야 합니다.

await client.Connect();

조직 호출

클라이언트에서 조직을 호출하는 것은 조직 코드 내에서 이러한 호출을 하는 작업과 다르지 않습니다. T가 대상 조직 인터페이스인 동일한 IGrainFactory.GetGrain<TGrainInterface>(Type, Guid) 메서드는 두 경우 모두 조직 참조를 가져오는 데 사용됩니다. 차이점은 IGrainFactory.GetGrain이 호출되는 팩터리 개체에 있습니다. 클라이언트 코드에서는 다음 예제와 같이 연결된 클라이언트 개체를 통해 이 작업을 수행합니다.

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

await joinGameTask;

조직 메서드에 대한 호출은 조직 인터페이스 규칙에 따라 Task 또는 Task<TResult>를 반환합니다. 클라이언트는 await 키워드를 사용하여 스레드를 차단하지 않고 반환된 Task를 비동기적으로 기다리거나 경우에 따라 Wait() 메서드를 사용하여 현재 실행 스레드를 차단할 수 있습니다.

클라이언트 코드와 다른 조직 내에서 조직을 호출하는 경우의 주요 차이점은 조직의 단일 스레드 실행 모델입니다. 조직은 Orleans 런타임에 의해 단일 스레드로 제한되지만 클라이언트는 다중 스레드일 수 있습니다. Orleans는 클라이언트 쪽에서 이러한 보장을 제공하지 않으므로 잠금, 이벤트, Tasks와 같이 환경에 적합한 동기화 구문을 사용하여 동시성을 관리하는 것은 클라이언트의 몫입니다.

알림 수신

간단한 요청-응답 패턴으로는 충분하지 않고 클라이언트가 비동기 알림을 받아야 하는 경우가 있습니다. 예를 들어 사용자는 팔로우하는 다른 사용자가 새 메시지를 게시한 경우 알림을 받고자 할 수 있습니다.

관찰자의 사용은 클라이언트 쪽 개체를 조직과 유사한 대상으로 노출하여 조직으로 호출할 수 있도록 하는 메커니즘 중 하나입니다. 관찰자에 대한 호출은 단방향 최선의 노력 메시지로 전송되므로 성공 또는 실패의 표시를 제공하지 않습니다. 따라서 필요한 경우 관찰자를 기반으로 더 높은 수준의 안정성 메커니즘을 빌드하는 것은 애플리케이션 코드의 책임입니다.

클라이언트에 비동기 메시지를 전달하는 데 사용할 수 있는 또 다른 메커니즘은 Streams입니다. 스트림은 개별 메시지 전달의 성공 또는 실패를 표시하므로 클라이언트의 안정적인 통신이 가능합니다.

클라이언트 연결

클러스터 클라이언트에 연결 문제가 발생할 수 있는 두 가지 시나리오가 있습니다.

  • 클라이언트가 사일로에 연결을 시도하는 경우입니다.
  • 연결된 클러스터 클라이언트에서 가져온 조직 참조를 호출하는 경우

첫 번째 경우 클라이언트는 사일로에 연결을 시도합니다. 클라이언트가 사일로에 연결할 수 없는 경우 무엇이 잘못되었는지 나타내는 예외가 throw됩니다. 예외를 처리하도록 IClientConnectionRetryFilter를 등록하고 다시 시도할지 여부를 결정할 수 있습니다. 재시도 필터가 제공되지 않거나 재시도 필터가 false를 반환하면 클라이언트는 좋은 결과를 포기합니다.

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

클러스터 클라이언트에 연결 문제가 발생할 수 있는 두 가지 시나리오가 있습니다.

  • IClusterClient.Connect() 메서드가 처음 호출되는 경우
  • 연결된 클러스터 클라이언트에서 가져온 조직 참조를 호출하는 경우

첫 번째 경우 Connect 메서드는 무엇이 잘못되었는지 나타내는 예외를 throw합니다. 이는 일반적으로 (반드시 그렇지는 않음) SiloUnavailableException입니다. 이 경우 클러스터 클라이언트 인스턴스를 사용할 수 없으므로 삭제해야 합니다. 재시도 필터 함수는 필요에 따라 Connect 메서드에 제공될 수 있습니다. 예를 들어 다른 시도를 하기 전에 지정된 기간 동안 기다릴 수 있습니다. 재시도 필터가 제공되지 않거나 재시도 필터가 false를 반환하면 클라이언트는 좋은 결과를 포기합니다.

Connect가 성공적으로 반환되면 클러스터 클라이언트는 삭제될 때까지 사용할 수 있습니다. 즉, 클라이언트에서 연결 문제가 발생하더라도 무기한 복구를 시도합니다. 정확한 복구 동작은 다음과 같이 ClientBuilder가 제공하는 GatewayOptions 개체에서 구성할 수 있습니다.

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

두 번째 경우, 조직 호출 중에 연결 문제가 발생하는 경우 클라이언트 쪽에서 SiloUnavailableException이 throw됩니다. 다음과 같이 처리할 수 있습니다.

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

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

이 상황에서는 조직 참조가 무효화되지 않습니다. 나중에 연결이 다시 설정되었을 때 동일한 참조에서 호출을 다시 시도할 수 있습니다.

종속성 주입

.NET 제네릭 호스트를 사용하는 프로그램에서 외부 클라이언트를 만드는 권장 방법은 종속성 주입을 통해 IClusterClient 싱글톤 인스턴스를 삽입하는 것입니다. 그러면 호스트된 서비스, ASP.NET 컨트롤러 등에서 생성자 매개 변수로 허용될 수 있습니다.

참고 항목

Orleans 사일로를 연결하는 프로세스와 동일한 프로세스에서 공동 호스팅하는 경우 클라이언트를 수동으로 만들 필요가 없습니다. Orleans는 자동으로 하나를 제공하고 수명을 적절하게 관리합니다.

다른 프로세스(다른 컴퓨터)의 클러스터에 연결하는 경우 일반적인 패턴은 다음과 같이 호스트된 서비스를 만드는 것입니다.

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

그러면 서비스는 다음과 같이 등록됩니다.

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

예시

다음은 Orleans에 연결하고, 플레이어 계정을 찾고, 플레이어가 관찰자와 함께 속한 게임 세션에 대한 업데이트를 구독하고, 프로그램이 수동으로 종료될 때까지 알림을 출력하는 클라이언트 애플리케이션 기반에 제공된 예제의 확장 버전입니다.

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