Orleans Клиенты

Клиент позволяет незернированному коду взаимодействовать с кластером Orleans . Клиенты позволяют коду приложения взаимодействовать с зернами и потоками, размещенными в кластере. Существует два способа получения клиента в зависимости от размещения клиентского кода: в том же процессе, что и silo, или в отдельном процессе. В этой статье рассматриваются оба варианта, начиная с рекомендуемого подхода: совместное размещение клиентского кода в том же процессе, что и код зерна.

Клиенты с совместным хостингом

Если вы размещаете клиентский код в том же процессе, что и код зерна, вы можете напрямую получить клиента из контейнера внедрения зависимостей хостинг-приложения. В этом случае клиент непосредственно взаимодействует с silo, к которому он подключен, и может воспользоваться дополнительными знаниями silo о кластере.

Этот подход обеспечивает несколько преимуществ, включая снижение нагрузки на сеть и ЦП, снижение задержки и повышение пропускной способности и надежности. Клиент использует знания silo о топологии кластера и состоянии и не нуждается в отдельном шлюзе. Это позволяет избежать сетевого перехода и операции сериализации/десериализации, тем самым повышая надежность за счет минимизации количества необходимых узлов между клиентом и граном. Если зерно является зерном-работником без состояния или случайно активируется на том же силосе, где размещен клиент, сериализация или сетевое взаимодействие не требуются вообще, что позволяет клиенту достичь увеличения производительности и надежности. Совместное размещение клиента и кода зерна также упрощает развертывание и топологию приложений, устраняя необходимость развертывания и мониторинга двух отдельных двоичных файлов приложений.

Существуют также недостатки этого подхода, в первую очередь, что код зерна больше не изолирован от клиентского процесса. Таким образом, проблемы в клиентском коде, такие как блокировка ввода-вывода или соперничество за блокировку, приводящее к истощению потоков, могут повлиять на производительность кода зерен. Даже без таких дефектов кода эффекты шумного соседа могут возникать просто потому, что клиентский код выполняется на том же процессоре, что и код зерна, что создает дополнительную нагрузку на кэш ЦП и увеличивает конкуренцию за локальные ресурсы. Кроме того, определение источника этих проблем становится более сложным, так как системы мониторинга не могут логически различать клиентский код и код зерна.

Несмотря на эти недостатки, совместное размещение клиентского кода с кодом grain является популярным вариантом и рекомендованным подходом для большинства приложений. Упомянутые выше недостатки часто минимальны в практике по следующим причинам:

  • Клиентский код часто очень легкий (например, преобразование входящих HTTP-запросов в вызовы функций). Таким образом, эффекты "шумного соседа" минимальны и сопоставимы по стоимости с в противном случае необходимым шлюзом.
  • Если возникает проблема с производительностью, типичный рабочий процесс, скорее всего, включает такие средства, как профилировщики ЦП и отладчики. Эти инструменты остаются эффективными для быстрого обнаружения источника проблемы, даже когда клиентский и гранулярный код выполняются в одном процессе. Другими словами, в то время как метрики становятся более грубыми и менее способны точно определить источник проблемы, более подробные средства по-прежнему эффективны.

Получение клиента из хоста

При использовании универсального узла .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 для выполнения одного или нескольких вызовов метода к объектам.
  • Обрабатывает успешное завершение вызовов зерна, их сбои и любые возвращаемые значения.
  • Отправляет ответ на веб-запрос.

Инициализировать клиент системы Grain

Прежде чем использовать клиент зерна для выполнения вызовов к зернам, размещенным в кластере Orleans, необходимо настроить, инициализировать и подключить его к кластеру.

Предоставьте конфигурацию с помощью UseOrleansClient, а также нескольких дополнительных классов опций, содержащих иерархию свойств конфигурации для программной настройки клиента. Дополнительные сведения см. в разделе "Конфигурация клиента".

Рассмотрим следующий пример конфигурации клиента:

Использование TokenCredential с URI службы — это рекомендуемый подход. Этот шаблон избегает хранения секретов в конфигурации и использует идентификатор Microsoft Entra для безопасной проверки подлинности.

DefaultAzureCredential предоставляет цепочку учетных данных, которая легко работает в локальных средах разработки и производственных средах. Во время разработки он использует учетные данные Azure CLI или Visual Studio. В среде эксплуатации Azure он автоматически использует управляемое удостоверение, назначенное вашему ресурсу.

Подсказка

