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

gRPC и protobuf

gRPC — это фреймворк удалённого вызова процедур от Google, у которого есть три независимых слоя, и путаница между ними — источник большинства неудачных ответов на собеседовании. Первый слой — IDL и сериализация: язык описания .proto (Protocol Buffers) и его бинарный wire-формат. Второй слой — транспорт: HTTP/2 поверх TCP (обычно с TLS), где каждый RPC — это отдельный HTTP/2-стрим, а тело сообщения передаётся DATA-фреймами. Третий слой — рантайм и кодогенерация: protoc/buf порождают из .proto типы сообщений и интерфейсы клиента/сервера, а библиотека google.golang.org/grpc берёт на себя мультиплексирование, дедлайны, метаданные, статус-коды, ретраи, балансировку и интерсепторы.

Ключевая модель в голове про wire-формат: protobuf не передаёт имена полей. Каждое поле кодируется как tag = (field_number << 3) | wire_type, дальше — значение. Wire-типов пять актуальных: 0 VARINT (int32/int64/uint/bool/enum/sint через zigzag), 1 I64 (fixed64/sfixed64/double), 2 LEN (string/bytes/вложенные сообщения/packed repeated), 5 I32 (fixed32/sfixed32/float); типы 3/4 — устаревшие groups. Отсюда следуют почти все правила совместимости: контракт — это номера полей и wire-типы, а не имена. Имя поля значимо только для сгенерированного кода и для JSON-представления (protojson, grpc-gateway).

Поверх HTTP/2 gRPC добавляет своё обрамление: заголовки :method: POST, :path: /package.Service/Method, content-type: application/grpc+proto, произвольные метаданные (обычные HTTP/2-заголовки, сжимаются HPACK), а тело — последовательность length-prefixed сообщений: 1 байт флага компрессии + 4 байта длины big-endian + сериализованное сообщение. Завершается вызов трейлерами grpc-status/grpc-message — именно поэтому gRPC требует HTTP/2 и не работает из браузера напрямую (нужен grpc-web-прокси). Из HTTP/2 бесплатно приходят мультиплексирование без head-of-line blocking на уровне приложения (но не на уровне TCP), сжатие заголовков и поток-ориентированный flow control с оконным механизмом.

Четыре типа вызовов — unary, server streaming, client streaming, bidirectional streaming — описываются прямо в .proto ключевым словом stream. Отдельная важная деталь для Go: context.Context в gRPC — не декорация. Дедлайн клиента едет по проводу в заголовке grpc-timeout, сервер видит его в ctx, а отмена клиентом транслируется в RST_STREAM и отмену серверного контекста. Это то, чего в голом REST по умолчанию нет.

Коротко. По умолчанию используется значение из спецификации HTTP/2 — 65 535 байт (64 КиБ − 1) на стрим; в grpc-go таким же берётся и начальное окно соединения, но по умолчанию включён BDP-based autotuning, который динамически поднимает окна вплоть до 4 МБ. Как только вы явно задаёте grpc.WithInitialWindowSize/grpc.WithInitialConnWindowSize, авто-тюнинг отключается и окно фиксируется.

Глубже. В google.golang.org/grpc/internal/transport/defaults.go лежит defaultWindowSize = 65535, и initialWindowSize равен ему же — это то самое значение SETTINGS_INITIAL_WINDOW_SIZE из RFC 7540. Транспорт при старте проверяет: если пользователь передал InitialConnWindowSize/InitialWindowSize со значением не меньше 65535, флаг dynamicWindow выключается; иначе работает оценщик BDP (BDP ping’и + скользящее среднее), который увеличивает окна до предела bdpLimit = 4 МБ. В grpc-java дефолт другой — фиксированное окно порядка 1 МБ, поэтому «правильный» ответ зависит от реализации, и на собеседовании стоит это оговорить.

Три вещи, которые полезно добавить, потому что именно их проверяют. Первое: окно бывает двух уровней — на соединение и на стрим, и узким местом становится меньшее из них; при 100 параллельных стримах в одном соединении маленькое connection-окно душит всех. Второе: значения меньше 65535 библиотека игнорирует (это нижняя граница из спеки). Третье и самое частое — окно flow control не имеет отношения к MaxRecvMsgSize: лимит на размер одного сообщения в grpc-go по умолчанию 4 МБ на приём (grpc.MaxCallRecvMsgSize/grpc.MaxRecvMsgSize) и практически безлимитен на отправку. Если вы получили ResourceExhausted: grpc: received message larger than max, крутить надо не окно, а лимит сообщения. Ручной подъём окон (например, до 16 МБ) осмыслен на «толстых и длинных» каналах — межрегиональная репликация, стриминг больших объёмов, — где произведение полосы на RTT заведомо больше 4 МБ.

Как шарили Прото файлы между сервисами, если один зависит от другого и что-то меняется в proto?

Заголовок раздела «Как шарили Прото файлы между сервисами, если один зависит от другого и что-то меняется в proto?»

Коротко. Рабочих схем три: (1) монорепозиторий с общим каталогом proto/ и генерацией на месте; (2) отдельный репозиторий-контрактов, подключаемый по версии/тегу, из которого CI собирает и публикует сгенерированные SDK; (3) Buf Schema Registry как «npm для протоконтрактов». Изменения при этом всегда аддитивны и прогоняются через buf breaking в CI, а выкатка идёт по правилу «сначала сервер, потом клиент».

