はなちるのマイノート

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

【C#】パフォーマンス・バイナリサイズともに優れたシリアライザー「MessagePack for C#」の基礎的な使い方

はじめに

今回はC#用のMessagePackシリアライザーであるMessagePack for C#の基礎的な使い方を紹介したいと思います。

github.com

概要

MessagePack for C#ハイパフォーマンスなMessagePackシリアライザーです。GitHubのReadmeにも記載されていますが、Json.NETは当然のことながら、protobuf-netMsgPack-Cliよりもパフォーマンスやシリアライズ後のバイナリサイズともに優秀です。

The extremely fast MessagePack serializer for C#. It is 10x faster than MsgPack-Cli and outperforms other C# serializers. MessagePack for C# also ships with built-in support for LZ4 compression - an extremely fast compression algorithm. Performance is important, particularly in applications like games, distributed computing, microservices, or data caches.

// DeepL翻訳
C#用の超高速MessagePackシリアライザー。MsgPack-Cliの10倍高速で、他のC#シリアライザを凌駕します。また、MessagePack for C#は、非常に高速な圧縮アルゴリズムであるLZ4圧縮をビルトインでサポートしています。特にゲーム、分散コンピューティング、マイクロサービス、データキャッシュなどのアプリケーションでは、パフォーマンスが重要です。

GitHub - MessagePack-CSharp/MessagePack-CSharp: Extremely Fast MessagePack Serializer for C#(.NET, .NET Core, Unity, Xamarin). / msgpack.org[C#]


ちなみにMessagePack自体の説明は以下の公式サイトに掲載されています。

MessagePackは、効率の良いバイナリ形式のオブジェクト・シリアライズ フォーマットです。JSONの置き換えとして使うことができ、様々なプログラミング言語をまたいでデータを交換することが可能です。しかも、JSONよりも速くてコンパクトです。例えば、小さな整数値はたった1バイト、短い文字列は文字列自体の長さ+1バイトでシリアライズできます。

MessagePack: JSONをもっと速く、小さく。

MessagePackの仕様
github.com

環境

  • Rider 2023.1.3
  • Console Application
  • .net7.0
  • C#11

インストール方法

Rider上のエクスプローラーから.csprojを右クリックし、NuGetパッケージの管理を選択します。

NuGetパッケージの管理

あとはMessagePackと検索して右上の+ボタンを押せばインストール完了です。ちなみに現在(2024/1/3)の最新はv2.6.100-alphaでしたが、この記事では2.5.140を利用しています。

MessagePackのインストール

使い方

シリアライズ・デシリアライズ対象に対して以下の操作を行います。

  1. クラスまたは構造体に対して[MessagePackObject]を付与する
  2. シリアライズ対象のメンバ(フィールド+プロパティ)に[Key]を付与する
[MessagePackObject]
public class Person
{
    // 一意のインデックス(もしくは文字列)をKeyに指定する
    [Key(0)]
    public int Age { get; set; }

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

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

    // シリアライズされるべきではないフィールド・プロパティは[IgnoreMember]を付与する
    [IgnoreMember]
    public string FullName => FirstName + LastName;
}

シリアライズ・デシリアライズを行う際はMessagePackSerializer.SerializeMessagePackSerializer.Deserializeを利用します。

public static void Main()
{
    var person = new Person
    {
        Age = 20,
        FirstName = "John",
        LastName = "Doe"
    };

    // シリアライズ
    byte[] bytes = MessagePackSerializer.Serialize(person);
        
    // 中身(バイナリ) : 93 14 A4 4A 6F 68 6E A3 44 6F 65
    Console.WriteLine(string.Join(" ", bytes.Select(x => x.ToString("X"))));
        
    // Jsonへの変換も可能
    // [20,"John","Doe"]
    var json = MessagePackSerializer.ConvertToJson(bytes);
    Console.WriteLine(json);
        
    // デシリアライズ
    Person newPerson = MessagePackSerializer.Deserialize<Person>(bytes);

    // 20
    Console.WriteLine(newPerson.Age);
        
    // John
    Console.WriteLine(newPerson.FirstName);

    // Doe
    Console.WriteLine(newPerson.LastName);
}

