voidlearn
 

9.4REST, RPC, gRPC

Сравни REST, RPC, gRPC и WebSocket как модели контракта и обмена данными. Разбери protobuf и балансировку долгоживущего HTTP/2 connection, а затем выбери transport по направлению потока, сроку жизни и требованиям к промежуточной инфраструктуре.

REST: что это на самом деле

REST не формат, не «JSON по HTTP» и не «CRUD с красивыми URL». Это архитектурный стиль из диссертации Роя Филдинга (2000), одного из авторов HTTP. Филдинг описывал не «как делать API», а почему веб как система масштабируется: как миллиарды клиентов, кэшей и прокси работают вместе, не зная друг о друге. REST сводится к набору ограничений, и масштабируемость следует из них сама.

Шесть ограничений

  1. Клиент-сервер. Разделение ответственности: интерфейс отдельно, хранение отдельно, и стороны развиваются независимо.
  2. Stateless. Каждый запрос самодостаточен: между запросами сервер ничего не помнит о клиенте, всё нужное едет в самом запросе (токен, параметры, версия). Отсюда горизонтальное масштабирование: любой запрос обработает любой инстанс, а падение узла не рвёт «сессию».
  3. Кэшируемость. Ответ обязан явно говорить, кэшируемый он или нет. Поэтому кэши и CDN можно вставить между клиентом и сервером, не меняя ни того, ни другого.
  4. Единообразный интерфейс считается центральным ограничением и распадается на четыре: идентификация ресурсов через URI; манипуляция ресурсами через представления; самоописывающиеся сообщения (метод, Content-Type, кэш-заголовки); HATEOAS.
  5. Слоистая система. Клиент не знает, разговаривает он с сервером напрямую или через три прокси, CDN и балансировщик. Именно поэтому промежуточные узлы вообще возможны.
  6. Code on demand (необязательное). Сервер может прислать исполняемый код - исторически это JavaScript в браузере.

Ресурс и представление - не одно и то же

Ресурс - абстракция, «заказ №42», сущность с идентичностью. Представление фиксирует состояние ресурса в конкретный момент и в конкретном формате: JSON, XML, HTML, PDF, protobuf. Так работает content negotiation: один и тот же URI /orders/42 может отдавать разные представления в зависимости от Accept. Отсюда правило: URI идентифицирует ресурс, а не файл и не метод. А /getOrder?id=42 уже не ресурс, а вызов процедуры, замаскированный под URL.

HATEOAS - то, чего почти никто не делает

Идея: клиент знает одну точку входа, а дальше двигается по ссылкам, которые сервер присылает в ответах. Не «клиент захардкодил, что отменить заказ можно POST /orders/{id}/cancel», а «сервер прислал в ответе ссылку cancel - значит, отмена сейчас возможна; не прислал - значит, нет». Состояние переходов живёт на сервере, клиент его не дублирует. Так работает сам веб: браузер не знает структуру сайта, он знает, как ходить по ссылкам.

На практике HATEOAS почти не встречается, и на то есть причины. У машинного клиента нет «человека, который решает, куда кликнуть», так что код всё равно пишут под конкретные переходы; ответы раздуваются; кодогенерация и типизация из OpenAPI дают больше пользы, чем гипермедиа; а форматов гипермедиа несколько (HAL, JSON:API, Siren, Collection+JSON), и ни один не стал общепринятым. В 2008 году Филдинг даже написал отдельный пост «REST APIs must be hypertext-driven» и прямо сказал там, что почти всё, что называют REST, этого названия не заслуживает.

Модель зрелости Ричардсона Чем выше ступень, тем больше работы делает сам протокол HTTP, а не формат тела запроса Уровень 0 Один URL, один глагол POST /api SOAP, XML-RPC, JSON-RPC Уровень 1 Появились ресурсы POST /users/42/rename POST /users/42/delete URL много, метод один, ошибки в теле с кодом 200 Уровень 2 Глаголы HTTP и статус-коды GET /users/42 200 PUT /users/42 204 DELETE /users/42 204 POST /users 201 Заработали кэш, ретраи, идемпотентность, условные запросы, промежуточные узлы Уровень 3 HATEOAS: гипермедиа { "id": 42, "state": "new", "_links": { "self": "/orders/42", "pay": "/orders/42/pay", "cancel": "/orders/42/cancel" } } Клиент не хардкодит URL - идёт по ссылкам из ответа. Нельзя отменить - нет ссылки. здесь живёт 95% того, что называют REST REST по Филдингу Уровни 0 и 1 - это RPC, просто по HTTP: протокол используется как транспорт для конверта. Уровень 2 - точка окупаемости: дальше выгода падает, а цена для клиента растёт. Поэтому индустрия и остановилась на нём.
Четыре ступени. Вопросом «какой у вас уровень зрелости» проверяют, понимает ли кандидат, что уровень 2 даёт кэш, ретраи и идемпотентность бесплатно, а уровень 0 не даёт ничего, кроме туннеля поверх POST.
Как отвечать про RESTful и REST-like

Строго говоря, RESTful называют API, который соблюдает все ограничения, включая HATEOAS (уровень 3). У REST-like (он же «REST-ish», «HTTP API», уровень 2) ресурсные URI, правильные глаголы, правильные коды, но нет гипермедиа. Сильный ответ звучит так: «Мы делаем REST-like, уровень 2 по Ричардсону. HATEOAS сознательно не делаем: контракт описан в OpenAPI, клиенты генерируются, и гипермедиа не окупается. Это осознанный компромисс, а не незнание.» За ответ «у нас REST, потому что JSON и GET/POST» сразу ставят минус: JSON к REST отношения не имеет вообще.

REST против RPC: разные модели мышления

Разница не в технологиях, а в том, что считать единицей мышления. REST мыслит существительными: есть ресурс, у него есть состояние, а набор действий над ним фиксирован и задан протоколом (GET/POST/PUT/PATCH/DELETE). RPC мыслит глаголами: есть удалённая процедура с именем и аргументами, а сколько таких процедур и как они называются, решает автор сервиса.

RESTRPC (в т.ч. gRPC)
Единицаресурс (существительное)процедура (глагол)
Расширение APIновый ресурс или новое представлениеновый метод
Набор операцийфиксирован протоколомпроизволен, растёт бесконечно
Ошибкистатус-коды HTTPсвои коды в ответе
Кэшиз коробки, на любом промежуточном узлетолько руками у клиента
Открываемостьcurl, браузер, любой прокси понимаетнужен контракт и инструменты
Плохо ложится наоперации, которые не про состояние («пересчитать», «отправить письмо»)публичные API, кэшируемое чтение

Слабость у REST тоже есть: не всякое действие сводится к состоянию ресурса. «Перевести деньги», «пересчитать маршрут», «отправить письмо» приходится выворачивать в псевдоресурсы (POST /transfers, POST /route-calculations). Часто это нормальный ход: операция становится сущностью с идентификатором, её можно переспросить и сделать идемпотентной. Но иногда выходит натягивание совы на глобус, и честнее сказать «здесь у нас RPC-ручка».

Может ли REST жить не поверх HTTP

Теоретически может, и это правильный ответ. REST не упоминает HTTP: он требует идентификаторы ресурсов, единообразный интерфейс, statelessness, кэшируемость и слоистость. Годится любой протокол, который это даёт. Ровно так устроен CoAP (RFC 7252): REST-семантика (GET/POST/PUT/DELETE, коды ответов, URI, кэширование по Max-Age) поверх UDP для IoT-устройств. Похожее делают поверх AMQP и MQTT, а метод и путь кладут в свойства сообщения.

Практически же широко используется только HTTP, и не случайно: он уже даёт готовые URI, готовые глаголы с описанной семантикой, коды ответов, согласование содержимого и кэш-инфраструктуру целой планеты. Отсюда формулировка для ответа: «REST - стиль, HTTP - его единственная промышленная реализация; всё, что теряешь вне HTTP, придётся написать самому».

Контракт: OpenAPI и protobuf

Слабое место REST в том, что контракта внутри протокола нет: HTTP не знает, какие поля у твоего JSON. Поэтому контракт выносят наружу, в OpenAPI (бывший Swagger): это YAML/JSON-описание путей, методов, параметров, схем тел и кодов ответов. Из него получают интерактивную документацию (Swagger UI), клиентов и серверные заглушки (oapi-codegen, openapi-generator), валидацию запросов на лету, моки для фронтенда, контрактные тесты и проверку обратной совместимости в CI (oasdiff).

В gRPC контракт встроен: .proto и есть источник истины, без него сервис не собрать. Пожалуй, это главное практическое отличие. В REST схема остаётся необязательным артефактом и может протухнуть, а в gRPC она не протухнет по построению.

gRPC: что под капотом

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

Технически каждый вызов едет обычным HTTP/2-запросом: POST на путь /пакет.Сервис/Метод, заголовок content-type: application/grpc+proto, в теле последовательность фреймов «1 байт флага сжатия + 4 байта длины + сообщение». Результат вызова приезжает не в статусе HTTP, а в трейлерах (grpc-status, grpc-message) после тела. HTTP-статус при этом почти всегда 200, даже если вызов провалился, и на этом регулярно ломаются наивные метрики на балансировщике.

Почему быстрее

  • Protobuf вместо JSON. Бинарный формат без имён полей на проводе: вместо {"user_id":300} едет 08 AC 02. Обычно в 3–8 раз меньше и в несколько раз быстрее в разборе: парсер читает длины и смещения, а не разбирает текст посимвольно с аллокацией строк.
  • HTTP/2 и мультиплексирование. В одном TCP-соединении идут сотни одновременных вызовов, без очереди и без «одно соединение - один запрос». Соединения не открываются заново, значит, нет ни рукопожатий, ни разогрева congestion window.
  • HPACK. Заголовки сжимаются и, что важнее, повторяющиеся передаются как индекс в таблице: во втором и последующих вызовах :authority, user-agent, content-type занимают байты, а не сотни байт.
  • Кодогенерация. Никакого ручного маппинга: protoc-gen-go генерирует описание сообщения, а сериализует рантайм по таблицам, собранным один раз (прямую запись полей генерирует vtprotobuf). В Go разница с encoding/json по аллокациям всё равно заметная.
  • Стриминг из коробки. Не нужно эмулировать поток через пагинацию или long polling.
Оговорка про «быстрее»

На типичной бизнес-ручке, где 20 мс уходит в базу, разница между JSON и protobuf теряется в шуме. gRPC выигрывает там, где много мелких вызовов (сервис-к-сервису, десятки тысяч RPS), где большие объёмы (выгрузки, телеметрия) или где дорогая сеть (мобильный клиент). Ответ «gRPC быстрее, поэтому берём везде» слабый. Сильный называет условия, при которых разница вообще заметна.

Как выглядит контракт и генерация

syntax = "proto3";
package order.v1;
option go_package = "github.com/acme/api/gen/order/v1;orderv1";

service OrderService {
  rpc GetOrder    (GetOrderRequest)    returns (Order);
  rpc ListOrders  (ListOrdersRequest)  returns (stream Order);
  rpc ImportOrders(stream Order)       returns (ImportSummary);
  rpc Watch       (stream WatchRequest) returns (stream OrderEvent);
}

message GetOrderRequest {
  int64 id = 1;
}

message Order {
  int64  id       = 1;
  string customer = 2;
  int64  amount   = 3;   // копейки, а не float
  Status status   = 4;
  reserved 5, 6;
  reserved "legacy_code";
}

enum Status {
  STATUS_UNSPECIFIED = 0;   // нулевое значение обязано быть «неизвестно»
  STATUS_NEW         = 1;
  STATUS_PAID        = 2;
}

