Запускаем Protobuf на С++ в VS

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

Краткое описание

Сообщения в protobuf представляет из себя закодированную последовательность бит из ключей и значений. Ключи определяются с помощью номера определённого в файле .proto и кода типа переменной. Файл .proto определяет поля каждого сообщения. Сам файл .proto преобразуется в классы, для работы с сериализацией и десериализацией данных.

Подготовка к работе с protobuf

Простой ликбез прошли, теперь можно задаться вопросом, а что нам понадобится для того, чтобы самим поковырять protobuf. А всё просто, нам понадобится:

  • CMake

  • VS 2019

  • Проект Protobuf

Сборка проекта Protobuf с github

Прежде всего скачаем Protobuf с github. В разделе Release находим файл вида protobuf-cpp-version.zip. На момент начала написания статьи использовалась версия 3.19.0, но процесса сборки это не меняет.

Когда мы скачали архив, его необходимо подготовить для сборки в vs, на мой взгляд - это самый простой способ сборки проекта, во всяком случае для тех, у кого лапки. Кому интересно изучить информацию про установку и сборку protobuf подробнее, то можно почитать файл readme в директории protobuf/cmake/README.md, в статье же будет представлена выжимка необходимого минимума.

Для команд написаных ниже, необходимо использовать командную строку от VS. В моём случае она называлась x86_x64 Cross Tools Command Promt for VS 2019. Если использовать стандартную командную строку, то возможны лишние манипуляции.

В открытой командной строке необходимо перейти в директорию с нашим распакованным protobuf.

В данном случае C:\Path\to\ - адрес до нашего распакованного архива. Для тех, кто не помнит как обратиться быстро в самое начало директории локального диска или же перейти в другой, тогда в командную строку надо прописать что-то вроде:

 C:\Program Files (x86)\Microsoft Visual Studio\2019\Community>E:
 E:\>

Теперь, повторяем команды из readme. Если вы только установили СMake, стоит обратить внимание на то, что вы указали в path путь к вашему cmake, проверить это можно с помощью простой команды:

E:\AllWs\vs\protoExample\proto_settings> cmake --version
cmake version 3.17.0-rc1

Когда мы убедились, что всё хорошо, приступаем к подготовке нашего проекта.

 C:\Path\to\protobuf\cmake>mkdir build & cd build
 C:\Path\to\protobuf\cmake\build>
 C:\Path\to\protobuf\cmake\build>mkdir solution & cd solution
 C:\Path\to\protobuf\cmake\build\solution>cmake -G "Visual Studio 16 2019" ^
 -DCMAKE_INSTALL_PREFIX=../../../../install ^
 ../..

Часть команд мы пропустили для простоты и сразу перепрыгнули к созданию проекта в VS19. После чего мы можем у себя обнаружить в папке solution множество файлов, и нам необходимо открыть файл *.sln. Открыв его мы можем увидеть:

Собираем INSTALL. Теперь переходим в папку, в которой у нас был распакован архив с Protobuf, там у нас должна появиться папка install, и там нас интересуют следующие папки:

  • bin

  • include

  • lib

Сборка под release и debug сгенерирует разные файлы lib:

  • libprotobuf.lib

  • libprotobufd.lib

Подготовка VS под запуск примеров с protobuf

Для описания данных protobuf используется файл с расширением *.proto. Однако, файл с таким расширением нельзя просто так взять и впихнуть в проект, для этого необходимо этот файл преобразовать с помощью полученого на предыдущем этапе файла, расположенного в папке bin, с названием protoc.exe. Для удобства создадим папку, в которую мы скинем protoc.exe. Туда же добавим:

addressbook.proto
syntax = "proto2";

package tutorial;

message Person {
  optional string name = 1;
  optional int32 id = 2;
  optional string email = 3;

  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }

  message PhoneNumber {
    optional string number = 1;
    optional PhoneType type = 2 [default = HOME];
  }

  repeated PhoneNumber phones = 4;
}

message AddressBook {
  repeated Person people = 1;
}

Теперь для преобразования файла addressbook.proto запускаем команду

