gRPC и RPC: когда REST уже не хватает

REST хорошо подходит для публичных HTTP API: ресурсы, URL, методы, статус-коды, JSON, понятная отладка через браузер и curl. Но в микросервисах часто появляется другая боль: десятки внутренних сервисов должны быстро и строго общаться друг с другом, клиенты на разных языках должны получать одинаковый контракт, а схема запроса не должна жить только в README.

gRPC решает эту задачу через RPC и контракт-first подход. Вместо "ресурс + HTTP-метод" мы описываем сервис и его методы:

proto
service CourseService { rpc GetLesson(GetLessonRequest) returns (GetLessonResponse); }

Для Go-разработчика это похоже на вызов обычного метода, только этот метод живёт на другом процессе или сервере:

go
lesson, err := client.GetLesson(ctx, &coursev1.GetLessonRequest{ Slug: "grpc-overview", })

Под капотом всё равно сеть, таймауты, ошибки, сериализация и контракты. Просто gRPC прячет транспортную механику за сгенерированным клиентом и серверным интерфейсом.


REST и gRPC - разные модели

REST моделирует систему как набор ресурсов:

text
GET /lessons/grpc-overview POST /submissions

gRPC моделирует систему как набор сервисов и методов:

proto
service LessonService { rpc GetLesson(GetLessonRequest) returns (GetLessonResponse); rpc SubmitSolution(SubmitSolutionRequest) returns (SubmitSolutionResponse); }
ВопросRESTgRPC
Основная модельРесурсыСервисы и методы
КонтрактOpenAPI/документация, часто отдельно.proto как источник истины
ДанныеОбычно JSONОбычно Protocol Buffers
ТранспортHTTP/1.1 или HTTP/2HTTP/2
КлиентыПишутся руками или генерируютсяОбычно генерируются
Удобство для браузераВысокоеНизкое без прокси/gRPC-Web
Service-to-serviceМожно, но много ручной работыОсновной сценарий

Важно: gRPC не "лучше REST" вообще. Это другой инструмент. REST проще для публичного API, интеграций и браузерного мира. gRPC сильнее там, где нужен строгий контракт, быстрый бинарный формат, много внутренних клиентов и streaming.


Что происходит под капотом

gRPC использует несколько слоёв:

text
Ваш Go-код ↓ Сгенерированный gRPC client/server ↓ Protocol Buffers messages ↓ gRPC status, metadata, deadlines ↓ HTTP/2 streams ↓ TCP/TLS

HTTP/2

gRPC работает поверх HTTP/2. Это даёт:

  • multiplexing: несколько RPC могут идти по одному соединению;
  • headers/trailers: metadata и финальный gRPC status передаются отдельно от body;
  • flow control: транспорт умеет тормозить отправителя, если получатель не успевает читать;
  • streaming: один RPC может передавать много сообщений в одну или обе стороны.

Обычно вы не работаете с HTTP/2 напрямую. Вы пишете .proto, генерируете код и реализуете Go-интерфейс.

Почему это не проверяют обычным curl

REST endpoint часто можно быстро потрогать так:

bash
curl http://localhost:8080/lessons/grpc-overview

С gRPC так обычно не получится. У gRPC другой wire protocol: HTTP/2, protobuf body, специальные headers/trailers и gRPC status. Обычный HTTP-запрос не знает, какой protobuf message сериализовать и как прочитать gRPC trailers.

Для локальной проверки используют другие варианты:

  • маленький Go-клиент на generated stub;
  • интеграционный тест, который поднимает gRPC server на локальном listener;
  • grpcurl или GUI-клиент вроде Kreya/BloomRPC, если включена reflection или передан .proto;
  • server reflection в dev/staging, чтобы инструмент мог сам узнать список сервисов.

Главная мысль: gRPC debug-инструменты должны понимать контракт. Если хочется "просто открыть в браузере", значит для этого сценария лучше подходит REST, gateway или gRPC-Web, а не чистый service-to-service gRPC.

Protocol Buffers

Protocol Buffers, или protobuf, описывает структуру сообщений:

proto
message GetLessonRequest { string slug = 1; } message GetLessonResponse { string title = 1; string body_markdown = 2; }

Числа 1, 2 - это не порядок для красоты, а часть wire-формата. Клиент и сервер используют номера полей, чтобы понимать бинарные данные. Поэтому номера существующих полей нельзя переиспользовать для другого смысла.


Четыре типа RPC

gRPC поддерживает не только один запрос - один ответ.