Глубже. Что стоит рассказать как процесс, а не как набор технологий:

  • Владение. Владелец .proto — команда, которая реализует сервис. Потребители не правят чужой контракт, а присылают PR в репозиторий владельца. Каталог структурируется по схеме BSR-стиля: proto/<company>/<domain>/<version>/<file>.proto, например proto/acme/order/v1/order.proto.
  • Доставка потребителям. Худший вариант — копипаста .proto в каждый сервис: контракты быстро расходятся. Git submodule работает, но болезненно ревьюится. Практично: репозиторий контрактов + CI, который на каждый тег генерирует Go-код и публикует его отдельным Go-модулем (github.com/acme/protos-go с семвером). Потребитель делает go get github.com/acme/protos-go@v1.14.0, и обновление контракта становится обычным обновлением зависимости — видимым в go.mod, ревьюемым, откатываемым.
  • Что происходит при изменении. В CI репозитория контрактов обязательны два шага: buf lint (стиль, обязательные v1 в пакете, именование) и buf breaking --against '.git#branch=main' — он сам знает правила wire-совместимости и роняет сборку, если кто-то поменял номер поля, удалил поле без reserved или сменил тип. Только аддитивные изменения проходят автоматически.
  • Порядок выкатки. Новое поле в ответе: сначала деплоим сервер, потом клиента (клиент со старым кодом просто не увидит поле — protobuf сохраняет неизвестные поля и не падает). Новое поле в запросе: сначала сервер учится его читать (с дефолтом для старых клиентов), потом клиент начинает слать. Никогда не наоборот.
  • Ломающее изменение. Если оно неизбежно — новый пакет v2, оба сервиса регистрируются одновременно (RegisterOrderServiceServer для v1 и v2 на одном порту), клиенты мигрируют по одному, v1 удаляется после того, как метрики показали ноль вызовов.

Что такое gRPC? Как называется формат передачи данных в gRPC?

Заголовок раздела «Что такое gRPC? Как называется формат передачи данных в gRPC?»

Коротко. gRPC — открытый RPC-фреймворк от Google: контракт описывается в .proto, из него генерируется код клиента и сервера, транспорт — HTTP/2. Формат передачи данных по умолчанию — Protocol Buffers (protobuf), бинарная сериализация; в HTTP-заголовке это выглядит как content-type: application/grpc+proto.

Глубже. Protobuf — дефолт, но не единственный вариант: gRPC определяет абстракцию кодека (encoding.Codec), и в Go можно зарегистрировать свой — существуют реализации для JSON, а в Google-инфраструктуре встречается FlatBuffers. Суффикс в content-type как раз обозначает кодек: application/grpc+proto, application/grpc+json. Плюс отдельным слоем идёт компрессия сообщений (grpc.UseCompressor(gzip.Name), заголовок grpc-encoding) — она применяется к каждому сообщению отдельно, и именно про неё говорит первый байт в length-prefix’е каждого фрейма сообщения.

Зачем нужен sint32 чем отличается от обычного int32?

Заголовок раздела «Зачем нужен sint32 чем отличается от обычного int32?»

Коротко. sint32 кодирует значение zigzag-кодированием, поэтому небольшие по модулю отрицательные числа занимают 1–2 байта, тогда как любое отрицательное int32 в varint всегда занимает ровно 10 байт. Если поле часто бывает отрицательным — берите sint32/sint64; если отрицательных значений почти нет — int32 не хуже.

Глубже. Причина «10 байт» в том, что отрицательное int32 перед кодированием знаково расширяется до 64 бит: -1 превращается в 0xFFFFFFFFFFFFFFFF, а varint кодирует по 7 значимых бит на байт, то есть 64/7 → 10 байт. Zigzag же перемешивает знак в младший бит: (n << 1) ^ (n >> 31) для 32-битных, (n << 1) ^ (n >> 63) для 64-битных. В итоге 0 → 0, -1 → 1, 1 → 2, -2 → 3, 2 → 4, и -1 занимает один байт.

// Ровно то, что делает кодек protobuf под капотом.
func zigzag32(n int32) uint32 { return uint32(n<<1) ^ uint32(n>>31) }
func unzigzag32(u uint32) int32 { return int32(u>>1) ^ -int32(u&1) }

Важная деталь про совместимость: sint32/sint64 не wire-совместимы с int32/int64 — байты те же по wire-типу (VARINT), но интерпретируются иначе, так что смена типа поля с int32 на sint32 в проде молча испортит данные. Между собой sint32 и sint64 совместимы. И третий вариант — sfixed32: всегда 4 байта, выгоден, когда значения большие по модулю (например, случайные 32-битные ID), где varint выродился бы в 5 байт.

Что можно изменить в .proto -файле gRPC, чтобы не сломать обратную совместимость с существующими клиентами? Какие изменения безопасны, а какие - нет?

Заголовок раздела «Что можно изменить в .proto -файле gRPC, чтобы не сломать обратную совместимость с существующими клиентами? Какие изменения безопасны, а какие - нет?»

Коротко. Безопасно: добавлять новые поля с новыми номерами, добавлять новые методы и сервисы, переименовывать поля (wire-формат оперирует номерами), удалять поля с обязательным reserved на номер и имя, добавлять значения в enum (с оговорками). Опасно: менять номер поля, менять тип на wire-несовместимый, переиспользовать освободившийся номер, переименовывать/удалять rpc-методы, менять package, вносить существующие поля в oneof.

Глубже. Полный практический список.