// Остальные сообщения сервиса - по тому же образцу.
message ListOrdersRequest { string customer = 1; }
message ImportSummary     { int32  imported = 1; }
message WatchRequest      { int64  order_id = 1; }
message OrderEvent        { Order  order    = 1; }
# два плагина: один генерирует сообщения, другой сервисный код
protoc --go_out=. --go_opt=paths=source_relative \
       --go-grpc_out=. --go-grpc_opt=paths=source_relative \
       order/v1/order.proto

# на практике вместо protoc обычно берут buf: линтер, детектор ломающих
# изменений и реестр схем в одном инструменте
buf lint
buf breaking --against '.git#branch=main'
buf generate

Генератор выдаёт структуры сообщений, интерфейс клиента (OrderServiceClient) и интерфейс сервера (OrderServiceServer) плюс UnimplementedOrderServiceServer для встраивания. Встраивать его нужно всегда: именно он позволяет добавлять методы в .proto, не ломая компиляцию существующих серверов.

Четыре вида вызовов

Четыре формы одного HTTP/2-потока Unary Get(Req) -> Resp клиент сервер 1 сообщение 1 сообщение Обычный вызов: 95% всего трафика. Ретраи и дедлайны работают тривиально. Server streaming List(Req) -> stream клиент сервер N сообщений Выгрузка большого списка без пагинации, подписка на события. Client streaming Upload(stream) -> Resp клиент сервер 1 итог в конце Загрузка файла кусками, пакетная запись метрик, агрегация на сервере. Bidirectional Chat(stream) -> stream клиент сервер порядок независим Чат, торговый стакан, двусторонняя синхронизация, длинный диалог с состоянием. Во всех четырёх случаях это ровно один HTTP/2-поток внутри одного соединения. Разница только в том, сколько сообщений едет в каждую сторону до закрытия половины потока. Стриминг не требует ни нового соединения, ни другого порта, ни WebSocket.
Одна механика, четыре формы. Стриминг в gRPC не отдельная технология, а свойство того же самого потока HTTP/2, поэтому он дёшев.

Protobuf на проводе

Главная идея формата: имён полей на проводе нет. Есть номера. Сообщение устроено как последовательность пар «ключ, значение», где ключ кодирует номер поля и wire type (как читать значение). Парсер, встретив незнакомый номер, знает по wire type, сколько байт пропустить. Отсюда почти бесплатная прямая и обратная совместимость.

Wire type («тип на проводе») не совпадает с типом из .proto: он отвечает на единственный вопрос «как найти конец значения». Вариантов всего шесть, поэтому wire type влезает в три бита ключа. Varint (variable-length integer, «целое переменной длины») записывает число так, чтобы мелкие числа занимали мало места: в каждом байте 7 полезных бит, а старший, восьмой, бит работает флагом «дальше есть ещё байт». Число 1 умещается в один байт, 300 в два, миллиард в пять.

Wire typeНазваниеКак читаетсяТипы
0VARINTбайты, пока старший бит = 1int32/64, uint32/64, sint32/64, bool, enum
1I64ровно 8 байтfixed64, sfixed64, double
2LENvarint длины, потом столько байтstring, bytes, вложенные сообщения, packed-массивы
3/4SGROUP/EGROUPгруппы: устарели, но встречаются в proto2 и в editions (DELIMITED)-
5I32ровно 4 байтаfixed32, sfixed32, float
1. Что реально едет по сети message User { int32 id = 1; string name = 2; } при id = 300, name = "Go" 0x08 00001000 0xAC 10101100 0x02 00000010 0x12 00010010 0x02 00000010 0x47 'G' 0x6F 'o' ключ varint 300 ключ длина = 2 "Go" Семь байт. Для сравнения: {"id":300,"name":"Go"} - 22 байта, и это ещё без пробелов. 2. Ключ = (номер поля << 3) | wire type 0x12 0 0 0 1 0 0 1 0 MSB 0 номер поля 0010 = 2 wire type 010 = 2 (LEN) Ключ - это тоже varint. Пока номер поля ≤ 15, он вместе с тремя битами типа влезает в один байт: 15 << 3 | 7 = 127 ≤ 127 Уже поле 16 даёт 16 << 3 = 128 > 127 - ключ становится двухбайтовым. Поэтому номера 1–15 отдают горячим полям, которые есть в каждом сообщении, а редкие нумеруют с 16. 3. varint: как 300 превращается в AC 02 300 = 1 0010 1100 в двоичном (9 значащих бит - в один байт не влезает) Режем справа на группы по 7 бит и к каждой добавляем старший бит-продолжение: 1 0101100 младшие 7 бит (44) + флаг 1 = 0xAC 0 0000010 старшие биты (2) + флаг 0 = 0x02 Младшая группа идёт ПЕРВОЙ: AC 02, а не 02 AC Старший бит = 1 значит «читай следующий байт», = 0 значит «число кончилось». Числа до 127 - один байт, до 16383 - два. Обратная сторона: у int32 значение −1 в дополнительном коде - это 64 единицы, то есть 10 байт varint. Для чисел, которые бывают отрицательными, есть sint32/sint64 с zigzag: (n << 1) ^ (n >> 31), у sint64 сдвиг 63. Тогда 0→0, −1→1, 1→2, −2→3 - маленькое по модулю остаётся коротким.
Семь байт вместо двадцати двух. На этом вопросе любят проверять побитовый разбор: «покажи, почему 300 - это именно AC 02». Умение довести до битов отличает «читал про protobuf» от «понимаю формат».

Эволюция схемы

Из «на проводе только номера» следуют все правила совместимости. Безопасно: добавить новое поле с новым номером (старый читатель его проигнорирует, новый увидит нулевое значение); переименовать поле (имя на провод не попадает, но сломаются JSON-представление и код); удалить поле, если номер уйдёт в reserved; добавить значение в enum (при условии, что клиент корректно обрабатывает неизвестное); превратить одиночное поле в repeated того же типа для string, bytes и сообщений. Нельзя: менять номер существующего поля; менять тип на несовместимый (int32 ↔ string); переиспользовать номер удалённого поля.

Чем опасно переиспользование номера

Допустим, было int64 user_id = 3, поле удалили, а через полгода добавили bool is_test = 3. Оба кодируются как VARINT, и парсер подмены не заметит. Старый сервис, которого забыли передеплоить, пришлёт user_id = 7712, а новый прочитает is_test = true, и никакой ошибки не будет: данные молча испортятся. От этого и спасает reserved 3; - компилятор не даст переиспользовать номер. Резервировать надо и имя (reserved "user_id";), чтобы не сломать JSON-маппинг. В proto3 к этому же относится правило про нулевое значение enum: элемент с номером 0 обязан быть первым и означать «не задано».

Ещё тонкость proto3: у скалярных полей нет различия между «ноль» и «не задано», на провод не пишется ни то, ни другое. Если разница важна (частичное обновление, «сбросить скидку в 0» против «не трогать скидку»), используют optional (вернулся в proto3 с версии 3.15 и даёт явное присутствие) либо обёртки google.protobuf.Int32Value, либо FieldMask со списком изменяемых путей.

Protobuf встречается далеко за пределами gRPC: формат хранения событий в Kafka (со Schema Registry), внутренний формат метрик Prometheus (remote write), Envoy xDS API, конфигурация в Kubernetes-совместимых системах, кэши и очереди, где важен размер, а также mobile-протоколы. Поэтому часто уточняют: «где protobuf используется, кроме gRPC». Ответ: «везде, где нужна компактная схема с версионированием, и транспорт RPC тут лишь частный случай».

Балансировка: почему gRPC ломается о L4

Суть в том, что два уровня не совпадают: L4-балансировщик распределяет соединения, а gRPC распределяет вызовы внутри одного соединения. Пока единица работы совпадала с единицей балансировки (HTTP/1.1: запрос ≈ соединение), всё сходилось. С HTTP/2 совпадение исчезло.

REST поверх HTTP/1.1: соединения короткие, их много клиент A клиент B клиент C L4 / NLB выбор по 5-кортежу при установке TCP pod 1 pod 2 pod 3 ≈33% ≈33% ≈33% Каждый новый запрос рано или поздно открывает новое соединение, а значит заново проходит через выбор пода. gRPC поверх HTTP/2 через тот же L4: одно соединение живёт часами клиент A клиент B клиент C L4 / NLB видит байты, не видит вызовы pod 1 pod 2 pod 3 100% RPS 0% 0% Балансировщик выбрал под один раз, при установке соединения. Дальше все RPC - это мультиплексированные потоки внутри уже выбранного TCP-соединения. Тот же баг с другой стороны: новый под после автоскейла или деплоя не получает трафик вообще, пока клиенты не переподключатся, а старый под после «слива» держит соединения до последнего. Классическая жалоба: «мы отскейлились с 3 до 30, а latency не упала». Лечится четырьмя способами: L7-балансировщик (Envoy, nginx, ALB), client-side LB через resolver, service mesh (sidecar), MAX_CONNECTION_AGE на сервере - принудительный GOAWAY, чтобы клиент периодически перевыбирал под.
Единица балансировки против единицы работы. L4 распределяет соединения, gRPC распределяет вызовы внутри соединения. Пока соединение и вызов совпадали, проблемы не было.

Четыре способа починить

  • L7-балансировщик. Envoy, nginx с grpc_pass, Traefik или AWS ALB терминируют HTTP/2, видят отдельные потоки и раскидывают каждый вызов. Плюс: клиенту ничего не надо знать. Минус: лишний хоп, ресурсы, и это ещё одна точка отказа.
  • Client-side load balancing. Клиент сам получает список адресов (в Kubernetes для этого есть headless service, у которого DNS отдаёт все IP подов, а не один ClusterIP) и держит по соединению к каждому, выбирая под на каждый вызов (grpc.WithDefaultServiceConfig с round_robin). Лишнего хопа нет, задержка минимальная, зато логика балансировки размазана по клиентам, а число соединений растёт как «клиенты × поды».
  • Service mesh. Istio/Linkerd ставят sidecar-прокси в каждый под, рядом с приложением: приложение ходит по обычному адресу сервиса и о перехвате не знает: трафик заворачивают на прокси, а он берёт на себя балансировку, ретраи, mTLS и телеметрию. Фактически client-side LB, вынесенный из кода.
  • Ограничение времени жизни соединения. На сервере задают keepalive.ServerParameters{MaxConnectionAge: 30*time.Minute, MaxConnectionAgeGrace: 5*time.Minute}, и тогда сервер сам шлёт GOAWAY, клиент переподключается и заново проходит балансировку. Полноценным решением это не назовёшь, но страховка дешёвая, и включать её стоит всегда.
Как это звучит в ответе

«gRPC держит одно долгоживущее HTTP/2-соединение и мультиплексирует в нём вызовы. L4-балансировщик принимает решение один раз, на SYN. Дальше он просто перекладывает байты и про отдельные RPC не знает. Значит, весь трафик клиента приклеивается к одному поду: один горит, остальные простаивают, а новые поды после скейла не получают нагрузку вовсе. Чинится L7-прокси, client-side-балансировкой через headless service, mesh-ом или MaxConnectionAge. Кстати, ровно та же проблема бывает у долгоживущих WebSocket-соединений и у пулов подключений к базе.»

Реалтайм поверх HTTP: три подхода

Общая проблема: HTTP спроектирован как «клиент спросил - сервер ответил», а нужно «сервер сам сказал, когда что-то случилось». Исторически это решали тремя способами, и все три живы.

