Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 19 из 25 ~20 мин +50 XP

Сериализация: оптимизация proto

Восемнадцатый урок научил сервер рассказывать о себе, а grpcurl — превращать ваш JSON в то, что реально едет по сети. Но что именно едет по сети, мы так и не видели: gRPC передаёт protobuf в бинарном виде, и весь ключ «оптимизация protobuf» живёт именно там, в этих байтах. Здесь вы разберёте устройство проводного формата: сообщение — это последовательность пар ключ-значение, где ключом служит номер поля, а не имя; откуда берётся формула тега (field_number << 3) | wire_type и почему номера 1–15 дешевле остальных; как работает varint и почему отрицательные int32 стоят десять байт, а sint32 со схемой ZigZag — один; как устроены length-delimited записи, вложенные сообщения и packed repeated. Затем — правила эволюции: reserved и катастрофа переиспользования номера, backward compatibility при выкатке новых версий proto-схем, что произойдёт, когда старый клиент прочитает новое сообщение, чем optional в proto3 (с протокола 3.15) отличается от неявных полей и почему это лучший дефолт. Финал — по-инженерному: сжатие gzip в gRPC через encoding.RegisterCompressor и grpc.UseCompressor, пятибайтовая рамка сообщения с флагом сжатия и потолок в четыре мегабайта, который gRPC ставит каждому входящему сообщению по умолчанию.

Содержание урока · 17 разделов

оптимизация protobuf: сообщение как серия ключ-значение#

Прежде чем что-то оптимизировать, посмотрим, что мы оптимизируем. Документация protobuf.dev по кодированию начинается с определения: «A protocol buffer message is a series of key-value pairs» — сообщение на проводе это серия пар ключ-значение, и дальше — самое важное предложение темы: «The binary version of a message just uses the field's number as the key – the name and declared type for each field can only be determined on the decoding end by referencing the message type's definition». В байтах нет ни имён полей, ни типов: есть номер поля, номер wire type и payload. Имя, тип и всё остальное живёт только в схеме, которую обе стороны обязаны держать согласованной — этим, кстати, занимается кодогенерация из третьего урока, раскладывая дескрипторы в сгенерированные пакеты. Каждая пара кодируется как запись из номера поля, wire type и полезной нагрузки, и «The wire type tells the parser how big the payload after it is. This allows old parsers to skip over new fields they don't understand» — вот фундамент всей совместимости: парсер, не знающий поля, всё равно знает, сколько байт пропустить. Схему называют Tag-Length-Value: «This type of scheme is sometimes called Tag-Length-Value, or TLV». Отсюда два практических вывода, ради которых и существует оптимизация protobuf. Первый: на проводе правит бал номер поля — менять его нельзя, а переименовывать можно почти безнаказанно, что перевернёт ваш взгляд на эволюцию схем ниже. Второй: размер сообщения зависит не от объявления, а от значений и номеров — «разжиревший» int64 в поле номер 30 дороже аккуратного значения в поле номер 3. Подглядывать за байтами удобно тем же grpcurl из восемнадцатого урока: он, правда, показывает вам JSON и дескрипторы, а не сырые байты, но именно сырые байты мы сейчас разберём руками.

Шесть wire types: что чем кодируется#

Номер и имяТипы полейPayload
0 — VARINTint32, int64, uint32, uint64, sint32, sint64, bool, enumvarint: от одного до десяти байт, длина зависит от значения
1 — I64fixed64, sfixed64, doubleвосемь байт фиксированно, little-endian
2 — LENstring, bytes, вложенные сообщения, packed repeatedvarint-префикс длины, затем столько байт полезной нагрузки
3 — SGROUPгруппы (устарело)пустая запись-открывашка, конец — парной записью EGROUP
4 — EGROUPгруппы (устарело)пустая запись-закрывашка
5 — I32fixed32, sfixed32, floatчетыре байта фиксированно, little-endian

Тег: формула (field_number << 3) | wire_type#

