システムが大きくなってくると、「あるクラスで起きた出来事を、別のクラスに知らせたい」という場面が増えてきます。直接メソッドを呼べばシンプルですが、呼び出し元が呼び出し先を直接知っているということは、両者が密接に結びついている(密結合)ということでもあります。
この密結合を避けるための設計パターンのひとつが Pub/Sub(パブリッシュ・サブスクライブ)パターン です。
Pub/Sub パターンとは
Pub/Sub は登場人物が3つあります。
| 役割 | 説明 |
|---|---|
| Publisher(発行者) | イベントやメッセージを送り出す側 |
| Subscriber(購読者) | メッセージを受け取って処理する側 |
| Broker(仲介者) | Publisher と Subscriber をつなぐ中間層 |
Publisher は「誰が受け取るか」を知りません。Subscriber は「誰が送ってきたか」を知りません。Broker がその橋渡しをすることで、両者は互いを意識せずに済みます。
Publisher → [Broker] → Subscriber A
→ Subscriber B
→ Subscriber C
C# での基本実装
まずシンプルな Pub/Sub の仕組みを自前で作ってみます。
// メッセージの型
public record OrderPlaced(int OrderId, string ProductName);
// Broker(メッセージバス)
public class MessageBus
{
private readonly Dictionary<Type, List<Delegate>> _handlers = new();
public void Subscribe<T>(Action<T> handler)
{
var type = typeof(T);
if (!_handlers.ContainsKey(type))
_handlers[type] = new List<Delegate>();
_handlers[type].Add(handler);
}
public void Publish<T>(T message)
{
var type = typeof(T);
if (!_handlers.TryGetValue(type, out var handlers)) return;
foreach (var handler in handlers)
((Action<T>)handler)(message);
}
}
Publisher 側はメッセージを Broker に投げるだけです。
var bus = new MessageBus();
// Subscriber A:注文ログを記録する
bus.Subscribe<OrderPlaced>(e =>
Console.WriteLine($"[Log] 注文受付: {e.ProductName} (ID: {e.OrderId})")
);
// Subscriber B:メール通知を送る
bus.Subscribe<OrderPlaced>(e =>
Console.WriteLine($"[Mail] {e.ProductName} のご注文ありがとうございます")
);
// Publisher:注文が確定したタイミングで発行
bus.Publish(new OrderPlaced(1001, "ノートPC"));
出力:
[Log] 注文受付: ノートPC (ID: 1001)
[Mail] ノートPC のご注文ありがとうございます
Publisher は Publish() を呼ぶだけで、ログとメール通知の両方が動きます。どちらの処理も Publisher は関知していません。
C# の event との違い
C# には event キーワードによる組み込みのイベント機構があります。Observer パターンに近い仕組みで、Pub/Sub と混同されることがあります。
| 比較項目 | event | Pub/Sub |
|---|---|---|
| 仲介者 | なし(直接登録) | Broker が間に入る |
| 結合度 | Publisher が Subscriber の型を知る | 互いに無関係 |
| スコープ | 同一プロセス内 | プロセス・サービスをまたぐことも可能 |
| 用途 | UI イベント、シンプルな通知 | サービス間連携、非同期処理 |
event は手軽ですが、Publisher が Subscriber の存在を間接的に知っている形になります。Pub/Sub は Broker を挟むことでその依存を断ち切ります。
より実践的な構成
実務では次のような方向に発展します。
インターフェースで Subscriber を定義する
public interface IHandler<T>
{
void Handle(T message);
}
public class OrderLogHandler : IHandler<OrderPlaced>
{
public void Handle(OrderPlaced message)
=> Console.WriteLine($"[Log] 注文受付: {message.ProductName}");
}
クロージャーではなくクラスで Subscriber を表現することで、依存注入(DI)との相性が良くなります。
非同期対応
public interface IAsyncHandler<T>
{
Task HandleAsync(T message, CancellationToken ct = default);
}
メール送信や外部 API 呼び出しなど、I/O を伴う処理は非同期ハンドラーに分けておくと扱いやすくなります。
外部サービスへの発展
Pub/Sub パターンはプロセス内だけでなく、サービス間の非同期通信にも広く使われています。
- AWS SQS + SNS — SNS でメッセージを発行、SQS で受け取る
- Azure Service Bus — トピック/サブスクリプション構成
- RabbitMQ — Exchange と Queue による Broker 構成
アーキテクチャが大きくなるにつれ、Broker の役割は外部のメッセージングサービスが担うことになります。ただし基本の考え方は同じで、「Publisher と Subscriber を切り離す」という点は変わりません。
まとめ
Pub/Sub パターンのポイントを整理します。
- Publisher はメッセージを投げるだけで、誰が受け取るかを知らない
- Subscriber は何が発行したかを知らずにメッセージを処理する
- Broker が両者を仲介することで、互いの依存を断ち切る
- C# の
eventより疎結合で、スケールしやすい
小さなアプリでは event で十分ですが、処理が増えて「どこで何をしているか」が分散してきたタイミングで Pub/Sub を検討する価値が出てきます。