Безопасно на уровне провода:

  • Новое поле с ранее не использовавшимся номером. Старый клиент положит его в unknown fields и сохранит при повторной сериализации.
  • Удаление поля — но обязательно с reserved 5; и reserved "old_field";, иначе однажды кто-то переиспользует номер 5 под string, а на проводе останутся старые int’ы.
  • Переименование поля и переименование сообщения (имя сообщения на проводе не передаётся; передаётся в Any через type URL — вот там переименование ломает).
  • Смена типа внутри группы «варинтов»: int32 ↔ int64 ↔ uint32 ↔ uint64 ↔ bool совместимы по wire (с риском переполнения/усечения при сужении, а отрицательные int32, прочитанные как int64, дадут корректное значение — расширение знака уже в байтах).
  • fixed32 ↔ sfixed32, fixed64 ↔ sfixed64.
  • string ↔ bytes, если содержимое — валидный UTF-8.
  • Вложенное сообщение ↔ bytes, если в bytes лежит его сериализация.
  • Добавление нового rpc-метода и нового сервиса. Старый клиент просто их не вызывает.
  • Превращение одиночного поля в repeated того же типа — wire-совместимо, но меняет семантику: читатель, ожидающий одиночное, возьмёт последнее значение для скаляров и смёржит для сообщений.
  • Внесение одного нового поля в новый oneof.

Опасно:

  • Менять номер существующего поля — это другое поле.
  • Переиспользовать номер удалённого поля.
  • Менять тип между несовместимыми группами: int32 → sint32, int32 → fixed32, string → int64, float ↔ double.
  • Удалять или переименовывать rpc-метод либо сервис: имя едет в :path, старый клиент получит Unimplemented.
  • Менять package или option go_package в части имени пакета — тот же эффект, меняется путь.
  • Переносить существующие поля в oneof (или из него): читатель начнёт затирать соседние поля.
  • Менять optionalrepeated для сообщений, ожидая прежней семантики.
  • Добавлять значения в enum, если потребители — proto2/закрытые enum’ы или если код делает switch без default: proto3-enum’ы открытые (неизвестное значение сохраняется числом), а закрытые кладут его в unknown fields.

Отдельно: у proto3 нет required — и слава богу, потому что required в proto2 фактически неудаляемо. Если нужна явная «присутствует/отсутствует», используйте ключевое слово optional в proto3 (стабильно с protobuf 3.15) — оно даёт explicit presence и указатель в Go-структуре.

Как логировать запросы и ответы в grpc? Байты читать неудобно.

Заголовок раздела «Как логировать запросы и ответы в grpc? Байты читать неудобно.»

Коротко. Стандартный способ — интерсептор, который приводит req/resp к proto.Message и сериализует их через protojson (или prototext) в человекочитаемый вид. На проде это включают выборочно — по сэмплированию или для конкретных методов, с редактированием чувствительных полей.

Глубже. Минимальный серверный интерсептор:

package interceptor
import (
"context"
"log/slog"
"google.golang.org/grpc"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
func Logging(log *slog.Logger) grpc.UnaryServerInterceptor {
m := protojson.MarshalOptions{EmitUnpopulated: false}
render := func(v any) string {
msg, ok := v.(proto.Message)
if !ok {
return ""
}
b, err := m.Marshal(msg)
if err != nil {
return ""
}
return string(b)
}
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
resp, err := handler(ctx, req)
log.InfoContext(ctx, "grpc call",
"method", info.FullMethod,
"request", render(req),
"response", render(resp),
"code", status.Code(err).String(),
)
return resp, err
}
}

Подключается как grpc.NewServer(grpc.ChainUnaryInterceptor(interceptor.Logging(log))); для стримов нужен отдельный StreamServerInterceptor с обёрткой над grpc.ServerStream, перехватывающей SendMsg/RecvMsg — руками писать не обязательно, это есть в github.com/grpc-ecosystem/go-grpc-middleware/v2 (пакеты interceptors/logging и interceptors/protovalidate).

Что добавить к ответу, чтобы он звучал «с прода». Во-первых, PII: не логируйте payload целиком по умолчанию. В protobuf есть опция поля debug_redact = true, и prototext/protojson в свежих версиях Go-рантайма умеют её учитывать в debug-выводе; более надёжно — держать явный список полей-исключений или логировать только идентификаторы и размеры. Во-вторых, объём: полные тела на высоконагруженном сервисе съедят логи, поэтому сэмплирование (1 из N), обрезание по длине и включение по флагу/заголовку. В-третьих, альтернативные инструменты для отладки: stats.Handler даёт события с размерами сообщений и таймингами (удобно для метрик), встроенный binary logging (google.golang.org/grpc/binarylog) пишет бинарный лог RPC для последующего разбора, grpcurl/grpcui/evans вместе с server reflection позволяют вызывать методы руками в JSON, а Wireshark с включённым protobuf-диссектором и вашими .proto расшифровывает трафик. И совсем базовое: GRPC_GO_LOG_VERBOSITY_LEVEL=99 GRPC_GO_LOG_SEVERITY_LEVEL=info включает внутренние логи grpc-go — полезно при отладке коннектов и балансировки, но payload там не будет.

GRPC: есть клиент и сервер, надо переименовать поле. Надо править и клиент, и сервер?

Заголовок раздела «GRPC: есть клиент и сервер, надо переименовать поле. Надо править и клиент, и сервер?»

Коротко. На проводе переименование ничего не ломает — protobuf передаёт номер поля, а не имя, поэтому старый клиент и новый сервер будут понимать друг друга. Но сгенерированный код меняется (в Go — имя поля структуры и геттер), поэтому каждая сторона, которая хочет пользоваться новым именем, должна перегенерировать код и перекомпилироваться. Одновременного деплоя не требуется.

