C#百万オブジェクトシリアル化深剖析:ネットワーク転送における速度とサイズの完璧なバランスを実現する方法

C#百万オブジェクトシリアル化深剖析:ネットワーク転送における速度とサイズの完璧なバランスを実現する方法

ネットワーク通信において、データシリアル化はオブジェクトの状態を保存可能または転送可能な形式に変換するプロセスであり、これはTCPネットワーク転送にとって特に重要です。プロジェクトで数十万件のデータを転送する必要がある場合、従来のJSONシリアル化方式では冗長なフィールド名と文字列形式のため、バイナリパケットのサイズが大きくなり、シリアル化と逆シリアル化の効率が低下します。これらの問題を解決するために、より効率的なシリアル化方法を採用してパケットサイズを削減し、処理速度を向上させることを検討しています。

最終更新 2023/12/11 22:29
沙漠尽头的狼
読了目安 18 分
カテゴリ
.NET
タグ
.NET C# 最適化

目次

  1. 本記事の背景
  2. テストデータの構築
  3. 方式の比較
    • Jsonシリアライズ
    • カスタムバイナリシリアライズ
    • BinaryWriter/BinaryReader
    • ProtoBuf
    • MessagePack
    • 方式分析
    • ベンチマークテスト
  4. まとめ

1. 本記事の背景

皆さんこんにちは、沙漠尽头的狼です。

ネットワーク通信において、データシリアライズはオブジェクトの状態を保存可能または転送可能な形式に変換するプロセスであり、TCPネットワーク転送において特に重要です。プロジェクトで数十万件のデータを転送する必要がある場合、従来のJsonシリアライズ方式では、冗長なフィールド名や文字列形式によりバイナリパッケージのサイズが大きくなり、シリアライズとデシリアライズの効率も低下します。これらの問題を解決するため、より効率的なシリアライズ手法を検討し、パッケージサイズを削減し処理速度を向上させることを目的としています。本記事では、カスタムバイナリシリアライズ、BinaryWriter/BinaryReaderMessagePackProtoBufの4つのシリアライズ手法を比較し、そのパフォーマンスを比較することで、現時点でのベストプラクティスガイドを提供します。

2. テストデータの構築

C#コンソールアプリを作成し、OrganizationMember の2つのクラスを追加します。クラスには基本データ型と List<T> が含まれ、その他の配列やディクショナリは必要に応じて拡張できます。

public class Organization
{
    public int Id { get; set; }

    public string[]? Tags { get; set; }

    public List<Member>? Members { get; set; }
}

public class Member
{
    public int Id { get; set; }

    public string? Name { get; set; }

    public string? Description { get; set; }

    public string? Address { get; set; }

    public double Value { get; set; }

    public long UpdateTime { get; set; }
}

100万件のテストデータを作成します(タイトルと呼応させるため)。

public class BenchmarkTest
{
    /// <summary>
    /// テストデータ数
    /// </summary>
    private const int DataCount = 1000000;

    private static readonly Random RandomShared = new(DateTime.Now.Millisecond);

    /// <summary>
    /// テストデータ
    /// </summary>
    private static readonly Organization TestData = new()
    {
        Id = 1,
        Tags = Enumerable.Range(0, 5).Select(index => $"テストタグ{index}").ToArray(),
        Members = Enumerable.Range(0, DataCount).Select(index => new Member()
        {
            Id = index,
            Name = $"テスト名前{index}",
            Description = $"テスト説明{RandomShared.Next(1, int.MaxValue)}",
            Address = $"テスト住所{RandomShared.Next(1, int.MaxValue)}",
            Value = RandomShared.Next(1, int.MaxValue),
            UpdateTime = ((DateTimeOffset)DateTime.Now).ToUnixTimeMilliseconds()
        }).ToList()
    };
}

3. 方式の比較

まずシリアライズインターフェース ISerializeHelper を作成し、各種シリアライズプロバイダーがこれを実装します。

public interface ISerializeHelper
{
    byte[] Serialize(Organization data);

    Organization? Deserialize(byte[] buffer);
}

次に BenchmarkTest クラスを作成し、シリアライズプロバイダーを実行する RunSerialize メソッドを追加します。このメソッド内でプロバイダーのシリアライズおよびデシリアライズメソッドを順に呼び出し、統一して処理時間とパッケージサイズを出力・統計します。

