3.3.2 应用层协议设计protobuf(二进制序列化协议)

文章目录

  • [3.3.2 应用层协议设计protobuf(二进制序列化协议)](#3.3.2 应用层协议设计protobuf(二进制序列化协议))
    • [1. 什么是协议设计](#1. 什么是协议设计)
    • [2. 判断消息的完整性->区分消息的边界](#2. 判断消息的完整性->区分消息的边界)
      • 1.固定长度
      • [2. 特定符号](#2. 特定符号)
      • [3. 固定消息头加消息体](#3. 固定消息头加消息体)
      • [4. 序列化后buffer前面加上一个字符流的头部,有个字符加上信息长度](#4. 序列化后buffer前面加上一个字符流的头部,有个字符加上信息长度)
    • [3. 协议设计](#3. 协议设计)
      • [1. 重点](#1. 重点)
      • [2. 设计范例](#2. 设计范例)
        • [1. IM即时通讯](#1. IM即时通讯)
        • [2. 云平台节点服务器](#2. 云平台节点服务器)
        • [3, nginx反向代理协议](#3, nginx反向代理协议)
        • [4. http](#4. http)
        • [5. redis](#5. redis)
      • [3. 序列化方法](#3. 序列化方法)
        • [1. 序列化和反序列化概念](#1. 序列化和反序列化概念)
        • [2. 什么情况下需要序列化](#2. 什么情况下需要序列化)
        • [3. 常见序列化方法(xml,json,protocolbuffer)](#3. 常见序列化方法(xml,json,protocolbuffer))
    • [4. protobuf使用](#4. protobuf使用)
    • [5. 扩充oneof](#5. 扩充oneof)
    • 6.示例

3.3.2 应用层协议设计protobuf(二进制序列化协议)

重点:

1.掌握通信协议设计原理

2.理解protobuf为什么快

3.掌握protobuf怎么在工程使用

1. 什么是协议设计

什么是协议

协议(Protocol) 是一套规则或约定,用于规范不同实体之间通信的格式、内容、顺序以及如何处理错误等。

在计算机系统中:

  • 协议定义了数据如何编码、传输与解释
  • 类似现实生活中的"语言":如果你我要对话,必须说同一种语言、知道语序、停顿、语义。

比如:

  • TCP 协议规定了传输层如何建立连接、如何可靠传输数据;
  • HTTP 协议规定了浏览器和服务器之间如何请求和响应网页;
  • 自定义的"消息格式"也是协议

为什么说进程间通信就需要协议,而不是客户端与服务端之间


客户端与服务端之间的通信就是一种进程间通信(跨主机或本地)。进程间通信(IPC)包括:

  • 本地 IPC:如共享内存、管道、消息队列;
  • 网络 IPC:如 TCP/IP 通信(典型是客户端-服务器模式)。

不管哪种,只要两个独立进程 之间要交换数据,就必须 达成"怎么解释这些数据 "的共识 ------ 即需要 协议

为什么需要自己设计协议

原因 描述
效率需求 可能只需要几个字节传输,不需要完整 HTTP 报文(太臃肿)
场景定制 系统消息结构是固定/可预测的,用现成协议反而复杂
资源受限 在嵌入式或无人机/无人船等平台,资源宝贵,自己设计协议可以压缩带宽和内存使用
安全性 有些自定义协议对攻击者来说是"黑盒",可降低被攻击面
功能扩展方便 可以灵活设计ACK机制、心跳包、数据校验等机制

举例:简单的自定义协议

plain 复制代码
[HEAD][TYPE][LEN][DATA][CRC]
  • HEAD:帧头(0xAA55);
  • TYPE:消息类型(如心跳/导航指令);
  • LEN:数据长度;
  • DATA:数据本体;
  • CRC:校验码。

这种协议既精简又高效,适用于 USV/UAV 协同这类低时延、低资源的跨平台通信。

2. 判断消息的完整性->区分消息的边界

1.固定长度

  • 每条消息占用固定的字节数,没有冗余的长度字段
  • 接收方按照"每N字节一条消息"读取和处理

优点:

  • 解析速度极快,无需动态判断;
  • 非常适合带宽有限、实时性强的嵌入式或硬实时系统;
  • 实现简单,不需要缓冲区滚动或多线程解析器。

缺点:

  • 缺乏灵活性,不能应对可变数据量;
  • 可能造成 空间浪费(实际数据 < 固定长度);
  • 不适合复杂的交互系统或信息种类过多的系统。

2. 特定符号

  • 用特殊字符/标志表示消息开始和结束,如:
plain 复制代码
<SOF>数据内容<EOF>

举例:

  • GPS NMEA 协议:$GPRMC,.....*校验码\r\n
  • 串口通信中常见 <STX>...<ETX>

优点:

  • 可变长度支持;
  • 清晰易读,调试方便(尤其串口、日志里常用)。

缺点:

  • 需要转义特殊字符(若内容中也出现SOF/EOF字符);
  • 协议解析器略复杂
  • 对字节流完整性要求较高(中间SOF/EOF丢失会错位)

3. 固定消息头加消息体

  • 报文格式一般为:
plain 复制代码
| HEAD | TYPE | LENGTH | BODY | CRC |
  • 解析时先识别HEAD,再读取LENGTH字段确定接收多少字节。

举例:

  • 工业协议如 Modbus、CAN、无人机MAVLink

优点:

  • 通用性最强;
  • 支持变长数据,适合异构系统通信;
  • 能加校验码(CRC)、消息ID(用于ACK、重传等)等高级特性

缺点:

  • 相比纯固定长度复杂

4. 序列化后buffer前面加上一个字符流的头部,有个字符加上信息长度

原理

  • 先用 JSON、Protobuf 等序列化数据;
  • 再加上前缀(如总长度),如:
plain 复制代码
[4字节长度][序列化后的内容]

举例:

  • Redis 使用 RESP 协议(文本前缀 + 换行符);
  • HTTP/2、gRPC、Kafka 等使用 [length][payload];
  • socket.io、Thrift 等也有类似机制。

优点:

  • 灵活性最高;
  • 可嵌套结构体(如嵌入式系统与服务器对接);
  • 配合现成的库(Protobuf/Flatbuffers)效率也很高

缺点:

  • 要引入额外序列化库;
  • 消息体结构需要双方都了解(或共享 schema)
  • 带宽、内存资源敏感的设备慎用(不适合裸片/超轻系统)

3. 协议设计

1. 重点

  1. 消息边界
  2. 版本区分
  3. 消息类型区分
  4. 消息budy序列化协议选择

2. 设计范例

1. IM即时通讯
  1. 消息边界:第一个字节作为帧的起始
  2. 版本号:靠前放
  3. appid:识别不同客户
  4. service_id:对应命令的分类组
  5. commend_id:分组里面的子命令
  6. seq_num:消息序号(业务序号)
  7. reserve:保留
    头有十六个字节
  8. body
2. 云平台节点服务器
  1. STAG:通信协议数据开始标记
  2. version:版本号
  3. checksum:校验和,crc校验
  4. type:协议体格式(json,xml,protobuf等)
  5. seqno:序列号
  6. length:报文长度
  7. reserve:保留
    头有十八个字节
  8. body
3, nginx反向代理协议

Nginx 自身协议不多,但它支持例如:

  • HTTP(标准格式)
  • FastCGI、GRPC、Websocket 转发协议
  • 内部也可做 X-Forwarded-For、X-Real-IP 等头部协议标注。

实践中经常会在 Header 中加自定义字段来传递用户/设备上下文

4. http

两个换行符之后就是body,复杂

5. redis

缺点:

  • 基于行文本协议 ,有前缀 *$ 等表示参数数量/长度;
  • 单行或多行结构都可以表示;
  • 高效解析、极简实现、支持 pipeline。

优点:

  • 文本协议,调试方便;
  • 对性能也进行了很好的优化

3. 序列化方法

1. 序列化和反序列化概念

序列化(Serialization)

将内存中的对象(数据结构)转换为可存储或传输的格式

常用于把:

  • 内存对象 → 字节流 / 字符串 / 文件 / 网络报文

比如把结构体 Person{name="Tom", age=25} 转换成:

  • "{"name":"Tom","age":25}"(JSON)
  • 0A 03 54 6F 6D 10 19(protobuf 编码)

反序列化:

把序列化后的格式重新转换回内存中的对象

它是序列化的逆过程,接收到字节流或字符串后重新还原成程序对象

2. 什么情况下需要序列化

场景一:网络通信

  • 客户端和服务端要通过网络传输结构化数据;
  • 需要把对象编码成"可发送的格式";
  • 常用于 API 通信、微服务通信、远程调用(RPC)。

场景二:数据存储

  • 对象持久化到磁盘、缓存、数据库等;
  • 比如存储用户配置、本地缓存等。

场景三:跨语言/平台通信

  • 不同语言之间的数据交互(如 Python ↔ C++);
  • 需要通用协议(JSON、protobuf)
3. 常见序列化方法(xml,json,protocolbuffer)
序列化格式 可读性 压缩效率 跨语言 编码速度 使用场景举例
JSON ✅ 高 ❌ 一般 ✅ 强 ✅ 快 Web API、配置文件
XML ✅ 高 ❌ 差 ✅ 强 ❌ 慢 配置、SOAP协议
protobuf ❌ 差 ✅ 高 ✅ 强 ✅ 非常快 微服务、IoT传输

JSON(JavaScript Object Notation)

plain 复制代码
{"name":"Alice","age":23}
  • 文本格式,易读易写;

XML(eXtensible Markup Language)

plain 复制代码
<person><name>Alice</name><age>23</age></person>
  • 结构清晰,冗余更高;

Protocol Buffers(Google 出品)

plain 复制代码
message Person {
  string name = 1;
  int32 age = 2;
}
  • 编译成高效二进制格式
  • 非常适合高性能、低带宽传输场景
  • 支持版本升级、字段可选

4. protobuf使用

由 Google 提出的一种语言无关、平台无关、可扩展的结构化数据序列化方法

IDL:接口描述语言'

protobuf协议的工作流程:

(1)编写 .proto 文件(IDL,接口描述语言)

plain 复制代码
syntax = "proto3";

message Person {
  int32 id = 1;
  string name = 2;
  string email = 3;
}

(2)用 protoc 编译器生成语言对应的代码

plain 复制代码
protoc --cpp_out=. person.proto

生成文件包括:

  • person.pb.h:头文件,包含类声明
  • person.pb.cc:源文件,包含实现

(3)在代码中使用

plain 复制代码
#include "person.pb.h"

Person p;
p.set_id(123);
p.set_name("Alice");
p.set_email("[email protected]");

string data;
p.SerializeToString(&data);  // 序列化成二进制字符串

Person p2;
p2.ParseFromString(data);    // 反序列化回来

(4)编译时记得加上 .pb.cc

plain 复制代码
g++ main.cpp person.pb.cc -lprotobuf -o myprog

protobuf为什么省字节:

1)只传编号,不传字段名

  • Protobuf 会将每个字段赋予一个唯一编号(比如 name = 2
  • 序列化时不会发送"name",而是发送编号 2 + 内容
  • 这比 JSON 里 "name":"Alice" 要节省非常多

2)可变长度编码(Varint)

  • 小数字用更少字节表示,比如:
    • int32 id = 1; → 值为 5 时只用 1 个字节
  • 不像固定长度的 int32 总是占 4 字节

3)不传默认值

  • 字段没设置时不会被序列化(默认值省略)
  • 例如,string name = "" 不会写入输出数据

3. protobuf使用实例以及工程经验

  1. proto文件命名规则:工程名.模块名.proto
  2. proto命名空间
  3. 引用文件
plain 复制代码
import "auth/user.proto";

message LoginRequest {
  auth.User user = 1;
}
  1. 多个平台使用同一份proto文件(独立git去维护这个git文件)

4. protobuf编码原理

  1. TLV结构
  • T(Tag):字段编号 + wire type
  • L(Length):字段长度(部分类型,如 string, bytes)
  • V(Value):字段值本身

例如:

plain 复制代码
message Person {
  int32 id = 1;
  string name = 2;
}

会被编码为:

plain 复制代码
[Tag=8][Varint=id] [Tag=18][Length][String=name]

2.为什么要用变长编码

原理(Base128 编码):

  • 一个字节只有 7 位存储有效数据,第 8 位(MSB)表示是否还有下一个字节。
  • 最后一字节的 MSB 为 0,其他为 1。
数值 二进制 Base128 表示(字节流)
1 00000001 0x01
300 100101100 0xAC 0x02

优点:

  • 小整数压缩特别高效(如常见的 id、flag)
  • 节省带宽与存储

缺点:

  • 编码解析稍复杂(需 bit 操作)

和固定长度编码区别

类型 描述 优点 缺点 场景
可变长度(Varint) int32、int64、bool 等 小数字更节省 编解码复杂,性能略差 id、flag、状态位、枚举等
固定长度 fixed32、fixed64 编解码快,CPU 缓存友好 小数字浪费空间 经纬度、时间戳、浮点数、hash值等

5. protobuf的数据组织,协议消息升级

  1. 新增字段
plain 复制代码
message User {
  uint32 id = 1;
  string name = 2;
  string email = 3; // 新增字段
}
  • 老版本:直接忽略 email
  • 新版本:可正常使用 email
  1. 删除字段的错误方式
plain 复制代码
message User {
  uint32 id = 1;
  // 删除 name = 2 (错误❌)
}

正确方式是:

plain 复制代码
message User {
  uint32 id = 1;
  reserved 2;
  reserved "name";
}
  1. 字段编号变动(大坑)
plain 复制代码
// 老版本
string nickname = 2;

// 新版本(错误)
string nickname = 4;  // ❌ 改了 tag 编号

老版本程序会错误地把别的字段解析成 nickname,数据混乱


向前兼容 vs 向后兼容

兼容方向 定义 例子
向前兼容 新代码能读取老数据 老版本写的 protobuf,新代码能读
向后兼容 老代码能读取新数据(忽略新增字段) 老版本代码能跑新版消息

Protobuf 默认支持 向后兼容,即新增字段自动被老版本忽略

5. 扩充oneof

一种节省空间并简化协议字段表示的方法

  1. 可以安全添加新字段
plain 复制代码
message Action {
  oneof cmd {
    string login = 1;
    string logout = 2;
    string heartbeat = 3; // ✅ 安全新增
  }
}

兼容性说明

  • 向后兼容 :老版本程序会忽略 heartbeat
  • 向前兼容 :新版本能正常解析老版本发出的 login/logout
  1. 不可删除已有字段
plain 复制代码
// 错误示范
message Action {
  oneof cmd {
    string login = 1;
    // 删除 logout = 2 ❌
  }
}

即使客户端不再使用某个字段,也不要删除,应该使用 reserved

plain 复制代码
reserved 2;
reserved "logout";

6.示例

  1. message.proto
plain 复制代码
syntax = "proto3";
package demo;

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

  1. main.cpp
plain 复制代码
#include <iostream>
#include "message.pb.h"

int main() {
    demo::HelloRequest req;
    req.set_name("HIT");

    demo::HelloReply rep;
    rep.set_message("Hello " + req.name());

    std::cout << rep.message() << std::endl;
    return 0;
}

  1. CMakeLists.txt
plain 复制代码
cmake_minimum_required(VERSION 3.10)
project(protobuf_demo)
#寻找系统中安装的 Protobuf 库
find_package(Protobuf REQUIRED)

# 编译 .proto 文件为 .pb.h 和 .pb.cc
protobuf_generate_cpp(PROTO_SRCS PROTO_HDRS message.proto)

#创建一个可执行程序 main,它会编译 main.cpp 和刚才生成的 .pb.cc 文件
add_executable(main main.cpp ${PROTO_SRCS} ${PROTO_HDRS})

#把 protobuf 的库链接进可执行程序中,比如 libprotobuf.a 或 libprotobuf.so
target_link_libraries(main PRIVATE ${Protobuf_LIBRARIES})

指定 .pb.h 的头文件路径,告诉编译器去哪里找
target_include_directories(main PRIVATE ${Protobuf_INCLUDE_DIRS})

  1. 编译步骤
plain 复制代码
mkdir build
cd build
cmake ..
make
./main
# 输出:Hello HIT
相关推荐
就新年快乐吧5 分钟前
【HD-RK3576-PI】定制用户升级固件
linux
Aska_Lv9 分钟前
RocketMQ---core原理
后端
AronTing14 分钟前
10-Spring Cloud Alibaba 之 Dubbo 深度剖析与实战
后端·面试·架构
爬菜16 分钟前
进程(完)
linux
2401_8582861118 分钟前
CD27.【C++ Dev】类和对象(18)友元和内部类
开发语言·c++·类和对象
没逻辑18 分钟前
⏰ Redis 在支付系统中作为延迟任务队列的实践
redis·后端
(王子变青蛙)20 分钟前
C++初始
开发语言·c++·程序人生
莫有杯子的龙潭峡谷20 分钟前
4.15 代码随想录第四十四天打卡
c++·算法
雷渊20 分钟前
如何保证数据库和Es的数据一致性?
java·后端·面试
fjkxyl21 分钟前
Spring的启动流程
java·后端·spring