Protobuf: Wire Format
Protocol Buffers — обзор
Protocol Buffers (Protobuf) — формат бинарной сериализации от Google, изначально созданный для внутренних RPC-систем. Сейчас — де-факто стандарт для gRPC (HTTP/2 + Protobuf), мобильных приложений (Protobuf Lite), и межсервисной коммуникации. Поддерживает 11+ языков: C++, Java, Python, Go, Rust, C#, Kotlin, Swift, Objective-C, PHP, Ruby.
Ключевое отличие от Avro: Protobuf использует field numbers вместо имён полей на wire. Байтовый поток содержит только числовые теги и значения — имена полей существуют только в .proto файлах и сгенерированном коде.
Proto IDL
Protobuf использует .proto файлы для описания схемы:
syntax = "proto3";
message User {
int32 id = 1; // field number 1
string name = 2; // field number 2
string email = 3; // field number 3
int32 age = 4; // field number 4
}
Числа справа — field numbers (от 1 до 2²⁹−1 = 536 870 911). Номера 1–15 кодируются одним байтом в field tag, 16–2047 — двумя. Поэтому часто используемые поля получают номера 1–15.
Field numbers — это контракт совместимости. Номер поля id = 1 фиксируется навсегда. Его нельзя переназначить другому полю — даже если id удалён из сообщения. Зарезервируйте удалённые номера через reserved 1;.
Wire Types
Protobuf определяет 6 wire types — каждый указывает ридеру, сколько байт занимает значение:
Wire types 3 и 4 (SGROUP / EGROUP) использовались в proto2 для группировки полей. В proto3 deprecated и не используются. Современный Protobuf фактически работает с 4 wire types: 0, 1, 2, 5.
Wire type критически важен для forward compatibility: если ридер встречает неизвестный field number, он использует wire type чтобы пропустить значение без понимания его содержимого.
Field Tag
Каждое поле на wire начинается с field tag — одного varint, кодирующего field number и wire type:
field_tag = (field_number << 3) | wire_type
Field Tag (varint)
Field tag — единственный varint, содержащий и номер поля, и wire type. Младшие 3 бита — wire type, остальные — field number.Поля 1–15: один байт для field tag. Поля 16–2047: два байта. Поля 2048+: три байта и больше. Именно поэтому частые поля получают номера 1–15.
Varint Encoding
Base 128 Varint — основа Protobuf. Каждый байт использует 7 бит для данных и 1 бит (MSB) как флаг продолжения:
Значение: 300 (десятичное)
Исходное значение 300 в десятичной системе. Нужно закодировать в varint (base-128).300 = 0b100101100 → группы по 7 бит: 0000010 | 0101100
300 в двоичной: 100101100. Разбиваем на группы по 7 бит справа налево: 0000010 | 0101100.Wire: 0xAC 0x02 (2 байта вместо 4 для int32)
На wire: 0xAC 0x02. Little-endian порядок групп: младшая группа первая. Декодирование: убрать MSB, собрать в обратном порядке: 0000010_0101100 = 300.Алгоритм кодирования:
- Разбить число на группы по 7 бит (LSB first)
- Для каждой группы кроме последней: установить MSB = 1
- Для последней группы: MSB = 0
int32 отрицательные числа: Protobuf sign-extends int32 до 64 бит, поэтому -1 кодируется как 0xFFFFFFFFFFFFFFFF — 10 байт! Для отрицательных чисел используйте sint32/sint64 с zigzag encoding.
Zigzag Encoding
Zigzag решает проблему отрицательных чисел — маппит знаковые на беззнаковые, чтобы маленькие абсолютные значения давали маленькие varint:
zigzag(n) = (n << 1) ^ (n >> 31) // для 32-бит
zigzag(n) = (n << 1) ^ (n >> 63) // для 64-бит
Паттерн: 0, -1, 1, -2, 2, -3, 3 → 0, 1, 2, 3, 4, 5, 6. Маленькие абсолютные значения дают маленькие varint — по 1 байту. Сравните: int32(-1) = 10 байт (sign extension), sint32(-1) = 1 байт (zigzag).
Полный пример кодирования
Закодируем сообщение User со значениями id=150, name="Al":
Wire: 08 96 01 12 02 41 6C (7 байт)
Итого 7 байт на wire: 08 96 01 12 02 41 6C. Для сравнения: JSON с теми же данными — 23 байта. Протобуф в 3.3 раза компактнее.В proto3 поля с default values (0 для чисел, "" для строк, false для bool) не кодируются на wire. Это экономит байты, но означает, что ридер не может отличить «поле явно установлено в 0» от «поле не установлено». В proto2 это различие существовало через has_field.
Length-Delimited Fields
Wire type 2 (LEN) используется для строк, байтовых массивов, embedded messages и packed repeated fields. Формат одинаков: varint-длина + данные:
Packed Repeated Fields
Repeated поля (массивы) в proto3 по умолчанию используют packed encoding для скалярных типов:
Packed encoding экономит байты при больших массивах — один field tag вместо N. Но работает только для скалярных типов (int, float, enum). Строки и вложенные сообщения всегда unpacked.
Порядок полей на wire
Protobuf не гарантирует порядок полей на wire. Encoder может писать поля в любом порядке, ридер должен обрабатывать любой порядок. Одно и то же поле может появиться несколько раз — для скалярных типов побеждает последнее значение, для repeated — конкатенация.
Поток байт с field tags
Ридер получает байтовый поток с field tags. Некоторые tags могут содержать field numbers, которых нет в текущей версии .proto.Field number известен?
Для каждого field tag: проверить field number. Если field number известен — декодировать значение. Если неизвестен — использовать wire type для пропуска.Unknown fields сохраняются в памяти при десериализации. Если объект re-serialized обратно на wire, неизвестные поля включаются в output. Это обеспечивает round-trip preservation — промежуточный сервис не теряет поля, добавленные новой версией.
Сравнение с Avro
| Аспект | Protobuf | Avro |
|---|---|---|
| Идентификация полей | Field tag (number + wire type) | Порядок по схеме |
| Unknown fields | Skip по wire type, сохранить raw | Невозможно без обеих схем |
| Overhead на поле | 1-2 байта (field tag) | 0 байт |
| Strings | Length-prefixed UTF-8 | Length-prefixed UTF-8 |
| Integers | Varint (1-10 байт) | Zigzag varint (1-10 байт) |
| Signed integers | sint* = zigzag, int* = sign-extend | Всегда zigzag |
| Enum | Varint index | Avro int index |
| Schema transport | .proto файлы + protoc | JSON в header/registry |