Resolve DistributedActor protocols for server-client apps
01 何が問題だったのか
SE-0336 / SE-0344 で導入された distributed actor は、「ローカルかリモートか」を意識せずに actor へメッセージを送れる location transparency を提供します。しかし、リモート参照を得る唯一の方法は具体的な distributed actor 型に対して resolve(id:using:) を呼ぶことでした。
protocol Greeter: DistributedActor {
distributed func greet(name: String) -> String
}
distributed actor EnglishGreeter: Greeter {
typealias ActorSystem = ClusterSystem
func greet(name: String) -> String { "Hello, \(name)!" }
}
let remote: EnglishGreeter =
try EnglishGreeter.resolve(id: knownID, using: system)
let greeting = try await remote.greet(name: "Caplin")
この前提は、クラスタの全ノードが同じバイナリを共有するpeer-to-peer構成ではうまく働きます。しかし、次のようなユースケースでは破綻します。
- クライアント/サーバ構成: クライアントはサーバ実装の具体的な distributed actor 型(
EnglishGreeter等)を知らないし、知らせたくない。 - IPC: デーモンプロセスをサーバ、アプリをクライアントとして通信したいが、やはり実装型はクライアントから隠蔽したい。
理想的にはクライアント/サーバ/APIの3モジュール構成で、
- APIモジュール:
DistributedActor制約付きのprotocol Greeterだけを公開 - サーバモジュール:
Greeterを実装した具体的な distributed actor を持つ - クライアントモジュール:
Greeterの具体実装を知らずに、プロトコルだけからリモート参照を解決して呼び出す
としたいのですが、現状では resolve(...) に具体型が必須なため、この分離ができません。
さらに前提として、distributed actor の ActorSystem は具体的なnominal型でなければならず、プロトコルで抽象化したり、distributed actor 自身を ActorSystem についてジェネリックにすることもできませんでした。そのため、「どんな actor system でも動く汎用的な distributed protocol / distributed actor」を書く術もありませんでした。
02 どのように解決されるのか
distributed actor のプロトコルに @Resolvable マクロを付けることで、具体型を知らないクライアントからもプロトコルだけでリモート参照を解決 できるようにします。加えて、distributed actor を ActorSystem についてジェネリックにできるようにし、distributed 向けメタデータの仕組みをプロトコル要件にも対応させます。
@Resolvable マクロ
@Resolvable は、DistributedActor を継承したプロトコルに付与するattached declaration macroです。付与するプロトコルは、ActorSystem が扱う SerializationRequirement を where 節で明示する必要があります(SerializationRequirement は必ずプロトコル型である必要があります)。
import Distributed
@Resolvable
protocol Greeter: DistributedActor
where ActorSystem: DistributedActorSystem<any Codable> {
distributed func greet(name: String) -> String
}
マクロは、プロトコル名に $ を前置したスタブとなる具体的な distributed actor 型(上の例なら $Greeter)を合成します。利用者が意識するAPIは基本的にこの $付き型だけで、クライアント側ではこれを使ってリモート参照を解決します。
// クライアント側
let greeter = try $Greeter.resolve(id: id, using: clusterSystem)
let name = try await greeter.greet(name: "Caplin")
サーバ側は従来通り、プロトコルに適合する具体的な distributed actor を普通に定義します。
// サーバ側
distributed actor EnglishGreeter: Greeter {
typealias ActorSystem = ClusterSystem
distributed func greet(name: String) -> String {
"Hello, \(name)!"
}
}
この構成により、APIモジュール(プロトコルのみ)/サーバモジュール(実装)/クライアントモジュール($Greeter 経由で呼び出すだけ)という役割分担ができるようになります。クライアントは EnglishGreeter のような具体型を一切知る必要がありません。
実際に受け取った参照を取り回す際は、$Greeter そのものより some Greeter / any Greeter を使うことが推奨されます。これにより、「プロキシ経由かローカル実装か」に依存しないコードになり、後からモジュール構成を変更してローカル参照に差し替えるような場合にも対応しやすくなります。
distributed でない要件をプロトコルに含めることも可能ですが、リモート参照に対しては実質的に呼び出せません(whenLocal(operation:) でローカル参照として扱えた場合のみ呼び出せます)。
@Resolvable が合成するもの
@Resolvable は、次のような $付きスタブ型と、スタブ用のデフォルト実装を合成します。併せて、空のマーカプロトコル DistributedActorStub も導入されます。
public protocol DistributedActorStub where Self: DistributedActor {}
// マクロが合成するスタブ型(概念イメージ)
distributed actor $Greeter<ActorSystem>: Greeter, DistributedActorStub
where ActorSystem: DistributedActorSystem<any Codable> {
private init() {} // 直接初期化は不可。resolve(id:using:) のみ
}
extension Greeter where Self: DistributedActorStub {
// 各要件に対するスタブ実装(中身は fatalError)
}
スタブ実装は呼び出されると fatalError になりますが、resolve で得られるのは常にリモート参照なので、実際の呼び出しは DistributedActorSystem の remoteCall に回送され、このスタブ本体が呼ばれることはありません。
なお合成コードの詳細は将来変わりうるもので、利用者が依存してよいのは「プロトコル名に $ を前置した型が、元プロトコルと同じアクセスレベルで公開される」という事実だけです。
親プロトコルを継承する場合、親側の要件にはデフォルト実装があるか、親プロトコル自身にも @Resolvable が付いている必要があります。
ActorSystem についてジェネリックな distributed actor
スタブ型 $Greeter<ActorSystem> が成立するための基盤として、distributed actor を ActorSystem についてジェネリックにできるようになります。従来は typealias ActorSystem = ClusterSystem のような具体型指定が必須で、同じ distributed actor を別の actor system で使い回すことはできませんでした。
この制限を取り払うため、DistributedActorSystem プロトコルは SerializationRequirement をprimary associated typeとして露出するようになります。
protocol DistributedActorSystem<SerializationRequirement>: Sendable {
associatedtype SerializationRequirement
where SerializationRequirement == InvocationEncoder.SerializationRequirement,
SerializationRequirement == InvocationDecoder.SerializationRequirement,
SerializationRequirement == ResultHandler.SerializationRequirement
// ...
}
これによって、次のようにシリアライゼーション要件を指定したうえで、任意の actor system で使える汎用 distributed actor を書けます。
distributed actor DistributedAsyncSequence<Element, ActorSystem>
where Element: Sendable & Codable,
ActorSystem: DistributedActorSystem<any Codable> {
distributed func exampleNextElement() async throws -> Element? { /* ... */ }
}
ActorSystem のシリアライゼーション要件がコンパイル時に分かるため、引数や戻り値が要件(上の例なら Codable)を満たすかどうかはこれまで通り静的にチェックされます。
同じことはプロトコル側でも可能です。
protocol DistributedAsyncSequence: DistributedActor
where ActorSystem: DistributedActorSystem<any Codable> {
associatedtype Element: Sendable & Codable
distributed func exampleNextElement() async throws -> Element?
}
ActorSystem.SerializationRequirement を明示せずに where ActorSystem: DistributedActorSystem とだけ書くとコンパイルエラーです。DistributedActorSystem<any Codable> のような具体的な要件が必要です。
DefaultDistributedActorSystem との関係
モジュール全体に typealias DefaultDistributedActorSystem = ClusterSystem を置くと、各 distributed actor の ActorSystem が暗黙に埋められます。ただし ActorSystem をジェネリックにした distributed actor ではこの暗黙 typealias よりもジェネリックパラメータが優先され、汎用の actor として扱われます。
typealias DefaultDistributedActorSystem = ClusterSystem
distributed actor Worker<ActorSystem>
where ActorSystem: DistributedActorSystem<any Codable> {
distributed func work()
}
// ActorSystem は ClusterSystem に固定されず、呼び出し側が決める
プロトコル要件に対応した呼び出しメタデータ
distributed 呼び出しは、受信側で RemoteCallTarget.identifier(従来はmangleされた具体メソッド名)から実メソッドを引き当てて実行されます。今回の変更では、プロトコル要件の呼び出しに対して$付きスタブ型を基準としたidentifier(例: "Shared.$Greeter.greet(name:)" のようなイメージ)を生成するようにmanglingを拡張し、受信側ではそのidentifierから、実際に解決された具体 distributed actor のwitnessを経由してメソッドを呼べるようにします。
この結果、クライアントは具体実装型を知らずにプロトコル経由でのリモート呼び出しが可能になり、サーバ側の executeDistributedTarget(on:target:invocationDecoder:handler:) の使い方は従来と変わりません。
この変更はwire protocol上もadditiveで、以前サポートしていなかった「distributed protocolメソッドのリモート呼び出し」が新たに可能になるだけです。
Future Directions
今回のスコープ外として、次のような発展が示されています(speculativeなもので、実現を約束するものではありません)。
- distributed protocolのノン・ブレイキングな進化を支援するツール。たとえば
@Resolvable(deprecatedName: ...)や@Distributed.Deprecated(newVersion:defaults:)のようなマクロで、非推奨メソッドから新APIへの自動委譲を生成する方向性。 - remote call target identifierのmangling方式を actor system 側からカスタマイズできるようにする仕組み。identifierを短くしたり、送信回数を減らすような圧縮表現を組み込む余地があります。
- distributed メタデータを使った監査支援。
swift-inspectのようなツールでバイナリから全ての distributed エントリポイントを列挙できるようにし、セキュリティ監査を容易にする方向性。