public class BenchmarkTest
{
    // ... 前述のコード省略
    private static void RunSerialize(ISerializeHelper helper)
    {
        Stopwatch sw = Stopwatch.StartNew();

        var buffer = helper.Serialize(TestData);

        sw.Stop();
        Log($"{helper.GetType().Name} Serialize {sw.ElapsedMilliseconds}ms {buffer.Length}byte");

        sw.Restart();

        var data = helper.Deserialize(buffer);

        sw.Stop();

        Log($"{helper.GetType().Name} Deserialize {sw.ElapsedMilliseconds}ms {data?.Members?.Count}件");
    }

    private static void Log(string log)
    {
        Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss fff}: {log}");
    }
}

3.1. Jsonシリアライズ

まずJsonシリアライズをテストします。System.Text.Json パッケージをインストールします。

<PackageReference Include="System.Text.Json" Version="8.0.0" />

JsonSerializeHelper プロバイダーを作成し、ISerializeHelper インターフェースを実装します。

using ByteTest.Core.Models;
using System.Text.Json;

namespace ByteTest.Core.Helpers;

public class JsonSerializeHelper : ISerializeHelper
{
    public byte[] Serialize(Organization data)
    {
        return JsonSerializer.SerializeToUtf8Bytes(data);
    }

    public Organization? Deserialize(byte[] buffer)
    {
        var data = JsonSerializer.Deserialize<Organization>(buffer);
        return data;
    }
}

JsonSerializer.SerializeToUtf8Bytes メソッドは直接バイトデータを生成するため、JSONデータをファイルやネットワークストリームに書き込む必要がある場合に便利です(文字列からバイトへの変換が不要)。また、不要な文字列割り当てを回避するためパフォーマンスが向上しメモリ負荷が軽減されます。

次に BenchmarkTest クラスにテストメソッド Test を追加します。

public static void Test()
{
    RunSerialize(new JsonByteSerializeHelper());
}

Program で Test() を呼び出します。

BenchmarkTest.Test();

プログラム出力:

2023-12-10 22:28:24 880: JsonByteSerializeHelper Serialize 2813ms 196227181byte
2023-12-10 22:28:26 858: JsonByteSerializeHelper Deserialize 1964ms 1000000件

Jsonシリアライズで100万件のデータに 2.8s、パッケージサイズ 187.14MB。大きく、遅いです。

3.2. カスタムバイナリシリアライズ

次にカスタムバイナリシリアライズをテストします。これは私が以前よく使用していた方法ですが、今見ると冗長です。まずデータパケットのフィールド仕様を定義します。

データ型 バイナリ長 説明
数値型 (short\ushort\int\uint\long\ulong\double 等) 2\2\4\4\8\8\8 基本数値型は固定長
string 4+n int型4バイトで文字列のバイナリ長を表し、nは文字列のバイナリ配列の実長
T[]`List` 4+n 配列やリストは文字列と同様にint型4バイトでバイナリ長を表し、nは配列/リストのバイナリ配列の実長

CustomSerializeHelper を追加し、ISerializeHelper を実装します。

using ByteTest.Core.Models;

namespace ByteTest.Core.Helpers;

public class CustomSerializeHelper : ISerializeHelper
{
    public byte[] Serialize(Organization data)
    {
        // 1. Idの計算
        var idBuffer = BitConverter.GetBytes(data.Id);

        // 2. Tag配列の計算
        var tagBuffer = GetBytes(data.Tags);

        // 3. Membersの計算
        var membersBuffer = GetBytes(data.Members);

        return GetBytes(new[] { idBuffer, tagBuffer, membersBuffer });
    }

    public Organization? Deserialize(byte[] buffer)
    {
        var data = new Organization();
        var index = 0;

        data.Id = BitConverter.ToInt32(buffer, index);
        index += sizeof(int);

        data.Tags = GetTags(buffer, ref index);
        data.Members = GetMembers(buffer, ref index);
        return data;
    }

    /// <summary>
    /// 文字列リストのbyte[]を取得
    /// </summary>
    private byte[] GetBytes(string[]? data)
    {
        var dataCount = data?.Length ?? 0;
        var dataCountBuffer = BitConverter.GetBytes(dataCount);

        if (dataCount <= 0)
        {
            return dataCountBuffer;
        }

        var dataValueBuffers = data!.Select(item => ByteHelper.GetBytes(item)!).ToArray();
        var dataValueBuffer = GetBytes(dataValueBuffers);
        return GetBytes(new[] { dataCountBuffer, dataValueBuffer });
    }

