dotnet csharp unity rust ffi zenoh mqtt

Unity と iOS で動く Zenoh の C# バインディングを作った

ZenohDotNet という、Eclipse Zenoh を .NET / Unity から利用するための C# バインディングを作りました。

この記事では、なぜ MQTT ではなく Zenoh を選んだのか、Unity と iOS で動かすためにどのような構成にしたのかを紹介します。

Note

ZenohDotNet は非公式のコミュニティ実装で、現時点では Alpha です。API が変更される可能性があり、プロダクション利用時は実際のワークロードでの評価が必要です。

なぜ Zenoh を選んだのか

検討していたのは、Unity クライアントを含む複数ノード間でリアルタイムにデータをやり取りするためのミドルウェアです。

最初の候補として MQTT がありました。MQTT は軽量な Pub/Sub プロトコルであり、実装やマネージドサービスも豊富です。一方で、MQTT は Client-Server 型のプロトコルで、クライアントは基本的に Broker を介して通信します。

Zenoh を選んだ一番の理由は、将来クライアント数や拠点が増えたときに、MQTT Broker をスケールさせる構成を考えるよりも、Zenoh の方が段階的に拡張しやすそうだと考えたためです。

もちろん MQTT Broker もクラスタリングなどによってスケールできます。ただし、その具体的な方式は Broker の製品やサービスに依存します。今回重視したのは、最初からひとつの構成に固定するのではなく、規模や配置に合わせて通信トポロジ自体を選択できることでした。

  • 同一 LAN 内では Peer-to-Peer で、できるだけシンプルかつ低遅延に通信したい
  • クライアント数が増えたら Router を配置し、接続や配送を集約したい
  • 拠点やクラウドをまたぐ場合は、複数の Router を接続したい
  • 制約のある端末は Client として Router に接続したい

Zenoh は同じプロトコルで Peer-to-Peer、Client-Router、Routed、そしてそれらを組み合わせた構成を取れます。公式ドキュメントでも、Peer-to-Peer は各 Peer が直接セッションを持つ一方、スケーラビリティや端末リソースを考慮する場合は Client モードで接続先を集約できると説明されています。

つまり「Zenoh なら無条件にスケールする」という意味ではありません。小規模なローカル通信から始め、クライアント数や拠点が増えた段階で Router を導入し、階層化・分散するという選択肢を同じミドルウェアの中で取れるため、今回の用途では MQTT Broker を中心に構成するよりもスケールさせやすいのではないか、と考えました。

MQTT と Zenoh の比較

どちらも Pub/Sub に利用できますが、設計の中心は少し異なります。

観点MQTTZenoh
基本モデルClient-Server 型の Pub/SubPub/Sub に加えて Query/Reply、Liveliness などを提供
トポロジClient と BrokerPeer、Client、Router を組み合わせて選択可能
Broker / Router原則として Broker を介して配送P2P では Router なしでも通信可能。必要に応じて Router を配置
スケール方法Broker のクラスタリングなど。具体的な方式は製品やサービスに依存Peer、Client、Router や Region を使い、通信モデルと配置を選択
データの指定Topic / Topic FilterKey Expression
配送制御QoS 0 / 1 / 2、Retain、永続 Session などPriority、Congestion Control など。利用可能な項目は API や構成に依存
Request / ReplyMQTT 5 の Response Topic と Correlation Data を使ってアプリ側で構成Query / Queryable がプロトコルの抽象として存在
主な選択理由成熟したエコシステム、Broker やクラウドサービスの選択肢、明確な配送 QoSトポロジの自由度、Pub/Sub と Query の統合、Edge から Cloud まで同じモデルで扱えること

MQTT は QoS、Retain、永続 Session といったメッセージ配送の機能が明確で、運用実績や製品の選択肢も多いです。Broker 中心の構成が要件に合うなら、依然として有力な選択肢です。

Zenoh は Broker レスの P2P も、Router を使った集約も選べます。また、流れているデータを受け取る Pub/Sub だけでなく、Key Expression に対して問い合わせる Query / Queryable を同じモデルで扱える点も特徴です。

