Все программы работают с данными. Эти данные надо где-то хранить и иногда куда-нибудь передавать. Для того и другого придумали много полезного. Но вот часто в самой программе мы работаем с этими данными совсем в другом виде и нам приходится писать много кода, чтобы засейвить состояние объектов в ПО. Сегодня мы узнаем, как избежать написания килобайтов вспомогательного кода сериализации.

Для начала небольшой ликбез. Сериализация — это процесс перевода какой-либо структуры данных в последовательность байт. Эта последовательность может быть как бинарным представлением этих данных, так и текстовым. В большинстве случаев сериализация нужна для сохранения состояния программы на жесткий диск или пересылки каких-либо сообщений по сети. Распаковка сериализованных данных называется десериализацией.

 

Сериализация своими руками

Когда перед программистом встает задача упаковки структур данных, например для их последующей передачи по сети, у него есть несколько путей, по которым он может пойти. Один из них — написать все самому, с нуля. Но и тут перед ним открывается развилка из трех дорог.

Самый простой и довольно популярный способ — это представить все данные в виде строк. В этом случае на выходе мы получим поток ASCII-символов (а может быть, и не ASCII), который затем будет передан по сети или записан в файл. Если попробовать набросать схематичный код, то он будет выглядеть примерно так:

Сериализация в строки

class MyClass
{
    int x;
    int y;
    std::string str;

public:
    void MyClass()
    {
        x = 120;
        y = 23;
        str = "some string"
    }

    std::string save()
    {
        std::stringstream out;
        out << x << '\n' << y << '\n' << str;
        return out.str();
    }
}

На выходе функции save мы получим примерно такую строку: «120\n23\nsome string». Плюсы этого подхода в том, что данные остаются сравнительно читаемыми для человека, а сама реализация проста и не требует специальных знаний. А основным минусом тут будет то, что представление структур в виде строки подойдет только для очень простых наборов данных. К тому же придется писать довольно много кода для кодирования и декодирования, а скорость его выполнения будет оставлять желать лучшего.

Другой популярный метод это запись данных в XML. Разнообразных библиотек, занимающихся парсингом XML-файлов, можно насчитать великое множество, что упрощает процесс написания механизмов сериализации. Данные в этом случае представлены еще нагляднее, да и гибкость тут на высоте. Многие популярные протоколы используют этот язык разметки в качестве своей основы, так как он расширяемый и позволяет не сильно задумываться об обратной совместимости при обновлении структуры данных. К таким протоколам можно отнести SOAP или Jabber.

Но, как и в случае с предыдущим способом, чтение и запись данных в XML-формат накладывает большие ограничения на производительность. Навигация по дереву неслабо нагрузит процессор, да и код все-таки тоже придется немного дописать, чтобы все работало так, как задумано. Еще один минус, о котором многие забывают в эпоху высокоскоростного интернета, — это размер получаемых данных. При достаточно больших объемах информации или плохих сетевых соединениях использовать XML не очень целесообразно.

Ну и наконец, последний метод, который получил широкое распространение, — это упаковка в бинарный вид. В этом случае мы практически полностью теряем читабельность сериализованных данных, но зато значительно выигрываем в скорости их парсинга и выходном объеме. Все бы хорошо, но в случае байтового представления информации мы получаем кучу проблем с совместимостью при изменении структур данных в программе, а также тратим много усилий на поддержку кода упаковки и распаковки в актуальном состоянии.

 

Protocol Buffers from Google

Все недостатки перечисленных методов призван устранить protobuf от Гугла. Protocol Buffers — это специальный метод кодирования структур данных, который позволяет быстро и без проблем сериализовать все что угодно. PB имеет официальную поддержку от Гугла для таких языков, как C++, Java и Python. Эта поддержка выражается в наличии компилятора для специального языка, описывающего структуры данных.

Начать работу с сериализацией от поискового гиганта очень просто. Сперва следует описать данные в proto-файле. Представим, что мы делаем тулзу, которая работает со списком людей и номерами их кредитных карт. На языке protobuf требуемые структуры данных будут выглядеть примерно так:

Описание данных в proto-файле

package CardsApp;

message CardHolder {
    required string firstName = 1;
    required string lastName = 2;
    required int32 id = 3;

    enum CardType {
        VISA = 0;
        MASTERCARD = 1;
        AMERICANEXPRESS = 2;
    }

    message CreditCard {
        required string cardNumber = 1;
        optional CardType type = 2 [default = VISA];
    }

    repeated CreditCard card = 4;
}

message CardHoldersList {
    repeated CardHolder person = 1;
}