    /// <summary>
    /// メンバーリストのbyte[]を取得
    /// </summary>
    private byte[] GetBytes(List<Member>? data)
    {
        var dataCount = data?.Count ?? 0;
        var dataCountBuffer = BitConverter.GetBytes(dataCount);

        if (dataCount <= 0)
        {
            return dataCountBuffer;
        }

        var dataValueBuffers = data!.Select(item =>
        {
            var idBuffer = BitConverter.GetBytes(item.Id);
            var nameBuffer = ByteHelper.GetBytes(item.Name);
            var descriptionBuffer = ByteHelper.GetBytes(item.Description);
            var addressBuffer = ByteHelper.GetBytes(item.Address);
            var valueBuffer = BitConverter.GetBytes(item.Value);
            var updateTimeBuffer = BitConverter.GetBytes(item.UpdateTime);

            var buffer = GetBytes(new byte[][]
                { idBuffer, nameBuffer, descriptionBuffer, addressBuffer, valueBuffer, updateTimeBuffer });
            return buffer;
        }).ToArray();
        var dataValueBuffer = GetBytes(dataValueBuffers);
        return GetBytes(new[] { dataCountBuffer, dataValueBuffer });
    }

    private byte[] GetBytes(byte[][] data)
    {
        var dataBufferLen = data.Sum(itemBuffer => itemBuffer.Length);
        var dataBuffer = new byte[dataBufferLen];
        var dataIndex = 0;
        for (var i = 0; i < data.Length; i++)
        {
            var itemBuffer = data[i];
            Array.Copy(itemBuffer, 0, dataBuffer, dataIndex, itemBuffer.Length);

            dataIndex += itemBuffer.Length;
        }

        return dataBuffer;
    }

    private string[]? GetTags(byte[] buffer, ref int index)
    {
        var count = BitConverter.ToInt32(buffer, index);
        index += sizeof(int);

        if (count <= 0)
        {
            return default;
        }

        var data = new string[count];
        for (var i = 0; i < count; i++)
        {
            data[i] = GetString(buffer, ref index);
        }

        return data;
    }

    private List<Member>? GetMembers(byte[] buffer, ref int index)
    {
        var count = BitConverter.ToInt32(buffer, index);
        index += sizeof(int);

        if (count <= 0)
        {
            return default;
        }

        var data = new List<Member>();
        for (var i = 0; i < count; i++)
        {
            var people = new Member();

            people.Id = BitConverter.ToInt32(buffer, index);
            index += sizeof(int);

            people.Name = GetString(buffer, ref index);

            people.Description = GetString(buffer, ref index);

            people.Address = GetString(buffer, ref index);

            people.Value = BitConverter.ToDouble(buffer, index);
            index += sizeof(double);

            people.UpdateTime = BitConverter.ToInt64(buffer, index);
            index += sizeof(long);

            data.Add(people);
        }

        return data;
    }

    private string GetString(byte[] buffer, ref int index)
    {
        var count = BitConverter.ToInt32(buffer, index);
        index += sizeof(int);

        if (count <= 0)
        {
            return string.Empty;
        }

        var data = ByteHelper.DefaultEncoding.GetString(buffer, index, count);
        index += count;
        return data;
    }
}

コードは多いですが、概要をつかめば十分です。主に BitConverter.GetBytesBitConverter.ToXXX を使用して基本データ型の byte[] を取得・設定し、複数の byte[] を結合する際はデータが小さい場合は Array.Copy、大きい場合は Buffer.BlockCopy が推奨されます。

ヘルパークラス ByteHelper を使用して文字列の byte[] を計算します。

public static class ByteHelper
{
    public static Encoding DefaultEncoding = Encoding.UTF8;

