Запускаем 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 данные хранятся по принципу ключ - значение. Ключ определяется по формуле:
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)
Izaron
14.01.2022 11:52последовательность бит из ключей и значений
На самом деле в сериализованном представлении ключи выкинуты (остались значения), так как считается, что контрагент знает про тот же .proto файл и может с его помощью расшифровать бинарный поток. Без .proto файла бинарный поток понять человеку/программе невозможно, читабельны только string-поля.
BAHOO Автор
14.01.2022 15:10+1Хорошо, а почему если я сериализую данные и их куда-то записываю появляется ключ, или вы о другом?
Да, не зная структуры сообщения расшифровать корректно не выйдет т.к для того же знакового и беззнакового инта один и тот же wire type. Более того, можно и десериализовать одно сообщение в другое.
Izaron
14.01.2022 15:29Я имел в виду названия полей, то есть из записи
repeated string Pepper = 13;
В сериализованном виде слово "Pepper" пропадет, но к сожалению я не так прочитал абзац (неправильно понял), посчитал что "Pepper" и есть "ключ" (по аналогии с JSON).
Rezzet
На мой взгляд в 22 году пора начинать использовать пакетные менеджеры для с++. Например vcpkg, по настроению можно взять конан или любую альтернативу.