---
title: "gRPC транспорт в go-kit"
description: "gRPC-транспорт go-kit: proto-контракт stringsvc, grpctrans.NewServer и ServeGRPC, биндинг к grpc.Server, metadata, интерсепторы и gRPC-клиент в sd.Factory."
course: "go-kit: микросервисный тулкит"
url: "https://golangmaster.ru/courses/go-kit/lessons/13-grpc-transport/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 22
---

# gRPC транспорт в go-kit

*Курс «go-kit: микросервисный тулкит», урок 13 из 25. ~22 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-kit/lessons/13-grpc-transport/*

Фабрика из урока 12 строит HTTP-ноги, но индустрия для service-to-service выбирает gRPC: бинарный protobuf поверх HTTP/2 и контракт в .proto, из которого кодогенерация выдаёт структуры и интерфейс сервиса. Готовый go kit grpc-адаптер живёт в kit/transport/grpc — это тот же grpc transport, что и HTTP: на входе endpoint, по бокам те же кодеки, только вместо *http.Request каркас gRPC передаёт уже разобранные proto-сообщения. Ты соберёшь proto-контракт stringsvc, свяжешь endpoint'ы из урока 02 с grpctrans.NewServer через ServeGRPC, поднимешь второй слушатель на :8082 рядом с HTTP-сервером из урока 04, пропишешь metadata и интерсепторы каркаса, а на выходе — gRPC-клиент, который встаёт в sd.Factory из урока 12 и закрывает conn вместе с вычеркнутым инстансом.

### 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](/courses/go-kit/lessons/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 и кодогенерация

```go
syntax = "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 в деле

```go
import (
        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

```go
grpcListener, 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: таблица кодов

```go
grpctrans.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](/courses/go-kit/lessons/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](/courses/go-kit/lessons/07-metriki/), интерсептор их не заменяет, а дополняет транспортным уровнем.

### Recovery-интерсептор

```go
func 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](/courses/go-kit/lessons/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

```go
func 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-транспорта

1. Описать .proto-контракт по операциям интерфейса сервиса; Err-поле — строкой, номера полей — сразу как контракт.
2. Сгенерировать pb-пакет: protoc с protoc-gen-go и protoc-gen-go-grpc, go_package с путём к pb.
3. Написать пары gRPC-кодеков: decodeGRPC*Request и encodeGRPC*Response по паре на метод.
4. Собрать grpcServer со встроенным Unimplemented и Handler'ами через grpctrans.NewServer; конструктор NewGRPCServer от endpoint'ов.
5. Подключить ServerBefore: requestIDToContext и kitot.GRPCToContext; ServerErrorEncoder — таблица кодов.
6. Каркас: net.Listen :8082, grpc.NewServer с ChainUnaryInterceptor (recovery, auth), pb.RegisterStringsServer, Serve.
7. Клиент: фабрика с grpc.Dial + grpctrans.NewClient и conn как io.Closer — подставить в цепочку sd из уроков 11-12.
8. Проверить grpcurl'ом оба метода и убедиться, что логи, метрики и спаны из уроков 06-08 видят gRPC-вызовы.

> **Не смешивай статусы и Err-поле.** В 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?

- http.Handler — монтируется на mux, как kithttp.NewServer
- ✅ grpctrans.Handler с методом ServeGRPC — ты зовёшь его из метода своей реализации интерфейса
- pb.StringsServer — можно передавать в pb.RegisterStringsServer напрямую
- *grpc.Server — сразу вызываем Serve(listener)

Каркас gRPC вызывает методы твоего сгенерированного интерфейса, поэтому go-kit не может вернуть самодостаточный хендлер. NewServer возвращает Handler с ServeGRPC(ctx, req) — а склейкой занимается твой grpcServer: метод интерфейса передаёт запрос в ServeGRPC и приводит ответ к pb-типу. Именно поэтому «биндинг» — отдельный шаг, которого не было в HTTP: там точкой встречи был стандартный http.Handler.

**Вопрос:** Endpoint вернул err — context.DeadlineExceeded, ServerErrorEncoder не задан. Что увидит клиент?

- codes.InvalidArgument — каркас считает таймаут ошибкой клиента
- ✅ codes.DeadlineExceeded — каркас сам конвертирует ошибки контекста
- Ошибка приедет в Err-поле reply, вызов считается успешным
- codes.Internal — любые ошибки без энкодера считаются аварией

Каркас google.golang.org/grpc сам конвертирует ошибки контекста: DeadlineExceeded остаётся кодом DeadlineExceeded, Canceled — кодом Canceled. Остальные незнакомые ошибки уезжают как codes.Unknown — «непонятно что», по которому не отличить плохой запрос от аварии. Отсюда правило: ServerErrorEncoder нужен почти всегда — своя таблица кодов делает поведение сервиса предсказуемым, как codeFrom в HTTP из урока 04.

**Вопрос:** Куда класть аутентификацию по токену и recovery — в ServerBefore или в интерсептор каркаса?

- В ServerBefore — он и есть перехватчик go-kit, разницы нет
- ✅ В интерсептор: он обёрнут вокруг всего вызова и может оборвать его до декодирования
- Auth в ServerErrorEncoder, recovery в ServerAfter
- Видно будет только из бенчмарка — выбирают по вкусу

Интерсептор работает снаружи Handler'а: он видит info.FullMethod, сырой req и волен не вызвать handler — так auth отклоняет незалогиненный вызов до всякой работы, а recovery ловит панику в любом месте, включая ServerBefore. ServerBefore запускается уже внутри ServeGRPC: он поставляет данные в контекст (request_id, спан), но отклонить вызов не может. Разные уровни — разные обязанности; метрики endpoint'а при этом остаются в instrumentingMiddleware из урока 07.

### Один endpoint, два транспорта

```go
uppercase := makeUppercaseEndpoint(svc)
uppercase = loggingMiddleware(logger)(uppercase)      // урок 06
uppercase = instrumentingMiddleware(uppercase)        // урок 07

// HTTP на :8081 (урок 04) и gRPC на :8082 (этот урок)
// едят ОДИН И ТОТ ЖЕ uppercase.
// Пришёл вызов по gRPC — что окажется в логах и метриках?
```

**Что выведет программа?**

- Ничего: обёртки подключены только в HTTP-цепочке
- ✅ Те же записи took и request_count — обёртки стоят на endpoint'е и не знают, откуда пришёл вызов
- Двойные записи: gRPC вызов считает себя дважды
- Паника: endpoint нельзя вызывать из двух транспортов одновременно

Транспорт-агностичность из урока 01 на практике: middleware стоят на endpoint'е, а endpoint — функция от ctx и request, ему всё равно, кто построил request. loggingMiddleware запишет took с учётом gRPC-декодирования, instrumentingMiddleware посчитает вызов с теми же метками — request_count увеличится ровно один раз, из какого бы транспорта вызов ни пришёл. Если захочешь различать транспорты в метриках, добавь метку в ServerBefore через контекст — это работа поставки данных, как раз его уровень.

### Инстанс покраснел — что с conn?

```go
// Консул-цепочка из урока 12, фабрика — gRPC из этого урока:
// возвращает endpoint и conn как io.Closer.
// Инстанс перестал проходить health check и исчез из sd.Event.
```

**Что выведет программа?**

- Ничего: gRPC-соединения живут до остановки процесса
- ✅ Endpointer вызовет Close у фабричного результата — conn закроется вместе с инстансом
- grpc.Dial сам заметит смерть инстанса и переподключится к живому
- conn будет переиспользован для следующего добавленного инстанса

Вот зачем в контракте sd.Factory второе значение — io.Closer. Endpointer пересобирает набор endpoint'ов на каждое sd.Event: новорождённые строит фабрикой, вычеркнутые закрывает через их Closer. HTTP-фабрика возвращала nil — ей нечего было закрывать; gRPC-фабрика держит *grpc.ClientConn и обязана её вернуть. Автопереподключение внутри conn — правда, но оно спасает лишь от морганий сети того же инстанса: когда инстанс вычеркнут из реестра, endpoint с его conn больше не нужен — закрыть и освободить. Ресурсная гигиена discovery — побочный бонус контракта.

### Собери серверную и клиентскую половины

Восстанови четыре строки. Пропуски — по порядку: конструктор Handler'а в kit/transport/grpc, метод Handler'а, сгенерированная функция регистрации, конструктор соединения каркаса.

```go
// серверная половина:
upper := grpctrans.___(uppercase, decodeGRPCUppercaseRequest, encodeGRPCUppercaseResponse)

func (s *grpcServer) Uppercase(ctx context.Context, req *pb.UppercaseRequest) (*pb.UppercaseReply, error) {
        _, rep, err := s.uppercase.___(ctx, req)
        if err != nil {
                return nil, err
        }
        return rep.(*pb.UppercaseReply), nil
}

// main:
baseServer := grpc.NewServer()
pb.___(baseServer, NewGRPCServer(uppercase, count))

// клиентская половина — фабрика из урока 12:
conn, err := grpc.___(instance, grpc.WithTransportCredentials(insecure.NewCredentials()))
```

Подсказка: Первый пропуск — конструктор из kit/transport/grpc (не путай с каркасным grpc.NewServer ниже); второй — единственный метод grpctrans.Handler; третий — связка каркаса с реализацией; четвёртый — установка соединения с инстансом.

Правильные ответы в пропусках: `NewServer`, `ServeGRPC`, `RegisterStringsServer`, `Dial`

### Путь gRPC-вызова

Расставь этапы вызова Uppercase по gRPC: от grpcurl до ответа.

Правильный порядок строк:

1. `grpcurl послал вызов метода /strings.Strings/Uppercase по HTTP/2`
2. `Каркас gRPC нашёл Strings_ServiceDesc и запустил цепочку UnaryInterceptor`
3. `recovery-интерсептор обернул UnaryHandler и передал вызов дальше`
4. `grpcServer.Uppercase отдал *pb.UppercaseRequest в grpctrans.Handler.ServeGRPC`
5. `ServerBefore дописал в контекст request_id из metadata и спан из GRPCToContext`
6. `decodeGRPCUppercaseRequest собрал UppercaseRequest и позвал endpoint`
7. `endpoint прошёл обёртки курса и вернул UppercaseResponse с Err-полем`
8. `encodeGRPCUppercaseResponse собрал *pb.UppercaseReply, каркас сериализовал protobuf и отдал статус`

### Термины урока

- **grpctrans.Handler** — Интерфейс ServeGRPC(ctx, request) (context.Context, interface{}, error): стык каркаса gRPC с endpoint'ом; возвращается grpctrans.NewServer.
- **ServeGRPC** — Метод Handler'а: принимает уже разобранное proto-сообщение, отдаёт сгенерированный ответ; ошибки err канала уезжают как gRPC-статус.
- **ServerRequestFunc / ClientRequestFunc** — Функции для ServerBefore/ClientBefore: func(ctx, *metadata.MD) ctx — metadata в контекст на входе, контекст в metadata на выходе; там живут request_id и спан.
- **metadata.MD** — Заголовки gRPC: map[string][]string с ключами в нижнем регистре; gRPC-аналог http.Header, носитель uber-trace-id между сервисами.
- **ServerErrorEncoder** — Опция NewServer: превращает err канала в status.Error с осмысленным кодом — таблица codeFrom в мире gRPC; Err-поле для бизнеса не трогает.
- **grpctrans.NewClient** — (conn, ServiceDesc, methodName, enc, dec): исходящий endpoint поверх gRPC; в sd.Factory возвращает conn как io.Closer — Endpointer закроет при вычёркивании инстанса.

### Что дальше

Транспортная глава закрыта: endpoint из [урока 02-endpoint](/courses/go-kit/lessons/02-endpoint/) обзавёлся вторым лицом — HTTP из [урока 04-http-transport](/courses/go-kit/lessons/04-http-transport/) и gRPC из этого урока едят один и тот же набор с обёртками из уроков 06-10, а gRPC-клиент встал в фабрику из урока 12 и замкнул круг discovery. Освежить кодеки — в [уроке 05-codeki](/courses/go-kit/lessons/05-codeki/), балансировку с ретраями — в [уроке 11-lb-proxy](/courses/go-kit/lessons/11-lb-proxy/); карта курса — на [странице курса](/courses/go-kit/). Дальше два урока про middleware как самостоятельный слой: сначала урок 14-endpoint-middleware — логи и метрики как endpoint-обёртки, тот же instrumentingMiddleware, но собранный по правилам пакета endpoint; затем урок 15-service-middleware — декораторы сервиса и инъекция зависимостей. Транспорт может меняться, слои — нет: в этом и был план.

## Коротко: что запомнить

- 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 вызовет при вычёркивании инстанса.
