C#(.NET, .NET Core, Unity, Xamarin)用の新しい高速なMessagePack実装

と、いうものを作りました。MessagePackのC#版です。以前に作ったZeroFormatterのコードをベースに、バイナリの読み書きをMsgPackのフォーマットに差し替えたものになります。MsgPackのライブラリはすでにあるじゃん(MsgPack-Cli)!ってことなんですが、パフォーマンスにかなり差があります。

JSON.NET(スタンダードで、豊富なAPIを持ってる)に対するJil(スピード特化、APIは必要十分はあるけれどJSON.NETほどではない)のようなものと思ってください。とはいえ、生のまま使っても問題は出ない(デフォルトのままで最高速が出るようにチューニングしてある)でしょうし、カスタマイズの口自体も十分用意してあります!詳しくは「拡張」の項で説明しますが、既に私自身が他のライブラリへの対応・インメモリデータベースの内部構造・RPCのシリアライゼーションフォーマットとして応用アプリケーションを作りまくっていて、それの要求に十分応えられるだけの拡張性があります。

今回のコードは、未来のアーキテクチャで実装された、C#のシリアライザ設計を一歩前進させる、隙のない代物になっています。というのは大げさでもなく、現代最先端のC#の設計技術を投下してあるので、世代的に今までのものとは、一つ二つ先を行ってます。C#でJSON以外のフォーマットのシリアライザを使おうと考えたら、もうこれ一択で悩まなくていいですよ。いや、ZeroFormatterとは悩んでください。

そう、ZeroFormatterは?というと、性能特性にクセがあるので、汎用フォーマットとしてはMsgPackのほうがずっと使いやすい、ですね。もちろん、無限大高速な性質はハマるシチュエーションではすごくハマると思いますよ!別にオワコンじゃないです!しかし、FlatBuffersが主流にはならないのと同じように、ハマるシチュエーションをきちんと考えたほうが良いかな、といったところはやっぱあります。使い勝手は工夫しましたが、どうしても、これ系のバイナリ形式そのもののクセは存在しちゃうので。

ところで、詳しくは圧縮の項で説明しますが、LZ4を内蔵したことにより、パフォーマンスを比較的維持したまま、更にファイルサイズを縮めることを可能にしています。これは、ただたんに出来上がったものを上からLZ4で圧縮しているのではなくて、MessagePack + LZ4のパイプラインを一体化して、LZ4のネイティブAPIを効率よく叩くことによって実現しています。また、lz4自体のオプションもシリアライザと併用して使うのに最適になるように調整してあります(コードもメモリプールを使って圧縮のために使う辞書のアロケーションをなくしたりなどの改造を入れてる)

Unity向けには、更にunsafeな拡張をONにしるとVector3[](など)のシリアライズがJsonUtilityの20倍高速化される拡張機能なども設けてます。これは超強力で、Meshなどの巨大データや大量の位置データのやり取りなどに役立つはずです。C#マジおせーからC++で書こうぜ、に最後の最後はなるにしても、それまでの遊び幅は大幅に拡張されるでしょう。

使いかた

Unity版はサイトのReleasesページから、.NETはNuGet経由で入れてもらうのがいいでしょふ。

APIのノリは完全に一緒で、静的関数のSerializeかDeserializeを呼ぶだけです。ただし対象クラスへの特別なマークが必要です。

// 属性をつけるのは「必須」です、これは堅牢性を高めるためです
[MessagePackObject]
public class MyClass
{
    // Keyは配列のindexとして扱います、これはバージョニングで重要です
    // Key名はIntかStringが選べて、Intの場合はArrayで、Stringの場合はMapでシリアライズされます
    [Key(0)]
    public int Age { get; set; }

    [Key(1)]
    public string FirstName { get; set; }

    [Key(2)]
    public string LastName { get; set; }

    // publicメンバーで不要なフィールドは明示的に[IgnoreMember]を付与する必要があります
    [IgnoreMember]
    public string FullName { get { return FirstName + LastName; } }
}

class Program
{
    static void Main(string[] args)
    {
        var mc = new MyClass
        {
            Age = 99,
            FirstName = "hoge",
            LastName = "huga",
        };

        // 基本的に Serialize/Deserialize を呼ぶだけの直感的で単純なAPIが全てです
        var bytes = MessagePackSerializer.Serialize(mc);
        var mc2 = MessagePackSerializer.Deserialize<MyClass>(bytes);

        // ToJsonメソッドによってバイナリを簡単に読みやすいJSON文字列に変換できます
        // これはデバッグ用途などで非常に役に立つでしょう!
        var json = MessagePackSerializer.ToJson(bytes);
        Console.WriteLine(json); // [99,"hoge","huga"]
    }
}

属性をつけるのが「必須」なのは煩わしいところですが、これは堅牢性を高めるためです。MsgPack-Cliとの機能面での最大の差はオブジェクトシリアライズの扱いで、MsgPack-CliはデフォルトでArray、かつ、何もマークしていないものもシリアライズ可能です。これは、プロパティが増えた時の挙動(バージョニング)が極めて危険で、全くよろしくない。そのため、そもそも必須扱いにしてプログラム実行時の限りなく早いタイミングで気づけるようにしています。

かわりにこの煩わしさは、Visual StudioのAnalyzerによってある程度緩和できるようにしています。

