/
d
e
v
/
t
o
k
i
o
r
y
Блог <tokiory>
P
r
o
t
o
b
u
f
:
Ч
т
о
э
т
о
и
с
ч
е
м
е
г
о
е
д
я
т
?
В данном статье мы поговорим о Protobuf, выясним для чего нужна данная технология и как её использовать

Совсем недавно, в контексте разработки одного из моих пет-проектов, мне удалось поработать с Protobuf.

Сначала я не понимал зачем нужна технология, которая просто предоставляет нам чуть более мощный аналог REST API, который даже не поддерживается (нативно) в браузерах, однако, по ходу разработки я понял, что это одна из самых удобных технологий, что я щупал за последнее время.

Контракты

Одна из насущных проблем при разработке микросервисной архитектуры — контракты.

Контракт

Контракт — соглашение или спецификация, которая определяет как взаимодействует клиент и сервер.

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

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

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

Protobuf решает эту проблему автогенерацией кода. Мы будем писать код похожий на следующий:

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

Вверху мы видим сниппет, который содержит “сообщение”. Данное сообщение будет преобразовываться в структуру на выбранном нами языке программирования и являться нашей структурой данных для отправки/принятия запроса. Если мы изменим данный код и перекомпилируем его, то protobuf создаст новые файлы с кодом на выбранном нами языке, тем самым невелируя проблему исправления кода в разных микросервисах.

Если использовать protobuf, то весь процесс обновления структур данных, которые будут использоваться в контрактах будет выглядеть следующим образом:

При этом, желательно хранить protobuf-схемы в отдельном репозитории/пакете и обновлять данный репозиторий вне зависимости от микросервисов.

0
1
0
1
1
0
0
0
0
0
0
0
0
1
0
1
0
0
1
1
0
0
1
0
1
0
1
0
1
1
1
1
0
0
0
0
1
1
1
1
0
1
1
0
1
1
0
0
0
0
1
1
1
1
0
0
0
1
0
1
0
0
1
1
1
0
1
1
0
1
1

Введение

Protobuf, по сути своей, является протоколом для описания формата данных, которые легко сериализовать/десериализовать. Сама платформа дает нам компилятор, который преобразует proto-схемы в структуры на разных языках.

Компилятор protobuf

Для того чтобы использовать protobuf нам нужно установить компилятор, это можно сделать в консоли:

# MacOS
brew install protobuf

# Ubuntu/Debian
sudo apt install -y protobuf-compiler

# Arch
sudo pacman -S protobuf

# Fedora/RHEL
sudo dnf install protobuf-compiler

# Windows
winget install protobuf # Версия с Winget
choco install protoc    # Версия c Choco

Для того чтобы проверить успешно ли установился компилятор, достаточно ввести следующее:

protoc --version

Если команда отдала версию, то всё ок. Если же высветился текст с “Not found”, то что-то пошло не так.

Версии proto

Стоит уточнить, что в данной статье мы будем изучать proto3. До него были версии proto2 и proto (неожиданно). Отличия версий вы можете глянуть в официальной документации, однако, я постараюсь упомянуть о них в этой статье.

Объявление структур

Protobuf предоставляет нам специальный язык (proto) для того чтобы описывать структуры данных. Мы начнем изучение данного языка с простого примера:

syntax = "proto3";

message SendCommentRequest {
  int32 article_id = 1;
  int32 user_id = 2;
  string comment = 3;
}

Представим, что у нас есть микросервис, который занимается хранением и обработкой логики комментариев. Сниппет вверху описывает структуру данных для запроса, который отправляет комментарий пользователя к определенной статье.

Пройдемся по каждой строке отдельно и рассмотрим что она делает:

syntax = "proto3";

message SendCommentRequest {
  int32 article_id = 1;
  int32 user_id = 2;
  string comment = 3;
}

Сначала мы явно указываем какую из версий protobuf мы используем. В данном случае мы используем третью версию протокола.

Обратите внимание

В protobuf активно используется символ ; для обозначения окончания строки. Если мы забудем ;, то ничего не заработает.

syntax = "proto3";

message SendCommentRequest {
int32 article_id = 1;
int32 user_id = 2;
string comment = 3;
}

Тут мы объявляем новую структуру данных, которую мы назвали SendCommentRequest.

Запрос или ответ?

Хорошей практикой при описании структур для запросов считается указывать Request или Response в конце названия структуры

syntax = "proto3";

