ねののお庭。

かりかりもふもふ。

【C#】Source Generatorを使ったSignalR Clinetに強く型付けするためのライブラリをリリースしました、という話。

タイトルの通りなんですが、C# 9で導入された新機能であるSource Generatorを使った、SignalR Clinetに強く型付けするためのライブラリをリリースしました。

github.com

SignalRってなんぞ、っていうと、リアルタイム通信用のライブラリ。Socket.IOと似たような感じのもの、といった方が一部の人には分かりがいいかもしれない。 C#のサーバサイドフレームのデファクトであるASP.NET Coreにデフォルトでビルトインされているので、お手軽にブラウザとリアルタイム通信しようと思ったらとりあえずこれ使っとけ、みたいなところがある。少なくともgRPC-webよりお手軽。

もちろんSignalRはサーバ/ブラウザ間の通信だけじゃなくて、サーバ/C#クライアントapp間でもリアルタイム通信できます。 できるんですが、サーバサイドは強く型付けして書けるのに、クライアント側は強く型付けできません。型が欲しい...。

たとえば、以下みたいな2つのインターフェースがあったとします。

interface IClientContract
{
    Task SomeClientMethod1(string user, string message);
    Task SomeClientMethod2();
}

interface IHubContract
{
    Task<string> SomeHubMethod1(string user, string message);
    Task SomeHubMethod2();
}

そんな時、サーバ側では以下みたいに書けます。

using Microsoft.AspNetCore.SignalR;

class SomeHub : Hub<IClientContract>, IHubContract
{
    public async Task<string> SomeHubMethod1(string user, string message)
    {
        await this.Clients.All.SomeClientMethod1(user, message);
        return "OK!";
    }

    public async Task SomeHubMethod2()
    {
        await this.Clients.Caller.SomeClientMethod2();
    }
}

継承しているHub<T>の型引数にクライアント側のインターフェースを与えてやると、Clients.All.SomeClientMethod1(...)みたいな感じでクライアント側に定義されている関数を叩けます。 それに対してクライアント側はどうか。

HubConnection connection = ...;

// 関数名を文字列で指定してコールバックを登録しなければならない。
// コースバックの引数の型もOn()の型引数で指定しないといけない。
connection.On<string, string>("SomeClientMethod1", (user, message) => {});

// ハブ側(サーバ側)のメソッドを叩くのも、やっぱり文字列で指定しなければならない。
// 返り値の型も型引数で指定しなければならない。
// ハブに定義されている関数に渡すための引数(ここで"user"とか渡しているやつ)の型はobject型...
var ret = await connection.InvokeAsync<string>("SomeHubMethod1", "user", "message");

文字列で関数名を指定!コールバックの引数の型も型引数で手動で指定!Hub側の関数の返り値の型も型引数で指定!Hub側の関数に渡す引数の型はobject型!

という具合で、サーバ側の定義と睨めっこしてせっせと合わせていかないといけません。タイポは許されませんし、型違いも許されません。 サーバ側の定義を変更したら、やっぱり睨めっこして合わせていかなければなりませんし、しかもコンパイル時エラーにはならないので、実行してみて初めてミスに気づく類のものです。

とまぁ、こんな感じで嫌ポイントがたくさんあるわけです。 ではどうするか。

Hub<T>の型引数のためにどうせインターフェース定義するんだからそれ活用したいじゃん、と素朴になるわけです。

そんなこんなでTypedSignalR.Clientという、SignalR Clinetに強く型付けするためのライブラリを作りました。 GitHubでスター☆押してくれると泣いて喜びます!

github.com

使い方

使い方は非常に単純で、

class Receiver : IClientContract
{
    // 適当な実装
}

HubConnection connection = ...;

// Hub側のメソッドを叩くためのHubPorxyを生成
var hub = connection.CreateHubProxy<IHubContract>();

// Hub側から叩かれるメソッドが定義されたReceiverを登録。
var subscription = connection.Register<IClientContract>(new Receiver());

// 文字列での関数名の指定やobject型からの解放!
hub.SomeHubMethod1("user", "message");

// Receiverの登録解除はDispose叩くだけ。
subscription.Dispose();

はい、非常にシンプルです。

また接続が切れたり再接続された際に発火されるイベントがありますが、HubConnectionには、public event Func<Exception,Task> Closed;という形でAPIが生えており、登録や解除を+=, -=演算子で頑張らないといけないため、非常に面倒です。

ということで以下のようなインターフェースを用意しました。