また、気楽にやりたい場合は、[MessagePackContract(keyAsPropertyName = true)]にすると、プロパティへの属性付けは不要で、プロパティ名をキーとして扱いMap形式でシリアライズします。JSONライクで手軽ですが、シリアライズ/デシリアライズにかかる時間と、バイナリサイズは肥大化します。ただしKeyに名前がついてるとデバッグ時の楽さはあがるのと、遅くなるといっても依然高速なので、「アリ」な選択ではあるでしょう。

後述しますが引数にFormatterResolverを渡すことによってシリアライザの挙動がカスタマイズできて、標準で用意している ContractlessStandardResolver を渡すと(あるいはSetDefaultResolverでデフォルト挙動を差し替えることも可能)、[MessagePackObject]属性の付与も不要になります。

MessagePackSerializer.Serialize(mc, MessagePack.Resolvers.ContractlessStandardResolver.Instance);

この場合もキー名を文字列としてMapでシリアライズします。Mapを使うので、バージョニングに対する不安もありません。このオプションを合わせた場合が、最もお気楽に使える、 JSON.NETとの互換性というか使用感は変わらない感じになるんじゃないかと思います。また、この場合は匿名型もシリアライズできます(デシリアライズはできない)。

と、色々ありますが、お薦めは明示的にMessagePackObjectをつけて、KeyをIntにすることです。ようするにデフォルトのままが最も最高の効率で最もお薦め、ということです!まぁContractlessStandardResolverも悪くはないです、特に後述するLZ4圧縮と組み合わせれば配列など気になるデッカいデータを処理する時にはきちんとキーを縮められるので、全然良いかなとは。

パフォーマンス/最適化

細かい機能は置いといて、まずパフォーマンスについて詳しく見ていきましょう!

オールスターで並べてみました。小さくて見えませんね、もう少し大きい図はGitHubのページにあるのでそちらを。とりあえず最強に速いです、ということで。

どんなケースが来ても、まぁ、速いデス。圧倒的に。で、速い理由というか他が遅い理由は無限大に説明できるんで、まぁいいでしょう。基本的にはZeroFormatterで行ったことがそのままあてはまってますが、それに加えてMessagePackの仕様に対する最適化と、ZeroFormatterよりも効率的なIL生成によって、なんか結果ZeroFormatterより速くなってしまってなんともかんとも……。

・一切無駄なオブジェクトを生成しない、最終的なbyte[]以外のアロケーションは一切なし
・シリアライズ時のbyte[]の拡張が必要な場合も、64K以下は効率的に内蔵の作業用メモリプールを使うためアロケーションなし
・Streamベースではなくbyte[]ベースのプリミティブAPIにより、Stream抽象による呼び出しオーバーヘッドを削減
・シリアライザのキャッシュ/ルックアップにジェネリクス型変数からの取り出しによるDictionary呼び出しコストを削減
・効率的なメモリプールの使用による作業領域のメモリ拡張の削減
・デリゲート経由ではなく直接、型をIL生成することによる余分な呼び出しコストの削減
・ILコード生成時にプリミティブに対する書き込み/読み込みは、プリミティブAPIを直接呼び出すコード生成によりメソッド呼び出しコスト削減
・ILコード生成時にMsgPackの固定範囲に収まっているキーは範囲分岐判定せず直接呼び出すコードを埋め込み
・コレクションのイテレートをIEnumerable抽象で扱わず、各コレクションそれぞれに対し個別に最適化
・プリミティブ配列に関しては更にジェネリクスも使わず各プリミティブ配列専用のビルトインシリアライザを用意
・ルックアップテーブル事前生成によるデシリアライズ時のタイプ判定コードを削除
・文字列など長さが必要な可変フォーマットに対するヒューリスティックな長さ判定によるコピーコスト削減
・全コードパスがジェネリクスで貫通していてボクシング一切なし
・IL生成ができない環境ではソースコード解析からの事前コード生成による対応

頭からつま先までギッチシと最適化してあるんで、これ以上の速いシリアライザを書くことは不可能でしょう。ってZeroFormatterの時にも言った気がするので説得力が微妙になくなってますが、今度の今度こそもうやれることは絶対にない、というレベルでありとあらゆる設計と技法を突っ込んだので、これがC#の性能限界でしょう、しかも今回はunsafeではなくてsafeなのです!(LZ4, Unityのunsafe拡張を除く)。unsafeがなくてもC#は速いんです。はい。これはMsgPackがBigEndianなのでunsafe使ってもうまみがあんまないから、非unsafeに倒してみたってところですんが。

IL生成がより効率的になったのは、ZeroFormatter以降に何故かILを書きまくる羽目になったせいか、私自身のIL書き能力が向上したことによる余裕によって、結構アグレッシブに生成時分岐で最適なコードを直接埋め込んでみたからです。やっただけ効果は出ますねえ、やはり。なるほど。

コレクションのイテレートに関しては、さすがに数多いので抽象化はしてるんですが、こんなジェネリクス型を用意しました。

public abstract class CollectionFormatterBase<TElement, TIntermediate, TEnumerator, TCollection> : IMessagePackFormatter<TCollection>
    where TCollection : IEnumerable<TElement>
    where TEnumerator : IEnumerator<TElement>

微妙に奇々怪々な内容になっていますが、これが最も速いコレクションのシリアライズ/デシリアライズをするために必要な抽象なのです。例えば、これなら各コレクション専用のstruct enumeratorを使うことができます。ただたんにIEnumerable<T>をforeachするだけじゃ遅くてやってられないのですよ。