message SendCommentRequest {
int32 article_id = 1;
int32 user_id = 2;
string comment = 3;
}

В данном куске мы объявляем три поля:

  • article_id — ID статьи, которая имеет тип int32 (32-х битное целочисленное значение);
  • user_id — ID пользователя, который отправляет комментарий, данное поле тоже имеет тип int32 (32-х битное целочисленное значение);
  • comment — содержание комментария, данное поле имеет тип string;
1 / 0
Свайпайте для просмотра слайдов

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

Нумерация полей

Числа справа являются нумерацией для полей. Данные числа критически важны при сериализации и десериализаци стркутуры данных, а также для обратной совместимости. Как только вы использовали число с определенным полем, вы больше не можете использовать данное число.

Для объявления новых полей мы должны использовать числа, которые еще не использовались в текущей структуре данных. Если мы удалили какое-то поле из структуры, то мы должны зарезервировать число, за которым было закреплено данное поле:

syntax = "proto3";

message SendCommentRequest {
  reserved 1;
  int32 article_id = 1;
  int32 user_id = 2;
  string comment = 3;
}

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

  1. За каждым полем должно быть закреплено уникальное число;
  2. Есть числа, которые зарезервированы внутренней логикой protobuf, это числа от 19 000 до 19 999;
  3. Мы не можем переиспользовать зарезервированные числа в рамках одной структуры;

Если у одного из наших полей поменялся тип данных (к примеру у user_id), то мы должны удалить данное поле, зарезервировать его номер и создать новое поле с новым типом:

syntax = "proto3";

message SendCommentRequest {
  reserved 2;
  int32 article_id = 1;
  int32 user_id = 2;
  string user_id = 4;
  string comment = 3;
}

Также, мы не можем менять местами нумерацию полей (по какой-то неведомой причине).
Следующий пример изменения некорректен:

syntax = "proto3";

message SendCommentRequest {
  int32 article_id = 1;
  int32 user_id = 2;
  int32 article_id = 2;
  int32 user_id = 1;
  string comment = 3;
}

В сниппете вверху мы поменяли числа местами у полей article_id и user_id. После компиляции нашей схемы клиент будет сериализовывать данные в неправильном порядке, сервер же, будет неправильно парсить данные поля и все сломается.

Полезный совет

В следующей секции мы рассмотрим опциональные поля и узнаем что не все данные обязательно должны быть указаны для сериализации.

Старайтесь использовать нумерацию от 0 до 15 для полей, которые указываются (используются) чаще всего.

Модификаторы полей

У полей могут быть модификаторы, которые определяют их поведение. В protobuf 3 есть три модификатора:

  • optional — поле может быть не установлено;
  • repeated — поле может содержать несколько значений (слайс);
  • map — поле может содержать несколько пар “ключ-значение”.

Вот примеры использования модификаторов:

syntax = "proto3";

message SendCommentRequest {
  int32 article_id = 1;
  int32 user_id = 2;
  string comment = 3;
  repeated string tags = 4;
  map<string, string> metadata = 5;
  optional string is_reviewed = 6;
}

Поле с модификатором repeated при десериализации будет преобразовано в список нефиксированной длины (вектор), а поле с модификатором map в структуру с ключами и значениями.

Поле с модификатором optional будет преобразовано в указатель на значение, что позволит нам проверить было ли поле установлено или нет.

Примечательно, что если мы не укажем модификатор optional и попытаемся сериализовать структуру без значения в определенном поле (к примеру, user_id не будет явно задан), то такое поле будет содержать значение по умолчанию (для int32 этим значением будет 0), более подробно о нулевых значениях мы поговорим чуть позже.

Модификатор optional решает проблему отличия не заданного явно значения от значения
“по умолчанию”.

Типы данных

Protobuf поддерживает множество типов данных, которые можно использовать в своих структурах. В таблице внизу предоставлены все доступные скалярные типы данных:

Тип данных Описание
double Число двойной точности с плавающей запятой (64 бита).
float Число одинарной точности с плавающей запятой (32 бита).
int32 Целое число со знаком (32 бита). Использует кодирование переменной длины. Неэффективен для отрицательных значений — лучше использовать sint32.
int64 Целое число со знаком (64 бита). Использует кодирование переменной длины. Неэффективен для отрицательных значений — лучше использовать sint64.
uint32 Беззнаковое целое число (32 бита). Использует кодирование переменной длины.
uint64 Беззнаковое целое число (64 бита). Использует кодирование переменной длины.
sint32 Целое число со знаком (32 бита). Использует кодирование ZigZag, оптимизированное для отрицательных значений.
sint64 Целое число со знаком (64 бита). Использует кодирование ZigZag, оптимизированное для отрицательных значений.
fixed32 Беззнаковое целое число (32 бита). Всегда занимает 4 байта. Эффективнее uint32, если значения часто превышают 228.
fixed64 Беззнаковое целое число (64 бита). Всегда занимает 8 байт. Эффективнее uint64, если значения часто превышают 256.
sfixed32 Целое число со знаком (32 бита). Всегда занимает 4 байта.
sfixed64 Целое число со знаком (64 бита). Всегда занимает 8 байт.
bool Логическое значение (true/false).
string Строка в кодировке UTF-8 или 7-бит ASCII. Длина не должна превышать 232 байт.
bytes Последовательность байт произвольного содержания. Максимум — 232 байт.

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

Вообще при работе с числами, есть один простой гайдлайн:

  1. Если число беззнаковое – uint32/uint64;
  2. Если число всегда беззнаковое, но большое – fixed32/fixed64;
  3. Если число всегда отрицательное или чаще отрицательное, чем положительное – sint32/sint64;
  4. Если число всегда отрицательное или чаще отрицательное, чем положительное, так еще и большое – sfixed32/sfixed64;
  5. Если число может быть отрицательным и положительным – int32/int64;
Кодирование переменной длины?

Это метод сериализации чисел, при котором меньшее значение занимает меньше байт. В Protocol Buffers используется varint — компактное представление целых чисел:

Например:

  • Число 1 будет закодировано в 1 байт.
  • Число 300 — в 2 байта.
  • Число 2,000,000 — уже в 3–4 байта.

Это делает передачу маленьких чисел очень эффективной по объему данных.

Для отрицательных значений обычный int32 и int64 неэффективны: varint кодирует их как большие положительные числа в 10 байт.

Для этого придуманы sint32 и sint64, которые используют ZigZag-кодирование — оно преобразует отрицательные числа в компактную форму.

Также, в protobuf есть нескалярные типы данных, среди которых перечисления (Enums) и другие структуры (Messages), мы поговорим о них чуть позже.

Значения по умолчанию // Нулевые значения

Если при сериализации структуры данных не было установлено значение для поля, то оно будет сериализовано и десериализовано со значением по умолчанию. Для каждого типа данных есть свои значения по умолчанию:

Тип данных Значение по умолчанию
Числовые типы данных 0
Булев тип данных false
Строка ""
Байты ""
Сообщение null (или пустое вложенное сообщение, в зависимости от языка)
Массив []
Карта {}

Если мы явно указали значение по умолчанию как значение для поля, то само значение не будет сериализовано, однако название поля будет сериализовано. При десериализации сервер увидит, что у поля нет явного значения и присвоит ему значение по умолчанию, это позволяет нам экономить память и не указывать лишние “пустые” значения.

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

Перечисления

Давайте добавим в нашу структуру новое поле — markup. Данное поле будет указывать на используемую разметку в комментарии. Всего у нас будет несколько вариантов: markdown, html и plaintext.

Мы можем немножко переделать нашу структуру данных, у нас получится следующее:

syntax = "proto3";

message SendCommentRequest {
  int32 article_id = 1;
  int32 user_id = 2;
  string comment = 3;
  string markup = 4;
}

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

Перечисления в proto3 создаются следующим образом:

enum Markup {
  MARKUP_PLAINTEXT = 0;
  MARKUP_MARKDOWN = 1;
  MARKUP_HTML = 2;
}

Нулевым значением у данного перечисления является MARKUP_PLAINTEXT, потому что именно этот элемент стоит первым в перечислении. Стоит подметить, что у нулевого элемента в перечислении обязательно должно быть значение 0.

При обновлении перечисления (удаления или изменения полей), мы, как и в сообщениях, можем использовать ключевое слово reserved:

enum Markup {
  reserved 1;
  MARKUP_PLAINTEXT = 0;
  MARKUP_MARKDOWN = 1;
  MARKUP_HTML = 2;
}

Для того чтобы использовать наше перечисление, мы можем подставить его название вместо типа для определенного поля:

syntax = "proto3";

enum Markup {
  MARKUP_PLAINTEXT = 0;
  MARKUP_MARKDOWN = 1;
  MARKUP_HTML = 2;
}

