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? 調整為 float 或 byte 的成果。
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 伺服器端與用戶端聯調測試程式哦。