はじめに
今回はMessagePack-CSharp
でCustom Formatter
を作成する方法を紹介したいと思います。
概要
Custom Formatter
を作成することで、独自に定義した型・サードパーティ製ライブラリで定義された型などのMessagePack-CSharp
が対応していない型に対してシリアライズ・デシリアライズ処理を記述することで対応させることができます。
環境
MessagePack v2.5.172
やり方
今回は分かりやすいようにサンプルとして、Unity.Mathematics
に定義されているint3
を真似した型で実験しようと思います。
// nullチェック周りの解説も入れたいのでstructではなくclassにしてます public class Int3 { public int X { get; } public int Y { get; } public int Z { get; } public Int3(int x, int y, int z) { X = x; Y = y; Z = z; } }
Custom Formatterの作成
まずは対象の型をジェネリクスで指定したIMessagePackFormatter<T>
を実装したFormatter
を定義します。
public class Int3Formatter : IMessagePackFormatter<Int3> { public void Serialize(ref MessagePackWriter writer, Int3? value, MessagePackSerializerOptions options) { // どのようにシリアライズするか記述する } public Int3? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { // どのようにデシリアライズするか記述する } }
シリアライズ・デシリアライズ処理を記述していきます。今回の場合は[x, y, z]
のように配列で表現していきます。
public class Int3Formatter : IMessagePackFormatter<Int3> { public void Serialize(ref MessagePackWriter writer, Int3? value, MessagePackSerializerOptions options) { // valueがnullのときはnilを書き込む if (value == null) { writer.WriteNil(); return; } // 3つの要素を持つ配列を定義する writer.WriteArrayHeader(3); writer.WriteInt32(value.X); writer.WriteInt32(value.Y); writer.WriteInt32(value.Z); // System.Text.Jsonなんかでは「writer.WriteEndArray()」のような終了を表す記述が必要ですが、MessagePackでは必要ありません // MessagePackでは最初に要素数を指定する仕様になってます } public Int3? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { // Nilで読み取ることができたらnullを返す if (reader.TryReadNil()) { return null; } // 信頼されていないデータをデシリアライズする際にセキュリティな側面での対応です // 内部的に「reader.Depth++」される // MessagePackSecurity.MaximumObjectGraphDepth以上(デフォルトint.MaxValue)にDepthがなると、Security的に危ういためInsufficientExecutionStackExceptionを投げる options.Security.DepthStep(ref reader); Int3 int3; try { var count = reader.ReadArrayHeader(); if (count != 3) throw new ArgumentException(count.ToString()); var x = reader.ReadInt32(); var y = reader.ReadInt32(); var z = reader.ReadInt32(); int3 = new Int3(x, y, z); } finally { // options.Security.DepthStep(ref reader)により「reader.Depth++」されたのを戻す reader.Depth--; } return int3; } }
まず注目してほしいのは配列の長さと要素を書き込んでいる箇所です。
// シリアライズ writer.WriteArrayHeader(3); writer.WriteInt32(value.X); writer.WriteInt32(value.Y); writer.WriteInt32(value.Z);
// デシリアライズ var count = reader.ReadArrayHeader(); if (count != 3) throw new ArgumentException(count.ToString()); var x = reader.ReadInt32(); var y = reader.ReadInt32(); var z = reader.ReadInt32();
MessagePackのフォーマット仕様として、配列は最初に種類を記述 => 要素数を記述 => それぞれの要素を記述
という流れになってます。それに従って、最初にArrayHeader
を記述して各Int32
を書き込むという流れになってます。
fixarray stores an array whose length is upto 15 elements: +--------+~~~~~~~~~~~~~~~~~+ |1001XXXX| N objects | +--------+~~~~~~~~~~~~~~~~~+ array 16 stores an array whose length is upto (2^16)-1 elements: +--------+--------+--------+~~~~~~~~~~~~~~~~~+ | 0xdc |YYYYYYYY|YYYYYYYY| N objects | +--------+--------+--------+~~~~~~~~~~~~~~~~~+ array 32 stores an array whose length is upto (2^32)-1 elements: +--------+--------+--------+--------+--------+~~~~~~~~~~~~~~~~~+ | 0xdd |ZZZZZZZZ|ZZZZZZZZ|ZZZZZZZZ|ZZZZZZZZ| N objects | +--------+--------+--------+--------+--------+~~~~~~~~~~~~~~~~~+ where * XXXX is a 4-bit unsigned integer which represents N * YYYYYYYY_YYYYYYYY is a 16-bit big-endian unsigned integer which represents N * ZZZZZZZZ_ZZZZZZZZ_ZZZZZZZZ_ZZZZZZZZ is a 32-bit big-endian unsigned integer which represents N * N is the size of an array
msgpack/spec.md at master · msgpack/msgpack · GitHub
あとは一応null
対策のために、null
のときはnil
を書き込んでいます。
// シリアライズ if (value == null) { writer.WriteNil(); return; }
// デシリアライズ if (reader.TryReadNil()) { return null; }
おそらく一番謎に感じるのはoptions.Security.DepthStep
の箇所だと思います。
// 信頼されていないデータをデシリアライズする際にセキュリティな側面での対応です // 内部的に「reader.Depth++」される // MessagePackSecurity.MaximumObjectGraphDepth以上(デフォルトint.MaxValue)にDepthがなると、Security的に危ういためInsufficientExecutionStackExceptionを投げる options.Security.DepthStep(ref reader); Int3 int3; try { var count = reader.ReadArrayHeader(); if (count != 3) throw new ArgumentException(count.ToString()); var x = reader.ReadInt32(); var y = reader.ReadInt32(); var z = reader.ReadInt32(); int3 = new Int3(x, y, z); } finally { // options.Security.DepthStep(ref reader)により「reader.Depth++」されたのを戻す reader.Depth--; }
これはセキュリティのために記述されていて、内部実装を見れば意外とシンプルです。
デシリアライズを開始するタイミングでreader.Depth
をインクリメントし、終わった時にデクリメントするように実装してあげます。そうすることで、悪意を持ったデータをデシリアライズしようとしてデシリアライズ処理が際限ないレベルで行われることを防ぎます。
まあ防ぐというよりは、StackOverflowとエラーを区別するようにするという意図もあるとは思いますが。
Resolverの作成
Int3Formatter
を直接利用することもあるかとは思いますが、MessagePackSerializer
を通して利用することが大半でしょう。
それを実現するためにはまずResolver
を作成する必要があります。作り方としてはIFormatterResolver
を実装した型を定義します。
public class Int3Resolver : IFormatterResolver { public IMessagePackFormatter<T>? GetFormatter<T>() { // Tから判断して適切なFormatterを返す // 該当するFormatterがなければnullを返す } }
パフォーマンスのためにResolver
・Formatter
を使い回すようにしながら該当Formatter
を返すようにしてあげます。
public class Int3Resolver : IFormatterResolver { // Resolverを使い回すため用意 public static Int3Resolver Instance = new(); // Formatterを使い回すため用意 private readonly Int3Formatter _int3Formatter = new(); public IMessagePackFormatter<T>? GetFormatter<T>() { if (typeof(T) == typeof(Int3)) { return (IMessagePackFormatter<T>)_int3Formatter; } return null; } }
よりパフォーマンスを上げるために
GetFormatter
のたびにtypeof
が走るのはパフォーマンス的には微妙です。さらに改善するためにはStatic Type Caching
という手法を使います。
www.hanachiru-blog.com
public class Int3Resolver : IFormatterResolver { // Resolverを使い回すため用意 public static Int3Resolver Instance = new(); public IMessagePackFormatter<T>? GetFormatter<T>() { return FormatterCache<T>.Formatter; } // Static Type Cachingによる高速化 private static class FormatterCache<T> { // T型に対して必ず一度だけFormatterの設定が行われるようになる internal static readonly IMessagePackFormatter<T>? Formatter; static FormatterCache() { Formatter = (IMessagePackFormatter<T>?)Int3FormatterCacheHelper.CreateFormatter(typeof(T)); } } } internal static class Int3FormatterCacheHelper { public static object? CreateFormatter(Type t) { if (t == typeof(Int3)) { return new Int3Formatter(); } return null; } }
MessagePackSerializerOptionsへの設定
MessagePackSerializer.Serialize
やMessagePackSerializer.Deserialize
は引数に設定したMessagePackSerializerOptions
に指定したResolver
が適応されます。また明示的に指定しない場合はMessagePackSerializer.DefaultOptions
が利用されています。
その前提でMessagePackSerializer.DefaultOptions
を書き換えてみましょう。
基本的にMessagePack-CSharp
がデフォルトで用意してくれているResolver
に自身が定義したResolver
を追加したCompositeResolver
をまず定義してあげます。
// 最初にヒットしたFormatterが利用されるので、Resolver.Instanceの並び順は非常に重要 IFormatterResolver resolver = CompositeResolver.Create( Int3Resolver.Instance, StandardResolver.Instance );
StandardResolver.Instance
がデフォルトで用意してくれているFormatter
が入っているResolver
です。その前に先ほど定義してあげたInt3Resolver.Instance
を挟んであげることで、独自の型かどうかチェックしてFormatter
を返してくれるようになります。
それをもとにMessagePackSerializer.DefaultOptions
に適応してみます。
// 最初にヒットしたFormatterが利用されるので、Resolver.Instanceの並び順は非常に重要 IFormatterResolver resolver = CompositeResolver.Create( Int3Resolver.Instance, StandardResolver.Instance ); // グローバルな設定を書き換える MessagePackSerializer.DefaultOptions = MessagePackSerializer.DefaultOptions.WithResolver( resolver );
これで設定はすべて完成です。
実験
今までの作業が正しく動作するのか確認してみましょう。
// 最初にヒットしたFormatterが利用されるので、Resolver.Instanceの並び順は非常に重要 IFormatterResolver resolver = CompositeResolver.Create( Int3Resolver.Instance, StandardResolver.Instance ); // グローバルな設定を書き換える MessagePackSerializer.DefaultOptions = MessagePackSerializer.DefaultOptions.WithResolver( resolver ); var int3 = new Int3(1, 2, 3); var serializedInt3 = MessagePackSerializer.Serialize(int3); // 93, d2, 0, 0, 0, 1, d2, 0, 0, 0, 2, d2, 0, 0, 0, 3 Console.WriteLine(string.Join(", ", serializedInt3.Select(x => x.ToString("x")))); var deserializedInt3 = MessagePackSerializer.Deserialize<Int3>(serializedInt3); // 1, 2, 3 Console.WriteLine(deserializedInt3.X); Console.WriteLine(deserializedInt3.Y); Console.WriteLine(deserializedInt3.Z);
正しく標準出力されていれば成功です。
コード全文
using MessagePack; using MessagePack.Formatters; using MessagePack.Resolvers; namespace Sample; public class Program { public static void Main(string[] args) { // 最初にヒットしたFormatterが利用されるので、Resolver.Instanceの並び順は非常に重要 IFormatterResolver resolver = CompositeResolver.Create( Int3Resolver.Instance, StandardResolver.Instance ); // グローバルな設定を書き換える MessagePackSerializer.DefaultOptions = MessagePackSerializer.DefaultOptions.WithResolver( resolver ); var int3 = new Int3(1, 2, 3); var serializedInt3 = MessagePackSerializer.Serialize(int3); // 93, d2, 0, 0, 0, 1, d2, 0, 0, 0, 2, d2, 0, 0, 0, 3 Console.WriteLine(string.Join(", ", serializedInt3.Select(x => x.ToString("x")))); var deserializedInt3 = MessagePackSerializer.Deserialize<Int3>(serializedInt3); // 1, 2, 3 Console.WriteLine(deserializedInt3.X); Console.WriteLine(deserializedInt3.Y); Console.WriteLine(deserializedInt3.Z); } } public class Int3Formatter : IMessagePackFormatter<Int3> { public void Serialize(ref MessagePackWriter writer, Int3? value, MessagePackSerializerOptions options) { // valueがnullのときはnilを書き込む if (value == null) { writer.WriteNil(); return; } // 3つの要素を持つ配列を定義する writer.WriteArrayHeader(3); writer.WriteInt32(value.X); writer.WriteInt32(value.Y); writer.WriteInt32(value.Z); // System.Text.Jsonなんかでは「writer.WriteEndArray()」のような終了を表す記述が必要ですが、MessagePackでは必要ありません // MessagePackでは最初に要素数を指定する仕様になってます } public Int3? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { // Nilで読み取ることができたらnullを返す if (reader.TryReadNil()) { return null; } // 信頼されていないデータをデシリアライズする際にセキュリティな側面での対応です // 内部的に「reader.Depth++」される // MessagePackSecurity.MaximumObjectGraphDepth以上(デフォルトint.MaxValue)にDepthがなると、Security的に危ういためInsufficientExecutionStackExceptionを投げる options.Security.DepthStep(ref reader); Int3 int3; try { var count = reader.ReadArrayHeader(); if (count != 3) throw new ArgumentException(count.ToString()); var x = reader.ReadInt32(); var y = reader.ReadInt32(); var z = reader.ReadInt32(); int3 = new Int3(x, y, z); } finally { // options.Security.DepthStep(ref reader)により「reader.Depth++」されたのを戻す reader.Depth--; } return int3; } } public class Int3Resolver : IFormatterResolver { // Resolverを使い回すため用意 public static Int3Resolver Instance = new(); public IMessagePackFormatter<T>? GetFormatter<T>() { return FormatterCache<T>.Formatter; } // Static Type Cachingによる高速化 private static class FormatterCache<T> { // T型に対して必ず一度だけFormatterの設定が行われるようになる internal static readonly IMessagePackFormatter<T>? Formatter; static FormatterCache() { Formatter = (IMessagePackFormatter<T>?)Int3FormatterCacheHelper.CreateFormatter(typeof(T)); } } } internal static class Int3FormatterCacheHelper { public static object? CreateFormatter(Type t) { if (t == typeof(Int3)) { return new Int3Formatter(); } return null; } }