17. Protocol Buffers

Под капотом: бинарная физика, Varint и рантайм Go

«Низкоуровневая работа с битами — вовсе не забытое ремесло, а фундамент, на котором покоится архитектура высокопроизводительных систем».
— Принцип оптимизации Bell Labs

Бинарная физика контрактов: Как работает Protobuf

В статье 16. gRPC. Основы мы определили Protocol Buffers (Protobuf) как фундамент, на котором строится высокопроизводительное межсервисное взаимодействие. Мы обсуждали его на архитектурном уровне, сравнивая с JSON (7. Форматы данных JSON vs Protobuf). Теперь пришло время спуститься на уровень байтов и регистров процессора, чтобы понять, за счет чего достигается эта производительность и какие скрытые ловушки ждут Go-разработчика при работе со сгенерированным кодом.

Protobuf — это не просто формат, это компилятор (protoc) и строгая спецификация бинарной упаковки данных. В отличие от JSON, который отправляет в сеть ключи в виде строк ("user_id": 123), Protobuf превращает структуру в максимально плотный поток бит, где нет ни одного лишнего пробела или символа кавычки.


Идентификация полей: Магия чисел (Tags)

Фундаментальное правило Protobuf: имена полей существуют только для людей. Рантайм Go и бинарный протокол ничего не знают о поле user_id. Они знают только его числовой тег (Tag).

PROTOBUF
message User {
  int64 id = 1;       // 1 - это Tag
  string email = 2;   // 2 - это Tag
}

Когда protoc-gen-go генерирует Go-код, эти теги "зашиваются" под капот методов сериализации. Если вы поменяете имя поля с email на primary_email в .proto файле, бинарная совместимость не сломается. Сервер на Go и клиент на Python продолжат прекрасно понимать друг друга.


Mechanical Sympathy: Алгоритм Varint (LEB128)

Junior-разработчик может подумать: если поле имеет тип int64, значит, в сеть всегда отправляется 8 байт. В мире баз данных и структур C это так. Но Protobuf использует динамическое сжатие целых чисел — Varint (Variable-length integer).

Под капотом используется алгоритм кодирования LEB128 (Little Endian Base 128).
В каждом байте числа 7 бит используются для хранения самих данных (Payload), а 8-й бит (Most Significant Bit, MSB) работает как флаг продолжения:

  • MSB = 1: "Это еще не конец, читай следующий байт".
  • MSB = 0: "Это последний байт числа".

Если вы отправляете число 1 в поле типа int64, Protobuf не будет слать 8 байт 00000000 ... 00000001. Он отправит ровно 1 байт: 00000001 (MSB равен нулю).
Если вы отправляете число 150, оно займет 2 байта.

Как формируется итоговый бинарный фрейм?
Перед каждым значением идет так называемый "Ключ" (Key), который сообщает парсеру номер поля (Tag) и тип данных (Wire Type).
Формула вычисления ключа на уровне битовых операций:
Key = Tag << 3 BIT-OR WireType (то есть (Tag << 3) | WireType).

Существует всего несколько Wire Types:

  • 0: Varint (для int32, int64, uint32, bool, enum)
  • 1: 64-bit (фиксированные 8 байт для fixed64, sfixed64, double)
  • 2: Length-delimited (строки, байты, вложенные сообщения — сначала идет длина Varint-ом, потом сами данные)
  • 5: 32-bit (фиксированные 4 байта)
flowchart TD
    Field["Поле int32 id = 1<br>Значение 150"] -->|"Шаг 1"| TagCalc["Вычисление Ключа<br>Тег 1 сдвиг влево на 3 BIT-OR WireType 0"]
    TagCalc -->|"Результат"| KeyByte["Байт 00001000<br>Десятичное число 8"]

    Field -->|"Шаг 2"| VarintCalc["Алгоритм LEB128<br>Сжатие числа 150"]
    VarintCalc -->|"Результат"| VarintBytes["Байты 10010110 00000001<br>MSB первого байта равен 1"]

    KeyByte -->|"Шаг 3<br>Конкатенация"| Net["Сетевой IO<br>Отправка 3 байт вместо 12"]
    VarintBytes --> Net

    classDef proc fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#e2e8f0;
    classDef calc fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#e2e8f0;
    class Field,Net proc;
    class TagCalc,KeyByte,VarintCalc,VarintBytes calc;
  