Ключ каждой записи — это varint, собранный из номера поля и wire type: «encoded as a varint formed from the field number and the wire type via the formula (field_number << 3) | wire_type». Разбор обратный: «the low 3 bits tell us the wire type, and the rest of the integer tells us the field number» — младшие три бита это wire type, остальное номер. Проверьте на двух знакомых байтах. Первый байт сообщения из схемы message Test1 { int32 a = 1; } — 0x08: двоичное 00001000, младшие три бита 000 — VARINT, сдвиг вправо даёт номер 1. Для message Test2 { string b = 2; } тег — 0x12: двоичное 00010010, младшие биты 010 — это LEN, номер 2. Отсюда следствие, с которого начинается любая оптимизация proto: чем меньше номер, тем короче тег. «You should use the field numbers 1 through 15 for the most-frequently-set fields» — документ прямо советует отдавать диапазон 1–15 самым часто заполняемым полям, потому что «Lower field number values take less space in the wire format»: номера 1–15 кодируются тегом в один байт, 16–2047 — в два. Диапазон номеров ограничен 536 870 911, потому что «The field number is limited to 29 bits rather than 32 bits because three bits are used to specify the field's wire format». И есть запрещённая зона посередине: «Field numbers 19,000 to 19,999 are reserved for the Protocol Buffers implementation» — «The protocol buffer compiler will complain if you use one of these reserved field numbers in your message», protoc отвергнет такую схему на компиляции. Каноничным для нас остаётся Order из второго урока: id = 1, items = 2 — как раз в дешёвом диапазоне.

Проверка: разберите тег

Первый байт записи в бинарном сообщении — 0x12 (десятичное 18). Что это значит?

varint: база-128 и экономия на малых значениях#

VARINT — самый частый тип на проводе, и «Variable-width integers, or varints, are at the core of the wire format. They allow encoding unsigned 64-bit integers using anywhere between one and ten bytes, with small values using fewer bytes». Устройство простое: «Each byte in the varint has a continuation bit that indicates if the byte that follows it is part of the varint. This is the most significant bit (MSB)» — старший бит байта это продолжение, остальные семь бит — полезная нагрузка, и «These 7-bit payloads are in little-endian order» — семибитные группы склеиваются младшими вперёд. Классический пример из доки: значение 150 для поля a = 1 едет тремя байтами 08 96 01. Тег 0x08 мы разобрали; varint 96 01 раскладывается так: 10010110 00000001, у первого байта MSB установлен — есть продолжение; сбрасываем биты продолжения, получаем 0010110 и 0000001, разворачиваем в big-endian: 00000010010110 — это 128 + 16 + 4 + 2 = 150. Обратите внимание на цену: 150 стоит одного байта varint, но 1 000 000 — уже трёх, 2^31 и больше — пяти, а почти любое большое 64-битное — всех десяти. Отсюда главное правило варинт-экономии: размер зависит от значений, а не от объявления. Есть и обратная сторона sparseness: «Missing fields are easy to encode: we just leave out the record if it's not present» — незаполненное поле не едет вовсе, поэтому «huge»-прото с горой объявленных полей и парой заполненных на проводе компактнее, чем выглядит в схеме. Bool живёт в VARINT и всегда кодируется ровно одним байтом 00 или 01 — промежуточных значений у него нет.

Что лежит на проводе?

Прочитайте код и выберите, что он выведет:

// proto
message Test1 {
  int32 a = 1;
}
// в коде: a = 150, сериализация

Отрицательные числа: десять байт за -2#

Здесь новички платят самую дорогую цену. Для обычных int32 и int64 документ говорит жёстко: «The intN types encode negative numbers as two's complement, which means that, as unsigned, 64-bit integers, they have their highest bit set. As a result, this means that all ten bytes must be used» — отрицательное значение превращается в 64-битное дополнительное с выставленным старшим битом, и varint вынужден тащить все десять байт. Двести пятьдесят байт на сотню отрицательных температур — это не шутка, а реальный профиль трафика. Лечится специальной парой типов: «sintN uses the ZigZag encoding instead of two's complement to encode negative integers» — положительные значения переходят в чётные, отрицательные в нечётные: «Positive integers p are encoded as 2 p (the even numbers), while negative integers n are encoded as 2 |n| - 1 (the odd numbers)». Таблица отражения: 0 → 0, -1 → 1, 1 → 2, -2 → 3, 2147483647 → 4294967294, -2147483648 → 4294967295; формулы — (n << 1) ^ (n >> 31) для sint32 и (n << 1) ^ (n >> 63) для 64-битной версии. В protoscope из доки -500 помечается суффиксом z и равняется varint 999. Итог: -1 в sint32 стоит один байт, в int32 — десять. Когда ширина важнее знака, есть второй путь — fixed: fixed64, sfixed64 и double всегда занимают восемь байт (wire type I64), fixed32, sfixed32 и float — четыре (wire type I32); для координат, хэшей и денежных сумм с плавающей точкой фиксированная ширина честнее varint-roulette.