Глубже. Три оговорки, которые превращают «безопасное» переименование в инцидент. Первая — JSON-представление: если рядом стоит grpc-gateway, transcoding или вы храните сообщения в JSON через protojson, имя поля значимо (json_name), и переименование ломает HTTP-клиентов и уже записанные документы. Спасает [json_name = "oldName"] на поле, сохраняющий прежнее JSON-имя. Вторая — Kafka/БД, где лежат сериализованные protobuf-сообщения: там всё нормально, номера не менялись. Третья — рефлексия и динамические клиенты (grpcurl, скрипты на dynamicpb), которые обращаются по имени, — они сломаются.

Практический вывод для интервью: скажите, что переименование поля — «безопасное по wire, ломающее по коду» изменение, и что при большом числе потребителей его обычно делают не переименованием, а добавлением нового поля с новым именем и номером, дублированием записи в оба, миграцией читателей и последующим reserved на старый номер.

Коротко. Это контрактный RPC поверх HTTP/2 с бинарной сериализацией protobuf и кодогенерацией клиента и сервера; даёт четыре типа вызовов (unary и три вида стриминга), дедлайны и отмену, метаданные, единый набор статус-кодов, интерсепторы и клиентскую балансировку. Оптимален для внутренней межсервисной коммуникации; для публичного и браузерного API обычно оставляют REST/JSON или ставят grpc-gateway.

Глубже. Что стоит перечислить как сильные стороны и сразу как оборотную сторону.

Сильные стороны: строгий контракт и невозможность «случайно» разойтись в типах; кодогенерация вместо ручного маршалинга; HTTP/2 с мультиплексированием — одно TCP-соединение на пару сервисов вместо пула; сквозные дедлайны, которые в grpc-go естественно ложатся на context.Context и распространяются по цепочке вызовов; отмена клиента, доходящая до сервера; фиксированный набор кодов (codes.NotFound, codes.DeadlineExceeded, codes.ResourceExhausted, …) плюс богатые ошибки через google.rpc.Status details; интерсепторы как естественное место для трассировки, метрик, авторизации и валидации; клиентская балансировка (round_robin, pick_first, xDS) с resolver’ами вроде dns:/// — то есть без внешнего L7-балансировщика; server reflection и health checking как стандартизированные протоколы; двунаправленный стриминг, который в REST пришлось бы имитировать WebSocket’ами.

Слабые: трафик не читается глазами — нужны grpcurl/Wireshark с диссектором; браузер напрямую не умеет (нужен grpc-web и прокси); L7-балансировщики и сетевое оборудование хуже дружат с долгоживущими HTTP/2-соединениями — типичная проблема, когда все стримы прилипли к одному поду и балансировка «по соединениям» перестаёт работать (лечится MaxConnectionAge в keepalive-параметрах сервера); порог входа выше, нужна дисциплина версионирования контрактов; отладка и интеграция с внешними партнёрами сложнее, чем «дай curl».

Как правильно править поля в proto-структурах (удалять/добавлять новые/переименовывать), учитывая что сервис-клиент и сервис-сервер уже в проде?

Заголовок раздела «Как правильно править поля в proto-структурах (удалять/добавлять новые/переименовывать), учитывая что сервис-клиент и сервис-сервер уже в проде?»

Коротко. См. выше правила совместимости. Процессуально: только аддитивные изменения; удаление — через reserved на номер и имя; переименование — либо чистое переименование (wire-безопасно, требует перекомпиляции обеих сторон), либо, если есть JSON/внешние потребители, expand/contract через новое поле; порядок выкатки — «сначала тот, кто умеет читать новое, потом тот, кто его пишет»; всё это защищено buf breaking в CI.

Глубже. Разложу по операциям, чтобы ответ звучал как регламент.

Добавить поле. Берёте следующий свободный номер (и помните, что номера 1–15 занимают 1 байт тега, 16–2047 — 2 байта, так что «горячие» поля держат в первой пятнашке; номера 19000–19999 зарезервированы protobuf). Новое поле обязано иметь осмысленный нулевой дефолт для старых клиентов: в proto3 у скаляров нет presence, и false/0/"" неотличимы от «не прислали». Если это принципиально — optional для explicit presence или wrapper-типы (google.protobuf.BoolValue). Деплой: сначала читатель, потом писатель.

Удалить поле. Сначала перестаём его писать (релиз N), потом перестаём читать (релиз N+1), потом удаляем из .proto и добавляем reserved 7; reserved "legacy_status"; (релиз N+2). reserved на имя нужен не для провода, а чтобы никто не завёл поле с тем же именем и другой семантикой, что сломает JSON-совместимость.

Переименовать поле. Технически — просто правка имени, номер и тип на месте; обе стороны перегенерируют код в своём темпе. Если есть JSON-контур — фиксируете [json_name = "..."]. Если хочется совсем без риска — добавление нового поля с новым номером и двойная запись на переходный период.

Изменить тип. Только внутри совместимых групп (см. вопрос выше). Всё остальное — новое поле, а старое в reserved после миграции.

Изменить метод. Сигнатуру rpc менять нельзя ни в какую сторону, кроме добавления полей в его request/response-сообщения — именно поэтому в protobuf-стиле каждый метод получает собственные XxxRequest/XxxResponse, даже если они пустые: это единственный способ расширять API без ломки.

И оргчасть: в CI прогоняйте buf breaking --against относительно main; в PR-шаблоне требуйте отметку «изменение аддитивно/ломающее»; на приёмке — контрактные тесты, где старый клиент из предыдущего тега ходит в новый сервер.

