Совсем недавно, в контексте разработки одного из моих пет-проектов, мне удалось поработать с Protobuf.
Сначала я не понимал зачем нужна технология, которая просто предоставляет нам чуть более мощный аналог REST API, который даже не поддерживается (нативно) в браузерах, однако, по ходу разработки я понял, что это одна из самых удобных технологий, что я щупал за последнее время.
Контракты
Одна из насущных проблем при разработке микросервисной архитектуры — контракты.
Контракт — соглашение или спецификация, которая определяет как взаимодействует клиент и сервер.
Контракт включает в себя описание доступных операций, форматов данных, параметров запросов и ответов, а также ожидаемого поведения системы.
Представим, что на одном из микросервисов изменились необходимые данные для отправки запроса. Как другие микросервисы поймут что нужно отправлять другие данные? Никак.
Все дело в том, что если мы поменяем интерфейс одного из запросов в одном из микросервисов – нам сразу же придется переделывать все структуры для отправки запроса в других микросервисах.
Protobuf решает эту проблему автогенерацией кода. Мы будем писать код похожий на следующий:
message Person {
string name = 1;
int32 id = 2;
string email = 3;
}
Вверху мы видим сниппет, который содержит “сообщение”. Данное сообщение будет преобразовываться в структуру на выбранном нами языке программирования и являться нашей структурой данных для отправки/принятия запроса. Если мы изменим данный код и перекомпилируем его, то protobuf создаст новые файлы с кодом на выбранном нами языке, тем самым невелируя проблему исправления кода в разных микросервисах.
Если использовать protobuf, то весь процесс обновления структур данных, которые будут использоваться в контрактах будет выглядеть следующим образом:
При этом, желательно хранить protobuf-схемы в отдельном репозитории/пакете и обновлять данный репозиторий вне зависимости от микросервисов.
Введение
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 {
reserved 1;
int32 article_id = 1;
int32 user_id = 2;
string comment = 3;
}
Давайте сразу поговорим об ограничениях, которые оговариваются в документации:
- За каждым полем должно быть закреплено уникальное число;
- Есть числа, которые зарезервированы внутренней логикой protobuf, это числа от
19 000
до19 999
; - Мы не можем переиспользовать зарезервированные числа в рамках одной структуры;
Если у одного из наших полей поменялся тип данных (к примеру у 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 достаточно много числовых типов, которые могут показаться избыточными, однако, они позволяют нам оптимизировать сериализацию и десериализацию данных.
Вообще при работе с числами, есть один простой гайдлайн:
- Если число беззнаковое –
uint32
/uint64
; - Если число всегда беззнаковое, но большое –
fixed32
/fixed64
; - Если число всегда отрицательное или чаще отрицательное, чем положительное –
sint32
/sint64
; - Если число всегда отрицательное или чаще отрицательное, чем положительное, так еще и большое –
sfixed32
/sfixed64
; - Если число может быть отрицательным и положительным –
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;
}
Разделение схем
До текущего момента мы писали все схемы в одном файле, давайте назовем его comment.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
может использоваться не только в комментариях, но и в других сообщениях, давайте вынесем его в отдельный файл:
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;
}
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
:
syntax = "proto3";
package foo;
В файле, который импортирует foo.proto
мы сможем использовать сообщение Foo
с помощью префикса пакета:
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 # Указываем путь к файлу,
# из которого должны быть взяты сервисы и сообщения