Long polling клиент сервер GET /events держит запрос 200 + событие пауза пауза по таймауту - 204, и клиент спрашивает снова Каждое событие - отдельный цикл запрос-ответ со всеми заголовками. Между ответом и следующим запросом события копятся на сервере. SSE - Server-Sent Events клиент сервер GET /stream, Accept: text/event-stream одно соединение остаётся открытым; ответ приходит бесконечным chunked-потоком data: {...} id: 17 Только сервер→клиент. Формат текстовый (data:, event:, id:, retry:). Переподключение и Last-Event-ID - в самом браузерном API. WebSocket клиент сервер GET /ws Upgrade: websocket 101 Switching Protocols дальше это уже не HTTP: кадры в обе стороны ping / pong Оверхед кадра - 2–14 байт. Заголовков нет вообще, куки не пересылаются, HTTP-кэш и HTTP-ретраи не работают: всё своё.
Три способа обмануть модель «запрос-ответ». Long polling эмулирует push циклом запросов, SSE держит один поток в одну сторону, WebSocket меняет протокол и открывает обе.
Long pollingSSEWebSocket
Направлениесервер→клиент (эмуляция)только сервер→клиентобе стороны
Протоколобычный HTTPобычный HTTP, text/event-streamws:///wss:// после Upgrade
Соединений на клиентанепрерывный цикл новыходноодно
Форматлюбойтолько текст (бинарь - base64)текст и бинарь
Переподключениеестественноеавтоматическое + Last-Event-IDпишешь сам
Прокси и корпоративные сетипроходит вездепроходит вездеиногда режут, спасает wss
Оверхед на сообщениеполный набор заголовковнесколько байт2–14 байт
Сжатие и HTTP-кэшработаютсжатие да, кэш потоку ни к чемусвоё (permessage-deflate)
Куда идётлегаси и совместимостьлента, уведомления, прогресс, токены LLMчат, игры, торги, совместное редактирование
Правило выбора

Нужен поток только вниз - SSE: дешевле, проще, переподключение бесплатно, работает через любой HTTP-прокси. Для настоящего двустороннего диалога с низкой задержкой нужен WebSocket. Между сервисами внутри инфраструктуры лучше gRPC-стриминг: он делает то же самое, но с контрактом, дедлайнами и кодами ошибок. Long polling остаётся запасным вариантом для клиентов, где ничего другого нет. А если событий мало и задержка в секунды терпима, самым надёжным и дешёвым в поддержке решением будет обычный polling раз в N секунд. Предложить его вслух - признак зрелости, а не лени.

Форматы сериализации: чем платишь за формат

ФорматСхемаРазмерСкоростьЧитаемостьГде уместен
JSONвнешняя (OpenAPI, JSON Schema) или никакойбазовый ориентирсредняядапубличные API, конфиги, логи, всё, что смотрят глазами
Protobufобязательная, .proto×0.2–0.4 от JSONвысокаянетмежсервисное, события в брокере, мобильные клиенты
MessagePackнет (как JSON, но бинарно)×0.8–0.95высокаянеткэши, Redis, когда менять контракт нельзя, а трафик жалко
gobсамоописывающийся, только Goпервое ×1.1–1.3 (описание типов), дальше ×0.2–0.4высокаянетGo↔Go, RPC внутри одной кодовой базы, снапшоты на диск
XMLXSD, очень строгая×1.3–2 от JSONнизкаяусловноSOAP, госинтеграции, банки, документооборот с подписью
YAMLвнешняя-низкаялучшаятолько конфиги; для трафика не используют
Avroобязательная, схема едет отдельно×0.2–0.4высокаянетKafka + Schema Registry, аналитические пайплайны, Hadoop
Про gob спрашивают три вещи
  • Только Go. Другие языки его не читают, так что для любого внешнего API он отпадает сразу.
  • Самоописывающийся. В поток едет описание типов, поэтому первое сообщение выходит большим, а следующие однотипные стоят копейки. Значит, gob хорош для потока и плох для одиночных мелких значений в кэше.
  • Экспортируемые поля и нулевые значения. Кодируются только публичные поля; поля с нулевым значением по умолчанию не передаются вовсе. Для интерфейсных полей нужен gob.Register, иначе кодирование вернёт ошибку «type not registered for interface», а декодирование — «name not registered for interface».

