Модуль: 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: 0 VARINT, 1 I64 (64-bit), 2 LEN (length-delimited), 5 I32 (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 typeGo типЗаметки
int32, int64VARINTint32, int64неэффективны для отрицательных (10 байт)
uint32, uint64VARINTuint32, uint64
sint32, sint64VARINTint32, int64ZigZag, эффективны для отрицательных
boolVARINTbool1 байт (0/1)
enumVARINTсвой тип (int32)
fixed64, sfixed64, doubleI64uint64/int64/float64всегда 8 байт
fixed32, sfixed32, floatI32uint32/int32/float32всегда 4 байта
string, bytesLENstring, []byteдлина + payload; string обязан быть UTF-8
messageLEN*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_type

Wire types:

wire_typeИмяИспользуется для
0VARINTint32/64, uint32/64, sint32/64, bool, enum
1I64 (64-bit)fixed64, sfixed64, double
2LEN (length-delimited)string, bytes, embedded messages, packed repeated
3SGROUP (deprecated)start group
4EGROUP (deprecated)end group
5I32 (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 1100 0000 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 (байт)
011
-1101
15022
-150102

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, усекается).
  • sint32sint64 совместимы между собой, но не совместимы с обычными int-типами (другое кодирование, ZigZag).
  • stringbytes совместимы, если строка валидный UTF-8 (оба LEN).
  • fixed32sfixed32 (оба I32), fixed64sfixed64 (оба I64).
  • Embedded message ↔ bytes совместимы на wire (оба LEN), если bytes содержит валидную сериализацию.

proto3: default values и отсутствие required#

  • В proto3 нет required (был в proto2 и считается анти-паттерном: required ломает эволюцию — нельзя удалить required-поле, не сломав старых читателей).
  • Default по типам: числа → 0, boolfalse, 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), и почему встречаются в старых схемах.
  • Any vs oneof vs полиморфизм. Когда 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, почему string zero-copy не всегда возможен.