MessagePack for C# でMessagePack旧仕様のbyte[]に対応させる

久しぶりの投稿です。

C#におけるMessagePackライブラリの中で、高速であるとされる MessagePack-CSharp

github.com

高速で洗練されているものの、率直に申しますと、MessagePackの古い仕様のサポートが不十分で面倒なようです。その克服の方法です。

ちなみにこの克服にあたり、本家MessagePack-CSharpに修正のpull requestを出し、受理されました。

github.com github.com

ただし旧仕様を使うなら、速度等にこだわりがないなら、古くから実績のある MessagePack for CLI (以下 MsgPack-Cli) を使うのが楽だと思います。 github.com

環境

MessagePackの古い仕様による問題

(私は全然詳しくないのですが) MessagePackの当初の仕様では、文字列型が存在せず、raw bytes として扱われます。 https://github.com/msgpack/msgpack/blob/master/spec-old.md

その後、現在の仕様では文字列型が追加されました。 https://github.com/msgpack/msgpack/blob/master/spec.md

それにより、例えば古い仕様で raw 16型 を表す0xda は、新仕様では str 16 を表します。raw 32も同様です。古いMessagePackデータを新仕様のライブラリで解釈するとエラーになります。

MessagePack-CSharp でエラーになる例と、その解決法

以降では、MsgPack-Cliをリファレンス実装 (旧仕様のMessagePackを使う実装) として使用します。

byte[] 単独のデシリアライズ

まずはbyte配列1個のデシリアライズに挑戦。

// byte[] を旧仕様でシリアライズ
byte[] sourceBytes = Enumerable.Repeat((byte)128, 1000).ToArray(); // 適当なbyte配列
byte[] messagePackBytes = SerializeByClassicMsgPack(sourceBytes);

// MessagePack-CSharpでデシリアライズ
byte[] deserializedBytes = MessagePackSerializer.Deserialize<byte[]>(messagePackBytes);
// MsgPack-Cli により、旧仕様でシリアライズして返す
private static byte[] SerializeByClassicMsgPack<T>(T obj)
{
    var context = new MsgPack.Serialization.SerializationContext
    {
        SerializationMethod = MsgPack.Serialization.SerializationMethod.Array,
        // 旧仕様でお願いします(超楽!)
        CompatibilityOptions = { PackerCompatibilityOptions = MsgPack.PackerCompatibilityOptions.Classic }
    };

    var serializer = MsgPack.Serialization.MessagePackSerializer.Get<T>(context);
    using (var memory = new MemoryStream())
    {
        serializer.Pack(memory, obj);
        return memory.ToArray();
    }
}

これを実行すると、str 16が云々と言われて例外が発生します。 f:id:Schima:20171010170814p:plain

当時かなり途方にくれましたが、これの解決には MessagePack.Formatters.OldSpecBinaryFormatter を使います。

https://github.com/neuecc/MessagePack-CSharp/blob/64c6c1d09ec1bdb0105288ec76330ffbcf11cead/src/MessagePack/Formatters/OldSpecFormatter.cs#L178

byte[] deserializedBytes = OldSpecBinaryFormatter.Instance.Deserialize(
    messagePackBytes, 0, StandardResolver.Instance, out var readSize);

クラスのプロパティとしてbyteがある例

上記で最低限の実装はどうにか書けます。しかし実際にはbyte単独でシリアライズすることはあまりなく、クラス単位で行うことがほとんどだと思います。

[DataContract]
public class Foo
{
    [DataMember]
    public int Id { get; set; }
    [DataMember]
    public byte[] Value { get; set; }
}
// MsgPack-Cli により、旧仕様でシリアライズして返す
private static byte[] SerializeByClassicMsgPack<T>(T obj)
{
    var context = new MsgPack.Serialization.SerializationContext
    {
        SerializationMethod = MsgPack.Serialization.SerializationMethod.Map, // ここだけArrayから変更
        CompatibilityOptions = { PackerCompatibilityOptions = MsgPack.PackerCompatibilityOptions.Classic }
    };

    var serializer = MsgPack.Serialization.MessagePackSerializer.Get<T>(context);
    using (var memory = new MemoryStream())
    {
        serializer.Pack(memory, obj);
        return memory.ToArray();
    }
}
var sourceBytes = Enumerable.Repeat((byte)128, 1000).ToArray(); // 適当なbyte配列
var foo = new Foo { Id = 123, Value = sourceBytes };
var messagePackBytes = SerializeByClassicMsgPack(foo);