Про YAML надо предупредить отдельно: мало того что он медленный, он ещё и опасный. Классическая «норвежская проблема»: country: NO в YAML 1.1 разбирается как булев false. А из якорей и ссылок (&anchor/*ref) собирают «YAML-бомбу», которая раскрывается экспоненциально. Так что YAML годится для конфигов, которые пишет человек, и никогда для трафика.

Вопросы

11
Суть: REST - архитектурный стиль из диссертации Филдинга, набор шести ограничений, из которых вытекает масштабируемость веба. Ключевые: stateless, кэшируемость, единообразный интерфейс (URI + глаголы + самоописывающиеся сообщения + HATEOAS), слоистость. Почти все «REST API» - это уровень 2 по Ричардсону, то есть REST-like: ресурсы и глаголы есть, HATEOAS нет.

Шесть ограничений и что каждое даёт

ОграничениеЧто запрещаетЧто за это получаешь
Клиент-серверсмешивать UI и хранениенезависимую эволюцию сторон
Statelessпомнить клиента между запросамигоризонтальное масштабирование, простые ретраи
Кэшируемостьмолчать про кэшируемость ответаCDN и прокси между клиентом и сервером
Единообразный интерфейссвои глаголы и свои кодылюбой посредник понимает семантику
Слоистостьклиенту знать, кто на том концепрокси, шлюзы, балансировщики, mesh
Code on demand (опц.)-исполняемый код от сервера (JS в браузере)

Stateless - что это значит на практике

Это не значит «состояния нет вообще»: данные в базе никуда не деваются. Речь про состояние сессии клиента на конкретном инстансе. Всё нужное запрос обязан нести сам: токен вместо «сервер помнит, что ты залогинен», курсор пагинации вместо «сервер помнит, где ты остановился». Взамен любой инстанс обрабатывает любой запрос, sticky session не нужен, падение узла не рвёт работу пользователя, ретрай безопасен, а автоскейл честный.

Каверзный уточняющий: а куки и сессии нарушают REST?

Строго говоря, да. Серверная сессия по session_id нарушает statelessness: сервер хранит состояние клиента между запросами. Поэтому в API-мире и прижились самодостаточные токены (JWT), где всё нужное лежит в самом запросе. Но правильный ответ на этом не заканчивается: у stateless-токенов своя цена. Мгновенно их не отозвать (нужен чёрный список, а это снова состояние), весят они больше, и незаметно обновить их нельзя. Практическую середину даёт внешнее хранилище сессий (Redis): инстансы остаются взаимозаменяемыми, а состояние вынесено вовне.

Ресурс, представление, URI

  • Ресурс - сущность с идентичностью: «заказ 42». Представлением называют то, как её сериализовали сейчас: JSON, XML, CSV, PDF. Один URI отдаёт много представлений, нужное клиент выбирает через Accept.
  • URI именует существительное, а не действие. /orders/42 годится, а /getOrder?id=42 уже RPC в URL-костюме.
  • Коллекция и элемент: /orders и /orders/42. Множественное число никто не требует, это соглашение, но полезное.
  • Вкладывай не глубже одного уровня: /orders/42/items. Дальше (/users/7/orders/42/items/3/comments) начинается ад: элемент всё равно адресуется своим id, а длинные пути ломают кэш и роутинг.

RESTful vs REST-like

RESTful соблюдает все ограничения, включая гипермедиа: уровень 3. У REST-like есть ресурсные URI, корректные методы и статус-коды, но переходы клиент знает из документации, а не из ответов: уровень 2. Индустрия живёт на уровне 2, потому что там всё окупается: кэш, идемпотентность, ретраи и понятность для прокси уже есть, а гипермедиа машинному клиенту обходится дорого.

Три ловушки на этом вопросе
  • «REST - это JSON по HTTP». Нет, JSON к REST не относится: REST-ответ бывает XML, HTML или protobuf, а JSON-RPC вообще RPC, а не REST.
  • «REST - это CRUD». Тоже нет. Методы приятно совпадают с CRUD, но смысл REST в единообразном интерфейсе, а не в четырёх операциях над таблицей.
  • «У нас REST, потому что красивые URL». Красивые URL дают только уровень 1, самый бесполезный: ресурсы уже есть, а выгоды от протокола ещё нет.
Суть: REST мыслит ресурсами (существительными) с фиксированным набором операций из протокола; RPC мыслит процедурами (глаголами), набор которых произволен и растёт. Отсюда всё остальное: у REST кэш и семантика бесплатны от HTTP, у RPC - гибкость и выразительность. REST может жить не поверх HTTP (CoAP - живой пример), но практически не живёт, потому что теряет ровно то, ради чего его берут.

Одна и та же задача в двух моделях

# REST: состояние ресурса
GET    /orders/42            -> 200 Order
POST   /orders               -> 201 + Location
PATCH  /orders/42            -> 200
DELETE /orders/42            -> 204

# «отменить» тоже состояние:
PATCH  /orders/42
{"status": "cancelled"}
# или ресурс-действие:
POST   /orders/42/cancellation
# RPC: список процедур
POST /OrderService/GetOrder
POST /OrderService/CreateOrder
POST /OrderService/UpdateOrder
POST /OrderService/DeleteOrder
POST /OrderService/CancelOrder
POST /OrderService/RecalculateTotals
POST /OrderService/SendReceiptEmail
# в gRPC то же самое, но с контрактом
# и бинарным телом

Посмотри на нижние строки правой колонки: RecalculateTotals и SendReceiptEmail в RPC ложатся естественно, а в REST их приходится придумывать как ресурсы. Это честная слабость REST, и, признав её, ты только выиграешь. Обратный пример: «отдай мне заказ, и пусть CDN его закэширует на 5 минут». В REST хватит одной строки заголовка, в RPC нужна отдельная инфраструктура.

Разница в том, где живёт семантика

  • REST: семантика в протоколе. Посредник (прокси, CDN, WAF, балансировщик) видит GET /orders/42 и знает: это чтение, безопасно, идемпотентно, кэшируемо, можно ретраить. Ему не нужен твой контракт.
  • RPC: семантика в контракте. Для посредника любой вызов выглядит как POST с непрозрачным телом. Не зная твоего .proto, он не может ни ретраить, ни кэшировать, ни маршрутизировать по смыслу.
  • Отсюда и практическое правило: наружу - REST (открываемость, кэш, любой клиент), внутрь - RPC/gRPC (контракт, скорость, стриминг).

Может ли REST жить не поверх HTTP

Может: в диссертации HTTP ни разу не назван обязательным. Требуются идентификаторы ресурсов, единообразный набор операций с описанной семантикой, самоописывающиеся сообщения, statelessness, явная кэшируемость, слоистость.

  • CoAP (RFC 7252) даёт самый чистый пример: GET/POST/PUT/DELETE, URI вида coap://device/sensors/temp, коды ответов «2.05 Content», «4.04 Not Found», кэширование по Max-Age, подписки через Observe. Работает поверх UDP, потому что рассчитан на устройства с батарейкой и каналом в килобиты.
  • Поверх брокера (AMQP, MQTT, NATS): метод и путь кладут в свойства сообщения, а ответ шлют в reply-to. Формально похоже на REST, по сути это уже самодельный протокол.

Почему всё равно HTTP? Он уже даёт URI, глаголы с зафиксированной семантикой, коды ответов, согласование содержимого, условные запросы, диапазоны, сжатие, а главное, планетарную кэш-инфраструктуру и инструменты, которые понимают всё это без твоего участия. Вне HTTP всё это пишут руками, и обычно хуже.

Обратная сторона вопроса

Спрашивают и наоборот: «а gRPC - это обязательно HTTP/2?» Формально нет: gRPC описан как спецификация RPC, а HTTP/2 служит ей основным транспортом. Есть gRPC-Web (поверх HTTP/1.1 через прокси, потому что браузер не даёт управлять HTTP/2-фреймами и трейлерами) и реализации поверх QUIC/HTTP/3. Но в отличие от REST, gRPC привязан к транспорту гораздо сильнее: без мультиплексирования HTTP/2 половина его преимуществ исчезает.

Суть: REST кэшируется чужими руками - браузером, CDN, реверс-прокси, - потому что GET + URI + кэш-заголовки понятны всем посредникам. RPC - это всегда POST с непрозрачным телом, поэтому кэш строишь сам. С балансировкой ровно наоборот: REST на HTTP/1.1 раскидывается любым L4, а gRPC на одном долгоживущем HTTP/2-соединении «приклеивается» к одному поду, потому что L4 выбирает бэкенд один раз - при установке TCP.

Кэш: почему у REST он бесплатный

  • Ключом кэша служит URI плюс заголовки из Vary. У RPC ключа нет: у всех вызовов один путь и разные тела, а тело в ключ кэша не входит.
  • GET объявлен безопасным и идемпотентным, поэтому кэшировать его можно по умолчанию. POST кэшируем только в теории и практически никем.
  • Условные запросы (ETag + If-None-Match → 304) дают почти бесплатную ревалидацию: 150 байт вместо мегабайта.
  • Уровни кэша складываются: браузер → Service Worker → CDN → реверс-прокси → кэш приложения. Каждый снимает часть нагрузки, и ни один не знает про твой код.

В RPC/gRPC всё это делают вручную: кэш в Redis по ключу из имени метода и полей запроса, своя логика инвалидации, TTL своими руками. Исключения есть: кэшируемые методы можно объявить через google.api.http и выставить наружу как GET через grpc-gateway. Но это уже трансляция в REST, а не кэш gRPC.

Балансировка: где именно ломается

  1. Клиент открывает одно TCP-соединение. L4-балансировщик выбирает бэкенд по 5-кортежу (src IP, src port, dst IP, dst port, протокол) и делает это один раз.
  2. gRPC мультиплексирует в этом соединении сотни потоков. Все они, естественно, идут на выбранный бэкенд.
  3. Соединение живёт часами: gRPC его не рвёт, а keepalive-пинги не дают закрыться.
  4. В итоге один под получает весь трафик клиента, остальные простаивают. Отскейлились с 3 подов до 30, а нагрузка не перераспределилась: никто не переподключался.
Симптомы, по которым это узнают в проде
  • CPU на одном поде 90%, на остальных 5%, а HPA считает среднее и поэтому не скейлит.
  • После деплоя новые поды стоят пустые, пока старые не убьют.
  • Задержка не падает от добавления реплик.
  • В Kubernetes через обычный ClusterIP так будет гарантированно: kube-proxy работает на L4 (iptables, nftables или IPVS) и балансирует соединения, а не запросы.

Решения и их цена

СпособКак работаетЦена
L7-проксиEnvoy/nginx/ALB терминируют HTTP/2 и раскидывают каждый вызовлишний хоп, ресурсы, ещё одна точка отказа
Client-side LBheadless service + resolver отдаёт все IP, клиент держит по соединению к каждому и делает round-robin по вызовамлогика в каждом клиенте, соединений «клиенты × поды»
Service meshsidecar делает то же самое, но вне кода приложениясложность платформы, +1–2 мс на хоп, потребление памяти
MaxConnectionAgeсервер шлёт GOAWAY по возрасту соединения, клиент перевыбирает подпериодические переподключения, перебалансировка «раз в N минут», а не мгновенная
// 1. Сервер: не давать соединениям жить вечно + защита от «пинг-флуда»
srv := grpc.NewServer(
    grpc.KeepaliveParams(keepalive.ServerParameters{
        MaxConnectionAge:      30 * time.Minute, // потом GOAWAY
        MaxConnectionAgeGrace: 5 * time.Minute,  // доиграть текущие RPC
        Time:                  2 * time.Minute,  // пинг простаивающему клиенту
        Timeout:               20 * time.Second,
    }),
    grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
        MinTime:             1 * time.Minute,
        PermitWithoutStream: true,
    }),
)

// 2. Клиент: сам балансирует через headless service.
// dns:/// отдаёт все A-записи; перечитывает их резолвер при обрыве, а не по таймеру.
conn, err := grpc.NewClient(
    "dns:///orders.default.svc.cluster.local:9000",
    grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"round_robin":{}}]}`),
    grpc.WithTransportCredentials(insecure.NewCredentials()),
)
Чем добить ответ

Скажи, что это не свойство gRPC, а свойство долгоживущих соединений. Ровно та же картина у WebSocket-серверов, у пулов подключений к Postgres через pgbouncer в режиме session, у Kafka-консьюмеров и у HTTP/2-клиентов на чистом REST. И упомяни обратную ловушку: при client-side LB число соединений равно «клиентов × реплик», так что на 200 клиентах и 50 подах получается 10 000 соединений, и тут уже выигрывает mesh или L7-прокси.

Суть: OpenAPI - машиночитаемое описание HTTP-API: пути, методы, параметры, схемы тел, коды ответов, аутентификация. Из него генерируют документацию, клиентов, серверные заглушки, моки, валидацию и проверку обратной совместимости в CI. Contract-first - спека источник истины, код генерируется; code-first - спека вытаскивается из кода аннотациями. Команды чаще выбирают contract-first там, где API - межкомандный контракт, и code-first там, где API внутренний и меняется быстро.

Терминология, на которой путаются

Swagger - исторический бренд: в конце 2015 года спецификацию Swagger передали в Linux Foundation, а с 2016-го она называется OpenAPI (3.0, 3.1, 3.2). Сейчас «Swagger» зовут инструменты (Swagger UI, Swagger Editor, Swagger Codegen), а формат называется OpenAPI. Главное в 3.1: он стал полностью совместим с JSON Schema 2020-12, так что одна схема годится и для валидации, и для документации.

Что реально даёт спека

  • Документация без ручной работы в Swagger UI, Redoc, Scalar. Уже одно это окупает спеку.
  • Кодогенерация клиентов для всех потребителей: фронтенд, мобилки, соседние команды. В Go есть oapi-codegen (умеет и сервер: типы, роутинг, валидацию под chi/echo/gin/std).
  • Валидация на входе из схемы, а не из руками написанных if-ов. Меньше кода, меньше расхождений документации с поведением.
  • Моки для фронтенда (Prism, WireMock): фронт пишут, не дожидаясь бэкенда.
  • Контрактные тесты и diff в CI: oasdiff breaking --fail-on ERR валит пайплайн, если удалили поле или сузили enum. Так спека из документа становится инструментом.
  • Импорт в шлюзы: Kong, APIM, AWS API Gateway настраиваются прямо из OpenAPI.

Contract-first против code-first

Contract-firstCode-first
Источник истиныopenapi.yaml в репозиториикод и аннотации над хендлерами
Порядок работысогласовали спеку → сгенерировали типы и роутинг → написали логикунаписали хендлеры → сгенерировали спеку → отдали потребителям
Параллельная работафронт стартует сразу с моковфронт ждёт готовый бэкенд
Расхождение кода и спекиневозможно: код из спекиобычное дело: забыли обновить аннотацию
Обратная совместимостьпроверяется diff-ом спеки в CIловится глазами на ревью
Скорость на стартемедленнее, спека - отдельный артефакт для ревьюбыстрее, спека «сама появляется»
Инструменты в Gooapi-codegen, ogenswaggo/swag, go-swagger
Что реально выбирают в командах

Правило простое: чем больше потребителей и чем дороже сломать клиента, тем сильнее аргумент за contract-first. Публичному API, API между командами и API с внешними партнёрами нужен contract-first: спеку ревьюят как код, breaking-changes ловит CI. Внутреннему сервису с одним потребителем в том же репозитории и API, которое меняется каждую неделю, code-first дешевле и честнее. Хуже всего code-first без автогенерации, когда спеку пишут руками отдельно от кода: за месяц она протухнет и начнёт вредить.

# contract-first в Go: генерируем типы + серверный интерфейс + валидацию
oapi-codegen -generate types,chi-server,spec -package api \
             -o internal/api/api.gen.go openapi.yaml

# CI проверяет обратную совместимость
oasdiff breaking origin/main:openapi.yaml openapi.yaml --fail-on ERR
Как это сравнивают с gRPC

В gRPC вопроса «contract-first или code-first» нет: единственный источник истины там .proto, и code-first в Go не встречается (в .NET есть protobuf-net.Grpc, но это экзотика). Это и есть главный аргумент за gRPC во внутренних API: контракт не может разойтись с реальностью, а buf breaking проверяет совместимость из коробки. Верно и обратное: contract-first в REST пытается повторить дисциплину, которую gRPC даёт по построению.

Суть: gRPC - RPC-фреймворк: контракт в .proto, сериализация protobuf, транспорт HTTP/2, кодогенерация клиента и сервера. Быстрее за счёт четырёх вещей: компактный бинарный формат без имён полей, мультиплексирование в одном долгоживущем соединении, сжатие заголовков HPACK, отсутствие рефлексии в сгенерированном коде. Плюс встроенные дедлайны, отмена, метаданные, стриминг и интерцепторы.

Что происходит при вызове

  1. Сгенерированный клиент сериализует запрос в protobuf.
  2. Открывается новый поток в уже существующем HTTP/2-соединении. Летят HEADERS: :method: POST, :path: /order.v1.OrderService/GetOrder, content-type: application/grpc, te: trailers, дедлайн в grpc-timeout, метаданные пользователя.
  3. DATA-фреймы несут «length-prefixed message»: 1 байт флага сжатия + 4 байта длины big-endian + сами байты сообщения. Если сообщений несколько, это и есть стриминг.
  4. Ответ приходит так же, а результат вызова лежит в трейлерах после тела: grpc-status: 0, при ошибке ещё grpc-message и grpc-status-details-bin.
HTTP-статус почти всегда 200

Ошибка gRPC едет в трейлере, а не в статусной строке. Поэтому дашборд «доля 5xx на балансировщике» для gRPC-сервиса показывает ноль, даже когда половина вызовов падает с INTERNAL. Мониторить надо grpc_status: через интерцептор с метриками или через L7-прокси, который умеет читать трейлеры (Envoy умеет). Отдельный подвох: если прокси отвечает не 200, клиент видит это как UNAVAILABLE (502, 503, 504) или UNIMPLEMENTED (404). Второе особенно путает, ведь метод-то реализован.

Откуда берётся скорость - по пунктам

МеханизмЧто экономитКогда заметно
protobuf вместо JSONразмер тела (×0,2–0,4 от JSON), CPU на разбормного мелких сообщений или большие объёмы
HTTP/2 мультиплексированиерукопожатия, разогрев congestion window, очередь запросоввысокий RPS, множество параллельных вызовов
HPACKповторяющиеся заголовки → индексы в таблицетысячи мелких вызовов с одинаковыми заголовками
Кодогенерациярефлексию и аллокации при маршалингегорячий путь, где сериализация видна в профиле
Бинарный фреймингпарсинг текставсегда, но мало

Трезвая оценка: на межсервисном вызове «получить объект по id» gRPC выигрывает единицы миллисекунд, а по трафику в разы. Если ручка делает три запроса в базу на 20 мс, выигрыш утонет. Поэтому правильный ответ всегда уточняет: «быстрее в чём и при каких условиях».

Минимальный сервер и клиент в Go

// сервер
type server struct {
    orderv1.UnimplementedOrderServiceServer // обязательно: даёт forward-совместимость
    repo *Repo
}

func (s *server) GetOrder(ctx context.Context, req *orderv1.GetOrderRequest) (*orderv1.Order, error) {
    o, err := s.repo.Get(ctx, req.GetId())      // GetXxx() безопасен для nil-приёмника
    if errors.Is(err, ErrNotFound) {
        return nil, status.Errorf(codes.NotFound, "order %d not found", req.GetId())
    }
    if err != nil {
        return nil, status.Error(codes.Internal, "internal error") // без утечки деталей наружу
    }
    return toProto(o), nil
}

func main() {
    lis, _ := net.Listen("tcp", ":9000")
    s := grpc.NewServer(grpc.ChainUnaryInterceptor(logging, recovery, metrics))
    orderv1.RegisterOrderServiceServer(s, &server{repo: repo})
    reflection.Register(s) // чтобы grpcurl/Postman видели схему; в проде только внутри периметра
    _ = s.Serve(lis)
}
// клиент: NewClient не подключается сразу, соединение ленивое
conn, err := grpc.NewClient("orders:9000", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil { return err }
defer conn.Close()

cli := orderv1.NewOrderServiceClient(conn)

ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()

o, err := cli.GetOrder(ctx, &orderv1.GetOrderRequest{Id: 42})
if st, ok := status.FromError(err); ok && st.Code() == codes.NotFound {
    // здесь обрабатываем именно NotFound
}
Что спросят следом
  • Один ClientConn на процесс, а не на вызов. Он потокобезопасен, внутри держит пул и умеет переподключаться. Типичная ошибка: создавать соединение на каждый запрос и терять на этом весь выигрыш.
  • Почему UnimplementedXxxServer обязателен. Он даёт реализацию по умолчанию для будущих методов: если в .proto добавили метод, старые серверы продолжают компилироваться и отвечают Unimplemented, а не падают.
  • Ограничение размера сообщения. По умолчанию приём до 4 МБ, превышение приходит как ResourceExhausted: grpc: received message larger than max. Лечат это стримингом, а не подъёмом лимита до 100 МБ.
  • Браузер напрямую в gRPC не может: fetch не даёт доступа к трейлерам и фреймам HTTP/2, поэтому нужен gRPC-Web и прокси (Envoy).
Суть: на проводе нет имён полей - только пары «ключ, значение», где ключ = (номер поля << 3) | wire type. Wire type говорит, сколько байт занимает значение, поэтому незнакомое поле можно молча пропустить - отсюда совместимость в обе стороны. Числа кодируются varint по 7 бит на байт со старшим битом-продолжением. Правила эволюции следуют из этого напрямую: номер поля - вечный, тип менять нельзя, удалённые номера в reserved.

Разбор конкретного сообщения

message User {
  int32  id   = 1;
  string name = 2;
}
// id = 300, name = "Go"   ->   08 AC 02 12 02 47 6F   (7 байт)

08 = 0000 1000 -> номер поля 1, wire type 0 (VARINT)
AC = 1010 1100 -> старший бит 1: читаем дальше; полезные 7 бит = 0101100
02 = 0000 0010 -> старший бит 0: конец числа;   полезные 7 бит = 0000010
     значение = (0000010 << 7) | 0101100 = 256 + 44 = 300
12 = 0001 0010 -> номер поля 2, wire type 2 (LEN)
02 -> длина 2
47 6F -> "Go"

// то же в JSON: {"id":300,"name":"Go"} = 22 байта, и это без пробелов

Из разбора видно: младшая группа varint идёт первой (little-endian по 7-битным группам), а числа 0–127 занимают ровно один байт. Ключ тоже varint: пока номер поля не больше 15, ключ влезает в один байт (15<<3|7 = 127), а с номера 16 занимает два. Поэтому в проектных гайдлайнах номера 1–15 берегут для полей, которые есть в каждом сообщении, а редкие поля нумеруют от 16.

Wire types и типы данных

WTЧтениеТипыЗамечание
0 VARINTбайты, пока MSB = 1int32/64, uint32/64, sint32/64, bool, enumотрицательные int32 - всегда 10 байт
1 I648 байтfixed64, sfixed64, doubleвыгоден, когда числа обычно большие
2 LENvarint длины + данныеstring, bytes, вложенные сообщения, packed-массивывложенность = просто байты внутри байтов
5 I324 байтаfixed32, sfixed32, floatдля хэшей и id фиксированной ширины
zigzag: зачем нужен sint32/sint64

int32 x = -1 в дополнительном коде расширяется до 64 бит из одних единиц, и varint занимает 10 байт вместо одного. sint32 применяет zigzag: (n << 1) ^ (n >> 31), то есть 0→0, −1→1, 1→2, −2→3, 2→4, и маленькие по модулю числа остаются короткими при любом знаке. Правило такое: если поле бывает отрицательным, бери sint; если никогда не отрицательное, int или uint; если всегда большое (хэш, id-снежинка), fixed.

Эволюция схемы

ДействиеМожно?Почему
Добавить поле с новым номеромдастарый читатель пропустит по wire type, новый увидит нулевое значение
Переименовать поледа на проводеимени на проводе нет; но сломается JSON-маппинг и код
Удалить поледа с reservedиначе номер переиспользуют и данные молча испортятся
Добавить значение в enumда осторожностарый клиент получит неизвестное число; в proto3 оно сохраняется как есть
int32 ↔ int64 ↔ bool ↔ uint32даодинаковый wire type; но при сужении значение обрежется
string ↔ bytesдаоба LEN; строка должна быть валидным UTF-8
Одиночное поле → repeatedда для сообщенийстарый читатель сольёт элементы в одно сообщение (последнее значение берут только у скаляров, string и bytes)
Сменить номер полянетэто другое поле; старые данные станут «неизвестными»
int32 ↔ string, int32 ↔ fixed32нетразные wire types - мусор при разборе
Переиспользовать номер удалённого поляникогдатихая порча данных, самая дорогая из ошибок
Менять required (proto2)нетровно поэтому в proto3 required вырезали совсем
message Order {
  int64  id       = 1;
  string customer = 2;
  int64  amount   = 3;

  reserved 4, 7 to 9;          // номера, которые больше нельзя занимать
  reserved "discount", "promo"; // и имена, чтобы не сломать JSON-представление

  optional string coupon = 10;  // explicit presence: отличаем "" от «не задано»
}
Как переиспользованный номер тихо портит данные

Было int64 user_id = 3, поле удалили, через полгода добавили bool is_test = 3. Оба VARINT, так что парсер разницы не заметит. Сервис, который забыли передеплоить, пришлёт user_id = 7712, а новый прочитает is_test = true (любое ненулевое число даёт true) и уведёт заказ в тестовый контур. Ни ошибки, ни лога, ни алерта. От этого и защищает reserved, а в CI ещё и buf breaking, который валит PR при такой попытке. Второй эшелон защиты: версия в имени пакета (order.v1, order.v2), и ломающие изменения выносят в новый пакет, а не правят старый.

Ещё две тонкости proto3, которые любят спрашивать. Первая: нет различия «ноль» и «не задано» у скаляров, нулевые значения на провод вообще не пишутся. Отсюда проблема с частичным обновлением («сбросить скидку в 0» неотличимо от «не трогать»), её решают через optional, обёртки (google.protobuf.Int32Value) или FieldMask. Вторая: protobuf не детерминирован. Порядок полей и кодирование map не гарантированы между реализациями и версиями, поэтому байты protobuf не годятся ни в ключ кэша, ни во вход хэша, ни в основу цифровой подписи.

Где применяется, кроме gRPC

  • События в Kafka/Pulsar вместе со Schema Registry: компактно и с контролем совместимости схем (как альтернативу берут Avro, у которого схема эволюционирует по именам, а не по номерам).
  • В Prometheus remote write метрики едут в protobuf поверх HTTP, сжатые snappy.
  • Envoy xDS - вся динамическая конфигурация service mesh описана в protobuf.
  • Кэши и хранилища, где важен размер значения; в Redis это заметно на десятках миллионов ключей.
  • Мобильные и IoT-протоколы, где трафик стоит денег и батарейки.
  • Форматы файлов: например, OSM PBF для карт, tfrecord-подобные форматы в ML.
Суть: четыре формы одного и того же HTTP/2-потока, отличаются только тем, сколько сообщений едет в каждую сторону. Unary - 1:1, обычный вызов. Server streaming - 1:N, выгрузка и подписка. Client streaming - N:1, загрузка и агрегация. Bidirectional - N:M независимо, диалог. Стриминг не требует ни отдельного соединения, ни WebSocket.

Как объявляются и когда какой

ВидВ .protoКогда братьЧем опасен
Unaryrpc Get(Req) returns (Resp)по умолчанию всёничем; ретраи и дедлайны тривиальны
Server streamingrpc List(Req) returns (stream Resp)большая выгрузка, подписка на события, прогресс долгой операцииретрай означает «начать сначала»; нужен курсор для возобновления
Client streamingrpc Upload(stream Req) returns (Resp)загрузка файла кусками, батч метрик, агрегация на серверерезультат только в конце; при обрыве непонятно, что успело записаться
Bidirectionalrpc Chat(stream Req) returns (stream Resp)чат, стакан котировок, синхронизация, длинный диалог с состояниемсостояние на сервере ломает stateless и балансировку; сложнее всего отлаживать

Server streaming в Go

// сервер: шлём заказы потоком, пока клиент не отменил
func (s *server) ListOrders(req *orderv1.ListOrdersRequest, stream orderv1.OrderService_ListOrdersServer) error {
    rows, err := s.repo.Cursor(stream.Context(), req.GetFilter())
    if err != nil { return status.Error(codes.Internal, "query failed") }
    defer rows.Close()

    for rows.Next() {
        select {
        case <-stream.Context().Done(): // клиент ушёл или сработал дедлайн
            return status.FromContextError(stream.Context().Err()).Err()
        default:
        }
        if err := stream.Send(toProto(rows.Order())); err != nil {
            return err // соединение оборвалось, отдаём наверх без обёртки
        }
    }
    return rows.Err()
}

// клиент: поток заканчивается io.EOF, и это не ошибка
st, err := cli.ListOrders(ctx, &orderv1.ListOrdersRequest{})
if err != nil { return err }
for {
    o, err := st.Recv()
    if errors.Is(err, io.EOF) { break }   // нормальное завершение
    if err != nil { return err }          // всё остальное - ошибка
    handle(o)
}

Client streaming и bidirectional

// client streaming: клиент шлёт N, получает 1 итог
up, _ := cli.ImportOrders(ctx)
for _, o := range batch {
    if err := up.Send(o); err != nil { break } // при ошибке Send настоящую причину даст CloseAndRecv
}
summary, err := up.CloseAndRecv()

// bidirectional: запись и чтение в разных горутинах, не вперемешку
stream, _ := cli.Watch(ctx)
go func() {
    for ev := range outbox {
        if err := stream.Send(ev); err != nil { return }
    }
    _ = stream.CloseSend() // «я больше не пишу», но продолжаю читать
}()
for {
    ev, err := stream.Recv()
    if errors.Is(err, io.EOF) { break }
    if err != nil { return err }
    apply(ev)
}
Правила, на которых спотыкаются
  • Send и Recv потокобезопасны между собой, но не в паре одинаковых. Один писатель и один читатель на поток; параллельные Send из двух горутин дают гонку и повреждённый поток.
  • io.EOF из Recv означает успех, а не ошибку: вызов завершился со статусом OK.
  • При ошибке Send нельзя доверять её тексту: реальную причину сервер положит в статус, который приедет из Recv/CloseAndRecv.
  • Дедлайн действует на весь поток целиком, а не на отдельное сообщение. Для бесконечной подписки дедлайн не ставят, а обрыв ловят keepalive-пингами и своими heartbeat-сообщениями.
  • Ретраить стриминг нечем. Встроенная политика retry в gRPC работает, только пока сервер не отправил ни одного сообщения, а дальше остаётся своя логика возобновления по курсору или по «последнему полученному id».
  • Flow control есть. Медленный читатель тормозит писателя окном HTTP/2. Это хорошо (память не растёт без предела), но Send из-за этого может заблокироваться, так что вызывай его под контекстом.
Практическое правило

Начинай с unary, он проще во всём: ретраи, дедлайны, балансировка, метрики, отладка. Переходи на server streaming, когда ответ не помещается в память или нужна подписка. Client streaming бери только для настоящей заливки данных. Bidirectional оставь на крайний случай: он делает соединение stateful, а stateful-соединение конфликтует и с балансировкой, и с деплоем.

Суть: gRPC передаёт дедлайн в заголовке grpc-timeout остатком времени, а каждый узел пересчитывает его в свой абсолютный момент, и он распространяется по всей цепочке вызовов: каждый следующий сервис получает остаток. Отмена клиента доезжает до сервера как закрытие контекста. Метаданные - это заголовки и трейлеры HTTP/2. Ошибки - фиксированный набор из 17 кодов, не совпадающий с HTTP один в один. Интерцепторы - middleware, отдельные для unary и для stream.

Дедлайн, а не таймаут - и почему это важно

Клиент передаёт не «дай мне 300 мс», а «истекает через 300 мс от сейчас». Сервер получает grpc-timeout: 300m, кладёт дедлайн в ctx, а вызывая следующий сервис, передаёт тот же ctx, то есть оставшееся время. Если A дал 300 мс, B потратил 250 на базу, то C получит 50 мс и, скорее всего, сразу вернёт DeadlineExceeded, не тратя ресурсы впустую. Так цепочка обрывается вся разом, а не каскадом наложенных друг на друга таймаутов.

// дедлайн задаёт вызывающий, а не сервер
ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
resp, err := cli.GetOrder(ctx, req)
if status.Code(err) == codes.DeadlineExceeded { /* сюда попадём ровно через 300 мс */ }