message SendCommentRequest {
  int32 article_id = 1;
  int32 user_id = 2;
  string comment = 3;
  string markup = 4;
  Markup markup = 4;
}

Сообщения

Все это время мы описывали структуры данных с помощью ключевого слова message.

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

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

Мы можем использовать сообщения как тип данных. Давайте добавим в нашу структуру еще одно поле — parent. Данное поле будет свидетельствовать о том, что текущий пользователь отправляет комментарий не первого уровня, а в какую-то ветку (как это сделано на Habr или Reddit):

syntax = "proto3";

enum Markup {
  MARKUP_PLAINTEXT = 0;
  MARKUP_MARKDOWN = 1;
  MARKUP_HTML = 2;
}

message ParentComment { 
  int32 article_id = 1;
  int32 commend_id = 2;
} 

message SendCommentRequest {
  int32 article_id = 1;
  int32 user_id = 2;
  string comment = 3;
  Markup markup = 4;
  optional ParentComment parent = 5;
}

Также (если нам понадобится), то мы можем встраивать сообщения прямо в другие сообщения:

message SendCommentRequest {
  message ParentComment { 
    int32 article_id = 1;
    int32 commend_id = 2;
  } 
  int32 article_id = 1;
  int32 user_id = 2;
  string comment = 3;
  Markup markup = 4;
  optional ParentComment parent = 5;
}

Таким образом мы можем создавать иерархию сообщений, для того чтобы легче структурировать данные. Если же нам потребуется переиспользовать ParentComment где-то кроме SendCommentRequest, то мы можем обратиться к данному сообщению по полному имени:

message Example {
  SendCommentRequest.ParentComment parent = 1;
}

Мапы

Мапы (их еще называют “картами”) — это ассоциативные списки, где ключом может быть любое скалярное значение (кроме float и bytes), а значением любое значение (но без каких-либо модификаторов).

message ParentComment {
  int32 article_id = 1;
  int32 commend_id = 2;
}

message SendCommentRequest {
  int32 article_id = 1;
  int32 user_id = 2;
  string comment = 3;
  Markup markup = 4;
  optional ParentComment parent = 5;
  map<string, ParentComment> quotedComments = 6;
}
Сериализация Proto
В данном редакторе вы можете посмотреть как сериализуется Protobuf.
В процессе того, как будете играться, обратите внимание, что сериализиуется тут только структура под название OurMessage. Такие рамки поставлены из-за того что я ленивый серилизация множества структур заняла бы намного больше места.
Прото-схема
(можно редактировать)
Сгенерированный JS (в формате JSON)
Данные для схемы
(можно редактировать)
Сериализованные данные (в формате HEX)

Разделение схем

До текущего момента мы писали все схемы в одном файле, давайте назовем его comment.proto:

comment.proto
proto
syntax = "proto3";

enum Markup {
  MARKUP_PLAINTEXT = 0;
  MARKUP_MARKDOWN = 1;
  MARKUP_HTML = 2;
}

message ParentComment {
  int32 article_id = 1;
  int32 commend_id = 2;
}

message SendCommentRequest {
  int32 article_id = 1;
  int32 user_id = 2;
  string comment = 3;
  Markup markup = 4;
  optional ParentComment parent = 5;
}

Представим, что наше перечисление Markup может использоваться не только в комментариях, но и в других сообщениях, давайте вынесем его в отдельный файл:

comment.proto
protobuf
syntax = "proto3";

import "markup.proto";

enum Markup { 
  MARKUP_PLAINTEXT = 0;
  MARKUP_MARKDOWN = 1;
  MARKUP_HTML = 2;
} 

message ParentComment {
  int32 article_id = 1;
  int32 commend_id = 2;
}

message SendCommentRequest {
  int32 article_id = 1;
  int32 user_id = 2;
  string comment = 3;
  Markup markup = 4;
  optional ParentComment parent = 5;
}
markup.proto
protobuf
syntax = "proto3";

enum Markup {
  MARKUP_PLAINTEXT = 0;
  MARKUP_MARKDOWN = 1;
  MARKUP_HTML = 2;
}

Для того чтобы скомпилировать данный код, нам нужно будет указать компилятору protoc базовый путь до директории с файлами protobuf с помощью флага --proto_path:

protoc --proto_path=./ comment.proto

# Мы также можем использовать сокращение с помощью -I
protoc -I=./ comment.proto

Пакеты

