mcpField
A
akka-hosting-actor-patterns

akka-hosting-actor-patterns

github.com/aaronontheweb開発1.2k424 回インストール今日更新
リポジトリ

akka-hosting-actor-patterns について

エンティティベースのシステムを構築する開発者向けに、Akka.Hosting で Akka.NET のアクターを構成するパターンを解説します。同じアクターコードをローカル実行とクラスターシャーディングで使う方法に加え、スケジューリング、登録、DI スコープを扱います。

主な機能

  1. 01ローカルとクラスターの実行モード
  2. 02ローカルのエンティティ別子アクターのルーティング
  3. 03メッセージ抽出パターン
  4. 04Akka.Hosting の登録拡張
  5. 05ITimeProvider によるテスト可能なスケジューリング
  6. 06メッセージ単位の DI スコープ

活用シーン

  1. 01クラスターなしでエンティティアクターをテスト
  2. 02クラスターシャーディングでエンティティアクターを配置
  3. 03永続化されるアクターリマインダーのスケジュール
  4. 04長寿命アクターでスコープサービスを安全に使用

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/aaronontheweb/dotnet-skills --skill akka-hosting-actor-patterns

インストール後、Claude Code で/akka-hosting-actor-patternsを入力して読み込まれたか確認します。

詳細

Akka.Hosting のアクターパターン

このスキルを使用する場面

次の場合にこのスキルを使用します。

  • ドメインオブジェクト(ユーザー、注文、請求書など)を表すエンティティアクターを構築するとき
  • ユニットテスト(クラスタリングなし)と本番環境(クラスターシャーディング)の両方で動作するアクターが必要なとき
  • akka-reminders を使ってスケジュールされたタスクを設定するとき
  • Akka.Hosting の拡張メソッドを使ってアクターを登録するとき
  • 再利用可能なアクター構成パターンを作成するとき

基本原則

  1. 実行モードの抽象化 - 同じアクターコードをローカル(テスト)またはクラスター化環境(本番環境)で実行できます
  2. ローカル環境では GenericChildPerEntityParent - クラスターのオーバーヘッドなしでシャーディングのセマンティクスを再現します
  3. ルーティングにはメッセージ抽出器を使用 - Akka.Cluster.Sharding の IMessageExtractor インターフェイスを再利用します
  4. Akka.Hosting の拡張メソッド - 相互に組み合わせやすい、流れるような構成を実現します
  5. テスト可能性のために ITimeProvider を使用 - DateTime.Now の代わりに ActorSystem.Scheduler を使用します

実行モード

アクターの動作を制御する列挙型を定義します。

/// <summary>
/// Determines how Akka.NET should be configured
/// </summary>
public enum AkkaExecutionMode
{
    /// <summary>
    /// Pure local actor system - no remoting, no clustering.
    /// Use GenericChildPerEntityParent instead of ShardRegion.
    /// Ideal for unit tests and simple scenarios.
    /// </summary>
    LocalTest,

    /// <summary>
    /// Full clustering with ShardRegion.
    /// Use for integration testing and production.
    /// </summary>
    Clustered
}

GenericChildPerEntityParent

クラスターを必要とせずにクラスターシャーディングのセマンティクスを再現し、子エンティティにメッセージをルーティングする軽量な親アクターです。

using Akka.Actor;
using Akka.Cluster.Sharding;

/// <summary>
/// A generic "child per entity" parent actor.
/// </summary>
/// <remarks>
/// Reuses Akka.Cluster.Sharding's IMessageExtractor for consistent routing.
/// Ideal for unit tests where clustering overhead is unnecessary.
/// </remarks>
public sealed class GenericChildPerEntityParent : ReceiveActor
{
    public static Props CreateProps(
        IMessageExtractor extractor,
        Func<string, Props> propsFactory)
    {
        return Props.Create(() =>
            new GenericChildPerEntityParent(extractor, propsFactory));
    }

    private readonly IMessageExtractor _extractor;
    private readonly Func<string, Props> _propsFactory;