というような細かいハックは沢山入ってるんですが、とはいえ基本的にはStreamを捨ててbyte[]ベースにしたというのが大きいですね。byte[]ベースなのストリーミングでのシリアライズ/デシリアライズができないのですが、例えば巨大配列のケースではプリミティブAPIと小シリアライザを使って対処するとか、逃げ口はそれなりに用意されてるので、超絶巨大な一個のオブジェクト、みたいなシチュエーションじゃなきゃ大概なんとかなるものです。

System.IO.Pipelinesが出たら、Pipelines版作ってもいいかな、とは思いますが。しかし、そっちがあればbyte[]版とかイラネー?っていうと、実際のところそんなこたぁなくて、In/Outがbyte[]で確定してる状況では、byte[]版のほうが良いでしょうね。System.IO.Pipelinesで作るとストリーミングでシリアライズ/デシリアライズできるので、その点は良くなると思うんですが、利用するフレームワークの口が大抵はbyteで空いてるんで、ほとんどのシチュエーションでbyte[]版のほうが良好ってことになりそうだとは思ってます。ので、別にそんな優先度も希望も高くは持ってません。XxxAsyncみたいな非同期APIも同じような話が言えて、細切れでawaitかけるような中身になってると、むしろ相当遅くなってしまいます。基本的にはガリッとバッファ確保してガッと書いて、ガッとFlushにしないとダメなのですよ。なので、まぁPipelines版は別ですが、ふつーの形で非同期APIを作る意味は全くないと思ってるんで、それはナシです。むしろそういうのがあると、そっちのほうが良いのかな、とユーザーに思わせてしまうのでAPI設計的に非常によろしくない。

ファイルサイズと圧縮

MessagePackのイケてるところは、型の表現力が非常に高いのに、バイナリサイズが小さくなるところ。一般的にオブジェクトへのシリアライズにはArrayフォーマットが使われて、これはProtoなどのTagで1バイト使用するより小さくなる。もちろん、Arrayを使うことはバージョニングに問題を抱えていないこともないですが、概ねNil埋めで大丈夫な範囲に収まるので許容できるのではないかと考えています。

が、それと圧縮は別問題で、やっぱ圧縮は圧縮で、かけると非常に縮むんですよね。でも当然圧縮は別途パフォーマンスロスを抱えてしまうわけで、と、そこでMessagePack for C#は最速を誇るlz4での圧縮を標準でサポートしました。LZ4は圧縮率はそこそこですが、圧縮/伸張が速い(特に伸張がヤバいぐらい速い)という特徴があります。これはMessagePackのユースケースにかなりハマるんじゃないでしょうか(圧縮率が重要なシチュエーションでは、lz4と同作者のZStandardというものがあって、これもバランス良くて素晴らしい)。

// 基本的に MessagePackSerializer のかわりに LZ4MessagePackSerialzier を呼ぶだけ
var bytes = LZ4MessagePackSerialzier.Serialize(mc);
var mc2 = LZ4MessagePackSerialzier.Deserialize<MyClass>(bytes);

// ToJsonメソッドによってバイナリを簡単に読みやすいJSON文字列に変換できます
// これはデバッグ用途などで非常に役に立つでしょう!
var json = LZ4MessagePackSerialzier.ToJson(bytes);
Console.WriteLine(json); // [99,"hoge","huga"]

んで、とにかく速い。ほとんど変わらないだけの圧縮/伸張速度なのにファイルサイズは激縮み!ただし、一応言っておくと圧縮はデータの内容によって全く効かないこともあれば、重複だらけデータなら効果はてきめんになったり(だからJSON+GZipで配列縮めると大量の同じような文字列キーが縮んでほぼ無視できるようになる)ということがあります。この試験データは重複多めなので、圧縮が効きやすいうえに効率も良いのでめっちゃ縮んでいるだけです。処理時間も複雑なデータであれば、このデータのようにあんま変わらない、などということはなく2倍ぐらいの差になるケースも出てきます(それでも他のシリアライザを単独で使うより速いというのが驚異的な話なのですが!)。この辺は相性とかモノ次第って面もありますが、実際リアルなデータ(現在開発中のゲーム)での色々寄せ集めて集合させた5Mぐらいのデータは800KBになりました、速度的にはx1.5がけぐらい。全然割に合います。

で、このLZ4圧縮はMsgPackで出来上がったデータに対して上からLZ4をかけてるわけではありません。まず、これ自体が正しいMsgPackデータになってます(なので他のMessagePackシリアライザにそのまま渡しても認識はできる、デシリアライズはできませんが、正しく実装されたシリアライザなら少なくとも(Bodyはbinaryですが)Dumpは可能)。MsgPackの仕様のExt領域を使って(TypeCode:99)、LZ4圧縮によるMsgPackという形でシリアライズしています。