Допустим, что у нас есть сообщение Foo в файле foo.proto. Если мы импортируем упомянутый нами файл в другой файл, где уже есть структура Foo, то мы получим ошибку компиляции, потому что произошла коллизия имен.

Для того чтобы решить эту проблему, мы можем использовать ключевое слово package:

foo.proto
protobuf
syntax = "proto3";
package foo;

В файле, который импортирует foo.proto мы сможем использовать сообщение Foo с помощью префикса пакета:

bar.proto
protobuf
syntax = "proto3";

import "foo.proto";

message Foo {}

message Bar {
  Foo first = 1;      // Мы используем Foo из текущего файла
  foo.Foo second = 2; // Мы используем Foo из файла foo.proto
}

Чтобы использовать структуру из файла

Неизвестный тип данных

Бывают случаи, когда мы не можем точно указать какой тип данных придет в определенном поле, в таких случаях нам помогают Any и oneof.

Any — это тип данных, который может хранить в себе все что угодно: строки, числа, перечисления или сообщения.

syntax = "proto3";

import "google/protobuf/any.proto";

message SendCommentRequest {
  int32 article_id = 1;
  int32 user_id = 2;
  string comment = 3;
  Markup markup = 4;
  optional ParentComment parent = 5;
  google.protobuf.Any metadata = 6;
}

Как понятно из сниппета выше – мы не можем использовать Any просто так, для того чтобы использовать данный тип данных нам нужно импортировать его из стандартной библиотеки protobuf.

Если у сообщения может быть одно из нескольких полей — мы должны использовать oneof:

syntax = "proto3";
message SendCommentRequest {
  int32 article_id = 1;
  int32 user_id = 2;
  string comment = 3;
  Markup markup = 4;
  optional ParentComment parent = 5;
  oneof date { 
    string str_date = 6;
    uint32 timestamp = 7;
  } 
}
Примечание

Мы не можем использовать repeated внутри oneof. Мы также не можем использовать сочетание repeated oneof.

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

Сервисы

Основное применение сообщений из protobuf — обмен данными между сервисами. До текущего момента мы просто объявляли сообщения, не прототипируя методы самих сервисов, однако, protobuf умеет и в прототипирование сервисов.

Давайте придумаем новый пример, чтобы нам было легче понять как работает прототипирование сервисов. Возьмем самый простой пример — поиск статей.

syntax = "proto3";

// Структура статьи
message Article {
  uint32 id = 1;
  string title = 2;
  string description = 3;
}

// Запрос поиска
message SearchRequest {
  string query = 1;
  string sort = 2;
  repeated string tags = 3;
}

// Ответ поиска
message SearchResponse {
  repeated Article articles = 1;
}

Теперь, когда у нас есть все необходимые сообщения для создания сервиса, давайте создадим его с помощью ключевого слова service:

service ArticleSearchService {
  rpc search(SearchRequest) returns SearchResponse;
}

Синтаксис у сервисов достаточно простой. После ключевого слова service мы уточняем название для сервиса (в данном случае ArticleSearchService), затем мы указываем методы с помощью ключевого слова rpc после которого идет название метода.

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

Опции

Опции — это директивы, которые никак не влияют на сами сообщения и сервисы, однако влияют на генерируемый код. Когда вы будете генерировать код для Java, Go, C, C++ или любого другого языка, вам наверняка нужно будет указать название пакета, модификаторы доступа или любые другие мета-данные.

Для этого и нужны опции:

syntax = "proto3";

// Указываем название пакета для генерации на Java
option java_package = "com.example.foo";

// Указываем название пакета для Go
option go_package = "github.com/yourusername/yourrepo/yourproto";

// Указываем название пакета для C#
option csharp_namespace = "My.Custom.Namespace";

Генерация кода

Мы подошли к финальному этапу нашего туториала. Сгенерировать код из proto-файлов можно с помощью компилятора и расширений для компилятора.

Для примера, давайте сгененрируем код для Go. Для начала нам нужно будет установить расширения для генерации Go-кода и gRPC:

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

Теперь мы можем сгенерировать пакеты:

protoc 
  --go_out=.               # Генерируем код в текущую директорию
  --go-grpc_out=.          # Генерируем gRPC-код в текущую директорию
  -I=./proto               # Устанавливаем корневую директорию proto-файлов
  ./proto/yourfile.proto   # Указываем путь к файлу,
                           # из которого должны быть взяты сервисы и сообщения