なお、性能はメッセージサイズ、トランスポート、QoS、ネットワーク、Broker / Router の構成によって変わります。Zenoh 公式には MQTT、Kafka、DDS とのベンチマークがありますが、この記事ではその数値をそのまま採用理由にはしていません。最終的には対象環境で計測すべきです。

ZenohDotNet の構成

Zenoh の本体は Rust で実装されています。ZenohDotNet では Rust の zenoh crate を利用し、C ABI の FFI を境界として C# から呼び出します。

C# / Unity API

ZenohDotNet.Client / ZenohDotNet.Unity

ZenohDotNet.Native(P/Invoke)

C ABI(csbindgen で C# 宣言を生成)

zenoh-ffi(Rust)

Eclipse Zenoh(Rust crate)

パッケージは大きく 3 層に分けています。

  • ZenohDotNet.Native: .NET Standard 2.1 の低レベル FFI ラッパー
  • ZenohDotNet.Client: .NET 8 以降向けの非同期 API
  • ZenohDotNet.Unity: UniTask や Unity のライフサイクルを考慮した UPM パッケージ

Rust 側では Session、Publisher、Subscriber などをラップしたポインタを C# 側へ返します。境界を C ABI に限定することで、Rust 固有の ABI を C# へ直接公開しないようにしています。

たとえば Session を開く関数は次のような形です。

#[no_mangle]
pub extern "C" fn zenoh_open(config_json: *const c_char) -> *mut c_void {
    // JSON5 の設定から Zenoh Session を生成し、ハンドルを返す
}

C# 側には、対応する DllImport が生成されます。

[DllImport(
    __DllName,
    EntryPoint = "zenoh_open",
    CallingConvention = CallingConvention.Cdecl,
    ExactSpelling = true)]
internal static extern void* zenoh_open(byte* configJson);

csbindgen で P/Invoke 宣言を自動生成する

FFI の関数が増えるたびに C# の DllImport を手書きすると、関数名、引数型、Calling Convention などの差分が入り込みやすくなります。そこで Cysharp/csbindgen を使い、Rust の extern "C" 関数から C# の宣言を自動生成しています。

build.rs の設定は次のようになっています。

csbindgen::Builder::default()
    .input_extern_file("src/lib.rs")
    .csharp_dll_name("zenoh_ffi")
    .csharp_dll_name_if(
        "(UNITY_IOS || UNITY_WEBGL) && !UNITY_EDITOR",
        "__Internal")
    .csharp_class_name("NativeMethods")
    .csharp_namespace("ZenohDotNet.Native.FFI")
    .csharp_use_function_pointer(false)
    .generate_csharp_file(output_dir.join("NativeMethods.g.cs"))?;

csharp_use_function_pointer(false) にして delegate を生成しているのは、Unity との互換性を考慮したためです。また、iOS の実機ビルドでは静的ライブラリにリンクされたシンボルを __Internal から解決する必要があるため、条件付きで DLL 名を切り替えています。

自動生成の対象は低レベルな P/Invoke 宣言です。その上に IDisposable / IAsyncDisposable、例外処理、文字列や byte 配列の変換などを担当する C# の API を実装しています。FFI の機械的な部分は生成し、C# としての使いやすさは手書きの層で作る方針です。

Unity と iOS で動かすために考慮したこと

通常の .NET アプリケーションだけであれば、ネイティブライブラリを Runtime Identifier ごとに NuGet パッケージへ含めることで利用できます。しかし Unity、特に iOS では追加の考慮が必要です。

IL2CPP / AOT から呼び戻せる callback にする

Subscriber などでは、Rust から C# の callback を呼び出します。iOS の IL2CPP / AOT 環境では、capturing lambda やインスタンスメソッドをそのままネイティブへ渡す構成を避ける必要があります。

ZenohDotNet では static delegate に MonoPInvokeCallback を付け、GCHandle を context pointer として渡しています。callback を受けた static メソッドが context pointer から C# オブジェクトを復元し、ユーザーの callback を呼ぶ構成です。

Rust の static library を Unity の iOS Plugin として含める

Rust 側は cdylib に加えて staticlib も生成します。

[lib]
crate-type = ["cdylib", "staticlib"]

iOS 向けには aarch64-apple-ios でビルドした libzenoh_ffi.a を UPM パッケージの Plugins/iOS に配置し、Unity が生成する Xcode プロジェクトへリンクさせます。Unity Editor 上では通常の zenoh_ffi、iOS 実機では前述の __Internal を参照します。

Unity の main thread へ callback を戻す

Zenoh の callback は Unity の main thread で実行されるとは限りません。Unity 用のラッパーでは UniTask を利用し、受信 callback を main thread へ dispatch してからユーザーコードを呼び出します。

Action<ZenohDotNet.Native.Sample> wrappedCallback = nativeSample =>
{
    var sample = new Sample(
        nativeSample.KeyExpression,
        nativeSample.Payload);
 
    UniTask.Post(() => callback(sample));
};

これにより、受信後に GameObject や UI を操作するコードを Unity 側で扱いやすくしています。

Unity から使ってみる

UPM の Git URL からパッケージを追加できます。ZenohDotNet.Unity は UniTask を利用するため、先に UniTask の導入が必要です。

https://github.com/konnta0/ZenohDotNet.git#upm

最小構成では、Session を開いて Publisher を宣言し、Key Expression に対してデータを送信します。

using Cysharp.Threading.Tasks;
using UnityEngine;
using ZenohDotNet.Unity;
 
public class ZenohPublisherExample : MonoBehaviour
{
    private Session session;
    private Publisher publisher;
 
    private async void Start()
    {
        session = await Session.OpenAsync(
            this.GetCancellationTokenOnDestroy());
 
        publisher = await session.DeclarePublisherAsync(
            "unity/player/position");
    }
 
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            publisher.Put($"{transform.position}");
        }
    }
 
    private void OnDestroy()
    {
        publisher?.Dispose();
        session?.Dispose();
    }
}

