自学内容网 自学内容网

Proto3 深度解读:Protobuf 的用法与实例分析(C++)

1. 前言

1.1 序列化和反序列化

  • 序列化:把 对象转换为字节序列的过程称为 对象的序列化。
  • 反序列化:把字节序列恢复为对象的过程称为 对象的反序列化。

1.2 什么情况下要进行序列化

  • 存储数据:当你想把的内存中的对象状态保存到⼀个⽂件中或者存到数据库中时。
  • 网络传输:⽹络直接传输数据,但是⽆法直接传输对象,所以要在传输前序列化,传输完成后反序列化成对象。例如我们之前学习过socket编程中发送与接收数据。

1.3 部分序列化工具

序列化工具库/语言特点
JSONjson (Python)人类可读,广泛使用,适合简单数据交换。
Jackson (Java)
Gson (Java)
json_encode (PHP)
XMLxml.etree.ElementTree (Python)支持复杂数据结构,较为冗长。
JAXB (Java)
SimpleXML (PHP)
Protocol Buffersprotobuf高效,支持多种语言,适合性能优化场景。
Apache Avroavro用于大数据应用,支持动态模式,适合与 Hadoop 集成。
MessagePackmsgpack高效的二进制格式,适合性能敏感的应用。
ThriftThrift支持多种语言的跨平台服务,适合微服务架构。
CBORcbor二进制格式,类似 JSON,但更高效。
FlatBuffersflatbuffers高性能序列化,支持随机访问。

2. 了解Protobuf

2.1 什么是Protobuf

根据官方的文档介绍,有以下内容:

Protocol Buffers 是 Google 的一种语言无关、平台无关、可扩展的序列化结构数据的方法。它可用于数据通信协议、数据存储等。

Protocol Buffers 相较于 XML,是一种灵活、高效、自动化机制的结构数据序列化方法,但比 XML 更小、更快、更简单。你可以定义数据的结构,然后使用特殊生成的源代码轻松地在各种数据流中使用不同语言进行编写和读取结构数据。你甚至可以更新数据结构,而不破坏由旧数据结构编译的已部署程序。

Protocol Buffers(protobuf)具有以下特点:

  1. 高效性:使用二进制格式,序列化和反序列化速度快,数据体积小。
  2. 语言无关:支持多种编程语言(如C++, Java, Python等),方便跨语言使用。
  3. 平台无关:可以在不同平台之间交换数据,增强兼容性。
  4. 可扩展性:支持在不破坏旧版本的情况下添加新字段,保持向后兼容。
  5. 结构化数据:允许定义复杂数据结构,支持嵌套、枚举等。
  6. 简单易用:通过.proto文件定义数据结构,生成代码后使用简便。

正因为这些优点,所以让我们在开发过程中可以选择使用这个工具;


2.2 proto 成分

一个标准的 Protocol Buffers (proto) 文件通常包含以下几个部分:

  1. 语法声明:指明使用的语法版本,例如 syntax = "proto3";

  2. 包声明:定义消息的命名空间,例如 package user;

  3. 消息定义:定义数据结构,使用 message 关键字,例如:

    message User {
        string name = 1;
        string email = 2;
    }
    
  4. 枚举定义:定义枚举类型,使用 enum 关键字,例如:

    enum UserRole {
        ADMIN = 0;
        USER = 1;
    }
    
  5. 服务定义:用于定义 RPC 服务和方法,例如:

    service UserService {
        rpc AddUser(User) returns (UserResponse);
    }
    
  6. 选项:可以为消息、字段或包设置额外的选项,以改变生成代码的行为。

这些部分组合在一起,定义了数据的结构和如何通过 RPC 进行交互。


2.3 如何编译proto

当我们编写完了一个proto文件后,要使用protoc编译器工具,编译该proto文件并生成相应的头文件与源文件:

protoc命令有以下的常用参数:

参数描述
--proto_path=<path>指定查找 .proto 文件的目录
--cpp_out=<output_directory>生成 C++ 代码并输出到指定目录
--java_out=<output_directory>生成 Java 代码并输出到指定目录
--python_out=<output_directory>生成 Python 代码并输出到指定目录
--go_out=<output_directory>生成 Go 代码并输出到指定目录
--descriptor_set_out=<output_file>将描述符集输出到指定文件
--proto3显式指定使用 Protocol Buffers 3 语法
--include_imports在描述符集中包括所有导入的文件
--version输出 protoc 的版本信息
--help显示帮助信息,列出所有可用参数和用法

一个完整的指令为:

protoc --proto_path=./src --cpp_out=./output ./src/myfile.proto

这个指令将从 ./src 目录中查找 myfile.proto 文件,并将生成的 C++ 代码输出到 ./output 目录。


2.4 编译.proto文件后生成的文件都有什么?

生成的 pb.hpb.cc 文件通常包含以下内容:

pb.h 文件内容

  • 包含头文件:引入必要的库和其他头文件,例如 #include <google/protobuf/message.h>

  • 命名空间:通常会有一个包的命名空间,例如 namespace user { ... }

  • 消息类定义:每个定义的消息类型都会生成一个对应的类,例如:

    class User : public ::google::protobuf::Message {
    public:
        User();
        virtual ~User();
        // 方法声明
    };
    
  • 字段的访问器:每个字段通常会有访问器方法,例如 name()set_name(const std::string&)

  • 序列化/反序列化方法:例如 SerializeToString()ParseFromString() 方法。

  • 默认构造函数和析构函数:管理内存和初始化。

  • 序列化和反序列化相关的方法:例如 MergeFrom(), CopyFrom() 等。

pb.cc 文件内容

  • 实现类方法pb.h 中声明的所有方法的实现。

  • 序列化和反序列化逻辑:包括将消息转换为字节流和从字节流解析消息的代码。

  • 字段的初始值和清理:构造函数和析构函数的实现。

  • 调试和输出方法:如 DebugString() 方法,用于输出消息的文本表示。

  • 注册消息:如果使用了自定义类型,可能会有注册代码以确保 Protocol Buffers 正确处理这些类型。

这些文件是从 .proto 文件编译生成的,主要用于处理 Protocol Buffers 的数据序列化和反序列化。


3. Protobuf的使用

3.1 Protobuf的使用过程

在这里插入图片描述

根据上图内容,简述protobuf功能即:

  1. 首先编写.proto文件,其中包含对象的结构体对象message以及相关内容属性;
  2. 使用protoc编译器编译.proto文件,生成一系列包含相关接口的头文件和源文件;
  3. 引入生成的头文件,根据生成的各种接口,实现对.proto文件中定义的字段进行设置和获取,和对message对象进⾏序列化和反序列化

总结:ProtoBuf依赖通过编译生成的头文件和源文件进行使用。


3.2 上手编写实例

根据上述对ProtoBuf的认识,下面上手编写一个实例代码,使用Protobuf,
要实现的内容包括:

  • 编写一个user.proto文件,包括基本的用户信息
  • 编译该proto文件,利用生成的头文件和源文件写代码
  • 编写write.cc 与 read.cc,分别用于将输入用户信息并写入文件;从文件中读取用户信息;

user.proto:

syntax = "proto3";

package user;

message Article { // 文章
    string title = 1;
    string content = 2;
}

message ArticleList { // 文章列表
    repeated Article articles = 1; // repeated: 数组
}

message User { // 用户
    string name = 1;
    string email = 2;
    ArticleList article_list = 3;
}

message UserList { // 用户列表
    repeated User users = 1;
}

此时用protoc进行编译:

protoc --cpp_out=. user.proto

编译后,可以正确生成pb.h 与 pb.cc 文件

接下来编写 write.cc 代码:

#include <iostream>
#include <fstream>
#include "user.pb.h"

using namespace std;
using namespace user;