Беглый взгляд по содержимому вызовет легкое чувство дежавю у C++- и Java-кодеров. И действительно, синтаксис очень похож. В начале файла находится строка с именем пакета. Она определяет область видимости данных и служит для предотвращения конфликта имен. Далее следует блок с сообщениями, которые начинаются с ключевого слова message. Эти конструкции являются аналогами структур в C++. Поля сообщения поддерживают такие типы данных, как bool, string, int32 и так далее. Например, поле firstName является строковой переменной. В начале объявления этой переменной находится ключевое слово required. Из названия нетрудно догадаться, что это поле должно быть всегда инициализировано. Всего таких спецификаторов может быть три: required, optional и repeated. Optional говорит протобаф-компилятору, что поле может быть не инициализировано, а repeated сообщает о возможности неоднократного повторения переменной, описанной с помощью этого спецификатора в структуре данных. Кроме того, каждый элемент имеет так называемые таги (десятичная цифра после знака равно в конце объявления).

В примере видно, что, помимо сообщений, мы можем определять перечисления, а сами messages могут быть вложены одно в другое и использоваться как пользовательские типы при объявлении элементов других сообщений. Все это обещает нам, простым программистам, высокую гибкость и кучу удовольствия от работы с кодом.

После правильного описания используемых нами данных скормим наш протофайл специальному компилятору. Скачать его можно по ссылке на врезке. Поскольку Google официально поддерживает целых три языка программирования, то компилеру надо знать, под какой язык мы генерируем код. Если наш файл называется cardholders.proto, то для успешного завершения операции командная строка будет выглядеть примерно так:

protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/cardholders.proto

Тут стоит обратить внимание на параметр —cpp_out, именно он определяет, что мы генерируем код для C++. Заглянув в получившийся на выходе cradsapp.pb.h, можно найти много всего интересного. Заголовочный файл получился достаточно объемный, и полный его листинг тут показать сложно, но зато без проблем можно разобраться с некоторыми его кусками.

C++-код для сообщения CardHolder

// firstName
inline bool has_firstName() const;
inline void clear_firstName();
inline const ::std::string& firstName() const;
inline void set_firstName(const ::std::string& value);
inline void set_firstName(const char* value);
inline ::std::string* mutable_firstName();

// lastName
inline bool has_lastName() const;
inline void clear_lastName();
inline const ::std::string& lastName() const;
inline void set_lastName(const ::std::string& value);
inline void set_lastName(const char* value);
inline ::std::string* mutable_lastName();

// id
inline bool has_id() const;
inline void clear_id();
inline int32_t id() const;
inline void set_id(int32_t value);

// card
inline int card_size() const;
inline void clear_card();
inline const ::google::protobuf::RepeatedPtrField< ::CardsApp::CardHolder_CreditCard >& card() const;
inline ::google::protobuf::RepeatedPtrField< ::CardsApp::CardHolder_CreditCard >* mutable_card();
inline const ::CardsApp::CardHolder_CreditCard& card(int index) const;
inline ::CardsApp::CardHolder_CreditCard* mutable_card(int index);
inline ::CardsApp::CardHolder_CreditCard* add_card();

Мы видим, что для каждого поля сообщения CardHolder сгенерился метод clear_xxx(), где вместо xxx имя поля. По названию не трудно догадаться, что он делает. Также присутствуют методы has_ и set_. Значение элемента получается через функцию с именем, аналогичным имени этого элемента. Особое внимание следует уделить полю card. Из-за того что мы его пометили как repeated, код для него получился немного другой. В частности, у нас есть метод size(), который возвращает количество банковских карт, закрепленных за человеком, а также метод add, служащий для добавления элемента к уже существующим. Поля с пометкой optional или repeated могут предоставить доступ к сырому указателю с помощью mutable_ «getter». Для более детального изучения того, что нагенерил protoc от «корпорации добра», крайне рекомендуется заглянуть внутрь получившегося h-файла.

Помимо перечисленных методов, каждый класс Protocol Buffer имеет функции сериализации в бинарное представление и парсинга этого представления.

Методы для парсинга и сериализации

bool SerializeToString(string* output) const;
bool ParseFromString(const string& data);
bool SerializeToOstream(ostream* output) const;
bool ParseFromIstream(istream* input); 

Тут следует заметить, что serialize-методы используют STL-строку лишь в качестве контейнера для бинарных данных. Не стоит надеяться, что, заглянув внутрь string-переменной, можно будет обнаружить хоть сколько-нибудь читаемый текст.

Теперь мы наконец добрались до самого интересного — использования полученных классов на практике. Это очень просто. Чтобы убедиться в этом, достаточно взглянуть на код ниже.

Сериализация и десериализация protobuf