トポロジを明示したい場合は SessionConfig で Mode と接続先を指定できます。たとえば Router に集約する Client 構成は次のようになります。

var config = new ZenohDotNet.Native.SessionConfig()
    .WithMode(ZenohDotNet.Native.SessionMode.Client)
    .WithConnect("tcp/192.168.1.10:7447");
 
var session = await Session.OpenAsync(
    config,
    this.GetCancellationTokenOnDestroy());

開発初期は Peer モード、運用時は Router を利用する、といった切り替えを C# 側の設定から行えます。

現在の対応範囲と今後

現在は、次の機能を実装しています。

  • Session
  • Publisher / Subscriber
  • Query / Queryable
  • Liveliness
  • .NET 8 向け Client API
  • Unity / UniTask 向け API
  • Windows、Linux、macOS、Android、iOS 向けネイティブビルド
  • 型付きメッセージ用 Source Generator

一方で、まだ Alpha であり、パフォーマンスのチューニング、テスト対象の拡充、Router / Client を組み合わせた構成の検証などは継続していく予定です。

iOS 向けは GitHub Actions の公開 workflow で aarch64-apple-ios 向けの static library をクロスビルドし、NuGet / UPM パッケージへ組み込んでいます。一方、Android 向けは build-native.sh に Android NDK を使ったビルド処理があるものの、現時点の公開 workflow の build matrix には含まれていません。そのため Android 版については、NDK を用意した環境でのクロスビルドとパッケージへの組み込みが必要です。

まとめ

MQTT は成熟したエコシステムと明確な配送 QoS を持つ、堅実な選択肢です。一方、今回のように LAN 内の P2P から Router を使った大規模・複数拠点構成まで、要件に応じてトポロジを選びたい場合、Zenoh は面白い選択肢になります。

ZenohDotNet では、Zenoh の Rust 実装を C ABI で公開し、csbindgen で C# の P/Invoke 宣言を生成する構成にしました。その上に .NET と Unity 向けの API を用意し、IL2CPP / AOT、static library、main thread への callback dispatch といった点を考慮することで、Unity と iOS でも利用できる形にしています。

リポジトリはこちらです。

参考資料