Сериализация и десериализация в/из Protobuf на C++
18 декабря 2017
Некоторое время назад мы научились сериализовывать классы в языке C++ в формат JSON при помощи библиотеки RapidJSON. Формат JSON хорош тем, что он текстовый, а значит может быть прочитан человеком, что удобно при той же отладке. Плох же JSON тем, что никак не проверяет соответствие данных какой-либо схеме. Кроме того, этот формат крайне неэффективен, как минимум, потому что сериализованные объекты хранят имена всех ключей. Наконец, при работе с RapidJSON нам пришлось руками писать методы toJSON и fromJSON. Всех этих недостатков лишен формат Protobuf, с которым мы сегодня и познакомимся.
Пакет с библиотекой libprotobuf, компилятором protoc, заголовочными файлами и так далее в вашем любимом дистрибутиве Linux почти наверняка будет называться protobuf. Например, в Arch Linux пакет ставится так:
Создадим файл Game.proto, содержащий описание наших будущих классов:
package me.eax.examples.game;
enum Spell {
FIREBALL = 0;
THUNDERBOLT = 1;
}
enum Weapon {
SWORD = 0;
BOW = 1;
}
message WarriorInfo {
Weapon weapon = 1;
int64 arrows_number = 2;
}
message MageInfo {
repeated Spell spellbook = 1;
int64 mana = 2;
}
message Hero {
string name = 1;
int64 hp = 2;
int64 xp = 3;
oneof class_specific_info {
WarriorInfo warrior_info = 4;
MageInfo mage_info = 5;
}
}
Пример позаимствован из заметки Зачем нужен Thrift и основы работы с ним на Scala. Форматы Thrift и Protobuf очень похожи и различаются в несущественных моментах. Для этой заметки я выбрал Protobuf просто потому что раньше мне не доводилось с ним работать, и было интересно попробовать.
Fun fact! Раньше в Protobuf было разделение на required и optional поля. Однако в Protobuf 3 все поля являются optional. Опыт показывает, что даже поля, которые изначально кажутся required, время от времени все равно приходится объявлять устаревшими. Поэтому в целом от них больше вреда, чем пользы.
Fun fact! Protobuf позволяет превращать поля в repeated-поля (то есть, списки), с сохранением обратной совместимости.
Думаю, что proto-файл не нуждается в особых пояснениях, поэтому пойдем дальше.
Если вы собираете проект при помощи CMake, содержимое CMakeLists.txt будет примерно таким:
project(protobuf-example)
include_directories(include)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED on)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra -Werror")
include(FindProtobuf)
find_package(Protobuf REQUIRED)
include_directories(${PROTOBUF_INCLUDE_DIR})
# to find *.bp.h files
include_directories(${CMAKE_CURRENT_BINARY_DIR})
protobuf_generate_cpp(PROTO_SRC PROTO_HEADER src/proto/Game.proto)
add_library(proto ${PROTO_HEADER} ${PROTO_SRC})
add_executable(main src/Main.cpp)
target_link_libraries(main proto ${PROTOBUF_LIBRARY})
А вот и пример кода, использующего объявленные выше классы:
#include <fstream>
#include <stdexcept>
#include <Game.pb.h>
using namespace std;
using namespace me::eax::examples::game;
void saveHero(const char* fname, const Hero& hero) {
fstream out(fname, ios::out | ios::trunc | ios::binary);
if(!hero.SerializeToOstream(&out))
throw runtime_error("saveHero() failed");
}
void loadHero(const char* fname, Hero& hero) {
fstream in(fname, ios::in | ios::binary);
if(!hero.ParseFromIstream(&in))
throw runtime_error("loadHero() failed");
}
void printHero(const Hero& hero) {
cout << "Name: " << hero.name() << endl;
cout << "HP: " << hero.hp() << endl;
cout << "XP: " << hero.xp() << endl;
if(hero.has_mage_info()) {
cout << "Class: mage" << endl;
cout << "Spellbook: ";
for(int i = 0; i < hero.mage_info().spellbook_size(); i++) {
switch(hero.mage_info().spellbook(i)) {
case Spell::FIREBALL:
cout << "fireball, ";
break;
case Spell::THUNDERBOLT:
cout << "thunderbolt, ";
break;
default:
cout << "(unknown spell), ";
break;
}
}
cout << endl;
cout << "Mana: " << hero.mage_info().mana() << endl;
} else if(hero.has_warrior_info()) {
cout << "Class: warrior" << endl;
cout << "Weapon: " << (
hero.warrior_info().weapon() == Weapon::SWORD ? "sword" :
hero.warrior_info().weapon() == Weapon::BOW ? "bow" :
"(unknown weapon)"
) << endl;
cout << "Arrows: " << hero.warrior_info().arrows_number()
<< endl;
} else {
cout << "Class: (unknown class)" << endl;
}
cout << endl;
}
int main() {
// Verify that the version of the library that we linked against is
// compatible with the version of the headers we compiled against.
GOOGLE_PROTOBUF_VERIFY_VERSION;
Hero warrior;
warrior.set_name("eax");
warrior.set_hp(50);
warrior.set_xp(256);
warrior.mutable_warrior_info()->set_weapon(Weapon::SWORD);
warrior.mutable_warrior_info()->set_arrows_number(15);
Hero mage;
mage.set_name("afiskon");
mage.set_hp(25);
mage.set_xp(1024);
mage.mutable_mage_info()->add_spellbook(Spell::FIREBALL);
mage.mutable_mage_info()->add_spellbook(Spell::THUNDERBOLT);
mage.mutable_mage_info()->set_mana(100);
cout << "Saving heroes..." << endl;
saveHero("eax.dat", warrior);
saveHero("afiskon.dat", mage);
cout << "Loading heroes..." << endl;
Hero warrior2;
Hero mage2;
loadHero("eax.dat", warrior2);
loadHero("afiskon.dat", mage2);
cout << endl;
printHero(warrior2);
printHero(mage2);
}
Опять же, не думаю, что приведенный код нуждается в каких-либо пояснениях. Отмечу только, что размер файлов eax.dat и afiskon.dat составил 14 и 22 байта соответственно. Впечатляет, если вспомнить, что каждый класс содержит по крайней мере 3 поля с типом int64, которые занимают 24 байта в несжатом виде. Давайте посмотрим на файлы поближе, попытавшись декодировать их в соответствии с описанием формата Protobuf:
00000000 0a 03 65 61 78 10 32 18 80 02 22 02 10 0f
0a 00001 010 - 1 = тэг поля, 2 = тип поля (string/bytes/...)
03 длина строки
65 61 78 строка "eax" (Hero.name)
10 00010 000 - 2 = тэг поля, 0 = тип поля (variant)
32 0 0110010 = 50 (Hero.hp)
18 00011 000 - 3 = тэг поля, 0 = тип поля (variant)
80 02 1 0000000 0 0000010 => 0000010 0000000 = 256 (Hero.xp)
22 00100 010 - 4 = тэг поля, 2 = тип поля (string/bytes/...)
02 длина вложенного сообщения (WarriorInfo)
10 00010 000 - 2 = тэг поля, 0 = тип поля (variant)
0f 0 0001111 = 15 (WarriorInfo.arrows_number)
afiskon.dat:
00000000 0a 07 61 66 69 73 6b 6f 6e 10 19 18 80 08 2a 06
00000010 0a 02 00 01 10 64
0a 00001 010 - 1 = тэг поля, 2 = тип поля (string/bytes)
07 длина строки
61 66 69 строка "afiskon" (Hero.name)
73 6b 6f
6e
10 00010 000 - 2 = тэг поля, 0 = тип поля (variant)
19 0 0011001 = 25 (Hero.hp)
18 00011 000 - 3 = тэг поля, 0 = тип поля (variant)
80 08 1 0000000 0 0001000 => 0001000 0000000 = 1024 (Hero.xp)
2a 00101 010 - 5 = тэг поля, 2 = тип поля (string/bytes/...)
06 длина вложенного сообщения (MageInfo)
0a 00001 010 - 1 = тэг поля, 2 = тип поля (string/bytes/...)
02 длина списка
00 Spell::FIREBALL
01 Spell::THUNDERBOLT
10 00010 000 - 2 = тэг поля, 0 = тип поля (variant)
64 0 1100100 = 100 (MageInfo.mana)
Как видите, ничего сверх сложного! Интересно, что значение Weapon::SWORD
вообще не было закодировано. Другими словами, Protobuf не позволяет отличить случаи, когда поле отсутствует, и когда оно имеет «значение по умолчанию», как в нашем примере. Кстати, используя описанные здесь знания, я написал расширение для PostgreSQL под названием pg_protobuf, которое может вас заинтересовать.
Полную версию исходников к этой заметке вы найдете на GitHub. Кроме того, обратите внимание на официальную документацию по Protobuf. Там вы найдете информацию обо всех поддерживаемых типах, включая bool, double и map, а также примеры использования Protobuf на Java, Go, Python, и не только.
А во что вы нынче предпочитаете сериализовывать ваши данные — Protobuf, Thrift, Avro или, быть может, во что-то другое?
Дополнение: Возможно, вас также заинтересует пост Сериализация в языке Go на примере библиотеки codec.
Метки: C/C++.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.