Рантайм Go и внутреннее устройство структур

Когда утилита protoc генерирует .pb.go файл, создаваемые структуры разительно отличаются от простых DTO.

Go
// Сгенерированный код выглядит примерно так
type User struct {
    state         protoimpl.MessageState
    sizeCache     protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields

    Id    int64  `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
    Email string `protobuf:"bytes,2,opt,name=email,proto3" json:"email,omitempty"`
}

Структура содержит скрытые поля (state, sizeCache, unknownFields). Они необходимы рантайму google.golang.org/protobuf для оптимизации вычисления размера буфера перед аллокацией и хранения метаданных.


Проблема Zero Values и возвращение Optional

В спецификации proto3 Google сделал кардинальное изменение по сравнению с proto2: все поля стали опциональными по умолчанию, но было убрано понятие "присутствия поля" (Field Presence).

Как это работает в сети:
Если поле типа int32 age = 3; имеет значение 0 (Zero Value), Protobuf вообще не отправляет его в сеть.
Это великолепно экономит трафик, но создает фундаментальную проблему для бэкенд-разработки (особенно для PATCH запросов из 4. Resource oriented design).

Если ваш Go-сервер получает структуру User, и поле Age равно 0, вы не знаете:

  1. Клиент явно прислал возраст 0.
  2. Клиент вообще не прислал это поле (оно обнулилось при инициализации структуры в Go).

Решение (начиная с proto 3.15):
Google вернул ключевое слово optional.

PROTOBUF
message UpdateUserRequest {
  optional int32 age = 1;
}

При генерации Go-кода, optional int32 превращается в указатель *int32.
Теперь, если поле не было отправлено в сеть, указатель будет nil. Если прислали 0 — будет указатель на область памяти с нулем. Это позволяет четко различать намерения клиента.


Forward Compatibility и Unknown Fields

Что произойдет, если микросервис А (версия v2) отправляет сообщение с полями 1, 2 и 3, а микросервис Б (версия v1) знает только про поля 1 и 2?

В JSON мы часто используем жесткую валидацию и можем упасть с ошибкой "неизвестное поле".
В Protobuf заложена строгая политика Forward Compatibility (Прямой совместимости).
Когда рантайм Go в сервисе Б читает поток байт и встречает Тег 3, он не паникует. Он видит его Wire Type, считывает нужное количество байт и помещает их в то самое скрытое поле unknownFields внутри сгенерированной структуры User.

Зачем сохранять то, что мы не понимаем?
Представьте, что сервис Б — это просто роутер или прокси. Он принял сообщение от А, посмотрел на id, ничего не понял в новых полях и переслал это сообщение в сервис В (который уже обновлен до v2).
При сериализации рантайм Go "приклеит" байты из unknownFields к исходящему потоку. Таким образом, новые данные не теряются при прохождении через старые узлы системы.


Итог

  1. Protobuf — это математически выверенный механизм упаковки данных. Имена полей игнорируются, всё строится на числовых Тегах (Tags).
  2. Целые числа сжимаются алгоритмом LEB128 (Varint), где маленькие значения занимают 1 байт.
  3. Сгенерированные структуры в Go содержат внутреннее состояние. Их можно сравнивать только через proto.Equal.
  4. В proto3 нулевые значения не передаются по сети. Для явного отличия отсутствующего поля от нуля используйте ключевое слово optional.
  5. Рантайм сохраняет неизвестные поля (Unknown Fields) в памяти, позволяя старым версиям микросервисов прозрачно пересылать новые форматы данных.

Понимая физику бинарных кадров и мультиплексирование потоков, мы готовы выйти за рамки классического паттерна Request-Response. Что делать, если нам нужно непрерывно загружать гигабайты логов или отдавать ленту котировок в реальном времени, не разрывая соединение? Эти задачи решает мощнейший механизм HTTP/2, который мы разберем в следующей статье: 18. gRPC streaming.