Проверка: тип для отрицательных значений

Поле будет часто хранить отрицательные и часто небольшие положительные значения — дельты мониторинга, разности координат. Что выбрать, чтобы не платить за каждое отрицательное значение десятью байтами?

LEN: строки, байты, вложенные сообщения и packed#

Всё остальное на проводе — length-delimited. «The LEN wire type has a dynamic length, specified by a varint immediately after the tag, which is followed by the payload as usual» — тег, varint-длина, payload. Пример из доки: строка testing для поля b = 2 — это 12 07 74 65 73 74 69 6e 67: тег 0x12, длина 7, семь байт UTF-8; «The int32 varint means that the max length of a string is 2GB» — отсюда же потолок в 2 ГиБ на сериализованное сообщение. bytes кодируется так же. Вложенные сообщения — тоже LEN: для message Test3 { Test1 c = 3; } со значением c.a = 150 на проводе лежит 1a 03 08 96 01 — тег поля 3, длина 3, внутри ровно те три байта из первого примера. Заметьте цену вложенности: каждый уровень добавляет свой тег и свою длину — глубокая иерархия мелких сообщений раздувает трафик сильнее плоской схемы. repeated-поля чисел в proto3 кодируются packed по умолчанию: вместо записи на элемент — «encoded as a single LEN record that contains each element concatenated» — один LEN-рекорд с конкатенацией значений. При этом порядок записей не важен: записи одного repeated-поля могут перемежаться другими полями, и «only the order of records for the same field with respect to each other is preserved». Парсеры обязаны понимать обе формы — packed и по-элементную — «This permits adding [packed=true] to existing fields in a forward- and backward-compatible way», ещё один пример того, как формат прощает эволюцию.

reserved: почему переиспользование номера — катастрофа#

Теперь правила эволюции — то, ради чего бинарный формат вскрывали. Поле больше не нужно: удаляйте спокойно, но с одним обязательным жестом. Документ proto3 не оставляет выбора: «you must reserve the deleted field number» — номер удалённого поля обязан попасть в reserved. Почему так строго? Потому что формат не даёт второго шанса: «Reusing a field number makes decoding wire-format messages ambiguous» — декодирование сообщений, записанных одной версией схемы и прочитанных другой, становится неоднозначным, и «The protobuf wire format is lean and doesn't provide a way to detect fields encoded using one definition and decoded using another» — на проводе нет контрольной суммы или сигнатуры, которая поймает подмену. Побегут старые данные мимо нового поля — документ перечисляет последствия по возрастанию боли: потерянные часы отладки, «A parse/merge error (best case scenario)», а дальше — «Leaked PII/SPII» и «Data corruption»: например, старое поле хранило email в LEN-записи, новое поле с тем же номером объявили int32 — парсер прочитает длину как значение, а байты письма молча выбросит или уронит разбор. Синтаксис защиты: reserved 2, 15, 9 to 11; — «Reserved field number ranges are inclusive (9 to 11 is the same as 9, 10, 11)». Номер — не единственное, что стоит заблокировать: «You should also reserve the field name to allow JSON and TextFormat encodings of your message to continue to parse» — имя тоже резервируют — reserved "foo", "bar"; — потому что JSON-кодирование ходит по именам. И жёсткое ограничение языка: «you can't mix field names and field numbers in the same reserved statement» — имена и номера резервируются отдельными строками. protoc проверит оба списка и не даст новому полю занять чужой номер или имя.

Эволюция Order: reserved в бою

GO
// shop/v1/order.proto — схема выросла после второго урока
syntax = "proto3";

package shop.v1;

option go_package = "github.com/example/shop/gen/shopv1";

import "google/protobuf/timestamp.proto";

message Order {
  int64 id = 1;                    // канон второго урока: номера 1–6 не переезжают
  repeated OrderItem items = 2;
  map<string, string> metadata = 3;
  optional string promo_code = 4;  // явное присутствие — ниже объясним, что это меняет
  OrderStatus status = 5;
  google.protobuf.Timestamp created_at = 6;

  // Поле coupon_code = 7 жило в промежуточной версии и было удалено.
  // Номер и имя выведены из оборота навсегда — protoc не даст их занять:
  reserved 7;
  reserved "coupon_code";

  optional int64 total_cents = 8;  // новое поле: старые клиенты молча его пропустят
}