interface IHubConnectionObserver
{
    Task OnClosed(Exception e);
    Task OnReconnected(string connectionId);
    Task OnReconnecting(Exception e);
}

このインターフェースを実装したインスタンスをRegister関数の引数に渡してやると、自動で切断/再接続などのイベントが登録されます。

class Receiver : IClientContract, IHubConnectionObserver
{
    // 適当な実装
}

HubConnection connection = ...;

// IHubConnectionObserverが実装されていれば、自動でそれらの関数も登録。
var subscription = connection.Register<IClientContract>(new Receiver());

Source Generatorが何をやっているか。

Source Generatorが何をやっているかというと、まずCreateHubProxyRegisterといったメソッドが使われているところを探します。そして使われているメソッドに与えられている型引数を調べて、それにあったクラスや関数を生成します。

これらの解析を行うのはRoslyn APIを通して行います。 Roslynは .NET Compiler Platformと呼ばれるものの通称で、構文解析の結果やそのセマンティックなどの情報を提供してくれます。 ぶっちゃけC#でアプリ書いてる分にはまったく触らないAPIですが、非常に素敵なものとなっております。

TypedSignalR.ClientがRoslynで解析等してる事を列挙するとざっと以下みたいな感じです。

  • 全ソースから目的の拡張メソッドが呼ばれているところの探索
    • TypedSignalR.ClientではCreateHubProxyとかを使ってるところ。
  • 拡張メソッドを呼んでいるクラスのチェック
    • CreateHubProxyHubConnectionから呼ばれているか。
    • 同一の名前空間にCreateHubProxyを別のクラスの拡張メソッドとして新規に定義することも可能なので。
  • 拡張メソッドが含まれている名前空間のチェック
    • CreateHubProxynamespace TypedSignalR.Clientに含まれている関数か。
    • namespace TypedSignalR.Client以外のところにHubConnectionの拡張メソッドとしてCreateHubProxyを定義できない事もないので。
  • 拡張メソッドから型引数の抽出
  • 型引数がインターフェースかのチェック
  • インターフェースに定義されているメンバがすべて関数かチェック
  • メンバ関数の名前、返り値型、引数の型や名前の抽出
  • メンバ関数の返り値がTaskTask<T>かのチェック
  • 制約に従っていない場合は詳細なコンパイルエラーを提示

具体的には、まず以下のように関数が使われていたとします。

var hub = connection.CreateHubProxy<IHubContract>();

今回作成したライブラリはまずこの使用箇所を見つけます。見つけたら、型引数に与えられている型が何かを調べます。今回の場合はIHubContractです。 そしてIHubContractに定義されている関数をしらべて、以下のようにIHubContractが実装された、Hub側の関数を叩くためのクラスを生成します。

private class HubInvoker : IHubContract
{
    private readonly HubConnection _connection;

    public HubInvoker(HubConnection connection)
    {
        _connection = connection;
    }

    public Task<string> SomeHubMethod1(string user, string message)
    {
        return _connection.InvokeCoreAsync<string>(nameof(SomeHubMethod1), new object[] { user, message });
    }

    public Task SomeHubMethod2()
    {
        return _connection.InvokeCoreAsync(nameof(SomeHubMethod2), Array.Empty<object>());
    }
}

そしてCreateHubProxyが叩かれた際には以下みたいな感じで生成されたclass HubInvokerをインスタンス化して返します。

public static partial class Extensions
{
    static Extensions()
    {
        // 静的コンストラクタ内で登録
        HubInvokerCache<IHubContract>.Construct = static connection => new HubInvoker(connection);
    }

    // static type caching
    private static class HubInvokerConstructorCache<T>
    {
        public static Func<HubConnection, T> Construct;
    }

    public static THub CreateHubProxy<THub>(this HubConnection connection)
    {
        return HubInvokerConstructorCache<THub>.Construct(connection);
    }
}

次にRegisterの場合ですが、以下みたいな使われ方をしていたとします。

connection.Register<IClientContract>(new Receiver());

この場合、ライブラリは型引数で与えられたインターフェースに定義されている関数を総なめして、以下のようなHubConnectionとReceiverをバインドする関数を生成します。Register関数内部ではこの生成された関数が使われます。

private static CompositeDisposable Bind(HubConnection connection, IClientContract receiver)
{
    var d1 = connection.On<string, string UserDefine>(nameof(receiver.SomeClientMethod1), receiver.SomeClientMethod1);
    var d2 = connection.On(nameof(receiver.SomeClientMethod2), receiver.SomeClientMethod2);

    var compositeDisposable = new CompositeDisposable();
    compositeDisposable.Add(d1);
    compositeDisposable.Add(d2);
    return compositeDisposable;
}

