一、為什么是 Protobuf(而不是 XML/自定義字符串/.NET 二進制序列化)
在需要把結構化對象持久化或跨進程/跨語言傳輸時,常見方案各有痛點:
- BinaryFormatter 等 .NET 二進制序列化:對類型簽名與版本極其脆弱、體積偏大,且跨語言互通性差。
- 自定義分隔字符串:一次性編碼/解析成本高,健壯性與可讀性差。
- XML:可讀但冗長、編解碼開銷大。
Protocol Buffers(Protobuf) 的優勢在于:
用一個 .proto
文件聲明消息結構,protoc
生成高效的 C# 類型,提供自動的二進制編碼/解碼;并且天然支持演進(老代碼可讀新消息、反之亦然)。
二、工程與示例骨架
本文示例是一個命令行通訊錄工具:
- 可新增聯系人并寫入文件;
- 可讀取文件并打印聯系人列表。
完整示例(Program.cs
、addressbook.proto
等)可按本文步驟自行創建;若你使用官方示例倉庫,也能在 examples
與 csharp/src/AddressBook
目錄中找到等價實現。
三、定義協議:addressbook.proto
syntax = "proto3";
package tutorial;import "google/protobuf/timestamp.proto";// 覆蓋 C# 默認命名空間(否則取 package 名)
option csharp_namespace = "Google.Protobuf.Examples.AddressBook";message Person {string name = 1;int32 id = 2; // 唯一 IDstring email = 3;enum PhoneType {PHONE_TYPE_UNSPECIFIED = 0;PHONE_TYPE_MOBILE = 1;PHONE_TYPE_HOME = 2;PHONE_TYPE_WORK = 3;}message PhoneNumber {string number = 1;PhoneType type = 2;}repeated PhoneNumber phones = 4;google.protobuf.Timestamp last_updated = 5;
}message AddressBook {repeated Person people = 1;
}
要點速記
package
防沖突;csharp_namespace
可定制命名空間。= 1/2/…
是標簽號(wire tag),1–15 編碼更省字節,優先留給常用或 repeated 字段。- 未賦值字段使用類型默認值(數字 0、布爾 false、字符串空串、枚舉 0 值)。
repeated
是有序動態數組;集合類型在 C# 中為RepeatedField<T>
(只讀集合,支持增刪元素)。- 嵌套
message
與enum
提升結構清晰度;可以引入標準類型(如Timestamp
)。
四、編譯生成 C# 代碼
安裝好 protoc
后執行:
protoc -I=$SRC_DIR --csharp_out=$DST_DIR $SRC_DIR/addressbook.proto
輸出 Addressbook.cs
,其中包含:
- 靜態
Addressbook
(元數據); AddressBook
、Person
、Person.Types.PhoneNumber
類;Person.Types.PhoneType
枚舉。
注意:
RepeatedField<T>
是只讀集合屬性,不能整體替換,只能調用Add/Remove/Clear
等方法修改元素。
五、寫與讀:序列化 / 反序列化
1)寫入
using System.IO;
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using Google.Protobuf.Examples.AddressBook;
using static Google.Protobuf.Examples.AddressBook.Person.Types;var john = new Person {Id = 1234,Name = "John Doe",Email = "jdoe@example.com",LastUpdated = Timestamp.FromDateTime(DateTime.UtcNow)
};
john.Phones.Add(new PhoneNumber { Number = "555-4321", Type = PhoneType.HOME });var book = new AddressBook();
book.People.Add(john);using var output = File.Create("addressbook.binpb");
book.WriteTo(output); // 二進制高效寫入
2)讀取
using var input = File.OpenRead("addressbook.binpb");
var parsed = AddressBook.Parser.ParseFrom(input);foreach (var p in parsed.People) {Console.WriteLine($"{p.Id} | {p.Name} | {p.Email}");foreach (var ph in p.Phones) {Console.WriteLine($" - {ph.Type}: {ph.Number}");}
}
六、生成代碼的常見模式
- 屬性訪問:普通字段直接 get/set;
repeated
用集合操作。 using static
簡化枚舉/嵌套名:using static Person.Types;
之后可寫PhoneType.HOME
。- Debug 與 JSON:調試可用
ToString()
(等同JsonFormatter
的簡版),生產落盤/傳輸請使用二進制WriteTo/ParseFrom
;不要把調試輸出當數據格式使用。
七、向前/向后兼容的演進法則
演進 .proto
需遵守:
- 不要修改已有字段的標簽號;
- 可以刪除字段;
- 可以新增字段,但必須使用從未使用過的新標簽號(包括曾被刪除的號也不能復用)。
這樣:
- 舊程序讀新消息:會忽略看不懂的新字段;
- 新程序讀舊消息:新加字段會呈現其默認值(比如空串/0/false)。
小貼士:把最常用/可能
repeated
的字段優先安排在 1–15,可觀節省體積。
八、反射(Reflection)一鍵遍歷任意消息
當你需要寫通用邏輯(如通用打印、對比、轉其它文本格式)時,反射很有用:
using Google.Protobuf;
using Google.Protobuf.Reflection;static void DumpTopLevel(IMessage msg) {var desc = msg.Descriptor;foreach (var f in desc.Fields.InDeclarationOrder()) {var val = f.Accessor.GetValue(msg);Console.WriteLine($"#{f.FieldNumber} {f.Name} = {val}");}
}
九、最佳實踐與易踩坑
- 不要把生成的 Protobuf 類當領域模型的“上帝類”:它們是數據載體。復雜業務建議用封裝類包一層,便于隱藏實現細節與組合額外行為。
- 文件擴展名約定:二進制
.binpb
、文本.txtpb
、JSON.json
,保持項目內一致性。 - 調試輸出 ≠ 數據格式:日志用調試格式即可;要傳輸/落盤請用二進制或顯式
TextFormat/JSON
。 - 版本演進前先留余量:給可能增長的 repeated 預留 1–15;對未來“也許有用”的字段先不上線,避免隨后改 tag。
- 單元測試:比較對象請用值相等(
Equals
/字段比對)而非字節流相等;序列化非確定性,不同運行時/版本字節序可能不同但語義相同。
十、把示例跑起來(最短路徑)
- 安裝
Google.Protobuf
NuGet 與protoc
; - 寫好
addressbook.proto
; - 執行
protoc --csharp_out
生成Addressbook.cs
; - 在項目中引用生成文件與
Google.Protobuf
; - 按第 五 節代碼寫入/讀取,驗證通訊錄功能。