なんでかというと、そもそもLZ4がbyte[]ブロックベースで動作する圧縮フォーマットなのです。(C#の)Streamとして使えるベンリAPIがあったりしますが、それはただのラッパーで、むしろかなり速度低下させる一因です。黙ってbyte[]ベースの最もプリミティブなLZ4のAPIを叩く。それが最高に速い。そして、つまりこれって今のMessagePack for C#の実装とめっちゃ相性が良い、こっちもbyte[]ベースですから。相性が良いのは良いとして、ただたんに左から右に流すだけだと、無駄なbyte[]コピーが発生しちゃうんですよね(最終サイズのbyte[]にリサイズするコストとかがどうしてもある)。どうせLZ4通すなら、別にその時点はただの中間地点なので、リサイズする必要はないんで、当然ノーリサイズでそのまま流す。リサイズするのはLZ4通した本当の最後の最後だけ。

それとLZ4の生デコンプレスAPIは、「復元後(圧縮前)のサイズを知っている」ことで、より効率的にデコンプレスできるようになっています。が、LZ4自身には復元後のサイズは埋まってません。なるほど。なるほど。なのでふつーに左から右に流すだけ圧縮だと、真の意味で効率的な復元は実現できません。そこでExt領域を使っている理由がでてきて、MessagePack for C#のLZ4統合では、復元後のサイズを先頭に埋め込んであります。それを使うことにより、真の最高速でのLZ4によるデコンプレスを実現してます。

なお、独断と偏見により64バイト以下はLZ4として圧縮せず素通しするようにしています。なので頻繁に送受信する軽量なデータは圧縮/伸張によるパフォーマンスの影響を一切受けません。これもExt領域を使った意味があって、素通しでもLZ4でも、そのまんまMsgPackとして扱えるんですね。どちらもValidなMsgPackなので、きっちり正しくクライアント側でハンドリングできるようになりました。

シリアライザの選択に悩まないと言いましたが、MessagePackSerializerを使うかLZ4MessagePackSerializerを使うかは、悩みますねえー。

イミュータブルオブジェクトへのデシリアライズ

デシリアライズ処理には通常publicなsetterを要求しますが、MessagePack C#はイミュータブルオブジェクトへのデシリアライズを可能にしています。これが出来ると、

[MessagePackObject]
public class Point
{
    [Key(0)]
    public int X { get; }
    [Key(1)]
    public int Y { get; }

    public Point((int, int) p)
    {
        this.X = p.Item1;
        this.Y = p.Item2;
    }

    [SerializationConstructor]
    public Point(int x, int y)
    {
        this.X = x;
        this.Y = y;
    }
}

KeyがIntの場合は引数の位置で、Stringの場合は名前(大文字小文字無視)でマッチさせます。ある程度「気を利かせてくれる」とかではなく、明確に仕様として設け、コンフィグの口を持っているところは目新しいんじゃないかと。そして、これ、実際便利です。

Union

Union(インターフェイスのシリアライズ/ポリモーフィズム)は2要素の配列として表現しています。一つ目が識別キー。二つ目が中身。

// mark inheritance types
[MessagePack.Union(0, typeof(FooClass))]
[MessagePack.Union(1, typeof(BarClass))]
public interface IUnionSample
{
}

[MessagePackObject]
public class FooClass : IUnionSample
{
    [Key(0)]
    public int XYZ { get; set; }
}

[MessagePackObject]
public class BarClass : IUnionSample
{
    [Key(0)]
    public string OPQ { get; set; }
}

// ---

IUnionSample data = new FooClass() { XYZ = 999 };

// serialize interface.
var bin = MessagePackSerializer.Serialize(data);

// deserialize interface.
var reData = MessagePackSerializer.Deserialize<IUnionSample>(bin);

// use type-switch of C# 7.0
switch (reData)
{
    case FooClass x:
        Console.WriteLine(x.XYZ);
        break;
    case BarClass x:
        Console.WriteLine(x.OPQ);
        break;
    default:
        break;
}

これ、C# 7.0の型でswitchできるのと相性良いんですよね。便利で良くなったと思います。

拡張

今回、デフォルトでやたら拡張パッケージがあります。

Install-Package MessagePack.ImmutableCollection
Install-Package MessagePack.ReactiveProperty
Install-Package MessagePack.UnityShims
Install-Package MessagePack.AspNetCoreMvcFormatter

ImmutableCollectionやReactivePropertyをシリアライズ可能にするやつ。UnityShimsはUnityと相互通信する際のVector3とかとそのシリアライザ。AspNetCoreMvcFormatterはASP.NET Core MVC用のシリアライザ換装するやつです。

拡張を有効にする場合は、Resolverというものを使っていきます。こんな感じで。

// set extensions to default resolver.
MessagePack.Resolvers.CompositeResolver.RegisterAndSetAsDefault(
    // enable extension packages first
    ImmutableCollectionResolver.Instance,
    ReactivePropertyResolver.Instance,
    MessagePack.Unity.Extension.UnityBlitResolver.Instance,
    MessagePack.Unity.UnityResolver.Instance,

    // finaly use standard(default) resolver
    StandardResolver.Instance);
);

この辺のは細かい使い方といったところなので、ReadMeを見てもらえれば、なのですが、MessagePack for C#ではコンフィグ/拡張ポイントをResolverに寄せているので、これの仕組みさえ理解してもらえれば全ての拡張の方法がわかります!逆に、これがちょっと初見だとむつかしめなので、もう少し優しい何かも用意したい気もしなくはないですが、多分、このままでいいんじゃないかな、とも思ってます。

for Unity

今回はZeroFormatterと違って、コードジェネレート不要です!なんですと!!!きっちりとUnityでちゃんと動作するILGenerationによって、ふつーの.NET版と変わらない動的コード生成/パフォーマンスでUnityでも動きます。IL2CPPじゃなければ。IL2CPPじゃなければ。PCでもAndroidでもどんとこい、なんですが、IL2CPPはダメです。IL2CPPの場合は、やっぱりコードジェネレートしてください、今回もコードジェネレーター同梱してあります(そして未だにWindowsでしか動作しません、なんとかしたい……)