    /// <summary>
    /// 文字列のバイナリデータを取得:文字列バイナリデータ = 4バイトint(文字列の実長) + nバイト(文字列の実際の値)
    /// </summary>
    public static byte[] GetBytes(string? str)
    {
        if (string.IsNullOrEmpty(str))
        {
            return BitConverter.GetBytes(0);
        }

        var strValueBuffer = DefaultEncoding.GetBytes(str);
        var strValueLen = strValueBuffer.Length;
        var strValueLenBuffer = BitConverter.GetBytes(strValueLen);

        var strBufferLen = sizeof(int) + strValueLen;
        var strBuffer = new byte[strBufferLen];

        var index = 0;
        Array.Copy(strValueLenBuffer, 0, strBuffer, index, sizeof(int));
        index += sizeof(int);

        Array.Copy(strValueBuffer, 0, strBuffer, index, strValueLen);

        return strBuffer;
    }
}

Test メソッドを修正して、CustomSerializeHelper を追加します。

public static void Test()
{
    var serializeHelpers = new List<ISerializeHelper>
    {
        new JsonSerializeHelper(),
        new CustomSerializeHelper()
    };

    serializeHelpers.ForEach(RunSerialize);
}

プログラム出力:

2023-12-10 22:45:14 701: JsonSerializeHelper Serialize 2774ms 196225588byte
2023-12-10 22:45:16 613: JsonSerializeHelper Deserialize 1898ms 1000000件
2023-12-10 22:45:17 414: CustomSerializeHelper Serialize 801ms 92854209byte
2023-12-10 22:45:18 072: CustomSerializeHelper Deserialize 657ms 1000000件

最適化の効果が確認できました。カスタムバイナリシリアライズでは約1億バイト(約100MB)削減、シリアライズ(パッキング)時間は約2秒短縮、デシリアライズ(アンパッキング)時間は約1秒短縮されました。

3.3. BinaryWriter/BinaryReader

BinaryWriterBinaryReader クラスは、バイナリ形式でデータを書き込み・読み取りするためのクラスです。int、float、double、string などの基本データ型のバイナリ表現を書き込んだり読み取ったりする一連のメソッドを提供します。通常ファイルストリーム(FileStream)と共に使用されますが、他の種類のストリーム(MemoryStream など)とも併用可能です。

カスタム方式は完全手動で、バイト配列のコピーや変換が必要で原始的です。BinaryWriter/BinaryReader を使用したシリアライズはバイナリシリアライズの標準的な使い方と言えるでしょう。

BinarySerializeHelper クラスを作成し、ISerializeHelper インターフェースを実装します。

public class BinarySerializeHelper : ISerializeHelper
{
    public byte[] Serialize(Organization data)
    {
        using var stream = new MemoryStream();
        using var writer = new BinaryWriter(stream, ByteHelper.DefaultEncoding);
        writer.Write(data.Id);
        Write(writer, data.Tags);
        Write(writer, data.Members);

        return stream.ToArray();
    }

    public Organization Deserialize(byte[] buffer)
    {
        var data = new Organization();
        using var stream = new MemoryStream(buffer);
        using var reader = new BinaryReader(stream, ByteHelper.DefaultEncoding);
        data.Id = reader.ReadInt32();
        data.Tags = ReadStringList(reader);
        data.Members = ReadPeopleList(reader);

        return data;
    }

    private static void Write(BinaryWriter writer, string[]? data)
    {
        var count = data?.Length ?? 0;
        writer.Write(count);
        if (count <= 0)
        {
            return;
        }

        foreach (var item in data!)
        {
            writer.Write(item);
        }
    }

    private static void Write(BinaryWriter writer, List<Member>? data)
    {
        var count = data?.Count ?? 0;
        writer.Write(count);
        if (count > 0)
        {
            foreach (var item in data)
            {
                writer.Write(item.Id);
                writer.Write(item.Name ?? string.Empty);
                writer.Write(item.Description ?? string.Empty);
                writer.Write(item.Address ?? string.Empty);
                writer.Write(item.Value);
                writer.Write(item.UpdateTime);
            }
        }
    }

    private static string[]? ReadStringList(BinaryReader reader)
    {
        var count = reader.ReadInt32();
        if (count <= 0)
        {
            return default;
        }

        var values = new string[count];
        for (int i = 0; i < count; i++)
        {
            values[i] = reader.ReadString();
        }

        return values;
    }