    public GenericChildPerEntityParent(
        IMessageExtractor extractor,
        Func<string, Props> propsFactory)
    {
        _extractor = extractor;
        _propsFactory = propsFactory;

        ReceiveAny(message =>
        {
            var entityId = _extractor.EntityId(message);
            if (entityId is null) return;

            // Get existing child or create new one
            Context.Child(entityId)
                .GetOrElse(() => Context.ActorOf(_propsFactory(entityId), entityId))
                .Forward(_extractor.EntityMessage(message));
        });
    }
}

メッセージ抽出器

Akka.Cluster.Sharding の IMessageExtractor を実装する抽出器を作成します。

using Akka.Cluster.Sharding;

/// <summary>
/// Routes messages to entity actors based on a strongly-typed ID.
/// </summary>
public sealed class OrderMessageExtractor : HashCodeMessageExtractor
{
    public const int DefaultShardCount = 40;

    public OrderMessageExtractor(int maxNumberOfShards = DefaultShardCount)
        : base(maxNumberOfShards)
    {
    }

    public override string? EntityId(object message)
    {
        return message switch
        {
            IWithOrderId msg => msg.OrderId.Value.ToString(),
            _ => null
        };
    }
}

// Define an interface for messages that target a specific entity
public interface IWithOrderId
{
    OrderId OrderId { get; }
}

// Use strongly-typed IDs
public readonly record struct OrderId(Guid Value)
{
    public static OrderId New() => new(Guid.NewGuid());
    public override string ToString() => Value.ToString();
}

Akka.Hosting の拡張メソッド

実行モードを抽象化する拡張メソッドを作成します。

using Akka.Cluster.Hosting;
using Akka.Cluster.Sharding;
using Akka.Hosting;

public static class OrderActorHostingExtensions
{
    /// <summary>
    /// Adds OrderActor with support for both local and clustered modes.
    /// </summary>
    public static AkkaConfigurationBuilder WithOrderActor(
        this AkkaConfigurationBuilder builder,
        AkkaExecutionMode executionMode = AkkaExecutionMode.Clustered,
        string? clusterRole = null)
    {
        if (executionMode == AkkaExecutionMode.LocalTest)
        {
            // Non-clustered mode: Use GenericChildPerEntityParent
            builder.WithActors((system, registry, resolver) =>
            {
                var parent = system.ActorOf(
                    GenericChildPerEntityParent.CreateProps(
                        new OrderMessageExtractor(),
                        entityId => resolver.Props<OrderActor>(entityId)),
                    "orders");

                registry.Register<OrderActor>(parent);
            });
        }
        else
        {
            // Clustered mode: Use ShardRegion
            builder.WithShardRegion<OrderActor>(
                "orders",
                (system, registry, resolver) =>
                    entityId => resolver.Props<OrderActor>(entityId),
                new OrderMessageExtractor(),
                new ShardOptions
                {
                    StateStoreMode = StateStoreMode.DData,
                    Role = clusterRole
                });
        }

        return builder;
    }
}

複数のアクターの組み合わせ

すべてのドメインアクターを登録する便利なメソッドを作成します。

public static class DomainActorHostingExtensions
{
    /// <summary>
    /// Adds all order domain actors with sharding support.
    /// </summary>
    public static AkkaConfigurationBuilder WithOrderDomainActors(
        this AkkaConfigurationBuilder builder,
        AkkaExecutionMode executionMode = AkkaExecutionMode.Clustered,
        string? clusterRole = null)
    {
        return builder
            .WithOrderActor(executionMode, clusterRole)
            .WithPaymentActor(executionMode, clusterRole)
            .WithShipmentActor(executionMode, clusterRole)
            .WithNotificationActor(); // Singleton, no sharding needed
    }
}

スケジューリングに ITimeProvider を使用する

時間ベースのロジックをテスト可能にするため、ActorSystem の Scheduler を ITimeProvider として登録します。

public static class SharedAkkaHostingExtensions
{
    public static IServiceCollection AddAkkaWithTimeProvider(
        this IServiceCollection services,
        Action<AkkaConfigurationBuilder, IServiceProvider> configure)
    {
        // Register ITimeProvider using the ActorSystem's Scheduler
        services.AddSingleton<ITimeProvider>(sp =>
            sp.GetRequiredService<ActorSystem>().Scheduler);

        return services.ConfigureAkka((builder, sp) =>
        {
            configure(builder, sp);
        });
    }
}

// In your actor, inject ITimeProvider
public class SubscriptionActor : ReceiveActor
{
    private readonly ITimeProvider _timeProvider;