// сервер проверяет остаток и передаёт контекст дальше
func (s *server) GetOrder(ctx context.Context, req *orderv1.GetOrderRequest) (*orderv1.Order, error) {
    if dl, ok := ctx.Deadline(); ok && time.Until(dl) < 20*time.Millisecond {
        return nil, status.Error(codes.DeadlineExceeded, "not enough time left") // fail fast
    }
    o, err := s.repo.Get(ctx, req.GetId()) // ctx уходит в database/sql, запрос отменится сам
    ...
}
Три классические ошибки
  • Нет дедлайна вообще. Без него вызов ждёт обрыва соединения, а если сервер завис при живом соединении - бесконечно. Один зависший сервис затыкает пул горутин у всех вызывающих. Дедлайн нужен на каждом исходящем вызове, дефолтный удобно навесить интерцептором.
  • context.Background() внутри хендлера. Цепочка рвётся: отмена и дедлайн клиента дальше не доедут. Оправдано это только для фоновой работы, которая обязана пережить запрос, и ей нужен новый контекст со своим таймаутом (context.WithoutCancel в Go 1.21+ сохраняет значения, но снимает отмену).
  • Дедлайн больше, чем у вызывающего. Бессмысленно: клиент уже уйдёт, а сервер продолжит жечь ресурсы. Вниз по цепочке дедлайн только уменьшается.