    private static List<Member>? ReadPeopleList(BinaryReader reader)
    {
        var count = reader.ReadInt32();
        if (count <= 0)
        {
            return default;
        }

        var values = new List<Member>();
        for (int i = 0; i < count; i++)
        {
            var item = new Member();
            item.Id = reader.ReadInt32();
            item.Name = reader.ReadString();
            item.Description = reader.ReadString();
            item.Address = reader.ReadString();
            item.Value = reader.ReadDouble();
            item.UpdateTime = reader.ReadInt64();

            values.Add(item);
        }

        return values;
    }
}

コード量は同じくらいです。リストのシリアライズ/デシリアライズは、カスタムバイナリシリアライズのときからメソッドにまとめ、リフレクションで汎用リストに対応させるべきでしたが、この節でも同様です。これ以上は改良せず、BenchmarkTest クラスの Test メソッドに BinarySerializeHelper を追加してプログラムを再実行します。

2023-12-10 22:52:56 986: JsonSerializeHelper Serialize 2715ms 196225584byte
2023-12-10 22:52:58 910: JsonSerializeHelper Deserialize 1910ms 1000000件
2023-12-10 22:52:59 730: CustomSerializeHelper Serialize 819ms 92853722byte
2023-12-10 22:53:00 389: CustomSerializeHelper Deserialize 659ms 1000000件
2023-12-10 22:53:00 660: BinarySerializeHelper Serialize 269ms 83853707byte
2023-12-10 22:53:01 466: BinarySerializeHelper Deserialize 806ms 1000000件

BinaryWriter/BinaryReader を使用すると、パッケージサイズはカスタムバイナリシリアライズよりも約8.5MB小さくなり、シリアライズ速度も約0.5秒速くなりました。デシリアライズはやや遅くなりましたが良好です。

3.4. ProtoBuf

Google の Protocol Buffers をご存知ですか?

この節では protobuf-net ライブラリを紹介します。これは .NET 環境で Google Protocol Buffers 形式のデータシリアライズをサポートするライブラリです。Protocol Buffers は軽量で効率的な構造化データシリアライズ機構であり、サービス間やアプリケーション間の通信、データストレージなどに使用されます。

protobuf-net パッケージをインストールします。

<PackageReference Include="protobuf-net" Version="3.2.30" />

テスト対象のクラスにクラスシリアライズ属性 [ProtoContract] とプロパティシリアライズ属性 [ProtoMember(シリアライズ順序)] を追加します。

[ProtoContract]
public class Organization
{
    [ProtoMember(1)] public int Id { get; set; }

    [ProtoMember(2)] public string[]? Tags { get; set; }

    [ProtoMember(3)] public List<Member>? Members { get; set; }
}

[ProtoContract]
public class Member
{
    [ProtoMember(1)] public int Id { get; set; }

    [ProtoMember(2)] public string? Name { get; set; }

    [ProtoMember(3)] public string? Description { get; set; }

    [ProtoMember(4)] public string? Address { get; set; }

    [ProtoMember(5)] public double Value { get; set; }

    [ProtoMember(6)] public long UpdateTime { get; set; }
}

ProtoBufSerializeHelper クラスを作成し、ISerializeHelper を実装します。

using ByteTest.Core.Models;
using ProtoBuf;

namespace ByteTest.Core.Helpers;

public class ProtoBufSerializeHelper : ISerializeHelper
{
    public byte[] Serialize(Organization data)
    {
        using var stream = new MemoryStream();
        Serializer.Serialize(stream, data);
        return stream.ToArray();
    }

    public Organization? Deserialize(byte[] buffer)
    {
        using var stream = new MemoryStream(buffer);
        return Serializer.Deserialize<Organization>(stream);
    }
}

さらに、上記の Test メソッドに ProtoBufSerializeHelper を追加します。

2023-12-10 23:01:17 478: JsonSerializeHelper Serialize 2767ms 196225803byte
2023-12-10 23:01:19 556: JsonSerializeHelper Deserialize 2064ms 1000000件
2023-12-10 23:01:20 350: CustomSerializeHelper Serialize 793ms 92853782byte
2023-12-10 23:01:21 012: CustomSerializeHelper Deserialize 662ms 1000000件
2023-12-10 23:01:21 271: BinarySerializeHelper Serialize 258ms 83853767byte
2023-12-10 23:01:22 086: BinarySerializeHelper Deserialize 815ms 1000000件
2023-12-10 23:01:22 629: ProtoBufSerializeHelper Serialize 542ms 88837248byte
2023-12-10 23:01:23 688: ProtoBufSerializeHelper Deserialize 1058ms 1000000件

