9.4REST, RPC, gRPC
Сравни REST, RPC, gRPC и WebSocket как модели контракта и обмена данными. Разбери protobuf и балансировку долгоживущего HTTP/2 connection, а затем выбери transport по направлению потока, сроку жизни и требованиям к промежуточной инфраструктуре.
REST: что это на самом деле
REST не формат, не «JSON по HTTP» и не «CRUD с красивыми URL». Это архитектурный стиль из диссертации Роя Филдинга (2000), одного из авторов HTTP. Филдинг описывал не «как делать API», а почему веб как система масштабируется: как миллиарды клиентов, кэшей и прокси работают вместе, не зная друг о друге. REST сводится к набору ограничений, и масштабируемость следует из них сама.
Шесть ограничений
- Клиент-сервер. Разделение ответственности: интерфейс отдельно, хранение отдельно, и стороны развиваются независимо.
- Stateless. Каждый запрос самодостаточен: между запросами сервер ничего не помнит о клиенте, всё нужное едет в самом запросе (токен, параметры, версия). Отсюда горизонтальное масштабирование: любой запрос обработает любой инстанс, а падение узла не рвёт «сессию».
- Кэшируемость. Ответ обязан явно говорить, кэшируемый он или нет. Поэтому кэши и CDN можно вставить между клиентом и сервером, не меняя ни того, ни другого.
- Единообразный интерфейс считается центральным ограничением и распадается на четыре:
идентификация ресурсов через URI; манипуляция ресурсами через представления;
самоописывающиеся сообщения (метод,
Content-Type, кэш-заголовки); HATEOAS. - Слоистая система. Клиент не знает, разговаривает он с сервером напрямую или через три прокси, CDN и балансировщик. Именно поэтому промежуточные узлы вообще возможны.
- 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, этого названия не заслуживает.
Строго говоря, 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 мыслит глаголами: есть удалённая процедура с именем и аргументами, а сколько таких процедур и как они называются, решает автор сервиса.
| REST | RPC (в т.ч. 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, не ломая компиляцию существующих серверов.
Четыре вида вызовов
Protobuf на проводе
Главная идея формата: имён полей на проводе нет. Есть номера. Сообщение устроено как последовательность пар «ключ, значение», где ключ кодирует номер поля и wire type (как читать значение). Парсер, встретив незнакомый номер, знает по wire type, сколько байт пропустить. Отсюда почти бесплатная прямая и обратная совместимость.
Wire type («тип на проводе») не
совпадает с типом из .proto: он отвечает на единственный вопрос «как найти конец
значения». Вариантов всего шесть, поэтому wire type влезает в три бита ключа. Varint
(variable-length integer, «целое переменной длины») записывает число так, чтобы мелкие
числа занимали мало места: в каждом байте 7 полезных бит, а старший, восьмой, бит работает
флагом «дальше есть ещё байт». Число 1 умещается в один байт, 300 в два, миллиард в пять.
| Wire type | Название | Как читается | Типы |
|---|---|---|---|
0 | VARINT | байты, пока старший бит = 1 | int32/64, uint32/64, sint32/64, bool, enum |
1 | I64 | ровно 8 байт | fixed64, sfixed64, double |
2 | LEN | varint длины, потом столько байт | string, bytes, вложенные сообщения, packed-массивы |
3/4 | SGROUP/EGROUP | группы: устарели, но встречаются в proto2 и в editions (DELIMITED) | - |
5 | I32 | ровно 4 байта | fixed32, sfixed32, float |
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 совпадение исчезло.
Четыре способа починить
- 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 | SSE | WebSocket | |
|---|---|---|---|
| Направление | сервер→клиент (эмуляция) | только сервер→клиент | обе стороны |
| Протокол | обычный HTTP | обычный HTTP, text/event-stream | ws:///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 внутри одной кодовой базы, снапшоты на диск |
| XML | XSD, очень строгая | ×1.3–2 от JSON | низкая | условно | SOAP, госинтеграции, банки, документооборот с подписью |
| YAML | внешняя | - | низкая | лучшая | только конфиги; для трафика не используют |
| Avro | обязательная, схема едет отдельно | ×0.2–0.4 | высокая | нет | Kafka + Schema Registry, аналитические пайплайны, Hadoop |
- Только 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Шесть ограничений и что каждое даёт
| Ограничение | Что запрещает | Что за это получаешь |
|---|---|---|
| Клиент-сервер | смешивать UI и хранение | независимую эволюцию сторон |
| Stateless | помнить клиента между запросами | горизонтальное масштабирование, простые ретраи |
| Кэшируемость | молчать про кэшируемость ответа | CDN и прокси между клиентом и сервером |
| Единообразный интерфейс | свои глаголы и свои коды | любой посредник понимает семантику |
| Слоистость | клиенту знать, кто на том конце | прокси, шлюзы, балансировщики, mesh |
| Code on demand (опц.) | - | исполняемый код от сервера (JS в браузере) |
Stateless - что это значит на практике
Это не значит «состояния нет вообще»: данные в базе никуда не деваются. Речь про состояние сессии клиента на конкретном инстансе. Всё нужное запрос обязан нести сам: токен вместо «сервер помнит, что ты залогинен», курсор пагинации вместо «сервер помнит, где ты остановился». Взамен любой инстанс обрабатывает любой запрос, sticky session не нужен, падение узла не рвёт работу пользователя, ретрай безопасен, а автоскейл честный.
Строго говоря, да. Серверная сессия по 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: состояние ресурса
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 половина его преимуществ исчезает.
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.
Балансировка: где именно ломается
- Клиент открывает одно TCP-соединение. L4-балансировщик выбирает бэкенд по 5-кортежу (src IP, src port, dst IP, dst port, протокол) и делает это один раз.
- gRPC мультиплексирует в этом соединении сотни потоков. Все они, естественно, идут на выбранный бэкенд.
- Соединение живёт часами: gRPC его не рвёт, а keepalive-пинги не дают закрыться.
- В итоге один под получает весь трафик клиента, остальные простаивают. Отскейлились с 3 подов до 30, а нагрузка не перераспределилась: никто не переподключался.
- CPU на одном поде 90%, на остальных 5%, а HPA считает среднее и поэтому не скейлит.
- После деплоя новые поды стоят пустые, пока старые не убьют.
- Задержка не падает от добавления реплик.
- В Kubernetes через обычный ClusterIP так будет гарантированно: kube-proxy работает на L4 (iptables, nftables или IPVS) и балансирует соединения, а не запросы.
Решения и их цена
| Способ | Как работает | Цена |
|---|---|---|
| L7-прокси | Envoy/nginx/ALB терминируют HTTP/2 и раскидывают каждый вызов | лишний хоп, ресурсы, ещё одна точка отказа |
| Client-side LB | headless service + resolver отдаёт все IP, клиент держит по соединению к каждому и делает round-robin по вызовам | логика в каждом клиенте, соединений «клиенты × поды» |
| Service mesh | sidecar делает то же самое, но вне кода приложения | сложность платформы, +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-прокси.
Терминология, на которой путаются
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-first | Code-first | |
|---|---|---|
| Источник истины | openapi.yaml в репозитории | код и аннотации над хендлерами |
| Порядок работы | согласовали спеку → сгенерировали типы и роутинг → написали логику | написали хендлеры → сгенерировали спеку → отдали потребителям |
| Параллельная работа | фронт стартует сразу с моков | фронт ждёт готовый бэкенд |
| Расхождение кода и спеки | невозможно: код из спеки | обычное дело: забыли обновить аннотацию |
| Обратная совместимость | проверяется diff-ом спеки в CI | ловится глазами на ревью |
| Скорость на старте | медленнее, спека - отдельный артефакт для ревью | быстрее, спека «сама появляется» |
| Инструменты в Go | oapi-codegen, ogen | swaggo/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 вопроса «contract-first или code-first» нет: единственный источник истины там
.proto, и code-first в Go не встречается (в .NET есть protobuf-net.Grpc, но это экзотика). Это и есть главный аргумент за
gRPC во внутренних API: контракт не может разойтись с реальностью, а
buf breaking проверяет совместимость из коробки. Верно и обратное:
contract-first в REST пытается повторить дисциплину, которую gRPC даёт по построению.
.proto,
сериализация protobuf, транспорт HTTP/2, кодогенерация клиента и сервера. Быстрее за счёт
четырёх вещей: компактный бинарный формат без имён полей, мультиплексирование в одном
долгоживущем соединении, сжатие заголовков HPACK, отсутствие рефлексии в сгенерированном коде.
Плюс встроенные дедлайны, отмена, метаданные, стриминг и интерцепторы.Что происходит при вызове
- Сгенерированный клиент сериализует запрос в protobuf.
- Открывается новый поток в уже существующем HTTP/2-соединении. Летят HEADERS:
:method: POST,:path: /order.v1.OrderService/GetOrder,content-type: application/grpc,te: trailers, дедлайн вgrpc-timeout, метаданные пользователя. - DATA-фреймы несут «length-prefixed message»: 1 байт флага сжатия + 4 байта длины big-endian + сами байты сообщения. Если сообщений несколько, это и есть стриминг.
- Ответ приходит так же, а результат вызова лежит в трейлерах после тела:
grpc-status: 0, при ошибке ещёgrpc-messageиgrpc-status-details-bin.
Ошибка 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 = 1 | int32/64, uint32/64, sint32/64, bool, enum | отрицательные int32 - всегда 10 байт |
| 1 I64 | 8 байт | fixed64, sfixed64, double | выгоден, когда числа обычно большие |
| 2 LEN | varint длины + данные | string, bytes, вложенные сообщения, packed-массивы | вложенность = просто байты внутри байтов |
| 5 I32 | 4 байта | fixed32, sfixed32, float | для хэшей и id фиксированной ширины |
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.
Как объявляются и когда какой
| Вид | В .proto | Когда брать | Чем опасен |
|---|---|---|---|
| Unary | rpc Get(Req) returns (Resp) | по умолчанию всё | ничем; ретраи и дедлайны тривиальны |
| Server streaming | rpc List(Req) returns (stream Resp) | большая выгрузка, подписка на события, прогресс долгой операции | ретрай означает «начать сначала»; нужен курсор для возобновления |
| Client streaming | rpc Upload(stream Req) returns (Resp) | загрузка файла кусками, батч метрик, агрегация на сервере | результат только в конце; при обрыве непонятно, что успело записаться |
| Bidirectional | rpc 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-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 | REST |
|---|---|---|
| Кто потребитель | свои сервисы, мобильные клиенты с вашим 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 плюс общий пакет с типами дешевле в поддержке, а разницу в скорости мы всё равно не заметим.» Последняя фраза важнее первых двух: по ней видно, что ты выбираешь по контексту, а не по моде.
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бинарь,0x8close,0x9ping,0xApong), MASK-бит, длина (7, 7+16 или 7+64 бита). Итого 2–14 байт вместо сотен байт HTTP-заголовков. - Клиент обязан маскировать данные 4-байтовым ключом (сервер - нет). Это тоже не про шифрование: маскирование ломает атаки на промежуточные прокси, которые могли бы принять содержимое кадра за начало нового HTTP-запроса (cache poisoning).
- Ping/pong - служебные кадры для keepalive, и они обязательны: без трафика NAT и балансировщики рвут «мёртвые» соединения через 30–120 секунд, а клиент узнаёт об этом только при попытке записи. Пингуют обычно раз в 20–30 секунд.
- Сообщение можно фрагментировать на несколько кадров: так стримят большие полезные нагрузки.
Чем отличается от HTTP
| HTTP | WebSocket | |
|---|---|---|
| Модель | запрос-ответ, инициатор всегда клиент | полный дуплекс, инициировать может любой |
| Состояние | stateless | stateful: соединение и есть сессия |
| Оверхед сообщения | сотни байт заголовков | 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 данные осядут в буфере и никуда не поедут
}
}
}
- Нет пингов. Соединения молча умирают в 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с горутиной на соединение.
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× | высокая | нет | только Go | Go↔Go, снапшоты, net/rpc |
| XML | XSD, строгая | 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).
- «Пусто» означает конкретный список:
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 теряет точность: id9007199254740993приезжает изменённым. Лечится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 строже, причём ощутимо: он отвергает документы, которые старый пакет годами молча глотал. На этом миграции и спотыкаются.
// 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 сами по себе могут не дать окончательную причину, поэтому клиент должен обработать результат завершения. Для очень больших передач добавь лимиты и обратное давление.