めちゃくちゃ簡単ですね。またMessagePackSerializer.ConvertToJsonMessagePackSerializer.ConvertFromJsonを利用する事でJsonとの相互変換も可能になります。

Keyのインデックスについて

ちなみに先ほどのサンプルでは[Key]のインデックスを連番にしていましたが、以下のように連番でなくするとシリアライズ結果も変わります。

[MessagePackObject]
public class MyClass
{
    // 一意のインデックス(もしくは文字列)をKeyに指定する
    [Key(0)]
    public int Age { get; set; }

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

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

    // シリアライズされるべきではないフィールド・プロパティは[IgnoreMember]を付与する
    [IgnoreMember]
    public string FullName => FirstName + LastName;
}
// 連番のときのシリアライズ結果 (バイナリと対応するJson)
93 14 A4 4A 6F 68 6E A3 44 6F 65
[20,"John","Doe"]

// 上記のシリアライズ結果 (バイナリと対応するJson)
9B 14 A4 4A 6F 68 6E C0 C0 C0 C0 C0 C0 C0 C0 A3 44 6F 65
[20,"John",null,null,null,null,null,null,null,null,"Doe"]

この後紹介しますが、[Key]にインデックスを指定すると配列でシリアライズされます。

配列かマップ(辞書)か

[Key]にインデックスを指定すると配列の形になりますが、文字列を指定するとマップ(辞書)になります。

[MessagePackObject]
public class Person
{
    [Key("Age")]
    public int Age { get; set; }

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

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

    [IgnoreMember] public string FullName => FirstName + LastName;
}
// Keyに連番のインデックスを指定したときのシリアライズ結果(バイナリ + Json)
93 14 A4 4A 6F 68 6E A3 44 6F 65
[20,"John","Doe"]

// Keyに文字列を指定したときのシリアライズ結果(バイナリ + Json)
83 A3 41 67 65 14 A9 46 69 72 73 74 4E 61 6D 65 A4 4A 6F 68 6E A8 4C 61 73 74 4E 61 6D 65 A3 44 6F 65
{"Age":20,"FirstName":"John","LastName":"Doe"}

パフォーマンスとバイナリサイズの観点から言うと配列に軍配があがりますが、汎用性の観点から言うとマップに軍配があがるでしょう。これは適材適所ですね。


また例外として[MessagePackObject(keyAsPropertyName: true)]を利用すると、明示的に[Key]を指定する必要なくマップで表現されるようになります。

[MessagePackObject(keyAsPropertyName:true)]
public class Person
{
    public int Age { get; set; }

    public string FirstName { get; set; }

    public string LastName { get; set; }

    [IgnoreMember] public string FullName => FirstName + LastName;
}
// シリアライズ結果(バイナリ + Json)
83 A3 41 67 65 14 A9 46 69 72 73 74 4E 61 6D 65 A4 4A 6F 68 6E A8 4C 61 73 74 4E 61 6D 65 A3 44 6F 65
{"Age":20,"FirstName":"John","LastName":"Doe"}

シリアライズの前・デシリアライズの後に処理を挟む

IMessagePackSerializationCallbackReceiverを実装することで、シリアライズの前・デシリアライズの後に処理を挟む事ができます。

[MessagePackObject]
public class Person : IMessagePackSerializationCallbackReceiver
{
    [Key(0)]
    public int Age { get; set; }

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

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

    [IgnoreMember] public string FullName => FirstName + LastName;
    
    public void OnBeforeSerialize()
    {
        Console.WriteLine("OnBeforeSerialize");
    }

    public void OnAfterDeserialize()
    {
        Console.WriteLine("OnAfterDeserialize");
    }
}

さいごに

今回は基本的な使い方しか紹介していないので、詳細はMessagePack for C#のReadmeを参照してください。

github.com