protoc_path\protoc.exe --cpp_out=. addressbook.proto

Кому лень, можно создать просто файл формата *.bat, в который поместим следующий код:

@set file_name=addressbook
@set mypath=%cd%
@if exist %file_name%.proto ( 
	%mypath%\protoc.exe --cpp_out=. %file_name%.proto
	@echo %file_name%.pb.cc and %file_name%.pb.h created)
@pause

В этой директории у нас появятся addressbook.pb.cc и addressbook.pb.h.

Подготовительный этап у нас закончен. У нас теперь есть всё, чтобы запустить примеры, предложенные на официальном сайте. Поэтому, мы по старинке создаём проект в VS, в main файл добавляем код. Я немножко изменил проект, чтобы можно было проще запускать предложенные примеры по очереди.

main.cpp
#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;
#define READ_EXAMPLE 1
#define WRITE_EXAMPLE 2
#define TYPE_EXAMPLE WRITE_EXAMPLE

// This function fills in a Person message based on user input.
void PromptForAddress(tutorial::Person* person) {
    cout << "Enter person ID number: ";
    int id;
    cin >> id;
    person->set_id(id);
    cin.ignore(256, '\n');

    cout << "Enter name: ";
    getline(cin, *person->mutable_name());

    cout << "Enter email address (blank for none): ";
    string email;
    getline(cin, email);
    if (!email.empty()) {
        person->set_email(email);
    }

    while (true) {
        cout << "Enter a phone number (or leave blank to finish): ";
        string number;
        getline(cin, number);
        if (number.empty()) {
            break;
        }

        tutorial::Person::PhoneNumber* phone_number = person->add_phones();
        phone_number->set_number(number);

        cout << "Is this a mobile, home, or work phone? ";
        string type;
        getline(cin, type);
        if (type == "mobile") {
            phone_number->set_type(tutorial::Person::MOBILE);
        }
        else if (type == "home") {
            phone_number->set_type(tutorial::Person::HOME);
        }
        else if (type == "work") {
            phone_number->set_type(tutorial::Person::WORK);
        }
        else {
            cout << "Unknown phone type.  Using default." << endl;
        }
    }

}

// Iterates though all people in the AddressBook and prints info about them.
void ListPeople(const tutorial::AddressBook& address_book) {
    for (int i = 0; i < address_book.people_size(); i++) {
        const tutorial::Person& person = address_book.people(i);

        cout << "Person ID: " << person.id() << endl;
        cout << "  Name: " << person.name() << endl;
        if (person.has_email()) {
            cout << "  E-mail address: " << person.email() << endl;
        }

        for (int j = 0; j < person.phones_size(); j++) {
            const tutorial::Person::PhoneNumber& phone_number = person.phones(j);

            switch (phone_number.type()) {
            case tutorial::Person::MOBILE:
                cout << "  Mobile phone #: ";
                break;
            case tutorial::Person::HOME:
                cout << "  Home phone #: ";
                break;
            case tutorial::Person::WORK:
                cout << "  Work phone #: ";
                break;
            }
            cout << phone_number.number() << endl;
        }
    }
}


// Main function:  Reads the entire address book from a file,
//   adds one person based on user input, then writes it back out to the same
//   file.
int main(int argc, char* argv[]) {
    // 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;

    if (argc != 2) {
        cerr << "Usage:  " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
        return -1;
    }

    tutorial::AddressBook address_book;

#if(TYPE_EXAMPLE == WRITE_EXAMPLE)
    {
        // Read the existing address book.
        fstream input(argv[1], ios::in | ios::binary);
        if (!input) {
            cout << argv[1] << ": File not found.  Creating a new file." << endl;
        }
        else if (!address_book.ParseFromIstream(&input)) {
            cerr << "Failed to parse address book." << endl;
            return -1;
        }
    }

    // Add an address.
    PromptForAddress(address_book.add_people());
    PromptForAddress(address_book.add_people());

    {
        // Write the new address book back to disk.
        fstream output(argv[1], ios::out | ios::trunc | ios::binary);
        if (!address_book.SerializeToOstream(&output)) {
            cerr << "Failed to write address book." << endl;
            return -1;
        }
    }

#elif(TYPE_EXAMPLE == READ_EXAMPLE)

    {
        // Read the existing address book.
        fstream input(argv[1], ios::in | ios::binary);
        if (!address_book.ParseFromIstream(&input)) {
            cerr << "Failed to parse address book." << endl;
            return -1;
        }
    }

    ListPeople(address_book);

#endif
    // Optional:  Delete all global objects allocated by libprotobuf.
    google::protobuf::ShutdownProtobufLibrary();

    return 0;
}