Отмена работает симметрично: если клиент вызвал cancel() или ушёл, gRPC шлёт RST_STREAM, а сервер видит ctx.Done() и context.Canceled. Отсюда разница в кодах: Canceled - отменил клиент, DeadlineExceeded - вышло время. Первое обычно не считают ошибкой сервиса и не алертят по нему, а второе считают.

Метаданные

// клиент кладёт метаданные в исходящий контекст
ctx = metadata.AppendToOutgoingContext(ctx,
    "authorization", "Bearer "+token,
    "x-request-id", reqID,
    "x-trace-bin", string(traceBytes)) // суффикс -bin => значение бинарное, поедет в base64

// сервер читает входящие
md, ok := metadata.FromIncomingContext(ctx)
vals := md.Get("x-request-id")

// заголовки (до тела) и трейлеры (после тела)
var header, trailer metadata.MD
resp, err := cli.GetOrder(ctx, req, grpc.Header(&header), grpc.Trailer(&trailer))

Имена ключей приводятся к нижнему регистру, ключи с суффиксом -bin несут бинарь и кодируются base64, а префикс grpc- зарезервирован. Через метаданные передают аутентификацию, трассировку, идемпотентность, версию клиента и локаль, то есть всё сквозное, чему не место в теле сообщения.

Коды ошибок и их маппинг на HTTP

КодСмыслHTTP-аналогРетраить?
OK (0)успех200-
CANCELLED (1)отменил клиент499нет
UNKNOWN (2)необёрнутая ошибка (паника без recovery роняет процесс)500нет
INVALID_ARGUMENT (3)запрос неверен сам по себе400нет
DEADLINE_EXCEEDED (4)вышло время504осторожно
NOT_FOUND (5)нет сущности404нет
ALREADY_EXISTS (6)конфликт создания409нет
PERMISSION_DENIED (7)аутентифицирован, но нельзя403нет
RESOURCE_EXHAUSTED (8)квота, лимит, слишком большое сообщение429да, с backoff
FAILED_PRECONDITION (9)состояние системы не позволяет400/409нет
ABORTED (10)конфликт транзакции, гонка409да, на верхнем уровне
OUT_OF_RANGE (11)вышли за границу диапазона400нет
UNIMPLEMENTED (12)метода нет501нет
INTERNAL (13)сломались мы500нет
UNAVAILABLE (14)сервис недоступен сейчас503да, основной ретраибельный
DATA_LOSS (15)потеря или порча данных500нет
UNAUTHENTICATED (16)нет или невалиден токен401нет
Тонкости с кодами ошибок
  • Маппинг не биективен. HTTP-кодов ~60, gRPC-кодов 17. Через grpc-gateway FailedPrecondition и InvalidArgument оба уезжают в 400, а обратно из 400 однозначно не восстановить.
  • Три разных «нельзя». InvalidArgument означает, что запрос плох независимо от состояния системы. FailedPrecondition - запрос нормальный, но система не в том состоянии (удалить непустую папку). OutOfRange - частный случай: запрос может стать валидным позже (чтение за концом файла).
  • Богатые ошибки. Кроме кода и текста есть google.rpc.Status.details, куда кладут типизированные детали: BadRequest.FieldViolation с полем и причиной, RetryInfo с рекомендованной задержкой, QuotaFailure. В Go это status.New(code, msg).WithDetails(…).
  • Не светить внутренности. err.Error() из базы, отданный наружу как Internal, выдаёт имена таблиц и запросы. Клиенту отдают общий текст, а полную ошибку с request-id пишут в лог.

Интерцепторы

Это middleware gRPC, и их два независимых семейства: unary и stream. Одну и ту же функцию на оба не навесишь. Об этом регулярно забывают, и стриминговые методы остаются без логов, метрик и recovery.

// unary-интерцептор: логи + метрики + recovery
func unaryObs(l *slog.Logger) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo,
                handler grpc.UnaryHandler) (resp any, err error) {
        start := time.Now()
        defer func() {
            if r := recover(); r != nil {          // паника не должна ронять весь сервер
                l.Error("panic", "method", info.FullMethod, "panic", r)
                err = status.Error(codes.Internal, "internal error")
            }
            code := status.Code(err)
            rpcDuration.WithLabelValues(info.FullMethod, code.String()).Observe(time.Since(start).Seconds())
            l.Info("rpc", "method", info.FullMethod, "code", code, "dur", time.Since(start))
        }()
        return handler(ctx, req)
    }
}

// stream-интерцептор: чтобы подменить ctx, оборачивают ServerStream
type wrappedStream struct {
    grpc.ServerStream
    ctx context.Context
}
func (w *wrappedStream) Context() context.Context { return w.ctx }

func streamAuth(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, h grpc.StreamHandler) error {
    ctx, err := authorize(ss.Context())
    if err != nil { return err }
    return h(srv, &wrappedStream{ServerStream: ss, ctx: ctx})
}

s := grpc.NewServer(
    grpc.ChainUnaryInterceptor(unaryObs(log), auth, validate, ratelimit),  // порядок = порядок выполнения
    grpc.ChainStreamInterceptor(streamObs(log), streamAuth),
)

Типовой набор в проде: recovery (самый внешний), логирование и трассировка, метрики, аутентификация и авторизация, валидация запроса (protoc-gen-validate), rate limiting, проброс request-id. На клиенте интерцепторами вешают дефолтный дедлайн, ретраи, circuit breaker и подстановку токена. ChainUnaryInterceptor вызывает их по порядку, снаружи внутрь, поэтому recovery и логирование идут первыми, а бизнес-валидация последней.

Суть: граница проходит по потребителю, а не по производительности. Свои сервисы, которые ты деплоишь и обновляешь сам, - gRPC: контракт, кодогенерация, стриминг, дедлайны. Чужие клиенты, которых ты не контролируешь (браузер, партнёры, публичное API), - REST: открываемость, кэш, любой инструмент, нулевой порог входа. Часто верно и то и другое одновременно: gRPC внутри, REST на границе через шлюз.
КритерийgRPCREST
Кто потребительсвои сервисы, мобильные клиенты с вашим SDKбраузер, партнёры, публичное API
Контрактобязателен и не может протухнутьопциональный OpenAPI
Открываемостьнужен grpcurl/Postman и рефлексияcurl и браузер
Кэш на посредникахнетда, бесплатно
Стримингвстроен, четыре видаSSE или WebSocket отдельно
Балансировка через L4ломается, нужен L7 или client-sideработает
Отладка «глазами»бинарь, нужен инструментчитается в логах и в devtools
Порог входа для новой командыprotoc/buf, генерация, версия пакетанулевой
Дедлайны и отмена сквозь цепочкуиз коробкируками через контекст и заголовки
Через корпоративные прокси и WAFиногда проблемы с HTTP/2 и трейлерамипроходит везде

Гибрид, который встречается чаще всего

Внутри ходит gRPC, а на границе стоит шлюз, который выставляет наружу REST/JSON: grpc-gateway генерирует обратный прокси прямо из .proto с аннотациями google.api.http, так что и REST-контракт, и OpenAPI-спека берутся из того же источника истины. Браузеру отдают gRPC-Web через Envoy. Схема одна, интерфейсов два, и руками их синхронизировать не надо.

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (Order) {
    option (google.api.http) = { get: "/v1/orders/{id}" };   // и REST, и gRPC из одного описания
  }
  rpc CreateOrder(CreateOrderRequest) returns (Order) {
    option (google.api.http) = { post: "/v1/orders", body: "*" };
  }
}
Аргументы, которые стоит назвать вслух
  • За gRPC: контракт не расходится с кодом; buf breaking ловит ломающие изменения в CI; клиенты генерируются для всех языков; дедлайны и отмена проходят сквозь всю цепочку; стриминг без костылей; трафик в разы меньше.
  • Против gRPC: из браузера напрямую не вызвать; ломается о L4-балансировщики; бинарь не почитать в логах и не подебажить curl-ом; нужна инфраструктура генерации; в мониторинге всё выглядит как HTTP 200; для команды без опыта это заметный организационный оверхед.
  • За REST: нулевой порог входа, кэш и CDN бесплатно, его понимает любой инструмент, ретраи и идемпотентность заданы протоколом, отлично живёт за WAF и корпоративным прокси.
  • Против REST: контракт опционален и протухает; «правильные» коды и формы ошибок держатся на дисциплине; JSON дороже и по CPU, и по трафику; стриминг требует отдельной технологии.
Готовая формулировка