// crash
Foo deserializedObj = MessagePackSerializer.Deserialize<Foo>(messagePackBytes);

上記も動きません。

MessagePack-CSharp 1.7.0 以降での回避

https://github.com/neuecc/MessagePack-CSharp/releases/tag/v1.7.0

Improve MessagePackFormatterAtribute supports property and field.

これを活用し、byte[]のシリアライズを特別扱いさせます。

public class OldSpecByteArrayFormatter : IMessagePackFormatter<byte[]>
{
    public int Serialize(ref byte[] bytes, int offset, byte[] value, IFormatterResolver formatterResolver)
    {
        return OldSpecBinaryFormatter.Instance.Serialize(ref bytes, offset, value, formatterResolver);
    }

    public byte[] Deserialize(byte[] bytes, int offset, IFormatterResolver formatterResolver, out int 
readSize)
    {
        return OldSpecBinaryFormatter.Instance.Deserialize(bytes, offset, formatterResolver, out readSize);
    }
}
[DataContract]
public class Foo
{
    [DataMember]
    public int Id { get; set; }

    [DataMember]
    [MessagePackFormatter(typeof(OldSpecByteArrayFormatter))] // これが肝
    
    // 注:typeof(OldSpecBinaryFormatter) と直接書きたいですが、1.7.0時点ではコンストラクタがinternalなので落ちます

    public byte[] Value { get; set; }
}
var sourceBytes = Enumerable.Repeat((byte)128, 1000).ToArray(); // 適当なbyte配列
var foo = new Foo { Id = 123, Value = sourceBytes };
var messagePackBytes = SerializeByClassicMsgPack(foo);

// 成功!
Foo deserializedObj = MessagePackSerializer.Deserialize<Foo>(messagePackBytes);

MessagePack-CSharp 1.6.2 以前での回避

MessagePack-CSharp 1.7.0から、netstandard2.0が必須になりました。しかし1.xが必要な場面で困るので、更新できないケースもあるかと思います。 (たとえば、AWS Lambda とか)

1.6.2以前ではMessagePackFormatterをフィールドに指定できないので、以下のような手を使います。

// MessagePack旧仕様対応シリアライザ内蔵の、byte[] をラップするオブジェクト
[MessagePackObject]
[MessagePackFormatter(typeof(Formatter))]
public class OldSpecByteArray 
{
    [Key(0)]
    public byte[] Value { get; set; }

    // OldSpecByteArray と MessagePack の変換をするフォーマットクラス
    // 1.7.0向けで示した実装とほぼ同じ
    public class Formatter : IMessagePackFormatter<OldSpecByteArray>
    {
        public int Serialize(ref byte[] bytes, int offset, OldSpecByteArray value, IFormatterResolver formatterResolver)
        {
            return OldSpecBinaryFormatter.Instance.Serialize(ref bytes, offset, value.Value, formatterResolver);
        }

        public OldSpecByteArray Deserialize(byte[] bytes, int offset, IFormatterResolver formatterResolver, out int readSize)
        {
            var value = OldSpecBinaryFormatter.Instance.Deserialize(bytes, offset, formatterResolver, out readSize);
            return new OldSpecByteArray { Value = value };
        }
    }
}
// Fooオブジェクトには byte[] の代わりに OldSpecByteArray を持たせる
[DataContract]
public class Foo
{
    [DataMember]
    public int Id { get; set; }

    [DataMember]
    public OldSpecByteArray Value { get; set; }
}