C#物件二進位序列化最佳化:位元欄位技術實現極限壓縮

C#物件二進位序列化最佳化:位元欄位技術實現極限壓縮

展示如何將C#物件轉換為二進位形式,並進行最佳化以減少網路傳輸中的資料封包大小。

最後更新 2024/1/22 上午12:33
沙漠尽头的狼
預計閱讀 18 分鐘
分類
.NET
標籤
.NET C# 二進位

1. 引言

在作業系統中,程序資訊對於系統監控和效能分析至關重要。假設我們需要開發一個監控程式,該程式能夠擷取目前作業系統的程序資訊,並將其高效率地傳輸到其他端(如伺服器端或監控端)。在這個過程中,如何將擷取到的程序物件轉換為二進位資料,並進行最佳化,以減小資料封包的大小,成為了一個關鍵問題。本文將透過逐步分析,探討如何使用位元欄位技術對 C# 物件進行二進位序列化最佳化。

作業系統程序資訊

首先,我們給出了一個程序物件的欄位定義範例。為了透過網路(TCP/UDP)傳輸該物件,我們需要將其轉換為二進位格式。在這個過程中,如何做到最小的資料封包大小是一個挑戰。

欄位名 說明 範例
PID 程序 ID 10565
Name 程序名稱 碼坊
Publisher 發行者 沙漠盡頭的狼
CommandLine 命令列 dotnet CodeWF.Tools.dll
CPU CPU(所有核心的總處理利用率) 2.3%
Memory 記憶體(程序佔用的實體記憶體) 0.1%
Disk 磁碟(所有實體驅動器的總利用率) 0.1 MB/秒
Network 網路(目前主要網路上的網路利用率 0 Mbps
GPU GPU(所有 GPU 引擎的最高利用率) 2.2%
GPUEngine GPU 引擎 GPU 0 - 3D
PowerUsage 電源使用情況(CPU、磁碟和 GPU 對功耗的影響)
PowerUsageTrend 電源使用情況趨勢(一段時間內 CPU、磁碟和 GPU 對功耗的影響) 非常低
Type 程序類型 應用
Status 程序狀態 效率模式

2. 最佳化過程

2.1. 程序物件定義與初步分析

我們根據欄位的範例值確定了每個欄位的資料型別。

欄位名 資料型別 說明 範例
PID int 程序 ID 10565
Name string? 程序名稱 碼坊
Publisher string? 發行者 沙漠盡頭的狼
CommandLine string? 命令列 dotnet CodeWF.Tools.dll
CPU string? CPU(所有核心的總處理利用率) 2.3%
Memory string? 記憶體(程序佔用的實體記憶體) 0.1%
Disk string? 磁碟(所有實體驅動器的總利用率) 0.1 MB/秒
Network string? 網路(目前主要網路上的網路利用率 0 Mbps
GPU string? GPU(所有 GPU 引擎的最高利用率) 2.2%
GPUEngine string? GPU 引擎 GPU 0 - 3D
PowerUsage string? 電源使用情況(CPU、磁碟和 GPU 對功耗的影響)
PowerUsageTrend string? 電源使用情況趨勢(一段時間內 CPU、磁碟和 GPU 對功耗的影響) 非常低
Type string? 程序類型 應用
Status string? 程序狀態 效率模式

建立一個 C# 類別 SystemProcess 表示程序資訊:

public class SystemProcess
{
    public int PID { get; set; }
    public string? Name { get; set; }
    public string? Publisher { get; set; }
    public string? CommandLine { get; set; }
    public string? CPU { get; set; }
    public string? Memory { get; set; }
    public string? Disk { get; set; }
    public string? Network { get; set; }
    public string? GPU { get; set; }
    public string? GPUEngine { get; set; }
    public string? PowerUsage { get; set; }
    public string? PowerUsageTrend { get; set; }
    public string? Type { get; set; }
    public string? Status { get; set; }
}

定義測試資料

private SystemProcess _codeWFObject = new SystemProcess()
{
    PID = 10565,
    Name = "碼坊",
    Publisher = "沙漠盡頭的狼",
    CommandLine = "dotnet CodeWF.Tools.dll",
    CPU = "2.3%",
    Memory = "0.1%",
    Disk = "0.1 MB/秒",
    Network = "0 Mbps",
    GPU = "2.2%",
    GPUEngine = "GPU 0 - 3D",
    PowerUsage = "低",
    PowerUsageTrend = "非常低",
    Type = "應用",
    Status = "效率模式"
};

2.2. 排除 Json 序列化

將物件轉為 Json 字串,這在 Web 開發是最常見的,因為簡潔,前後端都方便處理:

public class SysteProcessUnitTest
{
    private readonly ITestOutputHelper _testOutputHelper;

    private SystemProcess _codeWFObject // 前面已給出定義,這裡省略

    public SysteProcessUnitTest(ITestOutputHelper testOutputHelper)
    {
        _testOutputHelper = testOutputHelper;
    }

    /// <summary>
    /// Json序列化大小測試
    /// </summary>
    [Fact]
    public void Test_SerializeJsonData_Success()
    {
        var jsonData = JsonSerializer.Serialize(_codeWFObject);
        _testOutputHelper.WriteLine($"Json長度:{jsonData.Length}");

        var jsonDataBytes = Encoding.UTF8.GetBytes(jsonData);
        _testOutputHelper.WriteLine($"json二進位長度:{jsonDataBytes.Length}");
    }
}
標準輸出: 
Json長度:366
json二進位長度:366

儘管 Json 序列化在 Web 開發中非常流行,因為它簡潔且易於處理,但在 TCP/UDP 網路傳輸中,Json 序列化可能導致不必要的資料封包大小增加(冗餘的欄位名宣告)。因此,我們排除了 Json 序列化,並尋找其他更高效能的二進位序列化方法。

{
  "PID": 10565,
  "Name": "\u7801\u754C\u5DE5\u574A",
  "Publisher": "\u6C99\u6F20\u5C3D\u5934\u7684\u72FC",
  "CommandLine": "dotnet CodeWF.Tools.dll",
  "CPU": "2.3%",
  "Memory": "0.1%",
  "Disk": "0.1 MB/\u79D2",
  "Network": "0 Mbps",
  "GPU": "2.2%",
  "GPUEngine": "GPU 0 - 3D",
  "PowerUsage": "\u4F4E",
  "PowerUsageTrend": "\u975E\u5E38\u4F4E",
  "Type": "\u5E94\u7528",
  "Status": "\u6548\u7387\u6A21\u5F0F"
}

2.3. 使用 BinaryWriter 進行二進位序列化

使用站長前一篇文章寫的二進位序列化幫助類別 SerializeHelper 轉換,該類別使用 BinaryWriter 將物件轉換為二進位資料(反序列化使用 BinaryReader)。

首先,我們使 SystemProcess 類別實作了一個空介面 INetObject,並在類別上添加了 NetHeadAttribute 特性(加上了資料封包頭部定義,便於多個網路物件反序列化識別,序列化後會多出數個位元組,主要是系統 Id、網路物件 Id、物件版本號等序列化輔助欄位)。

/// <summary>
/// 網路物件序列化介面
/// </summary>
public interface INetObject
{
}
[NetHead(1, 1)]
public class SystemProcess : INetObject
{
 	// 省略欄位定義
}

然後,我們編寫了一個測試方法來驗證序列化和反序列化的正確性,並列印了序列化後的二進位資料長度。

/// <summary>
/// 二進位序列化測試
/// </summary>
[Fact]
public void Test_SerializeToBytes_Success()
{
    var buffer = SerializeHelper.SerializeByNative(_codeWFObject, 1);
    _testOutputHelper.WriteLine($"序列化後二進位長度:{buffer.Length}");

    var deserializeObj = SerializeHelper.DeserializeByNative<SystemProcess>(buffer);
    Assert.Equal("碼坊", deserializeObj.Name);
}
標準輸出: 
序列化後二進位長度:152

比 Json 體積小了一半多(366 到 152,還多了幾個欄位哦),上面單元測試也測試了資料反序列化後驗證資料是否正確,我們就以這個基礎繼續最佳化。

2.4. 資料型別調整

為了進一步最佳化二進位資料的大小,我們對資料型別進行了調整。透過對程序資料範例的分析,我們發現一些欄位的資料型別可以更加緊湊地表示。例如,CPU 使用率可以只傳遞數字部分(如 2.3),而不需要傳遞百分比符號;程序型別只傳遞列舉值,而不用傳遞個性化字串。這種調整可以減小資料封包的大小。

欄位名 資料型別 說明 範例
PID int 程序 ID 10565
Name string? 程序名稱 碼坊
Publisher string? 發行者 沙漠盡頭的狼
CommandLine string? 命令列 dotnet CodeWF.Tools.dll
CPU float CPU(所有核心的總處理利用率) 2.3
Memory float 記憶體(程序佔用的實體記憶體) 0.1
Disk float 磁碟(所有實體驅動器的總利用率) 0.1
Network float 網路(目前主要網路上的網路利用率 0
GPU float GPU(所有 GPU 引擎的最高利用率) 2.2
GPUEngine byte GPU 引擎,0:無,1:GPU 0 - 3D 1
PowerUsage byte 電源使用情況(CPU、磁碟和 GPU 對功耗的影響),0:非常低,1:低,2:中,3:高,4:非常高 1
PowerUsageTrend byte 電源使用情況趨勢(一段時間內 CPU、磁碟和 GPU 對功耗的影響),0:非常低,1:低,2:中,3:高,4:非常高 0
Type byte 程序型別,0:應用,1:背景程序 0
Status byte 程序狀態,0:正常執行,1:效率模式,2:暫停 1

修改測試資料定義:

[NetHead(1, 2)]
public class SystemProcess2 : INetObject
{
    public int PID { get; set; }
    public string? Name { get; set; }
    public string? Publisher { get; set; }
    public string? CommandLine { get; set; }
    public float CPU { get; set; }
    public float Memory { get; set; }
    public float Disk { get; set; }
    public float Network { get; set; }
    public float GPU { get; set; }
    public byte GPUEngine { get; set; }
    public byte PowerUsage { get; set; }
    public byte PowerUsageTrend { get; set; }
    public byte Type { get; set; }
    public byte Status { get; set; }
}
/// <summary>
/// 普通最佳化欄位資料型別
/// </summary>
private SystemProcess2 _codeWFObject2 = new SystemProcess2()
{
    PID = 10565,
    Name = "碼坊",
    Publisher = "沙漠盡頭的狼",
    CommandLine = "dotnet CodeWF.Tools.dll",
    CPU = 2.3f,
    Memory = 0.1f,
    Disk = 0.1f,
    Network = 0,
    GPU = 2.2f,
    GPUEngine = 1,
    PowerUsage = 1,
    PowerUsageTrend = 0,
    Type = 0,
    Status = 1
};

添加單元測試如下:

/// <summary>
/// 二進位序列化測試
/// </summary>
[Fact]
public void Test_SerializeToBytes2_Success()
{
    var buffer = SerializeHelper.SerializeByNative(_codeWFObject2, 1);
    _testOutputHelper.WriteLine($"序列化後二進位長度:{buffer.Length}");

    var deserializeObj = SerializeHelper.DeserializeByNative<SystemProcess2>(buffer);
    Assert.Equal("碼坊", deserializeObj.Name);
    Assert.Equal(2.2f, deserializeObj.GPU);
}

測試結果:

標準輸出: 
序列化後二進位長度:99

封包體積又最佳化了 1/3,由 152 位元組減小到 99 位元組長度,這是部分欄位資料型別由 string? 調整為 floatbyte 的成果。

2.5. 再次資料型別調整與位元欄位最佳化

更進一步地,我們引入了位元欄位技術。位元欄位允許我們更加精細地控制欄位在記憶體中的佈局,從而進一步減小二進位資料的大小。我們重新定義了欄位規則,並使用位元欄位來表示一些列舉值欄位。透過這種方式,我們能夠顯著地減小資料封包的大小。

看前一張表和下表比對,主要是兩種資料型別調整,規則如下:

  • 第一種:部分欄位只是一些列舉值,使用的 byte 表示,即 8 位元(bit),其中比如程序型別只有 2 個狀態(0:應用,1:背景程序),正好可以用 1 位元表示(0、1);像電源使用情況,無非就是 5 個狀態,用 3 位元可表示全(可表示 6 種狀態);

  • 第二種:部分 float 資料型別,實際情況我們只會要求精確到小數位 1 位。數值表示的百分比,那麼不會超過 1(即 100.0%),可以考慮取整,如 23.3%,傳遞的 23.3,乘以 10,傳 233 即可,最大不會超過 1000(即 100.0,100%),另一程序解析資料後,再除以 10 使用,那麼就可以將資料型別由 float 表示的 4 位元組 32 位元最佳化為 10 位元(最大值 1024)。

按這個規則我們重新定義欄位規則如下:

欄位名 資料型別 說明 範例
PID int 程序 ID 10565
Name string? 程序名稱 碼坊
Publisher string? 發行者 沙漠盡頭的狼
CommandLine string? 命令列 dotnet CodeWF.Tools.dll
Data byte[8] 固定大小的幾個欄位,為什麼是 8 個位元組長度?(註:反序列化還會多定義 4 個位元組表示 byte[] 長度,所以 Data 欄位總共佔 12 個位元組)?

固定欄位(Data)的詳細說明如下:

欄位名 Offset Size 說明 範例
CPU 0 10 CPU(所有核心的總處理利用率),最後一位表示小數位,比如 23 表示 2.3% 23
Memory 10 10 記憶體(程序佔用的實體記憶體),最後一位表示小數位,比如 1 表示 0.1%,值可根據基本資訊計算 1
Disk 20 10 磁碟(所有實體驅動器的總利用率),最後一位表示小數位,比如 1 表示 0.1%,值可根據基本資訊計算 1
Network 30 10 網路(目前主要網路上的網路利用率),最後一位表示小數位,比如 253 表示 25.3%,值可根據基本資訊計算 0
GPU 40 10 GPU(所有 GPU 引擎的最高利用率),最後一位表示小數位,比如 253 表示 25.3 22
GPUEngine 50 1 GPU 引擎,0:無,1:GPU 0 - 3D 1
PowerUsage 51 3 電源使用情況(CPU、磁碟和 GPU 對功耗的影響),0:非常低,1:低,2:中,3:高,4:非常高 1
PowerUsageTrend 54 3 電源使用情況趨勢(一段時間內 CPU、磁碟和 GPU 對功耗的影響),0:非常低,1:低,2:中,3:高,4:非常高 0
Type 57 1 程序型別,0:應用,1:背景程序 0
Status 58 2 程序狀態,0:正常執行,1:效率模式,2:暫停 1

上面這張表是部分固定範例欄位的位元欄位規則表,Offset 表示欄位在 Data 位元組陣列中的位置(以 bit 為單位計算),Size 表示欄位在 Data 中佔有的大小(同樣以 bit 單位計算),如 Memory 欄位,在 Data 位元組陣列中,佔據 10 到 20 位的空間。

由此就將固定大小的、原本 25 個位元組長度的 10 個欄位最佳化到 8 位元組了(5 個 float 4 位元組 32 位元最佳化為 10 位元,單位元組 8 位元最佳化到 2 位元、4 位元、6 位元,即 200 位元(25*8)最佳化到 64 位元(實際是 60 位元,由於網路傳輸最小單位是 byte,所以向上取整 8 位元組 64 位元))。

修改類別定義如下,注意看程式碼中的註解:

[NetHead(1, 3)]
public class SystemProcess3 : INetObject
{
    public int PID { get; set; }
    public string? Name { get; set; }
    public string? Publisher { get; set; }
    public string? CommandLine { get; set; }
    private byte[]? _data;
    /// <summary>
    /// 序列化,這是實際需要序列化的資料
    /// </summary>
    public byte[]? Data
    {
        get => _data;
        set
        {
            _data = value;

            // 這是關鍵:在反序列化將 byte 轉換為物件,方便程式中使用(位元欄位操作)
            _processData = _data?.ToFieldObject<SystemProcessData>();
        }
    }

    private SystemProcessData? _processData;

    /// <summary>
    /// 程序資料,添加 NetIgnoreMember 在序列化時會忽略
    /// </summary>
    [NetIgnoreMember]
    public SystemProcessData? ProcessData
    {
        get => _processData;
        set
        {
            _processData = value;

            // 這裡關鍵:將物件轉換為 byte[](位元欄位序列化操作)
            _data = _processData?.FieldObjectBuffer();
        }
    }
}

public record SystemProcessData
{
    [NetFieldOffset(0, 10)] public short CPU { get; set; }
    [NetFieldOffset(10, 10)] public short Memory { get; set; }
    [NetFieldOffset(20, 10)] public short Disk { get; set; }
    [NetFieldOffset(30, 10)] public short Network { get; set; }
    [NetFieldOffset(40, 10)] public short GPU { get; set; }
    [NetFieldOffset(50, 1)] public byte GPUEngine { get; set; }
    [NetFieldOffset(51, 3)] public byte PowerUsage { get; set; }
    [NetFieldOffset(54, 3)] public byte PowerUsageTrend { get; set; }
    [NetFieldOffset(57, 1)] public byte Type { get; set; }
    [NetFieldOffset(58, 2)] public byte Status { get; set; }
}

添加單元測試如下:

/// <summary>
/// 極限最佳化欄位資料型別
/// </summary>
private SystemProcess3 _codeWFObject3 = new SystemProcess3()
{
    PID = 10565,
    Name = "碼坊",
    Publisher = "沙漠盡頭的狼",
    CommandLine = "dotnet CodeWF.Tools.dll",
    ProcessData = new SystemProcessData()
    {
        CPU = 23,
        Memory = 1,
        Disk = 1,
        Network = 0,
        GPU = 22,
        GPUEngine = 1,
        PowerUsage = 1,
        PowerUsageTrend = 0,
        Type = 0,
        Status = 1
    }
};

/// <summary>
/// 二進位極限序列化測試
/// </summary>
[Fact]
public void Test_SerializeToBytes3_Success()
{
    var buffer = SerializeHelper.SerializeByNative(_codeWFObject3, 1);
    _testOutputHelper.WriteLine($"序列化後二進位長度:{buffer.Length}");

    var deserializeObj = SerializeHelper.DeserializeByNative<SystemProcess3>(buffer);
    Assert.Equal("碼坊", deserializeObj.Name);
    Assert.Equal(23, deserializeObj.ProcessData.CPU);
    Assert.Equal(1, deserializeObj.ProcessData.PowerUsage);
}

測試輸出:

標準輸出: 
序列化後二進位長度:86

99 又最佳化到 86 個位元組,13 個位元組哦,在極限網路環境下非常可觀,比如 100 萬資料,那不就是 12.4MB 了?關於位元欄位序列化和反序列的程式碼這裡不細說了,很枯燥,站長可能也說不清楚,程式碼長這樣:

public partial class SerializeHelper
{
    public static byte[] FieldObjectBuffer<T>(this T obj) where T : class
    {
        var properties = typeof(T).GetProperties();
        var totalSize = 0;

        // 計算總的 bit 長度
        foreach (var property in properties)
        {
            if (!Attribute.IsDefined(property, typeof(NetFieldOffsetAttribute)))
            {
                continue;
            }

            var offsetAttribute =
                (NetFieldOffsetAttribute)property.GetCustomAttribute(typeof(NetFieldOffsetAttribute))!;
            totalSize = Math.Max(totalSize, offsetAttribute.Offset + offsetAttribute.Size);
        }

        var bufferLength = (int)Math.Ceiling((double)totalSize / 8);
        var buffer = new byte[bufferLength];

        foreach (var property in properties)
        {
            if (!Attribute.IsDefined(property, typeof(NetFieldOffsetAttribute)))
            {
                continue;
            }

            var offsetAttribute =
                (NetFieldOffsetAttribute)property.GetCustomAttribute(typeof(NetFieldOffsetAttribute))!;
            dynamic value = property.GetValue(obj)!; // 使用 dynamic 型別動態取得屬性值
            SetBitValue(ref buffer, value, offsetAttribute.Offset, offsetAttribute.Size);
        }

        return buffer;
    }

    public static T ToFieldObject<T>(this byte[] buffer) where T : class, new()
    {
        var obj = new T();
        var properties = typeof(T).GetProperties();

        foreach (var property in properties)
        {
            if (!Attribute.IsDefined(property, typeof(NetFieldOffsetAttribute)))
            {
                continue;
            }

            var offsetAttribute =
                (NetFieldOffsetAttribute)property.GetCustomAttribute(typeof(NetFieldOffsetAttribute))!;
            dynamic value = GetValueFromBit(buffer, offsetAttribute.Offset, offsetAttribute.Size,
                property.PropertyType);
            property.SetValue(obj, value);
        }

        return obj;
    }

    /// <summary>
    /// 將值按位寫入 buffer
    /// </summary>
    /// <param name="buffer"></param>
    /// <param name="value"></param>
    /// <param name="offset"></param>
    /// <param name="size"></param>
    private static void SetBitValue(ref byte[] buffer, int value, int offset, int size)
    {
        var mask = (1 << size) - 1;
        buffer[offset / 8] |= (byte)((value & mask) << (offset % 8));
        if (offset % 8 + size > 8)
        {
            buffer[offset / 8 + 1] |= (byte)((value & mask) >> (8 - offset % 8));
        }
    }

    /// <summary>
    /// 從 buffer 中按位讀取值
    /// </summary>
    /// <param name="buffer"></param>
    /// <param name="offset"></param>
    /// <param name="size"></param>
    /// <param name="propertyType"></param>
    /// <returns></returns>
    private static dynamic GetValueFromBit(byte[] buffer, int offset, int size, Type propertyType)
    {
        var mask = (1 << size) - 1;
        var bitValue = (buffer[offset / 8] >> (offset % 8)) & mask;
        if (offset % 8 + size > 8)
        {
            bitValue |= (buffer[offset / 8 + 1] << (8 - offset % 8)) & mask;
        }

        dynamic result = Convert.ChangeType(bitValue, propertyType); // 根據屬性型別進行轉換
        return result;
    }
}

3. 最佳化效果與總結

透過逐步最佳化,我們從最初的 Json 序列化 366 位元組減小到了使用普通二進位序列化的 152 位元組,再進一步使用位元欄位技術最佳化到了 86 位元組。這種最佳化在網路傳輸中是非常可觀的,尤其是在需要傳輸大量資料的情況下。

本文透過一個範例案例,探討了 C# 物件二進位序列化的最佳化方法。透過使用位元欄位技術,我們實現了對資料封包大小的極限壓縮,提高了網路傳輸的效率。這對於開發 C/S 程式來說是一種樂趣,也是追求極致效能的一種體現。

最後,我們提供了本文測試原始碼的 GitHub 連結,供讀者參考和學習。

彩蛋:該倉庫有上篇《C# 百萬物件序列化深度剖析:如何在網路傳輸中實現速度與體積的完美平衡 (dotnet9.com)》案例程式碼,也附帶了 TCP、UDP 伺服器端與用戶端聯調測試程式哦。

繼續探索

延伸閱讀

更多文章
同分類 / 同標籤 2026/2/7

AOT使用經驗總結

從專案建立伊始,就應養成良好的習慣,即只要添加了新功能或使用了較新的語法,就及時進行 AOT 發布測試。

繼續閱讀