ねののお庭。

かりかりもふもふ。

【C#/TypeScript】TypeScript の SignalR client でも MessagePackHubProtocol を使いたい!

リアルタイム通信するなら、やはりシリアライズ/デシリアライズは高速であればあるほどいい。 というわけで、TypeScript の SignalR client でも JSON ではなく MessagePack を使いたいよね、使おう!というお話。

SignalR の protocol

SignalR は、内部で用いる server と client 間の protocol を変える事が出来ます。 その protocol は幾つかあるのですが、基本的には以下の 2 つのどちらかを使う事になります。

  • JsonHubProtocol
  • MessagePackHubProtocol

ようするに、異なる protocol を使えるという事は、異なるシリアライザを使える、という事です。 server-side と client-side それぞれに正しくプロトコルを実装できるのであれば、自前で独自プロトコルを実装する事だって出来ます(普通そんな事はしませんが)。

JsonHubProtocol はシリアライザに System.Text.Json を使っており、一方で MessagePackHubProtocol は MessagePack-CSharp が使われています。 さて、どちらのシリアライザが高速でしょう? 答えは、MessagePack-CSharp です。 シリアライザのベンチマークはいろんな所でいろんな人がいろいろ書いているので、そちらをご覧ください。

そんなわけなので、SignalR のようなリアルタイム通信ではシリアライズ/デシリアライズにかかるコストは小さければ小さいほどいいので、MessagePackHubProtocol を使いたいぞ!となります。なりますよね? では、どう使うか。

SignalR の client が C# の場合は話は簡単です。 Microsoft.AspNetCore.SignalR.ClientMicrosoft.AspNetCore.SignalR.Protocols.MessagePack の2つのパッケージを追加し、HubConnection を作成する時に、AddMessagePackProtocol を呼べばいいだけです。 これで特に問題はありません。

var hubConnection = new HubConnectionBuilder()
    .WithUrl("/myhub")
    .AddMessagePackProtocol()
    .Build();

では、TypeScript は?パッケージを追加して

yarn add @microsoft/signalr @microsoft/signalr-protocol-msgpack

以下のように .withHubProtocol(new MessagePackHubProtocol()) を呼べばいいだけ? ...では済みません。残念ながら。

const connection = new HubConnectionBuilder()
    .withUrl("/myhub")
    .withHubProtocol(new MessagePackHubProtocol())
    .build();

シリアライザと言語

TypeScript で MessagePackHubProtocol を使う場合、.withHubProtocol(new MessagePackHubProtocol()) をすればいいだけではないと書きました。 C# で MessagePackHubProtocol を使う分 AddMessagePackProtocol すればいいだけなのに、TypeScript で .withHubProtocol(new MessagePackHubProtocol()) をすればいいだけではないのは何故でしょう? TypeScript で JsonHubProtocol を使うのは問題ないのに、MessagePackHubProtocol を使うと問題になるのは何故でしょう?

MessagePackHubProtocol を C# と TypeScript で使う場合の違い

まず、幾つか前提を起きます

  • server-side、つまり SignalR Hub は C# で記述します。
  • MessagePack はバイナリシリアライズです。シリアライズしたバイナリを、msgpack-bin と記述する事とします。

server-side は C# 固定なので、client-sise で C# を使うか、TypeScript を使うかで何が変わってくるの?というお話。

client が C# の場合