«Наружу - REST, потому что клиентов мы не контролируем, а ошибка сломает чужой код. Внутрь - gRPC, потому что там мы контролируем обе стороны и выигрываем от контракта, генерации и дедлайнов. Если внутри всего три сервиса и один язык, я бы gRPC не тащил: JSON по HTTP плюс общий пакет с типами дешевле в поддержке, а разницу в скорости мы всё равно не заметим.» Последняя фраза важнее первых двух: по ней видно, что ты выбираешь по контексту, а не по моде.

Суть: WebSocket - отдельный протокол (RFC 6455) поверх того же TCP, в который переходят из HTTP одним рукопожатием Upgrade и ответом 101 Switching Protocols. После этого HTTP закончился: идут двусторонние кадры с оверхедом 2–14 байт, без заголовков, без кэша, без ретраев. SSE - поток только сервер→клиент по обычному HTTP. Long polling - эмуляция push циклом запросов.

Рукопожатие

GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==     <- случайные 16 байт в base64
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat.v1                 <- согласование субпротокола
Origin: https://app.example.com                 <- проверять обязательно!

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat.v1

Sec-WebSocket-Accept вычисляется как base64(SHA1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")). Это не безопасность, а защита от дурака: так клиент проверяет, что на том конце WebSocket-сервер, а не кэширующий прокси, который решил, что понял запрос.

Кадры

  • Заголовок кадра: FIN-бит (последний фрагмент), 4 бита opcode (0x1 текст, 0x2 бинарь, 0x8 close, 0x9 ping, 0xA pong), MASK-бит, длина (7, 7+16 или 7+64 бита). Итого 2–14 байт вместо сотен байт HTTP-заголовков.
  • Клиент обязан маскировать данные 4-байтовым ключом (сервер - нет). Это тоже не про шифрование: маскирование ломает атаки на промежуточные прокси, которые могли бы принять содержимое кадра за начало нового HTTP-запроса (cache poisoning).
  • Ping/pong - служебные кадры для keepalive, и они обязательны: без трафика NAT и балансировщики рвут «мёртвые» соединения через 30–120 секунд, а клиент узнаёт об этом только при попытке записи. Пингуют обычно раз в 20–30 секунд.
  • Сообщение можно фрагментировать на несколько кадров: так стримят большие полезные нагрузки.

Чем отличается от HTTP

HTTPWebSocket
Модельзапрос-ответ, инициатор всегда клиентполный дуплекс, инициировать может любой
Состояниеstatelessstateful: соединение и есть сессия
Оверхед сообщениясотни байт заголовков2–14 байт
Кэш, ретраи, кодыиз протоколаничего, пишешь сам
Авторизациязаголовок на каждый запростолько при рукопожатии; дальше - свой протокол
Балансировкапо запросампо соединениям, со всеми последствиями
Масштабированиелюбой инстанс обслужит любой запроснужен общий слой (Redis Pub/Sub, NATS) для рассылки между инстансами

Когда что брать

  • WebSocket нужен для обмена в обе стороны с низкой задержкой: чат, совместное редактирование, игры, торговый терминал, интерактивные дашборды с командами.
  • Для потока только вниз хватает SSE: лента событий, уведомления, прогресс длинной задачи, потоковая выдача текста LLM, живые метрики. Он дешевле, работает через любой прокси, а переподключение и Last-Event-ID встроены в браузерный EventSource. Историческое ограничение в 6 соединений на домен в HTTP/1.1 снимает мультиплексирование HTTP/2.
  • Long polling остался ради совместимости с древними клиентами и сетями, где ничего другого не проходит.
  • gRPC-стриминг - то же самое между сервисами, но с контрактом, дедлайнами, кодами ошибок и flow control.
  • Обычный polling раз в 5–30 секунд подойдёт, если событий мало и задержка терпима. Дешевле в эксплуатации ничего нет, и вспомнить о нём - признак опыта.
// SSE в Go делается обычным долгим обработчиком, без библиотек
func stream(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.Header().Set("X-Accel-Buffering", "no") // иначе nginx буферизует и «реалтайма» не будет

    rc := http.NewResponseController(w) // Go 1.20+: доступ к Flush и дедлайнам записи
    events := subscribe(r.Context(), r.Header.Get("Last-Event-ID"))
    heartbeat := time.NewTicker(20 * time.Second)
    defer heartbeat.Stop()

    for {
        select {
        case <-r.Context().Done(): // клиент отвалился
            return
        case <-heartbeat.C:
            fmt.Fprint(w, ": ping\n\n") // комментарий-пульс, чтобы прокси не закрыл соединение
            _ = rc.Flush()
        case ev := <-events:
            fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, ev.JSON)
            _ = rc.Flush() // без Flush данные осядут в буфере и никуда не поедут
        }
    }
}
Что ломается в проде с WebSocket
  • Нет пингов. Соединения молча умирают в NAT и на балансировщике, а клиент «висит» и не переподключается.
  • Нет реконнекта с backoff - при рестарте сервера все клиенты ломятся обратно одновременно и кладут его снова. Нужен экспоненциальный backoff с джиттером.
  • Авторизация только на рукопожатии. Токен истёк через час, а соединение живёт дальше. Проверку надо повторять своим служебным сообщением или ограничивать время жизни соединения. Вдобавок браузерный WebSocket не умеет ставить заголовок Authorization, поэтому токен тащат в query (он утечёт в логи), в куку или шлют первым сообщением после соединения. Последнее безопаснее.
  • Нет проверки Origin. Получаем Cross-Site WebSocket Hijacking: на WebSocket не действует Same-Origin Policy, и куки уйдут автоматически.
  • Масштабирование. Пользователь на поде A, а событие для него сгенерировал под B. Нужна общая шина: Redis Pub/Sub, NATS, Kafka. Сюда же липкость соединений и деплой, который рвёт все соединения разом.
  • Память. На соединение уходят две горутины и два буфера; 100 тысяч соединений уже требуют отдельного расчёта и, возможно, gobwas/ws с epoll вместо gorilla/websocket с горутиной на соединение.
Суть: выбор формата - это выбор между читаемостью и ценой (размер, CPU) при наличии или отсутствии схемы. JSON - универсальный компромисс для границы системы; protobuf - когда важны размер, скорость и контракт; gob - только Go↔Go; XML - легаси и строгие интеграции; YAML - конфиги и ничего больше. В Go главные грабли - omitempty, который не работает так, как ожидают, для time.Time, структур и «осмысленных нулей».

Сравнение по осям

ФорматСхемаРазмерСкоростьЧеловекочитаемКросс-язычностьНиша
JSONвнешняя, опциональная1×средняядаполнаяпубличные API, логи, конфиги
Protobufобязательная0.2–0.4×высокаянетполнаямежсервисное, события, мобилки
MessagePackнет0.8–0.95×высокаянетполнаякэш, Redis, «JSON, но меньше»
gobсамоописывающаясяпервое 1.1–1.3×, дальше 0.2–0.4×высокаянеттолько GoGo↔Go, снапшоты, net/rpc
XMLXSD, строгая1.3–2×низкаяусловнополнаяSOAP, госинтеграции, подписанные документы
YAMLвнешняя-низкаялучшаяполнаятолько конфиги
Avroобязательная, отдельно0.2–0.4×высокаянетполнаяKafka + Schema Registry, аналитика

Голосом стоит добавить, что у XML есть то, чего нет у JSON: атрибуты, пространства имён, XSD-валидация, XSLT и, главное, XML Signature, подпись части документа. Поэтому он и жив в банках и госсистемах, а вовсе не «потому что не переписали». У YAML же есть «норвежская проблема» (country: NO → false в YAML 1.1) и якоря, из которых собирают YAML-бомбу. Это ещё один довод не брать его форматом обмена.

encoding/json: теги и правила

type Order struct {
    ID        int64      `json:"id"`
    Customer  string     `json:"customer,omitempty"`
    Amount    int64      `json:"amount"`                 // копейки: float для денег не годится
    Note      *string    `json:"note,omitempty"`         // указатель: отличаем "" от отсутствия
    CreatedAt time.Time  `json:"created_at"`
    DeletedAt *time.Time `json:"deleted_at,omitempty"`   // только указатель даёт настоящий omitempty
    Internal  string     `json:"-"`                      // никогда не сериализуется
    Weird     string     `json:"-,"`                     // а это поле с именем "-"
    BigID     int64      `json:"big_id,string"`          // число как строка: спасает JS от потери точности
    Tags      []string   `json:"tags,omitempty"`         // nil и пустой срез оба исчезнут
    Meta      map[string]any `json:"meta,omitempty"`
}
  • Сериализуются только экспортируемые поля. Поле с маленькой буквы молча исчезнет, отсюда классическое «почему у меня в JSON пусто».
  • При разборе имена сопоставляются без учёта регистра, если точного совпадения нет: {"ID":1} попадёт в поле с тегом json:"id".
  • Встроенная (embedded) структура «разворачивается» в родителя, если у неё нет своего имени в теге.
  • []byte кодируется в base64. time.Time - в RFC 3339. Ключи map сортируются, поэтому вывод детерминирован (в отличие от protobuf).
  • json.Marshal экранирует <, >, & в \u003c, \u003e, \u0026. Отключается только через Encoder.SetEscapeHTML(false).
Четыре ловушки omitempty
  • «Пусто» означает конкретный список: false, 0, "", nil-указатель/интерфейс и пустые массив, срез, map, строка. И всё.
  • time.Time - структура, а структура никогда не «пустая». CreatedAt time.Time `json:",omitempty"` при нулевом значении честно выведет "0001-01-01T00:00:00Z". Помогает только *time.Time или, с Go 1.24, omitzero.
  • bool и осмысленный ноль. IsActive bool с omitempty исчезнет при false, хотя false обычно и есть значимая информация. То же с Discount int: «скидка 0» и «скидки не было» на приёмной стороне неразличимы. omitempty уместен для необязательных полей, а поле со значимым нулём оставляют без тега или делают указателем: omitzero выкинет false и 0 точно так же.
  • Пустая структура не убирается: Address struct{...} с omitempty выведет объект с нулевыми полями, например {"city":""}. Убирает его указатель или omitzero.
// Go 1.24: omitzero, каким omitempty должен был быть с самого начала
type Event struct {
    At      time.Time `json:"at,omitzero"`     // нулевое время исчезнет, этого ждали годами
    Retries int       `json:"retries,omitzero"`// а вот значимый 0 придётся оставить без тега
    Addr    Address   `json:"addr,omitzero"`   // пустая структура тоже исчезнет
}
// omitzero проверяет «нулевое значение типа» (и уважает метод IsZero() bool),
// omitempty проверяет «пустое» по своему списку. Их можно комбинировать в одном теге.

json.RawMessage и отложенный разбор

// когда часть документа надо разобрать позже или пробросить как есть
type Envelope struct {
    Type    string          `json:"type"`
    Payload json.RawMessage `json:"payload"` // не разбираем, пока не знаем тип
}

var e Envelope
if err := json.Unmarshal(data, &e); err != nil { return err }
switch e.Type {
case "order.created":
    var o OrderCreated
    if err := json.Unmarshal(e.Payload, &o); err != nil { return err }
    handle(o)
}

Тот же приём используют, чтобы не потерять неизвестные поля при проксировании: разобрал что нужно, остальное протащил байтами. json.RawMessage всего лишь []byte с методами MarshalJSON/UnmarshalJSON. При разборе байты копируются как есть, а при Marshal содержимое уплотняется и экранируется (< станет \u003c), так что байт в байт он данные не протащит.

Кастомные MarshalJSON и UnmarshalJSON

// Задача: наружу отдавать длительность как "1500ms", а не как 1500000000 наносекунд
type Duration struct {
    time.Duration
}

