Когда сервисы общаются между собой, текстовый JSON-over-HTTP — не всегда лучший выбор: он многословен, не типизирован на уровне контракта и относительно медленно сериализуется. gRPC предлагает альтернативу: строгий контракт, бинарный формат (Protocol Buffers) и HTTP/2 в качестве транспорта со стримингом из коробки.
Для .NET-разработчика gRPC не новость — grpc-dotnet встроен в платформу. Но в .NET кодогенерация спрятана: вы добавляете .proto в .csproj через <Protobuf>, и при сборке проекта Visual Studio/SDK незаметно генерирует C#-классы клиента и сервера. В Go этот шаг явный и внешний: контракт описывается в .proto, а код генерируется отдельной командой (protoc или, по-современному, buf — об этом следующая глава). Эта глава разбирает gRPC в Go end-to-end: от контракта до балансировки и mTLS.
Всё начинается с файла .proto — языко-независимого описания сервиса и сообщений. Это единственный источник правды о контракте; код для любого языка генерируется из него.
syntax = "proto3";
package items.v1;
// Куда генерировать Go-код (важно для импортов).
option go_package = "github.com/acme/items/gen/items/v1;itemsv1";
service ItemService {
// Unary: один запрос — один ответ.
rpc GetItem(GetItemRequest) returns (Item);
}
message GetItemRequest {
string id = 1; // номера полей — часть бинарного контракта, менять нельзя
}
message Item {
string id = 1;
string name = 2;
int64 price_cents = 3;
}Ключевая для Go деталь — option go_package: она задаёт путь импорта генерируемого пакета (часть до ;) и его имя (после ;). Номера полей (= 1, = 2) — это и есть бинарный контракт Protobuf: на проводе передаются именно они, а не имена, поэтому переименовать поле безопасно, а вот менять номер — нельзя.
Параллель с .NET: сам
.protoв Go и .NET идентичен — это общий стандарт Protobuf, файлы переносимы между платформами один в один. Различие только вoption go_package(Go-специфичная директива) и в том, когда и как из этого.protoпоявляется код. В .NET — неявно, при сборке проекта. В Go — явной командой, а результат коммитится в репозиторий.
Классический тулчейн состоит из компилятора protoc и двух плагинов:
protoc-gen-go— генерирует Go-структуры дляmessage(сериализация/десериализация Protobuf).protoc-gen-go-grpc— генерирует интерфейсы клиента и сервера дляservice.
Плагины ставятся как Go-инструменты:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latestЗапуск генерации (голым protoc — в следующей главе увидим, почему его вытеснил buf):
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
items/v1/items.protoНа выходе появляются два файла, которые коммитятся в git:
items/v1/items.pb.go— структурыGetItemRequest,Itemи их методы.items/v1/items_grpc.pb.go— интерфейсыItemServiceServer(его вы реализуете) иItemServiceClient(его вызываете).
flowchart LR
PROTO["items.proto<br/>(контракт)"] --> PROTOC["protoc + плагины"]
PROTOC --> PB["items.pb.go<br/>(сообщения)"]
PROTOC --> GRPC["items_grpc.pb.go<br/>(клиент + сервер)"]
PB --> SRV["ваша реализация<br/>сервера"]
GRPC --> SRV
PB --> CLI["вызывающий код<br/>(клиент)"]
GRPC --> CLI
Сгенерированный ItemServiceServer — это интерфейс. Вы пишете тип, который его реализует, встраивая UnimplementedItemServiceServer (это даёт forward-совместимость: если в контракт добавят новый метод, ваш сервер не перестанет компилироваться).
type itemServer struct {
itemsv1.UnimplementedItemServiceServer // встраивание ради forward-совместимости
}
func (s *itemServer) GetItem(ctx context.Context, req *itemsv1.GetItemRequest) (*itemsv1.Item, error) {
if req.GetId() == "" {
// Ошибки gRPC несут код состояния (аналог HTTP-статуса).
return nil, status.Error(codes.InvalidArgument, "id обязателен")
}
return &itemsv1.Item{
Id: req.GetId(),
Name: "Виджет",
PriceCents: 999,
}, nil
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatal(err)
}
s := grpc.NewServer()
itemsv1.RegisterItemServiceServer(s, &itemServer{})
log.Fatal(s.Serve(lis)) // блокируется, обслуживая соединения
}Обратите внимание: первый аргумент метода — ctx context.Context. Через него приходят дедлайн и отмена с клиентской стороны (см. select и context). Ошибки возвращаются не как обычный error, а с кодом состояния через status.Error(codes.X, msg) — это богаче, чем HTTP-статус, и стандартизировано для всех языков.
Параллель с .NET: в
grpc-dotnetвы наследуете сгенерированный базовый классItemService.ItemServiceBaseи переопределяете методы; сервис регистрируется в DI и роутинге черезapp.MapGrpcService<...>(). В Go вместо наследования базового класса — встраиваниеUnimplemented...структуры (композиция вместо наследования), а вместо DI-регистрации — явный вызовRegisterItemServiceServer(s, impl). Коды ошибокcodes.InvalidArgument/status.ErrorсоответствуютRpcException(new Status(StatusCode.InvalidArgument, ...)).
Современный grpc-go создаёт соединение через grpc.NewClient (старые grpc.Dial/DialContext помечены deprecated). Соединение (*grpc.ClientConn) — долгоживущее и потокобезопасное; его создают один раз и переиспользуют.
conn, err := grpc.NewClient(
"localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()), // без TLS — только для локалки!
)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
client := itemsv1.NewItemServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
item, err := client.GetItem(ctx, &itemsv1.GetItemRequest{Id: "42"})
if err != nil {
log.Fatalf("GetItem: %v", err)
}
fmt.Printf("%s — %d коп.\n", item.GetName(), item.GetPriceCents())Два важных момента. Во-первых, insecure.NewCredentials() отключает TLS — это допустимо только для локальной разработки; в проде нужны транспортные credentials (ниже про mTLS). Во-вторых, context с таймаутом задаёт дедлайн на конкретный вызов — он автоматически прокинется на сервер.
Параллель с .NET:
grpc.NewClient+NewItemServiceClient≈ создание каналаGrpcChannel.ForAddress(...)и клиентаnew ItemService.ItemServiceClient(channel). В ASP.NET Core клиента обычно регистрируют черезservices.AddGrpcClient<ItemServiceClient>(...)(с интеграцией в DI иIHttpClientFactory); в Go DI нет — вы держите*grpc.ClientConnсами и передаёте клиента как зависимость явно.
gRPC (поверх HTTP/2) поддерживает не только «запрос-ответ», а четыре режима. Различие задаётся ключевым словом stream в .proto:
service ChatService {
rpc Send(Msg) returns (Ack); // 1. unary
rpc Subscribe(Filter) returns (stream Msg); // 2. server-streaming
rpc Upload(stream Chunk) returns (UploadResult); // 3. client-streaming
rpc Chat(stream Msg) returns (stream Msg); // 4. bidi-streaming
}| Вид | .proto |
Когда применять | Аналог в .NET |
|---|---|---|---|
| Unary | (Req) returns (Resp) |
обычный запрос-ответ | Task<Resp> |
| Server-streaming | (Req) returns (stream Resp) |
сервер шлёт поток (подписка, лента событий) | IAsyncEnumerable<Resp> / responseStream |
| Client-streaming | (stream Req) returns (Resp) |
клиент шлёт поток (загрузка чанками) | requestStream |
| Bidi-streaming | (stream Req) returns (stream Resp) |
двусторонний обмен (чат, телеметрия) | оба потока |
Unary мы уже видели. Для контраста — набросок server-streaming сервера: вместо return ответа вы много раз вызываете stream.Send(...):
func (s *chatServer) Subscribe(req *chatv1.Filter, stream chatv1.ChatService_SubscribeServer) error {
for i := 0; i < 3; i++ {
if err := stream.Send(&chatv1.Msg{Text: fmt.Sprintf("событие %d", i)}); err != nil {
return err // клиент отвалился — прекращаем
}
time.Sleep(time.Second)
}
return nil // возврат nil закрывает поток
}А вот клиент, читающий этот поток в цикле до io.EOF:
stream, err := client.Subscribe(ctx, &chatv1.Filter{})
if err != nil {
log.Fatal(err)
}
for {
msg, err := stream.Recv()
if err == io.EOF { // поток корректно закрыт сервером
break
}
if err != nil {
log.Fatal(err)
}
fmt.Println(msg.GetText())
}Идиома for { Recv() ... io.EOF } — стандартный способ читать gRPC-поток в Go. В client-streaming симметрично: клиент много раз делает stream.Send(...), затем stream.CloseAndRecv(). В bidi оба конца читают и пишут одновременно (часто из разных горутин).
Параллель с .NET: server-streaming в
grpc-dotnetобычно выражают черезIAsyncEnumerable<T>на сервере (yield return) иawait foreachна клиенте — компилятор прячет курсор потока. В Go стриминг явный: вы сами вызываетеSend/Recvи сами ловитеio.EOFкак сигнал конца. Это многословнее, но прозрачно — видно каждое сообщение и каждую границу потока.
Здесь — важное архитектурное отличие от привычной модели «балансировщик-прокси». gRPC использует HTTP/2 с долгоживущими соединениями: классический L4-балансировщик, раздающий соединения по бэкендам, бесполезен — все запросы поедут по одному залипшему коннекту. Поэтому gRPC балансирует на стороне клиента: клиент сам знает про все адреса бэкендов и раскидывает по ним вызовы.
Включается это связкой «resolver + service config». DNS-resolver (dns:///) узнаёт список адресов за именем, а service config указывает политику round_robin:
conn, err := grpc.NewClient(
"dns:///items.prod.svc.cluster.local:50051", // dns:/// — резолвим ВСЕ A-записи
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"round_robin":{}}]}`),
)Что здесь происходит:
- Схема
dns:///(с тремя слешами) включает DNS-resolver, который вытягивает все адреса за именем (в Kubernetes — все Pod'ы за headless-сервисом), а не один. round_robinзаставляет клиент открыть подсоединения ко всем адресам и раскладывать вызовы по кругу: 1-й вызов → backend-1, 2-й → backend-2, и так далее.
flowchart TD
CLI["gRPC-клиент<br/>(round_robin)"]
R["DNS resolver<br/>dns:///items..."]
CLI -- "узнаёт все адреса" --> R
CLI -->|"вызов 1"| B1["backend-1"]
CLI -->|"вызов 2"| B2["backend-2"]
CLI -->|"вызов 3"| B3["backend-3"]
CLI -. "вызов 4 → снова backend-1" .-> B1
Параллель с .NET: в
grpc-dotnetту же client-side балансировку настраивают черезGrpcChannelOptionsсоServiceConfig { LoadBalancingConfigs = { new RoundRobinConfig() } }и резолвер адресов. Концепция идентична (балансирует клиент, а не прокси) — отличается лишь синтаксис: в Go политика задаётся строкой JSON вWithDefaultServiceConfig, что языко-независимо и совпадает с форматомservice configиз спецификации gRPC.
В проде соединения шифруют TLS, а для межсервисного доверия часто используют mTLS (mutual TLS): не только клиент проверяет сервер, но и сервер проверяет клиента по его сертификату. В Go это настраивается через пакет credentials поверх стандартного crypto/tls.
// Сервер: предъявляет свой серт и требует+проверяет серт клиента.
serverCert, _ := tls.LoadX509KeyPair("server.crt", "server.key")
caPool := x509.NewCertPool()
caPEM, _ := os.ReadFile("ca.crt")
caPool.AppendCertsFromPEM(caPEM)
creds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientCAs: caPool,
ClientAuth: tls.RequireAndVerifyClientCert, // <- это и делает TLS взаимным
})
s := grpc.NewServer(grpc.Creds(creds))// Клиент: предъявляет свой серт и проверяет серт сервера по тому же CA.
clientCert, _ := tls.LoadX509KeyPair("client.crt", "client.key")
creds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{clientCert},
RootCAs: caPool,
})
conn, _ := grpc.NewClient("items.prod:50051", grpc.WithTransportCredentials(creds))Суть взаимности — в ClientAuth: tls.RequireAndVerifyClientCert на сервере: без неё это был бы обычный односторонний TLS. Оба конца доверяют сертификатам, подписанным общим CA.
Параллель с .NET: это
tls.Config/credentials.NewTLSпротивHttpClientHandler.ClientCertificatesна клиенте и настроек Kestrel (ConfigureHttpsDefaultsсClientCertificateMode.RequireCertificate) на сервере. Механика X.509 и mTLS одинаковая (это уровень TLS, а не gRPC); различается лишь API настройкиtls.ConfigпротивHttpsConnectionAdapterOptions.
Аналог HTTP-middleware в мире gRPC — interceptors. Они оборачивают вызовы для сквозной логики (логирование, метрики, аутентификация, recover). Бывают unary и stream, по обе стороны (клиент/сервер):
// Серверный unary-перехватчик: логирует метод и длительность каждого вызова.
func loggingUnary(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
start := time.Now()
resp, err := handler(ctx, req) // передаём управление дальше (как next в HTTP-middleware)
log.Printf("%s — %s — err=%v", info.FullMethod, time.Since(start), err)
return resp, err
}
s := grpc.NewServer(grpc.ChainUnaryInterceptor(loggingUnary /*, authUnary, ... */))grpc.ChainUnaryInterceptor (и парный ChainStreamInterceptor) собирает несколько перехватчиков в цепочку — прямой аналог цепочки HTTP-middleware из предыдущей главы. Структурно handler(ctx, req) здесь играет ту же роль, что next.ServeHTTP(w, r) в HTTP.
Параллель с .NET: gRPC-interceptors ≈
Interceptorизgrpc-dotnet(базовый класс сUnaryServerHandlerи т.п.), которые регистрируют черезoptions.Interceptors.Add<...>(). Идея и место в конвейере те же; различие — в способе сборки цепочки (в Go этоChainUnaryInterceptor, в .NET — список в опциях с DI). Тема обсервабилити и перехвата подробно разбирается в Разделе 11.
- gRPC строится на контракте в
.proto: один языко-независимый файл — источник правды для всех сторон. Сам.protoв Go и .NET идентичен; различается процесс генерации. - В Go кодогенерация — явный внешний шаг:
protoc+protoc-gen-go(сообщения) +protoc-gen-go-grpc(клиент/сервер); результат (*.pb.go) коммитится в репозиторий. В .NET это спрятано в сборку проекта через<Protobuf>. - Сервер реализуют, встраивая
Unimplemented...(композиция вместо наследования) и регистрируя черезRegisterXServiceServer; ошибки несут код состояния (status.Error(codes.X, ...)). - Клиент создают через
grpc.NewClient(не устаревшийgrpc.Dial), переиспользуют*grpc.ClientConn, задают дедлайн черезcontext. - Четыре вида вызовов (unary, server/client/bidi-streaming) задаются словом
streamв.proto; стриминг в Go явный —Send/Recvиio.EOFвместоIAsyncEnumerable. - Балансировка в gRPC — client-side (
round_robin+dns:///-resolver черезWithDefaultServiceConfig), а не прокси, из-за долгоживущих HTTP/2-соединений. - mTLS настраивается через
credentials.NewTLSповерхcrypto/tls(tls.RequireAndVerifyClientCertделает TLS взаимным); сквозная логика — через interceptors (аналог middleware).
Дальше — как командой шарить .proto-контракты между сервисами и почему ручной protoc уступил место инструменту Buf.
⌂ Главная · ↑ Раздел · ← Предыдущий: HTTP-сервер · → Следующий: Шаринг контрактов и Buf