在即时通信系统里,Socket 解决的是“字节如何在两端传输”,Protobuf 解决的是“这些字节代表什么”。两者经常一起出现,但职责完全不同。
本文以一个匿名化的 Objective-C 即时通信场景为例,完整说明以下问题:
- 为什么选择 Protobuf,而不是直接传 JSON;
-
.proto、protoc和 Objective-C Runtime 各自负责什么; - 如何使用
protoc 26.1生成.pbobjc.h和.pbobjc.m; - Protobuf Payload 如何放入自定义 Socket Packet;
- 接收端如何处理 TCP 拆包、粘包,再解析 Protobuf;
- ACK、重试和幂等如何与
message_id配合; - 协议升级时,哪些改动兼容,哪些改动会造成线上事故。
本文所有类名、字段名、命令字和目录都是教学用的虚构示例,不对应任何具体项目。
一、先说结论:Protobuf 到底做什么
Protobuf(Protocol Buffers)是一套结构化数据序列化方案。
发送端把对象编码为二进制:
Objective-C 对象 -> Protobuf 编码 -> NSData
接收端把二进制还原为对象:
NSData -> Protobuf 解码 -> Objective-C 对象
它主要解决三个问题:
- 通信双方使用同一份结构定义;
- 以较紧凑的二进制格式传输数据;
- 协议可以在遵守规则的前提下逐步演进。
但要特别注意,Protobuf 不负责:
- 建立或维护 Socket 连接;
- 处理 TCP 拆包、粘包;
- 消息重试、ACK、去重;
- 数据库事务;
- 数据加密。
Protobuf 是编码方案,不是加密方案。抓到二进制数据后,只要拿到协议定义,内容仍然可以被解析。敏感数据应由 TLS 或业务加密层保护。
二、为什么 IM 更适合使用 Protobuf
1. 数据通常比 JSON 更紧凑
JSON 会重复传输字段名:
{
"sender_id": 1001,
"receiver_id": 2001,
"message_id": "m-001"
}
Protobuf 在二进制中主要传输“字段号 + 类型 + 值”,不会反复传输 sender_id 这样的完整字符串。对于消息量大、长连接常驻的 IM 场景,能节省带宽和编解码开销。
2. 协议约束比字典更明确
使用 NSDictionary 或 JSON 时,字段类型往往只存在于文档和开发者约定中。Protobuf 把结构直接写进 .proto:
int64 sender_id = 1;
string message_id = 3;
bytes content = 5;
代码生成后,Objective-C 层可以直接使用明确的属性,而不是到处写字符串 Key 和类型转换。
3. 更适合多端协作
同一份 .proto 可以生成 Objective-C、Swift、Java、Kotlin、Go、C++ 等语言的代码。客户端和服务端共享的是协议定义,而不是某一端手写的模型。
4. 支持向前、向后兼容
旧客户端收到新字段时会忽略未知字段;新客户端读取旧数据时,缺少的字段会使用默认值。这个能力很适合客户端版本无法同时升级的场景。
兼容性不是自动获得的。字段号、字段类型和删除策略仍然必须遵守规则,后文会详细说明。
三、三个版本概念不要混在一起
本文示例使用的协议编译器版本是:
protoc 26.1
工程中通常还会通过 CocoaPods 引入 Objective-C Runtime,例如:
pod 'Protobuf'
这两者不是同一个东西:
| 名称 | 作用 | 典型位置 |
|---|---|---|
.proto |
协议源文件,是数据结构的唯一事实来源 | Protocols/ |
protoc 26.1 |
读取 .proto,生成 Objective-C 代码 |
tools/protoc/bin/protoc |
| Objective-C Runtime | 提供 GPBMessage、解析、序列化等运行能力 |
CocoaPods Protobuf
|
.pbobjc.h/.m |
由生成器产生的业务协议类 | Generated/ |
生成代码中通常包含生成器与 Runtime 的版本检查。升级任一方时,应重新生成并实际编译验证,不能仅因为“版本号更大”就断定兼容。
本文只给出 protoc 26.1 的命令示例,不要求也不建议在不了解工程状态时直接覆盖现有生成文件。
四、定义一份可演进的聊天协议
下面是一份完整但匿名化的示例:
syntax = "proto3";
package chat.protocol;
option objc_class_prefix = "CB";
option optimize_for = LITE_RUNTIME;
enum ResultCode {
RESULT_CODE_UNSPECIFIED = 0;
RESULT_CODE_OK = 1;
RESULT_CODE_FAILED = 2;
}
message ChatMessage {
int64 sender_id = 1;
int64 receiver_id = 2;
string message_id = 3;
uint64 client_time = 4;
bytes content = 5;
}
message ChatAck {
string message_id = 1;
uint64 server_time = 2;
ResultCode result = 3;
}
逐项理解
syntax = "proto3" 表示使用 proto3 语法。
package chat.protocol 用于协议命名空间管理。它不等于 Objective-C 的模块名,也不要把真实公司域名或内部服务名写进公开文章。
objc_class_prefix = "CB" 给生成的 Objective-C 类增加前缀。例如 ChatMessage 会生成 CBChatMessage,可减少全局类名冲突。
optimize_for = LITE_RUNTIME 倾向于缩减生成代码依赖和体积,是否使用应由客户端、服务端和现有工程策略共同决定。已有协议不要为了“看起来更轻”而随意改动。
字段声明:
string message_id = 3;
可以拆成三部分:
-
string:字段类型; -
message_id:协议中的字段名; -
3:字段号,也叫 tag。
字段号才是线上二进制协议真正识别字段的关键,字段名主要服务于代码可读性。
五、常用字段类型怎么选
| Protobuf 类型 | Objective-C 常见映射 | 适用场景 |
|---|---|---|
string |
NSString * |
文本、消息 ID |
bytes |
NSData * |
二进制内容、嵌套加密结果 |
bool |
BOOL |
开关状态 |
int32 / int64
|
int32_t / int64_t
|
普通整数 |
uint32 / uint64
|
无符号整数 | 时间戳、非负计数 |
sint32 / sint64
|
有符号整数 | 经常出现负数的数值 |
enum |
生成的枚举 | 有限状态集合 |
repeated |
生成的数组容器 | 同类型列表 |
map |
生成的字典容器 | 键值表 |
oneof |
互斥字段组 | 多种消息体只允许出现一种 |
int32 和 sint32 的差异
Protobuf 的 int32/int64 使用 Varint。负数用普通 int32/int64 编码时通常不够紧凑;sint32/sint64 使用 ZigZag 编码,更适合正负数频繁出现的字段。
时间戳、长度、计数这类天然非负的数据,可以使用 uint64。不要只为了省几个字节就忽视业务范围和跨语言兼容。
bytes 不代表适合塞大文件
bytes 可以装任意二进制,但图片、视频、语音大文件不应直接塞进一条 Protobuf 消息。常见做法是:
- 文件通过对象存储或专门上传接口传输;
- Protobuf 只携带文件 URL、摘要、尺寸、时长等元数据;
- IM 消息仍保持小而可控。
这样可以避免 Socket 写缓冲区、内存峰值和重试成本被大文件拖垮。
六、使用 protoc 26.1 生成 Objective-C 文件
假设目录结构如下:
Demo/
├── Protocols/
│ └── chat_message.proto
├── Generated/
└── tools/
└── protoc/
└── bin/
└── protoc
生成单个协议文件:
tools/protoc/bin/protoc \
--proto_path=Protocols \
--objc_out=Generated \
Protocols/chat_message.proto
这里使用的是相对路径,不依赖任何人的电脑目录。
参数含义:
-
--proto_path=Protocols:指定.proto文件和import的搜索根目录; -
--objc_out=Generated:指定 Objective-C 生成文件的输出目录; - 最后一项:本次要编译的协议文件。
执行后通常得到:
Generated/
├── ChatMessage.pbobjc.h
└── ChatMessage.pbobjc.m
批量生成多个协议文件可以使用:
find Protocols -name '*.proto' -print0 |
xargs -0 tools/protoc/bin/protoc \
--proto_path=Protocols \
--objc_out=Generated
这段命令只应在确认以下事项后手动执行:
- 当前使用的确实是
protoc 26.1; -
.proto的 import 根目录正确; - 输出目录不会误覆盖其他分支的生成结果;
- 生成代码对应的 Objective-C Runtime 已确认兼容;
- 已通过 Git 观察生成前后的差异。
有 import 时怎么处理
例如:
import "common/result.proto";
那么 --proto_path 指向的目录下必须能找到:
Protocols/common/result.proto
import 的查找依据是 --proto_path,不是当前终端目录,也不是 Xcode Group 的显示层级。这是生成失败最常见的原因之一。
七、生成文件如何接入 Objective-C 工程
生成后的 .pbobjc.h 和 .pbobjc.m 应作为一组加入工程:
- 把文件放到稳定的协议生成目录;
- 在 Xcode 中加入对应 Target;
- 确认
.pbobjc.m出现在 Build Phases -> Compile Sources; - 通过 CocoaPods 或其他方式链接匹配的 Protobuf Runtime;
- 检查是否存在重复加入、重复生成或同名旧文件。
常见错误包括:
- 只加入
.h,没有编译.m,最终出现链接错误; - 同一个
.pbobjc.m被两个路径重复加入,出现 duplicate symbol; -
.proto已更新,但工程仍引用旧生成文件; - import 路径与文件实际目录不一致;
- 生成器与 Runtime 不兼容,触发生成代码中的版本检查。
不要修改 .pbobjc.h/.m
生成文件不是业务代码。正确的变更流程是:
修改 .proto -> 重新生成 -> 检查 diff -> 编译验证
手改生成文件,下次执行 protoc 就会全部丢失。需要转换或业务辅助逻辑时,应写 Category、Mapper 或独立适配层。
-fno-objc-arc 不是固定要求
一些历史工程会给生成的 .pbobjc.m 单独配置 -fno-objc-arc。这通常与当时的生成器和 Runtime 有关,并不是 protoc 26.1 的通用固定配置。
是否需要该参数,应以当前生成代码、Runtime 官方要求和实际编译结果为准。不要从旧工程机械复制。
八、Objective-C 中如何创建和序列化消息
引入生成头文件:
#import "ChatMessage.pbobjc.h"
创建消息对象:
CBChatMessage *message = [CBChatMessage message];
message.senderId = 1001;
message.receiverId = 2001;
message.messageId = @"m-20260924-001";
message.clientTime = 1780000000000;
message.content = [@"hello" dataUsingEncoding:NSUTF8StringEncoding];
.proto 中的下划线命名会映射为 Objective-C 驼峰属性,例如:
sender_id -> senderId
message_id -> messageId
client_time -> clientTime
序列化为 NSData:
NSData *payloadData = message.data;
此时的 payloadData 只是 Protobuf Payload,还不是一个可以直接解决 TCP 拆包问题的完整网络帧。
九、Protobuf 如何进入 Socket Packet
一个常见的长连接协议会在 Protobuf 外面再包一层固定格式 Header:
+-------------------- 16-byte Header -------------------+
| totalLength | flags | appId | command | bodyLen | seq |
+-------------------------------------------------------+
| Protobuf Payload |
+-------------------------------------------------------+
Header 中各字段的职责通常是:
| 字段 | 用途 |
|---|---|
totalLength |
整个 Packet 的长度 |
flags |
压缩、加密、版本等标志 |
appId |
应用或协议域标识 |
command |
决定 Payload 应使用哪种 Protobuf 类型解析 |
bodyLen |
Payload 长度 |
seq |
请求序号、追踪或应答关联 |
这些 Header 字段属于自定义传输协议,不属于 Protobuf。
发送代码可以抽象成:
CBChatMessage *message = [CBChatMessage message];
message.messageId = model.messageId;
message.senderId = model.senderId;
message.receiverId = model.receiverId;
message.content = model.contentData;
NSData *payloadData = message.data;
SocketPacket *packet = [SocketPacket packetWithCommand:SocketCommandChat
payload:payloadData];
[socketClient sendPacket:packet];
这里有三层对象:
业务模型 Model
-> Protobuf Message
-> Socket Packet(Header + Payload)
这样分层的价值是:
- 业务模型不需要知道二进制协议细节;
- Protobuf 类不需要知道 Socket 的连接状态;
- Packet 层统一处理长度、命令字和序号;
- 更换存储模型时不会直接污染协议层。
十、TCP 拆包、粘包与 Protobuf 的关系
TCP 是字节流,不保留应用层消息边界。
一次 write 的数据,接收端可能分多次收到:
发送:[完整 Packet A]
接收:[A 的前半段] [A 的后半段]
这就是常说的“拆包”。
多次 write 的数据,也可能一次收到:
发送:[Packet A] [Packet B]
接收:[Packet A + Packet B]
这就是常说的“粘包”。
Protobuf 无法判断一段 TCP 字节流从哪里开始、在哪里结束。因此接收端必须先通过 Header 中的长度字段切出完整 Packet,再把 Payload 交给 Protobuf。
典型接收流程:
1. Socket 收到新数据
2. 追加到接收缓冲区
3. 缓冲区不足 16 字节:继续等待
4. 读取 Header 和 bodyLen
5. 校验长度是否合理
6. 数据不足一个完整 Packet:继续等待
7. 截取完整 Packet
8. 根据 command 选择 Protobuf 类型
9. parseFromData:error:
10. 缓冲区还有数据:继续解析下一帧
伪代码示例:
- (void)appendReceivedData:(NSData *)receivedData {
[self.receiveBuffer appendData:receivedData];
while (self.receiveBuffer.length >= SocketHeaderLength) {
SocketHeader header = [self parseHeaderFromData:self.receiveBuffer];
if (header.bodyLength > SocketMaxPayloadLength) {
[self closeForInvalidPacket];
return;
}
NSUInteger packetLength = SocketHeaderLength + header.bodyLength;
if (self.receiveBuffer.length < packetLength) {
return;
}
NSRange payloadRange = NSMakeRange(SocketHeaderLength,
header.bodyLength);
NSData *payloadData = [self.receiveBuffer subdataWithRange:payloadRange];
[self.receiveBuffer replaceBytesInRange:NSMakeRange(0, packetLength)
withBytes:NULL
length:0];
[self handlePayload:payloadData command:header.command];
}
}
真正实现时还需要注意:
- Header 的字节序必须统一,网络协议通常采用大端序;
- 加法前要防止整数溢出;
-
bodyLength必须设置上限,不能完全信任网络数据; - Header 本身也要校验版本、命令字和合法范围;
- 解析失败不能继续把错误数据交给业务层;
- 如果启用了压缩或加密,应按 flags 规定的顺序处理。
十一、接收端如何解析 Protobuf
Packet 层已经切出完整 payloadData 后,再根据 command 选择具体消息类型:
- (void)handlePayload:(NSData *)payloadData
command:(SocketCommand)command {
switch (command) {
case SocketCommandChat:
[self handleChatPayload:payloadData];
break;
case SocketCommandAck:
[self handleAckPayload:payloadData];
break;
default:
[self reportUnsupportedCommand:command];
break;
}
}
解析聊天消息:
- (void)handleChatPayload:(NSData *)payloadData {
NSError *parseError = nil;
CBChatMessage *message = [CBChatMessage parseFromData:payloadData
error:&parseError];
if (parseError != nil || message == nil) {
[self reportParseError:parseError];
return;
}
[self.messageService receiveMessage:message];
}
不要忽略 NSError。网络数据可能被截断、版本不匹配、解密失败,也可能根本不是预期的 Protobuf 类型。
command 为什么重要
同一段二进制数据不会自动告诉 Runtime “我是 ChatMessage 还是 ChatAck”。外层 Header 的 command 相当于路由键:
command = Chat -> CBChatMessage
command = Ack -> CBChatAck
因此 Command 映射表本身也是协议的一部分,必须由两端共同维护,不能随意复用旧命令字。
十二、ACK、重试与幂等怎么结合
Protobuf 只负责把 message_id 编码进数据。可靠消息能力来自上层状态机。
常见流程:
客户端生成 message_id
|
v
本地落库,状态 = sending
|
v
Protobuf 编码 + Socket 发送
|
v
服务端按 message_id 去重
|
v
服务端返回 ChatAck
|
v
客户端更新状态 = sent
如果超时没有收到 ACK,客户端可能重发同一条消息。重发时必须继续使用原来的 message_id,不能每次生成新 ID。
服务端或接收端以 message_id 做唯一性判断:
- 第一次收到:写入并处理;
- 再次收到相同 ID:不重复创建消息,只返回已有处理结果。
这就是幂等:同一请求执行一次和执行多次,最终业务结果一致。
为什么仅有 sequence 不够
sequence 常用于一次连接内的请求关联,而 message_id 通常要求跨重连、跨重试保持稳定。连接断开后 sequence 可能重新计数,所以业务幂等应优先依赖全局稳定的消息 ID。
十三、协议演进的硬规则
1. 已发布字段号不能修改
错误示例:
// 旧版本
string message_id = 3;
// 错误:把同一字段改成 8
string message_id = 8;
对线上协议来说,3 才是这个字段的身份。改字段名通常不影响二进制兼容,改字段号会让新旧两端把它当成不同字段。
2. 删除字段后使用 reserved
message ChatMessage {
reserved 6;
reserved "legacy_text";
int64 sender_id = 1;
int64 receiver_id = 2;
string message_id = 3;
}
reserved 防止后来的人误用旧字段号或旧字段名。字段号一旦发布,就不应分配给新的语义。
3. 新字段使用新字段号
message ChatMessage {
int64 sender_id = 1;
int64 receiver_id = 2;
string message_id = 3;
uint64 client_time = 4;
bytes content = 5;
string trace_id = 7;
}
旧客户端一般会忽略 trace_id,新客户端读取旧消息时则得到默认值。
4. 不要随意修改字段类型
即使部分类型在线格式上看似兼容,也可能产生截断、符号变化或跨语言差异。最稳妥的做法是新增字段,完成灰度迁移后再保留旧字段为废弃状态。
5. enum 的 0 值表示未知状态
enum ResultCode {
RESULT_CODE_UNSPECIFIED = 0;
RESULT_CODE_OK = 1;
RESULT_CODE_FAILED = 2;
}
proto3 会把枚举的第一个值作为默认值,所以 0 最好表示“未知/未设置”,不要让它直接表示“成功”。否则字段缺失也会被误判为成功。
6. 优先使用 oneof 表达互斥关系
message MessageContent {
oneof value {
string text = 1;
ImageMeta image = 2;
AudioMeta audio = 3;
}
}
这比同时定义多个可选字段、再靠业务约定“只能设置一个”更可靠。
十四、协议设计中值得重点学习的能力
对于有 Objective-C 经验的开发者,学习重点不应停留在“怎么调用 message.data”,而应放在以下工程能力上。
1. Wire Format
理解 Varint、ZigZag、Length-delimited、字段号与 wire type,才能判断类型选择对体积和兼容性的真实影响。
2. Schema 演进
掌握新增字段、废弃字段、reserved、enum 扩展、oneof 演进,以及多版本客户端共存策略。
3. 代码生成链路
把 .proto 视为源代码,把 .pbobjc.* 视为构建产物。团队应统一生成器版本、命令和输出路径。
4. Socket 分帧
掌握固定 Header、长度字段、接收缓冲区、循环解帧、字节序、最大帧限制和异常连接处理。Protobuf 不能替代这一层。
5. 可靠消息状态机
理解本地落库、Outbox、发送、ACK、超时重试、幂等和最终状态收敛。Protobuf 只是状态机中的编码工具。
6. 安全边界
任何来自网络的长度和枚举值都不可信。先限制 Packet 大小,再解密/解压,最后解析 Protobuf,并控制单次处理的内存峰值。
7. 可观测性
日志应记录 command、sequence、message ID、Payload 长度、解析耗时和错误类型,但不要输出消息正文、令牌或完整二进制数据。
十五、推荐的工程目录和自动化策略
ProtocolModule/
├── Protocols/ # .proto 源文件
├── Generated/ # .pbobjc.h/.m
├── Scripts/ # 固定版本的生成脚本
└── Tests/ # 编解码与兼容性测试
推荐把生成文件提交到仓库,原因是:
- iOS 开发者拉取代码后不必先安装生成器;
- Code Review 能直接看到生成代码变化;
- 发布分支可以准确复现当时的协议产物。
同时在 CI 中执行一致性检查:
固定 protoc 版本重新生成
-> 与仓库中的 Generated 比较
-> 有差异则构建失败
这样可以发现“改了 .proto 忘记重新生成”和“开发者本机生成器版本不一致”两类问题。
注意:CI 的生成环境和命令应由团队维护。不要让脚本依赖某位开发者的绝对路径。
十六、建议补上的测试
1. 编解码回环测试
- (void)testChatMessageRoundTrip {
CBChatMessage *sourceMessage = [CBChatMessage message];
sourceMessage.senderId = 1001;
sourceMessage.messageId = @"test-message-id";
sourceMessage.content = [@"hello" dataUsingEncoding:NSUTF8StringEncoding];
NSError *parseError = nil;
CBChatMessage *decodedMessage =
[CBChatMessage parseFromData:sourceMessage.data
error:&parseError];
XCTAssertNil(parseError);
XCTAssertEqual(decodedMessage.senderId, sourceMessage.senderId);
XCTAssertEqualObjects(decodedMessage.messageId, sourceMessage.messageId);
XCTAssertEqualObjects(decodedMessage.content, sourceMessage.content);
}
2. 新旧协议兼容测试
保留一份旧版本编码得到的二进制 Fixture,让新代码持续解析;也可以让旧协议类读取新数据,验证新增字段不会破坏旧端。
3. Packet 边界测试
至少覆盖:
- Header 不完整;
- Payload 不完整;
- 一次收到两个 Packet;
- 一个 Packet 被拆成多次收到;
-
bodyLength为 0; -
bodyLength超过上限; - 非法 command;
- Protobuf 数据损坏。
4. 幂等测试
连续提交两次相同 message_id,验证数据库最终只有一条业务消息,并且两次都能得到确定的 ACK 结果。
十七、常见误区
误区一:用了 Protobuf 就没有粘包
错误。粘包是 TCP 字节流的边界问题,必须由 Packet Header 的长度字段和接收缓冲区解决。
误区二:二进制格式就是加密
错误。Protobuf 只是人眼不方便直接阅读,不能提供机密性和身份认证。
误区三:生成文件可以直接改
错误。任何修改都应回到 .proto,然后重新生成。
误区四:删除字段后可以复用字段号
错误。旧数据或旧客户端仍可能使用这个字段号。应使用 reserved 永久保留。
误区五:解析成功就代表消息可信
错误。解析成功只说明字节符合某种结构。权限、签名、发送方、会话归属和业务状态仍需独立校验。
误区六:protoc 版本等于 Runtime 版本
错误。一个负责生成代码,一个负责运行生成代码。必须按生成代码中的兼容检查和官方版本规则配套验证。
十八、一套完整的发送与接收链路
把前面的内容串起来,发送链路是:
1. UI 触发发送
2. Service 生成稳定 message_id
3. 消息与 Outbox 在同一数据库事务中落库
4. Mapper 把业务模型转换为 Protobuf Message
5. message.data 生成 Payload
6. Packet 层写入 Header、command、length、sequence
7. Socket 层发送完整 Packet
8. 收到 ACK 后更新本地消息状态
9. 超时则使用同一 message_id 重试
接收链路是:
1. Socket 收到任意长度的 NSData
2. 追加进接收缓冲区
3. Packet 层按 Header.length 循环切帧
4. 校验最大 Payload 长度
5. 根据 command 选择生成的 Protobuf 类型
6. parseFromData:error: 解码
7. 校验会话和业务字段
8. 使用 message_id 去重
9. 写数据库并通知界面刷新
这套分层中,Protobuf 只出现在第 4~6 步附近。把它放回完整链路中理解,就不会再把“序列化”“分包”“可靠投递”混成一个问题。
结语
Protobuf 的 API 很简单,真正有价值的部分是协议治理。
在 Objective-C IM 工程中,稳定的实践应当是:以 .proto 作为协议源文件,用固定的 protoc 26.1 生成 Objective-C 类型,通过匹配的 Runtime 完成编解码;外层 Packet 负责 TCP 消息边界,消息状态机负责 ACK、重试和幂等,数据库负责可靠落盘。
只要把这几层职责分清,再严格遵守字段号不可复用、生成文件不可手改、网络长度必须校验这些规则,Protobuf 就不只是一个“把对象转成 NSData”的工具,而会成为长期可维护的通信协议基础。