説明のため多少違いますが、およそこんな感じです。

We 🧡 コンパイル時エラー

ちなみにこのライブラリ、いくつかの制約があります。(後ろ2つはSignalRのサーバ側からの要求なんですが)

  • CreateHubProxy/Registerの型引数はインターフェースでなければならない。
  • CreateHubProxy/Registerの型引数に渡されるインターフェースは関数しか定義してはいけない。
  • HubProxyに使うインターフェースに定義される関数の返り値は Task or Task<T>でなければならない。
  • Receiverに使うインターフェースに定義されている関数の返り値はTaskでなければならない。

上記の制約を人間が守るのは非常に大変。 また、ランタイムエラーも極力避けたいです。ラインタイムエラー出されるくらいならコンパイル時エラーだしてくれた方が100億倍幸せです。 (ちなみにサーバ側はHub<T>のTにTask以外の返り値を返す関数を実装されたものを指定するとラインタイムエラーで死にます)

これらの制約を守り、ランタイムエラーを避けるため、詳細なコンパイル時エラーを出すようにしています。これでランタイムエラーとおさらば。 下に表示されているエラーをタブルクリックすれば問題箇所まで飛べるので、修正も簡単。

言語/構文的には問題なくても、我々開発者が「(言語的には問題ないけど)ライブラリの使い方間違えとるで」といった感じで独自のコンパイルエラーを提示する事が出来るというのは非常に素晴らしいですね。

f:id:nenoNaninu:20210613021554p:plain

Source Generatorはどういうコードを生成するべきか問題。

Source Generatorはビルドの一歩手前で実際のソースコードを生成します。 なので、生成されるソースコードをユーザが参照して叩いてもコンパイルは通り、問題なく使うことができます。これは実行時に動的コード生成する類のものではできなかったことです。 また、Visual Studioではインクリメンタルコンパイルが走るため、生成されたコードに対して問題なく補完が効きます。

なのですが、Source Generatorが生成したコードをユーザがバシバシ参照して叩いていいのか?という問題があります。

ユーザが参照する事に何の問題があるかというと、ライブラリが抱えている制約に沿わない使い方などでコード生成にコケた場合、修正するべきところ以外までIDEが怒ってきます。 それは「そんな関数はないぞ」であったり、「そんなクラスは無いぞ」であったり。 問題箇所1か所さえ修正すれば良くても大量のエラーが出てくるのは良くないと思います。その1か所の特定がやっかいになったりするので。 そのほかにもリファクタリングに追従できなかったり、いろいろ問題があります。

こういった事を避けるため、以下のような方針でTypedSignalR.Clientは実装しました。

  • 生成に絶対に失敗しないAPIをユーザに提供。(Xとします)
  • 生成に失敗する可能性がある部分は触りずらいようにしておき、原則X経由で叩いてもらうようにしておく。
    • あくまでSource Generatorはコード生成するので、完全に隠蔽する事は難しいため触りずらいようにしておく、くらいで。

まぁ、結局Source Generator使う場合でも、普通に動的コード生成を行う時のパターンと同じような感じがいいんじゃないでしょうか。

とはいえ生成に失敗する可能性はあるものの、生成されたコードをバシバシ触りたい、みたいな場合もあると思います。そういう場合はSource Generatorにコード生成させるための別プロジェクトを用意して、それをアプリケーション側のプロジェクトから参照する、くらいがちょうどよかったりするかもなぁとか思ってます。

まとめ

C# 9で導入された新機能であるSource Generatorを使った、SignalR Clinetに強く型付けするためのライブラリを作りました。

github.com

Source Generatorを書いてみたいけどRoslyn触ったことない人には結構参考になるんじゃないでしょうか。 Roslynが分からないとSource Generatorでメタプロっぽいこと出来ないのでRoslynは必須科目となっております。 参考になったらGitHubでスター☆押してネ!

その他

Source GeneratorとかAnalyzer書いてる時はRe#は切りましょう。いろいろ挙動が怪しいです。またRoslyn Componentが幸せにデバッグできるようになったのはVisual Studioでも直近なので、Re#はまだ対応できてないみたいです。またRe#つかってるとSource Generatorが生成したコードにジャンプできなかったりします(空っぽのファイルに飛ぶ)。