int main(int argc, char* argv[]) 
{
    GOOGLE_PROTOBUF_VERIFY_VERSION;

    CardsApp::CardHoldersList card_holders;

    // Добавляем кардхолдера
    AddToCardHolders(card_holders.add_person());

    // Записываем в файл
    fstream output(argv[1], ios::out | ios::trunc | ios::binary);
    if (!address_book.SerializeToOstream(&output)) 
    {
        cerr << "Failed to write file." << endl;
        return -1;
    }

    // Считываем из файла
    fstream input(argv[1], ios::in | ios::binary);
    if (!input) 
    {
        cout << argv[1] << ": File not found. Creating a new file." << endl;
    } 
    else if (!card_holders.ParseFromIstream(&input))
    {
        cerr << "Failed to parse file." << endl;
        return -1;
    }

    // Выводим на экран
    ListCardHolders(card_holders);

    // Очистка памяти
    google::protobuf::ShutdownProtobufLibrary();

    return 0;
}

Первое, что мы видим в main-функции, — это макрос проверки версии протобафа — GOOGLE_PROTOBUF_VERIFY_VERSION. Если мы прилинковали библиотеку, которая не поддерживает сгенерированный нами заголовочный файл, то нам об этом сообщат. Далее мы объявляем переменную card_holders, которая будет содержать все записи о владельцах карт. На текущий момент там пусто, и мы добавляем туда одну запись вызовом функции AddToCardHolders, код которой рассмотрим чуть ниже. Для сохранения всего это в файле мы открываем файловый поток и вызываем SerializeToOstream.

Чтобы убедиться в том, что все прошло нормально, прочитаем сериализированные сообщения из только что созданного протобафом файла. Для этого создадим еще один поток с атрибутами чтения и вызовем метод ParseFromIstream. После этого выведем на экран прочитанное с помощью написанной нами функции ListCardHolders. Ну а в конце не забываем вызвать google::protobuf::ShutdownProtobufLibrary() для предотвращения утечек памяти.

Ну и напоследок взглянем на код функций ListCardHolders и AddToCardHolders. Тут все настолько просто, что достаточно будет лишь нескольких строк из них. Доступ к элементам сообщения осуществляется с помощь GET-методов, имена которых совпадают с именами самих элементов. Запись значений производится при помощи set_ методов.

Добавление и вывод сериализованных данных

void ListCardHolders(const CardsApp::CardHoldersList& card_holders)
{
    for (int i = 0; i < card_holders.person_size(); i++)
    {
        const CardsApp::CardHolder& person = card_holders.person(i);

        cout << "Person ID: " << person.id() << endl;
        cout << "First Name: " << person.firstName() << endl;
        cout << "First Name: " << person.lastName() << endl;

        // Далее код вывода кредитных карт
    }
}

void AddToCardHolders(const CardsApp::CardHoldert& card_holder)
{
    cout << "Enter person ID number: ";
    int id;
    cin >> id;
    card_holder->set_id(id);
    cin.ignore(256, '\n');

    cout << "Enter first name: ";
    getline(cin, *card_holder->mutable_firstName());

    cout << "Enter last name: ";
    getline(cin, *card_holder->mutable_lastName());

    // Далее код ввода кредитных карт
}
 

boost::serialization

Сериализация от Гугла не единственная в своем роде. Есть еще всемогущий boost. Принципы его работы немного отличаются от Protocol Buffers. В частности, бустовая сериализация не требует описания структур данных, что представляется, с одной стороны, плюсом, поскольку не надо разбираться в генерируемом коде, а с другой — минусом, так как тебе придется ручками добавлять в каждый класс поддержку сериализации. Хотя последнее довольно спорно. Гугловая библиотека генерирует простейшие классы, и, скорее всего, их будет недостаточно для реализации задуманного набора функций над данными, и тебе придется писать классы обертки. В boost же ты пишешь классы с той функциональностью, которую захочешь, и потом добавляешь к ним сериализацию. К сожалению, формат статьи не позволяет рассмотреть boost::serialization, но для особо любознательных соответствующие ссылки во врезку уже добавлены.

 

WWW

 

Заключение

Protocol Buffers, boost::serialization — все это довольно мощные инструменты, которые позволят перевести данные и объекты в твоей программе в бинарный вид и безопасно переслать их по сети или сохранить в файл до лучших времен. В большинстве случаев эти библиотеки полностью оправдывают возлагаемые на них надежды, но в особо больших и сложных проектах писать собственные велосипеды не так уж и плохо — разумеется, в том случае, если тебе не хватает гибкости уже существующих. Но прежде чем отбрасывать готовые решения, следует хорошо их изучить.

 

1 комментарий

  1. igornet

    09.11.2017 at 14:29

    И на хрена было писать эту статью! Изучив ея, никто, никогда не начнет как надо использовать protobuf в своем проекте!

Оставить мнение

Check Also

Хакер ищет авторов. Читатель? Хакер? Программист? Безопасник? Мы тебе рады!

Восемнадцать лет мы делаем лучшее во всем русскоязычном пространстве издание по IT и инфор…