Коротко. JSON — текстовый самоописывающий формат без схемы; protobuf — бинарный формат со схемой, где на проводе едут номера полей вместо имён и нет разделителей. Отсюда: protobuf компактнее и быстрее парсится, даёт типобезопасность и кодогенерацию, но нечитаем глазами и бесполезен без .proto.

Глубже. Различия по осям, которые обычно и хочет услышать интервьюер:

  • Схема. В protobuf она обязательна и является контрактом; в JSON схема опциональна (JSON Schema/OpenAPI) и никем не проверяется в рантайме.
  • Самоописываемость. JSON можно прочитать без внешней информации; protobuf-байты без .proto разбираются лишь частично (номер и wire-тип видны, а тип и смысл — нет).
  • Типы. В JSON нет целых/дробных различий (все числа — double по стандарту JS-парсеров, отсюда классическая потеря точности на int64 — в protojson int64 сериализуется строкой именно поэтому), нет бинарных данных (только base64), нет enum. В protobuf есть fixed/varint/zigzag, bytes, enum, oneof, map, well-known types (Timestamp, Duration, Any, FieldMask).
  • Эволюция. В protobuf правила совместимости формализованы и проверяются инструментами; в JSON «поле переименовали» — обычный способ сломать прод.
  • Производительность. Protobuf сериализуется и десериализуется в разы быстрее и без аллокаций на парсинг строк; JSON в Go через encoding/json вдобавок использует рефлексию (в Go 1.24 в стандартной библиотеке появилась экспериментальная реализация encoding/json/v2 под GOEXPERIMENT, заметно быстрее, но это всё ещё текстовый формат).
  • Инструменты и люди. JSON отлаживается curlом, работает в браузере, читается человеком, логируется как есть. Protobuf требует grpcurl, reflection и дисциплины.
  • Неизвестные поля. protobuf-go по умолчанию сохраняет unknown fields при round-trip — прокси не теряет данные, которых не знает; в encoding/json при разборе в структуру всё неизвестное просто пропадает.

Grpc какой протокол использует для передачи данных по сети?

Заголовок раздела «Grpc какой протокол использует для передачи данных по сети?»

Коротко. HTTP/2 поверх TCP, как правило с TLS (ALPN h2; без шифрования — h2c). Прикладной уровень поверх HTTP/2 — length-prefixed protobuf-сообщения в DATA-фреймах, статус вызова передаётся в HTTP/2-трейлерах.

Глубже. Конкретика, которой отличается сильный ответ. Каждый RPC — отдельный HTTP/2-стрим внутри одного TCP-соединения; поэтому пул из одного соединения нормально держит сотни параллельных вызовов, а лимит задаётся настройкой SETTINGS_MAX_CONCURRENT_STREAMS (в grpc-go серверная сторона по умолчанию практически не ограничивает, но настраивается через grpc.MaxConcurrentStreams). Запрос — это всегда POST на путь /<proto-пакет>.<Service>/<Method>, метаданные — обычные заголовки (бинарные значения передаются в ключах с суффиксом -bin в base64), дедлайн — заголовок grpc-timeout вида 100m. HTTP/2 не устраняет head-of-line blocking на уровне TCP: потеря одного сегмента тормозит все стримы соединения — это одна из причин, почему появился gRPC поверх HTTP/3 (QUIC), но в Go-экосистеме он пока не в стандартной поставке grpc-go. Отдельно стоит помнить про keepalive: gRPC шлёт HTTP/2 PING’и (keepalive.ClientParameters/ServerParameters), потому что промежуточные NAT и балансировщики любят молча рвать простаивающие TCP-соединения, а слишком агрессивный клиентский keepalive словит ENHANCE_YOUR_CALM/too_many_pings от сервера с его EnforcementPolicy.

Коротко. Да, и стандартная практика — версия в имени пакета: acme.order.v1, каталог proto/acme/order/v1/. Внутри мажорной версии допускаются только обратно совместимые изменения; ломающее изменение означает новый пакет v2, который какое-то время сосуществует со v1 на том же сервере.

Глубже. Из чего строить ответ, даже если на вашем проекте всё было проще.

  • Почему версия в пакете, а не в URL/хедере. Потому что имя пакета входит в :path каждого вызова, значит две версии физически не конфликтуют: сервер регистрирует и orderv1.RegisterOrderServiceServer, и orderv2.RegisterOrderServiceServer, клиенты мигрируют независимо, а старый путь можно замерить метрикой и удалить, когда он обнулится.
  • Стадии зрелости. Google API design guide предлагает v1alpha1, v1beta1, v1: у alpha/beta нет гарантий совместимости, у стабильной версии — есть. Это удобный способ легально ломать API на ранней стадии.
  • Версия артефакта отдельно от версии API. Модуль со сгенерированным кодом версионируется семвером (protos-go v1.14.0), и это ортогонально v1 в имени пакета: минорные апдейты модуля добавляют поля, мажорные — новый пакет.
  • Что делает эту схему рабочей. Автоматика: buf breaking не даёт нарушить обещание внутри мажора, buf lint требует, чтобы у файла был versioned package. Без CI-проверки версионирование превращается в «мы договорились, но Вася забыл».
  • Чего делать не стоит. Версии в имени сообщения (OrderV2), версии в комментариях, «мажорная версия = новый сервис на новом порту без плана удаления старого».

Как храните протофайлы (отдельная репа или в проекте);