func (d Duration) MarshalJSON() ([]byte, error) {          // приёмник-значение
    return json.Marshal(d.String())
}

func (d *Duration) UnmarshalJSON(b []byte) error {          // обязательно указатель
    var s string
    if err := json.Unmarshal(b, &s); err != nil { return err }
    v, err := time.ParseDuration(s)
    if err != nil { return fmt.Errorf("duration %q: %w", s, err) }
    d.Duration = v
    return nil
}

// Приём «alias»: добавить поле без бесконечной рекурсии
type Order struct {
    ID     int64     `json:"id"`
    Status Status    `json:"status"`
}

func (o Order) MarshalJSON() ([]byte, error) {
    type alias Order          // новый тип без методов: рекурсии не будет
    return json.Marshal(struct {
        alias
        StatusText string `json:"status_text"`
    }{alias(o), o.Status.String()})
}
Грабли кастомных методов
  • Рекурсия. json.Marshal(o) внутри o.MarshalJSON() уходит в бесконечный цикл и переполняет стек. Спасает новый тип на той же основе (type alias Order - это не алиас, а отдельный тип): методов он не наследует.
  • Приёмник. MarshalJSON на значении работает и для значений, и для указателей; UnmarshalJSON обязан быть на указателе, иначе он вызовется на копии, и поле молча останется нулевым.
  • Есть варианты дешевле. Строкоподобным типам достаточно encoding.TextMarshaler/TextUnmarshaler: их уважают и JSON, и YAML, и ключи map. Для «того же, но с одним лишним полем» часто хватает встраивания и тегов.

Decoder против Unmarshal и неизвестные поля

// Unmarshal: весь документ уже в памяти
var o Order
err := json.Unmarshal(body, &o)

// Decoder читает из io.Reader: тело целиком не держим, умеем поток
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) // лимит от «бомбы» на 2 ГБ
dec.DisallowUnknownFields()                            // строгий разбор: лишнее поле = ошибка
dec.UseNumber()                                        // числа как json.Number, а не float64

if err := dec.Decode(&o); err != nil {
    var syn *json.SyntaxError
    var typ *json.UnmarshalTypeError
    switch {
    case errors.As(err, &syn):  // битый JSON
    case errors.As(err, &typ):  // "amount": "abc" при int64 - можно назвать поле в ответе
    }
    return err
}
// поток JSON-объектов подряд (ndjson): Decode в цикле до io.EOF
for dec.More() { ... }
Что спрашивают дальше
  • Неизвестные поля по умолчанию игнорируются, на этом и держится обратная совместимость JSON-API. DisallowUnknownFields включают на конфигах (лучше упасть на опечатке в имени параметра), а на публичном API обычно нет, иначе новое необязательное поле у клиента сломает тебе приём.
  • Числа по умолчанию становятся float64 при разборе в interface{}. Int64 больше 253 теряет точность: id 9007199254740993 приезжает изменённым. Лечится UseNumber() или тегом ,string.
  • Decoder не требует, чтобы документ кончился. Decode прочитает первое значение и остановится: {"a":1}{"b":2} не будет ошибкой. Для строгости после разбора проверь, что следующий dec.Token() вернул io.EOF: More() хвостовые ] и } не замечает.
  • Unmarshal не обнуляет приёмник. Разбор в уже заполненную структуру дополняет её: поля, которых нет в JSON, сохранят старые значения. Для срезов и map поведение своё и неинтуитивное, поэтому разбирают в свежую переменную.
  • Имена полей сопоставляются без учёта регистра, если точного совпадения нет. Так ведёт себя старый пакет, в encoding/json/v2 это изменилось (см. ниже).
  • Производительность. Старый encoding/json ходит по структуре рефлексией; на горячем пути её заменяли кодогенерацией (easyjson, ffjson) или быстрыми библиотеками (jsoniter, goccy/go-json, sonic) и выигрывали в разы. С Go 1.27 заметную часть разрыва закрыла сама стандартная библиотека, см. следующий блок про encoding/json/v2.

encoding/json/v2 - с Go 1.27 это уже не эксперимент

В Go 1.25 encoding/json/v2 был экспериментом и включался переменной окружения GOEXPERIMENT=jsonv2. В Go 1.27 он включён по умолчанию безо всяких настроек, а выключает его обратная переменная GOEXPERIMENT=nojsonv2. Пакетов теперь два, и граница между ними чёткая:

  • encoding/json/v2 отвечает за семантику: как Go-значение соответствует JSON. Теги, omitzero, кастомные маршалеры, опции разбора.
  • encoding/json/jsontext - синтаксис: как JSON выглядит текстом. Токены (Token), «сырое» значение (Value), потоковые Encoder/Decoder, отступы. Раньше всё это было слеплено в одном пакете, и половину настроек нельзя было выразить.

Паниковать не надо: старый encoding/json никуда не делся и поведения не поменял. Его переписали поверх v2, но наблюдаемую семантику намеренно сохранили, иначе сломался бы весь существующий код в мире. Строгость v2 приезжает ровно тогда, когда ты сам меняешь импорт. Зато Unmarshal стал заметно быстрее, а API - по-настоящему потоковым: MarshalWrite/UnmarshalRead работают с io.Writer/io.Reader, MarshalEncode/UnmarshalDecode - с jsontext.Encoder/Decoder.

import (
    "encoding/json/v2"        // пакет называется json, импортируется без переименования
    "encoding/json/jsontext"
)

// синтаксическую опцию (отступ) и семантическую передают одинаково, списком Options
b, err := json.Marshal(v, jsontext.WithIndent("  "))

// строгий разбор конфига: неизвестное поле = ошибка (замена DisallowUnknownFields)
err = json.Unmarshal(data, &cfg, json.RejectUnknownMembers(true))

// поток без промежуточного буфера
err = json.MarshalWrite(w, v)
err = json.UnmarshalRead(io.LimitReader(r.Body, 1<<20), &req)
Что ломается при переходе на v2 (проверено на go1.27)

v2 строже, причём ощутимо: он отвергает документы, которые старый пакет годами молча глотал. На этом миграции и спотыкаются.

// 1. Дублирующиеся ключи объекта
json.Unmarshal([]byte(`{"a":1,"a":2}`), &v)
// v2 -> ошибка: jsontext: duplicate object member name "a"
// v1 -> nil, молча побеждало последнее значение

// 2. Невалидный UTF-8 (и на чтении, и на записи)
json.Unmarshal([]byte("{\"b\":\"\xff\xfe\"}"), &v)
// v2 -> ошибка: jsontext: invalid UTF-8 within "/b" after offset 6
// v1 -> nil, битые байты тихо заменялись на U+FFFD

// 3. Регистр имён: v2 по умолчанию строгий
json.Unmarshal([]byte(`{"ID":7}`), &v)   // при теге `json:"id"`
// v2 -> поле осталось нулевым, ошибки нет
// v1 -> поле заполнено: при промахе он пробовал сопоставить без учёта регистра
  • Дубли ключей прилетают оттуда, где JSON собирают конкатенацией, шаблоном или склейкой двух объектов. С encoding/json это работает «как повезёт» (побеждает последнее значение), а после перехода на v2 клиент получит 400.
  • Битый UTF-8 появляется, когда в строковое поле попадает кусок бинарных данных, обрезанная по байтам строка или текст в чужой кодировке.
  • С регистром ломается тише всего: ошибки нет, поле просто остаётся нулевым. Старое поведение возвращает опция json.MatchCaseInsensitiveNames(true) или тег `json:"id,case:ignore"`.
  • omitempty в v2 значит другое. В v1 он определён через Go-значения (0, false, "", nil, пустые срез/map). В v2 - через JSON: поле опускается, если записалось бы как null, пустая строка, пустой объект или пустой массив. То есть 0 и false в v2 остаются в выводе, а в v1 исчезали. Вывод меняется без всякой ошибки, и такое ловится хуже всего.
  • Опции тега подчистили: format и unknown из ранних черновиков v2 убраны, inline переименован в embed.
Как отвечать про это на собесе

«С Go 1.27 encoding/json/v2 в стандартной библиотеке по умолчанию, а encoding/json - совместимая обёртка над ним, так что само обновление Go ничего не ломает. Ломает переезд на v2: он отвергает дубли ключей и невалидный UTF-8, по умолчанию сравнивает имена с учётом регистра, а omitempty у него определён через JSON, а не через Go: 0 и false больше не исчезают. Поэтому в новом коде я сразу пишу omitzero, а миграцию делаю сервис за сервисом с прогоном реального трафика».

Что стоит запомнить

  • REST моделирует ресурсы и их состояния; RPC моделирует удалённый вызов операции.
  • gRPC использует HTTP/2 и protobuf, а streaming вызовы имеют явные схемы направления потока.
  • Обычный L4 балансировщик распределяет соединения; многие RPC на одном HTTP/2 connection могут оставаться на одном backend.

Частые ошибки

  • WebSocket начинается с HTTP Upgrade, после чего данные идут по WebSocket frames, а не как обычные HTTP запросы.
  • Не называй gRPC автоматически быстрее: учитывай connection reuse, сериализацию, размер сообщений и клиентскую инфраструктуру.
  • Долгоживущая gRPC связь может пережить обновление DNS и остаться привязанной к старому backend.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Разбери дедлайн gRPC

Клиентский вызов ниже имеет deadline 300 ms. Если серверная обработка превысит бюджет и контекст передаётся дальше, какой статус ожидает клиент и что должен сделать server handler при отмене?

ctx, cancel := context.WithTimeout(parent, 300*time.Millisecond)
defer cancel()
_, err := cli.GetOrder(ctx, req)
Ответ и разбор

Клиент обычно получит DeadlineExceeded, когда бюджет истечёт. Серверу нужно передавать входящий ctx в DB/outbound calls и завершать работу при ctx.Done(), чтобы не продолжать нагрузку после ухода клиента. Сетевая отмена может прийти с задержкой, поэтому критичный side effect требует идемпотентности.

Код-ревью

2. Почему L4 балансировка не распределила RPC

Клиент создал один grpc.ClientConn и выполняет RPC в цикле. Будет ли L4 распределять каждый вызов независимо?

conn, _ := grpc.NewClient(target, creds)
cli := orderv1.NewOrderServiceClient(conn)
for range 100 { _, _ = cli.GetOrder(ctx, req) }
Ответ и разбор

L4 выбирает backend при установлении TCP-соединения и не видит отдельные HTTP/2 streams как независимые соединения. Используй client-side load balancing/resolver, L7 proxy, либо периодически обновляй connections через MaxConnectionAge/GOAWAY с graceful grace. Учитывай повтор подключения, health state и безопасное завершение активных RPC.

Применение

3. Выбери SSE, WebSocket или gRPC stream

Браузеру нужно получать серверные уведомления с возможностью переподключения и Last-Event-ID. Выбери из SSE, WebSocket и gRPC stream и назови важное условие для прокси.

Ответ и разбор

SSE естественно подходит для однонаправленного server-to-browser потока поверх HTTP и поддерживает Last-Event-ID для возобновления. Отправляй события с id, храни/восстанавливай после этого id и отключи буферизацию в proxy, иначе события задержатся. Для двустороннего обмена рассмотри WebSocket.

Применение

4. Подбери контракт для пакетного импорта

Клиент отправляет тысячи заказов, сервер отвечает одной сводкой об успешных и ошибочных строках. Какой вид gRPC вызова соответствует обмену и почему?

Ответ и разбор

Client-streaming RPC: клиент отправляет много Order сообщений, затем закрывает отправку, а сервер возвращает один summary через CloseAndRecv. Ошибки Send сами по себе могут не дать окончательную причину, поэтому клиент должен обработать результат завершения. Для очень больших передач добавь лимиты и обратное давление.