Два reserved-оператора — номер и имя — обязаны стоять раздельно, как требует proto3. Обратите внимание, что grpcurl из восемнадцатого урока покажет вам только живые поля схемы: ranges reserved в describe не печатаются, они видны в .proto-исходнике и в дескрипторе. Поэтому проверка эволюции двухэтапная: describe после перегенерации должен показать total_cents и НЕ показать coupon_code, а сам факт reserved гардится компилятором — новое поле с номером 7 или именем coupon_code сломает protoc с понятной ошибкой.

Соберите защитную схему

Восстановите proto3-объявление: синтаксис, оба reserved-списка и поле с явным присутствием.

syntax = ""; message Legacy { int64 id = 1; string name = 2; // номера 5 и 9–11 освобождены в прошлых версиях схемы 5, 9 to 11; // имена, которые тоже нельзя переиспользовать — JSON ходит по именам "email", "fax"; // заданное false тоже едет по проводу bool active = 3; }

Backward compatibility: старый клиент читает новое сообщение#

Настало время развернуть два термина, которые постоянно путают. Forward compatibility — старый код читает данные новой схемы; backward compatibility — новый код читает данные старой схемы. Протокол даёт оба направления сразу, и механизм уже знаком: «The wire type tells the parser how big the payload after it is. This allows old parsers to skip over new fields they don't understand». Старый клиент встречает незнакомый тег, смотрит на три младших бита и ровно столько байт пропускает, сколько требует тип: VARINT — до байта без MSB, LEN — читает varint длины и отматывает её, I32/I64 — отматывает 4/8 байта. Документ proto3 подтверждает: «Adding new fields is safe ... old binaries simply ignore the new field when parsing» — добавление полей безопасно в обе стороны, значения незнакомых полей примут дефолты. Зеркально про номера: «Changing the field number is equivalent to deleting the field and adding a new field with the same type» — смена номера это не перенос, а удаление плюс новое поле, поэтому смена номеров живых полей — «Changing field numbers for any existing field is not safe». Причём unknown fields не выбрасываются: парсер сохраняет их в сообщении и возвращает на провод при ре-сериализации — цепочка прокси из старых версий не теряет новые данные. Есть и обратная сторона честности: дефолты неотличимы от пустых значений, поэтому старый код, читающий новое сообщение, видит нули там, где новый код видит данные. Наконец, правило Last One Wins: «if the same field appears multiple times, the parser accepts the last value it sees» — дубликат скаляра перезаписывается последним значением, дубликаты вложенных сообщений мержатся, а разбор конкатенации двух сообщений эквивалентен разбору по очереди с merge — удобное свойство для стриминга и логов.

Матрица изменений схемы#

ИзменениеВердикт форматаЧто помнить
Добавить поле с новым номеромБезопасноСтарые клиенты пропускают по wire type; новые видят дефолты вместо отсутствия
Удалить поле с reservedБезопасноНомер и имя — в reserved, иначе кто-то займёт их заново
Переименовать полеБезопасно на проводеНомер не меняется; имя важно для JSON и TextFormat — поэтому резервируют и его
Сменить номер живого поляWire-unsafeДокумент: «Changing field numbers for any existing field is not safe» — эквивалент удаления плюс нового поля
int32 → int64Условно совместимоСтарый int32-читатель усечёт значения больше INT32_MAX — следите за диапазоном
int32 → sint32НесовместимоZigZag кодирует иначе; sint-типы совместимы только друг с другом

Проверка: сервер выкатил новые поля

Сервер переехал на схему v2 с тремя новыми полями, а мобильный клиент ещё жив на v1. Клиент разбирает ответ v2-сервера. Что произойдёт?

Дубликат на проводе

Прочитайте код и выберите, что он выведет:

// proto: message Counter { int32 hits = 1; }
//
// с провода приходит конкатенация двух сериализаций:
// 08 05        (hits = 5)
// 08 07        (hits = 7)
//
// Counter parsed; fmt.Println(parsed.GetHits())

optional в proto3: явное присутствие с версии 3.15#

Во втором уроке в схеме Order уже мелькнуло optional string promo_code = 4; — теперь разберём, что это значит на проводе. У обычного (implicit) скалярного поля proto3 присутствия нет: нулевое значение не кодируется вовсе, и set-ли вы его или нет, на проводе не отличишь. Поле optional включает явное присутствие: «An optional field is in one of two possible states: the field is set, and contains a value that was explicitly set or parsed from the wire. It will be serialized to the wire» — либо unset, тогда возвращается дефолт и на провод ничего не едет; главное — «You can check to see if the value was explicitly set». В Go это делает поле указателем — PromoCode *string — и хаззеры GetPromoCode/HasPromoCode в сгенерированном коде. История версии: «Presence tracking for proto3 messages is enabled by default since v3.15.0 release», а до 3.12.0 требовался флаг --experimental_allow_proto3_optional; свежий protoc просто принимает optional. Зачем платить указателем? Разница проявляется в круглых поездках: «The serialized value following implicit presence discipline does not contain the default value, even though it was explicitly set» — клиент с implicit-полем, получив explicit-значение 0, потеряет факт «было ноль», и документ показывает, как assert на has после такого round trip падает. Поэтому рекомендация синтаксиса прямая: «optional is recommended over implicit fields for maximum compatibility with protobuf editions and proto2» — тот же механизм presence даёт и oneof из второго урока, поэтому различать «не задано» и «задано пусто» можно без своих флагов-костылей.

Сжатие gzip: кодеки, заголовки и пятибайтовая рамка#

Каждое protobuf-сообщение внутри gRPC едет не голым, а в пятибайтовой рамке: один байт — флаг сжатия, четыре байта — длина big-endian. В исходниках rpc_util.go это буквально: «The format of the payload: compressed or not?», тип payloadFormat, где compressionNone = 0 и compressionMade = 1 — единица в первом байте значит, что содержимое сжато кодеком, названным в заголовке grpc-encoding. Сжатие gzip в gRPC собирается из трёх деталей. Первая — регистрация кодека: предпочтительный способ — encoding.RegisterCompressor, и для gzip он уже написан за вас: пакет gzip «implements and registers the gzip compressor» — достаточно импортировать google.golang.org/grpc/encoding/gzip, init() зарегистрирует кодек сам; имя фиксировано константой Name со значением «gzip», и пакет честно помечен: «This package is EXPERIMENTAL and may be changed or removed in a later release». Вторая — выбор кодека клиентом: CallOption grpc.UseCompressor(gzip.Name) — док говорит, что он «sets the compressor used when sending the request», а через WithDefaultCallOptions становится дефолтом всех вызовов канала. Если кодек не установлен, клиент не пойдёт в сеть: «If UseCompressor is used and the corresponding compressor has not been installed, an Internal error will be returned to the application before the RPC is sent»; имя, которого нет в реестре, даёт InvalidArgument с текстом «grpc: compressor %q is not registered». Третья — сервер: «registered compressors will be used automatically to decode request messages and encode the responses», и правило симметрии: «Servers currently always respond using the same compression method specified by the client» — сжатый запрос получает сжатый ответ; кодека нет у сервера — «an Unimplemented status will be returned to the client». Форсировать кодек ответа можно grpc.SetSendCompressor(ctx, gzip.Name) внутри хендлера — «SetSendCompressor sets a compressor for outbound messages from the server», — но только если клиент заявлял кодек в grpc-accept-encoding — «compressor name must exist in the client advertised compressor names sent in grpc-accept-encoding header»; базовое правило остаётся прежним: «By default, server messages will be sent using the same compressor with which request messages were sent». Уровень сжатия настраивает gzip.SetLevel — но он «must only be called during initialization time (i.e. in an init() function), and is not thread-safe», и «gzip.HuffmanOnly is not supported».

Клиент: UseCompressor и blank-импорт кодека

GO
// cmd/orders-client/main.go — gzip для всех вызовов канала
import (
    "context"
    "log"
    "time"

    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    gzip "google.golang.org/grpc/encoding/gzip" // init() регистрирует кодек
    pb "shop/gen/shop/v1"
)

func main() {
    // grpc.NewClient из пятого урока + кодек по умолчанию для всех вызовов
    conn, err := grpc.NewClient("orders:50051",
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        // каждый вызов уйдёт с заголовком grpc-encoding: gzip
        grpc.WithDefaultCallOptions(grpc.UseCompressor(gzip.Name)),
    )
    if err != nil {
        log.Fatalf("NewClient: %v", err)
    }
    defer conn.Close()

    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    client := pb.NewOrderServiceClient(conn)
    order, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 42})
    if err != nil {
        log.Fatalf("GetOrder: %v", err)
    }
    log.Printf("order %d: %v байт истории", order.GetId(), len(order.GetItems()))
}