    public SubscriptionActor(ITimeProvider timeProvider)
    {
        _timeProvider = timeProvider;

        // Use _timeProvider.GetUtcNow() instead of DateTime.UtcNow
        // This allows tests to control time
    }
}

Akka.Reminders との統合

再起動後も維持される永続的なスケジュール済みタスクには、akka-reminders を使用します。

using Akka.Reminders;
using Akka.Reminders.Sql;
using Akka.Reminders.Sql.Configuration;
using Akka.Reminders.Storage;

public static class ReminderHostingExtensions
{
    /// <summary>
    /// Configures akka-reminders with PostgreSQL storage.
    /// </summary>
    public static AkkaConfigurationBuilder WithPostgresReminders(
        this AkkaConfigurationBuilder builder,
        string connectionString,
        string schemaName = "reminders",
        string tableName = "scheduled_reminders",
        bool autoInitialize = true)
    {
        return builder.WithLocalReminders(reminders => reminders
            .WithResolver(sys => new GenericChildPerEntityResolver(sys))
            .WithStorage(system =>
            {
                var settings = SqlReminderStorageSettings.CreatePostgreSql(
                    connectionString,
                    schemaName,
                    tableName,
                    autoInitialize);
                return new SqlReminderStorage(settings, system);
            })
            .WithSettings(new ReminderSettings
            {
                MaxSlippage = TimeSpan.FromSeconds(30),
                MaxDeliveryAttempts = 3,
                RetryBackoffBase = TimeSpan.FromSeconds(10)
            }));
    }

    /// <summary>
    /// Configures akka-reminders with in-memory storage for testing.
    /// </summary>
    public static AkkaConfigurationBuilder WithInMemoryReminders(
        this AkkaConfigurationBuilder builder)
    {
        return builder.WithLocalReminders(reminders => reminders
            .WithResolver(sys => new GenericChildPerEntityResolver(sys))
            .WithStorage(system => new InMemoryReminderStorage())
            .WithSettings(new ReminderSettings
            {
                MaxSlippage = TimeSpan.FromSeconds(1),
                MaxDeliveryAttempts = 3,
                RetryBackoffBase = TimeSpan.FromMilliseconds(100)
            }));
    }
}
子ごとのエンティティ向けのカスタムリマインダーリゾルバー

リマインダーのコールバックを GenericChildPerEntityParent アクターにルーティングします。

using Akka.Actor;
using Akka.Hosting;
using Akka.Reminders;

/// <summary>
/// Resolves reminder targets to GenericChildPerEntityParent actors.
/// </summary>
public sealed class GenericChildPerEntityResolver : IReminderActorResolver
{
    private readonly ActorSystem _system;

    public GenericChildPerEntityResolver(ActorSystem system)
    {
        _system = system;
    }

    public IActorRef ResolveActorRef(ReminderEntry entry)
    {
        var registry = ActorRegistry.For(_system);

        return entry.Key switch
        {
            var k when k.StartsWith("order-") =>
                registry.Get<OrderActor>(),
            var k when k.StartsWith("subscription-") =>
                registry.Get<SubscriptionActor>(),
            _ => throw new InvalidOperationException(
                $"Unknown reminder key format: {entry.Key}")
        };
    }
}

シングルトンアクター(シャーディングなし)

インスタンスを 1 つだけにするアクターの場合は、次のようにします。

public static AkkaConfigurationBuilder WithEmailSenderActor(
    this AkkaConfigurationBuilder builder)
{
    return builder.WithActors((system, registry, resolver) =>
    {
        var actor = system.ActorOf(
            resolver.Props<EmailSenderActor>(),
            "email-sender");
        registry.Register<EmailSenderActor>(actor);
    });
}

レジストリ用マーカー型

親として登録されたアクターを参照する必要がある場合は、次のようにします。

/// <summary>
/// Marker type for ActorRegistry to retrieve the order manager
/// (GenericChildPerEntityParent for OrderActors).
/// </summary>
public sealed class OrderManagerActor;

// Usage in extension method
registry.Register<OrderManagerActor>(parent);

// Usage in controller/service
public class OrderService
{
    private readonly IActorRef _orderManager;