更に今回はunsafeじゃありません!ほとんどのコードがsafeで動いてるのでソースコードべた配布。やったね。unsafe使わなくても結構速く出来るんですよ。とはいえ、LZ4がunsafeバリバリなので、LZ4使いたい場合はunsafeを有効にしてください。詳しいことはReadMeで。

ついでにunsafe時のスペシャルフィーチャーとして、エクストリーム高速なVector3[]シリアライザをUnity用に特別に用意しました。

JsonUtilityの20倍速い。これならMeshとかの大量の頂点を扱うものでも、そこそこなんとか戦えるんじゃないでしょうか。それ以上頑張りたかったらC++で、ですけれど、C#でもここまでなら頑張れる……!

なんで速いかというと、structの配列はメモリ上に一列に並ぶというC#の特性を利用して、まるっとそのままコピーしてるからです。Oh……。まぁ、アリでしょう。アリでしょう。なお、さすがにこれは正規のMessagePackの配列じゃなくなる(純粋なバイト列)ので、拡張フォーマットとしてマークして押し込んでます。MessagePackはこれが便利……なんか特化したの突っ込んでも仕様的にValidだと言い張れる。てわけで、アリでしょう。アリ。最高にクールな機能だと思ってます。

MsgPack-Cliとの互換性

あまり考えてない&こちらからサポートする気はあんまナイデス。互換性は基本的にあるんですが、微妙にありません!多分、普通に使ってる場合は非互換になります。C#の型をMsgPackとしてどう表現するか、というところで差異が出ちゃうんで、しょーがない。

Enumのシリアライズ/デシリアライズが、MessagePack for C#ではデフォルトはIntegerになります。文字列でのシリアライズ/デシリアライズのサポートは、Enumを文字列で扱うと明らかに遅くなるのでやる気nothing、と思ってたんですがまさかの1.0.0を投げた直後に要望が来たのでしょうがなく追加で入れることになったのであった。1.0.1スタートの理由、おうふおうふ。というわけでResolverを差し替えることによってEnumを文字列で扱う対応はできます。よかったね。なお、MsgPack-Cliは文字列になるほうがデフォです。なのでデフォのままだと、ここで互換性なくなります。

DateTimeの形式が互換性ありません。MessagePack for C#ではProposalで提唱されているTimestamp拡張を実装しています(ほぼほぼファイナルなんだと思うけど一向にマージされないので、早まったかな、どうなんだろう……)。これもResolverを自前で書けば解決可能なので適当にどうぞ。

あとはdecimalとかGuid辺りの扱いもちょっと違いますがResolverを自前で(以下略)

多言語間での通信

C#独自の型になると、なんというかよしなにハンドリングしてください状態になってしまうんですが、基本型だけ使ってる分には概ね大丈夫でしょう。ただしDateTimeだけは↑に書いたように、特殊なハンドリングしてるんで他の言語のサポート状況次第です。不安なら文字列にして送ったりUnixTimestampにして送ったりすればいいんじゃないでしょーか。DateTimeが互換の問題になるのは別にMsgPackに限らず、JSONでもよくあることですねー。故に標準で型としてサポートして欲しいし、↑のTimestamp拡張がAcceptされるのを待ち望んでいます。

あとは、オブジェクトはIntがキーのArrayかStringがキーのMapのどちらかです、ってことですね。これは他の言語も概ねその二択なので、問題なく相互変換できると思っています。

Protobufとの比較

