gRPC: основы
До сих пор у Brew был один способ говорить между сервисами — Kafka. Order-service клал событие в brew.orders.v1, кухня и платежи когда-нибудь его читали. Это асинхрон. Удобно для событий («заказ размещён», «платёж прошёл»), но не очень удобно, когда мобильное приложение клиента нажало «Оформить» и ждёт ответ прямо сейчас. «Создай заказ и верни мне его id» через топик не делается естественно — пришлось бы городить request-reply поверх Kafka, заводить correlation_id, ждать ответ из второго топика. Можно. Но громоздко.
Для синхронного запроса с ответом есть другой инструмент. HTTP/REST — классика. gRPC — то же самое, только с типами, бинарным форматом и кодогенерацией. Лекция про него — на примере синхронной write-стороны order-service Brew: gRPC CreateOrder («создай заказ, верни id»), который дополняет асинхронный поток заказов в brew.orders.v1.
gRPC в одном абзаце
gRPC — это RPC-фреймворк поверх HTTP/2. Сериализация — Protobuf (мы его уже знаем из модуля 05). Описываешь сервис в .proto-файле, кодогенератор делает Go-интерфейс под сервер и Go-клиент. Заполняешь интерфейс — получаешь рабочий сервер. Импортируешь клиент — получаешь готовый стаб с типизированными методами. Никаких ручных JSON-маршалов, никаких URL-роутеров.
Транспорт под капотом — HTTP/2. Отсюда мультиплексирование (много вызовов в одном соединении), стримы (об этом gRPC streaming), бинарные фреймы, header compression. На сетевом уровне всё ещё TCP плюс TLS, но фреймы уже HTTP/2.
Четыре типа RPC:
- Unary — обычный запрос-ответ. Клиент шлёт одно сообщение, сервер возвращает одно. Это лекция про unary.
- Server-stream — клиент шлёт один запрос, сервер отвечает потоком сообщений. Подписки, прогресс долгой операции.
- Client-stream — клиент льёт поток, сервер отвечает одним резюме в конце. Загрузка батчей.
- Bidi-stream — обе стороны одновременно шлют потоки. Чат-подобные сценарии, двусторонняя синхронизация.
Стримы — отдельная лекция. Тут только unary. Этого достаточно, чтобы получить первый рабочий сервер и клиент.
Что мы пишем
Синхронную write-сторону order-service Brew, сведённую к голому gRPC. Два метода:
Create(customer_id, amount, currency) -> Order— заводит заказ Brew, выдаёт id. Это и есть тот самыйCreateOrderиз реестра сервисов.Get(id) -> Order— отдаёт заказ по id.
Хранилище — map[string]*Order под RWMutex. Никаких БД, никакого Kafka. Лекция про gRPC, всё лишнее уберём — в Гибрид gRPC + Kafka этот же order-service обрастёт Postgres, outbox-таблицей и публикацией в brew.orders.v1.
.proto-файл
Контракт описывается в одном файле. Type-safety и совместимость — за счёт Protobuf, всё как в Protobuf в Go / Schema Registry. Новое тут — ключевое слово service и описание методов.
service OrderService {
rpc Create(CreateRequest) returns (CreateResponse);
rpc Get(GetRequest) returns (GetResponse);
}Каждый метод — это rpc <Имя>(<запрос>) returns (<ответ>). Запрос и ответ — обычные Protobuf-сообщения. Соглашение для unary: на каждый метод отдельная пара XxxRequest / XxxResponse. Звучит избыточно, но окупается на первой же эволюции — добавил поле в CreateRequest, и это никак не задело CreateResponse или GetRequest. Если заводить общий тип — придётся выкручиваться позже.
Реальный контракт лекции лежит в proto/orders/v1/orders.proto. Кроме сервиса там сидят Order (заказ Brew с customer_id — те же имена, что в каноне модуля 05), OrderStatus (enum с префиксом ORDER_STATUS_ — это buf-конвенция), CreateRequest, CreateResponse, GetRequest, GetResponse.
Кодогенерация поднимается через buf generate. В buf.gen.yaml подключены два плагина:
plugins:
- local: protoc-gen-go
out: gen
- local: protoc-gen-go-grpc
out: genПервый делает *.pb.go — обычные Go-структуры. Второй — *_grpc.pb.go с интерфейсом сервера, регистратором и клиентским стабом. Без второго плагина у тебя будут типы, но не будет сервера и клиента. Частая ловушка — забыть его поставить.
Установка локально (если впервые на машине):
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latestДальше make proto-gen выкатит файлы в gen/orders/v1/.
Сервер
grpc-server слушает TCP-порт, регистрирует реализацию OrderService, обрабатывает unary-вызовы. Стандартный shape для любого gRPC-приложения.
lis, err := net.Listen("tcp", *addr)
if err != nil { ... }
srv := grpc.NewServer(
grpc.UnaryInterceptor(loggingUnaryInterceptor(logger)),
)
ordersv1.RegisterOrderServiceServer(srv, &orderServer{store: store})
reflection.Register(srv)
if err := srv.Serve(lis); err != nil { ... }grpc.NewServer принимает опции — здесь это UnaryInterceptor, про него ниже. RegisterOrderServiceServer приходит из *_grpc.pb.go, она привязывает реализацию к серверу. reflection.Register — сервер начинает отвечать на запросы перечисления своих методов, это нужно для grpcurl без .proto. В проде reflection обычно отключают, потому что это лишнее раскрытие API.
Сама реализация unary-метода выглядит как обычная Go-функция:
func (s *orderServer) Create(_ context.Context, req *ordersv1.CreateRequest) (*ordersv1.CreateResponse, error) {
if req.GetCustomerId() == "" {
return nil, status.Error(codes.InvalidArgument, "customer_id is required")
}
...
o := &ordersv1.Order{
Id: uuid.NewString(),
CustomerId: req.GetCustomerId(),
AmountCents: req.GetAmountCents(),
...
}
s.store.put(o)
return &ordersv1.CreateResponse{Order: o}, nil
}Обрати внимание на ошибки. Не errors.New, не fmt.Errorf. Используется пакет google.golang.org/grpc/status плюс коды из google.golang.org/grpc/codes. Это и есть error model gRPC.
Error model
gRPC передаёт статус в HTTP/2-trailers. У статуса есть код (фиксированный enum) и сообщение (произвольная строка). Кодов конкретный набор — не нужно изобретать «пользовательские» коды или сериализовать ошибку в JSON.
Ходовые:
OK— всё хорошо. Возвращается, если handler вернулnil-ошибку.InvalidArgument— клиент прислал неправильные данные. Не путать сFailedPrecondition(данные правильные, но состояние сервера сейчас не позволяет).NotFound— запрошенного ресурса нет.AlreadyExists— попытка создать то, что уже существует.PermissionDenied— auth есть, но прав не хватает.Unauthenticated— auth вообще нет или невалидна.DeadlineExceeded— клиент или промежуточный gateway превысил deadline.Internal— что-то сломалось внутри сервера, без подробностей.Unavailable— временно нельзя, попробуй позже (часто означает, что соединение умерло — для retry-политик это сигнал «можно ретраить»).
В нашем Create пустой customer_id — это InvalidArgument. В Get отсутствующий заказ — NotFound. Серверу не приходится отдельно говорить «это retriable, это нет» — клиент или промежуточная инфраструктура смотрят на код и сами решают.
return nil, status.Errorf(codes.NotFound, "order %q not found", req.GetId())Это не просто Go-ошибка — это типизированная gRPC-ошибка, у которой код прилетит на клиент честно. Клиент потом может разобрать её через status.Code(err).
Клиент
grpc-client подключается, создаёт стаб, делает Create, делает Get, печатает результат. Бонусом ходит за несуществующим id, чтобы убедиться, что код приходит как NotFound.
conn, err := grpc.NewClient(
*addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithUnaryInterceptor(loggingUnaryClientInterceptor(logger)),
)
if err != nil { ... }
defer conn.Close()
client := ordersv1.NewOrderServiceClient(conn)grpc.NewClient — это современный API, заменивший устаревший grpc.Dial. Реальное соединение лениво поднимется при первом вызове. insecure.NewCredentials — потому что у нас plaintext-сервер на localhost, в проде там TLS.
Сам вызов:
createCtx, cancel := context.WithTimeout(ctx, *timeout)
defer cancel()
createResp, err := client.Create(createCtx, &ordersv1.CreateRequest{
CustomerId: *customerID,
AmountCents: *amount,
Currency: *currency,
})Тут важная деталь — context.WithTimeout. Это deadline на конкретный RPC. gRPC шлёт его в метаданные запроса, сервер видит и может своими руками прервать обработку, если зависает. Без deadline зависший сервер заблокирует клиента до тех пор, пока TCP-соединение не оборвёт OS — это могут быть минуты.
Правило: на каждый клиентский RPC ставь deadline. На сервере — респектуй пришедший ctx.Done(), не лезь в долгие операции без проверки контекста.
Разбор ошибки на клиенте делается через тот же status-пакет:
_, err = client.Get(notFoundCtx, &ordersv1.GetRequest{Id: "no-such-order"})
if code := status.Code(err); code != codes.NotFound {
logger.Warn("ожидали NotFound", "got_code", code)
}status.Code(nil) вернёт OK, поэтому проверять можно одним сравнением. Если ошибка — это не gRPC-ошибка вовсе (например, transport-level разрыв), код будет Unknown.
Interceptors
В коде сервера и клиента стояли две одинаковые штуки — UnaryInterceptor. Это middleware-механизм gRPC. Любой unary-вызов проходит через цепочку interceptor'ов до того, как дойти до handler'а (на сервере) или до сети (на клиенте).
Серверный interceptor выглядит так:
func loggingUnaryInterceptor(logger *slog.Logger) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
start := time.Now()
resp, err := handler(ctx, req)
dur := time.Since(start)
code := status.Code(err).String()
if err != nil {
logger.Error("rpc", "method", info.FullMethod, "code", code, "dur", dur, "err", err)
return resp, err
}
logger.Info("rpc", "method", info.FullMethod, "code", code, "dur", dur)
return resp, nil
}
}Это просто обёртка над handler'ом. Засекаешь время, дёргаешь, забираешь код через status.Code, логируешь. Для production туда же добавляют tracing (trace-id из metadata в логи и в OTel-span), recover от panic'а (чтобы panic не убивал весь сервер), сбор метрик, аутентификацию (читать токен из metadata, валидировать, класть claims в context).
Клиентский interceptor — зеркало:
func loggingUnaryClientInterceptor(logger *slog.Logger) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
start := time.Now()
err := invoker(ctx, method, req, reply, cc, opts...)
...
}
}Тут место для retry-логики, добавления auth-заголовков (через metadata.AppendToOutgoingContext), сбора client-side метрик. Стандартная цепочка для любого продового gRPC-клиента — auth → tracing → retry → metrics.
Сравнение с REST/HTTP
Если ты ожидал «gRPC всегда лучше HTTP» — нет, не всегда. Где gRPC выигрывает:
- Бинарный протокол. Меньше байт на проводе, чем JSON.
- Кодогенерация на обе стороны. Никакого «прочитал поле, оно строка, а должно быть число» в рантайме.
- Стримы из коробки. В HTTP/1.1 их нет, в HTTP/2 их можно сделать вручную через chunked, но это самодельщина.
- Deadlines прорастают через цепочку вызовов. В REST это руками таскать
X-Request-Timeoutили умирать молча.
Где REST/HTTP всё ещё уместен:
- Браузер. gRPC в браузер штатно не ходит (нужен gRPC-Web или прокси типа envoy). REST туда ходит без церемоний.
- Внешние API для третьих сторон. Все умеют HTTP+JSON, не все хотят разбираться с Protobuf.
- Простые admin-интерфейсы — поднял curl, дёрнул, посмотрел. С gRPC надо grpcurl (и reflection включить, чтобы не таскать .proto).
Внутри одного периметра, где обе стороны под твоим контролем — gRPC экономит силы. На границу с внешним миром — обычно REST или GraphQL.
Что делает grpcurl
grpcurl — это аналог curl для gRPC. Через него удобно дёргать сервер руками, без поднятия клиента. В нашем Makefile есть пара примеров:
grpcurl -plaintext localhost:50051 list
grpcurl -plaintext localhost:50051 describe orders.v1.OrderService.Create
grpcurl -plaintext -d '{"customer_id":"cus-007","amount_cents":2599,"currency":"EUR"}' \
localhost:50051 orders.v1.OrderService/CreateЭто работает потому, что у нас включён reflection. Без reflection пришлось бы передавать -proto proto/orders/v1/orders.proto -import-path proto. Удобно для дебага, неудобно для CI — там обычно пишут отдельный Go-клиент для проверок.
Запуск
Поднимаем сервер в одном терминале:
make run-serverВ другом терминале — клиент:
make run-clientВ выводе сервера видно лог из interceptor'а: метод, код, длительность. На клиенте — три блока: created, got, notfnd. Третий специально промахивается, чтобы показать, что NotFound — это типизированный код, а не текстовая «ошибка».
Если хочется потрогать сервер напрямую через grpcurl:
make grpcurl-list # перечислить сервисы
make grpcurl-create # создать заказ
make grpcurl-get ID=<uuid из ответа Create> # достать его обратноЗаметь — server stateful, store живёт в памяти. Перезапустил сервер — все заказы пропали. Это и есть граница лекции: мы сделали голый gRPC order-service Brew, без БД, без Kafka, без аутентификации. Дальше в Гибрид gRPC + Kafka этот же order-service обрастёт Postgres, outbox-таблицей и публикацией в brew.orders.v1 — там уже будет видно, как синхронный CreateOrder уживается с асинхронным потоком заказов в одном процессе. А в Saga: choreography vs orchestration заказ запустит сагу оплата → резерв ингредиентов → курьер с компенсациями.
Что дальше
В gRPC streaming — стримы. Server-stream, client-stream, bidi. Там же будет про backpressure на стриме и про то, чем gRPC-стрим принципиально отличается от Kafka-стрима (короткий ответ — durability и replay).
В Sync vs async: gRPC и Kafka — decision matrix: когда брать gRPC, когда Kafka. На примере «user signed up» с честными плюсами и минусами обоих подходов.
В Гибрид gRPC + Kafka — гибрид: gRPC для синхронной API + Kafka для событий + outbox для атомарности.
В Saga: choreography vs orchestration — saga и компенсации, choreography vs orchestration.
Пока — выйди в терминал и сделай make run-server плюс make run-client. Посмотри на лог. Дальше двигаемся.