    public OrderService(IRequiredActor<OrderManagerActor> orderManager)
    {
        _orderManager = orderManager.ActorRef;
    }

    public async Task<OrderResponse> CreateOrder(CreateOrderCommand cmd)
    {
        return await _orderManager.Ask<OrderResponse>(cmd);
    }
}

アクターでの DI スコープ管理

アクターには DI スコープが自動で設定されません。 ASP.NET コントローラー(HTTP リクエストごとにスコープが作成されます)とは異なり、アクターは長期間存続します。DbContext などのスコープ付きサービスが必要な場合は、IServiceProvider を注入し、手動でスコープを作成してください。

パターン:メッセージごとのスコープ
public sealed class OrderProcessingActor : ReceiveActor
{
    private readonly IServiceProvider _serviceProvider;
    private readonly IActorRef _notificationActor;

    public OrderProcessingActor(
        IServiceProvider serviceProvider,
        IRequiredActor<NotificationActor> notificationActor)
    {
        _serviceProvider = serviceProvider;
        _notificationActor = notificationActor.ActorRef;

        ReceiveAsync<ProcessOrder>(HandleProcessOrder);
    }

    private async Task HandleProcessOrder(ProcessOrder msg)
    {
        // Create scope for this message - disposed after processing
        using var scope = _serviceProvider.CreateScope();

        // Resolve scoped services within the scope
        var orderRepository = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
        var paymentService = scope.ServiceProvider.GetRequiredService<IPaymentService>();
        var emailComposer = scope.ServiceProvider.GetRequiredService<IOrderEmailComposer>();

        // Do work with scoped services
        var order = await orderRepository.GetByIdAsync(msg.OrderId);
        var payment = await paymentService.ProcessAsync(order);

        // DbContext changes committed when scope disposes
    }
}
このパターンを使う理由
利点説明
メッセージごとに新しい DbContextメッセージ間でエンティティの追跡状態が古くなりません
適切な破棄各メッセージの処理後にデータベース接続が解放されます
分離あるメッセージのエラーが別のメッセージの状態を破損しません
テスト可能テストでモックの IServiceProvider を注入できます
シングルトンサービス - 直接注入

ステートレスでスレッドセーフなサービスは、直接注入します(スコープは不要です)。

public sealed class NotificationActor : ReceiveActor
{
    private readonly IEmailLinkGenerator _linkGenerator;  // Singleton - OK!
    private readonly IMjmlTemplateRenderer _renderer;     // Singleton - OK!

    public NotificationActor(
        IEmailLinkGenerator linkGenerator,
        IMjmlTemplateRenderer renderer)
    {
        _linkGenerator = linkGenerator;
        _renderer = renderer;

        Receive<SendWelcomeEmail>(Handle);
    }
}
よくある誤り:スコープ付きサービスを直接注入する
// BAD: Scoped service injected into long-lived actor
public sealed class BadActor : ReceiveActor
{
    private readonly IOrderRepository _repo;  // Scoped! DbContext lives forever!

    public BadActor(IOrderRepository repo)  // Captured at actor creation
    {
        _repo = repo;  // This DbContext will become stale
    }
}

// GOOD: Inject IServiceProvider, create scope per message
public sealed class GoodActor : ReceiveActor
{
    private readonly IServiceProvider _sp;

    public GoodActor(IServiceProvider sp)
    {
        _sp = sp;
        ReceiveAsync<ProcessOrder>(async msg =>
        {
            using var scope = _sp.CreateScope();
            var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
            // Fresh DbContext for this message
        });
    }
}

DI の有効期間とスコープ管理について詳しくは、microsoft-extensions/dependency-injection スキルを参照してください。


クラスターシャーディングの構成

RememberEntities:ほぼ常に False

RememberEntities は、シャードリージョンが作成済みのすべてのエンティティを記憶し、自動的に再起動するかどうかを制御します。これはほぼ常に false にする必要があります。

builder.WithShardRegion<OrderActor>(
    "orders",
    (system, registry, resolver) => entityId => resolver.Props<OrderActor>(entityId),
    new OrderMessageExtractor(),
    new ShardOptions
    {
        StateStoreMode = StateStoreMode.DData,
        RememberEntities = false,  // DEFAULT - almost always correct
        Role = clusterRole
    });

