← назад к разделу

Когда один сервис зовёт другой сотни раз в секунду, накладные расходы REST начинают мешать: текстовый JSON надо сериализовать и парсить, заголовки повторяются, а контракт держится «на честном слове» — на документации, которая устаревает. gRPC решает именно эту задачу: строгий контракт, компактный бинарный формат и быстрый транспорт. Разберёмся, как он устроен и когда его стоит брать.

Разница видна уже на проводе: в JSON едут имена полей, в protobuf — их номера из контракта.

один и тот же заказ: id=A-1024, status=PAID, amount=1500JSON45 байтprotobuf17 байтменьше в 2,6 раза сервер добавил поле discount под номером 4 — клиент читает по номерам:1 → A-1024прочитал2 → PAIDпрочитал3 → 1500прочитал4 → 300пропустилработаетстало 20 байт в .proto поменяли номер status: 2 → 5, имя поля то же:1 → A-1024прочитал2 → нетstatus пустой3 → 1500прочитал5 → PAIDпропустилсломалосьбез ошибки + поле с новым номеромстарый клиент не заметилсмена номера полятихо ломает старых клиентов

Имя поля живёт только в .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 они встроены.

унарный клиент: запрос сервер: ответ серверный поток клиент: запрос сервер: ответ 1 сервер: ответ 2 сервер: ответ N клиентский поток клиент: часть 1 клиент: часть N сервер: один ответ двунаправленный клиент: запрос сервер: ответ клиент: запрос сервер: ответ

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

Присутствие поля: почему «сломалось без ошибки»

Главная особенность формата, из-за которой ломается тот самый пример со статусом: в 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: откуда берутся мультиплексирование и потоки.
  • Таймауты, ретраи и идемпотентность - что делать с вызовом, который не дошёл, даже когда дедлайн передаётся по цепочке.
  • Системный дизайн - как выбор стиля встраивается в проектирование системы целиком.