Именованный импорт gzip здесь делает двойную работу: он и запускает init() с encoding.RegisterCompressor, и даёт доступ к gzip.Name — blank-импорта для клиента с UseCompressor не хватит, константу надо откуда-то взять. Флаг сжатия в первом байте рамки встанет в 1, а сервер, у которого пакет импортирован, ответит тем же кодеком. Если сервер gzip не знает, ответ придёт статусом Unimplemented — это диагностичный признак забытого импорта, а не сетевой сбой.

Сервер: распаковка автоматом, ответ — тем же кодеком

GO
// cmd/orders/main.go — серверу достаточно blank-импорта
import (
    "log"
    "net"

    "google.golang.org/grpc"
    _ "google.golang.org/grpc/encoding/gzip" // регистрация кодека в init()
    pb "shop/gen/shop/v1"
)

func main() {
    s := grpc.NewServer()
    pb.RegisterOrderServiceServer(s, &server{})

    // Внутри хендлера можно сменить кодек ответа принудительно:
    //   grpc.SetSendCompressor(ctx, gzip.Name)
    // но клиент обязан заявить gzip в grpc-accept-encoding,
    // иначе SetSendCompressor вернёт ошибку валидации.

    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatal(err)
    }
    log.Print("listening :50051")
    s.Serve(lis) // сжатые запросы распакуются, ответы уйдут сжатыми
}