Заголовок раздела «Как храните протофайлы (отдельная репа или в проекте);»

Коротко. Оба варианта рабочие: в монорепозитории — общий каталог proto/ рядом с сервисами; в поли-репо — выделенный репозиторий контрактов, из которого CI публикует сгенерированные клиентские модули (либо Buf Schema Registry). Копировать .proto руками между сервисами — антипаттерн.

Глубже. Сравнение, которое стоит проговорить.

Монорепозиторий. Плюсы: одно атомарное изменение контракта и всех потребителей, отсутствие проблемы «у кого какая версия», простая генерация одним buf generate. Минусы: работает только когда все сервисы действительно в одном репо и одна CI-система; кросс-командные изменения требуют code owners.

Отдельный репозиторий контрактов. Плюсы: чёткая граница, независимый релизный цикл, легко подключать внешние команды, версии видны в go.mod потребителя. Минусы: два PR вместо одного и лаг между публикацией контракта и его использованием. Практический приём — публиковать не сами .proto, а сгенерированный код отдельным Go-модулем: потребителю тогда не нужны ни protoc, ни плагины, ни правильные версии генераторов; версии генераторов фиксируются один раз в buf.gen.yaml репозитория контрактов.

Buf Schema Registry. Плюсы: .proto как зависимости с семвером (buf.yaml/buf.lock), встроенные breaking-проверки, генерация SDK «из коробки», хостинг публичных схем (buf.build/googleapis/googleapis). Минусы: внешний сервис (есть self-hosted вариант в платной версии).

Git submodule. Технически возможно, на практике болезненно: рассинхрон, забытые git submodule update, плохой DX.

Отдельно про .proto в самом сервисе: контракт логично держать рядом с реализацией (api/proto/...), если у сервиса ровно один-два потребителя и все в одной команде; как только потребителей становится много, наступает момент вынести контракт наружу.

Коротко. Сравнивать корректнее «gRPC+protobuf» против «REST+JSON». Преимущества: строгий контракт и кодогенерация, компактнее и быстрее сериализация, HTTP/2-мультиплексирование поверх одного соединения, полноценный стриминг, сквозные дедлайны и отмена, стандартные статус-коды и интерсепторы, формализованные правила эволюции схемы.

Глубже. Практическая расшифровка по пунктам: контракт вместо документации — клиент невозможно написать «не так», потому что он сгенерирован; отсутствие ручного маршалинга убирает целый класс багов; бинарный формат даёт меньше байтов и заметно меньше CPU на (де)сериализацию, что на сервисе с десятками тысяч RPC/с превращается в реальные ядра; одно HTTP/2-соединение вместо пула HTTP/1.1 экономит TLS-хендшейки и сокеты; stream в контракте позволяет делать подписки и потоковые выгрузки без WebSocket-костылей; context с дедлайном едет по проводу, и вся цепочка вызовов гаснет одновременно, а не оставляет висящие горутины; коды ошибок унифицированы и машинно-различимы (в REST у каждой команды свой набор форматов тела ошибки); интерсепторы дают единообразную обвязку.

И обязательно назовите цену, иначе ответ выглядит рекламным: нечитаемый трафик, нет прямой поддержки в браузере, сложнее кэшировать (нет HTTP-кэширования по семантике GET), сложнее для внешних интеграций, требуется дисциплина версионирования, долгоживущие соединения плохо балансируются на L4. Для публичного API часто оставляют REST/JSON — и это не «слабость gRPC», а разные ниши.

Почему сообщение по gRPC весит меньше, чем сообщение по JSON?

Заголовок раздела «Почему сообщение по gRPC весит меньше, чем сообщение по JSON?»

Коротко. Потому что в protobuf на проводе нет имён полей (вместо них 1–2-байтовый тег с номером и wire-типом), нет структурных символов (кавычек, двоеточий, запятых, скобок, пробелов), числа кодируются бинарно (varint), а поля со значением по умолчанию вообще не передаются.

Глубже. Конкретный счёт. Сообщение {"user_id": 1000, "is_active": true} в JSON — 34 байта. В protobuf при int64 user_id = 1; bool is_active = 2; это 0x08 0xE8 0x07 (тег 1 + varint 1000) плюс 0x10 0x01 — итого 5 байт. Разбор экономии: имя "user_id" (9 байт с кавычками) сжалось до 1 байта тега; число 1000, записанное четырьмя ASCII-символами, стало двумя байтами varint; true из 4 символов стал одним байтом; исчезли {, }, :, ,, пробелы. Плюс bytes в protobuf передаётся как есть, тогда как в JSON бинарные данные приходится кодировать base64 с накладными +33%.

Обязательные оговорки, чтобы ответ был честным. Во-первых, преимущество сжимается на сообщениях, где основной объём — длинные строки (UTF-8 в обоих случаях занимает столько же, разница только в кавычках и имени поля). Во-вторых, gzip заметно выравнивает картину: JSON с повторяющимися именами полей сжимается очень хорошо, и на больших однородных массивах разница «protobuf vs gzip(JSON)» бывает уже в пределах десятков процентов. В-третьих, выигрыш protobuf не только в объёме, но и в CPU: парсинг бинарного формата не требует сканирования текста, поиска escape-последовательностей и конверсии чисел из десятичной записи, и в Go это ещё и заметно меньше аллокаций. В-четвёртых, repeated скалярные поля в proto3 по умолчанию packed — все элементы в одном LEN-блоке без повторения тега.

