← На главную

Сериализация и десериализация в/из Protobuf на C++

Некоторое время назад мы научились сериализовывать классы в языке C++ в формат JSON при помощи библиотеки RapidJSON. Формат JSON хорош тем, что он текстовый, а значит может быть прочитан человеком, что удобно при той же отладке. Плох же JSON тем, что никак не проверяет соответствие данных какой-либо схеме. Кроме того, этот формат крайне неэффективен, как минимум, потому что сериализованные объекты хранят имена всех ключей. Наконец, при работе с RapidJSON нам пришлось руками писать методы toJSON и fromJSON. Всех этих недостатков лишен формат Protobuf, с которым мы сегодня и познакомимся.

Пакет с библиотекой libprotobuf, компилятором protoc, заголовочными файлами и так далее в вашем любимом дистрибутиве Linux почти наверняка будет называться protobuf. Например, в Arch Linux пакет ставится так:

sudo pacman -S protobuf

Создадим файл Game.proto, содержащий описание наших будущих классов:

syntax = "proto3"; 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 будет примерно таким:

cmake_minimum_required(VERSION 3.1) 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 <iostream> #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:

eax.dat: 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.