Содержание урока · 14 разделов
- HTTP уже есть: зачем второй транспорт
- Proto-контракт stringsvc
- grpctrans.NewServer: Handler, а не transport.Server
- Биндинг: grpcServer над сгенерированным интерфейсом
- Ошибки: два канала, как в HTTP
- Metadata: заголовки gRPC
- Интерсепторы каркаса против ServerBefore
- Клиент: conn как io.Closer — круг discovery замкнут
- Карта gRPC-транспорта go-kit
- Порядок внедрения gRPC-транспорта
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
HTTP уже есть: зачем второй транспорт#
Сервис из уроков 04-10 отдаёт миру JSON по HTTP — и для публичного API это правильный выбор: читаемо, отлаживается curl'ом, открывается любым клиентом. Но внутри кластера сервисы зовут сервисы десятки раз на каждый внешний запрос, и там HTTP/1.1 с JSON начинает мешать: текстовые заголовки на каждый вызов, отсутствие контракта в коде — схема запроса живёт в догадках и чужих README, а клиентские библиотеки каждый пишет сам. gRPC решает обе проблемы: контракт объявляется в .proto-файле, из него кодогенерация выдаёт структуры и клиентский код на любом языке, а перевозкой занимается бинарный protobuf поверх HTTP/2 — мультиплексирование многих вызовов на одном TCP-соединении, без Head-of-Line блокировки HTTP/1.1. Позиция go-kit остаётся той же, что в уроке 01-filosofiya: транспорт — плагин, и gRPC — просто ещё один плагин рядом с HTTP. Endpoint не меняется, обёртки из уроков 06-10 не меняются, сервис не знает, кто его вызвал. Это и есть обещание «go kit grpc — то же ядро, другие ноги»: готовый адаптер живёт в пакете kit/transport/grpc, и сейчас мы его смонтируем. Когда gRPC не нужен: браузер (для него есть отдельная история grpc-web), публичный API и интеграции, где проще отдал JSON-форму, чем объяснять партнёру protoc.
Proto-контракт stringsvc#
Контракт начинается с файла. proto3 описывает service Strings с двумя rpc — Uppercase и Count, ровно те же операции, что в интерфейсе сервиса из урока 03, — и message-типы, зеркальные request/response-структурам из урока 02: поле s на входе, поля v и err на выходе, c для счётчика. Номера после знака равенства — это proto-аналог json-тегов из урока 02: уникальный идентификатор поля в бинарном формате, и менять их после релиза нельзя, как нельзя менять json-теги. Err-поле объявлено строкой — сериализуемой, в отличие от error; бизнес-ошибка едет в ответе, как ты привык. Компилятор protoc с двумя плагинами выдаёт pb-пакет: структуры UppercaseRequest и UppercaseReply, интерфейс StringsServer, который должен реализовать твой сервер, глобальную переменную Strings_ServiceDesc — описание сервиса для каркаса — и функцию RegisterStringsServer, связывающую каркас с твоей реализацией. Обрати внимание на правило из урока 05 в новом свете: кодеки — единственное место, где легальны транспортные типы. Здесь транспортными типами стали сгенерированные структуры pb.*, и граница не сдвинулась: endpoint и сервис про protobuf не узнают никогда.
stringsvc.proto и кодогенерация
GOsyntax = "proto3";
package strings;
option go_package = "example.com/stringsvc/pb";
service Strings {
rpc Uppercase (UppercaseRequest) returns (UppercaseReply) {}
rpc Count (CountRequest) returns (CountReply) {}
}
message UppercaseRequest { string s = 1; }
message UppercaseReply {
string v = 1;
string err = 2;
}
message CountRequest { string s = 1; }
message CountReply { int64 c = 1; }package strings задаёт имя сервиса целиком: strings.Strings — по нему каркас находит обработчик, и полный путь метода выглядит как /strings.Strings/Uppercase. option go_package говорит генератору, в какой пакет положить код — pb относительно корня модуля. Команда генерации: protoc -I . --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative stringsvc.proto — плагин protoc-gen-go выдаёт структуры и сериализацию, protoc-gen-go-grpc — интерфейс StringsServer, Strings_ServiceDesc и RegisterStringsServer. Поля v и err выровнены для читаемости, номера 1 и 2 — контракт бинарной раскладки: переименовать поле можно, перенумеровать — нельзя.
grpctrans.NewServer: Handler, а не transport.Server#
Здесь gRPC-транспорт сюрпризит того, кто привык к HTTP. kithttp.NewServer возвращает http.Handler: ты монтируешь его на mux, и каркас net/http сам зовёт ServeHTTP — go-kit контролирует всю дорогу от запроса до endpoint'а. У gRPC стандартная точка входа другая: каркас google.golang.org/grpc вызывает методы ТВОЕЙ реализации сгенерированного интерфейса StringsServer — и вставиться в эту цепочку извне библиотека не может. Поэтому grpctrans.NewServer(ep, dec, enc, options...) возвращает не готовый http.Handler, а grpctrans.Handler — объект с одним методом ServeGRPC(ctx, request) (context.Context, interface{}, error): принимает уже разобранное каркасом proto-сообщение, зовёт endpoint через декодер и энкодер, возвращает сгенерированный ответ. Склейкой занимается тонкий адаптер — твой grpcServer: каждый метод интерфейса делает три строки — передать запрос в ServeGRPC и type-assert ответа. Кодеки при этом знакомые до боли: те же имена типов DecodeRequestFunc и EncodeResponseFunc, что и в kithttp, сигнатура func(context.Context, interface{}) (interface{}, error) — только вместо http.Request на вход приходит pb.UppercaseRequest, и декодеру почти нечего парсить: каркас уже превратил байты в структуру.
gRPC-кодеки: сгенерированное в доменное и обратно
GO// Типы UppercaseRequest / UppercaseResponse — из урока 02,
// структуры pb.* — из кодогенерации выше.
func decodeGRPCUppercaseRequest(_ context.Context, grpcReq interface{}) (interface{}, error) {
req := grpcReq.(*pb.UppercaseRequest)
return UppercaseRequest{S: req.S}, nil
}
func encodeGRPCUppercaseResponse(_ context.Context, response interface{}) (interface{}, error) {
resp := response.(UppercaseResponse)
return &pb.UppercaseReply{V: resp.V, Err: resp.Err}, nil
}Два анти-коррупционных шва по четыре строки — и весь protobuf остался внутри них. Декодер перекладывает сгенерированную структуру в доменную пару урока 02: type-assert — единственное место, где мы верим каркасу, что пришёл именно pb.UppercaseRequest. Энкодер зеркален: Err-поле едет строкой в UppercaseReply — бизнес-ответ сохраняет свой канал, Failer из урока 02 продолжает работать. Сериализацию делает сам каркас (protobuf), поэтому кодеки не трогают байты вовсе — в HTTP-энкодере была json.Marshal, здесь её нет. Пара Count-кодеков пишется по той же схеме: CountRequest → CountRequest, CountResponse → pb.CountReply.
Биндинг: grpcServer над сгенерированным интерфейсом#
Адаптер — обычная структура с двумя Handler'ами по числу rpc. Обязательная деталь: встраивание pb.UnimplementedStringsServer. Генератор требует, чтобы реализация встраивала этот тип (в интерфейсе есть mustEmbedUnimplementedStringsServer) — страховка совместимости: когда в .proto добавят новый rpc, старый сервер не упадёт при регистрации, а ответит честным codes.Unimplemented. Методы grpcServer повторяют сигнатуры интерфейса: ctx и pb.UppercaseRequest на входе, pb.UppercaseReply и error на выходе. Три строки тела: позвать ServeGRPC, вернуть ошибку как есть, привести ответ к нужному типу — тот самый шов из урока 02, теперь в обратную сторону. Конструктор NewGRPCServer принимает готовые endpoint'ы — те самые makeUppercaseEndpoint и makeCountEndpoint из урока 02, уже обёрнутые логами, метриками, спанами, лимитером и breaker'ом, — и на каждый строит свой grpctrans.Handler со своими кодеками. Никакого состояния, никаких goroutine: адаптер существует только чтобы каркас и endpoint встретились.
grpctrans.Handler в деле
GOimport (
pb "example.com/stringsvc/pb"
"github.com/go-kit/kit/endpoint"
grpctrans "github.com/go-kit/kit/transport/grpc"
)
type grpcServer struct {
pb.UnimplementedStringsServer
uppercase grpctrans.Handler
count grpctrans.Handler
}
func (s *grpcServer) Uppercase(ctx context.Context, req *pb.UppercaseRequest) (*pb.UppercaseReply, error) {
_, rep, err := s.uppercase.ServeGRPC(ctx, req)
if err != nil {
return nil, err
}
return rep.(*pb.UppercaseReply), nil
}
func (s *grpcServer) Count(ctx context.Context, req *pb.CountRequest) (*pb.CountReply, error) {
_, rep, err := s.count.ServeGRPC(ctx, req)
if err != nil {
return nil, err
}
return rep.(*pb.CountReply), nil
}
func NewGRPCServer(uppercase, count endpoint.Endpoint) pb.StringsServer {
return &grpcServer{
uppercase: grpctrans.NewServer(
uppercase,
decodeGRPCUppercaseRequest,
encodeGRPCUppercaseResponse,
),
count: grpctrans.NewServer(
count,
decodeGRPCCountRequest,
encodeGRPCCountResponse,
),
}
}Вся связка каркаса с ядром курса — один файл. Заметь: методы не делают ничего, кроме маршрутизации в свои Handler'ы, — если бы в go-kit не было gRPC-транспорта, тебе пришлось бы городить этот переезд руками в каждом методе, и обёртки из уроков 06-10 остались бы за бортом. Ошибки из err канала уезжают наверх как есть — каркас превратит их в gRPC-статус; как управлять кодами — через два блока. Опции вроде ServerBefore передаются третьим и дальше аргументом в каждый NewServer — о них ниже.
main: второй слушатель рядом с HTTP
GOgrpcListener, err := net.Listen("tcp", ":8082")
if err != nil {
logger.Log("transport", "gRPC", "during", "Listen", "err", err)
os.Exit(1)
}
baseServer := grpc.NewServer(
grpc.ChainUnaryInterceptor(recoveryInterceptor, authInterceptor),
)
pb.RegisterStringsServer(baseServer, NewGRPCServer(uppercase, count))
logger.Log("transport", "gRPC", "addr", ":8082")
baseServer.Serve(grpcListener)Тот же main, второй слушатель: HTTP из урока 04 продолжает жить на :8081, gRPC встаёт на :8082 — и оба едят ОДНИ и те же endpoint'ы: переменные uppercase и count собраны по канону уроков 06-10 и переданы в оба транспорта. Никакого дублирования логики — транспорт-агностичность из урока 01 на деле. grpc.NewServer — это каркас google.golang.org/grpc, RegisterStringsServer — сгенерированная связка каркаса с твоим grpcServer, ChainUnaryInterceptor — цепочка перехватчиков, о которых отдельный разговор ниже. Для остановки без обрыва живых вызовов у каркаса есть baseServer.GracefulStop() — дренаж соединений разберём в уроке про graceful shutdown. Ручной вызов вместо curl — grpcurl: grpcurl -plaintext -d '{"s":"hi"}' localhost:8082 strings.Strings/Uppercase.
Ошибки: два канала, как в HTTP#
Канон из уроков 02 и 05 в gRPC работает дословно, потому что он не про HTTP, а про разделение каналов. Бизнес-ошибка лежит в Err-поле response-структуры с Failer — и в gRPC-энкодере она едет строкой в *pb.UppercaseReply.Err: клиент получит валидный ответ с err="empty string" и err-канал не дёрнет. Инфраструктурный сбой — дедлайн, недоступная база, сломанный декодер — возвращается по err канала, и здесь вступает каркас: ошибки контекста он конвертирует сам, context.DeadlineExceeded станет кодом DeadlineExceeded, а любую незнакомую ошибку — кодом Unknown с текстом. Unknown — это «непонятно что»: такой код не отличит плохой запрос от аварии, алерты и ретраи по нему слепы. Лечится опцией grpctrans.ServerErrorEncoder — она принимает ErrorEncoder func(ctx, err) error, который возвращает status.Error с осмысленным кодом: прямой аналог твоей таблицы codeFrom из урока 04, только вместо HTTP-статусов — коды gRPC. Правило то же: sentinel-ошибки сервиса мапишь сознательно, всё остальное честно падает в Internal.
ServerErrorEncoder: таблица кодов
GOgrpctrans.NewServer(
uppercase,
decodeGRPCUppercaseRequest,
encodeGRPCUppercaseResponse,
grpctrans.ServerErrorEncoder(func(ctx context.Context, err error) error {
switch {
case errors.Is(err, context.DeadlineExceeded):
return status.Error(codes.DeadlineExceeded, "endpoint deadline")
case errors.Is(err, ErrDBUnavailable): // sentinel твоего сервиса
return status.Error(codes.Unavailable, err.Error())
default:
return status.Error(codes.Internal, err.Error())
}
}),
)Таблица codeFrom в gRPC-одежде: errors.Is по sentinel-ошибкам, status.Error(codes.X, ...) на выходе. ErrEmpty здесь НЕ ветка — и это осознанно: пустая строка на входе по канону урока 02 — бизнес-ответ, она уехала в Err-поле reply и до error encoder'а не доходит. Если sentinel'ы сервиса начнут мапиться сюда, клиентская сторона увидит осмысленные коды: Unavailable — можно ретраить, как советовал lb.Retry из урока 11, InvalidArgument — повторять бессмысленно. Проверь себя: если ErrEmpty вдруг появится в этой таблице — значит, где-то выше он попал не в тот канал, и breaker'ы начнут размыкаться на валидных ответах.
Metadata: заголовки gRPC#
У HTTP-запроса были заголовки, у gRPC — metadata.MD: пары ключ-значение, где ключи в нижнем регистре, а каждое значение — срез строк, потому что один ключ может повторяться. Транспорт читает и пишет metadata теми же хуками, к которым ты привык в HTTP, только имена другие. ServerBefore принимает ServerRequestFunc — func(ctx, *metadata.MD) context.Context: функция достаёт из md.Get("x-request-id") значения и кладёт их в контекст — request_id из урока 06 переезжает на новый транспорт без единой правки в логах. Клиентская половина зеркальна: ClientBefore принимает ClientRequestFunc — из контекста в исходящий md через md.Set или md.Append. А для трейсинга go-kit даёт готовую пару: kitot.GRPCToContext(tracer, operationName, logger) на сервере и kitot.ContextToGRPC(tracer, logger) на клиенте — обещание из урока 08-tracing выполнено, SpanContext путешествует в metadata, как uber-trace-id в HTTP-заголовках. ServerAfter работает на выходе: его функции дописывают ответные заголовки — аналог финализатора. Вся наблюдаемость курса — логи 06, метрики 07, спаны 08 — продолжает работать, потому что она стоит на контексте, а контекст транспорт поставляет всегда.
ServerRequestFunc и ClientRequestFunc
GO// Серверная половина: metadata входящего вызова → контекст.
func requestIDToContext() grpctrans.ServerRequestFunc {
return func(ctx context.Context, md *metadata.MD) context.Context {
if ids := md.Get("x-request-id"); len(ids) > 0 {
return context.WithValue(ctx, requestIDKey, ids[0])
}
return ctx
}
}
// Клиентская половина: контекст → metadata исходящего вызова.
func requestIDToMetadata() grpctrans.ClientRequestFunc {
return func(ctx context.Context, md *metadata.MD) context.Context {
if id, ok := ctx.Value(requestIDKey).(string); ok {
md.Set("x-request-id", id)
}
return ctx
}
}requestIDKey — тот же закрытый тип-ключ из урока 06: контекст остаётся единственным шиной наблюдаемости. md.Get возвращает срез — «есть ли значения» проверяется длиной; md.Set перезаписывает, md.Append добавляет к существующим. Подключение на сервере: grpctrans.ServerBefore(requestIDToContext(), kitot.GRPCToContext(tracer, "gRPC /strings.Strings/Uppercase", logger)) — порядок как в HTTP: сначала свои ключи, потом спан; имя операции по конвенции урока 08 — «транспорт + маршрут». На клиенте зеркально: grpctrans.ClientBefore(requestIDToMetadata(), kitot.ContextToGRPC(tracer, logger)) — request_id и спан уезжают к соседу, Jaeger склеит сервисы в одно дерево. Ключи metadata каркас нормализует в нижний регистр — пиши сразу так, иначе удивишься пустому md.Get.
Интерсепторы каркаса против ServerBefore#
У gRPC есть собственный перехватный механизм, и важно не путать его с опциями go-kit. Интерсептор — функция func(ctx, req, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error): она обёрнута вокруг ВСЕГО вызова метода, от каркаса до ответа; в info.FullMethod лежит путь вида /strings.Strings/Uppercase. Подключаются они на каркасе: grpc.UnaryInterceptor для одного, grpc.ChainUnaryInterceptor для цепочки — первый в цепочке снаружи, как middleware роутера. Типовые jobs: recovery — поймать панику и вернуть codes.Internal вместо обрыва соединения; аутентификация — проверить токен в metadata и оборвать вызов до всякого декодирования; метрики уровня каркаса — счётчик grpc_server_handled_total по методам. Отсюда разница: ServerBefore работает ВНУТРИ Handler'а — когда метод уже выбран и ServeGRPC уже запущен, — он читает metadata в контекст и ни при каких условиях не может «отклонить» вызов. Интерсептор — снаружи, он видит сырой req и может не пустить handler дальше. Правило курса: то, что решает «пустить/не пустить» и ловит паники, — в интерсепторы; то, что поставляет данные в контекст для endpoint'а, — в ServerBefore. Метрики endpoint'а остаются в instrumentingMiddleware из урока 07-metriki, интерсептор их не заменяет, а дополняет транспортным уровнем.
Recovery-интерсептор
GOfunc recoveryInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (resp interface{}, err error) {
defer func() {
if r := recover(); r != nil {
logger.Log("panic", fmt.Sprint(r), "method", info.FullMethod)
err = status.Error(codes.Internal, "internal error")
}
}()
start := time.Now()
resp, err = handler(ctx, req)
logger.Log("method", info.FullMethod, "took", time.Since(start).String())
return resp, err
}Сигнатура стоит заучивания: ctx и req — вход, info говорит, какой метод вызван, handler — продолжение цепочки, и вызвать его — твоя обязанность: не вызвал — вызов не случился. Без recovery паника в endpoint'е оборвала бы HTTP/2-соединение со всеми мультиплексированными вызовами; здесь она превращается в status.Error(codes.Internal, ...) и строку в лог из урока 06. took у интерсептора — время всего вызова каркаса, включая кодеки; она шире took из loggingMiddleware — сравни в логах, разница и есть цена транспорта. В main их два: grpc.ChainUnaryInterceptor(recoveryInterceptor, authInterceptor) — recovery снаружи, чтобы словить панику даже из auth. Для стриминговых методов есть зеркальный StreamInterceptor — он понадобится, когда дойдёт до потоков.
Клиент: conn как io.Closer — круг discovery замкнут#
Клиентская сторона — недостающая деталь фабрики из урока 12-discovery. grpc.Dial(target, opts...) строит grpc.ClientConn — виртуальное соединение с инстансом (лениво: реальный коннект происходит на первом вызове); вместо устаревшего WithInsecure передай grpc.WithTransportCredentials(insecure.NewCredentials()). Дальше grpctrans.NewClient(conn, &pb.Strings_ServiceDesc, "Uppercase", enc, dec, options...) возвращает Client с методом Endpoint() — исходящий вызов стал endpoint'ом, как и обещал клиентский транспорт из урока 04. ServiceDesc берётся сгенерированный: клиенту из него важны только ServiceName и MethodName — они склеятся в путь /strings.Strings/Uppercase, поля Handler нужны серверной диспетчеризации, до которой клиенту дела нет. Кодеки клиента зеркальны серверным: EncodeRequestFunc пакует UppercaseRequest в pb.UppercaseRequest, DecodeResponseFunc разворачивает *pb.UppercaseReply в UppercaseResponse. А теперь главная строчка: фабрика возвращает conn вторым значением — io.Closer. HTTP-фабрика возвращала nil, gRPC-фабрика честно отдаёт соединение: когда инстанс покраснел и ушёл из sd.Event, Endpointer вызовет Close у его фабричного результата — conn закрыт, ресурсы отданы. Дальше цепочка из урока 11 не меняется ни на символ: lb.NewRoundRobin и lb.Retry работают поверх gRPC-ног так же, как поверх HTTP. В новых версиях grpc-go вместо grpc.Dial рекомендуют grpc.NewClient — сигнатура та же, семантика чище.
gRPC-фабрика для sd.Factory
GOfunc uppercaseGRPCFactory(instance string) (endpoint.Endpoint, io.Closer, error) {
conn, err := grpc.Dial(instance, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, nil, err
}
e := grpctrans.NewClient(
conn,
&pb.Strings_ServiceDesc, // сгенерированное описание сервиса
"Uppercase", // метод склеится в /strings.Strings/Uppercase
encodeGRPCUppercaseRequest, // UppercaseRequest → *pb.UppercaseRequest
decodeGRPCUppercaseResponse, // *pb.UppercaseReply → UppercaseResponse
grpctrans.ClientBefore(
requestIDToMetadata(),
kitot.ContextToGRPC(tracer, logger),
),
).Endpoint()
return e, conn, nil // conn — io.Closer: Endpointer закроет при вычёркивании инстанса
}Фабрика из урока 12 в gRPC-редакции: контракт func(instance string) (endpoint.Endpoint, io.Closer, error) исполнен дословно, и Closer наконец не nil. Клиентские опции повторяют серверную пару: request_id и спан едут в metadata каждого исходящего вызова — трейс склеит прокси и соседа по uber-trace-id, как в уроке 08. Заметь, кодеки клиента — это кодеки сервера, вывернутые наизнанку: encodeGRPCUppercaseRequest/decodeGRPCUppercaseResponse описаны в клиентском тексте выше. Подставь эту фабрику вместо uppercaseFactory в консул-примере урока 12 — и балансированная цепочка начнёт говорить protobuf, не переписав ни обёртки, ни прокси-middleware. В проде добавь в Dial таймаут-опции и крипто-креды: insecure — только внутри доверенной сети.
Карта gRPC-транспорта go-kit#
| Элемент | Пакет | Контракт | Роль |
|---|---|---|---|
| grpctrans.NewServer | kit/transport/grpc | (ep, dec, enc, options...) → *Server | endpoint в Handler со своими кодеками |
| grpctrans.Handler | kit/transport/grpc | ServeGRPC(ctx, req) (ctx, interface{}, error) | стык каркаса gRPC с endpoint'ом |
| DecodeRequestFunc | kit/transport/grpc | func(ctx, interface{}) (interface{}, error) | *pb.UppercaseRequest → UppercaseRequest |
| EncodeResponseFunc | kit/transport/grpc | func(ctx, interface{}) (interface{}, error) | UppercaseResponse → *pb.UppercaseReply |
| ServerErrorEncoder | kit/transport/grpc | func(ctx, err) error | err канала → status.Error с кодом |
| ServerRequestFunc / ClientRequestFunc | kit/transport/grpc | func(ctx, *metadata.MD) ctx | metadata ↔ контекст: request_id, спан |
| grpctrans.NewClient | kit/transport/grpc | (conn, ServiceDesc, method, enc, dec) | исходящий endpoint поверх gRPC |
| grpc.ChainUnaryInterceptor | google.golang.org/grpc | func(ctx, req, info, handler) | recovery, auth, метрики уровня каркаса |
Порядок внедрения gRPC-транспорта#
- Описать .proto-контракт по операциям интерфейса сервиса; Err-поле — строкой, номера полей — сразу как контракт.
- Сгенерировать pb-пакет: protoc с protoc-gen-go и protoc-gen-go-grpc, go_package с путём к pb.
- Написать пары gRPC-кодеков: decodeGRPCRequest и encodeGRPCResponse по паре на метод.
- Собрать grpcServer со встроенным Unimplemented и Handler'ами через grpctrans.NewServer; конструктор NewGRPCServer от endpoint'ов.
- Подключить ServerBefore: requestIDToContext и kitot.GRPCToContext; ServerErrorEncoder — таблица кодов.
- Каркас: net.Listen :8082, grpc.NewServer с ChainUnaryInterceptor (recovery, auth), pb.RegisterStringsServer, Serve.
- Клиент: фабрика с grpc.Dial + grpctrans.NewClient и conn как io.Closer — подставить в цепочку sd из уроков 11-12.
- Проверить grpcurl'ом оба метода и убедиться, что логи, метрики и спаны из уроков 06-08 видят gRPC-вызовы.
В gRPC соблазн велик: отправить ErrEmpty через status.Error(codes.InvalidArgument) и не таскать Err-поле в reply. Не поддавайся — канон из уроков 02 и 10 дороже краткости. Err-канал endpoint'а читают breaker, retry и instrumentingMiddleware: бизнес-отказ, приехавший статусом, будет посчитан инфраструктурным сбоем — breaker на клиенте начнёт размыкаться от пустых строк, метрики ошибок взлетят без аварии, алерты устанут врать. Плюс статус-ошибку никто не увидит в reply-поле: клиент получит err из вызова и может запустить повтор через lb.Retry — а повторять «пустую строку» бессмысленно. Err-поле для «не могу сделать», статус — для «система сломана»; границу ты уже проводил в уроках 02 и 04, здесь она та же.
Урок требует установленного Go, protoc с плагинами и сгенерированного pb-пакета: в песочнице курса сторонние модули и процессы не исполняются, поэтому интерактивы построены на чтении кода. Локально: сгенерируй pb, подними stringsvc с двумя слушателями, поставь grpcurl и вызови Uppercase с телом {"s":"hi"}; затем вызови без заголовка x-request-id и с ним — сравни строки логов; оборви соседа в консул-цепочке и посмотри, как conn закрывается вместе с инстансом.
Что возвращает grpctrans.NewServer и как он встаёт в каркас gRPC?
Endpoint вернул err — context.DeadlineExceeded, ServerErrorEncoder не задан. Что увидит клиент?
Куда класть аутентификацию по токену и recovery — в ServerBefore или в интерсептор каркаса?
Один endpoint, два транспорта
Прочитайте код и выберите, что он выведет:
uppercase := makeUppercaseEndpoint(svc)
uppercase = loggingMiddleware(logger)(uppercase) // урок 06
uppercase = instrumentingMiddleware(uppercase) // урок 07
// HTTP на :8081 (урок 04) и gRPC на :8082 (этот урок)
// едят ОДИН И ТОТ ЖЕ uppercase.
// Пришёл вызов по gRPC — что окажется в логах и метриках?Инстанс покраснел — что с conn?
Прочитайте код и выберите, что он выведет:
// Консул-цепочка из урока 12, фабрика — gRPC из этого урока:
// возвращает endpoint и conn как io.Closer.
// Инстанс перестал проходить health check и исчез из sd.Event.Собери серверную и клиентскую половины
Восстанови четыре строки. Пропуски — по порядку: конструктор Handler'а в kit/transport/grpc, метод Handler'а, сгенерированная функция регистрации, конструктор соединения каркаса.
Путь gRPC-вызова
Расставь этапы вызова Uppercase по gRPC: от grpcurl до ответа.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Термины урока
Что дальше#
Транспортная глава закрыта: endpoint из урока 02-endpoint обзавёлся вторым лицом — HTTP из урока 04-http-transport и gRPC из этого урока едят один и тот же набор с обёртками из уроков 06-10, а gRPC-клиент встал в фабрику из урока 12 и замкнул круг discovery. Освежить кодеки — в уроке 05-codeki, балансировку с ретраями — в уроке 11-lb-proxy; карта курса — на странице курса. Дальше два урока про middleware как самостоятельный слой: сначала урок 14-endpoint-middleware — логи и метрики как endpoint-обёртки, тот же instrumentingMiddleware, но собранный по правилам пакета endpoint; затем урок 15-service-middleware — декораторы сервиса и инъекция зависимостей. Транспорт может меняться, слои — нет: в этом и был план.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- gRPC в go-kit — ещё один плагин транспорта: тот же endpoint, те же обёртки, только ноги — бинарный protobuf поверх HTTP/2.
- Контракт объявляется в .proto: service Strings, rpc-методы, message-типы; protoc с protoc-gen-go и protoc-gen-go-grpc выдаёт pb-пакет, интерфейс StringsServer и Strings_ServiceDesc.
- grpctrans.NewServer(ep, dec, enc, options...) возвращает grpctrans.Handler — ServeGRPC(ctx, req); склейку с каркасом делает твой grpcServer со встроенным UnimplementedStringsServer.
- Кодеки перекладывают pb-структуры в доменные пары из урока 02 и обратно; сериализацией занимается сам каркас.
- Бизнес-ошибка — в Err-поле reply по канону Failer; err канала — в gRPC-статус: DeadlineExceeded каркас конвертирует сам, остальное настраивает ServerErrorEncoder.
- Metadata = заголовки gRPC: ServerBefore/ClientBefore гонят request_id и спан через контекст; kitot.GRPCToContext и ContextToGRPC склеивают трейс.
- Интерсепторы каркаса (recovery, auth, метрики вызова) — вокруг всего вызова; ServerBefore — внутри Handler'а и только поставляет данные.
- grpctrans.NewClient(conn, &pb.Strings_ServiceDesc, method, enc, dec).Endpoint() — исходящая нога; в sd.Factory conn возвращается io.Closer и закрывается вместе с инстансом.
Домашнее задание
Переведи stringsvc на gRPC-ноги. Первое: опиши stringsvc.proto по уроку, сгенерируй pb-пакет командой protoc с paths=source_relative и убедись, что в pb-папке появились UppercaseRequest, StringsServer и Strings_ServiceDesc. Второе: напиши gRPC-кодеки и grpcServer, подними второй слушатель на :8082 рядом с HTTP из урока 04 и проверь grpcurl'ом оба метода — в логах из урока 06 должны появиться те же request_count и took, что и от HTTP-вызовов. Третье: добавь ServerErrorEncoder с тремя ветками (DeadlineExceeded, sentinel твоего сервиса, Internal) и через grpcurl вызови метод с пустой строкой — убедись, что приедет err в reply, а не статус. Четвёртое: напиши requestIDToContext и requestIDToMetadata, прогони вызов с заголовком x-request-id через прокси из урока 11 на gRPC-фабрике из урока 12 — request_id должен дожить до логов соседа. Пятое: оберни каркас ChainUnaryInterceptor(recovery, auth) и проверь оба сценария: вызов без токена отваливается до endpoint'а, а паника в тестовом endpoint'е возвращается как codes.Internal без обрыва соединения; запиши, где в цепочке оказались твои логи.
Личные заметкиПусто
Вопросы и ответы по уроку
ServerBefore или grpc.UnaryInterceptor — что использовать для аутентификации?
Интерсептор: аутентификация решает «пустить/не пустить» и должна сработать до декодирования и до Handler'а — интерсептор видит info.FullMethod и может не вызвать handler, вернув codes.Unauthenticated. ServerBefore работает внутри ServeGRPC, когда метод уже выбран: он умеет только поставлять данные в контекст. Рабочая связка: auth-интерсептор проверяет токен и кладёт идентичность в контекст, ServerBefore дальше дообогащает контекст request_id и спаном.
Нужен ли gRPC, если внешний API остаётся HTTP/JSON?
Это не «или/или»: в go-kit оба транспорта едят одни endpoint'ы, и типовая схема — HTTP/JSON наружу для людей и партнёров, gRPC между сервисами кластера, где важны контракт в .proto, кодогенерация клиентов и бинарная экономия. Запускай оба слушателя в одном main: логи, метрики, спаны, лимитеры и breaker'ы общие. Если же сервис ходит только в браузер или отдаёт вебхуки, gRPC ничего не даст — grpc-web решает проблему браузера, но добавляет слой прокси.
grpc.Dial или grpc.NewClient — что использовать в фабрике?
В новых версиях grpc-go grpc.Dial считается устаревшим именем: grpc.NewClient делает то же самое с честной семантикой — сразу возвращает ленивый ClientConn, не пытаясь устанавливать соединение в момент вызова. Для существующего кода с grpc.Dial ничего не ломается — сигнатуры совпадают. Ключевое для фабрики из урока 12 не меняется: conn ленив, первый вызов устанавливает соединение, а закрытие — забота io.Closer, который Endpointer вызовет при вычёркивании инстанса.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
gRPC-Go: высокопроизводительный RPC
Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.
- Писать .proto-схемы и генерировать код
- Реализовывать unary и streaming сервисы
- Управлять метаданными, дедлайнами и отменами
- Строить interceptors для логов и аутентификации