あれ?ProtoBuf は BinaryWriter よりもパッケージサイズが大きく、しかも遅い。使い方が間違っているのでしょうか?もしかすると圧縮アルゴリズムを追加する必要があるかもしれません。これは今後の課題とし、最後の MessagePack を続けて見ていきます。使用上の問題があればご指摘ください。

3.5. MessagePack

最後のシリアライズパッケージ MessagePack を紹介します。これは効率的なバイナリシリアライズ形式で、異なるシステム間でデータを高速かつコンパクトに転送できます。JSON に似ていますが、より小さく、より速く、より省スペースです。

MessagePack パッケージをインストールする必要があります。

<PackageReference Include="MessagePack" Version="2.6.100-alpha" />

MessagePackSerializeHelper クラスを作成し、ISerializeHelper を実装します。

using ByteTest.Core.Models;
using MessagePack;

namespace ByteTest.Core.Helpers;

public class MessagePackSerializeHelper : ISerializeHelper
{
    // この方法はクラスとフィールドに属性を追加する必要がありやや手間ですが、圧縮オプションを追加するとパッケージサイズ・パッキング・アンパッキング速度が向上します
    //readonly MessagePackSerializerOptions _options = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray);

    // この方法は転送オブジェクトに属性を追加する必要がなく、圧縮オプションも追加可能
    readonly MessagePackSerializerOptions _options =
        MessagePack.Resolvers.ContractlessStandardResolver.Options.WithCompression(MessagePackCompression
            .Lz4BlockArray);

    public byte[] Serialize(Organization data)
    {
        return MessagePackSerializer.Serialize(data, _options);
    }

    public Organization? Deserialize(byte[] buffer)
    {
        return MessagePackSerializer.Deserialize<Organization>(buffer, _options);
    }
}

コメントアウトされたオプションのほうが優れている可能性があります(圧縮率が高く、サイズが小さくなる)。後でテストを追加します。

最後に Test メソッドを修正します。

   public static void Test(List<ISerializeHelper>? moreHelpers = null)
    {
        var serializeHelpers = new List<ISerializeHelper>
        {
            new JsonSerializeHelper(),
            new CustomSerializeHelper(),
            new BinarySerializeHelper(),
            new ProtoBufSerializeHelper(),
            new MessagePackSerializeHelper(),
        };
        if (moreHelpers?.Count() > 0)
        {
            serializeHelpers.AddRange(moreHelpers);
        }

        serializeHelpers.ForEach(RunSerialize);
    }

プログラムを実行します。

2023-12-11 21:34:47 782: JsonSerializeHelper Serialize 2456ms 196225500byte
2023-12-11 21:34:51 215: JsonSerializeHelper Deserialize 3430ms 1000000件
2023-12-11 21:34:52 186: CustomSerializeHelper Serialize 970ms 92853911byte
2023-12-11 21:34:52 711: CustomSerializeHelper Deserialize 526ms 1000000件
2023-12-11 21:34:53 734: BinarySerializeHelper Serialize 1022ms 83853896byte
2023-12-11 21:34:54 354: BinarySerializeHelper Deserialize 620ms 1000000件
2023-12-11 21:34:55 170: ProtoBufSerializeHelper Serialize 815ms 88837377byte
2023-12-11 21:34:56 205: ProtoBufSerializeHelper Deserialize 1035ms 1000000件
2023-12-11 21:34:57 123: MessagePackSerializeHelper Serialize 917ms 43583878byte
2023-12-11 21:34:58 527: MessagePackSerializeHelper Deserialize 1403ms 1000000件

次にコメントアウトしたオプションに切り替えます。コードを以下のように修正します。

using ByteTest.Core.Models;
using MessagePack;

namespace ByteTest.Core.Helpers;

public class MessagePackSerializeHelper : ISerializeHelper
{
    // この方法はクラスとフィールドに属性を追加する必要がありやや手間ですが、圧縮オプションを追加するとパッケージサイズ・パッキング・アンパッキング速度が向上します
    readonly MessagePackSerializerOptions _options = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray);

    // この方法は転送オブジェクトに属性を追加する必要がなく、圧縮オプションも追加可能
    //readonly MessagePackSerializerOptions _options =
    //    MessagePack.Resolvers.ContractlessStandardResolver.Options.WithCompression(MessagePackCompression
    //        .Lz4BlockArray);

    public byte[] Serialize(Organization data)
    {
        return MessagePackSerializer.Serialize(data, _options);
    }

    public Organization? Deserialize(byte[] buffer)
    {
        return MessagePackSerializer.Deserialize<Organization>(buffer, _options);
    }
}

