Learning Platform
Глоссарий Troubleshooting
Урок 06.01 · 30 мин
Средний
ProtobufWire FormatVarintZigzag EncodingWire TypesField TagsLength-Delimited

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 файлах и сгенерированном коде.

Protobuf vs Avro: что на wire?
Avro wireAvro кодирует значения без идентификаторов полей. Порядок полей определяется схемой. Ридер и райтер должны знать схему для декодирования.
Protobuf wireProtobuf кодирует пары (field_tag, value). Field tag содержит номер поля и wire type. Ридер может пропустить неизвестные поля по wire type.

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.

TIP

Field numbers — это контракт совместимости. Номер поля id = 1 фиксируется навсегда. Его нельзя переназначить другому полю — даже если id удалён из сообщения. Зарезервируйте удалённые номера через reserved 1;.

Wire Types

Protobuf определяет 6 wire types — каждый указывает ридеру, сколько байт занимает значение:

Protobuf Wire Types
IDЧисловой идентификатор wire type. Хранится в младших 3 битах field tag.
Wire TypeНазвание wire type в спецификации Protobuf.
РазмерКак ридер определяет длину значения.
Типы protoКакие типы из .proto файла используют данный wire type.
0Wire type 0: variable-length integer. Каждый байт использует 7 бит для данных, старший бит (MSB) — флаг продолжения.
Base 128 Varint — каждый байт кодирует 7 бит значения.
1–10 байт. Читать байты пока MSB=1, последний байт имеет MSB=0.
int32, int64, uint32, uint64, sint32, sint64, bool, enum
1Wire type 1: фиксированные 8 байт. Ридер всегда читает ровно 8 байт.
64-bit — фиксированная длина, little-endian.
Всегда 8 байт. Little-endian byte order.
fixed64, sfixed64, double
2Wire type 2: length-prefixed. Сначала varint с длиной, затем столько байт данных.
Length-delimited — varint-длина + данные.
varint(length) + length байт. Произвольный размер.
string, bytes, embedded messages, packed repeated fields
5Wire type 5: фиксированные 4 байта. Ридер всегда читает ровно 4 байта.
32-bit — фиксированная длина, little-endian.
Всегда 4 байта. Little-endian byte order.
fixed32, sfixed32, float
NOTE

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

Field Tag (varint)

Field tag — единственный varint, содержащий и номер поля, и wire type. Младшие 3 бита — wire type, остальные — field number.
Биты 3+Field number (номер поля из .proto). Для field number 1–15 помещается в оставшиеся 4 бита первого байта (7 бит varint − 3 бита wire type = 4 бита).
Биты 0-2Wire type (0–5). 3 бита = 8 возможных значений, из которых используются 4: 0 (VARINT), 1 (I64), 2 (LEN), 5 (I32).
Пример: field 1, VARINT(1 << 3) | 0 = 8 = 0x08. Один байт. Field number 1 + wire type 0 (VARINT).
Пример: field 2, LEN(2 << 3) | 2 = 18 = 0x12. Один байт. Field number 2 + wire type 2 (LEN).
Пример: field 16, VARINT(16 << 3) | 0 = 128 = varint 0x80 0x01. Два байта. Field number 16 не помещается в 4 бита первого байта.

Поля 1–15: один байт для field tag. Поля 16–2047: два байта. Поля 2048+: три байта и больше. Именно поэтому частые поля получают номера 1–15.

Varint Encoding

Base 128 Varint — основа Protobuf. Каждый байт использует 7 бит для данных и 1 бит (MSB) как флаг продолжения:

Varint Encoding: значение 300

Значение: 300 (десятичное)

Исходное значение 300 в десятичной системе. Нужно закодировать в varint (base-128).

300 = 0b100101100 → группы по 7 бит: 0000010 | 0101100