RememberEntities = true にすると発生する問題:

問題説明
メモリ使用量の際限ない増加作成されたすべてのエンティティが記憶され、永続的に再起動されます
クラスター起動の遅延起動時にクラスターが数千から数百万のエンティティを再起動する必要があります
古いエンティティの復活有効期限切れのセッション、送信済みメール、古い注文がすべて再起動されます
パッシベーションなしアイドル状態のエンティティがメモリを無期限に消費します(パッシベーションは無効になります)
各設定を使用するタイミング
エンティティの種類RememberEntities理由
UserSessionActorfalseセッションは期限切れになり、ログイン時に作成されます
DraftActorfalse下書きは送信または破棄される一時的なものです
EmailSenderActorfalse応答を待たない操作です
OrderActorfalse注文は完了し、常に新しい注文が作成されます
ShoppingCartActorfalseカートは期限切れになり、放棄されることもよくあります
TenantActor場合によっては trueテナントの集合は固定され、常に必要です
AccountActor場合によっては trueアカウントの集合には上限があり、長期間存続します

目安: RememberEntities = true を使用するのは、次の条件を満たす場合に限ります。

  1. エンティティの集合に上限がある(既知の最大数がある)
  2. 常に利用できる状態にしておくべき、長期間存続するドメインエンティティである
  3. エンティティを記憶しておくコストが、遅延作成のコストを下回る
WithShardRegion でのマーカー型

WithShardRegion<T> を使用する場合、ジェネリックパラメーター T は ActorRegistry のマーカー型として機能します。レジストリに一貫してアクセスするには、アクタークラスそのものではなく、専用のマーカー型を使用してください。

/// <summary>
/// Marker type for ActorRegistry. Use this to retrieve the OrderActor shard region.
/// </summary>
public sealed class OrderActorRegion;

// Registration - use marker type as generic parameter
builder.WithShardRegion<OrderActorRegion>(
    "orders",
    (system, registry, resolver) => entityId => resolver.Props<OrderActor>(entityId),
    new OrderMessageExtractor(),
    new ShardOptions { StateStoreMode = StateStoreMode.DData });

// Retrieval - same marker type
var orderRegion = ActorRegistry.Get<OrderActorRegion>();
orderRegion.Tell(new CreateOrder(orderId, amount));

マーカー型を使用する理由

  • WithShardRegion<T> は、シャードリージョンを型 T で自動登録します
  • アクタークラスを直接使用すると混乱を招く可能性があります(レジストリが返すのはアクターではなくリージョンです)
  • マーカー型を使うと意図が明確になり、LocalTest モードと Clustered モードの両方で一貫して動作します
レジストリ呼び出しの重複を避ける

WithShardRegion<T> はシャードリージョンを ActorRegistry に自動登録します。registry.Register<T>() を再度呼び出さないでください。

// BAD - redundant registration
builder.WithShardRegion<OrderActorRegion>("orders", ...)
    .WithActors((system, registry, resolver) =>
    {
        var region = registry.Get<OrderActorRegion>();
        registry.Register<OrderActorRegion>(region);  // UNNECESSARY!
    });

// GOOD - WithShardRegion already registers
builder.WithShardRegion<OrderActorRegion>("orders", ...);
// That's it - OrderActorRegion is now in the registry

ベストプラクティス

  1. 常に両方の実行モードをサポートする - コードを変更せずにテストしやすくなります
  2. 強い型付けの ID を使用する - string や Guid ではなく OrderId を使用します
  3. インターフェースベースのメッセージルーティング - 型安全に値を抽出するには IWithOrderId を使用します
  4. 子ではなく親を登録する - エンティティごとに子を作成する場合は、親を ActorRegistry に登録します
  5. 明確さのためにマーカー型を使用する - レジストリを検索するには空のマーカークラスを使用します
  6. 継承よりコンポジションを優先する - 拡張メソッドを連結し、深い階層は作成しないでください
  7. スケジューリングには ITimeProvider を使用する - アクター内で DateTime.Now を直接使用しないでください
  8. 永続性には akka-reminders を使用する - 再起動後も維持する必要があるスケジュール済みタスクに使用します
  9. RememberEntities = false をデフォルトにする - 上限があり、長期間存続するエンティティに限り true に設定します

コメント