c#對象二進位序列化優化:位域技術實現極限壓縮

c#對象二進位序列化優化:位域技術實現極限壓縮

展示如何將c#對象轉換為二進位形式,並進行優化以減少網絡傳輸中的數據包大小。

最后更新 2024/1/22 上午12:33
沙漠尽头的狼
预计阅读 17 分钟
分类
.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 服务端与客户端联调测试程序哦。

Keep Exploring

延伸阅读

更多文章
同分类 / 同标签 2026/2/7

aot使用經驗總結

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

继续阅读