引言:TCP 的"水流"本质
很多初学者在将 Protobuf 用于网络传输时,都会遇到一个经典问题:为什么我发了两个包,接收端却只收到一个?或者收到了一半?
这是因为 TCP 协议是面向字节流 (Byte Stream) 的,而不是面向消息 (Message) 的。TCP 就像一根水管,数据像水流一样流过去,中间没有任何边界。
- 粘包 (Sticky Packet): 发送端连发两个 100 字节的包,接收端可能一次性收到 200 字节。
- 半包 (Incomplete Packet): 发送端发了 100 字节,接收端只收到了 50 字节,剩下的还在路上。
而 Protobuf 的 SerializeToString 只是把对象变成了二进制干粉,它自己并不具备"划定边界"的功能。
因此,我们需要引入 Codec (编解码层) 来解决两个核心问题:
- 分帧 (Framing): 告诉接收端,一个完整的消息从哪里开始,到哪里结束。
- 身份识别 (Identity): 这串二进制数据还原出来,到底是
LoginRequest还是SensorData?
第一部分:分帧 (Framing) ------ 给消息加个"长度头"
解决粘包最经典的方法是 Length-Prefixed (长度前缀法)。
想象我们在寄快递。如果把东西散乱地扔在传送带上,分拣员会疯掉。我们需要把东西装进盒子 里,并在盒子上写明:"本盒子长 20 厘米"。
在网络协议设计中,我们通常在 Protobuf 数据前面加一个固定的 4 字节整数(int32),表示后续数据的长度。
处理流程:
- 发送端: 先算好 Protobuf 数据长度
N,先发 4 字节的N,再发N字节的数据。 - 接收端:
- 先阻塞读取 4 字节,得到长度
N。 - 如果缓冲区数据不够
N,就继续等待(解决半包)。 - 如果够了,就读取
N字节,这就切出了一个完整的包。
- 先阻塞读取 4 字节,得到长度
第二部分:身份识别 (Coding) ------ 只有基类指针,怎么发子类数据?
解决了分帧,我们拿到了一个完整的二进制包 payload。但是 Protobuf 的二进制是不包含类名的。
问题: 接收端收到数据后,应该创建 LoginRequest 还是 SensorData 来解析它?
解决方案: 我们需要在协议头里,把 消息的类名 (Type Name) 也打包进去。
最终的协议格式 (Wire Format)
一个成熟的 C++ 网络包通常长这样:
text
+-------------+-------------+------------------------+--------------------------+
| Total Len | Name Len | Type Name (String) | Protobuf Data (Binary) |
| (4 bytes) | (4 bytes) | e.g. "embed.Sensor" | (Payload) |
+-------------+-------------+------------------------+--------------------------+
关键技术点:多态与虚函数
很多同学会问:"我在发送端代码里持有的是一个 Message* 基类指针,我怎么知道它到底是哪个子类,并把它打包呢?"
答案是:C++ 的多态性 (Polymorphism)。
虽然你手里拿的是 Message*,但这个指针指向的内存里存的是 LoginRequest 对象。
Protobuf 的基类 google::protobuf::Message 定义了两个关键的虚函数:
GetDescriptor()->full_name(): 哪怕是基类指针,调用它也能返回子类的名字(如"embed.LoginRequest")。ByteSizeLong(): 能返回子类序列化后的大小。SerializeToString(): 能调用子类的序列化逻辑。
编码器 (Encoder) 的伪代码实现:
cpp
void Send(google::protobuf::Message* msg) {
// 1. 利用多态,获取子类真正的名字
std::string type_name = msg->GetDescriptor()->full_name();
// 2. 利用多态,序列化子类数据
std::string content;
msg->SerializeToString(&content);
// 3. 拼装协议头
int32_t name_len = type_name.size();
int32_t content_len = content.size();
int32_t total_len = 4 + name_len + content_len; // NameLen + Name + Data
// 4. 发送 (伪代码)
WriteInt32(total_len);
WriteInt32(name_len);
WriteString(type_name);
WriteData(content);
}
第三部分:解码与反射 (Decoding & Reflection) ------ 见证奇迹的时刻
接收端收到了数据,解析出了名字 "embed.LoginRequest" 和二进制数据。现在怎么把它变回 C++ 对象?
这时候不能用 switch-case(太丑陋了),而要用 Protobuf 的反射机制。
Protobuf 允许我们需要根据一个字符串名字 ,创建一个空的对应类的对象。
cpp
// 1. 拿到名字 "embed.LoginRequest"
std::string type_name = ...;
// 2. 查表找到描述符
const Descriptor* desc = DescriptorPool::generated_pool()->FindMessageTypeByName(type_name);
// 3. 找到原型工厂
const Message* prototype = MessageFactory::generated_factory()->GetPrototype(desc);
// 4. "克隆"出一个新对象 (此时它是 LoginRequest,但被 Message* 指向)
Message* new_msg = prototype->New();
// 5. 填充数据
new_msg->ParseFromString(binary_data);
// 此时,new_msg 里已经装好了数据,可以交给分发器 (Dispatcher) 去处理了!
总结
网络编程的核心难点往往不在于 send 和 recv,而在于协议的设计。
- Codec (编解码) 负责把"无序的字节流"变成"有意义的消息对象"。
- 用 Length-Prefixed 解决粘包。
- 用 Type Name 解决类型识别。
- Protobuf + C++ 多态 是绝配。基类指针
Message*就像一个通用的"快递盒接口",它屏蔽了具体业务细节,让底层网络库可以通用化。