Серверу имя кодека в коде не нужно — blank-импорт срабатывает в init(), дальше транспорт читает grpc-encoding запроса и сам ставит флаг сжатия в ответах: по умолчанию используется тот же кодек, каким пришёл запрос. Дедлайн, метаданные и перехватчики из прежних уроков на сжатие не влияют: кодек стоит ниже перехватчиков, на границе транспорта, и работает для стриминга так же, как для unary — каждый элемент стрима едет в своей рамке со своим флагом.

Размер: 4 МБ по умолчанию, 2 ГиБ потолок, порядок полей#

Сжатие экономит байты, но у gRPC есть жёсткий потолок на каждое входящее сообщение. В server.go константа defaultServerMaxReceiveMessageSize = 1024 1024 4 — четыре мегабайта; столько же стоит и у клиента, defaultClientMaxReceiveMessageSize. Превышение ловится в парсере рамки: «grpc: received message larger than max (%d vs. %d)» со статусом ResourceExhausted — цифры в тексте это фактическая длина рамки и текущий потолок. Сжатие не спасает от лимита: длина в рамке — это размер сжатого payload, но grpc-go ограничивает и результат декомпрессии — декомпрессор режет распакованный поток на границе maxMessageSize+1, чтобы поймать превышение (doWithMaxSize в rpc_util.go). Свой потолок и у protobuf: «Protos must be smaller than 2 GiB when serialized» — varint-префикс длины LEN-записей шире не даёт. Теперь про порядок: «Field numbers may be declared in any order in a .proto file. The order chosen has no effect on how the messages are serialized» — перестановка полей в объявлении не меняет размер, писать «горячие поля первыми» для скорости сериализации бессмысленно; выгоден только номер — 1–15 дешевле тегом. И важное предостережение: «Serialization order is an implementation detail», «Do not assume the byte output of a serialized message is stable», по умолчанию «the default serialization is not deterministic» — хэшировать байты сериализации, сравнивать их или класть в БД как канонические нельзя; детерминированная сериализация гарантируется только внутри конкретной сборки. Всё это — ручки, которые крутим дальше: «двадцать второй урок — производительность и тюнинг: keepalive, message size, компрессия» — разберёт MaxReceiveMessageSize и буферные пулы по-настоящему.

Чек-лист: оптимизация proto без ломки совместимости#

  • Горячие поля получайте номера 1–15 — тег в один байт; 16–2047 стоят два байта.
  • Часто отрицательные значения — sint32/sint64; int32 с минусами платит десятью байтами за запись.
  • Стабильная ширина (координаты, хэши, float) — fixed32/fixed64/float/double, а не varint.
  • Удаляете поле — reserved для номера и отдельный reserved для имени, без исключений.
  • Никогда не меняйте номер живого поля: это удаление плюс новое поле для всех парсеров.
  • int32 → int64 совместимо с оговоркой об усечении; int32 → sint32 на проводе несовместимо вовсе.
  • Не хэшируйте и не сравнивайте байты сериализации: сериализация недетерминирована по умолчанию.
  • Огромные repeated-числа не размаркируйте вручную: packed включён, парсеры понимают обе формы.
  • «не задано» и «задано пусто» — это optional или oneof, а не sentinel-строки в implicit-полях.
  • После каждого изменения схемы: protoc, describe через grpcurl, прогон клиентов обеих версий.

