Akka.Hosting アクターパターン
このスキルを使用するタイミング
以下の場合にこのスキルを使用します:
- ドメインオブジェクト(ユーザー、注文、請求書など)を表すエンティティアクターを構築する場合
- 単体テスト(クラスタリングなし)と本番環境(クラスターシャーディング)の両方で動作するアクターが必要な場合
- akka-reminders を使用したスケジュールタスクを設定する場合
- Akka.Hosting 拡張メソッドでアクターを登録する場合
- 再利用可能なアクター設定パターンを作成する場合
基本原則
- 実行モード抽象化 - 同じアクターコードがローカル(テスト)とクラスタ(本番)の両方で実行される
- ローカル用 GenericChildPerEntityParent - クラスタオーバーヘッドなしでシャーディングセマンティクスを模倣
- ルーティング用メッセージ抽出 - Akka.Cluster.Sharding の IMessageExtractor インターフェースを再利用
- Akka.Hosting 拡張メソッド - 流暢な設定で構成が容易
- テスト容易性のための ITimeProvider - DateTime.Now の代わりに ActorSystem.Scheduler を使用
実行モード
アクターの動作を制御するための列挙型を定義します:
/// <summary>
/// Akka.NET の設定方法を決定します
/// </summary>
public enum AkkaExecutionMode
{
/// <summary>
/// 純粋なローカルアクターシステム - リモートなし、クラスタリングなし。
/// ShardRegion の代わりに GenericChildPerEntityParent を使用します。
/// 単体テストやシンプルなシナリオに最適です。
/// </summary>
LocalTest,
/// <summary>
/// ShardRegion を使用した完全なクラスタリング。
/// 統合テストや本番環境で使用します。
/// </summary>
Clustered
}
GenericChildPerEntityParent
子エンティティにメッセージをルーティングする軽量な親アクターで、クラスタを必要とせずにクラスターシャーディングのセマンティクスを模倣します:
using Akka.Actor;
using Akka.Cluster.Sharding;
/// <summary>
/// エンティティごとに子アクターを持つ汎用親アクター。
/// </summary>
/// <remarks>
/// 一貫したルーティングのために Akka.Cluster.Sharding の IMessageExtractor を再利用します。
/// クラスタリングのオーバーヘッドが不要な単体テストに最適です。
/// </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;
// 既存の子を取得するか、新しい子を作成
Context.Child(entityId)
.GetOrElse(() => Context.ActorOf(_propsFactory(entityId), entityId))
.Forward(_extractor.EntityMessage(message));
});
}
}
メッセージ抽出
Akka.Cluster.Sharding の IMessageExtractor を実装する抽出子を作成します:
using Akka.Cluster.Sharding;
/// <summary>
/// 厳密に型指定された 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
};
}
}
// 特定のエンティティを対象とするメッセージのインターフェースを定義
public interface IWithOrderId
{
OrderId OrderId { get; }
}
// 厳密に型指定された ID を使用
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>
/// OrderActor をローカルモードとクラスタモードの両方をサポートして追加します。
/// </summary>
public static AkkaConfigurationBuilder WithOrderActor(
this AkkaConfigurationBuilder builder,
AkkaExecutionMode executionMode = AkkaExecutionMode.Clustered,
string? clusterRole = null)
{
if (executionMode == AkkaExecutionMode.LocalTest)
{
// 非クラスタモード: 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
{
// クラスタモード: 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>
/// すべての注文ドメインアクターをシャーディングサポート付きで追加します。
/// </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(); // シングルトン、シャーディング不要
}
}
スケジューリングでの ITimeProvider の使用
テスト可能な時間ベースのロジックのために、ActorSystem の Scheduler を ITimeProvider として登録します:
public static class SharedAkkaHostingExtensions
{
public static IServiceCollection AddAkkaWithTimeProvider(
this IServiceCollection services,
Action<AkkaConfigurationBuilder, IServiceProvider> configure)
{
// ActorSystem の Scheduler を使用して ITimeProvider を登録
services.AddSingleton<ITimeProvider>(sp =>
sp.GetRequiredService<ActorSystem>().Scheduler);
return services.ConfigureAkka((builder, sp) =>
{
configure(builder, sp);
});
}
}
// アクター内で ITimeProvider を注入
public class SubscriptionActor : ReceiveActor
{
private readonly ITimeProvider _timeProvider;
public SubscriptionActor(ITimeProvider timeProvider)
{
_timeProvider = timeProvider;
// DateTime.UtcNow の代わりに _timeProvider.GetUtcNow() を使用
// これによりテストで時間を制御できる
}
}
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>
/// PostgreSQL ストレージを使用して akka-reminders を設定します。
/// </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>
/// テスト用にインメモリストレージを使用して akka-reminders を設定します。
/// </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>
/// リマインダーターゲットを GenericChildPerEntityParent アクターに解決します。
/// </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(
$"不明なリマインダーキー形式: {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>
/// OrderActor の GenericChildPerEntityParent を取得するための ActorRegistry のマーカータイプ。
/// </summary>
public sealed class OrderManagerActor;
// 拡張メソッドでの使用
registry.Register<OrderManagerActor>(parent);
// コントローラー/サービスでの使用
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)
{
// このメッセージ用のスコープを作成 - 処理後に破棄
using var scope = _serviceProvider.CreateScope();
// スコープ内でスコープサービスを解決
var orderRepository = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
var paymentService = scope.ServiceProvider.GetRequiredService<IPaymentService>();
var emailComposer = scope.ServiceProvider.GetRequiredService<IOrderEmailComposer>();
// スコープサービスで作業を実行
var order = await orderRepository.GetByIdAsync(msg.OrderId);
var payment = await paymentService.ProcessAsync(order);
// スコープが破棄されると DbContext の変更がコミットされる
}
}
このパターンの利点
| 利点 | 説明 |
|---|---|
| メッセージごとに新しい DbContext | メッセージ間で古いエンティティトラッキングがない |
| 適切な破棄 | 各メッセージ後にデータベース接続が解放される |
| 分離 | あるメッセージのエラーが別のメッセージの状態を破損しない |
| テスト容易性 | テストでモックの IServiceProvider を注入できる |
シングルトンサービス - 直接注入
ステートレスでスレッドセーフなサービスの場合は、直接注入します(スコープ不要):
public sealed class NotificationActor : ReceiveActor
{
private readonly IEmailLinkGenerator _linkGenerator; // シングルトン - OK!
private readonly IMjmlTemplateRenderer _renderer; // シングルトン - OK!
public NotificationActor(
IEmailLinkGenerator linkGenerator,
IMjmlTemplateRenderer renderer)
{
_linkGenerator = linkGenerator;
_renderer = renderer;
// ...
}
}