bool AddUser(UserList &userList) {
    cout << "------------ 新增用户 ------------" << endl;

    User *user = userList.add_users(); // 创建新用户并添加到列表

    cout << "请输入您的姓名:";
    string name;
    cin.ignore(); // 清除前一个输入的换行符
    getline(cin, name);
    user->set_name(name);

    cout << "请输入您的邮箱:";
    string email;
    cin >> email;
    user->set_email(email);

    ArticleList articleList;
    for (int i = 1;; i++) {
        cout << "输入文章信息 (quit: 退出)" << i << endl;
        string title, content;
        cout << "请输入文章标题:";
        cin.ignore();
        getline(cin, title);
        cout << "请输入文章内容:";
        getline(cin, content);

        if (title == "quit" || content == "quit") {
            cout << "------------ 退出成功 ------------" << endl;
            break;
        }

        Article *article = articleList.add_articles(); // 将文章添加到文章列表中
        article->set_title(title);
        article->set_content(content);
    }

    user->mutable_article_list()->CopyFrom(articleList); // 使用 mutable_article_list() 修改这行
    cout << "------------ 添加用户成功 ------------" << endl;
    return true;
}

int main(int argc, char *argv[]) {
    GOOGLE_PROTOBUF_VERIFY_VERSION; // 检查版本号
    if (argc != 2) {
        cerr << "Usage: " << argv[0] << " FILENAME" << endl;
        return -1;
    }

    UserList userList; // 创建 UserList 对象

    // 1. 从文件中读取用户列表
    fstream input(argv[1], ios::in | ios::binary);
    if (!input) {
        cerr << "Failed to open file: " << argv[1] << endl;
    } else {
        if (!userList.ParseFromIstream(&input)) {
            cerr << "Failed to parse user list." << endl;
            return -1;
        }
    }

    // 2. 添加用户
    while (true) {
        AddUser(userList);
        cout << "是否继续添加用户?(y/n)" << endl;
        char choice;
        cin >> choice;
        if (choice == 'n' || choice == 'N') {
            break;
        }
    }

    // 3. 将用户列表写入文件
    fstream output(argv[1], ios::out | ios::trunc | ios::binary);
    if (!output) {
        cerr << "Failed to open file: " << argv[1] << endl;
    } else {
        if (!userList.SerializeToOstream(&output)) {
            cerr << "Failed to write user list." << endl;
            return -1;
        }
    }

    // 4. 清理
    input.close();
    output.close();
    google::protobuf::ShutdownProtobufLibrary(); // 释放资源

    return 0;
}

接下来编写 read.cc 代码:

#include <iostream>
#include <fstream>
#include "user.pb.h"

using namespace std;
using namespace user;

void PrintUserInfo(const UserList& userList) {
    cout << " ----------- 用户信息 -----------" << endl;
    for (int i = 0; i < userList.users_size(); ++i) {
        const User& user = userList.users(i);
        cout << "用户名: " << user.name() << endl;
        cout << "邮箱: " << user.email() << endl;
        
        // 打印文章信息(如果有的话)
        if (user.article_list().articles_size() > 0) {
            cout << "文章列表: " << endl;
            for (int j = 0; j < user.article_list().articles_size(); ++j) {
                const Article& article = user.article_list().articles(j);
                cout << "  标题: " << article.title() << endl;
                cout << "  内容: " << article.content() << endl;
            }
        }
        cout << endl;
    }
}

int main(int argc, char* argv[]) {
    if (argc != 2) {
        cout << "Usage: " << argv[0] << " <filename>" << endl;
        return -1;
    }

    // 读取文件
    ifstream ifs(argv[1], ios::binary);
    if (!ifs) {
        cout << "Failed to open file: " << argv[1] << endl;
        return -1;
    }

    UserList userList; // 使用 UserList
    if (!userList.ParseFromIstream(&ifs)) {
        cout << "Failed to parse file: " << argv[1] << endl;
        return -1;
    }

    PrintUserInfo(userList);

    ifs.close();
    return 0;
}

此时依次执行write与read的可执行文件:

