MessagePack for C# 1.4.1 - JSONサポート強化, dynamic対応, Typelessシリアライズなど
- 2017-06-30
めちゃくちゃ久々ですが、この間、何も書いてないわけではなかったです!会社ブログのほうに、Unite 2017 Tokyo講演「「黒騎士と白の魔王」にみるC#で統一したサーバー/クライアント開発と現実的なUniRx使いこなし術」、リアルタイム通信におけるC# - async-awaitによるサーバーサイドゲームループ、MessagePack for C#に見るC#でのバイナリの読み方と最適化法と三本書いてました。
また、Unite 2017とAWS Summit 2017という大きめの会場での発表もしていました。
Uniteはクライアントサイド中心に、AWS Summitではサーバーサイド中心にという形で用意していたのですが、特にUniteのほうは幅広く扱いすぎて散漫になってしまって、割と反省しています。どちらのセッションもコード成分が少なめになってしまったのも如何ともし難いところで、どこかでもう少しコードコードしたものをしたい気は割としています。
MessagePack for C# 1.4.1
さて、本題。MessagePack for C#の1.4.1をリリースしました。ちなみに表記する際 MessagePack-CSharp と呼ぶべきか MessagePack for C# と呼ぶべきかが悩ましいですね。1.0.0の時から、特に機能追加でのアナウンスをしていなかったので、一挙紹介したいと思います。かなり強化されています……!
JSONサポート
もともとToJsonだけだったのですが(MessagePackBinaryをJSON形式に変換、バイナリなので中身がわかりにくいmsgpackの中身を解析するのに便利)、FromJsonが追加されています。
// JSON文字列をMessagePackバイナリ(byte[])に変換
var msgpackBin = MessagePackSerializer.FromJson(@"{""hoge"":""foo"",""huga"":2000}");
// byte[]は送信するなり保存するなり、MessagePackとしてDeserializeするなりお好きなように。
// {"hoge":"foo","huga":2000}
Console.WriteLine(MessagePackSerializer.ToJson(msgpackBin));
FromJson、便利なの?というと、んー、まぁあんまり使うことはないかなー、とは思いますが、(互換的な意味/ブラウザからだから)JSONで受けて、内部的にはMsgPackで流す、みたいなシナリオもなくはないんですよね。そういうところではいいんじゃないでしょうか。また、後述するdynamicと組み合わせると以外と便利かもしれません。
Dynamicデシリアライズ
XMLだと、構造を見て、手でマップしていくということが割とあったのですが、JSONではXMLにおける属性など複雑な要素がないぶんだけ、そのままストレートにデシリアライズでマッピングするだけで事足りることがほとんどになった気がします。ましてやMessagePackはバイナリなので、手付けで対応つけるのもやりにくいでしょう。とはいえ、C#的な構造に1:1でマッピング出来ないような構造がこないとも限らず、簡単に、動的に弄れる機構があれば、かなり有意義なのは間違いないでしょう。MessagePack for C#は、標準でdynamicで受けることで、動的オブジェクトとして操作できるようになります。
// こんなデータがあったとして
var bin = MessagePackSerializer.Serialize(new Dictionary<object, object>
{
{ "Name" , "foobar" },
{ "Arguments", new object[]{ 1, 100.424, "hugahuga" } },
});
// dynamicでデシリアライズ!
var d = MessagePackSerializer.Deserialize<dynamic>(bin);
// インデクサを使って動的に辿って取り出せる
Console.WriteLine(d["Name"]); // foobar
Console.WriteLine(d["Arguments"][1]); // 100.424
Console.WriteLine(d["Arguments"][2]); // hugahuga
// データ構造はToJsonで確認しておけばよろし
// {"Name":"foobar","Arguments":[1,100.424,"hugahuga"]}
Console.WriteLine(MessagePackSerializer.ToJson(bin));
ちなみにFromJsonとDeserialize<dynamic>を組み合わせれば、MessagePack for C#だけで簡易的なJSON解析・値の取得が可能になります。
// FromJsonとDeserialize<dynamic>を組み合わせてDynamicJsonになる
var d = MessagePackSerializer.Deserialize<dynamic>(MessagePackSerializer.FromJson(@"{""hoge"":""foo"",""huga"":2000}"));
Console.WriteLine(d["hoge"]); // foo
Console.WriteLine(d["huga"]); // 2000
性能的には、まぁわざわざmsgpackのbyte[]を介しているので、超速い!ってわけじゃないんですが、そもそもMessagePack for C#の速度が他の数倍速いということもあって、普通にかなりの速度が出ます。
なお、dynamicデシリアライズの正確な実体は PrimitiveObjectResolver で、StandardResolverの最後のフォールバックとして組み込まれています。
Typelessシリアライズ
Typelessって何?ってことですが、BinaryFormatterみたいなものです。普通の(?)シリアライザは、デシリアライズ時に<T>だの引数にTypeだのと、とにかく型を要求します。何故かと言うと、どの型に変換すればいいのかわからないから。でもBinaryFormatterは違います、APIを見てください、Typeを要求していないのです!
public object Deserialize(Stream serializationStream);
それなのにobjectで返されたほうには、ちゃんとシリアライズした時の型で帰ってくる。すごいね!便利だね!その理由は……、.NETの型がバイナリに埋まってるから。バイナリに埋まってるので、その情報を元にデシリアライズしているのです。というわけで、そんなTypelessで処理できるバージョンが実装されました。
// .Typeless経由でトップレベルのTypelessSerializerが使える
var bin = MessagePackSerializer.Typeless.Serialize(new MyClass() { Hoge = 100 });
// ちゃんとMyClass.Hoge = 100 でデシリアライズされてる
var mc = MessagePackSerializer.Typeless.Deserialize(bin);
// こんな風に、型名が先頭にシリアライズされてる。
// Dump結果はMapのように見えますが、実際はMsgPackの拡張領域(100)を使い、型を埋めている
// {"$type":"ConsoleApp73.MyClass, ConsoleApp73","Hoge":100}
Console.WriteLine(MessagePackSerializer.ToJson(bin));
実装的には TypelessContractlessStandardResolver 経由でシリアライズされているので、普通のシリアライズと混ぜることができます。どういうことかというと、object[]とかでも問答無用にきちんとシリアライズ/デシリアライズできます。
// こんな型があったとして
public class RpcInfo
{
public string MethodName { get; set; }
public object[] Arguments { get; set; }
}
// ----
var info = new RpcInfo
{
MethodName = "Hoge/Huga",
Arguments = new object[] { "foo", 100, new MyClass() }
};
// RpcInfoとしてシリアライズ
var bin = MessagePackSerializer.Serialize<RpcInfo>(info, TypelessContractlessStandardResolver.Instance);
// (object[] Arguments)が正しく復元されている
var info2 = MessagePackSerializer.Deserialize<RpcInfo>(bin, TypelessContractlessStandardResolver.Instance);
こういう、ふつーだと出来ないことが色々できる感じで夢広がりますね。前述のPrimitiveObjectResolverでも、まぁまぁ賄えるのですが、独自型とかを入れると扱いが厄介になってしまうので、そういう点でこちらの TypelessResolver のほうがイケテル度は高いです。
ところで、型を埋め込み、任意の型でデシリアライズできる場合には脆弱性が出る可能性があります。詳しくはBreaking .NET Through Serializationという資料を読んでほしいのですが(この資料は大変素晴らしいのでC#書く人は絶対読んだほうがいいですよ)、中には酷いクラスがあって、例えば System.CodeDom.Compiler.TempFileCollection はデストラクタでFile.Delete が走ります。基本的にインターネットの外からやってくるものに絶対の安全はありません。MessagePackはバイナリだからといって、別に不正データが投げつけられないわけではないので、TempFileCollection を型情報として埋めて、File.Deleteの対象をデシリアライズさせるものを投げつければ、ファイルをボロボロに削除されちゃうでしょう。
MessagePack for C#ではそれなりの安全性(最もキケンな[Serializable]のルールには従わない、↑で挙げられてるようなヤベークラスはそもそもデシリアライズできないようにしている)はありますが、絶対の保証がある、と言い切れるかというとなんともというところです。まぁ、シリアライザを作るってことは、表面上に見えるよりも、もっと色々なことを考えて作ってるんですよ、ということで。
標準Resolverから外しているように、Typeless自体がオススメかどうかというと微妙なのですが(型を埋め込む都合上バイナリサイズも膨らむし、他言語との互換性も消滅する)、欲しいシチュエーションというのは間違いなく存在するので、そういう時に覚えていてもらえれば嬉しいです。
Stream API
基本的にMessagePack for C#はbyte[]レベルで動作します。byte[]を直接読み、byte[]に直接書く。それにより、あらゆるオーバーヘッドを削減しているんですが、既存フレームワークなどにシリアライザ拡張を仕込む場合、Streamを引数に取るケースが多いんですね、というか普通そうですよね。そんな場合、高レベルAPI(MessagePackSerializer.Serialize/Deserialize)にはStreamオーバーロードが用意されているのですが、プリミティブなAPI(MessagePackBinary)には、ありませんでした。
さすがにそれはやりづらいねー、ってのはわかるー、ので、新しくMessagePackBinaryのWrite/ReadにStreamを受け取るオーバーロードが用意されました。最終的にbyte[]に読み取って/書き込んでから処理するのですが、そこのところを内部のメモリープールを通したりして、なるべくオーバーヘッドが少なくなるようにしています。
また、新たに MessagePackSerializer.Deserialize(Straem stream, bool readStrict) というオーバーロードが高レベルAPIに登場しました。readStrictがtrueの場合、Streamから読み取る範囲が、きっちりMessagePackのブロック分だけになります。デフォルトはfalseです。falseの場合はStreamを最後まで呼んで、そのbyte[]ブロックを処理します。そのため、Streamに連続的にMessagePackのバイナリが詰まっていた場合に処理できなかったんですね、これがreadStrictなら、正しくDeserializeを連発するだけでも動作させられます。
using (var ms = new MemoryStream())
{
// Streamに連続的に書き込む
MessagePackSerializer.Serialize(ms, new[] { 1, 10, 100, 1000 });
MessagePackSerializer.Serialize(ms, new[] { 1000, 100, 10, 1 });
ms.Position = 0;
// readStrict: trueで正しく順番にデシリアライズできる
var a1 = MessagePackSerializer.Deserialize<int[]>(ms, readStrict: true); // [1, 10, 100, 1000]
var a2 = MessagePackSerializer.Deserialize<int[]>(ms, readStrict: true); // [1000, 100, 10, 1]
}
じゃあtrueがデフォルトのほうがいいじゃん!ってことなんですが、パフォーマンス的にはfalseのほうがいいのです。というのも正確にMessagePackのブロック範囲を読み取るために、先にブロック範囲を解析する必要があるので……。これは、MessagePack for C#がbyte[]レベルで動作しているため、正しくストリーミングで読み書きできるわけじゃないからです。その辺のトレードオフは承知の上でbyte[]レベルを基本に敷いています。ストリーミングでやるから単純にロスなしでパフォーマンス良いんだぜ!じゃないところが世の中の現実的なところ、ということで。
Resolverによる拡張
MessagePack for C#の拡張ポイントは IFormatterResolver のみです。なんたらオプションとかなんたらセッティングスとかなく、どのリゾルバーを使うか。それだけの単純明快な仕様になっています。そして、それだけで十分すぎるほど機能するのです!なんでそうなのかというと、本質的にシリアライザって、ある型にたいしてどういうbyte[]を書く/読むか、ってことの連続にすぎないんですね。なので MessagePack for C# ではそこだけに注目して、ある型にたいしてどういうbyte[]を書く/読むか、を定義することがシリアライザの最小の実装としました。それがIMessagePackFormatter<T>で、Tに対してSerializeとDeserializeを定義します。組み込みで126個用意されてるようです、凄い、地道な作業です……。
スクロールバーの長さがものがたる。
IFormatterResolver は何かというと、その IMessagePackFormatter を取り出す機構です。
// IntFormatterが出てくる
var intFormatter = resolver.GetFormatter<int>();
で、それがどこで使われているかというと、IMessagePackFormatterです。IMessagePackFormatterを取り出すIFormatterResolverはIMessagePackFormatterで使われる、というわけわからん感じですが、どういうことかというと、例えばオブジェクトをシリアライズする場合。
[MessagePackObject]
public class SampleModel
{
[Key(0)]
public int Id{ get; set; }
[Key(1)]
public Person User { get; set; }
[Key(2)]
public DateTime CurrentTime { get; set; }
}
public sealed class SampleModelFormatter : IMessagePackFormatter<SampleModel>
{
public int Serialize(ref byte[] bytes, int offset, SampleModel value, IFormatterResolver formatterResolver)
{
if (value == null)
{
return MessagePackBinary.WriteNil(ref bytes, offset);
}
var startOffset = offset;
offset += MessagePackBinary.WriteFixedArrayHeaderUnsafe(ref bytes, offset, 3);
// formatterResolver経由で各型のシリアライザを取得している
offset += formatterResolver.GetFormatter<int>().Serialize(ref bytes, offset, value.Id, formatterResolver);
offset += formatterResolver.GetFormatter<Person>().Serialize(ref bytes, offset, value.User, formatterResolver);
offset += formatterResolver.GetFormatter<DateTime>().Serialize(ref bytes, offset, value.CurrentTime, formatterResolver);
return offset - startOffset;
}
}
オブジェクトのシリアライズが代表的ですが、型はネストするんですね、ネストした各プロパティの型の子シリアライザを取得するためにformatterResolverが使われます。このformatterResolverはシリアライズの際のトップレベルから渡され続けて、それにより挙動がカスタマイズできます。
// デフォルト:Contract(属性付与)が必要なResolver
MessagePackSerializer.Serialize(model, MessagePack.Resolvers.StandardResolver.Instance);
// 無指定で全てのpublic型をシリアライズなJSON.NETライクにカジュアルに使えるResolver
MessagePackSerializer.Serialize(model, MessagePack.Resolvers.ContractlessStandardResolver.Instance);
Resolverは大量に用意されているのですが、大きく分けて、他のと混ぜて使うためのものと、トップレベルで渡されることを想定した複合の二種があります。例えば単独だとDateTimeには組み込みで二種類あります。
// DateTimeFormatter, MsgPackのTimestampの仕様でシリアライズ/デシリアライズする。UTCになる。
var formatterA = BuiltinResolver.Instance.GetFormatter<DateTime>();
// DateTime.ToBinaryで.NETに特化した仕様でシリアライズ/デシリアライズする。DateTimeKindが保持される。
var formatterB = NativeDateTimeResolver.Instance.GetFormatter<DateTime>();
では、NativeDateTimeResolverを使いたい、という場合には、使いたいResolverを先に持ってけばいい、と。
// StandardResolverによる解決の前にNativeDateTimeResolverで解決させる
MessagePack.Resolvers.CompositeResolver.RegisterAndSetAsDefault(
NativeDateTimeResolver.Instance,
StandardResolver.Instance);
CompositeResolverは組み込みのお手軽にResolverのカスタムチェーンを作れる代物ですが、CompositeResolverにこだわらず、自分でResolverを作ってしまうのも良いです(むしろ割とそちらのほうがオススメ、ReadMeに書かれているものをコピペすれば、別に難しくはありません)。ちなみにStandardResolverは以下のような単発Resolverの混合品になっています。
// StandardResolverの解決順序
static readonly IFormatterResolver[] resolvers = new[]
{
BuiltinResolver.Instance, // Try Builtin
AttributeFormatterResolver.Instance, // Try use [MessagePackFormatter]
DynamicEnumResolver.Instance, // Try Enum
DynamicGenericResolver.Instance, // Try Array, Tuple, Collection
DynamicUnionResolver.Instance, // Try Union(Interface)
DynamicObjectResolver.Instance, // Try Object
PrimitiveObjectResolver.Instance // finally, try primitive resolver
};
ここから足したり引いたりして、オレオレStandardResolverを作っても良いわけです。それがMessagePack for C#のシリアライズ動作のカスタマイズになっています。なお、リゾルバーの解決チェーンはTの解決時に一回だけ走るようになっていて、そこで確定したら(ジェネリクスの利用法のハックにより)C#レベルでキャッシュされるので、超高速に取り出すような構造にしています。毎回、解決のチェーンを回したり、TypeをキーにしてDictionaryから引っ張る、とかやってたりしたら遅いですからね。
こういった仕組みだけで、ここまで徹底的に過激にやってる例は他にないんですが、めちゃくちゃ機能するので、世の中は見習うといいでしょう。
MessagePackFormatterAttribute
基本的にオブジェクトのシリアライズは、IMessagePackFormatterにより提供される外部シリアライザ経由で実行されます。通常は、属性付与により動的にシリアライザが生成されますが、全く別個のカスタマイズされた挙動をさせたい場合もなくはないでしょう、その際にはカスタムResolverを作って、通常利用するResolverの先頭に差し込んで貰う、というのも面倒くさいので、クラスに対して1:1で固有のシリアライザを紐付けられる属性を追加しました。
// この属性で渡したTypeがシリアライザとして使われる
[MessagePackFormatter(typeof(CustomObjectFormatter))]
public class CustomObject
{
string internalId;
public CustomObject()
{
this.internalId = Guid.NewGuid().ToString();
}
// ネストしたクラスの中にシリアライザがあるので、プライベートフィールドのシリアライズも可能
// みたいな自由なカスタマイズができるようになる
class CustomObjectFormatter : IMessagePackFormatter<CustomObject>
{
public int Serialize(ref byte[] bytes, int offset, CustomObject value, IFormatterResolver formatterResolver)
{
return formatterResolver.GetFormatterWithVerify<string>().Serialize(ref bytes, offset, value.internalId, formatterResolver);
}
public CustomObject Deserialize(byte[] bytes, int offset, IFormatterResolver formatterResolver, out int readSize)
{
var id = formatterResolver.GetFormatterWithVerify<string>().Deserialize(bytes, offset, formatterResolver, out readSize);
return new CustomObject { internalId = id };
}
}
}
このシリアライザの選択もResolverによって提供されていて、AttributeFormatterResolverがこの解決を行ってくれる代物になっています。なので、「MessagePackFormatterAttributeを無視したい」という場合はAttributeFormatterResolverを抜いたリゾルバーを渡せばいい、ということになります。また、それを無視した、更に別の挙動に変えたい場合は、「その前」にその型に適合するResolverを用意しておけばいいわけですね。シリアライザの挙動のカスタマイズは全てリゾルバーで解決可能、な問題になるように全体的なAPIを調整してあるのは、優れた点だと思っています。
DataContract対応
今まで独自属性(MessagePackObjectAttributeやKeyAttribute)のみだったのですが、DataContractAttributeにも対応しました。
[DataContract]
public class Sample1
{
[DataMember(Order = 0)]
public int Foo { get; set; }
[DataMember(Order = 1)]
public int Bar { get; set; }
}
Orderをint key, Nameをstring key代わりにできます。DataContractを使うことのメリットは、共有したい型のプロジェクトをMessagePack for C#の参照のないプレーンなプロジェクトにできることです。デメリットはAnalyzerの解析対象外になることと、mpc.exeによるコードジェネレート対象外になること。また、UnionやSerializationConstructorなどの、より強力なMessagePack for C#の機能は使えません。なので、できればMessagePack for C#を参照したほうがオススメです。
強い署名
すとぅろんぐねーむさいんど、好きですか?私は嫌いです。今の世の中に全く見合ってないレガシーなシステムだと思っています。しかし、.NETの世界は残念ながら強い署名と共に生きていくしかないのです。それは.NET Core時代であっても。CorefxのStrong Name Signingというドキュメントが最新の見解になりますが、もうこれが存在する理由は、互換性のためしょうがなく維持する必要があり、そして、署名されたものが存在すれば、そこからは署名の負の連鎖が繋がっているという、そういう荒涼とした世界だけです。
というわけで現状、NuGetでは署名したのが配られています。
性能改善
地道に出来るとこはやってますねん。特にオブジェクトをMapでシリアライズする場合(ContractlessResolverやKey(string)など)の性能を向上してます。これはJSONリプレイス的な意味で、かなり使われる形式なので、ちゃんと手を打ちたかったので。具体的にどんな形になったかというと
// こんなよくあるものがあるとして
[MessagePackObject(keyAsPropertyName: true)]
public class SampleModel
{
public int Age { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
}
// Beforeのサンプル
public sealed class SampleModelFormatter : IMessagePackFormatter<SampleModel>
{
public int Serialize(ref byte[] bytes, int offset, SampleModel value, IFormatterResolver formatterResolver)
{
if (value == null)
{
return MessagePackBinary.WriteNil(ref bytes, offset);
}
var startOffset = offset;
// 個数3が固定なので、コード生成時に15以下は判定なし(FixedMapHeaderUnsafe)で書き込み
offset += MessagePackBinary.WriteFixedMapHeaderUnsafe(ref bytes, offset, 3);
// {"プロパティ名":値} を書き込んでいく
offset += MessagePackBinary.WriteString(ref bytes, offset, "Age");
offset += MessagePackBinary.WriteInt32(ref bytes, offset, value.Age);
offset += MessagePackBinary.WriteString(ref bytes, offset, "FirstName");
offset += MessagePackBinary.WriteString(ref bytes, offset, value.FirstName);
offset += MessagePackBinary.WriteString(ref bytes, offset, "LastName");
offset += MessagePackBinary.WriteString(ref bytes, offset, value.LastName);
return offset - startOffset;
}
}
Beforeはせやな、って感じの、わりとストレートな実装でした。しいていえば、Mapのヘッダーサイズだけは最適化しています(コード生成時に判定できるので15以下ならFixed、それ以上なら内部で個数判定してフォーマットを決めるWriteMapHeaderを使ったコードを生成する)。
Afterは、というと
// Afterのサンプル
public sealed class SampleModelFormatter : IMessagePackFormatter<SampleModel>
{
// プロパティ名のバイト列は固定なので、事前に変換しておく
readonly byte[][] stringByteKeys = new byte[][]
{
global::System.Text.Encoding.UTF8.GetBytes("Age"),
global::System.Text.Encoding.UTF8.GetBytes("FirstName"),
global::System.Text.Encoding.UTF8.GetBytes("LastName"),
};
public int Serialize(ref byte[] bytes, int offset, SampleModel value, IFormatterResolver formatterResolver)
{
if (value == null)
{
return MessagePackBinary.WriteNil(ref bytes, offset);
}
var startOffset = offset;
offset += MessagePackBinary.WriteFixedMapHeaderUnsafe(ref bytes, offset, 3);
// 文字列のバイナリです、ということでそのままシーケンシャルに書いていく
// コード生成なら、生成時点で順番を固定で確定できるので、Dictionary<string, byte[]>みたいな辞書参照コストがかかるようなこともしない
offset += MessagePackBinary.WriteStringBytes(ref bytes, offset, stringByteKeys[0]);
offset += MessagePackBinary.WriteInt32(ref bytes, offset, value.Age);
offset += MessagePackBinary.WriteStringBytes(ref bytes, offset, stringByteKeys[1]);
offset += MessagePackBinary.WriteString(ref bytes, offset, value.FirstName);
offset += MessagePackBinary.WriteStringBytes(ref bytes, offset, stringByteKeys[2]);
offset += MessagePackBinary.WriteString(ref bytes, offset, value.LastName);
return offset - startOffset;
}
// deserialize...
}
プロパティ名は常に固定なのだから、事前に変換して持っておけばいいでしょ、という単純なお話でした。Beforeは毎回UTF8.GetBytesしていたわけですが、Afterではそのコストがゼロになっています。これはさすがに誰がどう見ても明らかにafterのほうが速い。実際に実装する時は、こういうようなコンセプトコードを書いた上で、動的生成のためILを打ち込みます。今回は変更量も大したことなかったので、割とサクッと書けました。よかったですね。
こういうのって、言われるとそりゃそーだってところだし難しい話でもなんでもない単純なことなんですが、割と見逃しちゃうところだったりします。コロンブスの卵的な。実装的にも(特にIL書く量が増えて)面倒くさいし。そういう部分を徹底的に精査して最適化を埋め込みまくってるのが、MessagePack for C#の速さの秘訣です。地道で、徹底的な改善こそが全て。近道なんてないのです。
Mapの場合、デシリアライズ速度も改善可能なんですが、アイディアはありつつちょっと具体的な実装がないのでまだ保留中。理屈的にはロスを減らせるんですが、せっかく実装しても、それが実際速いかどうかが別問題だったりで難しいんですよねえ。
まとめ
MessagePack for C#は既に黒騎士と白の魔王で全面的(Unityクライアント-gRPCサーバー間の通信と、サーバーサイドでのRedisへのシリアライズデータ格納)に使われているため、バグも概ね取り除かれていて、プロダクション環境で安心して使わえるレベルになっています。機能面でも、シリアライザに要求される幅広いシナリオに、ほとんど対応できるレベルになっています。というか、むしろ機能面でここまで揃ってるシリアライザも実際ないですね。JSON, Typeless, dynamic、そして拡張性。最強っぽい。細かいできることはまだ色々残っていますが(循環参照のサポートが一番大きいかな)、普通に使う限りは全く不便しないはずです。Unity向けにはコードジェネレータの利便性を高める(Macサポートとか)ってのがだいぶ優先度高めで未だに抜本的には手が出てません……。
ASP.NET Core MVCサポートも、私が適当に書いたものよりも、Using MessagePack with ASP.NET Core MVCといったちゃんとした(ちゃんとした!)実装を用意してもらったりなど、採用してもらっていってるかなー、と思います。それ以外にDatadogSharpという私が現在書いているDatadog APM用のクライアントの通信もMessagePack for C#を用いています。SignalRにMsgPack Protocolを採用するという話もあるんですが、それは強い署名がなかったので敗退したんですが、署名もしたしStream APIも入れたんで、機会あればもう少し粘りたいかな、といったところですね。
ところで、今日(今日!)のGTMF 2017 OSAKAにて株式会社CRI・ミドルウェアさんと共に「「黒騎士と白の魔王」の CRIWARE 活用事例」というセッションを行います。大阪です。実はこの記事、東京-大阪の新幹線の中で書いてるんですねー。また、同じ内容を7/14のGTMF 2017 TOKYOでも行いますので是非是非よろよろしくお願いします。懇親会などでもふらついていますので、よければ捕まえてやってください。
C# 7.0 custom task-like の正しいフレームワークでの利用法
- 2017-04-06
例年、この頃はMVP更新が云々とかなのですが、今年からシステムが変わって更新時期に変動があるんで何もありませんが、一応まだ継続しています。それはともかくとしてVisual Studio 2017が出ました。会社でも全プロジェクトがVS2017に移行完了を果たして、代わり映えしないようで、タプル記法のデコンストラクションとか工夫すると結構便利だな、とか使い始めると色々発見があります。タプル記法やデコンストラクションの工夫に関しては、弊社エンジニアリングブログのC# 7.0 が使えるようになったので ValueTuple を活用してみたをどうぞ。
そんな中で、私がはよ来てくれ……と願っていたC# 7.0の新機能は、task-likeです。Proposal: arbitrary task-like types returned from async methodsで延々と議論されていたようですが、これは何かというと、asyncでTask以外の型が返せるようになります。もともとC# 7.0からValueTaskが入って、async ValueTask<T> を返せるようになる必要があったついでに搭載されたみたいなものですが、色々何か出来そうですよね!?
というわけで、早速有効に使えるシチュエーションを用意しました。というか早速投下しています。
task-likeがない場合の苦痛
現在、私はMagicOnionというgRPCをベースにしたフレームワークを作っています。シリアライザはこないだ公開したエクストリーム速くて軽量なMessagePack for C#です。と、そういう細かいことはどうでもいいとして、MagicOnionではこんな風に書きます。
// 定義を用意して
public interface IMyFirstSerivce : IService<IMyFirstSerivce>
{
UnaryResult<int> Sum(int x, int y);
}
public class MyFirstSerivce : ServiceBase<IMyFirstSerivce>, IMyFirstSerivce
{
// これがサーバーで呼び出される実装になる
public UnaryResult<int> Sum(int x, int y)
{
var sum = x + y;
return UnaryResult(x + y);
}
}
static async Task Run()
{
var channel = new Channel("localhost:1111", ChannelCredentials.Insecure);
// インターフェースで動的にクライアントを自動生成する
var client = MagicOnionClient.Create<IMyFirstSerivce>(channel);
// 自然な感じでサーバー - クライアント通信で受け取れる
var result = await client.Sum(10, 20);
Console.WriteLine(result);
}
まぁまぁ自然な感じでいいじゃん?ってところですが、面倒くさいのは UnaryResult<T> を返さなければならないところ。そのため UnaryResuylt() というヘルパー関数を読んで包んだのをリターンする羽目になってます。これが地味に面倒くさい。return x + y; って書きたいじゃん、って。
で、MagicOnionがUnaryResultを強制するには理由があって、多くの場合は戻り値そのものだけで良いんですが、場合によってはレスポンスヘッダを取りたいとかステータスコードを取りたいとか、そういうのに対応する必要があるんですね。
// awaitしない
var response = client.Sum(10, 20);
// headerを取るとか
var header = await response.ResponseHeadersAsync;
// statusを取るとかしたかったりする
var trailer = response.GetStatus();
// 結果を取る場合。 await response はこれのショートカットでしかなかったりする
var result = await response.ResponseAsync;
APIの触り心地に関してはものすごく考えたんですが、最終的にこの辺が妥協点になってくるかな、と。しょうがないね。さて、ではasyncになるとどうでしょう?
public interface IMyFirstSerivce : IService<IMyFirstSerivce>
{
Task<UnaryResult<string>> EchoAsync(string message);
}
public class MyFirstSerivce : ServiceBase<IMyFirstSerivce>, IMyFirstSerivce
{
// サーバー側の書き味は普通、なんですが……
public async Task<UnaryResult<string>> EchoAsync(string message)
{
await Task.Delay(TimeSpan.FromSeconds(10));
return UnaryResult(message);
}
}
static async Task Run()
{
var channel = new Channel("localhost:1111", ChannelCredentials.Insecure);
var client = MagicOnionClient.Create<IMyFirstSerivce>(channel);
// await await !!!
var result = await await client.EchoAsync("hogehoge");
// というのも、await一発でUnaryResultの取得になる
var response = await client.EchoAsync("takotako");
// ようするにこれのショートカットはawait awaitになってしまうのだ……
var result2 = await response.ResponseAsync;
}
注目はawait awaitです。なんと、await awaitという世にも奇っ怪な記述が合法として出てくるのであった、最悪……。
task-likeがある場合
そこでC# 7.0 task-likeですよ!
// SyncもAsyncも共にUnaryResultとして定義
public interface IMyFirstSerivce : IService<IMyFirstSerivce>
{
UnaryResult<int> SumAsync(int x, int y);
UnaryResult<string> EchoAsync(string message);
}
public class MyFirstSerivce : ServiceBase<IMyFirstSerivce>, IMyFirstSerivce
{
public async UnaryResult<int> SumAsync(int x, int y)
{
// UnaryResult()で囲む必要なし!やったー!
return x + y;
}
public async UnaryResult<string> EchoAsync(string message)
{
// 勿論awaitする場合も普通に
await Task.Delay(TimeSpan.FromSeconds(3));
return message;
}
}
static async Task Run()
{
var channel = new Channel("localhost:1111", ChannelCredentials.Insecure);
var client = MagicOnionClient.Create<IMyFirstSerivce>(channel);
// 自然に扱える!
var result1 = await client.SumAsync(1, 100);
var result2 = await client.EchoAsync("hogehoge");
}
UnaryResult()でのラップもawait awaitも不要です。非常に綺麗にすっきりと扱えるようになりました。あってヨカッタtask-like。かなり有意義に使えてると思いますです。
これは何をやっているかというと、async UnaryResult の場合に独自のコード生成が入って、UnaryResult()の呼び出しを自動で行ってくれるようになってます。UnaryResult()でのラップやawait awaitもダルいのですが、地味に辛いのがTask<UnaryResult<T>>という、ジェネリクスが二階層になっているところですね。継承の連鎖が悪で少ないに越したことはないのと同様に、ジェネリクスのネストも、書き味的にも読み味的にも、少ないに越したことはないのです(ところでかんすーがたげんごの人は型をネストさせまくることの可読性低下にあまりにも無頓着すぎる気がとってもしてます、よくないね)。
警告を無視する
ところで、asyncでawaitなしだと警告がでます。CS1998 Async method lacks 'await' operators and will run synchronously というあれ。お薦めは、ガン無視することです。プロジェクト設定のほうで1998は警告「しない」にしちゃうのがいいでしょう。
ずっと会社でasyncまみれになってン年間過ごして思ったのは、この警告いらないわ。別に。抵抗感あるかもとは思いますが、それでもなお無視したほうが幸せ度上がると思います。
task-likeの作り方
適当にやりました。いや、だってよくわからんし。なんで適当にAsyncTaskMethodBuilderに丸投げです。まぁこれはValueTaskのtask-likeと一緒です。ノリが同じなのでそれで動くと思ってたし、実際それで動いた。超絶手間なくtask-like対応できたわー。
// 対象の型にAsyncMethodBuilder属性をつける
[AsyncMethodBuilder(typeof(AsyncUnaryResultMethodBuilder<>))]
public struct UnaryResult<TResponse>
{
}
// こちらがその中身。基本AsyncTaskMethodBuilderに丸投げです。
public struct AsyncUnaryResultMethodBuilder<T>
{
private AsyncTaskMethodBuilder<T> methodBuilder;
private T result;
private bool haveResult;
private bool useBuilder;
public static AsyncUnaryResultMethodBuilder<T> Create()
{
return new AsyncUnaryResultMethodBuilder<T>() { methodBuilder = AsyncTaskMethodBuilder<T>.Create() };
}
public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
methodBuilder.Start(ref stateMachine);
}
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
methodBuilder.SetStateMachine(stateMachine);
}
public void SetResult(T result)
{
if (useBuilder)
{
methodBuilder.SetResult(result);
}
else
{
this.result = result;
haveResult = true;
}
}
public void SetException(Exception exception)
{
methodBuilder.SetException(exception);
}
public UnaryResult<T> Task
{
get
{
if (haveResult)
{
return new UnaryResult<T>(result);
}
else
{
useBuilder = true;
return new UnaryResult<T>(methodBuilder.Task);
}
}
}
public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : INotifyCompletion
where TStateMachine : IAsyncStateMachine
{
useBuilder = true;
methodBuilder.AwaitOnCompleted(ref awaiter, ref stateMachine);
}
[SecuritySafeCritical]
public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : ICriticalNotifyCompletion
where TStateMachine : IAsyncStateMachine
{
useBuilder = true;
methodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
}
}
まぁ細かいことはいいんです、どうでも。
まとめ
C# 7.0は良い。というかMagicOnionはもはやC# 7.0が前提みたいな新世代フレームワークと化してますとかかんとか。MagicOnionは現在CM放送中(!)の黒騎士と白の魔王でも全面採用しています。黒騎士ではHTTP/1 Web APIはほぼ使われてないのです。クライアント-サーバー間もサーバー-サーバー間も全てgRPC。時代はHTTP/2。圧倒的な次世代。gRPCも、Unityでも動くようにgRPCにかなりの魔改造を施したカスタム仕様で、かなりアグレッシブな感じです。
その一端はUnite 2017でお話するつもりなので是非是非来てくださいな。もちろん、UniteはUnityのイベントなのでクライアントサイド中心の話なのでサーバー側(gRPC/MagicOnion)の話は少なめになりますが、近いうちに他のイベントでサーバー側でもお話できればな、と思ってます。ちょうど5月6月はクラウド系の大規模カンファレンスがラッシュでありますしね。
C#(.NET, .NET Core, Unity, Xamarin)用の新しい高速なMessagePack実装
- 2017-03-13
と、いうものを作りました。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
- 2017-03-05
オープンなようなクローズドなような、ラウンドテーブルディスカッションのような、少人数のところでUnityのユニットテストについて話してきました。というか、UniRxのために作って、以降、私の作るUnity用の色々なので使いまわしてる自作のユニットテストフレームワークについて、ですね。
このフレームワークはずっとUniRxの中に埋まったまんまだったんですが、使える形でパッケージしたのを、今日GitHubに公開しました。unitypackageとしても置いてあるので、一応インポートはしやすいはずです。
とりあえず必要な機能しか入れてないんで、汎用テストフレームワークとしては足りない機能が普通に多いので、その辺も作ってからアセットストアに公開したいなぁ、と思ってはいたんですが、まぁそうなるといつまで経っても公開できなさそうなので、とりあえず現段階のもので公開、です。
.NETのテスト事情、或いはUnityでテストを書かないことについて
私はライブラリとしてはふつーの.NETと共通で動くものを作ることが多いんで、まぁそういう場合は大部分はふつーの.NETのユニットテストを書いたほうが遥かに書きやすいでしょう!つまりUnityでテストを書くコツはUnityで書かないということです!!!みもふたもない。
テストのメソッドを右クリックしてデバッグ実行で直接Visual Studioのデバッガでダイレクトにアタッチできたりとか、基本的に最高ですね。
さて、スライドにも書いたのですが、最近はxUnit.netを好んで使っています。MSTestはいい加減投げ捨てていいでしょう、というか投げ捨てるべきでしょう。NUnitは知らん。いらん。補助としてChainingAssertionは変わらず使ってるんですが、.NETCore対応を内部では作って使ってるんですが公開には至ってない……。
また、モックライブラリとしてはMicrosoft Fakes Frameworkのような大仰なものは「絶対に」使うべきではない、という思いが強くなってます。テストはただでさえ負債になりやすいのに(盲目的にテストは書くべき信仰してる人は、テストの負債化に関して全く言及しないのがポジショントークなのか脳みそお花畑なのか、頭悪そうですね)、大きな自動生成を伴うものは負債の連鎖を作りやすいなー、と。シンプルに作らないと、シンプルに投げ捨てることができない、というね。そして、投げ捨てるのは簡単ではなく、投げ捨てるのもまた技術なわけです。
RuntimeUnitTestToolkit
.NETでテスト書くからそれでOK、というわけは当然なくて、Unityだけでしか動かない部分もあるし、そもそもUnityでちゃんと動くかどうかの保証はない。更にはIL2CPPに通した場合はやっぱり別物の挙動というか動かなくなるケースは「非常に多い」ので、ちゃんとIL2CPPで動くことを保証しなければならない。そこで作ったのがRuntimeUnitTestToolkitです。Unityには標準でテストツールあるじゃん、って話ですが、あれは実機動作させられないので論外です。それで用が満たせりゃあ標準の使うわ。
テストが並べられて、ボタン押したら実行、ボタンが緑になったら成功、赤になったら失敗というシンプルなふいんきのものです。一個のシーンになってるので、ビルドして実機転送すればそのまま実機で動きます。
実際に自分で使うには、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化とか。
こちらも、シリアライザのMessagePack for C#化とか大工事を何度かしつつも、もうすぐとりあえずStableといえるとこまで持ってけそうです。
また、Unity用のインメモリ内蔵データベースとしてMasterMemoryというのも作っていて
これももうすぐ公開できそうかもかもといったところで、とりあえず色々あって大変大変。どれもUnityでのユニットテストには RuntimeUnitTestToolkit で動かしてるんで、私自身は超ヘビーに使いまくってますよ、です。
2016年を振り返る
- 2016-12-31
振り返る、のも五回目。今年は、ものすごくC#を書く技量が向上した気がします。いやほんと。私も結構歳とった感があるのですが(昨日誕生日で33歳でした!)、まだグッと成長できる切り口が残ってたんだなぁと思うと大変嬉しい話です。正直今年はあまり良いニュースはなかったのですが、自分のメインの軸で自己成長を実現できたというのは、次のステップ頑張ろうって気になれます。
C#
プログラミングって、ある程度はパターンがあって、このシチュエーションにはこれを当てはめて、こういう風に組み立てていけば勝てる、みたいな手札の多さが強さ(?)みたいなところがあると思ってるんですが、ここ2年ほど私自身のデッキは割と安定していたんですよね。言語やフレームワークのアップデートに従って組み替えたり、他のライブラリを見て手札を、アイディアを増やすというのは随時やっていってましたが、大きく変わるようなことはなかったなあ、と。言語がアップデートされると、そりゃ当然手法も大きく変わるんですが、良くも悪くもC#は安定期に入っていて、ぶっちけそんな変わってないし、次のC#も大して変わらないですしね。
って状況だったんですが、今年はガラッと書き方、考え方が変わりました。もちろん、使い続けている手札もいっぱいありますが、新規に入ってきた要素もとても多くて。そのお陰で、APIの表現力も大幅に上がりました。組み合わせの問題でもあるので、手札が多いと、やれることの幅やAPIの表現力が爆発的に上がっていくので非常に良いことです(逆に手札が少ない人の作るAPIは窮屈だったりするというのはありますね、そういうのみると慢心してる感じだなあ、とか思ったりはします)
変わった要因は2つあって、一つは、今年はパフォーマンスを極限まで追い求めたコードを色々書いたから。ブログを漁るとUnityでのボクシングの殺し方、或いはラムダ式における見えないnewの見極め方、Unityにおけるコルーチンの省メモリと高速化について、或いはUniRx 5.3.0でのその反映、UniRx 5.4.0 - Unity 5.4対応とまだまだ最適化と、UniRxの継続アップデートはいつも新しいことを考えたり、導入したりするきっかけになっています。UniRxも今年はGitHubで1000Star越えを果たしたり、スーパーマリオラン(5000万ダウンロード!)に採用されていたりと、一つの山を超えた感じはあります。
個人的にブレークスルーだったのはLINQ to GameObject 2.1 - 手書き列挙子による性能向上と追加系をより使いやすくで、改めてLINQ、そしてパフォーマンスとは、に関して見直すきっかけになりました。そしてZeroFormatter - C#の最速かつ無限大高速な .NET, .NET Core, Unity用シリアライザーで、集大成として結実しました。いやぁ、大変だった。ほんと大変だった、終わってみればあっさりって気もしなくもないんですが、いやぁ、大変だった……。シリアライザなんて枯れた群雄割拠な代物と思ってましたが、性能面でもまだまだ全然追求できる幅あったんだというのは驚きで。意外と世の中まだやれることは無限にある。C#もまだまだ限界は迎えてない。
性能は最大の機能だ、というのは勿論なのですけれど、究極的にそれを実現するためには新しいアイディアを大量に投下しなきゃいけなかった。今まで自分はいかにヌルいコードを書いてたんだ、と痛感させられました。また、そんな性能追求ギプスのお陰で沢山の手札を手に入れられて、それは視野の広がりをもたらして、ただたんに性能のために、というだけじゃなく書き方の広がりを手に入れられたと思ってます。
突き詰めてやることにはとても意義がある。逆に、そこまでしなければ手に入れられないものもある。手札を増やすのに他の言語に浮気するってのも悪いことではないですが、その前に目の前のことを突き詰めてみるってのもいいんじゃないのってのはとっても思います。nullがどうこうとか言ってる前にC#どんだけ書けるのよ、みたいな。みたいな?
技術的負債との付き合い方
技術的負債って、優秀なエンジニアがしっかり考えれば発生しない。わけではないんですよね。コードなんて誰が書いても、書いた瞬間から腐敗は始まっていて、アプリケーションとしてローンチする前から負債になっている場合すらある。そして、出来ないエンジニアの作る負債よりも、むしろ出来るエンジニアの作る負債のほうが痛かったりする。JavaScript界隈でよく聞くような、新しい技術をいっぱい取り入れました、でももう時代遅れです!みたいなのは典型ですが(これも普通よりちょっと出来るエンジニアぐらいのほうがハマりやすい罠)、そんなんじゃなくても、大なり小なり腐敗を抱えて生きてるわけです。
永遠に輝くコードなんて存在しないからこそ、むしろいかに捨てるかに腐心するほうが良い。もちろん、私の書くものだって例外じゃあなくて、ゴミは作ってしまうのね。別にゴミだと思って作るわけじゃなくても!ダメだと気づいたら、しょうがないので焼却する。これがね、自分の作ったコードなら躊躇なく捨てられる。捨てた際のカバーもなんとかできる、こともある(できないこともある、ひどぅぃ)。けれど他人の作ったものの扱いはとても難しい。そもそも他人の書いたものをジャッジするのが難しい!自分の書いたものを、あぁ、アイディア自体がゴミでダメですね、と切り捨てれても、他人のものを正しく判定するのはむつかしいんだなあ。いや、現在にたいしてダメか否かの判定は簡単ですけれど、未来の判定をするのがむつかしい。
自分の書いたものだと未来も見えるんですよね、このアイディアの延長線上に何があるか想像がつく、未来がないことが見えた時、やめましょう、投げ捨てましょう、になる。けれど、他人の未来はわからなくて、今はまだまだだけど、もう少しやってりゃあなんとかなるかもしれない……。とか思っちゃうわけです。期待して。或いは目をつむって。実際大抵はそんなことはなくて、ダメなもんはダメだったりするわけですが。
損切りするのが難しいのと一緒で、そりゃうまくできりゃあ良いんですが。というかうまくできなきゃあダメなんですが。傷口は 消毒で誤魔化してないで、腐食が進む前に切り落とさなきゃ本当にダメで。腐った土台のうえでいくら技巧を凝らしても、醜い延命策で、なんの解決にもなってないというか、むしろただの時間の浪費なんですよね。いやはや。
何れにせよ、奢った気持ちで書かれたものはダメですねぇ。「よくできているのにどうしょうもなくダメなプログラム」とは何ぞやか、というのを考え直すきっかけになりましたし、そうして考え直すことは自分の書き方の変化にも繋がりました。自分自身ね、そういうの書いちゃってたりやっぱしてしまうわけで。
お仕事
というわけで技術的負債の返却、じゃないですが、今年の後半は、意識的に、問題を技術で解決するというところにフォーカスしていました。結構ね、状況は余裕じゃないんですが、なんとかして解消しなければならない!
ZeroFormatterを起点に、まだ未完成のものでMagicOnion - gRPC based HTTP/2 RPC Streaming FrameworkとMasterMemory - Embedded Readonly In-Memory Document Databaseというのを用意しています。
現状をクソだというのはイージーなんですが、なんとか維持しつつも解決させるってのは結構難しくある。アイデアというのは複数の問題を一気に解決するものであるとはよく言ったものですが、実際、これらの導入によって抱えている問題をそれなりに解決できる。といいなぁ。
技術で技術を返却するってのは、良くも悪くもですね。特に、私自身がCTOという立場でそれやってるのは、結構キワキワだとは思ってます。意識して脳みその9割をコードに割くようにしてるのは、逆に他のことはあまり考えてないってことですからねぇ。正直、あんまいいことじゃあないし、来年も同じようにしたいとは思わないというか、すべきではないと思ってますが、現在の状況からすればこれが最善、かな。と選んでやってます。この辺はしゃーない。もう少しうまくやれりゃあいいのですけれど。
損切りのタイミングを逸したとか、自分で返却しなきゃいけないものを返却できなかったりとか、前期であまり良い決断ができてなかったというのはうーむ、といったところも多々ありつつ。対外的なプレゼンスに関してはよくやれたと思ってますし、その辺の人にはできないことをやってるとは思いますが、それだけでいいと言い切れない程度には歯切れの悪い年でした。
ゲームとか音楽とか
とんかつDJアゲ太郎だけはアニメ全部見ました:) それ以外はアニメもドラマも何もかも完走できてないというかロクに見ちゃいない。本も読んでなければ漫画も見てないんですが、うーん、何が良かったかなあ。本日のバーガーはテーマ的には良かった!色々なハンバーガーがあるし、あっていんだよ、という当たり前のような当たり前を認識できて。
ゲームは、うーん、オーディンスフィア レイヴスラシルは今年でしたか、良かった。あとスーパーマリオランはレート3000、ブラックコインコンプぐらいにはやりました。レートカンストはちょっと不毛感あるので、いったんそろそろいいかな感もありますが。
音楽は、水曜日のカンパネラをよく聴いてましたねー、ジパングと私を鬼ヶ島に連れてってが傑作で。あと、つい先日出た戸川純 with Vampillia / わたしが鳴こうホトトギスが良くてホクホク。
来年
年始暫くはひたすらシステムプログラミングですねー。好きでやってていいってことにも、限度が、頻度というものがあって、大げさ大掛かりなものを連続して作らなきゃいけないってのは正直シンドイ。ゆうて神経めっちゃ使うのよ。やるにしても、もう少し間隔あけながらやりたいよぅ、というのも自業自得なんでしょーがない。
というわけかで、去年の目標であったグラフィックプログラミングはちっとも前進しませんでした。今年はVRにもしっかり手を出したかったんですが、あまりやれてないですね、まぁそうしたグラフィックプログラミングも、VRも、あと最近興味あるのはディープラーニングも、ゲームをリリースするまではお預け。
というわけで、リリースしましょう、ってことですね!
ASP.NET Coreを利用してASP.NET Coreを利用しないMiddlewareの作り方
- 2016-12-25
今回の記事はASP.NET Advent Calendar 2016向けのものとなります。最終日!特に書くつもりもなかったのですが、たまたま表題のような機能を持つMiddlewareを作ったので、せっかくなので書いておくか、みたいなみたいな。
.NET 4.6でASP.NET Core
まぁ普通に.NET 4.6でASP.NET Coreのパッケージ入れるだけなんですが。別にASP.NET Coreは.NET Coreでしか動かせないわけではなくて、ちゃんと(?).NET 4.6でも動きます。如何せん.NET Coreがまだ環境として成熟してはいないので、強くLinuxで動かしたいという欲求がなければ、まだまだWindows/.NET 4.6で動かすほうが無難でしょう。Visual Studioのサポートも2015だとちょっとイマイチだとも思っていて、私的には本格的に作り出していくのはVisual Studio 2017待ちです。脱Windowsとして、Linuxでホスティングするというシナリオ自体にはかなり魅力的に思っていますし、ライブラリを作るのだったら今だと.NET Core対応は必須だと思いますけれど。
Hello Middleware
Middlewareとはなんぞやか、というと、ASP.NET公式のMiddlewareのドキュメントが見れば良いですね。
Httpのリクエストを受けつけて、レスポンスを返す。ASP.NET Core MVCなどのフレームワークも、Middlewareの一種(図で言うところのMiddleware3にあたる、パイプラインの終点に位置する)と見なせます。このパイプラインのチェーンによって、事前に認証を挟んだりロギングを仕込んだりルーティングしたりなど、機能をアプリケーションに足していくことができます。
考え方も、実質的なメソッドシグネチャもASP.NET Coreの前身のOWINと同一です。今ではOWIN自体の機能や周辺フレームワークは完全に整っていて、ASP.NET Coreで全て賄えるようになっているので、新しく作る場合はASP.NET Coreのことだけを考えればいいでしょう。逆に、OWINで構築したものをASP.NET Coreへ移行することはそう難しくないです
ASP.NET Coreのパッケージはいろいろあって、どれを参照すべきか悩ましいのですが、最小のコア部分となるのはMicrosoft.AspNetCore.Http.Abstractionsです。これさえあればMiddlewareが作れます。
では、パイプラインの各部にフックするだけの単純なMiddlewareを作りましょう!
public class HelloMiddleware
{
// RequestDelegate = Func<HttpContext, Task>
readonly RequestDelegate next;
public HelloMiddleware(RequestDelegate next)
{
this.next = next;
}
public async Task Invoke(HttpContext context)
{
try
{
Console.WriteLine("Before Next");
// パイプラインの「次」のミドルウェアを呼ぶ
// 条件を判定して「呼ばない」という選択を取ることもできる
await next.Invoke(context);
Console.WriteLine("After Next");
}
catch (Exception ex)
{
Console.WriteLine("Exception" + ex.ToString());
}
finally
{
Console.WriteLine("Finally");
}
}
}
注意点としては、完全に「規約ベース」です。コンストラクタの第一引数はRequestDelegateを持ち(その他のパラメータが必要な場合は第二引数以降に書く)、public Task Invoke(HttpContext context)メソッドを持つ必要があります。逆に、それを満たしていればどのような形になっていても構いません。
この規約ベースなところは賛否あるかなぁ、というところですが(私はどちらかというと否)、C#の言語機能としてはしょうがない面もあります。(自分でもこの手のフレームワークを何個か作った経験があるところから理解している上で)実装面の話をすると、この規約で最も大事なところは、コンストラクタの第一引数でRequestDelegateを受け入れるところにあります。そして、C#は具象型のコンストラクタの型の制約は入れられないんですよね。なので、MiddlewareBaseとか作ってもあんま意味がなくて、ならもう全部規約ベースで処理しちゃおうって気持ちは分かります。
Invokeのメソッドシグネチャをpublic Task Invoke(HttpContext context, RequestDelegate next)にすることで、そうしたコンストラクタの制約を受ける必要がなくなって、メソッドに対するインターフェイスでC#として綺麗な制約をかけることは可能になるんですが(私も、なので以前はそういうデザインを取っていた)、そうなるとパフォーマンス上の問題を抱えることになります。Invoke(HttpContext context, RequestDelegate next)というメソッドシグネチャだと実行時に"next"を解決していくことになるのですが、これやるとどうしても、nextを解決するための余計なオブジェクト(クロージャを作るかそれ用の管理オブジェクトを新しく作るか)が必要になりますし、呼び出し階層もその中間層を挟むため、どうしても一個深くなってしまいます。
ミドルウェアパイプラインは構築時にnextを解決することができるわけで、そうした実行時のコストを構築時に抑え込むことが原理上可能です。それが、コンストラクタでnextを受け入れることです。C#を活かした設計の美しさ vs パフォーマンス。このMiddlewareチェーンはASP.NET Coreにおける最も最下層のレイヤー。この局面ではパフォーマンスを選ぶべきでしょう。実に良いチョイスだと思います。
最後に、使いやすいように拡張メソッドを用意しましょう。拡張メソッドなのでnamespaceは浅めのところにおいておくと使いやすいので、その辺は適当に気をつけましょう:)
public static class HelloMiddlewareExtensions
{
public static IApplicationBuilder UseHello(this IApplicationBuilder builder)
{
// 規約ベースで実行時にnewされる。パラメータがある場合はparams object[] argsで。
return builder.UseMiddleware<HelloMiddleware>();
}
}
Middlewareを使う
作ったら使わないと動作確認もできません!というわけでホスティングなのですが、これもAspNetCoreのパッケージはいっぱいありすぎてよくわからなかったりしますが、「Microsoft.AspNetCore.Server.*」がサーバーを立てるためのライブラリになってます。IISならIISIntegration、Linuxで動かすならKestrel、コンソールアプリなどでのセルフホストならWebListenerを選べばOK。今回はMicrosoft.AspNetCore.Server.WebListenerで行きましょう。
class Program
{
static void Main(string[] args)
{
var webHost = new WebHostBuilder()
.UseWebListener() // ホスティングサーバーを決める
.UseStartup<Startup>() // サーバー起動時に呼ばれるクラスを指定
.UseUrls("http://localhost:54321") // 立ち上げるアドレスを指定
.Build();
webHost.Run();
}
}
public class Startup
{
// Configure(IApplicationBuilder app)というのも規約ベースで名前固定
public void Configure(IApplicationBuilder app)
{
// さっき作ったMiddlewareを使う
app.UseHello();
// この場で最下層の匿名Middleware(nextがない)を作る
app.Run(async ctx =>
{
var now = DateTime.Now.ToString();
Console.WriteLine("---------" + now + "----------");
await ctx.Response.WriteAsync(DateTime.Now.ToString());
});
}
}
例によって規約ベースなところが多いので、まぁ最初はコピペで行きましょう、しょーがない。これでブラウザでlocalhost:54321を叩いてもらえば、現在時刻が出力されるのと、コンソールにはパイプライン通ってますよーのログが出ます。
基本のHello Worldはこんなところでしょう、後は全部これの応用に過ぎません。
ASP.NET Coreを利用してASP.NET Coreを利用しない
さて、本題(?)。現在、私はMagicOnionというフレームワークを作っていて(まぁまぁ動いてますが、一応alpha段階)、謳い文句は「gRPC based HTTP/2 RPC Streaming Framework for .NET, .NET Core and Unity」。つまり……?gRPCというGoogleの作っている「A high performance, open-source universal RPC framework」を下回りで使います。つまり、ASP.NET Coreは使いません。さよならASP.NET Core……。
gRPCは(.NET以外では)非常に盛り上がりを見せていて、ググればいっぱい日本語でもお話が見つかるので、知らない方は適当に検索を。非常に良いものです。
gRPCはHTTP/2ベースで、しかもデータは基本的にはProtocol Buffersでやり取りされているので、従来のエコシステム(HTTP/1 + JSON)からのアクセスが使えません。そこでgrpc-gatewayというプロキシを間に挟むことで HTTP/1 + JSONで受けてHTTP/2 + Protobuf にルーティングします。それによりSwaggerなどの便利UIも使えて大変捗るという図式です。素晴らしい!
grpc-gatewayは素晴らしいんですが、Pure Windows環境で使うのは恐らく無理があるのと、MagicOnionではデータをZeroFormatterでやり取りするようにしているので、そのまま使えません。残念ながら。しかし、特にSwaggerが使いたいんで絶対にgrpc-gateway的なものは欲しい。と、いうわけで、用意しました。ASP.NET Coreを利用して(HTTP/1 + JSON)、ASP.NET Coreを利用しない(HTTP/2 + gRPC/MagicOnion/ZeroFormatter)。
public class MagicOnionHttpGatewayMiddleware
{
readonly RequestDelegate next;
// MagicOnionのHandler(キニシナイ)
readonly IDictionary<string, MethodHandler> handlers;
// gRPCのコネクション
readonly Channel channel;
public MagicOnionHttpGatewayMiddleware(RequestDelegate next, IReadOnlyList<MethodHandler> handlers, Channel channel)
{
this.next = next;
this.handlers = handlers.ToDictionary(x => "/" + x.ToString());
this.channel = channel;
}
public async Task Invoke(HttpContext httpContext)
{
try
{
var path = httpContext.Request.Path.Value;
// HttpContextのパスをgRPCのパスと適当に照合する
MethodHandler handler;
if (!handlers.TryGetValue(path, out handler))
{
await next(httpContext);
return;
}
// BodyにJSONがやってきてるということにする(実際はFormからの場合など分岐がいっぱいでもっと複雑ですが!)
string body;
using (var sr = new StreamReader(httpContext.Request.Body, Encoding.UTF8))
{
body = sr.ReadToEnd();
}
// JSON -> C# Object
var deserializedObject = Newtonsoft.Json.JsonConvert.DeserializeObject(body, handler.RequestType);
// C# Object -> ZeroFormatter
var requestObject = handler.BoxedSerialize(deserializedObject);
// gRPCのMethodをリクエストを動的に作る
var method = new Method<byte[], byte[]>(MethodType.Unary, handler.ServiceName, handler.MethodInfo.Name, MagicOnionMarshallers.ByteArrayMarshaller, MagicOnionMarshallers.ByteArrayMarshaller);
// gRPCで通信、レスポンスを受け取る(ZeroFormatter)
var rawResponse = await new DefaultCallInvoker(channel)
.AsyncUnaryCall(method, null, default(CallOptions), requestObject);
// ZeroFormatter -> C# Object
var obj = handler.BoxedDeserialize(rawResponse);
// C# Object -> JSON
var v = JsonConvert.SerializeObject(obj, new[] { new Newtonsoft.Json.Converters.StringEnumConverter() });
// で、HttpContext.Responseに書く。
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsync(v);
}
catch (Exception ex)
{
// とりあえず例外はそのまんまドバーッと出しておいてみる
httpContext.Response.StatusCode = 500;
await httpContext.Response.WriteAsync(ex.ToString());
}
}
}
細かいところはどうでもいいんですが(あと一部端折ってます、実際はもう少し複雑なので)、基本的な流れはJSONをZeroFormatterに変換→内部で動いてるgRPCと通信→ZeroFormatterをJSONに変換。です。見事に左から右にデータを流すだけー、のお仕事、ですね!
MagicOnion本体は限界までボクシングが発生しないように、ラムダのキャプチャなどにも気を使って、ギチギチにパフォーマンスチューニングしてあるんですが、このGatewayはそんなに気を使ってません:) まぁ、もとより複数回の変換が走ってる、パフォーマンス最優先のレイヤーではないから、いっかな、という。どっちかというとデバッグ用途でSwaggerを使いたいがために用意したようなものです。本流の通信はこのレイヤーを通ることはないので。
ちゃんとgRPCでもSwagger使えてめっちゃ捗る。
What is MagicOnion?
gRPCは.protoを記述してサーバーコードの雛形とクライアントコードを生成します。私はこのIDL(Interface Definition Language)の層があまり好きじゃないんですね。そもそも、クライアントもサーバーも、ありとあらゆる層をC#で統一しているので、C#以外を考慮する必要がないというのもあるので。なので、C#自体をIDLとして使えるように調整したり、MVCフレームワークでいうフィルターが標準でないので、それを差し込めるようにしたり、gRPCは(int x, int y, int z)のような引数に並べるような書き方ができない(必ずRequestクラスを要求する!)ので、動的にそれを生成するようにしたりして、より自然にC#で使えるように、かつ、パフォーマンスも一切犠牲にしない(中間層が入ってるからオーバーヘッドと思いきや、むしろプリミティブ型が使えるようになったのでむしろ素のgRPCより速くなる)ようにしています。そもそもそしてUnityでも動作出来るような調整/カスタマイズなどなども込みで、ですね。
それ以外の話はZeroFormatterと謎RPCについて発表してきました。にて少し書いてあります。もう少し詳細な話は、完成した時に……。
まとめ
.NET Coreを本格的に(プロダクション環境で)使うということは、特に開発環境という点でまだ足りないところが多くて(project.json廃止とかゴタついたところもあるし)、VS2017待ちだと判断しています。しかし、ASP.NET Coreのフレームワーク面では十分完成していて、問題ないですね。なので、そちらから随時移行していきたいという気持ちでいます。
まぁ、とはいえ↑で書いたとおり、ほとんどASP.NET Core自体すら使わないんですが。うーん、そうですね、やっぱスタンダードな作り(JSON API)をクロスプラットフォームを紳士に取り組んでます、みたいなことやってる間に、世界は凄いスピードで回ってるんですよね。Microsoftは常に一歩遅いと思っていて、まぁ今回もやっぱそうですよね、という感じで、世間が成熟した頃にやっと乗り出すようなスピード感だと思ってます。ナデラでOSSでスピーディーなのかといったら、別に私はそう思ってないですね、スピードという点では相変わらずだなぁ、と。むしろ「正しくやろうとする」圧力の高さに自分で縛られてしまっている気すらします。スタンダードだからとJSONでコンフィグ頑張ろうとしてやっぱダメでした撤回、みたいな。そういうのあんま良くないし、その辺の束縛から自由になれた時が真のスタートなんじゃないかな。
ともあれ、私はgRPCにベットしてるんで、ASP.NET Core自体は割とどうでも良く思ってます、今のところ。でもそれはそれとして、当然(補助的に)使ってく必要はあるんで、そういう時にちょいちょいと出番はあるでしょう。
C#に置ける日付のシリアライズ、DateTimeとDateTimeOffsetの裏側について
- 2016-12-07
C# Advent Calendar 2016の記事になります。何気に毎年書いてるんですよねー。今年は、つい最近ZeroFormatterというC#で最速の(本当にね!)シリアライザを書いたので、その動的コード生成部分にフォーカスして、ILGenerator入門、にしようかと思ってました。ILGeneratorでIL手書き、というと、黒魔術!難しい!と思ってしまうけなのですが、実のところ別に、分かるとそれほど難しくはなくて(面倒くさい&デバッグしんどいというのはある)、しかし同時にILGeneratorで書いたから速かったり役に立ったり、というのもなかったりします。大事なのは、どういうコードを生成するのかと、全体でどう使わせるようなシステムに組み上げるのか、だったり。とはいえ、その理想的なシステムを組むための道具としてILGeneratorによるIL手書きが手元にあると、表現力の幅は広がるでしょう。
シリアライザ作って思ったのは、Jilは(実装が)大変良くできているし、SigilはIL生成において大いに役立つ素晴らしいライブラリだと思ってます。まぁ、そういうの作る時って依存避けたいので使わなかったけれどね……。
みたいなイイ話をしようと思っていたんですが、ちょっと路線変更でDateTimeについてということにします。えー。まぁいいじゃないですか、DateTimeだって深いですし、IL手書きなんかよりずっと馴染み深いではないですか。役立ち役立ち。
DateTimeとはなんぞやか
シリアライズの観点から言うと、ulongです。DateTimeとはulongのラッパー構造体というのが実体です。ulongとは、Ticksプロパティのことを指していて、なので例えばDayを取ろうとすれば内部的にはTicksから算出、AddHoursとすればhoursをTicksに変換した後に内部的なulongを足して、新しい構造体を返す。といった形に内部的にはなっています。それぞれのオペレーションは除算をちょっとやる程度なので、かなり軽量といってもいいでしょう。
つまり、DateTimeとはなんぞやかというのは、Ticksってなんやねん、という話でもある。
Ticksとは、100ナノセカンド精度での、0が0001/01/01 00:00:00から、最大が9999/12/31 23:59:59.999999までを指す。ほほー。
// 0001-01-01T00:00:00.0000000
new DateTime(ticks: 0).ToString("o");
// 0001-01-01T00:00:00.0000001
new DateTime(ticks: 1).ToString("o");
// 3155378975999999999
DateTime.MaxValue.Ticks
DateTimeにはもう一つ、Kindという情報も保持しています。KindはUtcかLocalか謎か(Unspecified)の三択。
public enum DateTimeKind
{
Unspecified = 0,
Utc = 1,
Local = 2
}
ふつーにDateTime.Nowで取得する値は、Localになっています、ので日本時間である+9:00された値が取れます。さて、このKindは内部的にはどこに保持されているかというと、Ticksと相乗りです!ulong、つまり8バイト、つまり64ビットのうち62ビットをTicksの表現に、残りの2ビットでKindを表現しています。なんでそういう構造になっているかといえば、まぁ節約ですね、メモリの節約。まー、コアライブラリなのでそういう気の使い方します、的な何か。
Ticksプロパティ、Kindプロパティはそれぞれ内部データを脱臭した値が出てくるので、そうしたTicks, Kindが相乗りした内部データを取りたい場合はToBinaryメソッドを使います。復元する場合は、FromBinaryです。
// Ticks + Kind(long, 8byte)
var dateData = DateTime.Now.ToBinary();
var now = DateTime.FromBinary(dateData);
これで8バイトでDateTimeの全てを表現できるので、これが最小かつ最速な手法になります。あまり使うこともないと思いますが。
さて、当然ZeroFormatterはそうしたToBinaryで保持してるんだよね!?というと、違います!seconds:long + nanos:intという12バイト使った表現(秒+ナノ秒)にしています。これはProtocol Buffersの表現を流用していて、うーん、一応クロスプラットフォーム的にはそのほうがいいかな、みたいな(でも今考えると別にTicksで何が悪い、って気はする……失敗した……)。そして、Kindは捨てています。シリアライズ時にToUniversalTimeでUTCに変換し、そのUTCの値のみシリアライズしています。
で、Kindは、私は捨てていいと思ってます。一応MSDNのDateTime、DateTimeOffset、TimeSpan、および TimeZoneInfo の使い分けというドキュメントにもありますが
DateTime データを保存または共有する際、UTC を使用する必要があり、DateTime 値の Kind プロパティを DateTimeKind.Utc に設定する必要があります。
UTCかLocalか、なんていうだけの二値はシリアライズに全く向いてないです。それだったらTimeZoneも保存しないと意味がない。アメリカで復元したらどうなんねん、みたいな。なのでシリアライズという観点で見るとKindはナンセンス極まりないです。これはDateTimeの設計が悪いって話でもあるんですが(後述するDateTimeOffsetがDateTimeのラッパーみたいな感じになってますけれど、本質的にはその逆であるべきだと思う)、その辺(初期の.NETのクラスはどうしても微妙にしょっぱいところがある)はshoganaiんで、受け入れるんだったらKindは無視。これが鉄板。
DateTimeOffset
Kindを無視するのはいいけれど、時差の保存は欲しいよね、という時の出番がDateTimeOffset。これは内部的には ulong(DateTime) + short(オフセット分) の2つの値で保持しています。まんま、DateTimeとOffset。DateTime.NowとDateTimeOffset.Nowって同じような値が帰ってくるし違いはなんなんやねん、というと、DateTimeOffsetはローカル時間といったKindじゃなくて、明確に内部的に+9時間というオフセットを持っているということです。
ZeroFormatterでシリアライズする際は、こちらはオフセットも保存していて、 seconds:long + nanos:int + minutes:short の14バイトの構成です。
ZeroFormatter上では明確にDateTimeとDateTimeOffsetは違うものとして取り扱ってるわけですが、よくあるDateTimeをToString("o")した場合って(んで、JSONなんかに乗せる場合って)
// 2016-12-07T03:19:23.7683110+09:00
DateTime.Now.ToString("o");
// 2016-12-07T03:19:23.7713117+09:00
DateTimeOffset.Now.ToString("o");
と、いうふうに、完全に一緒なわけです。というか、むしろこれはDateTimeの(文字列への)シリアライズをDateTimeOffsetとして表現している、とも言えます。まぁ、そのほうが実用上は親切ではある。が、これはDateTimeもDateTimeOffsetも区別してない(stringで表現)からっていうことであって、決してKindもシリアライズしているということではないということには注意。そして明確にDateTimeとしてDateTimeOffsetを違うものとして扱うなら(ZeroFormatterの場合)、良くも悪くもこういう表現はできないんだなぁ。不便だけどね。
基本的にDateTimeOffset、のほうが使われるべき正しい表現だと思うんですが、.NETのクラス設計上、DateTimeのほうが簡潔(だし内部構造的にもDateTimeOffsetはDateTime+αという形)で短い名前(名前超大事!)である以上、DateTimeの天下は揺るがないでしょう。残念なことにDateTimeOffsetの登場が.NET 2.0 SP1からだということもあるし。DateTimeOffsetがDateTimeで、DateTimeがLocalDateTimeだったら話は変わってくるでしょうけれど(そしてそんな構造だったらきっとLocalDateTimeは使われない)、まぁ変わらないものは変わらないです。まぁ保存用途ならUTCが良いと思うんで、現代的な意味では逆にDateTimeOffsetの出番はより減ってきたとも言える。データがクラウドに保存されて世界各国で共有されるとか当たり前なので、保存はUTC、表示時にToLocalTimeのほうが合理的。Kindって何やねん、と同じぐらいOffsetって何やねん、みたいな。
まぁLocalDateTime, ZonedDateTime, OffsetDateTimeという3種で表現というJava8方式が良いですよねということになる。
NodaTime
日付と時間に関しては、TimeZoneやCalendarなど、真面目に扱うとより泥沼街道を突っ走らなければならないわけですが、いっそ.NET標準のクラスを「使わない」という手もあります。NodaTimeは良い代替で、Javaの実質標準のJodaTime(後にJava8 Date API)の移植ではありますが、製作者がJon Skeet(Stackoverflow回答ランキング世界一位, Microsoft MVP, google, C# in Depth著者)なので、ありがちなJava移植おえー、みたいなのでは決してないのが一安心。
こういった標準クラスを置き換える野良ライブラリはシリアライズ出来ないのが難点で、そうしたシリアライズの表象でだけDateTime/DateTimeOffsetに置き換えるというのはよくあるパターンですが面倒くさくはある。シリアライザの拡張ポイントを利用してネイティブシリアライズ出来るようにするのが良い対応かなー、というのはあります。NodaTimeは標準でJson.NETに対応した拡張ライブラリが用意されているというところも、(当たり前ですが)わかってるなー度が高くていいですね。ZeroFormatterも拡張ポイントを持っているので、必要な分だけ手書きして対応させれば、まぁ、まぁ:)
まとめ
DateTimeOffsetも可愛い子ではある。時に使ってあげてください。というわけで、次のアドベントカレンダーは@Marimoiroさんです!
ZeroFormatterに見るC#で最速のシリアライザを作成する方法
- 2016-12-02
というタイトルで発表してきました。連続してZeroFormatterネタなのですが、今回はC#実装のほうにフォーカスして紹介しています。
intをシリアライズするところにフォーカスして、何故、既存のシリアライザは遅くて、何故ZeroFormatterは速いのかというところを解説しました。読んでもらえれば、理屈でパフォーマンスについて納得してもらえるんじゃないかと思います。
以下、会場であったFAQなどなぞ。
エンディアン違いは?
現在はリトルエンディアンしかサポートしてません。C#の動く環境ってほとんどリトルエンディアンなのでそこまで大きな問題ではないかな、と(Xboxはダメらしいですが)。対応しようと思えば当然できるんですが、Buffer.BlockCopyを多用しているので、そこの部分をバラさなきゃいけないので若干手間なのですよね(あと、性能面では低下します)。というわけで、要望があって困った、というレポートが来てから対応を考えます。一応、ビッグエンディアン下では例外を吐くようになっていて、そこの例外メッセージの中で、issueに自分の環境を書いていってください、みたいなメッセージを乗せています。
LINQ使っちゃダメなの?
んなこたぁないです。場所によりけりで、ZeroFormatter内部でも、コード動的生成する部分の型情報を舐めてどうこうするところでは使っています。それは「アプリケーションの寿命の中で最初の一回だけだから」「動的に作ったILをコンパイルする時間のほうが比較にならないぐらいに長いので、その程度を節約するのは無意味」だからです。
基本的には使おうよ、ってのは変わりはしないのですけれど、とはいえ、今まで良いとされてきた領域が、必ずしもそうなの?実はそうじゃないんじゃないの?というのを頭に入れて、都度都度考える必要は出てきているんじゃないかな、と思ってます。以前よりも。ゲームなんかでは今も昔も当然そうなのですけれど、ふつーのアプリケーションでも、今まで、単体のコンピューターで動くものは、まぁ限界もあるし、コンピューターの性能は上昇し続けるで、気にする必要はそんななかった。サーバーアプリケーションも。でも、今、サーバーアプリケーションって数十台、数百台のクラスタで動かすことも少なくなくて、それらの場合って少し性能を上げるだけで、数百台の見返りがあるんですよね。塵も積もれば山となる、今まではチリはつもらなかったけれど、今はつもりやすい環境になってきた。ってことを考えると、まぁ、特にライブラリや基盤部分のフレームワークなんかはどこでどう使われるか分からないので、気合入れてこう!っと。
2番じゃダメなんですか
まぁ、ダメですね!
せっかくライブラリ公開するなら多くの人に使ってもらいたいんですよね。これは、単純に使ってもらって嬉しいっていうのと、多くの人に使われることによって、バグが減る、機能のためのアイディアがもらえる、コントリビュートしてもらえてより強力なライブラリになれる、などなどもあります。そういうのって、会社にとってもメリットなんですよね。大きめの規模だったり独自性の高いライブラリは、社内だけで抱えたくないんです。まず、未来がない。未来がないものなんて使いたくない。というわけで、出来る限り、最初から公開を意識して作って、実際公開するわけですが、別に公開したからって未来があるわけでもない。多くの人に使われて、ある程度メジャー感が出て、はじめて未来が生まれる。なので、やるからには精一杯頑張ろうって感じですね。少なくとも何らかのインパクトは残したいと思ってやってます、いつも。
んで、2番ってヒキが全くないわけですよ。1番と2番があったら、そりゃ1番選ぶでしょ。 "Second Place is the First Loser"なわけです(ちょうど勉強会の時に聞いたので早速使ってみた)。というわけで、ヒキのある要素は色々必要で、一点目が「無限大に高速」で、これは勿論、非常に差別化要素になりうる目玉機能です。でも、それだけだとキワモノ臭さが抜けない。やっぱパフォーマンスが最大の機能なんですよね、この手のものは。だから、最速。最初はそれを目指してたわけじゃなかったんですが、スライド中に書いたように、初期設計段階でStreamを排除していたりステートを抜いてたりしたのが功を奏して、ある程度出来た段階で、最速が現実的に狙えると分かったので、そっから先はギアを切り替えてガチガチに書きました。そのせいで完成が若干遅れはしたんですが、結果としては非常に良かったと思ってます。
名前の由来
ゼロ速度のシリアライザということで。ZeroSerializerよりZeroFormatterのほうが格好良いと思います、語感が。
ZeroFormatterと謎RPCについて発表してきました。
- 2016-11-28
歌舞伎座.tech#12「メッセージフォーマット/RPC勉強会」で話してきました。前半はZeroFormatterについて、後半は謎の何かについて、です。
ZeroFormatterは良くも悪くもというか、あんま良くないんですがリリース頻度がすごくて、1.0出してから既に16回もアップデートを重ねていて最新は1.5.2です。1.0とは何だったのか……。いやまあ一応、自称、さすがに安定してきたとは思っています。思っています。思っています。常にこれで完成だ!って思ってはいます(反省)。
なんでこんなに変わったかというと、社内での置き換えで200クラス以上は書き換えてったんですが(とぅらい……)、わりと重箱の隅を突っつくような使い方をしてるところがあったりなかったりで、ビミョーに引っかかりまくったせいだ、という。ようは詰めが甘いってことなんですが、かなり色々なケースで鍛え上げられたという言い方はできます。それならちゃんと社内で叩き上げてから公開しろよって気がしなくもないんですが、公開後に皆さんから頂いたフィードバックはものすごく役立ったので、大変助かりました。お陰で当初よりも、更により良いものになったと思っています。
今回のセッションで省略した、C#の実装面でシリアライザのパフォーマンスを稼いでいく話については、12月1日に赤坂のbitFlyerさんで行われる【bitFlyer TechNight★ vol.2 C#LT Meetup!】でお話したいと思っていますので、良ければそちらへの参加もどうぞ。
Union Again
Union(1.5からDynamicUnionという動的にUnionを作る機能も入れています)は、成功時と失敗時(汎用のstring messageだけじゃなくて特化した何かを返したい)みたいな表現にも使えます。エラー表現が複数種類ある場合は、IsSuccessをenumに変えて、Union属性のtypeofに複数書いてもらえればOKって感じにサクッと拡張していけます。
[Union(typeof(Success), typeof(Error))]
public abstract class MyServiceResponse
{
[UnionKey]
public abstract bool IsSuccess { get; }
[ZeroFormattable]
public class Success : MyServiceResponse
{
public override bool IsSuccess => true;
[Index(0)]
public virtual int Foo { get; set; }
[Index(1)]
public virtual string Bar { get; set; }
}
[ZeroFormattable]
public class Error : MyServiceResponse
{
public override bool IsSuccess => false;
[Index(0)]
public virtual int ErrorCode { get; set; }
[Index(1)]
public virtual int Sender { get; set; }
[Index(2)]
public virtual int Receiver { get; set; }
[Index(3)]
public virtual string Message { get; set; }
}
}
よくある2つだけのケースの時に一々定義するのが面倒!ジェネリックなEitherが欲しい!って感じになるかもですが(なりますねぇ)、現状の素のUnion, DynamicUnionは継承を前提にした作りになっているので、ジェネリックなEitherは作れないです。ただバイナリ仕様的にはOKなので、そこはF#サポートエクステンションでEither対応させればいいんじゃないでしょうか!ちょっとIL書くだけです(自分ではやらない)。あと、継承前提とかだっせ、F#の判別共用体なら……とかってのも、結局、判別共用体の実態は(ILレベルでは)継承したクラスになってるんですからね!(ぶっちけ実行効率的には富豪過ぎるのでは……)
今回の勉強会では、Unionの話題いっぱい出ました、こんなにUnionの話が聞ける機会があるなんて……!Thriftのunion、GraphQLのUnion、ProtobufのOneof。いいですねいいですねー。
クロスプラットフォーム
Ruby実装とSwift実装を作っていただいています!わーい、ありがとうございます!会場のQ&Aにあったのですが、まぁ今回IDLを全体的に嫌った(実際、好きじゃない)内容を話していたのに、他言語で使うのにC#をIDL代わりにするという二重の苦痛なのいいの?ってことですが、そもそも、他言語で使うのにIDL自体が必須ではない、という認識に立ってます。
例えばJSONを使うのに、MsgPackを使うのにIDLは必須ではないでしょう。言語を超えなければ(単一言語内で完結している)、あるいはドキュメントベースでのやり取りをするならば、この場合だとRubyネイティブやSwiftネイティブの表現でZeroFormatterのシリアライズ/デシリアライズは達成できるはずだし、それでいい、それがいいと考えています。
ただ、言語を超えたやり取りをする時に、共通の語彙がないと面倒くさいよね、JSONならデータ自体がある程度自己記述的で、目で見てなんとかなるみたいな側面も実際あるけれど(あるいはデータから型を起こすことができる)、ZeroFormatterのバイナリはそうではないよね。という点で、共通のIDLはあったほうがしかりだし、そこで、まぁC#の表現をスキーマ代わりに使うという話になってきます。そこからジェネレータも兼務するかは別問題として。
なのでLTで発表されていたScalaによるサーバーとUnityによるクライアントをThriftのIDLのリポジトリ置いてやり取りするは、クライアント-サーバーで別言語な状態でコミュニケーションしていくにあたっては良いやり方だなあ、と思いましたし、IDLが存在する強みとも思いました(MsgPackが(実質的に)標準のIDLがないのはこういうところで地味に痛そうですね)。私のアプローチは、サーバーとクライアントを両方C#にすることによって超えていく、ということなのですが、それはそれで共通であることの大変さも存在するので(世界に銀の弾丸は存在しない!大事なのは大変さをどう超えていくか、ですね)、それぞれ環境にあった良いやり方を探っていきたいし、色々知りたいなあというところです。いやほんと、今回の勉強会は私もとても勉強になりました!
懇親会で聞いた、Protobufコードジェネレータがplugin形式になっててAST渡されて、自由に拡張できるってのは、良いですね。現在もC# -> C#書き出しのzfcは、ある程度コード解析してから出してるので、もう少しまとめてプラガブルにするとか、あとは、そのデータを標準入出力経由でやり取りすることでC#でのプラグインではなくてどの言語でも書けるようにする(zfcはZeroFormatterとしてのC#スキーマの解析だけを担ってあげる)、というのは良いなぁ、って感じなのでロードマップには入れたいですが、とにかくやることが無限大に膨らんでいくので、一旦は程々にしておきます。無限大に時間が捻出できれば……!!!
RPC Revisited
3年前から、LightNodeというアンチREST主義なフレームワーク(HTTP1上のRPC風味なRESTフレームワーク)を作っていたので、最近のファッキンRESTな風潮は時代が追いついた……、とか悦に浸ってたりなかったりするのですが、まぁ実際、RPCっすよね、って本当に思ってます。本当に本当に。一貫して。
gRPCはそんなRPC戦国時代の中でも、頭一つ抜けているし、今後デファクトスタンダードとなっていくと思っています。なので、まず一つはgRPCにベットします。そんな中で、C#の人間としてどのようなアプローチを取っていくかの、私からのアンサーがMagicOnionというマ・ニ・ア・ワ・ナ・カ・ッ・タ、フレームワークになっているんですが、まぁ間に合わなかったんであんまり語ることはありません。スライド中では、コンセプトの入り口ぐらいしか紹介できていなくて、もっと深い意味合いが存在しているんですが、その辺を語るのは出来上がってからにしましょう。その辺の間に合わなさから、C# Everywhereという「いつものところ」に話を落とすしかなかったんですが、いやー、本当のところはもう少し大層で高尚なビジョンがあるんです、はい。
ZeroFormatter 1.3 - 機能強化とstructの超高速性能とFAQと。
- 2016-11-14
ほとんど昨日の今日な状態で1.3って、バージョン1.0とは何だったのか、というかそれってベータだったということなのでは?という、あまりにいい加減なバージョン番号付けなのですけれど、そんなわけで1.3です。これが本当の1.0だ……。
基本的な概要は初出での記事 ZeroFormatter - C#の最速かつ無限大高速な .NET, .NET Core, Unity用シリアライザーを読んでいただければと思うのですが、では何が変わったかというと、ReadMeを全部書いた!いや地味に面倒なんですよ、分量あるし。英語だし。
というのもあるんですが、方向性を若干変えました。なんというか、反響が思ったよりも良すぎた。あまりの良さにビビッた(GitHub Starも私的最高伸び速度最大をマークした)、のと、だいぶ気を良くしたので、ユースケースを変えたベンチマークを他にとってみたりして、改めて考えた結果「汎用的に全方位に使える最強シリアライザ」にすることにした。というのが大きな方針転換。
汎用シリアライザとして
ビルトインでサポートしてる型を大幅に増やしました。具体的には
All primitives, All enums, TimeSpan, DateTime, DateTimeOffset,
Tuple<,...>, KeyValuePair<,>, KeyTuple<,...>,
Array, List<>, HashSet<>, Dictionary<,>, ReadOnlyCollection<>, ReadOnlyDictionary<,>,
IEnumerable<>, ICollection<>, IList<>, ISet<,>,
IReadOnlyCollection<>, IReadOnlyList<>, IReadOnlyDictionary<,>, ILookup<,>
and inherited ICollection<> with paramterless constructor
です。まぁようするに、普通に生活してて(?)出てくるほとんど全部の型がそのまま使えます。特にコレクション系を、普通に使ってても一切躓かないようにしました。1.0では実はIList/IDictionaryしかサポートしていなかったのです!もともとの発端がFlatBuffersのような内部にバイト配列を抱えてデシリアライズしないから無限大に速い(ツッコミどころの多いこの表現ですが、これはCap'n Protoから引用してます。Cap'n Protoは日本での知名度はゼロに近いですが、私は最初見た時かなり衝撃を受けました。ちなみに他にもタイムトラベルRPCとか、カッコイイ用語が目白押しなのもCap'n Protoは素敵です)、という点を強く意識していたので、具象型(ListとかArray)だと、それが実現できないんですよね。なので却下にしてたのですけれど、「汎用シリアライザ」として使わせたいんだったらサポートしたほうがいいかな、と。シリアライズ/デシリアライズ速度が他を圧倒して超高速だったというのも決断を後押ししてます。まぁこれだけ速いんだから全然いいだろ、みたいな。
structが超速い
というか、これに関しては他が遅すぎるといったほうが正しいぐらい。
intだけとかVector3とかそれの配列とか、HTMLぐらいを想定した大きめ文字列とかの結果です。文字列は結局UTF-8でエンコード/デコードするのはみんな変わらないのでそんなもんかってところですが、他が絶望的に違いすぎる。アホみたいに差が開いてるんですが、これは事実なんだなぁ。
これは、小さいデータに関しての考慮が全然ないから、というのがめっちゃ大きい。int(1)を書くってのは、つまり最速は BitConverter.GetBytes(1) なんですよ、で、もはやそこからどれだけ「遅くするか」の勝負ですらある。他のシリアライザは、やってることがあまりにも多い、だから際限なく、最速から遠くなる。ZeroFormatterは限界まで無駄がない(実際、これ以上縮めようがない)ので、もんのすごく差が開きます。どうせ小さいデータだから一個一個は差がデカいといっても小さいとも言えるんですが、頻度が高いと馬鹿にならない差になります。というかさすがにここまで違うと全然違うでしょう。
小さいデータのやり取りって、ないようで結構あるんですよ。ウェブだったら、例えばMemcachedやRedisなどKVSへのアクセスでintだけ格納したりとかって普通によくある。ゲームだったら座標データ(Vector3)のやり取りとかね。なのでまぁ、ZeroFormatterはかなり価値あるかなー、と。
Union型の追加
なにそれ、というと、一個の型の表明で複数の型を返せるようになります。どちらかというとポリモーフィズムのほうが近いですかねー、実際C#でのデシリアライズ結果はポリモーフィズムとしての表現に落としているので。ド直球に言うとFlatBuffersにあるやつです。
// こんなんで判別したいとして
public enum CharacterType
{
Human, Monster
}
// こんなふーにabstract classとUnionAttributeに子クラスを並べて、UnionKeyで識別するものを指します
[Union(typeof(Human), typeof(Monster))]
public abstract class Character
{
[UnionKey]
public abstract CharacterType Type { get; }
}
// あとは延々と並べる。
[ZeroFormattable]
public class Human : Character
{
// UnionKeyはintでもstringでもなんでもいいんですが、かならず同じ値が帰ってくるようにする必要がある
public override CharacterType Type => CharacterType.Human;
[Index(0)]
public virtual string Name { get; set; }
[Index(1)]
public virtual DateTime Birth { get; set; }
[Index(2)]
public virtual int Age { get; set; }
[Index(3)]
public virtual int Faith { get; set; }
}
[ZeroFormattable]
public class Monster : Character
{
public override CharacterType Type => CharacterType.Monster;
[Index(0)]
public virtual string Race { get; set; }
[Index(1)]
public virtual int Power { get; set; }
[Index(2)]
public virtual int Magic { get; set; }
}
// で、こう使う。
var demon = new Monster { Race = "Demon", Power = 9999, Magic = 1000 };
// Union型を指定してシリアライズする(そうしないと子を直接シリアライズしてしまうので)
var data = ZeroFormatterSerializer.Serialize<Character>(demon);
var union = ZeroFormatterSerializer.Deserialize<Character>(data);
// 結局みんな大好きswitchですが何か。
switch (union.Type)
{
case CharacterType.Monster:
var demon2 = (Monster)union;
demon2.Race...
demon2.Power..
demon2.Magic...
break;
case CharacterType.Human:
var human2 = (Human)union;
human2.Name...
human2.Birth...
human2.Age..
human2.Faith...
break;
default:
Assert.Fail("invalid");
break;
}
最終的にswitchなのがダサいといえばダサいんですが(C#でやる表現上の限界かな!)、まぁ悪くない落とし所なのではないかな、と。で、これ、便利ですよ。マジで。うーん、結構あるんですよね、状況に応じて複数データ返したいときって。で、愚直にやるとこうなるわけです。
public class Hoge
{
public 何か1の時の型 Nanika1 { get; set;}
public 何か2の時の型 Nanika2 { get; set;}
public 何か3の時の型 Nanika3 { get; set;}
}
いやー、色々無駄だし型の表現としてもアレだしちょっと、ねー、っていう。
Unionをシリアライザで記述するという点では、ZeroFormatterのやり方はかなり上手い感じで(自分で言う)、書きやすさと安全性(完全ではないけれど、意識しやすさが高いのでそこそこはある)をいい塩梅に両立させれたんじゃないかなー、と。特に書きやすさはかなりあると思います。というかぶっちけ他のシリアライザでこの手のポリモーフィズムやるのは凄まじく大変なので、革命的に便利になったといっても過言ではない。
バイナリ仕様の整理と多言語対応
諸々の追加や事情も踏まえて、バイナリ仕様を整理しました。
まず、言語中立にしました。いやまぁ、もともと、C#依存度の高いものは外して移植しようと思えばできるように、みたいな感じに作ってはいたのですけれど、より明確に中立を意識して整理しました。元々かなり頭悪く単純に作ってあるので(ZeroFormatterの速さは賢くないバイナリ仕様をC#実装力でねじ伏せる、というところがかなりあって、逆に言えば実装Firstで作られているので、言語実装で最速になるように寄り添って仕様が固まったとも言える)
というのと、↑のように遅延実行ではないコレクションのサポートを正式に入れるということで、Sequence Formatというのを正式に用意して遅延ではないDictionaryなどのレイアウトはここに属する、という形にしました。Objectも、ObjectとStruct という分けかたで定義して、KeyTupleはStructに属してますよ、みたいに割とそこそこちゃんと汎用的感な分類になってるんじゃあなかろうか。結構あーでもないこーでもないと弄ってたんですが、うーん、なるほど、こういうのは結果はあっさりしてるけど過程はとても大変……。
と、いうわけで、言語がC#のみってのはさすがに普通に欠点なんですが、整備してみたんで多言語サポートよろしくお願いします、みたいな(?)。やりたい気持ちはあるんですが、如何せんちょっとC#以外は手が回らないのデスデス。社内ではサーバーもC#で完動するようになってるので、あんまり強い外圧が働かなくて。そして実際手が回らないので。仕様作る!実装する!社内のプロジェクトのデータの移植もする!更にこれを使った次の何かも作る!あわあわわわわあわ、本当に手が回ってないヤヴァイ。
スキーマはあるよ
スキーマはあります。見えないだけで。どういうことかというとこういうことです。
namespace /* Namespace */
{
// Fomrat Schemna
[ZeroFormattable]
public class /* FormatName */
{
[Index(/* Index Number */)]
public virtual /* FormatType */ Name { get; set; }
}
// UnionSchema
[Union(typeof(/* Union Subtypes */))]
public abstract class UnionSchema
{
[UnionKey]
public abstract /* UnionKey Type */ Key { get; }
}
}
C#自体がスキーマなのです。それの利点はかなりあって、「パーサーを作らなくて済む(C#のコンパイラは既にC#で実装されていて、それのパーサーが使える)」「入力補完/コードフォーマット/シンタックスハイライト/アナライザー拡張などIDE(Visual Studio)の恩恵をフルに使える」ってのが、まずは良い。実際、zfc.exe(ZeroFormatterCompiler)という実行ファイルによって、C#というスキーマをもとにコード生成をしています。現在はAOTのためのC#コード生成ですが、別に出力を変えれば、他の言語のコードでも全然吐けます(ランタイムがないから無理だけど!)
デメリットは「機能が制限されてないので容易に制限からはみだせるので言語中立にしづらい」「現行のC#の言語機能に制限される(例えば非nullなStringは定義できない)」ってとこですね。特に前者がビミョーなんですが紳士協定の範囲内(C#としてコンパイル可能でもZeroFormatterとして解析不能だっていうエラーを放り投げちゃえばSyntaxErrorなコードと変わらない)に収めることはなんとか可能なんじゃあないかなあ、とか。ってのは夢見てます。
そして最大の利点がスキーマが生成を介さなくてもシェアできる、ということ。「プロジェクト参照」や「DLL参照」という形で、スキーマと生成コード(実際は実行時動的生成するんですが)をコード生成なしで複数プロジェクト間で共有できます。シームレスに。これは非常に大きくて、まぁ前の記事でも書いたんですがコード生成はやればやるほど複雑化していくんで、ないに越したことはないんですよね。んで、C# as Schemaだと、ゼロにできる。これはワークフローにとってはインパクトが相当大きいことです。
私は、コード生成や自動化って「したくない」ことの筆頭候補に挙げてます。自動化はミクロでは楽になっても、その積み重ねがマクロでは害悪になるケースが往々にして多い。なので、やるべきことは「自動化をしなくてすむ」ようにすることです。そのために脳みそを動かしたい。結果、脳みそが追いついてなくてそこら中が止まることも往々にしてある。shoganai。
まとめ
redddit/r/csharp/ZeroFormatterでAsk Me Anythingやってます(とは)。Fastestとかぶち撒けたせいでシリアライザ戦争が勃発している(恐ろしい)。なるほどWire、シランカッタ。コード的には基本的にZeroFormatterのほうが速そーなので、トータルで色々なケース作れば勝つと思うんだけど、弱点を突くと負けるケースは出てくるのかなぁ。とはいえ普通に私の手元で図ったら圧勝した、ふむ。(最終的に相手のベンチマークにZeroFormatter足して計測→結果 圧倒的な圧勝ということで、まぁしょうがない、相手が悪い。確かにWireは二位なので、惜しかったで賞というところ)
というわけで、真面目に、C#でサッと今使ってるシリアライザをそのまま置き換えられるものにしました。つまり、あらゆるところで使ってください、と言ってます。実際、小さなところから大きなところまで効果あると思います。小さなところは↑でstructを例にしましたが、大きなところでは、例えばバッチ処理の連鎖とかで、延々と巨大なデータを送っているのだけれど、一つ一つはその一部しか使わないんだよねー、みたいな場合。に、ものすごく効くんじゃない?って意見貰いました。その通りで、実際そういうケースでは正しくめっちゃ効きますねー。
とかとかって感なので、是非是非試してみてくださいな。あとクドい告知ですが11/27開催の歌舞伎座.tech#12「メッセージフォーマット/RPC勉強会」でもお話します&クロスプラットフォーム(Unity, Windows, Mac, Linux)で使える通信用のフレームワークをリリースします(!)のもします(ホントに!)
ZeroFormatter - C#の最速かつ無限大高速な .NET, .NET Core, Unity用シリアライザー
- 2016-11-08
(現状は)C#専用の、新しいシリアライズフォーマットを作りました。アセットストアには置いてないんですが、GitHubで公開しています。ReadMeが超書きかけですが明日ぐらいには全部書き終わってるはず……。
特徴はデシリアライズ速度がゼロなので、真の意味で爆速です。そう、無限大高速。
嘘くせー、って話なんですが、実のところこれは類似品があって、Googleの出してるFlatBuffersと基本的な考えは同じです(他にCap'n Protoというのもあります、こっちも元Googleの人ですね)。デシリアライズ「しない」から速い。つまるところ必要になるときまでパースを先送りするってことです。これは、アプリケーションの作りにもよりますが非常に効果があって、例えばデカいマスタデータをドバッと取得するなんてときに、その場で必要なデータってその巨大データのごく一部だったりするんですよね。全部パースしてデシリアライズなんかしてると遅くって。そういった問題をFlatBuffersなら一挙に解決できます(多分)。ってことは同種のZeroFormatterでも大解決できます。
なら、じゃあFlatBuffers使えばいいじゃんって話になると思うんですが、なんとFlatBuffersはAPIがエクストリームすぎて実用はマジ不可能。最速という噂のFlatbuffersの速度のヒミツと、導入方法の紹介という記事にもありますが、まぁfbsという専用IDLでスキーマ書いてジェネレートまでは許せても、その後のオブジェクトの生成をバイナリ弄っているかのように生でビルドさせるのは正気の沙汰ではない……。さすがにこれをふっつーに使うのは無理でしょ、無理。それでもピンとこない?このFlatBuffersの公式サンプルはどうでしょう?new Monster { Hp = ... Name =... WEapon... }ってだけのはずのコードが凄いことになってますけれど、まぁ、つまるところそういうことです。厳しい。絶対厳しい。(しかもそんだけやってもそこまで速くないという)。
というわけで、C#で「ちゃんと使える」というのを念頭において、シンプルなAPI(Serialize<T>とDeserialize<T>だけ!)で使えるようにデザインしました。
また、社内的事情で、IDictionaryやILookup(MultiDictionary)へのゼロ速度デシリアライズが欲しかったので(Dictionaryを作るのに配列を全部パースしてC#コードで構築、なんてやってると結局全部パースしててパースの先送りができないわ、Dictionary構築にかなり時間喰っちゃうわで全然ダメ)、ネイティブフォーマットの中にDictionaryやILookupを加えています。これにより爆速でDictionaryのデシリアライズが終わります。Dictionaryをまんま保存できるので、簡易データベース、インメモリKVSとなります。ただのシリアライズフォーマットより少し賢くて、SQLiteのようなデータベースほどは賢くない、けれど、Dictionaryそのものなので絶妙な使いやすさがある。結構、そういうのがマッチするシチュエーションって多いんじゃないかと思います(MySQLをメインに使っててもRedisも最高だよね、みたいな)
シリアライズ速度もまた、並み居る強豪を抑え(protobuf-net, MsgPack-Cli, UnityだとネイティブJsonUtilityなど)最速をマークしています。パフォーマンス系は痛い思い出があるので(性能ガン無視したゴテゴテした何かで構築すると、困ったときに性能を取り戻すのは非常に難しく、始まる前から技術的負債となる……)、とにかくパフォーマンス超優先、絶対落とさん、というぐらいにギチギチに突き詰めました。実際、今後C#でZeroFormatterを超える速度を叩き出すのは不可能でしょう。いやマジで。というぐらいにC#の最適化技法が詰め込んであります。
Unityサポートを最初から組んでいるシリアライザも珍しくて(まぁふつーは.NETでシリアライザ書くとふつーの.NETが対象になって対応が後手に回るので)、使えるっていうだけではなくて、ちゃんとiOS/IL2CPPでも最速が維持できるように組んであります。結果実際、ネイティブ実装なはずのJsonUtilityよりも速い。C#が遅いなんて誰が言ったヲイ。ちゃんと書けば速いのだ(まぁJSONじゃないからってアドバンテージはあるんだけど)。この辺はUniRxの実装で散々IL2CPPと格闘した経験がちゃんと活きてます。
メインターゲットは Server - Unity 間での通信のためですが、Server - ServerのRPC/Microservices的シナリオや、Unityでのファイルセーブなどのシナリオでも有意義に使うことは可能でしょう。難点はネットワーク通信に使うとサーバーもC#で実装しなきゃいけないってことですね!それはいいことですね!この際なのでC#で実装しましょう!そのために .NET Coreにも対応させたのでLinuxでも動かせますよ!
まぁ、この辺は来月ぐらいというか、今月末ぐらいには、更にクロスプラットフォーム(Unity, Windows, Mac, Linux)で使える通信用のフレームワークをリリースします(!)ので、そこはそれを待っていただければきっと活用の幅が広がるはずです……。詳しくは11/27開催の歌舞伎座.tech#12「メッセージフォーマット/RPC勉強会」でお話するつもりなので、是非来てくださいな。
使い方
DLLはNuGetに転がってます。
- PM> Install-Package ZeroFormatter
.NET用。
- PM> Install-Package ZeroFormatter.Interfaces
- PM> Install-Package ZeroFormatter.Unity
Unity用。Interfacesは.NET 3.5プロジェクトとUnityで共用できるのでクラスの共通化に使えます。Unityの場合はreleasesからバイナリをダウンロードしてもらったほうがいいかもしれません。
- PM> Install-Package ZeroFormatter.Analyzer
Visual Studio 2015用のAnalyzer。
クラスを定義して、ZeroFormatterSerializer.Serializeでbyte[], DeserializeでTが取れるというのが基本APIになります。
[ZeroFormattable]
public class MyClass
{
[Index(0)]
public virtual int Age { get; set; }
[Index(1)]
public virtual string FirstName { get; set; }
[Index(2)]
public virtual string LastName { get; set; }
[IgnoreFormat]
public string FullName { get { return FirstName + LastName; } }
[Index(3)]
public virtual IList<int> List { get; set; }
}
class Program
{
static void Main(string[] args)
{
var mc = new MyClass
{
Age = 99,
FirstName = "hoge",
LastName = "huga",
List = new List<int> { 1, 10, 100 }
};
var bytes = ZeroFormatterSerializer.Serialize(mc);
var mc2 = ZeroFormatterSerializer.Deserialize<MyClass>(bytes);
// ZeroFormatter.DynamicObjectSegments.MyClass
Console.WriteLine(mc2.GetType().FullName);
}
}
ZeroFormatterSerializerの使い方自体は超単純なんですが、対象となるクラスには幾つか制限があります。「ZeroFormattable」で「Indexで番号のついた」「virtualな」プロパティが必要です。更にコレクションはIList<T>で、ディクショナリはIDicitionary<TKey,TValue>で宣言しておく必要があります。おー、なんか面倒くさそうですね!そこでVisual Studioの環境ならAnalyzerが用意されていて、エディット時にリアルタイムで警告/修正してもらえます。
structにも対応していて、その場合は「Indexで0から欠番なしの連番がついたpublicなフィールドかプロパティ」と「その順番どおりの引数を持つコンストラクタ」が要求されます。これもAnalyzerが警告します。詳しいルールはReadMeで!
IDL経由で書くよりマシだし(普通のC#ですからね)、そこまで面倒くさくはないかなあ、というギリギリラインにしているつもりです。virtual強要のダルさとかルールの多さはVisual Studio Analyzerでカバーするという、今風の作りになってます。今風といっても、一つのライブラリにAnalyzerをセットでがっつし組み込むような作りしてる人は私以外見た覚えないけれど……。一昔前だとvirtual強要とか無理ゲーと思ってましたが、Analyzer以降の世代のC#ならこういう作りをしてもアリだなって思えているので、APIの見せ方の幅が広がると思うんで、もう少し増えてもいいんじゃないかなーとは思いますね。
デシリアライズと再シリアライズ
グラフ意味ないレベルなんですが、デシリアライズ速度。特に大きい配列とか、サイズがデカければデカいほど無限大に差は開きます。
なんでかといえば、裏にbyte[]を持って、ラップするクラスに包んでいるだけだからです。クラス定義で virtual を強要しているのは、デシリアライズ後のオブジェクトの実態は、裏で動的に作り変えてあり、それを継承したクラスにするためです。FlatBuffersと同等のパフォーマンスでありながら、極力自然なC#のシリアライズ/デシリアライズのAPIに載せるための手段です。
所詮はパースの先送りなので、全部の要素を使う場合はそこまで差は開きません(但し、ふつーのbyte[]から実体化するという点でのデシリアライズもかなり高速なので、仮に全プロパティを舐めても他のシリアライザよりも速度的には高速になってます、現状の実装だと)。まぁ、モノによってはすんごく効果的というのは分かってもらえるかと。実際うちの(開発中の)ゲームでは効果大(になる見込み)です。
そうして作り込んだオブジェクトの再シリアライズも強烈な速度です、というか、こちらも再シリアライズも無限大高速です。というのも裏で持ってるbyte[]をBuffer.BlockCopyでコピーするだけだから。シリアライズしないから無限大速い。これはひどぅぃ。
再シリアライズするシナリオっていうのは、サーバー側だとMicroservices的な分散環境でかなり効果あると思ってます。オブジェクトを左から右に流すだけって、それなりにあるんですよね。そういう時に生のbyte[]でやりくりするとかじゃなくて、ちゃんとオブジェクトとしての実体を持ちつつ(API的に嬉しい)、パフォーマンスも両立(左から右に流すだけなのでデシリアライズもしなければシリアライズもしない!)することが達成できます。
また、触らないというだけじゃなくて、触ることもできます。オブジェクトはミュータブルで、ちゃんとふつーのクラスのように扱って値も変えられます。FlatBuffersは制限付きで一部だけ可能なんですが、ZeroFormatterは全てを変更可能にしてます(イミュータブルにしたい場合はセッターをprotectedにしたりIListのかわりにIReadOnlyListで宣言したりすることでイミュータブルにできるので安心してくだしあ)。この場合、もし固定長の値(intとかfloatとか)を変更した場合は、裏のbyte[]に直接書き込むので、再シリアライズの高速性は維持されます。可変長の値(stringとかオブジェクトとか)を変更した場合は、そこの部分だけシリアライズが必要な差分としてマークされます。それ以外の箇所はbyte[]をBlockCopyするので、可能な限りの高速性を維持しつつも自由な編集を可能にしています。
これはFlatBuffersでは出来ないし、当然他のフォーマットでもできません。
シリアライズパフォーマンス
シリアライズはデシリアライズの時のようなチートは出来ないんで、ZeroFormatterの実装も正攻法で真正面から競ってます。で、ちゃんと速いというか十分以上に速いですというか.NET最速です、いやほんと。
せっかくなので比較対象は沢山用意していて、まぁprotobuf-netを基準として見るといいと思います。protobuf-netは実際、.NET最速シリアライザで、良いパフォーマンス出してます。でもZeroFormatterはその2倍以上速いんだなぁ。FsPicklerは個性的で面白いし、機能の豊富さを考えると十分よくやってる感じでいいですね。FlatBuffersは気合が足りないですね、あんだけ奇怪なAPIでこれかよ、みたいな。
Google.Protobufが凄く良好でビックリした。これはGoogle公式のProto3実装で、gRPCとか最近のGoogle公式でProtocol Buffersを多用するものはこれを使っていますね。ただ、汎用シリアライザじゃなくて、protoから生成してやらないと一歩も動けないタイプなので使いづらいとは思います。gRPCとかで完全にIDLのシステムが固まっている場合でなら、速度的に不安にならなくて良いという点で良いですねぇ。というかなんでこんな速いんだろ、いや、速いのはいいんですけど、コード的に、だったらZeroFormatterはもう少しいけるはずなはず、うーん、事前csコード生成で普通にコンパイルかけたほうが動的生成よりイケてるってのはあるにはあるんですが、とはいえとはいえ。むー。
というわけでZeroFormatterは実際超速い、んですが速度の秘訣は、沢山あります!幾つか紹介すると、一つは自分でコントロールできない実装を一切通してないから。ひたすらrefでbyte[]を回して、それに対して書き込むだけって感じになっていて、Streamすら使っていません。(Memory)Streamはあんまり通さないほうがいいですね、基本的にはパフォーマンスのネックになります。実際Google.Protobufやprotobuf-netは内部で書き込み用のbyte[]バッファを持ってて、溢れた時のFlushのタイミングでだけ嫌々(?)MemoryStreamに書きに行ってます。ZeroFormatterは更に徹底して、byte[]だけをひたすら引き回す。
次に、整数(など)が可変長じゃないから。Protocol BuffersやMsgPackはint(4バイト)をシリアライズするにあたって、4バイト使いません。というか使わない場合があります。それぞれのエンコード方式を使って、例えばよく使われる数字なんかは1バイトとか2バイトでシリアライズできたりします。これによってバイナリサイズが縮みます。素晴らしい。が、これはエンコードの一種と考えられるので、そのまんまintの4バイトを突っ込むのに比べてエンコードのコストがかかってます。ZeroFormatterは固定長です(これは別にパフォーマンス稼ぎたいわけじゃなくて、ランダムアクセス・ミュータブルなデシリアライズのために必要だからそうなってるだけなのですけれど)
文字列の取扱いもそこそこ工夫があります。まず、 Encoding.GetBytes(string) でbyte[]取ってストリームにWrite、なんてのはビミョー。そのbyte[]無駄じゃんって話で。GetBytesにはbyte[]を受け取ってそいつに書き込んでくれるオーバーロードがあるので、それを使います。じゃあ単純にbyte[]投げればいいのかっていうとそうでもなくて、byte[]の長さが足りない時に伸ばしてくれたりしないので、事前にちゃんと余裕もった長さにしてあげる必要があります。つまりエンコード後のサイズを知っておく必要がある。ここで Encoding.GetByteCount を大抵の実装は使うんですが、長さ分かるってことは実質エンコードしたようなものじゃん、と。というわけで、ここは Encoding.GetMaxByteCount で確保します。こっちのほうがずっと軽い。そして、別にちょっと大きめに取るのはそんな問題ないんですよ、後続がシリアライズするのに使うかもしれないし、そもそも既に大きめに確保されているかもしれない。
長さが分かっている場合(intしか返さない場合とかVector2しか返さないとか、何気にあるはず)は、返すbyte[]をきっちりそのサイズでしか確保しないという最適化が入っています。余計なバッファなし。これは↑のstringも同様で(stringだけ返すというのは非常によくある!)、その場合だけ大きめに確保はせず、ジャストサイズで返します。この辺をきっちりやってる実装は、ないですね(Streamが根底に入ってるとそもそも出来ないので、ZeroFormatterがbyte[]しか引き回さない戦略取ってるからこそ出来る芸当とも言える)
オブジェクトへのシリアライザは初回に一度だけDynamicAssemblyで型を動的に生成するわけですが、コード生成の外からループのヘルパーを通したりせずに、全てのコードを埋め込んでます。というわけで長めのil.Emitが延々と続いてるんですが、これは手間かけるだけの効果ありますね、最初はExpressionTreeでプロパティ単位でのシリアライザを用意して回してたりしたんですが、全部埋め込みにしたら劇的に良くなりました。こう差が出ると、あんまExpressionTreeで書いたほうがいいよねー、なんて気はなくなりました。
そうして生成したシリアライザのキャッシュにDictionaryは使いません。辞書のルックアップはオーバーヘッドです。.NETで最速の型をキーにした取り出しは、適当な<T>のクラスのスタティック変数から取り出すことです。特に静的コンストラクタはスレッドセーフが保証されているので、lockもいりません。つまりどうすればいいかというと、静的コンストラクタの中でifを書きまくることが絶対の正解です。if連打とか気持ち悪い?いやいや、いいんですよ、こんなんで、むしろこういうのがいいんですよ。
それやると一つの型につき一つのシリアライザしか登録できないのでコンフィグが出来ない!って話になってしまうんですが、今のとこZeroFormatterはそもそもノー・コンフィグなので問題ない(酷い)。というのはともかく、オプション毎に型を作って<TOption, T>という形で登録するっていう手法があります。その場合はオプションの全組み合わせを一つ一つの型として用意するということになります。んなのアホかって思うかもですが、実際にJilはそういう実装になっていて、真面目に現実的な手法です。
Enumの取扱いはかなり厄介で、そもそもToStringは遅くてヤバい。ので、ZeroFormatterは値でしかシリアライズしません。ToStringのキャッシュってのもありますが、じゃあそのキャッシュはどこに置くのって話になってきて(Dictionaryに突っ込むと取ってくるコストかかるので、やるなら専用シリアライザを動的に作ってIL内に文字列埋め込みが最速でしょうね)、やらなくていいならやらないにこしたことはない!
さて、というだけじゃなくて、そもそも実はEnumのUnderlyingTypeへのキャストも汎用的にやろうとするとかなり大変だったり。つまりTEnumをInt32に変換するって奴で、これ、正攻法でうまく(速く)やる手段はないです。そうなると結局動的コード生成するしかないってことになりそうで、その場合ExpressionTreeでサクッと作るのが正攻法なんですが、今回私はCreateDelegateのハックでやりました。例えば、通常は変換できないFunc<int,int>はFunc<T,int>に変換できます。TがEnumの場合、かつCreateDelegate経由の場合のみ。実装バグが、まぁベンリだしいいんじゃね?って感じで仕様として(?)残ったって感じなんですが、まぁ実際ベンリなので良きかな良きかな。ちなみに、これでExpressionTreeとか動的生成が効かないUnityでも行けるぜ!とか思ったら、そもそもUnityだと(古いmonoのコンパイラだと?)エディター上ですら動かなかった……。のでUnityではこのテクニックは使ってなくて、普通にEnumはクラスと同じように事前コードジェネレートの対象に含めてます。
あとは本当にボクシングが絶対に発生しないように書いてあります。アタリマエと思いきや意外と普通にこの辺が甘いコードは少なくなくて、protobuf-netですら秘孔を突くってほどじゃなく普通にボクシング行きのコードパス通せたりします。このボクシング殺すべしはUnityでも徹底していて、一切ボクシングなコードは通りません。どうしても必要そうな場合でもコードジェネレートでシリアライザを徹底的に事前生成させることで完全に回避してます。MsgPack-CliのUnity用コードが、コレクションをobjectで取り出すようにしてたり(汎用的なAOT対策としては、正解なのですが……)なので、Unityで徹頭徹尾やってるものも珍しい部類に入るんじゃないかと思います。
また、そもそもbyte[]を確保しない(外から渡せて縮小もしない)NoAlloc系のAPIも用意してます。外側でBufferPoolとか用意しといてもらえれば、ゴミを全く発生させないシリアライザになります。内部ではヘルパーオブジェクトの生成も全くしてない(最初から最後までbyte[]を引き回すだけでなんとかしてる)ですしね。これはリアルタイム通信書いてる時に、こんなにバンバン通信してる = シリアライザが動きまくってるのに byte[] を使い捨てまくり嫌すぎる、と思ってどうしても用意したかったのでした。まぁさすがにバンバン通信といったってUpdateループのような毎フレとかじゃあないんで、神経質になりすぎっちゃあなりすぎかもですが。
Unityでのパフォーマンス
ZeroFormatter, MsgPack-Cli, JsonUtilityでの計測です。ループ回数は500回でiPhone 6s Plus/IL2CPPで動かした結果です。ZeroFormatter, MsgPack-Cliはコードジェネレート済み、JsonUtilityはstringの後にEncoding.GetBytesでbyte[]を取る/byte[]からの復元を時間に含めてます(この手の使い方だと通常最終的にbyte[]に落とすはずなので)
デシリアライズは例によってチートなので見なくていいんですが、シリアライズもきっちり爆速です。というかJsonUtilityよりも速い。配列がMsgPack-Cliの10倍速い……(MsgPack-Cliの配列のデシリアライズ速度は正直結構厳しい結果ですね、うーん、なんでそうなのかは分からなくもなくはないんですが……)。
ZeroFormatterをUnityで使うには zfc.exe というコンソールアプリケーションを使ってシリアライザを事前生成します。今のところzfcはWindowsでしか動きません(本当は.NET Coreで実装してLinuxやMacでも動かせるようにしたかったんですが、コード解析に使っているRoslynのプロジェクト解析部分がWindows用しかまともに動かせないという鬼門があり、解決策は今のところない。もう少し.NET Coreが成熟すればいい感じになれるはず、まだ実は細かいところがイケてないのだ……)。生成物自体はどのプラットフォームでもいけます。
Unityでパフォーマンスが有利になる点といえば、ZeroFormatterでのデシリアライズ後のDictionaryは普通に書いたDictionaryよりも良い場合があります。何かというと、Unityの場合、Enumがキーの場合のDictionaryはパフォーマンスに不利です。というのも、参照の度に裏でボクシングが発生しているから(これはmonoの古いバージョンのEqualityComparer.Defaultの実装に問題があって、というかふつーの.NETのほうも4.5辺りまで微妙な実装でした)。で、解決策は専用のEqualityComparerを作ってセットしてあげること、です。面倒くさくてやってられないし実際それ分かっててもやれ(て)ないんですが、zfcで生成したDictionaryには専用のEqualityComparerが最初から自動でセットされてます。ので、その問題は起こりません。
ZeroFormatterはUnityのVector3とかはそのまんまだとシリアライズできないんですが、ZeroFormattableなstructに見せかけてzfcを通すと、Vector3用のシリアライザとかを作ってくれてベンリです。例えば
#if INCLUDE_ONLY_CODE_GENERATION
using ZeroFormatter;
namespace UnityEngine
{
[ZeroFormattable]
public struct Vector2
{
[Index(0)]
public float x;
[Index(1)]
public float y;
public Vector2(float x, float y)
{
this.x = x;
this.y = y;
}
}
}
#endif
のようなコードを用意しておくと、「INCLUDE_ONLY_CODE_GENERATION」が特別なシンボルになっていて、zfcのみで解析対象になってVector2用のシリアライザが生成されます。サーバー側でも受けたいとかって場合は、普通に↑のものをそのまま使えばそれはそれでOKです。ラップした何かに置き換える、なんてのは当然オーバーヘッドなわけなので、structがそのまま使えるんならそれにこしたことはないですからねえ。
zfcの解析対象はソースコードです。昔はコンパイル済みのDLLバイナリを解析するコードをよく書いていたのですが、CIでのビルドと相性が悪すぎて(ビルド順序の依存がある・互いが生成しあって、その生成物を参照しているような場合だとCI上でビルド不能になったりする)イマイチでした。というわけで、今後はコードジェネレートはソースコード解析によるものを主軸にしていこうと思っています。まぁ、そもそもコード生成なんてしないにこしたことはないんですけどね、ILでもなんでもいいから極力実行時動的生成にして、UnityのIL2CPP用だとか、特別な理由がある時だけ「しょうがないから」ソースコード生成する、ぐらいがいいと思ってます。全然、コード生成なんてほんといいもんでもなんでもないし、少なくするにこしたことはない。だから私はIDLを定義して生成するってのは嫌いで、C#そのものがIDLにならなきゃならないと思ってます。言語中立にしたいなら、その場合だけ「しょうがないから」IDLをジェネレートすればいい。そうですねぇ、仮にZeroFormatterを言語中立に拡大していくのだとしたら、C#をIDLの代わりにします。csx(C# Script)で直接コンパイルできるような。結構面白いと思うんだよね。
バイナリサイズ
バイナリサイズはMsgPackやProtocol Buffersに比べて「大きい」です。さすがにJSONよりは小さくなるんですが、まぁFlatBuffersとは同じぐらいですね。別にバイナリだから小さいなんてことはなくて、そう、FlatBuffersも結構大きいですよ。なんで大きいのかっていうと、ランダムアクセスするためのヘッダ領域が必要なので、その分が純粋にオーバーヘッドになってます。これはねえ、しゃーない。デシリアライズ先送りのための必要経費です。銀の弾丸なんてこの世にはなくて、トレードオフなんです、トレードオフ。gzipとかLZ4とかで圧縮しちゃうんなら結構縮められるので、もとよりMsgPack+gzipとかってやってるんなら、そんなにサイズに差は出てこないでしょう。せっかくの速度がウリのフォーマットなので、圧縮する場合はLZ4がお薦めです。結局、デシリアライズが速いといってもネットワーク転送量が多くなってしまえば、ネットワーク通信がボトルネックになってトータル処理時間では負けた!みたいなことだって普通に起こるわけなんで、全然、LZ4で圧縮ってのは良い選択です。ていうか私も(モノによってやるやらないの判断は入れますけれど)やります。
また、パフォーマンスのところで有利になると書いた可変長整数「ではない」ことは、バイナリサイズには当然響いてきます。固定長なのはパフォーマンスのためじゃなくてミュータブルにするためだったり、固定長配列の長さを真に固定するために必要だったりするのでしょうがないんですけれどね(FlatBuffersも勿論同様の話で、固定長で整数のサイズが大きくなってしまうのも必要経費でバイナリ仕様的にしょーがない)、どちらかというとパフォーマンスのほうが副産物で。
ところで突然ちなみにBinaryFormatterは更にもっとサイズでかいです、なんでかっていうとかなりリッチめに型情報が入ってるからなんですねえ。シリアライズ/デシリアライズも遅いんで、アレは使わないほうがいいですよ。
他のシリアライザにはないZeroFormatterだけのお薦め機能として、IDictionaryやILookup(MultiDictionary)へのゼロ速度デシリアライズというのを持っているんですが、なんと、それを使うとバイナリサイズが飛躍的に増大します!(ついでにシリアライズ速度も大きく低下する)。なんでかっていうと、中のハッシュテーブルを丸ごとシリアライズしてるので純粋にKey, Valueだけのシリアライズに比べて、結構に大きくなっちゃいます。なのでデフォルトでは有効になってなくて(?)、IDictionaryのかわりにILazyDictionary, ILookupのかわりにILazyLookupという形で型を宣言すると、そっちのモードでシリアライズします。これはトレードオフはトレードオフでも、ちゃんと理解した上で選択しないと危なっかしいので、デフォのIDictionaryは初回アクセス時に丸ごと構築するという、全然遅延してないじゃんモードになってます。
拡張性
ZeroFormatterはバイナリ生成のためのフレームワーク、ぐらいの気持ちで設計してあって、割とサクッと拡張して俺々バイナリを統合して流し込めるようになってます。というのも、ゲーム用に使うというのも主眼に入れてるので、一部の型は汎用ではなくて、特化したバイナリを流したいって局面は全然あるでしょう。拡張のコードの例として、Guidはデフォでサポートしてないんでプロパティの型に使うと怒られるんですが、
// こんな風にFormatter<T>を継承したクラスを作って
public class GuidFormatter : Formatter<Guid>
{
// もしバイナリが固定サイズなら数字を、そうじゃないならnullを返す
public override int? GetLength()
{
return 16;
}
// あとはbyte[]に対して書き込む/読み込む
// BinaryUtilが汎用的に使えるヘルパーになっている他、Formatter<T>.Defaultを呼べば子シリアライザを使える
public override int Serialize(ref byte[] bytes, int offset, Guid value)
{
return BinaryUtil.WriteBytes(ref bytes, offset, value.ToByteArray());
}
public override Guid Deserialize(ref byte[] bytes, int offset, DirtyTracker tracker, out int byteSize)
{
byteSize = 16;
var guidBytes = BinaryUtil.ReadBytes(ref bytes, offset, 16);
return new Guid(guidBytes);
}
}
// どっか起動時に↓のコードを呼んでおけば、Guidに対するデシリアライズが必要な時には↑のコードが呼ばれるようになる
ZeroFormatter.Formatters.Formatter<Guid>.Register(new GuidFormatter());
という風にすれば、どんな型でも対応させられます。ジェネリクス対応や動的に変動させたい、とかって場合のための登録の口も用意されているので(詳しくはReadMeを読んでね!)基本的にはどんな状況でもいけます。社内からはF#の判別共用体へのシリアライズを対応させるって話もありましたが果たして実装してもらえるのであろうか……。
この辺、protobufとかだとバイナリ仕様決まってるので、あんまり手をいれるのは気が引ける、って感じになりますが、新興フォーマットなだけに、別に自由にやっていいんじゃよ、って気になれます。MsgPackにも仕様の中にExtension typeありますけれど、如何せんZeroFormatterはオプションがない直線番長なので、考えることもまったくなく、とにかくbyte[]に書きたいように書けばそれでOK、問題なくちゃんと動きますよ、っていうのが嬉しい話です。
他言語サポート
ないです!私自身はちょっと出来ないので、気になる人がいれば、やっていただける人をゆるぼです。基本的なのは実のところかなり単純で、そこまでC#特化の何かを入れているわけでもなかったりします(というか、一応は汎用的なものを意識しているのでC#特化のものは極力入れてません)。独自のデータ構造が必要になる遅延Dictionaryとかが厳しいんですが(あと、あれはフォーマット的にも内部構造をベタシリアライズしているので、実装しづらさがかなりある)。一応、仕様サポートのステージは考えていて
- Stage1: 全てが先行評価される(無限大高速なほうの仕様は満たさない)、Decimal, LazyDictionary/LazyMultiDictionaryは非サポート
- Stage2: リスト、クラスが遅延評価される(無限大高速なデシリアライズ)、Decimal, LazyDictionary/LazyMultiDictionaryは非サポート
- Stage3: Decimalをサポートする、LazyDictionary/LazyMultiDictionaryは非サポート
- Stage4: 全フォーマットをサポートする
みたいな感じです。もし、やろう!という方がいれば、まずはStage1から試みてもらえるとどうでしょうかー。バイナリ仕様はGitHubのReadMeにあります。
まとめ
デシリアライズ先送りが魅力なのは勿論なのですが、先送りしないようなものであっても、他より高いパフォーマンスが出るので、ほぼ全方位に有効なものになってるんじゃないかと思います。比較対象としてやたらFlatBuffersに関して言及しましたが、実際のところ本当にあれ実用で使うのは無理なので(あんなんで普通に使えてる人いるのかな……)、まともに使える代物としては唯一無二な価値はあるんじゃないかな、と。
なんで作ろうかって思ったというと、絶賛開発中のゲームで手詰まったからなんですね、とほほ。巨大なDictionary/MultiDictionaryをデータベース代わりに起動時に構築する、というアプローチだったんですが、かなり破綻してて(起動時間は遅いし、そのための対応のせいでただでさえ未熟なワークフローが更にグチャグチャに)、gdgdループの根底にいたのが其奴なのであった。といっても、今更もう作りは変えられないんで、なんというか、なんとかするしかないわけで、ウルトラC的なアプローチに走ったのであった。そりゃ私だって別にこのレイヤーで俺々フォーマット作りたいなんて思わないですよ、んなもん常識的に考えて悪手に決まってるじゃん。他人がやるって言ったら全力で止めるわ。まぁ結果オーライで最終的にはZeroFormatterを活かした爆速仕様になる(予定)んで、いいってことよってことですかね。
元々はそうした無限大高速なデシリアライズと、ボトルネックにならない程度に普通に高速なシリアライズ、ぐらいに思っていたんですが、シリアライズの計測結果がかなり良かったので欲張って、いっそもうやるなら世界最速だろうとガッチガッチに実装し始めると性能は確かに伸びる。やればやるほど伸びる。が、実装時間も伸びる。やればやるほど。なるほど。とはいえ、実は告知してないだけでGitHub上ではpublicにしていたので、社外でも何人かの方には公開を伝えていて、ベータテスターじゃないけれど、様々なフィードバックなどなどを頂きました。それがなければ、全然もっと出来は悪かったと思うので、非常に感謝です。過去に制作した30のライブラリから見るC#コーディングテクニックと個人OSSの原理原則で偉そうに言いましたけれど、自分一人の限界を超えていけるのもいいことですね。外に出すってことで外圧も感じられるし:)MsgPack-Cliの藤原さんがセッションで言ってました気がしましたが、シリアライザーなんて作るのは奇特で、確かにちょっともう次はやりたくない、しんどいー。ILも一生分書いた気がする。しかし、例によって様々な既存シリアライザーの仕様から、それぞれのC#版の実装のコードを大量に読んだので、シリアライザーに関しては更に相当詳しくなりました、ううむ。シリアライザーとは結構ブログでことあるごとに記事書いてたりと、なんか長い付き合いなんですよねえ、最終的にまさか自分で作ることになるとは……。
と、そんなわけなので、是非是非使ってみてください。実用品なのかどうかで言ったら、会社で使う気満々というかそのための代物なので、その辺の耐久性はあります、まぁまだリリースされてないので(!)、耐久性はどんどん上がっていきます、ぐらいで。バグあればどんどん直すというのと、(UniRxで聞かれたことがあるのですが)社内用と社外用に分けてたりもないので、ちゃんとpublicなところでメンテナンスは続いていきます。
現状どうしても通信用フォーマットとしてはC#オンリーなので、サーバー側で送り出せなくて使いにくい、ということも絶対あるとは思うんですが、そのための解決策としてクロスプラットフォーム(Unity, Windows, Mac, Linux)で使えるC#製の通信用フレームワークをリリースする、という計画も控えているので、その辺も含めて注視していただければですね。繰り返しますが、その辺のところは11/27開催の歌舞伎座.tech#12「メッセージフォーマット/RPC勉強会」でお話するつもりなので、是非来てくださいな。
UnityのMonoアップグレードによるasync/awaitを更にUniRxで対応させる
- 2016-10-05
ついに!.NET 4.6アップグレードが始まりました。Unityの。Unity 5.5でC#コンパイラをアップグレードしていましたが、今回はついにフレームワークも、です。また、Unity 5.5のものはC#のバージョンは4に制限されていましたが、今回はC# 6が使えます。現在はForumでアーリアクセスバージョンが公開されていて、ついでにそこでリンクされているVisual Studio Tools for Unityも入れると、かなりふつーに.NET 4.6, C# 6対応で書ける感じです。
さて、.NET 4.6, C# 6といったら非同期。async/await。もちろん、書けました。が、しかし。
async Task ThraedingError()
{
Debug.Log($"Start ThreadId:{Thread.CurrentThread.ManagedThreadId}");
await Task.Delay(TimeSpan.FromMilliseconds(300));
Debug.Log($"From another thread, can't touch transform position. ThreadId:{Thread.CurrentThread.ManagedThreadId}");
Debug.Log(this.transform.position); // exception
}
これはtransformのとこで例外でます。なんでかっていうと、awaitによって別スレッドに行ってしまっているから。へー。この辺、async/awaitではSynchronizationContextという仕組みで制御するのですが、現在のUnity標準では特に何もされてないようです。
それだけだとアレなので、そこで出てくるのがUniRx。今日、アセットストアで最新バージョンのVer 5.5.0を公開したのですが、この5.5.0ではasync/await対応を試験的に入れています。それによって自動的にSynchronizationContextも生成/登録してくれます。
async Task UseUniRxInBackground()
{
Debug.Log($"Start ThreadId:{ Thread.CurrentThread.ManagedThreadId}");
await Task.Delay(TimeSpan.FromMilliseconds(300));
Debug.Log($"From same thread, because UniRx installs UniRxSynchronizationContext.ThreadId:{ Thread.CurrentThread.ManagedThreadId}");
Debug.Log(this.transform.position); // show transform
}
というように、UniRxをインポート後では、前の例外を吐いたコードと全く同じでも、ちゃんとメインスレッドに戻してくれるようになります。
Coroutine is awaitable
UniRxを入れることで追加される機能はそれだけではなく、更に普通のコルーチンもawait可能な仕組みを裏側で仕込んでいます。これにより
async Task CoroutineBridge()
{
Debug.Log("start www await");
var www = await new WWW("https://unity3d.com");
Debug.Log(www.text);
await CustomCoroutine();
Debug.Log("await after 3 seconds");
}
IEnumerator CustomCoroutine()
{
Debug.Log("start wait 3 seconds");
yield return new WaitForSeconds(3);
Debug.Log("end 3 seconds");
}
といったように、WWWとかIEnumeratorを直接awaitすることが可能になります。これはUniRx側で用意した仕組みによるものなので、普通では(現状は)できません。
勿論(?)IObservableもawait可能になっています。
async Task AwaitObservable()
{
Debug.Log("start await observable");
await Observable.NextFrame(); // like yield return null
await Observable.TimerFrame(5); // await 5 frame
try
{
// ObservableWWW promote exception when await(difference in await WWW)
var result = await ObservableWWW.Get("https://404.com");
Debug.Log(result);
}
catch (WWWErrorException ex)
{
Debug.LogError(ex.ToString());
}
Debug.Log("end await observable");
}
ObservableWWWを使うと例外はちゃんとtry-catchのほうに投げてくれるようになって、より自然に、簡単に扱えるようになります。
まとめ
思ったよりも、普通に使えて、普通に統合できるな、という印象があります < async/await。コルーチンで扱うよりも自然で、より強力なので、非同期を扱うのに適したシチュエーションではこっちのほうが良いのは間違いないはずです。Rxとの住み分けですが、基本的に非同期が対象ならばasync/awaitのほうが良いです。が、今回見ていただいたようにIObservableはawaitableなので、コードがRxになっているならば、現在のコードから自然にasync/awaitベースにソフトに移行することが可能でしょう。
Unityが今後、標準でSynchronizationContextを入れてくるのか、コルーチン対応クラスをawait対応にさせてくるのか、などはちょっと分かりません。分かりませんが、UniRxならば、その対応がずっと後のことになるとしても、今すぐ問題なく使うことが出来ますし、その可能性を今すぐ感じ取ることが可能なので、ぜひとも試してみてください!
余談
UniRxがAssetStore STAFFPICKに選ばれましたー。

うーん、嬉しい。
過去に制作した30のライブラリから見るC#コーディングテクニックと個人OSSの原理原則
- 2016-10-02
という(サブ)タイトルで、.NET Fringe Japan 2016で発表してきました。ニコ生では7時間目ぐらいから。
リンク集はこちら。
- #01 2009-04-04 linq.js
- #02 2009-10-29 AnonymousComparer
- #03 2010-04-07 DbExecutor
- #04 2010-04-30 DynamicJson
- #05 2010-07-15 XStreamingReader
- #06 2010-09-12 ReactiveOAuth
- #07 2011-02-22 Chaining Assertion
- #08 2011-10-17 ReactiveProperty
- #09 2012-02-18 ImplicitQueryString
- #10 2012-04-02 HashMapper
- #11 2012-12-03 MemcachedTranscoder
- #12 2013-02-27 AsyncOAuth
- #13 2013-04-05 CloudStructures
- #14 2013-12-06 OwinRequestScopeContext
- #15 2013-12-08 Owin.RedisSession
- #16 2013-12-23 LightNode
- #17 2014-03-11 RespClient
- #18 2014-05-28 UniRx
- #19 2014-09-24 LINQ to BigQuery
- #20 2014-10-28 LINQ to GameObject
- #21 2015-01-14 Open on GitHub
- #22 2015-03-30 NotifyPropertyChangedGenerator
- #23 2015-11-03 EtwStream
- #24 2016-03-18 SerializableDictionary
- #25 2016-05-23 MarkdownGenerator
- #26 2016-05-31 PhotonWire
- #27 2016-06-07 ObserveEveryValueChanged
作り続けることで確実にイディオムが身についていくことと、それの発展や組み合わせによって、より大きなことが出来るようになっていくんじゃないかと思います。発想も、手札が多ければ多いほど、よりよくやれるということが分かるということになりますしね。とはいえ、どうしても発想のベースは自分の手札からになっていくので、時々は異なるものへのチャレンジを意識して行わないとなー、ってとこですね。今回のスライドでも、幾つかはやったことないことを勉強のため、みたいなのがありました。Unity周りは仕事で始めたことですけれど、今は自分の中でも重要な柱です。
C#以外をやりたい、ってのは全然思わないんですが(言語の学習も悪くはないですが、それよりなにか作ったほうが100億倍良いのでは)、今猛烈に足りない/かつやりたい、のはグラフィック関係ですねー。自分で一本メガデモを作れるようになりたいってのは、ずっと昔から思っていることで、かつ、今もできていないことなので近いどこかでチャレンジしたいです。
Photon Server Deep Dive - PhotonWireの実装から見つめるPhotonServerの基礎と応用
- 2016-09-15
本題と関係ない連絡ですが、UniRx 5.4.1出しました。更新内容は主にUnity 5.5 Beta対応です(不幸にもそのままだとコンパイルエラーが出てしまっていたのだ!)。LINQ to GameObject 2.2もついでに出てます。こちらは最適化を更に進めたのと、Descendants(descendIntoChildren)というベンリメソッド(子孫要素への探索時に条件で打ち切る)の追加です。どちらも便利なので是非。
と、いうわけかで、昨日、GMO Photon 運営事務局さん開催のPhoton勉強会にてPhoton Server Deep Dive - PhotonWireの実装から見つめるPhoton Serverの基礎と応用というタイトルで話してきました。
Deep Diveなのか入門なのか微妙なところに落ち着いてしまいはしたのですけれど、他の通信ライブラリ候補との比較含めPhotonの検討理由、PhotonServerの真っ白な基本的なところ、PhotonWireの優れているところ、黒騎士と白の魔王で予定している構成、などなどを一通り紹介できる内容になったのではかと思います。
PhotonWireの細かい話はPhotonWire - Photon Server + Unityによる型付き非同期RPCフレームワークと、実装の(Photonと関係ないC#的な)細かい話は実例からみるC#でのメタプログラミング用法集のほうが詳しいです。おうふ。より詳細を話すつもりが、逆に表面的になってしまった。反省。
ZeroFormatter
一番反響があったのは、Photonよりも、むしろスライド53pから少し説明しているZeroFormatter(仮称)という、私が製作中の無限大に速い新シリアライザ/フォーマットの話でした。Oh……。
まぁ実際、(Unityに限らずですが特にUnityで)かなり使えるシリアライザにするつもりなので乞うご期待。JsonUtility、いいんですけど、制約が強すぎるんですよね、特にオブジェクトをデシリアライズする際に、nullが0埋めされたクラスに変換されちゃうのがかなりヤバかったりなので、汎用フォーマットとしては使いにくいのではないかな、というところはあります。速いんですけどねえ。また、FlatBuffersはAPIがヤバいので検討する価値もないと思ってます。あれはアプリケーションの層で実用に使うのは無理。
というわけで、絶妙にイイトコドリを目指してますので、乞うご期待。出来上がったらGitHubやUnityのAssetStoreに投下しますので人柱募集です。
LINQ to GameObject 2.1 - 手書き列挙子による性能向上と追加系をより使いやすく
- 2016-08-11
(前回の1.3から)1年ぶりの更新です!2.0は諸事情でスキップされました。アセットストアには出したんですが内容的にもう少しやりたかったのでなかったこと扱いで。LINQ to GameObject自体の説明はVer 1.0リリース時のブログLINQ to GameObjectによるUnityでのLINQの活用を参照ください。
今回はパフォーマンスチューニングを徹底的にやりました。というのも以前の素朴な実装は、素朴な通りの性能で、いいとか悪いとかじゃなく素朴なので、やるのならいっそギチギチにやってみたらどうかな、と。性能面でここまでやってるものは絶対にないはず。
もう一つは追加系をより使いやすく。のためにガッツリと破壊的変更を入れています。破壊的変更が入った理由は、使いにくかったからです。うぇぇ……。使いにくいポイントは概ね分かっていたし、プルリク等も貰っていたのですが、API的にイマイチなもので乗り気になれず、かといってAPIを維持しているとオーバーロードの解決などの問題でうまく処理できなくて、モニョモニョしている間に一年が経ってしまった。互換性は残したくはあったんですが、使いにくいままであったり、微妙なオーバーロードの追加とかで解決するよりは良いかな、と。いう決断です。
Traverse系
APIはほとんど変わってないです(但しnameでフィルターかけるオーバーロードは消しました、HTMLやXMLと違って名前でのフィルタの重要性がかなり低いので、むしろないほうがいいかな、と)。
ヒエラルキーをツリーに見立てて、「軸」の概念を元にして、必要となる全方向での列挙を満たしています。今回、コードを劇的に書き換えたパフォーマンスチューニングを施しました。一点目は、yield returnによるコードを、全部手書きの構造体の列挙子に書き換えてます。これにより列挙に伴うゴミ発生が理想的にはなくなっています、理想的には:)
残念ながら、そのままforeachに流すと C#のGCゴミとUnity(5.5)のコンパイラアップデートによるListのforeach問題解決について によりboxingが発生しますが(ゴミ化)、それでも構造体のサイズや再帰的に処理される場合での内部処理は気を配っているので(特にDescendantsはエクストリームにチューニングしたコードに変えた(再帰を特化Stackで置き換えたり……))、以前よりも良くなっているのは間違いないです。
ちなみに、基本的にはmutableなstructは避けたほうがいいです。Enumeratorはまさにそれで、実装にも注意が必要なら、利用にも注意を要するため(これはList<T>.Enumeratorも同様で、直接触ろうとすると罠にはまるケースが出てくる)なんでもかんでもstructで、というのは止めたほうがいいでしょう、どうしてもということでなければ原則やらないほうがいい事案です。struct enumeratorを返すテクニック自体は今は亡きXNAでも使われていたので(EffectPassCollectionやModelMeshCollectionなど各種コレクションがstruct Enumeratorを返す)、まぁ最終テクニックとしては有効(但し現状Unityではどうせforeachではボックス化されるのでそこまで有効ではないので、基本やらなくていいでしょう)
LINQで繋げたら、当然普通にLINQの消費フローに入るので、そんな意味ないんですけどね!というだけなのもアレなので、改善二点目、頻出パターンについて特化した最適化を入れてます。(+ OfComponent) + First, FirstOrDefault, ToArray に関しては通常のLINQではなく、この構造体Enumeratorに特化した呼び出しをするため、所謂LINQで想像する性能劣化を受けません。社内調べによると、割と FirstOrDefault や ToArray が直接接続されてる場合が多いので、それだけでも6~7割はカバーできているのではないかな、と。
更に三点目、ToArrayNonAllocというメモリ節約/GC防止メソッドが追加されています(IEnumerable<T>にも生やしてあるのでLINQ to GameObject関係ないシーンでも使えないこともない)
GameObject[] array = new GameObject[0];
// 毎フレーム走査していても余計なメモリ確保はしない!
void Update()
{
var size = origin.Children().ToArrayNonAlloc(ref array);
for (int i = 0; i < size; i++)
{
var element = array[i];
}
}
Physics.RaycastNonAllocやGetComponentsInChildren[T](List[T] results) のようなものですね。どうしても走査頻度が高くて、という場合には使えるんじゃないかと思います。まぁ、Find系は極力使わないように、というのと同じ話で、走査系を頻繁にやること自体が全然よくはないのですけれど。
また、ToArray/ToArrayNonAlloc/Destroyには(Func<GameObject, T> selector), (Func<GameObject, bool> filter), (Func<GameObject, bool> filter, Func<GameObject, T> selector), (Func<GameObject, TState> let, Func<TState, bool> filter, Func<TState, T> selector) といった、Where().Select().ToArray() のような割とよくある状況に対する最適化オーバーロードを入れてます。
この辺を活用してもらえば、単純にインラインで自前実装するよりも、むしろ速い/効率的なことのほうが多いでしょう。
特化したものを速くなるのはある種当たり前で、しかしそうするとメソッドが雪だるま式に増えるのが良くなくて、そしてLINQのいいところは合成可能なことにより特化させずとも無限の組み合わせで機能を実現できるところにある。しかし、まぁ勿論、柔軟性とパフォーマンスが幾ばくかトレードオフなのは当然の話なわけで、LINQの雰囲気を保ったまま、裏側だけ特化実装にこっそり差し替わってる。というあたりが落とし所としては良いのかな、と思ってますし、なのでそういう風に実装しました。
再帰的なイテレータの罠
Children(子要素列挙)なんかは数が大したことないので問題はそんなないんですが、Descendants(子孫要素列挙)は性能差が大きく出てきます。そして、利用頻度で言ってもDescendants系が基本多い。これのパフォーマンスを改善することは、非常に意味のあることです。さて、これはシンプルなDescendantsの実装です。
static IEnumerable<GameObject> Descendants(GameObject root)
{
yield return root;
foreach (Transform item in root.transform)
{
foreach (var child in Descendants(item.gameObject))
{
yield return child.gameObject;
}
}
}
このコードには大きな問題があります!再帰的なイテレータ、つまり foreach (var child in Descendants(item.gameObject)) は危険です。Baaaaad Practice、デス。要警戒です。これ、子孫にあるGameObjectの数だけ、イテレータ作ってます。GetEnumerator祭り!これは、LINQがどうのとかそういう次元を超えています。LINQのコストというのはメソッドチェーン分のGetEnumeratorの加算とMoveNextの連鎖による一回の呼び出しコストの増加が基本的な話で、ようするに2~3増えるという話で大したことあるといえば大したことあるし、大したことないといえば大したことない。が、さすがに要素数分だけ無駄にEnumerator作るとなったら話は別だ。ちょっとね、かなり気になるよね。
解決策は2つあります。一つはstruct enumeratorで、struct生成コストはあるもののゴミにはなりません。↑で書いたように実装済みです。
もう一点は、内部イテレーター化。イテレーターには概ね二種類、内部イテレーターと外部イテレーターがあります。外部イテレーターはforeachで使える、つまりGetEnumerator経由のもので、内部イテレーターはListのForEachなどクラスに直接生えてるもの。それぞれ利点と欠点があります。外部イテレーターの利点は柔軟性(LINQ)と言語サポート(foreach/generator)、よって基本的にはこちらを選べばOKです。欠点はパフォーマンスが内部イテレーターほど稼げない。どうしても一つシーケンスを進めるのにMoveNextとCurrentの2つのメソッド呼び出しが必要になるので。内部イテレーターの利点はパフォーマンスで、内部構造に最適化したループを回せるので、基本最速です。欠点は柔軟性がないのと、それぞれのコレクションで独自実装になること。
LINQ to GameObjectでは両方実装しています。外部イテレーターは手書きで最適化したstruct enumerator(とStackPoolと、その他諸々の仕掛け)によって、遅延実行やLINQサポートなどの柔軟性を維持したまま、パフォーマンスとGC行きのゴミを全く出さないようにしています。内部イテレーターに関してはForEachとToArray(NonAlloc)に関しては、外部イテレーター版と全く異なる実行パスを通ることにより、最速を維持します。
ところで、Unityネイティブに用意されているものがある場合は、それを使ったほうが速くなります。例えば DescendantsAndSelf().OfComponent().ToArray() は GetComponentsInChildren(includeInactive:true) に概ね等しく(一つのオブジェクトに複数コンポーネントが貼り付けてある場合、LINQ to GameObjectではそれぞれのGameObjectに一つのみ、GetComponentsInChildrenは複数と、正確には挙動が異なります)、後者を使ったほうが断然速い。一応ですが、ネイティブだから常に速いとか、そういうことはなくて、ネイティブ-マネージド間の変換コストのほうが勝る場合もあります(たとえばUnityにおけるコルーチンの省メモリと高速化について、或いはUniRx 5.3.0でのその反映のような話)。けれど、この場合は、C#だけで走査すると、GameObject毎でのGetComponentが避けられません(GetComponentのコストはタダではないのだ)。なので、一発でネイティブ内でかき集めてきたほうが絶対的に速くなります。子孫を辿るだけならほとんど遜色ない、むしろ速いといっていいぐらいなので、本当にこれはGetComponentに対する処理効率の差だけですね。これだけはどうにもできませんでした。
追加系
変わってます。使い勝手的にはこっちの対応がメインです。
以前のAPIの何が不便かって、引数にGameObjectしか受け付けなかった!そして戻り値がGameObject!大抵の場合はComponentを入れてComponentを受け取りたいのに!これは酷い!いやほんと酷すぎでした……。なんでそうなってたかというと言い訳はそれなりにあって、まずGameObjectとComponentって継承階層が別のとこにいるんですよねー、のが困る。それをオーバーロードとして分けると、IEnumerableを受け取るオーバーロードが存在していたため、どうやってもうまく型が解決できなかったのだ……。
もうどうにもならなかったので、API変えてます。IEnumerableを受け取るオーバーロードはXxxRangeという名前に分離。また、基本的には<T>を返すように、そして T:UnityEngine.Object を受け取れるようにしたので、引数としてやっとMonoBehaviourなComponentを素直に流し込めるようになりましたー。万歳。継承階層が別のとこにいて困ります問題は、UnityEngine.Objectを受け取った上で、動的にGameObjectとComponentに仕分けすることで解決。
というわけで、やっと自信持って普通に使えるようになりました。単純な話なんですが、まず破壊的変更にする、ということに腰が重かったことと、それを踏まえても、うまいAPIを構築するのに手間取った。のせいでこんなに遅れてしまって、いやはや……。
その他、あとDestroyでデフォルトでヒエラルキから外さなくしました。このヒエラルキから外すというのは最低のアイディアで、配列ではなく列挙しながら(LINQ to GameObjectでやるような!)Destroyする場合に、ヒエラルキから外すせいで位置がずれて死ぬ。というのを防ぐためにToArrayでキャッシュしなければならない(無駄なオーバーヘッド!)。というしょうもない自体に陥りがちなので、やめました。わざわざ外すコストだってゼロじゃないので、二重に悪い。
まとめ
GameObjectBuilderというものがあったのですが、イラナイ子なので消しました。LINQ to XMLのFunctional Constructionを模した――ものなのですが、そういう、コピーに一生懸命なだけなのって悪趣味なんですよね。大事なのは、概念(LINQ to Tree)を対象環境(Unity)に最適化することであって、コピーすることではない。そういうの、分かっているつもりではいたのですが、やり始めるとついついやってしまうところがある。随時見切って、バッサリ切り落とせるようにならないとですね。
LINQ to GameObjectのオリジナルのデザインは2014/10/28だったんですが、その頃は今よりは全然遥かにUnityへの習熟度、知識が欠けていたなぁ、というのを改めて痛感しました。思い上がる、ということはないですが、環境への理解力が足らないとどこかイマイチなものになってしまうわけで、C#云々抜きに、常にUnityに真摯に向き合ってかないとダメですね。実際問題、愛情を持って突き詰めて考えられないと、本当の理想のところまでは行けない。小手先の知識だけで処理したようなライブラリは、まぁ使いたくないですねえ、そういうの実際どうしてもどこか独りよがりのしょうもないものになってしまうので。
LINQは遅い/GCキツくなるというのは絶対的な事実ではあるのですけれど、極力書き味を失わないようにしつつ、6, 7割ぐらいのシチュエーションには特化した最適化を施し、何も考えずともむしろ普通に書くよりも速くなる。それ以外のシチュエーションでも、速さを意識した使い方をすれば、やはり普通に書くよりも速くなる。という、私的には理想的かな、というところで表現できたので、是非是非、機能を気にする人も、性能を気にする人も使ってみてください。どちらも満たせるものになっているはずです。
ところでしつこいですが、9/13にPhoton勉強会で「Photon Server Deep Dive - PhotonWireの実装から見つめるPhoton Serverの基礎と応用」というタイトルで話しますので、Photon興味ある人も、そうでなくてもUniRx興味ある人もどうぞ。LINQ to GameObject、或いはUnityとLINQについての話は、さすがにあんま関係ないのでセッション内容には含まれませんが懇親会ででも掴まえてもらえば何でも答えます。