Затем в проект добавляем наши сгенерированные файлы с помощью protoc.exe. Можно это сделать с помощью горячих клавиш Alt+Shift+A. Добавив наши файлы мы можем обнаружить, что чего-то нам для работы нашего примера не хватает, тут мы обращаемся к нашим сгенерированными папкам inclune и lib. В папке lib нас интересует libprotobuf.lib (для сборки Release) или libprotobufd.lib (для сборки debug), добавляем в наш проект.

После чего мы должны добавить в наш проект файлы из include. Просто кидаем в наш проект папку include, и указываем в "Свойства конфигурации → С/С++ → Общие → Дополнительные каталоги включаемых файлов" путь до папки include.

В примере функция main принимает аргументы, в примере это название файла, в который мы будем писать и из которого будем читать, чтобы можно было запускать из среды, мы добавим в свойства проекта название файла. Заходим в "Свойства конфигурации → Отладка" в поле "Аргументы команды" вписываем название файла, я обозвал его как "addressbook.bin".

Теперь осталось самое последнее, указать как собирать подключенную библиотеку. Заходим в "Свойства конфигурации → Создание кода" в поле "Библиотека времени выполнения" выбираем "Многопоточная/МТ". На этом подготовка окончена, можно запускать примеры.

Разбор примера

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

Сгенерированные методы после конвертации файла *.proto

В предыдущем разделе, когда мы генерировали классы сообщений protobuf, мы не вдавались в результат их преобразования. Поэтому я хотел бы немного затронуть эту тему. Для примера возьмём поле optional int32 id = 2; Если мы зайдём в сгенерированный заголовочник, то увидим:

// optional int32 id = 2;
inline bool Person::_internal_has_id() const;
inline bool Person::has_id() const;
inline void Person::clear_id();
inline int32_t Person::_internal_id() const;
inline int32_t Person::id() const;
inline void Person::_internal_set_id(int32_t value);
inline void Person::set_id(int32_t value);

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

Для переменных с другим модификатором, например repeated, появляется дополнительный метод формата add_variableName(), который отвечает за добавление новых переменных этого типа, метод оканчивающийся на _size, для определения размера и обращение по индексу. Кроме этого, по всем элементам можно пробежаться с помощью конструкции range based for, например так:

    tutorial::Person person;
    person.add_phones()->set_number("numb1");
    person.add_phones()->set_number("numb2");
    for (auto& phone : pers.phones())
    {
        std::cout << phone.number() << std::endl;
    }

Обратите внимание, класс Person создаётся через пространство имён tutorial. Как мы помним, в файле *.proto мы указали package tutorial. Это не было необходимо, однако, мы тогда в глобальное пространство имён вынесем достаточно много внутренних функций.

С переменными типа string можно работать не только через метод set, а также через *mutable методы. Вот пример работы с таким методом:

        tutorial::Person person;
        person.set_name("name");
        *person.mutable_name() += " surname ";
        std::cout << person.name();

Классы с сообщениями наследуются публично от класса Message. В нём определено много различных методов для сериализации, которые имеют имена начинающиеся с Serialize, а для десериализации достаточно использовать метод, начинающийся с Parse, например для сериализации в строку и десериализации из строки можно выполнить следующий код:

    tutorial::AddressBook addr_book1;
    addr_book1.add_people()->set_email("555-555-555");
    std::string Serialized = addr_book1.SerializeAsString();
    
    tutorial::AddressBook addr_book2;
    addr_book2.ParseFromString(Serialized);

