一、为什么是 Protobuf(而不是 XML/自定义字符串/.NET 二进制序列化)
在需要把结构化对象持久化或跨进程/跨语言传输时,常见方案各有痛点:
- BinaryFormatter 等 .NET 二进制序列化:对类型签名与版本极其脆弱、体积偏大,且跨语言互通性差。
- 自定义分隔字符串:一次性编码/解析成本高,健壮性与可读性差。
- XML :可读但冗长、编解码开销大。
Protocol Buffers(Protobuf) 的优势在于:
用一个 .proto
文件声明消息结构,protoc
生成高效的 C# 类型,提供自动 的二进制编码/解码;并且天然支持演进(老代码可读新消息、反之亦然)。
二、工程与示例骨架
本文示例是一个命令行通讯录工具:
- 可新增联系人并写入文件;
- 可读取文件并打印联系人列表。
完整示例(Program.cs
、addressbook.proto
等)可按本文步骤自行创建;若你使用官方示例仓库,也能在 examples
与 csharp/src/AddressBook
目录中找到等价实现。
三、定义协议:addressbook.proto
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; // 唯一 ID
string 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
后执行:
bash
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)写入
csharp
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)读取
csharp
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)一键遍历任意消息
当你需要写通用逻辑(如通用打印、对比、转其它文本格式)时,反射很有用:
csharp
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
; - 按第 五 节代码写入/读取,验证通讯录功能。