300 в двоичной: 100101100. Разбиваем на группы по 7 бит справа налево: 0000010 | 0101100.
Байт 1 (LSB)Младшие 7 бит: 0101100. MSB=1 (есть ещё байты). Результат: 1_0101100 = 0xAC.
Байт 2 (MSB)Старшие 7 бит: 0000010. MSB=0 (последний байт). Результат: 0_0000010 = 0x02.

Wire: 0xAC 0x02 (2 байта вместо 4 для int32)

На wire: 0xAC 0x02. Little-endian порядок групп: младшая группа первая. Декодирование: убрать MSB, собрать в обратном порядке: 0000010_0101100 = 300.

Алгоритм кодирования:

  1. Разбить число на группы по 7 бит (LSB first)
  2. Для каждой группы кроме последней: установить MSB = 1
  3. Для последней группы: MSB = 0
Varint: размер vs значение
ДиапазонДиапазон значений uint для данного количества байт.
БайтКоличество байт в varint encoding.
Бит данныхПолезных бит данных (7 на байт).
Маленькие числа — самые частые в реальных данных. 1 байт varint vs 4 байта fixed int32.
1Один байт: MSB=0, 7 бит данных. Значение до 127 помещается в один байт.
7 бит = 128 значений.
Два байта: первый с MSB=1, второй с MSB=0. 14 бит данных.
2Два байта varint. Большинство реальных ID попадают сюда.
14 бит = 16 384 значения.
Три байта: 21 бит данных. До ~2 миллионов.
3Три байта varint.
21 бит = 2 097 152 значений.
Максимум для varint — 10 байт (70 бит). int32 занимает до 5 байт, int64 — до 10.
4–104–10 байт. Varint кодирует до 64-бит unsigned.
28–70 бит данных соответственно.
WARNING

int32 отрицательные числа: Protobuf sign-extends int32 до 64 бит, поэтому -1 кодируется как 0xFFFFFFFFFFFFFFFF10 байт! Для отрицательных чисел используйте sint32/sint64 с zigzag encoding.

Zigzag Encoding

Zigzag решает проблему отрицательных чисел — маппит знаковые на беззнаковые, чтобы маленькие абсолютные значения давали маленькие varint:

zigzag(n) = (n << 1) ^ (n >> 31) // для 32-бит
zigzag(n) = (n << 1) ^ (n >> 63) // для 64-бит
Zigzag Mapping
SignedИсходное знаковое целое число (sint32/sint64 в proto).
ZigzagРезультат zigzag преобразования — всегда неотрицательное число.
Varint байтКоличество байт на wire после varint encoding zigzag-значения.
00 → zigzag(0) = 0.
0 маппится в 0.
varint(0) = 0x00, 1 байт.
-1-1 → zigzag(-1) = 1.
-1 маппится в 1. Чередование: отрицательные получают нечётные.
varint(1) = 0x01, 1 байт.
11 → zigzag(1) = 2.
1 маппится в 2. Положительные получают чётные.
varint(2) = 0x02, 1 байт.
-2-2 → zigzag(-2) = 3.
-2 маппится в 3.
varint(3) = 0x03, 1 байт.
21474836472147483647 → zigzag(2147483647) = 4294967294.
MAX_INT32 → большое zigzag, но всё равно меньше чем sign-extended отрицательное int32.
5 байт — максимум для sint32.
-2147483648-2147483648 → zigzag(-2147483648) = 4294967295.
MIN_INT32 → максимальное zigzag для 32-бит.
5 байт — максимум для sint32.

Паттерн: 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":

Кодирование User(id=150, name='Al')
User { id=150, name="Al", email="", age=0 }Исходное Protobuf-сообщение. Два поля с ненулевыми значениями. age=0 и email='' — default values, не кодируются в proto3.
Поле id (field 1, int32, wire type 0)Field tag: (1 << 3) | 0 = 8 = 0x08. Значение 150: varint(150) = 0x96 0x01. Итого 3 байта.
Поле name (field 2, string, wire type 2)Field tag: (2 << 3) | 2 = 18 = 0x12. Длина строки: varint(2) = 0x02. Данные: 'Al' = 0x41 0x6C. Итого 4 байта.