Разбор бинарного представления сериализированных данных

Для начала запустим проект с конфигурацией с записью и попробуем разобраться в том, что происходит

#define TYPE_EXAMPLE WRITE_EXAMPLE

Пример для записи я немного редактировал двумя вызовами добавления элементов в базу.

Запускаем программу и вводим данные с консоли.

Enter person ID number: 170
Enter name: name1
Enter email address (blank for none): mail1
Enter a phone number (or leave blank to finish): 1234567
Is this a mobile, home, or work phone? mobile
Enter a phone number (or leave blank to finish):
Enter person ID number: 85
Enter name: name2
Enter email address (blank for none): mail2
Enter a phone number (or leave blank to finish): 7654321
Is this a mobile, home, or work phone? mobile
Enter a phone number (or leave blank to finish):

Теперь посмотрим, что же мы увидим в созданном файле.

Какая-то абракадабра в декодированном тексте, зато в hex формате всё очень даже понятно, во всяком случае для десериализатора. Теперь давайте разберёмся с тем, как вообще наши данные должны расшифровываться.

Для более простой расшифровки данных я выбрал 170 и 85 в качестве id, поскольку эти величины легче найти в бинарном файле, да и int32 проще всего зашифрованы. В hex эти числа представлены как AA и 55. В protobuf данные хранятся по принципу ключ - значение. Ключ определяется по формуле:

(fieldNumber << 3) | wireType.

field_number - номер у переменной в описании структуры данных в файле *.proto. wire_type определяется по этой табличке

Wire_type

Meaning

Used for

0

Varint

int32, int64, uint32, uint64, sint32, sint64, bool, enum

1

64-bit

fixed64, sfixed64, double

2

Length-delimited

string, bytes, embedded messages, packed repeated fields

3

Start group

groups (deprecated)

4

End group

groups (deprecated)

5

32-bit

fixed32, sfixed32, float

Воспользовавшись формулой выше определим, какой ключ должен быть перед переменной id key = 2 << 3 | 0 = 0x0010

Это же число мы и видим в декодированом файле.

Давайте сейчас попробуем определить ключ для переменной типа string, это будет поле name. Ключ для этой переменной будет 1 << 3 | 2 = 0x0A. Замечаем, что перед переменными name1 и name2 (0x04 и 0x24 байт) стоит не 0x0A а 0x05. Это основная особенность подобных типов, они могут быть любой длины, вот 0x05 и отвечает за количество байт на эту переменную. Для таких типов идёт сначала ключ, потом количество байт.

Запускаем пример на чтение.

#define TYPE_EXAMPLE READ_EXAMPLE

И теперь мы увидим данные, которые мы записали в файл.

Ну вот и всё. Надеюсь эта статья поможет кому-то начать осваивать protobuf с с++.

Спасибо за внимание.

Комментарии (4)


  1. Rezzet
    14.01.2022 10:04
    +3

    На мой взгляд в 22 году пора начинать использовать пакетные менеджеры для с++. Например vcpkg, по настроению можно взять конан или любую альтернативу.


  1. Izaron
    14.01.2022 11:52

    последовательность бит из ключей и значений

    На самом деле в сериализованном представлении ключи выкинуты (остались значения), так как считается, что контрагент знает про тот же .proto файл и может с его помощью расшифровать бинарный поток. Без .proto файла бинарный поток понять человеку/программе невозможно, читабельны только string-поля.


    1. BAHOO Автор
      14.01.2022 15:10
      +1

      Хорошо, а почему если я сериализую данные и их куда-то записываю появляется ключ, или вы о другом?

      Да, не зная структуры сообщения расшифровать корректно не выйдет т.к для того же знакового и беззнакового инта один и тот же wire type. Более того, можно и десериализовать одно сообщение в другое.


      1. Izaron
        14.01.2022 15:29

        Я имел в виду названия полей, то есть из записи

        repeated string Pepper = 13;

        В сериализованном виде слово "Pepper" пропадет, но к сожалению я не так прочитал абзац (неправильно понял), посчитал что "Pepper" и есть "ключ" (по аналогии с JSON).