Коротко. Protocol Buffers — язык описания структур данных (IDL) плюс бинарный формат их сериализации плюс генератор кода для многих языков, разработанные в Google. Схема описывается в .proto, из неё protoc генерирует типы и методы маршалинга.

Глубже. Формат — это последовательность записей вида «тег → значение», где тег = (номер_поля << 3) | wire_type; длины у типов переменной длины идут явным префиксом, у varint’ов длина определяется по старшему continuation-биту. Поэтому парсер может пропустить незнакомое поле, зная только wire-тип — на этом и держится forward-совместимость. Актуальные версии синтаксиса: proto2 (есть required/optional/дефолты, закрытые enum), proto3 (нет required, implicit presence у скаляров, открытые enum, optional вернули для explicit presence), и появившиеся в protobuf 27 editions (edition = "2023") — способ конфигурировать поведение по фичам вместо жёсткого выбора proto2/proto3.

В Go актуальный рантайм — google.golang.org/protobuf (API v2, с 2020 года), где сообщения работают через protoreflect, а старый github.com/golang/protobuf — тонкая обёртка над ним и считается устаревшим. Полезно знать про well-known types: google.protobuf.Timestamp, Duration, Empty, Any (сообщение с type URL, для гетерогенных payload), Struct (произвольный JSON), FieldMask (частичное обновление, аналог PATCH), а также о том, что protobuf применяется далеко за пределами gRPC — в Kafka (со Schema Registry), в файлах на диске, в межпроцессном обмене.

Какой инструментарий для работы с протобуфами вы использовали?

Заголовок раздела «Какой инструментарий для работы с протобуфами вы использовали?»

Коротко. Базовый набор: buf (lint, breaking, generate, зависимости, BSR) вместо голого protoc; плагины protoc-gen-go и protoc-gen-go-grpc; grpcurl/grpcui/evans для ручных вызовов через server reflection; grpc-gateway и protoc-gen-openapiv2 для HTTP/JSON-фасада; protovalidate (или старый protoc-gen-validate) для валидации; ghz для нагрузочного теста.

Глубже. Чуть подробнее, зачем каждое.

  • buf. Заменяет ад из shell-скриптов вокруг protoc: buf.yaml описывает модуль и зависимости, buf.gen.yaml — плагины и выходные каталоги, buf lint держит единый стиль, buf breaking --against '.git#branch=main' защищает совместимость в CI, buf format форматирует. Отдельная ценность — плагины запускаются в контейнерах/через remote plugins, так что версии генераторов одинаковы у всех.
  • protoc + protoc-gen-go/protoc-gen-go-grpc. Классика; важно, что gRPC-код с 2020 года генерирует отдельный плагин, а не сам protoc-gen-go.
  • grpcurl / grpcui / evans / Postman. Ручные вызовы в JSON. Требуют либо .proto/дескрипторов, либо включённого server reflection (google.golang.org/grpc/reflection) — на стейджах включать стоит, на публичном проде обычно нет.
  • grpc-gateway + protoc-gen-openapiv2. Генерирует REST/JSON-прокси по аннотациям google.api.http и заодно OpenAPI-спеку — типичное решение «внутри gRPC, наружу REST».
  • protovalidate. Правила валидации прямо в .proto через CEL-выражения; заменяет envoyproxy/protoc-gen-validate, который был кодогенерацией.
  • Производительность. planetscale/vtprotobuf генерирует быстрые MarshalVT/UnmarshalVT без рефлексии и с пулингом — актуально, когда сериализация видна в профиле. Старый gogo/protobuf официально не поддерживается, миграция с него — частая задача.
  • Прочее. protoc-gen-doc для документации, prototool (устарел), mockgen для интерфейсов клиента, ghz для нагрузки, Wireshark с protobuf-диссектором, grpc_health_probe для k8s-пробы, protoc-gen-connect-go если используете Connect (совместимый с gRPC протокол, умеющий ещё и в HTTP/1.1 и в браузер).

Что такое GRPC, OpenAPI? Их особенности. Ваши критерии выбора между ними.

Заголовок раздела «Что такое GRPC, OpenAPI? Их особенности. Ваши критерии выбора между ними.»

Коротко. gRPC — это фреймворк (контракт + транспорт + рантайм + кодогенерация). OpenAPI — это только спецификация для описания HTTP/JSON-API, вокруг неё уже отдельно навешиваются генераторы, валидаторы и портал документации. Внутренние высоконагруженные сервисы с потоками и жёсткими SLA — gRPC; публичное API, браузер, партнёры, кэшируемые GET’ы — REST + OpenAPI; часто и то и другое одновременно через grpc-gateway.

Глубже. По особенностям. gRPC даёт бинарный формат, HTTP/2, стриминг, дедлайны, статус-коды и обязательную кодогенерацию — контракт первичен и физически недоступен в обход. OpenAPI (3.x) описывает пути, методы, схемы тел и коды ответов в YAML/JSON, и это описание может быть как источником истины (design-first, из него генерируют серверные интерфейсы, например oapi-codegen в Go), так и производным от кода (аннотации swaggo) — во втором случае спека регулярно расходится с реальностью, потому что ничто не обязывает их совпадать. Именно эта необязательность — главное практическое отличие: у gRPC контракт нельзя «немножко нарушить».

