使用 Protobuf 序列化你的数据
在这篇博文中,我们将讨论 Protobuf(Protocol Buffers),这是一个可以扩展到不仅仅是简单序列化库的项目。这里展示的完整示例可以在 Github 上找到。
⚠ (2023年5月24日) 本博客已更新,并与 Conan 2.x 兼容。此外,protobuf 语法已更新到版本 3。使用 Conan 1.x 和 protobuf 版本的旧文章已 存档在此。
你可能曾经需要开发一个项目,需要在进程之间甚至在具有不同处理器架构的不同机器之间交换信息。在这种情况下,一种众所周知的技术是 序列化,它概括为将数据结构或对象状态转换为可以被双方存储和检索的格式。
什么是 Protobuf?
Protocol Buffers 是一个在 BSD 3-Clause 许可证 下的开源项目,它是 Google 开发的一个流行项目,用于提供一种与语言无关、与平台无关且可扩展的机制来序列化结构化数据。它支持许多流行的语言,例如 C++、C#、Dart、Go、Java 和 Python。虽然仍然有一些非官方的 插件 支持其他语言,例如 C。你可以在 Github 上找到源代码,它的受欢迎程度已经接近 32K 星!
Protobuf 使用的与语言无关的机制允许你通过 .proto 文件以结构化格式建模消息。
syntax = "proto3";
message Person {
string name = 1;
int32 age = 2;
optional string email = 3;
}
在上面的示例中,我们使用了一个表示个人信息的结构,它具有必填属性,例如 name 和 age,以及可选的 email 数据。必填字段,顾名思义,在构造新消息时必须填写,否则会发生 运行时错误。
但为什么不用 XML 呢?
但是,如果我们可以使用 XML 等现有的东西,为什么还要使用另一种语言和序列化机制呢?答案是 性能。
Protobuf 在序列化方面有很多优势,这些优势超出了 XML 的能力。它允许你创建比使用 XML 更简单的描述。即使对于小型消息,当需要多个嵌套消息时,阅读 XML 对人眼来说也开始变得困难。
另一个优势是大小,由于 Protobuf 格式被简化,文件的大小可以比 XML 小 10 倍。但最大的好处是它的速度,它可以比标准 XML 序列化快 100 倍,这都是因为它优化了的机制。除了大小和速度之外,Protobuf 还拥有一个能够处理 .proto
文件以生成多种支持语言的编译器,这与传统方法不同,传统方法需要在多个源文件中安排相同的结构。
听起来不错,但在现实生活中我该如何使用它呢?
为了说明 Protocol Buffers 的用法,我们将通过不同的架构和相反的语言交换消息。我们将为 armv7hf 架构编译 C++ 代码,将对象序列化到文件,并通过 Python 脚本检索。对于那些需要通过 IPC 技术(即使对于嵌入式系统)在相反的架构之间交换消息的人来说,这是一个有利的模型。
在我们的示例中,我们将使用一条包含多个传感器读数的消息。文件 sensor.proto 将表示该消息,其描述如下:
syntax = "proto3";
message Sensor {
string name = 1;
double temperature = 2;
int32 humidity = 3;
enum SwitchLevel {
CLOSED = 0;
OPEN = 1;
}
SwitchLevel door = 5;
}
变量 syntax 指的是使用的 Protobuf 版本,可以是 proto2 或 proto3。版本 2 和 3 有重要的区别,但我们在这篇文章中只讨论版本 3。有关版本 2 的更多信息,请参阅 官方文档。除了声明的属性之外,前面还突出显示了枚举器 SwitchLevel,它表示端口的状态。例如,我们还可以包含新的消息,甚至包含多个端口的列表。有关 proto 版本 3 中使用的语法的完整描述,请参阅 语言指南。
Protobuf 序列化机制是通过 protoc
应用程序提供的,此编译器将解析 .proto
文件,并根据其参数配置的语言生成输出源文件,在本例中为 C++。你还可以通过阅读 编译器调用 部分获得更多信息。
$ protoc --cpp_out=. sensor.proto
protoc
编译器将生成 sensor.pb.h
和 sensor.pb.cc
文件,它们分别包含访问属性所需的 getter 和 setter,以及序列化和解析的方法。这些文件仅作为存根,需要包含 Protobuf 分发的头文件。如果没有此编译器,我们将不得不描述代码中对象序列化所有步骤,并且对于任何新的更改,都需要更新 C++ 和 Python 文件。
现在我们有了存根,我们可以实现一个示例来序列化传感器收集的数据。文件 main.cpp
将在下面描述
#include “sensor.pb.h”
int main() {
Sensor sensor;
sensor.set_name("Laboratory");
sensor.set_temperature(23.4);
sensor.set_humidity(68);
sensor.set_door(Sensor_SwitchLevel_OPEN);
}
Sensor 对象可以通过继承自 Message 类的的方法进行序列化。例如,我们可以通过 SerializeAsString 方法序列化为字符串。
请注意,除了其他架构之外,此重建还可以由 Protobuf 支持的其他语言执行。为了使传输能够通过不同的进程发生,需要使用 IPC 技术,为此,Google 提供了 gRPC 项目,这是一个通用的 RPC 框架,它直接支持 Protobuf。但是,我们在这篇文章中的意图只是讨论 Protobuf,因此我们将仅使用文本文件作为进程之间交换消息的一种手段。
#include <fstream>
#include “sensor.pb.h”
int main() {
Sensor sensor;
sensor.set_name("Laboratory");
sensor.set_temperature(23.4);
sensor.set_humidity(68);
sensor.set_door(Sensor_SwitchLevel_OPEN);
std::ofstream ofs("sensor.data", std::ios_base::out | std::ios_base::binary);
sensor.SerializeToOstream(&ofs);
}
要通过文件执行序列化,我们使用 SerializeToOstream 方法。
构建项目
在下一步中,我们将描述使用 CMake 构建项目的步骤。
cmake_minimum_required(VERSION 3.15)
project(sensor CXX)
set(CMAKE_VERBOSE_MAKEFILE ON)
find_package(Protobuf REQUIRED CONFIG)
protobuf_generate_cpp(PROTO_SRCS PROTO_HDRS sensor.proto)
add_executable(${PROJECT_NAME} main.cc ${PROTO_SRCS} ${PROTO_HDRS})
target_link_libraries(${PROJECT_NAME} PUBLIC protobuf::libprotobuf)
target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_BINARY_DIR})
target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_11)
此配方在调用 find_package 时搜索 Protobuf 项目提供的模块、库和宏。找到并正确加载后,protobuf_generate
宏将可用。 protobuf_generate_cpp 函数负责执行 protoc
并使用其生成的文件填充 PROTO_SRCS
和 PROTO_HDRS
变量。如果没有此功能,则需要手动添加 protoc
命令和必需的参数。后续行遵循 CMake 项目中最常见的方式。因为生成的文件将在构建目录中,所以需要通过 target_include_directories 包含它,以便 main.cc
可以解析 proto.pb.h
。
还可以观察到我们正在使用 Conan 来解决 Protobuf 作为依赖项。 CMakeDeps 函数将负责生成文件 FindProtobuf.cmake
,该文件包含所有必要的变量,此外还提供目标 protobuf::protobuf
。
此外,还必须声明以下依赖项的 conanfile.txt 文件
[requires]
protobuf/3.21.9
[tool_requires]
protobuf/3.21.9
[generators]
CMakeToolchain
CMakeDeps
[layout]
cmake_layout
由于 Protobuf 可以分为两部分,protoc 可执行文件
和库,我们将添加与 requires
和 tool_requires
相同的包,以便可以安装与主机架构相同的 protoc
作为构建要求,以及作为常规要求的目标架构 (aarch64) 的库。由于我们正在为此项目使用 CMake,因此我们需要声明 CMake 生成器 CMakeDeps 和 CMakeToolchain。 CMakeDeps
生成器将负责生成 FindProtobuf.cmake
文件,而 CMakeToolchain
生成器将负责生成 conan_toolchain.cmake
文件,CMake 将使用该文件来配置项目。此外,我们声明了布局 cmake_layout,它将负责组织构建目录中的文件。
现在只需运行命令来构建项目,如果你使用的是 Linux 或 MacOS
mkdir build
cd build/
conan install .. --build=missing
cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE=Release/generators/conan_toolchain.cmake
cmake --build .
./sensor
到目前为止一切都很好,但在交叉编译的情况下如何处理?在这种情况下,需要通知编译器和目标平台。
conan install .. -pr:b=default -pr:h=aarch64 --build=missing
cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE=Release/generators/conan_toolchain.cmake -DCMAKE_CXX_COMPILER=/usr/bin/aarch-linux-gnueabihf-g++
cmake --build .
在以上命令中,Conan 已经为主机架构和构建架构安装了 Protobuf。这两种 构建和主机配置文件 都是执行交叉编译所必需的。 build
配置文件将用于为amd64(用于构建项目的机器架构)安装 Protobuf,而 host
配置文件将用于为armv8(目标架构)安装 Protobuf。 CMAKE_CXX_COMPILER
变量将用于告知编译器,在本例中,将使用armv8的编译器来编译项目。
使用 Python 解析
现在我们进入第二步,使用 Python 读取文件并检索对象。为此,我们只需要更新 CMake 脚本,以便它生成 C++ 文件以及 Python 存根。
protobuf_generate_python(PROTO_PYS sensor.proto)
add_custom_target(proto_python ALL DEPENDS ${PROTO_PYS})
protobuf_generate_python
函数与 protobuf_generate_cpp
的目标相同,但它将生成文件 sensor_pb2.py
。添加了 proto_python
虚拟目标以强制 CMake 调用 Python 的生成器。
下一步是开发一个脚本,该脚本将读取包含序列化数据的文件,并通过上一步生成的脚本对其进行解析。
from sensor_pb2 import Sensor
if __name__ == "__main__":
with open("sensor.data", 'rb') as file:
content = file.read()
print("Retrieve Sensor object from sensor.data")
sensor = Sensor()
sensor.ParseFromString(content)
print(f"Sensor name: {sensor.name}")
print(f"Sensor temperature: {sensor.temperature}")
print(f"Sensor humidity: {sensor.humidity}")
print("Sensor door: {}".format("Open" if sensor.door else "Closed"))
该脚本非常简单,就像 C++ 中的代码一样,可以直接与 sensor_pb2.py
文件一起复制到目标平台。
结论
进程间传输数据、序列化对象甚至存储数据是在所有场景中广泛使用的技术,但在实现时需要付出大量努力,并且通常不是正在开发的项目的重点。序列化技术可以通过一些可用的项目(如 Protobuf)来解决,而无需深入了解处理所有数据所需的底层细节。
使用 Protobuf 的成功之处不仅在于序列化数据,还在于其整体机制,从使用的中立语言(灵活且易于理解)到支持多种语言的编译器,甚至与其他产品(如 gRPC)的集成,后者无需太多努力即可提供进程间的直接通信。
这篇博文教程演示了如何使用现成的工具,只需几个步骤即可解决可能需要数小时才能完成的任务(包括库开发),而无需从源代码构建。
有兴趣了解更多信息或对主题发表评论?请随时打开新的 issue。