Workflow: убираем поле, не сломав никого

Соберите безопасный порядок удаления поля coupon_code = 7 из живой схемы Order.

Строки

Ваша программа

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

Карточки: бинарный формат proto

Термин

varint

нажмите, чтобы перевернуть
Определение

Базовая кодировка wire format: «Variable-width integers, or varints, are at the core of the wire format» — от одного до десяти байт. В каждом байте MSB — бит продолжения, остальные семь бит — payload, группы склеиваются little-endian. 150 = 96 01. Размер зависит от значения, поэтому малые числа дёшевы, а большие и отрицательные int — дороги.

1 / 6

Что дальше по курсу#

Честная оговорка про формат, одна на урок: в самоучителе есть песочница с интерпретатором Go, но сеть в браузере недоступна — protoc, gRPC-сервер и grpcurl в браузере не запустить, поэтому runner-ов в этом уроке, как и во всём gRPC-курсе, нет; каждое упражнение делается локально за несколько минут. Три эксперимента. Первый: соберите сервер и клиент из четвёртого и пятого уроков, сериализуйте Order с id = 150 и hexdump-ните тело ответа — найдите те самые 08 96 01, затем поменяйте значение на 200000 и пересчитайте varint руками. Второй: добавьте gzip-кодек в клиент из этого урока и не импортируйте его на сервере — GetOrder вернёт Unimplemented; добавьте blank-импорт на сервер и убедитесь, что ответ снова приходит, а сниффер показывает grpc-encoding: gzip в обоих направлениях. Третий: удалите поле из схемы, поставьте reserved, перегенерируйте код и убедитесь, что старый бинарник клиента читает ответы нового сервера без ошибок, а protoc отвергает попытку занять зарезервированный номер. Дальше по маршруту: двадцатый урок — версионирование API: там «эволюция схем» поднимается с уровня формата на уровень организации кода — пакеты и версии proto-схем в одном сервисе, несколько версий одного API без вилок в коде; сегодняшняя матрица изменений станет его словарём. Перед этим освежите синтаксис proto3 из второго урока — номера полей, enum и oneof — и рефлексию из восемнадцатого — describe покажет живую схему после каждой эволюции. Полный маршрут курса — на странице gRPC-Go.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Сообщение на проводе — серия пар ключ-значение: ключ это номер поля и wire type по формуле (field_number << 3) | wire_type; имён и типов в байтах нет, они живут в схеме.
  • Wire types: 0 VARINT, 1 I64, 2 LEN, 5 I32 (3/4 — устаревшие группы); тип в теге говорит парсеру, сколько байт payload прочитать или пропустить.
  • varint — база-128 с битом продолжения: 150 = 96 01; размер зависит от значения; незаполненные поля на провод не едут — proto выходит sparse и компактным.
  • Отрицательные int32/int64 стоят десять байт; sint-типы применяют ZigZag ((n << 1) ^ (n >> 31)) и укладывают -1 в один байт; стабильная ширина — fixed/float/double.
  • Удаляйте поля только с reserved: номер и имя резервируются отдельными операторами; переиспользование номера делает декодирование неоднозначным — до утечки PII и порчи данных.
  • Backward и forward compatibility даются форматом: старые парсеры пропускают незнакомые поля, дубликаты скаляров разрешает Last One Wins, вложенные мержатся; смены номера поля не допускаются вовсе.
  • optional в proto3 — явное присутствие с протокола 3.15 (флаг --experimental_allow_proto3_optional раньше): в Go это указатель, различает «не задано» и «задано пусто», и рекомендуется вместо implicit-полей.
  • Сжатие gzip: encoding.RegisterCompressor, grpc.UseCompressor у клиента, сервер отвечает тем же кодеком; флаг сжатия — первый байт пятибайтовой рамки; потолок входящего сообщения — 4 МБ (ResourceExhausted), сериализованный прото — 2 ГиБ.

Домашнее задание