proto
service LessonService { // Unary: один request, один response. rpc GetLesson(GetLessonRequest) returns (GetLessonResponse); // Server-side streaming: один request, поток response. rpc ListLessons(ListLessonsRequest) returns (stream Lesson); // Client-side streaming: поток request, один response. rpc UploadProgress(stream ProgressEvent) returns (ProgressSummary); // Bidirectional streaming: поток request и поток response. rpc MentorChat(stream ChatMessage) returns (stream ChatMessage); }

В этом модуле мы пойдём от простого к сложному:

  1. Сначала разберём protobuf-схему.
  2. Потом сгенерируем Go-код.
  3. Потом напишем unary server/client.
  4. Потом разберём context, deadlines, metadata и ошибки.
  5. Потом встроим gRPC в Clean Architecture.
  6. В конце разберём streaming и production-практики.

Когда выбирать gRPC

Выбирайте gRPC, если:

  • сервисы общаются внутри backend-системы;
  • нужен строгий контракт и генерация клиентов;
  • есть клиенты на нескольких языках;
  • важны deadlines, cancellation и единая модель ошибок;
  • нужны server/client/bidirectional streams;
  • payload большой или запросов очень много.

REST обычно проще, если:

  • API публичное и его будут вызывать внешние пользователи;
  • нужен простой доступ из браузера;
  • важна читаемость JSON руками;
  • команда маленькая и контракт пока быстро меняется;
  • нет выигрыша от codegen и HTTP/2 streaming.

Практический подход: публичный edge API часто оставляют REST/HTTP, а внутреннее service-to-service взаимодействие делают на gRPC.


Как выглядит маленький gRPC-проект

В учебном проекте удобно держать контракт отдельно от реализации:

text
grpc-demo/ proto/ calculator/v1/calculator.proto gen/ calculator/v1/ calculator.pb.go calculator_grpc.pb.go cmd/ calculator-server/main.go calculator-client/main.go internal/ calculator/ server.go service.go mapper.go

proto/ - источник истины. gen/ - результат codegen, его не редактируют руками. cmd/calculator-server собирает приложение-сервер. cmd/calculator-client полезен как локальная проверка без браузера и curl. internal/calculator содержит вашу реализацию: бизнес-методы, маппинг и glue-код вокруг generated interface.

В реальных сервисах generated code часто лежит в отдельном module/repository с контрактами. Для обучения проще держать всё рядом, чтобы видеть весь путь от .proto до работающего вызова.


Мини-пример контракта

proto
syntax = "proto3"; package course.v1; option go_package = "example.com/mentor/gen/course/v1;coursev1"; service CourseService { rpc GetLesson(GetLessonRequest) returns (GetLessonResponse); } message GetLessonRequest { string slug = 1; } message GetLessonResponse { string slug = 1; string title = 2; string body_markdown = 3; }

Что здесь важно:

  • syntax = "proto3" выбирает современную версию языка protobuf;
  • package course.v1 задаёт пространство имён на уровне protobuf;
  • option go_package говорит генератору, какой Go package создать;
  • service описывает API;
  • message описывает request/response DTO;
  • номера полей фиксируют wire-contract.

Вопросы на собеседовании

  • Чем RPC отличается от REST?
  • Почему gRPC обычно используют для service-to-service?
  • Зачем gRPC нужен HTTP/2?
  • Почему gRPC не всегда удобен как публичный браузерный API?
  • Что такое unary RPC?
  • Какие типы streaming есть в gRPC?
  • Почему .proto лучше держать как источник истины, а не как "ещё одну документацию"?
  • Почему чистый gRPC неудобно проверять обычным curl?
  • Что должно лежать в proto/, gen/, cmd/ и internal/?

Источники


Практика

Quiz+10 XP

Когда gRPC обычно выигрывает у REST внутри микросервисной системы?

  • Когда нужен простой публичный API для браузера без прокси
  • Когда важен строгий контракт, generated clients и service-to-service взаимодействие
  • Когда API должен открываться обычным HTML form
  • Когда нужно полностью отказаться от deadlines и status codes
Predict+15 XP

Что выведет этот код?

go
package main import "fmt" func chooseAPI(internal bool, needsBrowser bool) string { if internal && !needsBrowser { return "rpc" } return "rest" } func main() { fmt.Println(chooseAPI(true, false)) fmt.Println(chooseAPI(false, true)) }
Задача+20 XP

Реализуй TransportChoice: если вызов внутренний и нужен строгий контракт, верни "grpc", иначе "rest".