はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【C#】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;
    }
}

Unity.Mathematics/src/Unity.Mathematics/int3.gen.cs at master · Unity-Technologies/Unity.Mathematics · GitHub

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を返す
    }
}

パフォーマンスのためにResolverFormatterを使い回すようにしながら該当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.SerializeMessagePackSerializer.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;
    }
}