Модуль: Backend · Уровень: Middle+/Senior
TL;DR#
- Protocol Buffers (protobuf) — бинарный, схемозависимый формат сериализации от Google. Схема описывается в
.proto, компилируетсяprotocв код целевого языка (для Go — пакетgoogle.golang.org/protobuf). - На проводе (wire format) сообщение — это последовательность пар
(tag, value), гдеtag = (field_number << 3) | wire_type. Имена полей на проводе отсутствуют — кодируются только номера. - Есть 4 актуальных wire type:
0VARINT,1I64 (64-bit),2LEN (length-delimited),5I32 (32-bit). Типы 3/4 (start/end group) устарели. - Номера полей 1–15 кодируются в 1 байт тега, 16–2047 — в 2 байта. Поэтому горячие/частые поля держим в 1–15.
- Совместимость держится на номерах полей, а не на именах. Backward (новый код читает старые данные) и forward (старый код читает новые данные) совместимость работают за счёт пропуска/сохранения unknown fields.
- Главные правила эволюции: никогда не меняй номер существующего поля, никогда не переиспользуй номер удалённого поля (
reserved), добавление полей безопасно, переименование безопасно. - В proto3 нет
required, по умолчанию поля имеют presence “implicit” (нельзя отличить zero от unset) — для явного presence используютoptional,oneofили wrapper-типы. oneof, well-known types (Timestamp,Duration,Any,Struct,FieldMask, wrappers),map,repeated+ packed encoding — стандартный senior-инструментарий.
Теория#
1. Что такое protobuf и зачем#
Protobuf — это IDL (interface definition language) + бинарный формат + кодогенерация. По сравнению с JSON:
- Компактнее: нет имён полей, числа кодируются varint’ом.
- Быстрее парсится: не нужно парсить текст, нет аллокаций строк-ключей.
- Строгая схема и кодогенерация: типобезопасность на этапе компиляции.
- Встроенные правила эволюции схемы — основа для долгоживущих API и event-стримов (Kafka и т.п.).
Минусы: не human-readable, требует схему для декодирования (без .proto поток байт почти бесполезен), слабее в self-describing сценариях.
2. proto3: синтаксис, поля, номера, scalar types#
syntax = "proto3";
package example.user.v1;
option go_package = "github.com/acme/api/gen/user/v1;userv1";
message User {
uint64 id = 1; // номер поля (field number / tag) = 1
string name = 2;
string email = 3;
bool is_active = 4;
repeated string roles = 5;
}Ключевое:
= N— это номер поля (field number), а не значение по умолчанию. Это идентификатор поля на проводе.- Допустимый диапазон номеров:
1 .. 536_870_911(2^29 − 1). - Зарезервированный диапазон
19000 .. 19999— для внутренних нужд protobuf, использовать нельзя.
Scalar-типы и их соответствие в Go:
| proto тип | wire type | Go тип | Заметки |
|---|---|---|---|
int32, int64 | VARINT | int32, int64 | неэффективны для отрицательных (10 байт) |
uint32, uint64 | VARINT | uint32, uint64 | |
sint32, sint64 | VARINT | int32, int64 | ZigZag, эффективны для отрицательных |
bool | VARINT | bool | 1 байт (0/1) |
enum | VARINT | свой тип (int32) | |
fixed64, sfixed64, double | I64 | uint64/int64/float64 | всегда 8 байт |
fixed32, sfixed32, float | I32 | uint32/int32/float32 | всегда 4 байта |
string, bytes | LEN | string, []byte | длина + payload; string обязан быть UTF-8 |
message | LEN | *Message | вложенное сообщение как length-delimited |
3. Wire format#
Сообщение = поток записей. Каждая запись начинается с тега (key), за которым следует значение в формате, зависящем от wire type.
3.1 Тег: (field_number << 3) | wire_type#
Тег — это varint. Младшие 3 бита — wire_type, остальные — номер поля.
tag = (field_number << 3) | wire_typeWire types:
| wire_type | Имя | Используется для |
|---|---|---|
| 0 | VARINT | int32/64, uint32/64, sint32/64, bool, enum |
| 1 | I64 (64-bit) | fixed64, sfixed64, double |
| 2 | LEN (length-delimited) | string, bytes, embedded messages, packed repeated |
| 3 | SGROUP (deprecated) | start group |
| 4 | EGROUP (deprecated) | end group |
| 5 | I32 (32-bit) | fixed32, sfixed32, float |
Пример: поле 2 типа string. tag = (2 << 3) | 2 = 0x12. Дальше идёт varint длины и байты строки.
3.2 Varint#
Varint кодирует целое числом байтов переменной длины. В каждом байте: старший бит (MSB, continuation bit) = 1, если есть следующий байт; 0 — последний. Остальные 7 бит — данные, little-endian по группам по 7 бит.
Пример: число 300.
300 = 0b100101100- Разбиваем по 7 бит снизу:
0101100и0000010. - Младшая группа идёт первой, ставим continuation:
1010 11000000 0010=0xAC 0x02.
// Концептуально (encoding/binary даёт PutUvarint/Uvarint)
buf := make([]byte, binary.MaxVarintLen64)
n := binary.PutUvarint(buf, 300) // n == 2, buf[:2] == {0xAC, 0x02}Важное следствие: маленькие числа — мало байт. Числа до 127 — 1 байт, до 16383 — 2 байта и т.д.
3.3 Почему int32/int64 плохи для отрицательных, и зачем ZigZag#
Отрицательные int32/int64 интерпретируются как large unsigned (two’s complement расширяется до 64 бит), поэтому -1 занимает 10 байт varint’а. Чтобы этого избежать, для знаковых, которые часто бывают отрицательными, используют sint32/sint64 с ZigZag-кодированием:
ZigZag(n) для 32 бит: (n << 1) ^ (n >> 31)
ZigZag(n) для 64 бит: (n << 1) ^ (n >> 63)Отображение: 0→0, -1→1, 1→2, -2→3, 2→4, .... Небольшие по модулю числа (в т.ч. отрицательные) дают маленький varint.
| Значение | int64 (байт) | sint64 / ZigZag (байт) |
|---|---|---|
| 0 | 1 | 1 |
| -1 | 10 | 1 |
| 150 | 2 | 2 |
| -150 | 10 | 2 |
3.4 Почему поля 1–15 дешевле#
Тег — varint. Поле 1–15 при сдвиге на 3 бита плюс 3 бита wire_type укладывается в 1 байт (значение тега ≤ 127). Поля 16–2047 → тег занимает 2 байта, и т.д. Для часто повторяющихся полей (особенно внутри repeated элементов) экономия в 1 байт на каждое значение существенна.
field 15, LEN: tag = (15<<3)|2 = 122 = 0x7A → 1 байт
field 16, LEN: tag = (16<<3)|2 = 130 → 2 байта (0x82 0x01)3.5 Length-delimited (LEN)#
Для string/bytes/embedded message/packed repeated: tag, затем varint длина, затем длина байт payload. Embedded message сериализуется рекурсивно и оборачивается как bytes — поэтому неизвестное сообщение можно пропустить, зная длину.
4. Совместимость: backward vs forward#
- Backward compatibility (обратная): новый код читает данные, записанные старой схемой. Достигается тем, что добавленные поля просто отсутствуют в старых данных → получают default.
- Forward compatibility (прямая): старый код читает данные, записанные новой схемой. Старый код не знает новых полей → они попадают в unknown fields и сохраняются при ре-сериализации (важно для прокси/relay, которые читают и пишут сообщение, не теряя новых полей).
Unknown fields: поля, чьи номера не описаны в текущей схеме. Парсер protobuf не выбрасывает их, а складывает в специальное хранилище и сериализует обратно. В Go доступ через рефлексию:
m := &userv1.User{}
_ = proto.Unmarshal(data, m)
unknown := m.ProtoReflect().GetUnknown() // []byte с непрослеженными полями(Замечание: при использовании Any или proto.Merge/Clone unknown fields переносятся; некоторые операции, например JSON-маршалинг, могут их игнорировать.)
5. Правила эволюции схемы#
Можно безопасно:
- Добавлять новые поля с новыми номерами.
- Переименовывать поля (имя не передаётся по проводу) — но это ломает текстовый формат/JSON и исходный код потребителей.
- Удалять поля — но номер обязательно зарезервировать.
- Менять поле между совместимыми varint-типами (см. ниже).
- Превращать single-поле в
repeatedтого же типа (с оговорками о packed, см. ниже) и наоборот для совместимости чтения.
Нельзя:
- Менять номер существующего поля.
- Переиспользовать номер ранее удалённого поля для другого смысла/типа → старые данные будут прочитаны неверно. Используем
reserved. - Менять wire type поля несовместимым образом (например string ↔ int32).
message User {
reserved 3, 7 to 9; // номера запрещены к повторному использованию
reserved "email", "phone"; // имена запрещены к переиспользованию
uint64 id = 1;
string name = 2;
}Совместимые смены типа#
- Между собой совместимы (VARINT):
int32,int64,uint32,uint64,bool,enum. Можно менять один на другой — но при усечении/переполнении значения интерпретируются как в C (например значение, не влезающее вint32, усекается). sint32↔sint64совместимы между собой, но не совместимы с обычными int-типами (другое кодирование, ZigZag).string↔bytesсовместимы, если строка валидный UTF-8 (оба LEN).fixed32↔sfixed32(оба I32),fixed64↔sfixed64(оба I64).- Embedded message ↔
bytesсовместимы на wire (оба LEN), если bytes содержит валидную сериализацию.
proto3: default values и отсутствие required#
- В proto3 нет
required(был в proto2 и считается анти-паттерном: required ломает эволюцию — нельзя удалить required-поле, не сломав старых читателей). - Default по типам: числа →
0,bool→false, string →"", bytes → пусто, enum →0, message →nil(unset). - По умолчанию (implicit presence) поля со значением default не сериализуются на провод (нулевые int, пустые string и т.п. пропускаются). Это и даёт компактность, но делает невозможным отличить «явно 0» от «не задано».
6. Presence и proto3 optional#
Implicit presence (по умолчанию в proto3 для scalar): нельзя узнать, было ли поле явно установлено в zero или вообще отсутствовало.
optional добавляет explicit presence — генерирует методы Has...() и pointer-семантику в Go:
message UpdateUserRequest {
uint64 id = 1;
optional string name = 2; // explicit presence
optional bool is_active = 3;
}req := &userv1.UpdateUserRequest{}
if req.Name != nil { // в Go optional scalar → *string
name := req.GetName()
_ = name
}
// req.HasName() также доступен через рефлексию / сгенерированный кодПод капотом optional реализован как oneof из одного поля (synthetic oneof) — на проводе ничего особенного, presence-бит хранится локально. Message-поля всегда имеют explicit presence (nil = unset), optional для них избыточен.
Альтернатива до появления proto3 optional — wrapper-типы (см. well-known types).
7. oneof#
oneof — набор полей, из которых установлено не более одного. Установка одного автоматически сбрасывает остальные. На проводе oneof — это просто обычные поля; «oneof-ность» обеспечивается генератором/рантаймом (при чтении нескольких полей одного oneof побеждает последнее в потоке).
message Event {
string id = 1;
oneof payload {
UserCreated user_created = 10;
UserDeleted user_deleted = 11;
OrderPlaced order_placed = 12;
}
}В Go генерируется интерфейс-обёртка:
switch p := ev.GetPayload().(type) {
case *eventv1.Event_UserCreated:
handleCreated(p.UserCreated)
case *eventv1.Event_UserDeleted:
handleDeleted(p.UserDeleted)
case *eventv1.Event_OrderPlaced:
handlePlaced(p.OrderPlaced)
case nil:
// ничего не установлено
}Эволюция и подводные камни oneof:
- Добавлять новые варианты в oneof безопасно по wire (это новый номер поля). Но старый код, не знающий нового варианта, увидит oneof как «не установлен» (вариант попадёт в unknown fields).
- Нельзя переносить существующее обычное поле внутрь oneof или наоборот безопасно во всех языках: это меняет семантику presence, хотя wire совместим. Перемещение одного поля в/из oneof обычно бинарно совместимо; перемещение нескольких — нет.
oneofне может бытьrepeated.- Поля внутри oneof не могут быть
optional(presence у oneof уже явный).
8. Well-known types#
Импортируются из google/protobuf/*.proto, в Go — пакет google.golang.org/protobuf/types/known/....
| Тип | Назначение |
|---|---|
Timestamp | момент времени (seconds + nanos от Unix epoch, UTC) |
Duration | длительность (seconds + nanos, знаковая) |
Empty | пустое сообщение (для RPC без аргументов/ответа) |
Any | произвольное упакованное сообщение + type URL |
Struct / Value / ListValue | динамический JSON-подобный объект |
FieldMask | список путей полей (для partial update / projection) |
BoolValue, Int32Value, StringValue, … (wrappers) | scalar с explicit presence |
import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";
import "google/protobuf/field_mask.proto";
import "google/protobuf/wrappers.proto";
import "google/protobuf/any.proto";
message Job {
google.protobuf.Timestamp created_at = 1;
google.protobuf.Duration timeout = 2;
google.protobuf.Int32Value retries = 3; // позволяет отличить 0 от "не задано"
google.protobuf.FieldMask update_mask = 4;
google.protobuf.Any payload = 5;
}import (
"google.golang.org/protobuf/types/known/timestamppb"
"google.golang.org/protobuf/types/known/anypb"
)
job.CreatedAt = timestamppb.New(time.Now())
t := job.GetCreatedAt().AsTime()
// Any: упаковка/распаковка конкретного типа
anyMsg, _ := anypb.New(&userv1.User{Id: 1})
var u userv1.User
_ = anyMsg.UnmarshalTo(&u)Wrappers и presence: до proto3 optional единственным способом отличить «zero» от «unset» для scalar были wrapper-сообщения (message → explicit presence, nil = unset). Сейчас для большинства случаев предпочтительнее optional, но wrappers всё ещё встречаются в legacy API и при работе с JSON-маппингом.
FieldMask: применяется в partial-update API (UpdateUserRequest { User user; FieldMask update_mask; }) — сервер обновляет только перечисленные в маске пути. Решает проблему «как отличить, что клиент хочет занулить поле, а что — не трогать».
Any: хранит сериализованное сообщение + type_url (например type.googleapis.com/example.user.v1.User). Опасность — теряется статическая типизация, нужен реестр типов для распаковки.
9. enum#
enum Status {
STATUS_UNSPECIFIED = 0; // ОБЯЗАТЕЛЬНО первый и = 0
STATUS_ACTIVE = 1;
STATUS_SUSPENDED = 2;
reserved 3, 4;
reserved "STATUS_LEGACY";
}- В proto3 первый элемент обязан иметь значение 0 и является default. Конвенция:
*_UNSPECIFIED = 0— чтобы default не означал случайно валидный бизнес-статус. - enum кодируется как VARINT (int32).
- Open enum semantics (proto3): неизвестное число при чтении сохраняется (не падает), доступно через
GetXxx()как сырое int-значение. Это даёт forward-совместимость: добавление новых значений enum безопасно. (В proto2 enum closed — неизвестное значение уходит в unknown fields.) - Эволюция: добавлять значения можно; удалять — резервируй номер и имя через
reserved. Не переиспользуй удалённые номера. - Allow aliases:
option allow_alias = true;разрешает два имени с одинаковым номером.
10. repeated, map, packed encoding#
message Metrics {
repeated int32 samples = 1; // packed по умолчанию (scalar)
repeated string tags = 2; // НЕ packed (LEN-элементы)
map<string, int64> counters = 3;
}Packed encoding: в proto3 repeated для scalar-типов с фиксированным/varint представлением (числа, bool, enum) кодируется packed по умолчанию — один тег + одна length-delimited область со всеми значениями подряд, без повторения тега:
[tag(LEN)] [len] [val1][val2][val3]...Это экономит по 1 тегу на элемент. repeated string/bytes/message не packable (каждый элемент — отдельная LEN-запись со своим тегом).
Совместимость: парсер обязан принимать обе формы (packed и unpacked) для packable полей — поэтому смена [packed=true]/[packed=false] бинарно совместима.
map — синтаксический сахар. На проводе map<K,V> эквивалентен:
message MapEntry {
K key = 1;
V value = 2;
}
repeated MapEntry counters = 3;Следствия:
- map не сохраняет порядок, дубликаты ключей → побеждает последний.
- map нельзя сделать
repeated, ключ — только integral/string/bool, значение — любой тип кроме другого map. - Можно эволюционировать
map<K,V>↔ соответствующийrepeated MapEntryбинарно совместимо.
Подводные камни / gotchas#
- Перепутать field number и значение по умолчанию.
int32 x = 5;— это поле номер 5, а не «x = 5». - Переиспользование номера удалённого поля — самая опасная ошибка эволюции: старые сериализованные данные молча декодируются в новое поле с тем же номером → порча данных. Всегда
reserved. - Implicit presence в proto3: нельзя отличить «0/пустую строку/false» от «не задано». Для PATCH-семантики используй
optional, wrappers илиFieldMask. - Отрицательные
int32/int64занимают 10 байт. Для координат, дельт и т.п. бериsint32/sint64. - Смена
int*↔sint*несовместима (разное кодирование), хотя оба VARINT. - enum default = 0: если 0 — валидный бизнес-смысл, легко получить молчаливый баг при unset. Держи
UNSPECIFIED = 0. - proto3 open enum: неизвестное значение enum приходит как сырое число; switch без
defaultэто пропустит. Всегда обрабатывай unknown. - Unknown fields могут теряться при некоторых операциях (JSON-маршалинг по умолчанию их игнорирует; устаревшие API). Для прокси, не теряющих новые поля, проверь, что path read→write идёт через бинарный proto.
Anyломает статическую типизацию и требует реестра типов; легко получить runtime-ошибку распаковки.- map не детерминирован: для стабильной сериализации (подписи, хеши) protobuf не гарантирует канонический порядок. Не используй обычную сериализацию для криптоподписи без deterministic-режима (
proto.MarshalOptions{Deterministic: true}сортирует ключи map, но это не межъязыковая гарантия канона). - proto2 vs proto3 enum: closed vs open — при миксе версий поведение неизвестного значения разное.
- Большие номера полей (>15, >2047) удорожают тег; не трать 1–15 на редкие поля.
required(proto2) делает поле невозможным к удалению — поэтому в proto3 его убрали; не воскрешай эту семантику ручными проверками так, чтобы они ломали forward-совместимость.
Вопросы на собеседовании#
В: Как кодируется тег поля на проводе и из чего он состоит?
О: Тег — это varint, равный (field_number << 3) | wire_type. Младшие 3 бита — wire type (0 VARINT, 1 I64, 2 LEN, 5 I32), старшие — номер поля. Имя поля на проводе не передаётся вообще.
В: Почему номера полей 1–15 предпочтительны для часто используемых полей? О: Тег кодируется varint’ом. Для номеров 1–15 значение тега ≤ 127 и помещается в 1 байт; для 16–2047 — 2 байта. На повторяющихся/частых полях экономия 1 байт на каждое значение заметна, поэтому горячие поля держат в 1–15.
В: Чем отличается backward от forward совместимости в protobuf и за счёт чего они достигаются? О: Backward — новый код читает старые данные (новые поля просто отсутствуют → default). Forward — старый код читает новые данные (неизвестные поля попадают в unknown fields и сохраняются при ре-сериализации). Оба держатся на стабильности номеров полей и на сохранении unknown fields.
В: Какие изменения схемы безопасны, а какие нет?
О: Безопасно: добавлять поля с новыми номерами, переименовывать поля (имя не на проводе), удалять поля с reserved номера. Совместимы смены между varint-типами (int32/int64/uint32/uint64/bool/enum), string↔bytes, fixed32↔sfixed32. Небезопасно: менять номер существующего поля, переиспользовать удалённый номер, менять wire type несовместимо (string↔int32, int↔sint).
В: Почему -1 в поле int64 занимает 10 байт и как это исправить?
О: Отрицательные значения int32/int64 представляются в two’s complement, расширенном до 64 бит, поэтому старшие биты выставлены и varint занимает максимум — 10 байт. Решение: sint32/sint64 с ZigZag-кодированием ((n<<1) ^ (n>>63)), где небольшие по модулю отрицательные числа дают короткий varint.
В: В proto3 нет required и по умолчанию нельзя отличить 0 от unset. Как реализовать PATCH/partial update?
О: Варианты: (1) optional поля — дают explicit presence и Has-методы/pointer в Go; (2) wrapper-типы (Int32Value и т.п.) — message с nil = unset; (3) FieldMask — клиент явно перечисляет, какие пути обновлять. FieldMask лучше всего решает «не трогать vs занулить».
В: Что такое oneof, как он ведёт себя на проводе и какие риски при эволюции?
О: oneof гарантирует, что установлено не более одного поля из группы; установка одного сбрасывает остальные. На проводе это обычные поля с разными номерами, при чтении нескольких побеждает последнее. Добавлять варианты безопасно (новый номер), но старый код увидит новый вариант как «не установлен». Перенос нескольких полей в/из oneof небезопасен; одно поле обычно бинарно совместимо. Поля oneof не могут быть repeated/optional.
В: Как packed encoding работает для repeated и для каких типов применяется?
О: В proto3 repeated scalar (числа/bool/enum) по умолчанию packed: один тег + length-delimited блок со всеми значениями подряд, без повторения тега на элемент. repeated string/bytes/message не packable — каждый элемент отдельная LEN-запись. Парсер обязан принимать обе формы, поэтому переключение packed бинарно совместимо.
В: Почему первый элемент enum в proto3 обязан быть 0 и что такое open enum?
О: Значение 0 — это default для unset, и proto3 требует, чтобы оно было первым; конвенция *_UNSPECIFIED = 0 не даёт default’у означать валидный бизнес-статус. Open enum (proto3): неизвестное при чтении число сохраняется как сырое значение (не падает и не уходит в unknown), что обеспечивает forward-совместимость при добавлении новых значений.
В: Как map<K,V> представлен на проводе и какие из этого следствия?
О: map — сахар над repeated сообщением {key=1; value=2;}. Следствия: порядок не сохраняется, при дублях ключей побеждает последний, ключ только integral/string/bool, значение — не map, нельзя repeated map. Сериализация map не канонична по порядку — для подписей нужен deterministic-режим.
На что копают на senior+#
- Точный wire format на байтах. Попросят руками закодировать сообщение: посчитать тег
(field<<3)|type, разложить число в varint, объяснить continuation bit и little-endian по 7 бит, посчитать байты для packed repeated. - Эволюция в проде с историческими данными. Сценарий: «удалили поле 3, через год добавили новое поле под номером 3» — что произойдёт со старыми событиями в Kafka/БД. Ожидают: молчаливая порча, отсюда дисциплина
reservedи code review схем, схема-реестр (buf, schema registry), линтеры (buf lint/breaking). - Presence-модель целиком. Различие implicit/explicit presence, как
optionalреализован через synthetic oneof, почему message-поля всегда имеют presence, когда wrappers vs optional vs FieldMask, влияние на JSON-маппинг (emitUnpopulated,useProtoNames). - Unknown fields в роли relay. Прокси/шлюз читает сообщение и пишет дальше — гарантия не потерять новые поля. Где unknown теряются (JSON,
Any, некоторые трансформации), и как это проверить. - Детерминированная сериализация и её пределы.
proto.MarshalOptions{Deterministic: true}сортирует map-ключи, но protobuf официально не гарантирует канонический byte-output между версиями/языками — поэтому нельзя строить криптоподписи на «просто Marshal». Альтернативы и риски. - Группы и legacy. Знание, что wire type 3/4 (groups) deprecated, чем заменены (nested messages), и почему встречаются в старых схемах.
Anyvsoneofvs полиморфизм. КогдаAnyоправдан (плагины, неизвестные заранее типы) противoneof(закрытый набор, типобезопасность), стоимость type URL и реестра.- Инструментарий. buf (lint, breaking change detection, BSR),
protocплагины,protoreflect/dynamicpb для обобщённой обработки, gRPC reflection, влияниеgo_packageи versioned packages (v1,v1beta1) на эволюцию API. - Производительность и аллокации в Go. Стоимость парсинга, переиспользование сообщений,
proto.Reset, влияние repeated/map на GC, почемуstringzero-copy не всегда возможен.