akka-hosting-actor-patterns について
エンティティベースのシステムを構築する開発者向けに、Akka.Hosting で Akka.NET のアクターを構成するパターンを解説します。同じアクターコードをローカル実行とクラスターシャーディングで使う方法に加え、スケジューリング、登録、DI スコープを扱います。
主な機能
- 01ローカルとクラスターの実行モード
- 02ローカルのエンティティ別子アクターのルーティング
- 03メッセージ抽出パターン
- 04Akka.Hosting の登録拡張
- 05ITimeProvider によるテスト可能なスケジューリング
- 06メッセージ単位の DI スコープ
活用シーン
- 01クラスターなしでエンティティアクターをテスト
- 02クラスターシャーディングでエンティティアクターを配置
- 03永続化されるアクターリマインダーのスケジュール
- 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 の拡張メソッドを使ってアクターを登録するとき
- 再利用可能なアクター構成パターンを作成するとき
基本原則
- 実行モードの抽象化 - 同じアクターコードをローカル(テスト)またはクラスター化環境(本番環境)で実行できます
- ローカル環境では GenericChildPerEntityParent - クラスターのオーバーヘッドなしでシャーディングのセマンティクスを再現します
- ルーティングにはメッセージ抽出器を使用 - Akka.Cluster.Sharding の IMessageExtractor インターフェイスを再利用します
- Akka.Hosting の拡張メソッド - 相互に組み合わせやすい、流れるような構成を実現します
- テスト可能性のために 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 | 理由 |
|---|---|---|
UserSessionActor | false | セッションは期限切れになり、ログイン時に作成されます |
DraftActor | false | 下書きは送信または破棄される一時的なものです |
EmailSenderActor | false | 応答を待たない操作です |
OrderActor | false | 注文は完了し、常に新しい注文が作成されます |
ShoppingCartActor | false | カートは期限切れになり、放棄されることもよくあります |
TenantActor | 場合によっては true | テナントの集合は固定され、常に必要です |
AccountActor | 場合によっては true | アカウントの集合には上限があり、長期間存続します |
目安: RememberEntities = true を使用するのは、次の条件を満たす場合に限ります。
- エンティティの集合に上限がある(既知の最大数がある)
- 常に利用できる状態にしておくべき、長期間存続するドメインエンティティである
- エンティティを記憶しておくコストが、遅延作成のコストを下回る
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
ベストプラクティス
- 常に両方の実行モードをサポートする - コードを変更せずにテストしやすくなります
- 強い型付けの ID を使用する -
stringやGuidではなくOrderIdを使用します - インターフェースベースのメッセージルーティング - 型安全に値を抽出するには
IWithOrderIdを使用します - 子ではなく親を登録する - エンティティごとに子を作成する場合は、親を ActorRegistry に登録します
- 明確さのためにマーカー型を使用する - レジストリを検索するには空のマーカークラスを使用します
- 継承よりコンポジションを優先する - 拡張メソッドを連結し、深い階層は作成しないでください
- スケジューリングには ITimeProvider を使用する - アクター内で
DateTime.Nowを直接使用しないでください - 永続性には akka-reminders を使用する - 再起動後も維持する必要があるスケジュール済みタスクに使用します
- RememberEntities = false をデフォルトにする - 上限があり、長期間存続するエンティティに限り true に設定します