Wire: 08 96 01 12 02 41 6C (7 байт)

Итого 7 байт на wire: 08 96 01 12 02 41 6C. Для сравнения: JSON с теми же данными — 23 байта. Протобуф в 3.3 раза компактнее.
NOTE

В proto3 поля с default values (0 для чисел, "" для строк, false для bool) не кодируются на wire. Это экономит байты, но означает, что ридер не может отличить «поле явно установлено в 0» от «поле не установлено». В proto2 это различие существовало через has_field.

Length-Delimited Fields

Wire type 2 (LEN) используется для строк, байтовых массивов, embedded messages и packed repeated fields. Формат одинаков: varint-длина + данные:

Length-Delimited Encoding
stringString: varint(длина UTF-8 в байтах) + UTF-8 данные. Не null-terminated.
bytesBytes: varint(длина) + raw bytes. Формат идентичен string на wire — разница только в интерпретации.
embedded messageEmbedded message: varint(размер сериализованного message) + сериализованные поля вложенного message. Рекурсивная структура.
packed repeatedPacked repeated: varint(общий размер) + конкатенация varint-значений (для varint-типов) или fixed-size значений. Экономит field tag на каждый элемент.

Packed Repeated Fields

Repeated поля (массивы) в proto3 по умолчанию используют packed encoding для скалярных типов:

Packed vs Unpacked Repeated
Unpacked (proto2 default)Каждый элемент имеет свой field tag. Для массива из 3 int32: 3 field tag + 3 varint. Избыточность.
Packed (proto3 default)Один field tag + общая длина + конкатенация значений. Для массива из 3 int32: 1 tag + 1 length + 3 varint. Экономия на тегах.

Packed encoding экономит байты при больших массивах — один field tag вместо N. Но работает только для скалярных типов (int, float, enum). Строки и вложенные сообщения всегда unpacked.

Порядок полей на wire

Protobuf не гарантирует порядок полей на wire. Encoder может писать поля в любом порядке, ридер должен обрабатывать любой порядок. Одно и то же поле может появиться несколько раз — для скалярных типов побеждает последнее значение, для repeated — конкатенация.

Декодирование: unknown fields

Поток байт с field tags

Ридер получает байтовый поток с field tags. Некоторые tags могут содержать field numbers, которых нет в текущей версии .proto.

Field number известен?

Для каждого field tag: проверить field number. Если field number известен — декодировать значение. Если неизвестен — использовать wire type для пропуска.
ДаДекодировать значение по типу из .proto файла. Например, wire type 0 + тип sint32 → zigzag decode + varint decode.
НетПропустить значение: VARINT — читать байты пока MSB=1. I64 — пропустить 8 байт. LEN — прочитать varint(length), пропустить length байт. I32 — пропустить 4 байт. Сохранить raw bytes в unknown fields для re-serialization.
TIP

Unknown fields сохраняются в памяти при десериализации. Если объект re-serialized обратно на wire, неизвестные поля включаются в output. Это обеспечивает round-trip preservation — промежуточный сервис не теряет поля, добавленные новой версией.

Сравнение с Avro

АспектProtobufAvro
Идентификация полейField tag (number + wire type)Порядок по схеме
Unknown fieldsSkip по wire type, сохранить rawНевозможно без обеих схем
Overhead на поле1-2 байта (field tag)0 байт
StringsLength-prefixed UTF-8Length-prefixed UTF-8
IntegersVarint (1-10 байт)Zigzag varint (1-10 байт)
Signed integerssint* = zigzag, int* = sign-extendВсегда zigzag
EnumVarint indexAvro int index
Schema transport.proto файлы + protocJSON в header/registry

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Protobuf field tag кодирует два значения в одном varint: field_number и wire_type. Формула: (field_number << 3) | wire_type. Для поля с field number 2 и wire type 2 (LEN, string): (2 << 3) | 2 = 18 = 0x12. Почему именно field numbers 1–15 рекомендуются для часто используемых полей?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5