さらに転送クラスに属性 [MessagePackObject] を追加し、シリアライズするプロパティには [Key(シリアライズインデックス)] を追加します。

using MessagePack;
using ProtoBuf;

namespace ByteTest.Core.Models;

[ProtoContract]
[MessagePackObject]
public class Organization
{
    [ProtoMember(1)] [Key(0)] public int Id { get; set; }

    [ProtoMember(2)] [Key(1)] public string[]? Tags { get; set; }

    [ProtoMember(3)] [Key(2)] public List<Member>? Members { get; set; }
}

[ProtoContract]
[MessagePackObject]
public class Member
{
    [ProtoMember(1)] [Key(0)] public int Id { get; set; }

    [ProtoMember(2)] [Key(1)] public string? Name { get; set; }

    [ProtoMember(3)] [Key(2)] public string? Description { get; set; }

    [ProtoMember(4)] [Key(3)] public string? Address { get; set; }

    [ProtoMember(5)] [Key(4)] public double Value { get; set; }

    [ProtoMember(6)] [Key(5)] public long UpdateTime { get; set; }
}

プログラム出力:

2023-12-11 21:49:34 153: JsonSerializeHelper Serialize 2383ms 196226140byte
2023-12-11 21:49:37 736: JsonSerializeHelper Deserialize 3581ms 1000000件
2023-12-11 21:49:38 720: CustomSerializeHelper Serialize 983ms 92854251byte
2023-12-11 21:49:39 250: CustomSerializeHelper Deserialize 530ms 1000000件
2023-12-11 21:49:40 273: BinarySerializeHelper Serialize 1023ms 83854236byte
2023-12-11 21:49:40 907: BinarySerializeHelper Deserialize 632ms 1000000件
2023-12-11 21:49:41 660: ProtoBufSerializeHelper Serialize 754ms 88837717byte
2023-12-11 21:49:42 676: ProtoBufSerializeHelper Deserialize 1014ms 1000000件
2023-12-11 21:49:43 357: MessagePackSerializeHelper Serialize 681ms 38706475byte
2023-12-11 21:49:44 344: MessagePackSerializeHelper Deserialize 986ms 1000000件

先ほどのオプションの MessagePack 出力を再掲します。

2023-12-11 21:34:57 123: MessagePackSerializeHelper Serialize 917ms 43583878byte
2023-12-11 21:34:58 527: MessagePackSerializeHelper Deserialize 1403ms 1000000件

最初のオプション(属性付き+Lz4BlockArray)のほうがパッケージサイズと速度ともに優れていることがわかります。

方式分析

100万件のテストデータ、5つのシリアライズ方式の統計データを表にまとめます。

シリアライズ方式 Json カスタムバイナリ Binary ProtoBuf MessagePack
シリアライズ後サイズ(MB) 187.13 88.55 79.97 84.72 36.91
シリアライズ時間(ms) 2383 983 1023 754 681
デシリアライズ時間(ms) 3581 530 632 1014 986

上の表から、シリアライズ後は MessagePack のパッケージが最小で 36.91MB、Json が最大で 187.13MB、残りの3方式は約80MBです。シリアライズ効率は MessagePack が最も良く、デシリアライズ効率はなんとカスタムバイナリ方式が最も速い結果になりました。Json 方式を除外してもう一度実行します。

2023-12-11 21:55:47 813: CustomSerializeHelper Serialize 1263ms 92854890byte
2023-12-11 21:55:48 804: CustomSerializeHelper Deserialize 989ms 1000000件
2023-12-11 21:55:49 215: BinarySerializeHelper Serialize 410ms 83854875byte
2023-12-11 21:55:50 081: BinarySerializeHelper Deserialize 866ms 1000000件
2023-12-11 21:55:50 726: ProtoBufSerializeHelper Serialize 644ms 88838356byte
2023-12-11 21:55:51 725: ProtoBufSerializeHelper Deserialize 999ms 1000000件
2023-12-11 21:55:52 426: MessagePackSerializeHelper Serialize 701ms 38701799byte
2023-12-11 21:55:53 427: MessagePackSerializeHelper Deserialize 999ms 1000000件
シリアライズ方式 カスタムバイナリ Binary ProtoBuf MessagePack
シリアライズ後サイズ(MB) 88.55 79.97 84.72 36.91
シリアライズ時間(ms) 1263 410 644 701
デシリアライズ時間(ms) 989 866 999 999