Protocol Buffersと比較すると、MsgPackはダンプ耐性があるのが好みです。自己記述的で、スキーマと照らし合わせなくても良いため、デバッグとかで何かと捗ります(MessagePack for C#についてるJSONへのダンプ機能は超嬉しいはず、ていうか私が超嬉しい)。また、nullの扱いが明確なのも嬉しいところで、Protobufはそれがかなりのハマりどころで、色々と詰むんですが、MsgPackは完全にC#をシリアライズ/デシリアライズしても自然のまま扱えます。どういうことかというとこういうことです。

[ProtoContract]
public class Parent
{
    [ProtoMember(1)]
    public int Primitive { get; set; }
    [ProtoMember(2)]
    public Child Prop { get; set; }
    [ProtoMember(3)]
    public int[] Array { get; set; }
}

[ProtoContract]
public class Child
{
    [ProtoMember(1)]
    public int Number { get; set; }
}

using (var ms = new MemoryStream())
{
    // nullをシリアライズすると
    ProtoBuf.Serializer.Serialize<Parent>(ms, null);

    ms.Position = 0;
    var result = ProtoBuf.Serializer.Deserialize<Parent>(ms);

    // なんとデシリアライズするとstructのように0埋めされたものになってデシリアライズする!これはヤバい。
    Console.WriteLine(result != null); // True
    Console.WriteLine(result.Primitive); // 0
    Console.WriteLine(result.Prop); // null
    Console.WriteLine(result.Array); // null
}

using (var ms = new MemoryStream())
{
    // 空配列をシリアライズする
    ProtoBuf.Serializer.Serialize<Parent>(ms, new Parent { Array = new int[0] });

    ms.Position = 0;
    var result = ProtoBuf.Serializer.Deserialize<Parent>(ms);

    // nullになって帰ってくる!なんじゃそりゃ、マジでヤバい。
    Console.WriteLine(result.Array == null); // True, null!
}

protobuf-netの問題というか、protobuf自体の型表現力的にしょーがないんですねー、protobufの表現力は実はかなり弱いのです……。なので、protobufを.protoからの生成じゃなく使う、つまり普通の汎用シリアライゼーションフォーマットとして使うのは激しくお薦めしません。実運用に入ると間違いなく問題になるはずです(というか実際グラニでは激しく問題になった!もう二度とprotobuf-netは使わん!)

かわりに、protobufはIDLやそのRPCフレームワークであるgRPCが強力で、多言語間での通信仕様として使うには、圧倒的に秀でていると思います。gRPCは最高ですよ。MsgPackはオブジェクトシリアライズの統一的仕様が存在しないので、言語間での通信仕様としては正直、かなり厳しいと思いますね。いや、別にJSONのように手で調整するなら構わないし、It's like JSONってのはそういうことだろっていうとそういうことなんですが、話が違うのはいかんせんバイナリだということ。JSONはテキストなので目で見て調整できたり、暗黙的にObjectはStringがKeyのMapですよね、で統一されてるんですが、MsgPackはバイナリなので調整辛いし、オブジェクトがArrayなのかMapなのかも統一感なかったりで、ちょっとショッパイと言わざるをえないです。

なので、gRPCとか言語超えたRPCではProtobufが圧倒的に優勢で、これは未来永劫変わらないでしょう。MsgPack-RPCやMsgPack-IDLはコケた、といっても過言ではないし、別に蘇ることもないと思うんで。

しかしバイナリ仕様としては非常に優れてるし、Dump可能なところも嬉しすぎるので、多言語間通信「以外」での局面では、最高のフォーマットだと思います。多言語間通信においても自社内とかの閉じたところなら調整はやりやすいので、決してダメというわけでもない、でしょうが、まぁそういう場合はIDL欲しくなるのがフツーなので、訴求力は弱くなっちゃてるでしょうねえ、現状で既に(MsgPackを「選ばない」理由としては至極真っ当だと思います)。RPCを捨てて、JSON-Schema的な純粋な仕様定義を再展開すればあるいは?とは、やっぱあんま思わないんで、ここはしゃーなしで諦めたほうがいいかしらん、外野の意見では。

MessagePack-RPC/gRPC

と、言っておきながらなんですが、MessagePack for C#を使ったRPCを作っています。MagicOnion - gRPC based HTTP/2 RPC Streaming Framework for .NET, .NET Core and Unity. ということで、通常gRPCはprotobufで通信するんですが、そのシリアライゼーションレイヤーをMessagePackに置き換えてます。なんでかっていうと、それによってIDL不要でRPCできるようにしてからです。IDLを使わない局面ではMsgPackは上で言った通り最高のフォーマットなので。

MagicOnionの特徴は、IDLを使わなくても、型安全で通信のスキーマがかっちり決まった状態になることです。何故か、というと、C#そのものがスキーマとして動くので。MagicOnionは Server C# - Client C# の通信フレームワークになっていて、多言語ではなく同言語間に限定することによって、MsgPackのウィークポイントを塞ぎつつ、素のgRPCよりも、よりC#の特色を活かした強力な機能と書きやすさを付与しています。パフォーマンスも文句なく良い、むしろ素のgRPCよりも良い(シリアライザの性能差で)

まだ開発中なので、今後に乞うご期待:) 実際にUnityで開発中のゲームはこのフレームワークを使ったものになっています。HTTP/1 APIは完全消滅。中々アグレッシブです。

まとめ

ZeroFormatterよりもResolver回り(拡張/オプション)のAPIが大幅に改善されてます。ふつーの利用時は関係ないんですが、フレームワークに組み込んだり、拡張する場合に、こちらのほうが圧倒的に良いです。性能特化のDIを用意したってことなんですが、まぁ相応に良いですねぇ。ちょっとDI嫌いは返上しよう……。ZeroFormatterにも後で移植しよう……。

改めてZeroFormatterとどっちがいーんですか!というと、特性に合わせて選んでくださいとしか言いようがありません。ZeroFormatterが効果アリ!なシチュエーションでピンポイントで使っていけば勿論それは効果アリ!ですが、ぶっちけ7割がた、MsgPackのほうが良いケースのほうが多いとは思っています。MsgPackは偉大なフォーマットですぞよ(ただしTimestampのフォーマットは早く決めて欲しい)。私の中でZeroFormatterのようなフォーマットが必要な理由が、MasterMemoryを作ったことにより、そっちのほうが上位の形で解消されてしまったというのがんががが……。

MsgPack-Cliとでは、まぁお好みで。アタリマエですが実績は無視できないファクターでしょう。ライブラリのメンテナーとして信頼できるかどうかも違いですね(私よりもずっと安定感あると思います!別に私もやらないわけじゃないんですが、ムラがあるんで)。それと私はSilverlightとかWindows Phoneとかサポートする気はないんで、その辺が必要な場合は必須ですね。

世の中、もう十分枯れきったと思っているところでも全然ゆるくて、手を加えられる余地はあるんだなぁ、というのは発見でした。シリアライザがここまで性能伸ばせるなんて、やってみるまで思いもしなかった。C#の良くないところに、ピーキーにチューニングされたライブラリが少ない(Javaのほうが遥かに多いのは事実でしょう!)ことがあり、それが諸々のパフォーマンステストや、そもそもの実績に影響を与えているのですよね。