DefaultAzureCredential легко работает как в локальной среде разработки, так и в производственной среде. В разработке он использует учетные данные Azure CLI или Visual Studio. В рабочей среде Azure он автоматически использует управляемое удостоверение ресурса. Для повышения производительности и отладки в рабочей среде рекомендуется заменить его определенными учетными данными, например ManagedIdentityCredential. Дополнительные сведения см. в руководстве по использованию defaultAzureCredential.

using Azure.Identity;

var builder = Host.CreateApplicationBuilder(args);
builder.UseOrleansClient(clientBuilder =>
{
    clientBuilder.Configure<ClusterOptions>(options =>
    {
        options.ClusterId = "my-first-cluster";
        options.ServiceId = "MyOrleansService";
    });

    clientBuilder.UseAzureStorageClustering(options =>
    {
        options.ConfigureTableServiceClient(
            new Uri("https://<your-storage-account>.table.core.windows.net"),
            new DefaultAzureCredential());
    });
});

using var host = builder.Build();
await host.StartAsync();

При запуске host клиент настраивается и становится доступным с помощью созданного экземпляра поставщика услуг.

Предоставьте конфигурацию с помощью ClientBuilder, а также нескольких дополнительных классов опций, содержащих иерархию свойств конфигурации для программной настройки клиента. Дополнительные сведения см. в разделе "Конфигурация клиента".

Пример конфигурации клиента:

var client = new ClientBuilder()
    .Configure<ClusterOptions>(options =>
    {
        options.ClusterId = "my-first-cluster";
        options.ServiceId = "MyOrleansService";
    })
    .UseAzureStorageClustering(
        options => options.ConfigureTableServiceClient(connectionString))
    .ConfigureApplicationParts(
        parts => parts.AddApplicationPart(typeof(IValueGrain).Assembly))
    .Build();

Наконец, необходимо вызвать Connect() метод в созданном клиентском объекте, чтобы подключить его к кластеру Orleans . Это асинхронный метод, возвращающий объект Task, поэтому необходимо дождаться его завершения, используя await или .Wait().

await client.Connect();

Вызовы к зернам

Вызовы к зернам от клиента не отличаются от выполнения таких вызовов в коде зерна. Используйте один и тот же метод IGrainFactory.GetGrain<TGrainInterface>(Type, Guid) (где T является целевой интерфейсом для grain) в обоих случаях, чтобы получить ссылки на grain . Разница заключается в том, какой объект фабрики вызывает 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. Потоки предоставляют индикаторы успеха или неудачи при доставке отдельных сообщений, для обеспечения надежной обратной связи с клиентом.

Клиентские подключения

Существует два сценария, в которых клиент кластера может столкнуться с проблемами с подключением:

  • Когда клиент пытается подключиться к silo.
  • При выполнении вызовов на зерна, ссылки на которые получены из подключенного клиента кластера.

В первом случае клиент пытается подключиться к хранилищу. Если клиент не может подключиться к какому-либо сайло, он вызывает исключение, указывая, что пошло не так. Вы можете зарегистрировать 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 указывающее, что пошло не так. Обычно это (но не обязательно) SiloUnavailableException. В этом случае экземпляр клиента кластера непригоден для использования и должен быть удален. При необходимости можно предоставить методу функцию фильтрации повторных попыток, которая, например, может ожидать указанное время прежде чем предпринять другую попытку. Если вы не предоставляете фильтр повторных попыток или если фильтр повторных попыток возвращает false, клиент окончательно прекращает попытки.

При Connect успешном возвращении клиент кластера гарантированно будет использоваться до тех пор, пока не будет удален. Это означает, что даже если клиент испытывает проблемы с подключением, он пытается восстанавливаться до бесконечности. Вы можете настроить точное поведение восстановления на объекте, предоставленном GatewayOptions, например:

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

Во втором случае, когда во время вызова grain возникает проблема с подключением, на стороне клиента выбрасывается SiloUnavailableException. Это можно сделать следующим образом:

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.TableServiceClient = new TableServiceClient(
                    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()}");
}
internal static class ExternalClientExample
{
    private static string connectionString = "UseDevelopmentStorage=true";

    public static async Task RunWatcherAsync()
    {
        try
        {
            var client = new ClientBuilder()
                .Configure<ClusterOptions>(options =>
                {
                    options.ClusterId = "my-first-cluster";
                    options.ServiceId = "MyOrleansService";
                })
                .UseAzureStorageClustering(
                    options => options.ConfigureTableServiceClient(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);
    }
}