Критерии выбора, которые звучат убедительно:

  • Кто потребитель. Браузер или внешние партнёры — REST/JSON и OpenAPI. Свои сервисы — gRPC.
  • Нагрузка и латентность. Десятки тысяч RPC/с, чувствительность к p99 — gRPC (меньше CPU на сериализацию, одно соединение, HPACK).
  • Нужны ли потоки. Подписки, длинные выгрузки, двунаправленный обмен — gRPC; в REST это WebSocket/SSE, то есть отдельный протокол сбоку.
  • Кэширование и инфраструктура. Если нужен CDN, HTTP-кэш, стандартные WAF и rate limiter’ы по URL — REST проще.
  • Зрелость команды и эксплуатация. gRPC требует навыков отладки бинарного трафика и дисциплины в контрактах.
  • Совместимость и эволюция. У gRPC правила формализованы и проверяются buf breaking; в OpenAPI есть diff-инструменты, но культура слабее.

И практический компромисс: описывать контракт в .proto, реализовывать сервис на gRPC, а наружу выставлять grpc-gateway с автогенерируемой OpenAPI-спекой — тогда внутренние вызовы идут по gRPC, а внешние клиенты получают привычный REST.

Коротко. См. выше «Что такое gRPC? Как называется формат передачи данных в gRPC?» — это RPC-фреймворк с контрактом в .proto, protobuf-сериализацией и транспортом HTTP/2. Если вопрос задают повторно и коротко, достаточно одной фразы: «способ вызывать метод чужого сервиса как локальную функцию, где сигнатуры и типы описаны в .proto, а весь клиент-серверный код сгенерирован».

Глубже. Единственное, что имеет смысл добавить относительно предыдущего ответа, — расшифровка названия и происхождение: gRPC вырос из внутреннего Google-фреймворка Stubby, опубликован в 2015 году, сейчас развивается под CNCF; буква «g» официально не расшифровывается как «Google» — в каждом релизе ей придумывают новое значение. Полезно также сказать, что gRPC — это спецификация протокола, а не одна библиотека: помимо grpc-go существуют совместимые реализации (например, ConnectRPC), которые говорят по тому же проводу.

Коротко. Три уровня защиты: правила (только аддитивные изменения, reserved при удалении, версия в имени пакета), автоматика (buf breaking и buf lint в CI, контрактные тесты старого клиента против нового сервера) и процесс выкатки (expand/contract, порядок «читатель раньше писателя», сосуществование v1 и v2 до обнуления метрик по старой версии).

Глубже. Практические приёмы, которых обычно ждут:

  1. Правила на уровне схемы. Никогда не переиспользовать номера; reserved на номер и имя при удалении; каждый rpc имеет собственные Request/Response-сообщения, даже пустые, чтобы их можно было расширять; новые методы вместо изменения существующих; enum всегда начинается с UNSPECIFIED = 0, и код обрабатывает неизвестные значения через default, а не паникует.
  2. Автоматика в CI. buf breaking с политикой WIRE_JSON (или WIRE, если JSON-контура нет) — он различает уровни строгости, что позволяет разрешить переименования, если наружу JSON не отдаётся. Плюс тест: сгенерировать клиент из предыдущего тега контракта и прогнать им сценарии против нового сервера.
  3. Устойчивость рантайма. protobuf-go сохраняет unknown fields при round-trip, так что промежуточный сервис не теряет поля, о которых не знает. Не используйте proto.Marshal для получения канонических байтов и не сравнивайте сериализованные сообщения побайтово — порядок полей и кодирование не гарантированы; сравнивайте через proto.Equal.
  4. Presence. В proto3 у скаляров нет различия «0 и не задано». Если добавляемое поле должно уметь «не задано», сразу объявляйте его optional или используйте wrapper-типы, иначе потом менять будет поздно.
  5. Процесс деплоя. Никаких изменений, требующих одновременной выкатки двух сервисов. При добавлении поля в ответ — сначала сервер; при добавлении в запрос — сначала сервер учится читать. При переезде на v2 — оба сервиса на одном порту, миграция клиентов по одному, удаление v1 после того, как счётчик вызовов по старому пути стабильно равен нулю.
  6. Наблюдаемость. Метрика по grpc-status в разрезе метода и версии пакета — единственный способ узнать, что кто-то остался на v1 или что новый клиент валится с Unimplemented. Плюс алерт на рост codes.Unimplemented/codes.InvalidArgument сразу после релиза.
  • Говорят «gRPC работает поверх HTTP» без уточнения версии. Именно HTTP/2 — из-за трейлеров (grpc-status) и мультиплексирования; HTTP/1.1 не подходит принципиально.
  • Путают контракт по именам и по номерам: утверждают, что переименование поля ломает клиентов на проводе. Ломается сгенерированный код и JSON-представление, а не бинарный обмен.
  • Считают, что можно переиспользовать номер удалённого поля «раз поля больше нет». Это классический способ получить молча испорченные данные; нужен reserved.
  • Смешивают окно flow control (65535 байт по умолчанию) с лимитом размера сообщения (4 МБ на приём в grpc-go) и пытаются лечить ResourceExhausted настройкой окна.
  • Уверены, что protobuf «всегда в разы меньше JSON». На строковых payload’ах и после gzip разница может быть скромной; настоящий выигрыш чаще в CPU и в наличии контракта.
  • Утверждают, что в proto3 есть required или что нулевое значение скаляра отличимо от отсутствующего без optional.
  • Забывают про балансировку: считают, что gRPC «сам балансируется» через обычный L4-балансировщик. Долгоживущее HTTP/2-соединение прилипает к одному бэкенду; нужны клиентская балансировка, MaxConnectionAge или L7-прокси.
  • Отвечают на «как логировать» словом «через fmt.Println(req)» вместо protojson/интерсепторов и не упоминают ни PII-редакцию, ни сэмплирование.