結局、今までC#がその辺を「ゆるく考えていた」ことの積み重なりが、今の体たらくを招いていることの一因だとも思っています。別にMicrosoftだけではなくコミュニティ全体がね。吐気がするような継承の瓦礫の塔を築いたり、無駄にFunctionalであろうとしたり。私は、C#は好きな言語だから使っているというだけじゃなくて、「前線で戦える言語」だから使っているのです。何かの理想を追う言語ではなく、真に実践的な言語であるから全力で投資しているのです。常に戦場であり、他の言語なりフレームワークなりと戦っているフィールドであり、そこではフェアに評価されるべきであり、戦って死ね。と。C#を前線で戦わせるためにも、こうして一つ一つ、証明し続けていかなければならないでしょう。

UniRxを支えるユニットテスト - RuntimeUnitTestToolkit for Unity

オープンなようなクローズドなような、ラウンドテーブルディスカッションのような、少人数のところでUnityのユニットテストについて話してきました。というか、UniRxのために作って、以降、私の作るUnity用の色々なので使いまわしてる自作のユニットテストフレームワークについて、ですね。

RuntimeUnitTestToolkit for Unity from Yoshifumi Kawai

このフレームワークはずっとUniRxの中に埋まったまんまだったんですが、使える形でパッケージしたのを、今日GitHubに公開しました。unitypackageとしても置いてあるので、一応インポートはしやすいはずです。

とりあえず必要な機能しか入れてないんで、汎用テストフレームワークとしては足りない機能が普通に多いので、その辺も作ってからアセットストアに公開したいなぁ、と思ってはいたんですが、まぁそうなるといつまで経っても公開できなさそうなので、とりあえず現段階のもので公開、です。

.NETのテスト事情、或いはUnityでテストを書かないことについて

私はライブラリとしてはふつーの.NETと共通で動くものを作ることが多いんで、まぁそういう場合は大部分はふつーの.NETのユニットテストを書いたほうが遥かに書きやすいでしょう!つまりUnityでテストを書くコツはUnityで書かないということです!!!みもふたもない。

image

テストのメソッドを右クリックしてデバッグ実行で直接Visual Studioのデバッガでダイレクトにアタッチできたりとか、基本的に最高ですね。

さて、スライドにも書いたのですが、最近はxUnit.netを好んで使っています。MSTestはいい加減投げ捨てていいでしょう、というか投げ捨てるべきでしょう。NUnitは知らん。いらん。補助としてChainingAssertionは変わらず使ってるんですが、.NETCore対応を内部では作って使ってるんですが公開には至ってない……。

また、モックライブラリとしてはMicrosoft Fakes Frameworkのような大仰なものは「絶対に」使うべきではない、という思いが強くなってます。テストはただでさえ負債になりやすいのに(盲目的にテストは書くべき信仰してる人は、テストの負債化に関して全く言及しないのがポジショントークなのか脳みそお花畑なのか、頭悪そうですね)、大きな自動生成を伴うものは負債の連鎖を作りやすいなー、と。シンプルに作らないと、シンプルに投げ捨てることができない、というね。そして、投げ捨てるのは簡単ではなく、投げ捨てるのもまた技術なわけです。

RuntimeUnitTestToolkit

.NETでテスト書くからそれでOK、というわけは当然なくて、Unityだけでしか動かない部分もあるし、そもそもUnityでちゃんと動くかどうかの保証はない。更にはIL2CPPに通した場合はやっぱり別物の挙動というか動かなくなるケースは「非常に多い」ので、ちゃんとIL2CPPで動くことを保証しなければならない。そこで作ったのがRuntimeUnitTestToolkitです。Unityには標準でテストツールあるじゃん、って話ですが、あれは実機動作させられないので論外です。それで用が満たせりゃあ標準の使うわ。

image

テストが並べられて、ボタン押したら実行、ボタンが緑になったら成功、赤になったら失敗というシンプルなふいんきのものです。一個のシーンになってるので、ビルドして実機転送すればそのまま実機で動きます。

実際に自分で使うには、Releaseページからunitypackageを落としてきてインポート。で、UnitTest.sceneを開いて再生すればOK。簡単簡単。

テストの書き方ですが、基本的にはMonoBehaviourを継承したりもしないシンプルなクラスを用意します。

// make unit test on plain C# class
public class SampleGroup
{
    // all public methods are automatically registered in test group
    public void SumTest()
    {
        var x = int.Parse("100");
        var y = int.Parse("200");

        // using RuntimeUnitTestToolkit;
        // 'Is' is Assertion method, same as Assert(actual, expected)
        (x + y).Is(300);
    }

    // return type 'IEnumerator' is marked as async test method
    public IEnumerator AsyncTest()
    {
        var testObject = new GameObject("Test");

        // wait asynchronous coroutine(UniRx coroutine runnner)
        yield return MainThreadDispatcher.StartCoroutine(MoveToRight(testObject));

        // assrtion
        testObject.transform.position.x.Is(60);

        GameObject.Destroy(testObject);
    }

    IEnumerator MoveToRight(GameObject o)
    {
        for (int i = 0; i < 60; i++)
        {
            var p = o.transform.position;
            p.x += 1;
            o.transform.position =  p;
            yield return null;
        }
    }
}

