Некоторое время назад мы научились сериализовывать классы в языке 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 .