Соберите лабораторию проводного формата на схеме Order. Первое: сериализуйте сообщение с a = 150, a = 200000 и a = -1 в int32-поле, hexdump-ните байты и пересчитайте каждый varint руками — убейтесь, что -1 занял десять байт; повторите тот же -1 в sint32-поле и объясните разницу формулами ZigZag. Второе: возьмите схему из урока, удалите поле с reserved для номера и имени, попробуйте объявить новое поле с этим номером и сохраните текст ошибки protoc; затем уберите reserved и повторите — почувствуйте, что защищает компилятор, а что остаётся на вашей совести. Третье: соберите три бинарника — сервер v1, сервер v2 с новым полем и клиент v1 — и проверьте матрицу из урока: клиент v1 читает ответы обеих версий серверов, клиент v2 читает ответ v1; опишите, где видны дефолты, а где unknown fields. Четвёртое: включите gzip только в клиенте и добейтесь Unimplemented от сервера без кодека; добавьте кодек серверу, потом форсируйте ответ grpc.SetSendCompressor внутри хендлера и разберите, при каком grpc-accept-encoding это легально. Пятое: напишите бенчмарк, сравнивающий размер сериализации Order с десятью элементами items против плоского repeated-поля чисел, и объясните, сколько байт на запись съедает вложенность LEN. Шестое: отправьте сообщение размером выше 4 МБ на дефолтный сервер и разберите статус ResourceExhausted по цифрам в тексте ошибки; поднимите потолок MaxReceiveMessageSize на сервере и клиенте и повторите, затем объясните, почему 2 ГиБ не преодолеть никак.

Личные заметкиПусто
Хранятся только в вашем браузере (gRPC · Сериализация: оптимизация proto)

Вопросы и ответы по уроку

Почему нельзя переиспользовать номер удалённого поля, если тип тот же?

Даже тот же тип не спасает от неоднозначности в архивах: сообщения, записанные старой версией схемы, на проводе неотличимы от новых, а «The protobuf wire format is lean and doesn't provide a way to detect fields encoded using one definition and decoded using another». Между удалением и повторным использованием поле обычно живёт под другим смыслом: старые данные прочитаются новыми семантиками, валидации пропустят мусор, а дальше — документ прямо перечисляет «Leaked PII/SPII» и «Data corruption». Если же под номером объявят несовместимый тип (LEN вместо VARINT), парсер вообще не сможет найти границы записи. reserved для номера и имени стоит ноль байт на проводе и снимает весь класс проблем — protoc сам отвергнет коллизию.

Что будет, если у одной из сторон нет gzip-кодека?

Сценарии разные по сторонам. Клиент вызвал grpc.UseCompressor, но пакет кодека не импортирован — клиент вернёт Internal до отправки запроса: «If UseCompressor is used and the corresponding compressor has not been installed, an Internal error will be returned to the application before the RPC is sent». Клиент послал grpc-encoding: gzip, но сервер кодек не зарегистрировал — клиент получит Unimplemented. Сервер вызвал grpc.SetSendCompressor, а клиент не заявлял кодек в grpc-accept-encoding — ошибка валидации внутри сервера. По умолчанию симметрия работает сама: сжатый запрос получает сжатый ответ тем же кодеком. Диагностика простая: проверьте импорты encoding/gzip с обеих сторон и заголовки grpc-encoding/grpc-accept-encoding на проводе.

Чем optional в proto3 отличается от просто нулевого значения поля?

Присутствием на проводе. Implicit-поле proto3 нулевое значение не кодирует — «не задано» и «задано пусто» неотличимы. optional включает явное присутствие: заданное поле едет по проводу даже с нулевым значением, незаданное — нет, и код может спросить наличие явно (в Go — указатель и Has-хаззер). Флаг стал доступен с protoc 3.15: «Presence tracking for proto3 messages is enabled by default since v3.15.0 release», ранее требовался --experimental_allow_proto3_optional. Документация рекомендует optional вместо implicit-полей ради совместимости с editions и proto2; тот же эффект присутствия даёт oneof. Помните про round trip: клиент на implicit-поле потеряет факт «было задано нулём» при пересылке через промежуточную версию схемы.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Connect25 уроков~12 чпродвинутый25 уроков уже доступны

Connect: современный RPC от Buf

Курс Connect (Buf): протоколы, клиенты, ошибки, interceptors, браузерные клиенты, buf CLI и BSR. 25 уроков и итоговый проект API.

  • Строить сервисы на connectrpc.com/connect
  • Понимать три протокола и выбирать правильный
  • Настраивать buf: lint, breaking changes, generate
  • Писать interceptors и обрабатывать ошибки