属性とかは特に必要なく、戻り値voidのパブリックメソッドは強制的にテストメソッドとして認識します。また、戻り値IEnumertorのクラスは非同期テストメソッドとして認識してコルーチンとして動かすので、中でyieldとか他のコルーチンを動かしての待機とかも自由にできます。

さすがに定義だけでテストクラスを認識できないので、それとは別にテストローダーを書いてあげます。

public static class UnitTestLoader
{
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    public static void Register()
    {
        // setup created test class to RegisterAllMethods<T>
        UnitTest.RegisterAllMethods<SampleGroup>();

        // and add other classes
    }
}

これで実行してやれば、書いたクラスが実行時にボタンとしてシーンに追加されます。

ある程度リフレクションでメソッドとかの認識をしているんですが、ちゃんとIL2CPPで動作するギリギリのリフレクション加減で仕上げつつ、書きやすい直感的にAPIに仕立てたというのが工夫ポイントですね!

with UniRx

UniRxは結構ユニットテスト向けだったりします。例えば何かアクションを加えてイベントが発行されることを確認したい、という場合に、IObservableとして公開されているならば

public IEnumerator WithUniRxTestA()
{
    // subscribe event callback
    var subscription = obj.SomeEventAsObservable().First().ToYieldInstruction();

    // raise event 
    obj.RaiseEventSomething();

    // check event raise complete
    yield return subscription;

    subscription.Result.Is();
}

と、サクッと書けたりします。あるいは、何か色々によって色々値が変わるということは

public IEnumerator UniRxTestB()
{
    // monitor value changed
    var subscription = obj.ObserveEveryValueChanged(x => x.someValue).Skip(1).First().ToYieldInstruction();

    // do something
    obj.DoSomething();

    // wait complete
    yield return subscription;

    subscription.Result.Is();
}

と、ObserveEveryValueChangedで外側からサクッと値の監視が可能です。また、各種のObservableTriggerを突っ込むことによって、外側から内部の状態をサクッとモニタできます。あまり実際のプログラムでは使うことはないようなことも、ユニットテストなら派手に使っても構わないし、そういう時に楽ができるツールがUniRxには揃っています。外側からサクッとどうこうする手段がないと、インスペクタにユニットテスト用の特別な何かを仕込んでアサートとかいう、しょぼいテストフレームワーク(UnityのIntegration Test Frameworkのことですよ!)になってしまいがちですので。

まとめ

現状のUnityの単体テストツールは、必要な要件を全く満たしてなくて使えなさすぎですぅ。テストツールは結構大事で、とりあえずテスト大事、とりあえずテスト書くんだ、とかいってしょうもないツールを土台にやってるとボロボロに負債になるんで、ちゃんと自分の要件を意識して選択しないとダメですね。そこも把握できてなかったり、あとシンタックスも非常に大事で、Spec系がぶっちゃけ書き方違うだけで本質的に変わらないのに非常に感触が変わるのと同じで、そういうの大事にできない人はプログラミングの感性足りてないんで、小手先のテスト信仰とかしてないで、それ以前にまともな感性磨いたほうが良さそうですね。

とはいえ、Unity 5.6から良くなる気配を見せていて、少なくともその延長線上にはちゃんとした未来がありそうなだけの土台は作れてそうなので良かった。それ以前の(現在の)は本当にセンスなさすぎて、こいつらの感性の先に未来はなさそうだなー、と思ってたんで。

RuntimeUnitTestToolkitをオススメするかっていうと、実機で動かすのに困ってればいいんじゃないでしょうか!とはいえ、素朴すぎるってところはあるんで、もう少し作り込まないと使えないというケースは多そうってところです。私も、自分の作る程度の規模では困ってないんですが、会社のプロジェクトに入れると困るところは多く出てきそうだなー、という感じですね。足らないところを自分で補っていけるならというところです。

近況

ところでなんと今年に入ってブログ書いてなかった!はうう!というのは、書きかけのプロジェクトが多くてそれにあくせくあくせくだからんですねえ。公開まであともう一歩、というところまでに持ってけているのは MessagePack for C#(.NET, .NET Core, Unity, Xamarin) です。

ZeroFormatterあるじゃん、なのに何故、って話ですが、まぁそれは公開時にでも。とりあえず、エクストリーム速いです。それと、拡張性も重視して組んでいて、Unity用の特殊な拡張をアドオンとして有効化すると、例えばVector3[]のシリアライズ/デシリアライズがJsonUtilityの50倍高速化(50倍!)とか、色々強力で強烈になってます。乞うご期待。

それと会社ブログ - Grani Engineering Blog始めましたということで、そっちに幾つか記事書いてますね。C#のswitch文のコンパイラ最適化についてとか。あとgRPC化とか。

NextGen Server/Client Architecture - gRPC + Unity + C# from Yoshifumi Kawai

こちらも、シリアライザのMessagePack for C#化とか大工事を何度かしつつも、もうすぐとりあえずStableといえるとこまで持ってけそうです。

また、Unity用のインメモリ内蔵データベースとしてMasterMemoryというのも作っていて

*neuecc/MasterMemory

これももうすぐ公開できそうかもかもといったところで、とりあえず色々あって大変大変。どれもUnityでのユニットテストには RuntimeUnitTestToolkit で動かしてるんで、私自身は超ヘビーに使いまくってますよ、です。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive