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

18 декабря 2017

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

Метки: .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.