在这里插入图片描述
在这里插入图片描述


4. proto3 语法

① enum类型

要注意枚举类型的定义有以下几种规则:

  1. 0值常量必须存在,且要作为第⼀个元素。这是为了与proto2的语义兼容:第⼀个元素作为默认值,且值为0。
  2. 枚举类型可以在消息外定义,也可以在消息体内定义(嵌套)。
  3. 枚举的常量值在32位整数的范围内。但因负值无效因而不建议使用(与编码规则有关)

在定义枚举值时需要注意:

  • 同级(同层)的枚举类型,各个枚举类型中的常量不能重名。
  • 在单个 .proto 文件下,最外层枚举类型和嵌套枚举类型不算同级。
  • 在多个 .proto 文件下,如果一个文件引入了其他文件且每个文件都未声明 package,则每个 .proto 文件中的枚举类型都在最外层,算同级。
  • 在多个 .proto 文件下,如果一个文件引入了其他文件且每个文件都声明了 package,则不算同级。

此时我们可以更新 message User ,添加enum类型:

message User {
    string name = 1;
    string email = 2;
    ArticleList article_list = 3; // 确保这个字段存在
    enum Gender {
        MALE = 0;
        FEMALE = 1;
    }
    Gender gender = 4;
}

在输入值时,进行判断即可:

cout << "请输入您的性别(1. MALE 2. FEMALE)" << endl;
    int gender;
    cin >> gender;
    if (gender == 1) {
        user->set_gender(User::MALE);
    } else if (gender == 2) {
        user->set_gender(User::FEMALE);
    }

② Any 类型

对于Any等类型,google已经定义完成,在安装protobuf时可以通过查看/usr/local/include/google/protobuf路径看头文件:

在这里插入图片描述

字段还可以声明为Any类型,可以理解为泛型类型。使⽤时可以在Any中存储任意消息类型。Any类型的字段也⽤repeated来修饰;


可以按下面的代码声明一个 Any 类型的字段,允许存储任意消息类型。以下是格式化后的文本示例:

import "google/protobuf/any.proto";

message Example {
    // 声明一个 Any 类型的字段
    repeated google.protobuf.Any any_field = 1;
}

③ oneof 字段

当消息中有多个可选字段,并且同时只能有⼀个字段被设置,可以使用 oneof 加强这个行为,同时可以节约内存;

比如我们将之前例子中的User的联系方式设置为oneof

message User {
    string name = 1;
    string email = 2;
    ArticleList article_list = 3; // 确保这个字段存在
    enum Gender {
        MALE = 0;
        FEMALE = 1;
    }
    Gender gender = 4;

    // 添加 Any 类型字段
    repeated google.protobuf.Any additional_info = 5; // 可以存储任意类型的信息

    oneof contact_information {
        string phone_number = 6;
        string QQ_number = 7;
    }
}

此时在写入用户信息时,就可以加入判断:

cout << "请输入您的联系方式(1. 电话号码 2. QQ号)" << endl;
    int contactType = 0;
    cin >> contactType;
    switch(contactType) {
        case 1:
            cout << "请输入您的电话号码:";
            string phoneNumber; cin >> phoneNumber; cin.ignore();
            user->set_phone_number(phoneNumber);
            break;
        case 2:
            cout << "请输入您的QQ号:";
            string qqNumber; cin >> qqNumber; cin.ignore();
            user->set_qq_number(qqNumber);
            break;
        default:
            cout << "无效的联系方式类型" << endl;
            return false;
    }

④ map

proto3 语法支持创建一个关联映射字段,也就是可以使用 map 类型去声明字段类型,格式为:

map<key_type, value_type> map_field = N;

要注意的是:

  • key_type 是除了 floatbytes 类型以外的任意标量类型。value_type 可以是任意类型。
  • map 字段不可以用 repeated 修饰。
  • map 中存入的元素是无序的。

据此我们向message user中添加:

    map<string, string> achievement = 8;

write操作中添加:

cout << "请输入成绩【科目-成绩】(quit - 退出)" << endl;
    while (true) {
        string subject, score;
        cout << "请输入科目:";
        cin >> subject;
        if (subject == "quit") {
            break;
        }
        cout << "请输入成绩:";
        cin >> score;
        if (score == "quit") {
            break;
        }
        user->mutable_achievement()->insert({subject, score});
    }

⑤ 默认值

反序列化消息时,若被反序列化的二进制序列中不包含某个字段,反序列化对象中相应字段时,就会设置为该字段的默认值。不同的类型对应的默认值不同:

字段类型默认值
字符串空字符串 ""
字节空字节 ""
布尔值false
数值类型0
枚举第一个定义的枚举值(必须为 0
消息字段未设置该字段(取值依赖于语言)
repeated 字段空(通常是空列表)
消息字段、oneof 字段、any 字段使用 has_ 方法检测是否被设置

⑥ 更新消息

Ⅰ 更新规则

当现有的消息类型已经不再满⾜需求,比如需要扩展⼀个字段,在不破坏任何现有代码的情况下更新消息类型,遵循如下规则即可:

更新规则说明
字段编号禁止修改已有字段的字段编号;移除字段时应保留编号(使用 reserved)以防重复使用。
整型字段兼容性int32uint32int64uint64bool之间可以互相转换,不破坏前后兼容性。
sint32与sint64sint32sint64相互兼容,但不与其他整型兼容。
字符串与字节stringbytes在合法UTF-8字节下是兼容的。
bytes与嵌套消息bytes包含消息编码版本时,与嵌套消息兼容。
fixed与sfixedfixed32sfixed32fixed64sfixed64是兼容的。
enum与整型enumint32uint32int64uint64兼容,但值不匹配时会被截断。
oneof:单值更改将单独值更改为新oneof类型成员是安全和二进制兼容的。
oneof:多个字段合并若确定没有代码一次性设置多个值,多个字段可移入新oneof类型。
oneof:移入已存在的oneof将任何字段移入已存在的oneof类型是不安全的。

Ⅱ 保留字段 reversed

如果通过删除或注释掉字段来更新消息类型,未来的用户在添加新字段时,有可能会使用以前已经存在但已经被删除或注释掉的字段编号。这可能导致在使用该 .proto 的旧版本时,程序会引发许多问题,如数据损坏和隐私错误等。

确保不会发生这种情况的一种方法是使用 reserved 将指定字段的编号或名称设置为保留项。当我们再使用这些编号或名称时,Protocol Buffers 的编译器将会警告这些编号或名称不可用。

syntax = "proto3";

message Person {
    string name = 1;
    int32 id = 2;
    
    reserved 3; // 保留编号3
    reserved "email"; // 保留字段名 "email"
    
    string phone_number = 4;
}

对于上面的 代码,不能再有字段编号为3,或是字段名为“email”
因为使用了reserve后,ProtoBuf在编译阶段就会拒绝使⽤已经保留的字段编号


⑦ 未知字段

未知字段是指在解析结构良好的 Protocol Buffers(protobuf)已序列化数据时,遇到的未识别字段。例如,当旧程序解析带有新字段的数据时,这些新字段就会成为旧程序的未知字段。

本来,proto3 在解析消息时总是会丢弃未知字段,但在 3.5 版本中重新引入了对未知字段的保留机制。因此,在 3.5 或更高版本中,未知字段在反序列化时会被保留,同时也会包含在序列化的结果中。

这种情况可能发生在以下几种情况下:

  1. 版本兼容性:当使用较旧的消息格式解析较新的消息时,新的字段可能在旧版本的代码中未被定义。

  2. 扩展性:Protocol Buffers 允许开发者在不破坏现有消息结构的情况下添加新字段。因此,接收到的消息可能包含当前未识别的字段。

特点

  • 自动忽略:在解码过程中,未知字段会被自动忽略,不会引发错误。

  • 保留数据:虽然这些字段不会被当前的应用程序识别,但它们仍然会在消息中保留下来,以便将来可能的处理。

  • 二进制格式:未知字段以二进制形式存储,因此不会影响已知字段的处理。

示例

假设我们有以下 proto 定义:

syntax = "proto3";

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

如果一个旧版本的程序接收到了包含一个新字段的消息,比如:

name: "Alice"
id: 123
email: "alice@example.com" // 新字段

在旧版本中,email 字段将被视为未知字段,并在解析时被忽略。这样,程序仍然能够正常工作,而不会因为未知字段而崩溃。


⑧ 前后兼容性

  1. 向前兼容性

向前兼容性指的是,使用旧版本的客户端可以理解新版本的消息格式。这是通过以下方式实现的:

  • 添加新字段:在不影响现有字段的情况下,可以安全地添加新的字段。旧版本的解析器会忽略这些新字段。

  • 保留未知字段:从 proto3.5 开始,未知字段在解析时被保留,这使得旧版本能够处理新版本的消息,而不会丢失数据。

  1. 向后兼容性

向后兼容性指的是,使用新版本的客户端可以理解旧版本的消息格式。这是通过以下方式实现的:

  • 删除字段:可以删除某些字段,但需要确保删除的字段在所有使用该消息的代码中都是可选的,且在必要时可以进行适当的处理。

  • 字段的默认值:对于未设置的字段,新版本的解析器将使用字段类型的默认值,从而确保消息的完整性。


⑨ option 选项

.proto 文件中,可以声明许多选项,使用 option 进行标注。这些选项能影响 proto 编译器的某些处理方式。

选项的完整列表在 google/protobuf/descriptor.proto 中定义。

Ⅰ 分类

选项描述
FileOptions用于配置整个 proto 文件的行为和属性。
MessageOptions用于配置单个消息类型的行为和属性。
FieldOptions用于配置单个字段的行为和属性。
EnumOptions用于配置枚举类型的行为和属性。
EnumValueOptions用于配置枚举值的行为和属性。
ServiceOptions用于配置服务的行为和属性。
MethodOptions用于配置服务中单个方法的行为和属性。

Ⅱ 常用选项列举

optimize_for

该选项为文件选项,可以设置 protoc 编译器的优化级别,分别为 SPEEDCODE_SIZELITE_RUNTIME。受该选项影响,设置不同的优化级别,编译 .proto 文件后生成的代码内容不同。

  • SPEED:

    • protoc 编译器将生成的代码是高度优化的,代码运行效率高,但由此生成的代码编译后会占用更多的空间。SPEED 是默认选项。
  • CODE_SIZE:

    • protoc 编译器将生成最少的类,会占用更少的空间,依赖基于反射的代码来实现序列化、反序列化和各种其他操作。但与 SPEED 恰恰相反,它的代码运行效率较低。这种方式适用于包含大量的 .proto 文件,但并不盲目追求速度的应用中。
  • LITE_RUNTIME:

    • 生成的代码执行效率高,同时生成代码编译后的所占用的空间也是非常少。这是以牺牲 Protocol Buffer 提供的反射功能为代价,仅提供 encoding + 序列化 功能。因此,在链接 BP 库时,仅需链接 libprotobuf-lite,而非 libprotobuf。这种模式通常用于资源有限的平台,例如移动手机平台中。

如果要使用,直接在message中加入即可:

option optimize_for = LITE_RUNTIME;

allow_alias

allow_alias 允许将相同的常量值分配给不同的枚举常量,用来定义别名。该选项为枚举选项。

举个例子:

enum ExampleEnum {
    OPTION_ONE = 1;
    OPTION_TWO = 1; // 允许与 OPTION_ONE 共享相同的值
}

对于上面的代码,OPTION_ONEOPTION_TWO 都被分配了相同的常量值 1,这使得它们可以互相作为别名。


利用Protobuf编写一个网络服务器

👇 代码链接在👇

Protobuf Server


原文地址:https://blog.csdn.net/Dreaming_TI/article/details/142451222

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!