パッケージサイズは変わらず、シリアライズは BinaryWriter が最速、デシリアライズも BinaryReader が最速です。テストデータは信頼できないため、ベンチマークテストを実施します。

ベンチマークテスト

ベンチマークテスト用に BenchmarkDotNet パッケージをインストールします。

<PackageReference Include="BenchmarkDotNet" Version="0.13.11" />

BenchmarkTest クラスを修正します。

using BenchmarkDotNet.Attributes;
using ByteTest.Core.Helpers;
using ByteTest.Core.Models;
using MessagePack;
using System.Diagnostics;

namespace ByteTest.Core.Test;

[MemoryDiagnoser, RankColumn]
public class BenchmarkTest
{
    // テストデータコード省略

    //[Benchmark]
    //public void JsonByteSerialize()
    //{
    //    RunSerialize(new JsonSerializeHelper());
    //}

    [Benchmark]
    public void CustomSerialize()
    {
        RunSerialize(new CustomSerializeHelper());
    }

    [Benchmark]
    public void BinarySerialize()
    {
        RunSerialize(new BinarySerializeHelper());
    }

    [Benchmark]
    public void ProtoBufPackSerialize()
    {
        RunSerialize(new ProtoBufSerializeHelper());
    }

    [Benchmark]
    public void MessagePackSerialize()
    {
        RunSerialize(new MessagePackSerializeHelper());
    }
	
    // 統計関連コード省略
}

Program.cs を修正します。

using BenchmarkDotNet.Running;
using ByteTest.Core.Test;

// ベンチマークテスト実行
BenchmarkRunner.Run<BenchmarkTest>();

// 通常テスト
//BenchmarkTest.Test();

テスト結果は以下の通りです。

Method Mean Error StdDev Rank Gen0 Gen1 Gen2 Allocated
CustomSerialize 1.702 s 0.0120 s 0.0094 s 4 156000.0000 45000.0000 2000.0000 1230.31 MB
BinarySerialize 1.100 s 0.0101 s 0.0084 s 1 38000.0000 14000.0000 2000.0000 566.16 MB
ProtoBufPackSerialize 1.337 s 0.0190 s 0.0159 s 2 38000.0000 14000.0000 2000.0000 581.66 MB
MessagePackSerialize 1.544 s 0.0222 s 0.0197 s 3 68000.0000 29000.0000 1000.0000 449.67 MB

大まかに見ると、サイズは MessagePack が優れており、ネットワーク転送におけるフラグメントが少なくなるため、ネットワークの往復時間が少なくなります。パッキングおよびアンパッキングの処理速度は、ネイティブの BinaryWriter と BinaryReader が優れています。

4. まとめ

総じて、データパケットサイズはネットワーク環境やデバイスの能力に合わせて適切に設定し、効率的なデータ転送を実現する必要があります。同時に、パッキングおよびアンパッキングの処理能力を高く保つことも、ネットワーク転送のパフォーマンスを維持する上で重要です。前者では MessagePack による圧縮、後者ではネイティブの BinaryWriter と BinaryReader を検討すると良いでしょう。

より良い方法があればコメントでお知らせください。また、このテストコードへの PR も歓迎します。リンク先: ByteTest

参考

さらに探索

関連読書

その他の記事
同じカテゴリ / 同じタグ 2026/04/22

各OSバージョンの.NETサポート状況(250707更新)

仮想マシンとテストマシンを使用して、各OSバージョンの.NETサポート状況を確認します。OSインストール後、対応するランタイムをインストールし、Stardustエージェントを実行できることを確認します(合格条件)。

続きを読む
同じカテゴリ / 同じタグ 2026/02/07

AOTの使用経験のまとめ

プロジェクト作成当初から、新機能を追加したり新しい構文を使用したりした場合には、すぐにAOT公開テストを実施するという良い習慣を身につけるべきです。

続きを読む