SignalR Hub (C#) <-> msgpack-bin <-> SignalR Client (C#)

この場合、特になんの問題もありません。 何故かというと、まず SignalR Hub と SignalR Client の双方で同一のシリアライザ(MessagePack-CSharp) が使われており、Hub と Client それぞれのメソッドのパラメータや返り値の型は、基本的に同一の型をそれぞれで扱う事になります(このあたりは TypedSignalR.Client を使えばいろいろ簡単かつ幸せになります)。 つまり、同一言語の同一のシリアライザで、同一の型のインスタンスをシリアライズ/デシリアライズするという、出来て当然のお話に帰着します。なので、これといって問題はないわけです。 msgpack-bin がどういうフォーマットになってるかとかほぼほぼ気にしないでOK。

client が TypeScript の場合

SignalR Hub (C#) <-> msgpack-bin <-> SignalR Client (TypeScript)

さて、client-side が TypeScript の場合はどうでしょう。 SignalR Hub で使われる MessagePack のシリアライザには MessagePack-CSharp が用いられ、SignalR Client では msgpack-javascript が用いられます。 言語が違うので、当然シリアライザが違います。当たり前ですね。

さて、server-side と client-side の双方が MessagePack という同一のフォーマットを用いつつも、言語及びシリアライザが違う事で発生する問題はなんでしょうか? それは、同一のフォーマットを、双方のシリアライザがどのように扱い、それぞれの言語にどう落とし込むかを理解しなければならない事です。

C# の簡単な POCO を MessagePack でシリアライズする事を考えます。

public class MyData
{
    public Guid Id { get; set; }
    public DateTime DateTime { get; set; }
    public string? Name { get; set; }
    public int Value { get; set; }
    public byte[] Bytes { get; set; }
    public MyEnum MyEnum { get; set; }
}

public enum MyEnum
{
    None,
    One,
    Two,
}

この簡単なクラスが MessagePack-CSharp によってシリアライズされ、MessagePack の仕様に沿ったバイナリ表現になったとき、どのように表現されているでしょうか? Guid は 8-4-4-4-12 の文字列?それとも 128 bit のバイト列?、DateTime は ISO 8601 に沿った文字列表現? それとも Unix time や DateTime.Ticks などの整数値表現? byte はそのままバイト列として扱われる? それとも JSON みたいに base64 でエンコードされた文字列としてシリアライズされる?enum は数値としてシリアライズされる? それとも文字列?そもそもユーザ定義型がシリアライズされた時どうなるの?array 表現?map 表現?map 表現の場合の key はプロパティ名が Pascal のまま使われる?それとも camel に直される?

などなど、非常に単純な C# の POCO をシリアライズしようとしただけで、少なくともこれくらいの事は把握しておく必要があることが分かります。大変ですね。 JSON と違って人間が読めるテキスト表現ではなくバイナリ表現なので、MessagePack の仕様を理解していないと、エスパーするのもちょっと厳しい。 C# to C#、つまり C# でシリアライズして C# でデシリアライズする場合ならこれらの事は把握してなくてもほぼ問題にならないんですけどね、言語を跨ぎシリアライザを跨ぐのでしょーがない。

上記の疑問等を解決するには、まず以下の事を把握する必要があります。

  • MessagePack の仕様
    • バイナリ上で何がどのようなフォーマットで表現されているか。
    • msgpack/spec.md を一通り読んで概ね理解できれば OK.
  • MessagePack-CSharp の仕様
    • C# の型をどのように MessagePack の仕様に落とし込んでいるか。
    • オプションでどのように落とし込み方が変わるか。
      • オプション一つで GUID, DateTime, enum あたりのバイナリへの落とし込み方が変わってくる。

さて、とりあえず上記の疑問が全て解決したとしましょう。 次に何を把握しないといけないかというと、msgpack-javascript が msgpack-bin をどのように解釈し、TypeScript のオブジェクトにデシリアライズする際どういう表現をおこなっているのか、という事です。 MessagePack の仕様をだいたい把握しているという前提を今はおいているので、ある程度 TypeScript 上でどのように表現されるか想像はつくのですが、それでもまだ C# における DateTime は TyepScript でデシリアライズされたら Date 型になるの?byte は number[] でデシリアライズされる?それとも Uint8Array?など、まぁ気になるポイントはつらつらと出てきます。そしてその全てを把握する必要があります。

ここまで、C# でシリアライズされ、TypeScript でデシリアライズされる場合を考えてきましたが、その逆もちゃんと把握する必要があります。 つまり、TypeScript でシリアライズし、C# でデシリアライズする場合です。 msgpack-bin から TypeScript のオブジェクトにデシリアライズする際はそれほど苦ではないのですが(なにせ TypeScript ...というか JavaScript なので、なんであれオブジェクトもとい連想配列にデシリアライズは出来てしまう。良くも悪くも。)、逆に msgpack-bin を C# のインスタンスにデシリアライズするのはそれなりにケアが必要です。プロパティ名の case から始まり、msgpack-bin で表現している値が C# のプロパティの型に対して適切な値にちゃんとなるかどうかなど。

要するに、最終的に以下の 3 点を正しく理解する必要があるという事です。

そんなわけなので、言語を跨いでコントラクト無しでバイナリシリアライザを使おうとするといろいろ大変なのです。 言語を跨いで通信する際に human readable な JSON が大人気になる理由も頷けるし、バイナリシリアライザを使う場合は contract first な Protocol Buffers が好まれるのも頷けるというものですね。

TypeScript で JsonHubProtocol と MessagePackHubProtocol を使う場合の違い

言語が違くても、シリアライザが違くても、JsonHubProtocol だと上記のような MessagePackHubProtocol で発生する問題が概ね起きません。 何故でしょうか?

それはほとんどの人が JSON に慣れ親しんでいるからです。 慣れ親しんでいるが故に、無意識のうちにどのようにシリアライズ/デシリアライズされ、JSON でどのように表現されるかエスパーし、それに従ってコードを書くことが出来てしまうからです。

JSON の構成要素、構造型としては object (dictionary) と array、プリミティブな値型としては string, number, boolean, null の 4 つしかない事を知っているはずです。 驚くべきことに、別に RFC 8259 とかでじっくり仕様を読んだことなくても殆どの人はそれを知っているでしょう。

そして、C# の任意の型のインスタンスをシリアライズした時、どうなるのかもおおよそ分かるでしょう。 C# のプロパティ名が JSON object の メンバー名に (ASP.NET Core の既定なら) camelCase となって対応し、値がユーザ定義型なら object に、T[]List<T>,IEnumerable<T> 等なら array に、intfloat, bool などのプリミティブ型であれば number, boolean に、そしてそれ以外のほぼ全て(DateTime, Guid, Uri 等) は string になると。 そしてデシリアライズする時に JSON object が C# の任意の型のインスタンスにどのようにマッピングされるかもだいたい分かるでしょう。

同様に、TypeScript で JSON を扱う場合もどのようにシリアライズされ、デシリアライズされるかも概ね知っているでしょう。 まとめると、以下の 3 つをほぼ無意識のうちに把握している事になります。

  • JSON の仕様 (RFC 8259)
    • RFC 8259 を読んだ事はなくても、だいたい皆知っている。
  • C# の JsonSerializer (System.Text.Json) の仕様
    • C# で JSON 弄ってる人はだいたい知ってる
  • TypeScript の JsonSerializer の仕様
    • TypeScript (JavaScript) で JSON 弄ってる人はだいたい知ってる

そんなわけなので、言語が違くても、シリアライザが違くても、JsonHubProtocol ではそれほど問題にならないのです。 JSON の普及力は恐るべしという話。

MessagePackHubProtocol を使うための解決策

言語跨いで MessagePackHubProtocol を使うのが大変そうなのは分かったけど、レスポンスの速度上げたいし、計算資源にかかる負荷も下げたいというのがプログラマ人情というものでしょう。 特にリアルタイム通信では猶更その傾向は強まるでしょう。

というわけでやっぱり、TypeScript client でも、JSON ではなく、MessagePack 使いたいですよね。 そこに冴えた解決策があります。TypedSignalR.Client.TypeScript を使う事です。 これは TypeScript の SignalR client を強く型付けするためのライブラリ及び CLI tool です。 そしてなんと、JsonHubProtocol と MessagePackHubProtocol の双方に対応しています。

TypedSignalR.Client.TypeScript は以下の事を事をやってくれます。

  • C# で記述された Hub/Receiver の interface を TypeScript の型(type) にトランスパイル。
    • それらと HubConnection を適切にバインドするコードの生成
  • C# で記述された Hub/Receiver の interface 内のメソッドのパラメータや返り値で利用されているユーザ定義型を TypeScript の型(type) にトランスパイル。
    • JsonHubProtocol / MessagePackHubProtocol それぞれに合わせて適切な型のマッピングをツール側が全部やってくれる。
      • つまり TypeScript を書く時、生成された型に従えば OK!という状況を作れる。

使い方

TypedSignalR.Client.TypeScript.NET Tool で CLI tool が提供されています。以下のコマンドでインストールして、help が表示されれば、CLI ツールのインストール完了です。

$ dotnet tool install --global TypedSignalR.Client.TypeScript.Generator
$ dotnet tsrts help

次に、ASP.NET Core の WEB API テンプレートを生成しましょう。CLI で dotnet new webapi -o SignalRSandbox とやってもいいですし、Visual Studio で作成しても良いです。そして以下のように、TypedSignalR.Client.TypeScript を使うためのパッケージと、Analyzer を追加します。 Analyzer は任意ですが、属性のアノテーション漏れとかを防いでくれるので、導入する事を推奨します。加えて SignalR で MessagePackHubProtocol を使うためのパッケージも追加します。

// TypedSignalR.Client.TypeScript を使うためのパッケージ群
dotnet add package TypedSignalR.Client.TypeScript.Attributes
dotnet add package TypedSignalR.Client.TypeScript.Analyzer (任意だけど、推奨)
dotnet add package Tapper.Analyzer (任意だけど、推奨)

// SignalR で MessagePackHubProtocol を使うためのパッケージ
dotnet add package Microsoft.AspNetCore.SignalR.Protocols.MessagePack

あとは簡単です!基本的には SignalR の Hub/Receiver の interface に属性 [Hub]/[Receiver] をアノテーションして上げるだけです! なお Hub/Receiver の interface 内で使われてるユーザ定義型は TypeScript 上の型にトランスパイルする必要があるため、[TranspilationSource] という属性を追加する必要があります。 ちょっと面倒だねと思うかもしれませんが、Analyzer を追加していれば [Hub]/[Receiver] の属性が適用された時点で、interface 内で使われているユーザ定義型に [TranspilationSource] がアノテーションされていなかったら、Analyzer が「このユーザ定義型に[TranspilationSource] がアノテーションされてないぞ~」と教えてくれるようになるので、実のところそれほど面倒でもなく、アノテーション漏れも防げます。 なので是非 Analyzer は導入しましょう。 そして Analyzer に指摘されるがままに [TranspilationSource] をつけて回りましょう。

using Tapper;
using TypedSignalR.Client;

namespace App;

[Hub] // <- 属性の追加!
public interface IChatHub
{
    Task Join(string username);
    Task Leave();
    Task<IEnumerable<string>> GetParticipants();
    Task SendMessage(string message);
}

[Receiver] // <- 属性の追加!
public interface IChatReceiver
{
    Task OnReceiveMessage(Message message);
    Task OnLeave(string username, DateTime dateTime);
    Task OnJoin(string username, DateTime dateTime);
}

[TranspilationSource] // <- 属性の追加!
public record Message(string Username, string Content, DateTime TimeStamp);

server-side では、上記の interface は以下のように Hub の実装に用います。これは普通ですね。

// 上記の interface を使って、通常通り Hub を実装する。
// これは標準的書き方で、TypedSignalR.Client.TypeScript 固有の書き方とかではない。
public class ChatHub : Hub<IChatReceiver>, IChatHub
{
    // いろいろ実装
}

残りの C# 側でやる事は、SignalR が server-side で MessagePackHubProtocol を使えるように構成する事だけです。 AddMessagePackProtocol() を呼びましょう。とりあえず、AddMessagePackProtocol() にはなにもオプションを渡さない感じでやっていきます。オプション渡すバージョンは後述します。

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR()
    .AddJsonProtocol()
    .AddMessagePackProtocol();

これで、C# で一通り記述するべき事は記述しました。

次に、TypedSignalR.Client.TypeScript を使って、C# から TypeScript コードを生成します。 とはいえ、1コマンド叩けばいいだけなので大変お手軽です。 MessagePackHubProtocol の既定の構成を使う場合は、以下のコマンドを叩くことで、適切な TypeScript コードを生成できます。

(ちなみに、CLI ツールで何もオプションを渡さなかった場合、TypedSignalR.Client.TypeScript は JsonHubProtocol の既定の構成に適した TypeScript コードを生成します)

$ dotnet tsrts --project path/to/Project.csproj --output generated --serializer MessagePack --naming-style none --enum name

これで --output で指定したディレクトリに TypeScript コードが出力されているハズです。 生成した TypeScript コードは、以下の用に使います。 基本的に、 getHubProxyFactory/getReceiverRegister を起点にコードを書くことになると思います。

import { HubConnectionBuilder } from "@microsoft/signalr";
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
import { getHubProxyFactory, getReceiverRegister } from "./generated/TypedSignalR.Client";
import { IChatReceiver } from "./generated/TypedSignalR.Client/App";
import { Message } from "./generated/App";

// 通常通り、HubConnectionBuilder を使って HubConnection を作成
const connection = new HubConnectionBuilder()
    .withUrl("https://example.com/hubs/chathub")
    .withHubProtocol(new MessagePackHubProtocol())
    .build();

// C# での IChatReceiver interface が TypeScript の IChatReceiver という型(type) にトランスパイルされる。
// 生成された IChatReceiver type に従ってメソッドを実装する。
const receiver: IChatReceiver = {
    onReceiveMessage: (message: Message): Promise<void> => {...},
    onLeave: (username: string, dateTime: Date): Promise<void> => {...},
    onJoin: (username: string, dateTime: Date): Promise<void> => {...}
}

// getHubProxyFactory の引数は string literal type なので、typo とかを気にする必要はありません!
// また getHubProxyFactory は引数によって返り値の型が異なるオーバーロードとして定義されているので、hubProxy は強く型付けされます。
// 詳細は実際に生成されたコードをみると一目瞭然です。
//     ex) https://github.com/nenoNaninu/TypedSignalR.Client.TypeScript/blob/main/samples/console.typescript/generated/TypedSignalR.Client/index.ts#L46-L58
const hubProxy = getHubProxyFactory("IChatHub") // HubProxyFactory<IChatHub>
    .createHubProxy(connection); // IChatHub

// getHubProxyFactory とほぼ同様。
// register を呼ぶ時に、上記で作成した receiver を渡して connection に対して登録する。
const subscription = getReceiverRegister("IChatReceiver") // ReceiverRegister<IChatReceiver>
    .register(connection, receiver) // Disposable

// 特に変わった事はせず、普通に start
await connection.start()

// hubProxy を通して、SignalR のメソッドを叩く。
// connection.invoke("join", username) とかしないでOK.
await hubProxy.join(username)

// もちろん、返り値も強く片付けされている。
const participants = await hubProxy.getParticipants()

これだけです! TypedSignalR.Client.TypeScript は TypeScript のコードを生成する際に、C# <-> MessagePack <-> TypeScript で問題が起きないように、TypeScript の型や、プロパティ名を適切にした上でコード生成しています。 なので、intellisense に従いコードを書き、コンパイルが通れば、TypeScript でも問題なく MessagePackHubProtocol を使う事が出来ます。便利!

推奨設定

さて、基本的に上記の設定で問題ないのですが、もうちょっとオススメな構成があります。 MessagePackHubProtocol は既定で enum を文字列としてシリアライズします (JsonHubProtocol は 既定で enum を整数値としてシリアライズするのに!)。 TypedSignalR.Client.TypeScript では C# で定義した enum を TypeScript コードにトランスパイルして出力しますから、文字列であって嬉しい事は正直あまりありません。

// TypedSignalR.Client.TypeScript を使えば、
// C# の enum は
[TranspilationSource]
public enum MyEnum
{
    None = 0,
    One = 1,
    Two = 2,
    Four = 4,
}

// 以下のような TypeScript の enum にトランスパイルされる。
// なので enum を文字列としてシリアライズする意味はそれほどない。
export enum MyEnum {
    None = 0,
    One = 1,
    Two = 2,
    Four = 4,
}

// --enum name オプションを渡していた場合は以下のような TypeScript コードになる。
export enum MyEnum {
    None = "None",
    One = "One",
    Two = "Two",
    Four = "Four",
}

なので、以下の用に AddMessagePackProtocol のオプションで、SerializerOptions を以下の用に設定してあげましょう。 こうすることで、enum が整数値としてシリアライズされるようになります。

builder.Services.AddSignalR()
    .AddJsonProtocol()
    .AddMessagePackProtocol(options =>
    {
        options.SerializerOptions = MessagePackSerializerOptions.Standard
            .WithResolver(ContractlessStandardResolver.Instance)
            .WithSecurity(MessagePackSecurity.UntrustedData);
    });

AddMessagePackProtocol でオプションをなにも構成しない時は、CLI ツールで --enum name というオプションを渡していましたが、不要となります。 なので、以下のコマンドを使う事で、適切な TypeScript コードが生成できます。

dotnet tsrts --project path/to/Project.csproj --output generated --serializer MessagePack --naming-style none

まとめ

SignalR には内部で使う protocol が複数存在し、基本的には公式から提供されている JsonHubProtocol と MessagePackHubProtocol の2種類どちらかを使う事になりますが、当然ながら高速な方を使いたいわけです。 そして高速なのは、MessagePackHubProtocol なので、そっちを使いたい、となります。ただし SignalR を C# <-> C# ではなく、C# <-> TypeScript で使う場合、MessagePackHubProtocol を使うのはちょっと難しいという話がありました。それは MessagePack の仕様と、C# と TypeScript それぞれの MessagePack シリアライザの仕様をしらないといけないという事です。なのでちょっとハードルが高めなのですが、でもやっぱり、TypeScript でも MessagePackHubProtocol 使いたいよね、と。 そこで TypedSignalR.Client.TypeScript を使えば、それらの難しい事を理解し、それらの事を常に考えながらコードを書く必要がなくなり、簡単に TypeScript でも MessagePackHubProtocol を使う事が出来る!というお話でした。

そんなわけで TypedSignalR.Client.TypeScript を使って、快適な SignalR ライフを送りましょう!