Когда один сервис зовёт другой сотни раз в секунду, накладные расходы REST начинают мешать: текстовый JSON надо сериализовать и парсить, заголовки повторяются, а контракт держится «на честном слове» — на документации, которая устаревает. gRPC решает именно эту задачу: строгий контракт, компактный бинарный формат и быстрый транспорт. Разберёмся, как он устроен и когда его стоит брать.
Разница видна уже на проводе: в JSON едут имена полей, в protobuf — их номера из контракта.
Имя поля живёт только в .proto, на провод уходит номер. Поэтому новое поле старый клиент просто пропускает, а смена номера ломает его молча — без ошибки сборки и без исключения.
Что такое gRPC
gRPC — это способ вызывать метод чужого сервиса так, будто он локальный: вы пишете orders.Get(id), а под капотом уходит сетевой запрос. Такой подход называется RPC (remote procedure call, удалённый вызов процедуры). Google сделал gRPC поверх двух вещей:
- protobuf (Protocol Buffers) — язык описания контракта и компактный бинарный формат данных;
- HTTP/2 — транспорт с мультиплексированием и постоянными соединениями (разобран в статье про версии HTTP).
В отличие от REST, где вы думаете в терминах ресурсов и URL, в gRPC вы думаете в терминах сервисов и их методов.
Контракт в protobuf
Всё начинается с .proto-файла — это единый источник правды о контракте. В нём описаны сообщения (структуры данных) и сервис с методами:
syntax = "proto3";
message GetOrderRequest {
string id = 1;
}
message Order {
string id = 1;
string status = 2;
int64 amount = 3; // в минимальных единицах, например копейках
}
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
}
Числа = 1, = 2 — это не значения, а номера полей: именно они пишутся в бинарный формат вместо имён. Поэтому protobuf компактнее JSON (нет повторяющихся имён полей) и совместим при эволюции: добавить новое поле с новым номером можно, не сломав старых клиентов.
Кодогенерация: контракт превращается в код
Из .proto инструмент генерирует классы и заглушки на нужном языке — сервер реализует интерфейс, клиент получает готовый вызов. Правило простое: .proto — источник правды, код из него генерируется, а не пишется руками. Это и есть главное отличие от REST: контракт проверяется компилятором. Опечатку в имени поля вы поймаете при сборке, а не в проде.
Четыре вида вызовов
Один запрос, один ответ, как обычный вызов метода: это унарный вызов, и большинству API его хватает. Не хватает его, когда клиент хочет узнавать об изменениях заказа по мере их появления: опрашивать сервер раз в секунду дорого и всё равно с опозданием. Для этого есть серверный стриминг, один запрос и поток ответов. Обратная ситуация, клиент грузит файл или телеметрию частями, а сервер отвечает один раз в конце: клиентский стриминг. А чат или обмен телеметрией в обе стороны это двунаправленный стриминг, оба конца шлют потоки одновременно. Все три потоковых режима возможны за счёт HTTP/2, и в REST их приходится изобретать отдельными протоколами, в gRPC они встроены.
Четыре вида вызова на одной оси времени: видно, сколько сообщений уходит в каждую сторону, а у двунаправленного порядок не задан и стороны шлют одновременно.
Присутствие поля: почему «сломалось без ошибки»
Главная особенность формата, из-за которой ломается тот самый пример со статусом: в proto3 по умолчанию нельзя отличить «поле не прислали» от «прислали значение по умолчанию». Пустая строка, ноль, false и отсутствие поля — для получателя одно и то же.
Почему так: формат не передаёт поля со значениями по умолчанию, чтобы экономить байты. Получатель, разбирая сообщение, просто не находит поле и подставляет умолчание. Отсюда и «сломалось без ошибки»: клиент не заполнил статус, сервер увидел пустую строку и принял её за законное значение.
Решается это явным указанием присутствия:
message UpdateOrderRequest {
string order_id = 1;
optional string status = 2; // теперь есть has_status()
}
Слово optional в proto3 не делает поле необязательным (они и так все необязательные) — оно включает отслеживание присутствия: в сгенерированном коде появляется проверка «поле было прислано». То же даёт обёртка над значением (google.protobuf.StringValue), но она дороже и сегодня применяется реже.
Практическое правило: для полей, где «не прислали» и «прислали пустое» означают разное — особенно в операциях частичного обновления — присутствие указывают явно. Для остальных полей умолчание в порядке, и это самый частый источник тихих ошибок при переходе с REST, где null и "" различаются сами.
Ошибки: коды состояния и детали
В gRPC нет кодов HTTP — у него свой набор кодов состояния, и соответствие привычным примерно такое.
| Код gRPC | Когда | Аналог в REST |
|---|---|---|
INVALID_ARGUMENT | запрос неверен по форме или значениям | 400 |
NOT_FOUND | объекта нет | 404 |
ALREADY_EXISTS | уже создан | 409 |
PERMISSION_DENIED | нельзя | 403 |
UNAUTHENTICATED | не опознан | 401 |
FAILED_PRECONDITION | состояние не позволяет операцию | 409 или 422 |
RESOURCE_EXHAUSTED | лимит или квота | 429 |
DEADLINE_EXCEEDED | не успели к дедлайну | таймаут |
UNAVAILABLE | сейчас недоступен, можно повторить | 503 |
INTERNAL | наша ошибка | 500 |
Два кода стоит различать особо. FAILED_PRECONDITION означает «не повторяй, пока не изменишь состояние системы» (заказ уже оплачен); UNAVAILABLE — «повторяй, скоро пройдёт». Именно по этому различию клиентские библиотеки решают, повторять ли вызов автоматически.
Деталей одного кода мало, и для них есть стандартная структура: вместе с кодом передают сообщение и список деталей — типизированных сообщений, описывающих, что именно не так. Готовые типы покрывают частые случаи: нарушение правил по полям (аналог массива ошибок валидации в REST), указание квоты, подсказка о повторе с задержкой, ссылка на документацию.
// упрощённо, как это выглядит на стороне сервера
Status {
code: INVALID_ARGUMENT
message: "Проверка не пройдена"
details: [ BadRequest { field_violations: [ { field: "quantity", description: "должно быть больше нуля" } ] } ]
}
Практический вывод: ответ на вопрос «как здесь выглядит 422 со списком полей» — это INVALID_ARGUMENT плюс деталь с нарушениями по полям. Структура другая, содержание то же, и клиенту так же не нужно разбирать текст сообщения.
Дедлайн, а не таймаут
Отличие, ради которого gRPC часто и выбирают. Клиент указывает не «сколько я жду», а момент, после которого ответ не нужен, — и этот момент передаётся по цепочке вызовов.
Разница на примере. Клиент дал сервису А дедлайн 500 миллисекунд. А потратил 300 на свою работу и вызывает Б — и передаёт Б дедлайн уже не 500, а оставшиеся 200. Б, если его работа заведомо дольше, может не начинать её вовсе и сразу вернуть «не успею». С обычными таймаутами так не выходит: у каждого участника свой независимый таймаут, и нижний сервис честно считает результат, который никому уже не нужен.
Отсюда три практических следствия. Дедлайн задают всегда — вызов без дедлайна может ждать вечно и удерживать ресурсы. Отмена доезжает до сервера: клиент, которому ответ стал не нужен, закрывает поток, и сервер об этом узнаёт (в обычном HTTP это выглядит как обрыв, который сервер часто игнорирует). И бюджет расходуется честно: сумма времени по цепочке не превышает исходного дедлайна, поэтому «умножение таймаутов» из мира REST здесь не происходит.
Совместимость: клиент, сервер и незнакомые поля
Правило «добавление поля безопасно» верно, но у него две стороны, и они не одинаковы.
Старый клиент, новый сервер. Клиент не знает о новом поле — он его просто не читает. Безопасно всегда.
Старый сервер, новый клиент. Сервер получает сообщение с незнакомым полем. Незнакомые поля в proto3 сохраняются при разборе, и если сервер просто перепакует сообщение дальше, поле уедет с ним. Но если по пути стоит что-то, что собирает сообщение заново (преобразователь в JSON и обратно, прокси с фильтрацией полей, старые библиотеки), поле потеряется молча — и новый клиент не узнает, что его данные не доехали.
Отсюда практическое правило: на добавление полей полагаются, но проверяют путь. И три запрета, которые не обходятся никак: номер поля не переиспользуют (новое поле с номером удалённого получит чужие данные — для этого номера помечают как reserved), тип поля не меняют, значение перечисления не переименовывают (в бинарном виде едет число, а не имя, — переименование меняет смысл для того, кто читает по имени).
Когда выигрыш действительно виден
Порядок величин, чтобы не брать gRPC ради «он быстрее».
Один вызов сам по себе быстрее незначительно: выигрыш бинарного формата над JSON на небольшом сообщении — десятки микросекунд, и он утонет в сетевой задержке. Разница становится заметной, когда счёт идёт на тысячи вызовов в секунду между сервисами: там экономия процессора на разборе и сборке сообщений даёт реальные проценты, а мультиплексирование в одном соединении избавляет от пула.
Второй порог — размер сообщений. На сообщениях в десятки килобайт и больше бинарный формат экономит уже не проценты, а разы по трафику, и это видно и в счёте за сеть, и в задержке.
И третий, самый частый в жизни довод, не связанный со скоростью: строгий контракт с кодогенерацией. Даже при сотне вызовов в секунду gRPC берут потому, что клиент и сервер собираются из одного файла и разъехаться не могут.
Как это включают в Java-проекте
Одной фразой, чтобы читатель знал направление. Файлы контракта (.proto) кладут в модуль, плагин сборки вызывает компилятор и генерирует классы сообщений и заготовки вызовов; сервер реализует сгенерированный базовый класс, клиент получает готовый вызывающий объект — блокирующий (обычный вызов) или асинхронный (для потоков и обратных вызовов). В Spring-проект это подключают отдельным модулем поддержки (вариантов несколько, они отличаются степенью интеграции с настройкой и наблюдаемостью), и дальше сервис живёт рядом с обычными HTTP-ручками, на своём порте.
Где это применяется
gRPC силён во внутренней связи между сервисами, где обе стороны ваши и важны скорость и строгий контракт:
- Микросервисы, которые часто зовут друг друга: бинарный формат и переиспользуемые HTTP/2-соединения экономят время (о стоимости установки соединений — в статье про соединения и пулы).
- Строго типизированные контракты между командами:
.proto— общий язык, кодогенерация не даёт разойтись. - Потоковые сценарии: телеметрия, подписки, обмен событиями.
Где gRPC — плохой выбор:
- Публичное API для браузеров и сторонних разработчиков. Браузер не умеет gRPC напрямую (нужен прокси grpc-web), а внешним потребителям привычнее REST + JSON, который видно глазами и легко потрогать curl'ом.
- Отладка «на коленке». Бинарный формат не прочитать в логах без инструментов; REST-ответ читается сразу.
- Кэширование HTTP. REST-
GETкэшируется прокси и CDN из коробки; gRPC-вызов — нет.
Где спотыкаются начинающие
- Тащат gRPC в публичное API ради «скорости», получая мучения с браузерами и внешними клиентами. Для публичного контура REST почти всегда правильнее.
- Меняют номера полей в
.proto— это ломает совместимость. Номер поля неприкосновенен: добавить поле с новым номером можно, а у удалённого номер и имя надо закрыть строкойreserved 2;/reserved "status";— иначе через полгода кто-нибудь займёт освободившийся номер под другое поле, и старые клиенты начнут читать чужие данные. - Забывают про дедлайны и ретраи. Быстрый вызов не значит надёжный — сеть всё так же ненадёжна (см. таймауты и ретраи).
- Ставят gRPC везде «потому что модно», хотя внутри всего пара вызовов в секунду — тогда выигрыш незаметен, а сложность добавилась.
Глубже: ошибки в gRPC: коды состояния и google.rpc.Statusрасширенное
Модель ошибок из раздела про REST стоит на HTTP-статусах и теле application/problem+json, и в gRPC она не работает: HTTP-статус у ответа почти всегда 200, а результат вызова лежит в трейлере grpc-status.
Кодов шестнадцать, и они про смысл: OK, INVALID_ARGUMENT (аналог 400), NOT_FOUND, ALREADY_EXISTS, PERMISSION_DENIED (403), UNAUTHENTICATED (401), FAILED_PRECONDITION (состояние не позволяет, аналог 409), RESOURCE_EXHAUSTED (429), UNAVAILABLE (503), DEADLINE_EXCEEDED, INTERNAL, UNIMPLEMENTED. Код выбирает сервер по тому же принципу, что статус в REST: клиент по нему решает, повторять ли. Повторяемым по умолчанию считают только UNAVAILABLE; DEADLINE_EXCEEDED повторяют лишь если операция идемпотентна, INTERNAL и UNKNOWN нет.
Кода и строки message мало для клиента, который хочет показать пользователю, какое поле неверно. Для этого есть богатая модель ошибок: сообщение google.rpc.Status с полем details, куда кладут типизированные подробности из google/rpc/error_details.proto: BadRequest со списком field_violations, ErrorInfo с машинным reason и domain (аналог поля code в REST-ошибке), RetryInfo с задержкой перед повтором (аналог Retry-After), QuotaFailure, PreconditionFailure. В Java это com.google.rpc.Status и StatusProto.toStatusRuntimeException(status) на сервере, StatusProto.fromThrowable(e) на клиенте:
Status status = Status.newBuilder()
.setCode(Code.INVALID_ARGUMENT_VALUE)
.setMessage("Заказ не проходит проверку")
.addDetails(Any.pack(BadRequest.newBuilder()
.addFieldViolations(FieldViolation.newBuilder()
.setField("items").setDescription("пустой заказ"))
.build()))
.build();
throw StatusProto.toStatusRuntimeException(status);
Так список нарушений переезжает из violations REST-ответа в BadRequest.field_violations, а code в ErrorInfo.reason, и модель ошибок остаётся одной на оба стиля, меняется только упаковка. Ловушки: балансировщик, который смотрит на HTTP-статус, считает все вызовы успешными и не видит ошибок, поэтому метрики снимают по grpc-status; текст message уходит клиенту как есть, и внутренности исключения в него не кладут, как и в REST; и google.rpc.Status это не io.grpc.Status, у них одинаковые имена и разные пакеты, что стоит часа отладки каждому, кто впервые их путает.
Коротко
- gRPC это контракт в
.proto, бинарный Protobuf и HTTP/2; клиент и сервер генерируются, номера полей неприкосновенны. - Четыре вида вызова: обычный, серверный поток, клиентский поток, двусторонний; последний отличает gRPC от REST по существу.
- Место gRPC внутри системы между своими сервисами; наружу и в браузер отдают REST.
- Ошибки живут в трейлере
grpc-statusс шестнадцатью кодами, повторяют толькоUNAVAILABLE; подробности вgoogle.rpc.Status.details(BadRequest,ErrorInfo,RetryInfo) сохраняют одну модель ошибок с REST. - В proto3 по умолчанию не отличить «не прислали» от значения по умолчанию: для полей, где это важно, ставят
optionalи проверяют присутствие. - Ошибки — свои коды состояния (
INVALID_ARGUMENT,NOT_FOUND,FAILED_PRECONDITION,UNAVAILABLE) плюс типизированные детали; по различиюFAILED_PRECONDITIONиUNAVAILABLEклиент решает, повторять ли. - Дедлайн передаётся по цепочке и уменьшается на каждом шаге, а отмена доезжает до сервера — поэтому таймауты не перемножаются.
- Номер поля не переиспользуют (помечают
reserved), тип не меняют, значение перечисления не переименовывают; незнакомые поля сохраняются, но теряются на пути через преобразователи.
Что почитать дальше
- REST - стиль по умолчанию: с него начинают, пока нет причин уходить в бинарный контракт.
- GraphQL - соседняя развилка, решающая другую боль: гибкие срезы данных для клиента.
- HTTP/2 - транспорт под gRPC: откуда берутся мультиплексирование и потоки.
- Таймауты, ретраи и идемпотентность - что делать с вызовом, который не дошёл, даже когда дедлайн передаётся по цепочке.
- Системный дизайн - как выбор стиля встраивается в проектирование системы целиком.