gRPC в Go: сервер, клиент и codegen

В gRPC разработчик обычно не пишет HTTP handler руками. Он описывает контракт в .proto, генерирует Go-код, реализует server interface и использует generated client.

Цепочка такая:

text
calculator.proto ↓ protoc + plugins calculator.pb.go calculator_grpc.pb.go ↓ ваш server implementation ваш client code

Инструменты

Нужны три вещи:

  • protoc - компилятор protobuf;
  • protoc-gen-go - генератор Go-типов для messages;
  • protoc-gen-go-grpc - генератор gRPC client/server кода.

Установка Go-плагинов:

bash
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

Генерация:

bash
protoc \ --go_out=. --go_opt=paths=source_relative \ --go-grpc_out=. --go-grpc_opt=paths=source_relative \ proto/calculator/v1/calculator.proto

После этого обычно появляются:

text
proto/calculator/v1/calculator.pb.go proto/calculator/v1/calculator_grpc.pb.go

Вариант с Buf

В современных проектах вместо длинной команды protoc часто используют buf. Он не заменяет protobuf и gRPC, а даёт удобный workflow вокруг схем:

  • единая конфигурация генерации;
  • lint .proto файлов;
  • breaking change checks;
  • воспроизводимый codegen в CI;
  • меньше ручных shell-команд у каждого разработчика.

Минимальный buf.yaml:

yaml
version: v2 modules: - path: proto lint: use: - STANDARD breaking: use: - FILE

Минимальный buf.gen.yaml:

yaml
version: v2 plugins: - remote: buf.build/protocolbuffers/go out: . opt: - paths=source_relative - remote: buf.build/grpc/go out: . opt: - paths=source_relative

Тогда обычный цикл разработки выглядит так:

bash
buf lint buf generate go test ./...

Если проект не использует remote plugins, можно поставить protoc-gen-go и protoc-gen-go-grpc локально и указать local plugins в buf.gen.yaml. Смысл тот же: .proto меняется руками, generated Go-код обновляется командой, а не редактируется вручную.


Контракт

proto
syntax = "proto3"; package calculator.v1; option go_package = "example.com/grpc-demo/proto/calculator/v1;calculatorv1"; service CalculatorService { rpc Add(AddRequest) returns (AddResponse); rpc Divide(DivideRequest) returns (DivideResponse); } message AddRequest { int64 a = 1; int64 b = 2; } message AddResponse { int64 result = 1; } message DivideRequest { int64 dividend = 1; int64 divisor = 2; } message DivideResponse { double result = 1; }

В calculator.pb.go будут Go-структуры сообщений. В calculator_grpc.pb.go будут gRPC-интерфейсы.

Разработчик реализует примерно такой интерфейс:

go
type CalculatorServiceServer interface { Add(context.Context, *AddRequest) (*AddResponse, error) Divide(context.Context, *DivideRequest) (*DivideResponse, error) }

Точная сигнатура находится в generated-файле, но идея именно такая. В современных версиях protoc-gen-go-grpc server interface может содержать служебное требование, связанное с Unimplemented...Server, поэтому не угадывайте интерфейс по памяти: откройте *_grpc.pb.go и реализуйте то, что сгенерировал ваш toolchain.


Структура проекта

Для маленького учебного сервиса достаточно такой раскладки:

text
grpc-demo/ go.mod buf.yaml buf.gen.yaml proto/ calculator/v1/calculator.proto gen/ calculator/v1/calculator.pb.go calculator/v1/calculator_grpc.pb.go cmd/ server/main.go client/main.go internal/ calculator/ server.go

Граница простая:

  • proto/ - контракт, который читают люди и генераторы;
  • gen/ - generated code, руками не правим;
  • internal/calculator/server.go - реализация generated server interface;
  • cmd/server - сборка gRPC server: listener, interceptors, регистрация сервисов;
  • cmd/client - локальный клиент для ручной проверки.

Такой layout помогает не превращать main.go в смесь transport setup, бизнес-логики и protobuf-маппинга.


Реализация сервера

go
package main import ( "context" "log" "net" calculatorv1 "example.com/grpc-demo/proto/calculator/v1" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) type CalculatorServer struct { calculatorv1.UnimplementedCalculatorServiceServer } func (s *CalculatorServer) Add( ctx context.Context, req *calculatorv1.AddRequest, ) (*calculatorv1.AddResponse, error) { return &calculatorv1.AddResponse{ Result: req.GetA() + req.GetB(), }, nil } func (s *CalculatorServer) Divide( ctx context.Context, req *calculatorv1.DivideRequest, ) (*calculatorv1.DivideResponse, error) { if req.GetDivisor() == 0 { return nil, status.Error(codes.InvalidArgument, "division by zero") } return &calculatorv1.DivideResponse{ Result: float64(req.GetDividend()) / float64(req.GetDivisor()), }, nil } func main() { lis, err := net.Listen("tcp", ":50051") if err != nil { log.Fatal(err) } grpcServer := grpc.NewServer() calculatorv1.RegisterCalculatorServiceServer(grpcServer, &CalculatorServer{}) log.Println("gRPC server listening on :50051") if err := grpcServer.Serve(lis); err != nil { log.Fatal(err) } }

UnimplementedCalculatorServiceServer встраивают, чтобы сервер был совместим с будущими добавлениями методов в generated interface. Если в .proto появится новый RPC, код не сломается совсем неожиданно, а gRPC вернёт корректный "unimplemented" для метода, который вы ещё не реализовали.

В production-коде main обычно делает чуть больше:

  • создаёт logger/config;
  • собирает usecase и repositories;
  • настраивает unary/stream interceptors;
  • регистрирует health/reflection для dev-среды;
  • корректно останавливает сервер по сигналу.

Graceful shutdown

Serve блокирует текущую горутину. Чтобы приложение завершалось аккуратно, сервер запускают отдельно и слушают SIGINT/SIGTERM:

go
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() grpcServer := grpc.NewServer() calculatorv1.RegisterCalculatorServiceServer(grpcServer, &CalculatorServer{}) serveErr := make(chan error, 1) go func() { serveErr <- grpcServer.Serve(lis) }() select { case <-ctx.Done(): log.Println("shutting down grpc server") done := make(chan struct{}) go func() { grpcServer.GracefulStop() close(done) }() select { case <-done: case <-time.After(10 * time.Second): grpcServer.Stop() } case err := <-serveErr: if err != nil { log.Fatal(err) } }

GracefulStop перестаёт принимать новые соединения и ждёт активные RPC. Stop рубит сразу. Поэтому обычно дают небольшой timeout: нормальные запросы успевают завершиться, зависшие не держат процесс бесконечно.


Клиент

go
package main import ( "context" "fmt" "log" "time" calculatorv1 "example.com/grpc-demo/proto/calculator/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) func main() { conn, err := grpc.NewClient( "localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { log.Fatal(err) } defer conn.Close() client := calculatorv1.NewCalculatorServiceClient(conn) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() resp, err := client.Add(ctx, &calculatorv1.AddRequest{ A: 40, B: 2, }) if err != nil { log.Fatal(err) } fmt.Println(resp.GetResult()) }

insecure.NewCredentials() допустим только для локального примера. В реальных сервисах обычно используют TLS или mesh/infra-уровень, который обеспечивает защищённый транспорт.

В старых примерах часто встречается grpc.Dial. В новом коде ориентируйтесь на актуальную документацию grpc-go и стиль версии, которую использует проект.

Для нового tutorial-кода в grpc-go начиная с v1.63 ориентируйтесь на grpc.NewClient. Он создаёт ClientConn как виртуальное соединение: реальный network connection устанавливается лениво при RPC, а при обрывах ClientConn сам управляет reconnect'ами. Поэтому обычно не нужно вручную "переподключать клиента" на каждый запрос.


Локальная проверка без curl

Самый надёжный способ проверить gRPC локально - написать маленький generated client. Он использует тот же контракт, что и настоящий потребитель:

bash
go run ./cmd/server

В другом терминале:

bash
go run ./cmd/client

Для автоматической проверки можно поднять сервер на случайном порту в тесте:

go
func TestCalculatorAdd(t *testing.T) { lis, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } srv := grpc.NewServer() calculatorv1.RegisterCalculatorServiceServer(srv, &CalculatorServer{}) go func() { _ = srv.Serve(lis) }() t.Cleanup(srv.Stop) conn, err := grpc.NewClient( lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = conn.Close() }) client := calculatorv1.NewCalculatorServiceClient(conn) resp, err := client.Add(context.Background(), &calculatorv1.AddRequest{A: 40, B: 2}) if err != nil { t.Fatal(err) } if resp.GetResult() != 42 { t.Fatalf("result = %d, want 42", resp.GetResult()) } }

Инструменты вроде grpcurl тоже подходят, но это не обычный curl: им нужен .proto файл или server reflection. Для учебного проекта Go-клиент и Go-тест обычно полезнее, потому что сразу проверяют generated client, deadline, status code и маппинг ошибок.


Где здесь timeout

Каждый сетевой вызов должен иметь ограничение по времени:

go
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() resp, err := client.Divide(ctx, &calculatorv1.DivideRequest{ Dividend: 10, Divisor: 2, })

Если deadline истечёт, клиент получит gRPC error с кодом DeadlineExceeded. Сервер при этом должен уважать ctx.Done(), особенно если метод делает долгие запросы в БД или внешние API.


Что читать в generated code

Откройте calculator_grpc.pb.go и найдите:

  • CalculatorServiceClient;
  • NewCalculatorServiceClient;
  • CalculatorServiceServer;
  • UnimplementedCalculatorServiceServer;
  • RegisterCalculatorServiceServer;
  • handler-функции, через которые gRPC связывает transport и ваш метод.

Это полезно сделать хотя бы один раз. После этого gRPC перестаёт выглядеть как магия: generated code просто вызывает ваш Go-метод с context.Context и request message.


Практика

  1. Создайте CalculatorService с методами Add и Divide.
  2. Сгенерируйте Go-код.
  3. Реализуйте сервер на порту :50051.
  4. Напишите CLI-клиент, который вызывает Add.
  5. Добавьте Divide и верните InvalidArgument при делении на ноль.
  6. На клиенте обработайте ошибку через status.FromError.

Типичные ошибки

  • Редактировать *.pb.go руками. Эти файлы перегенерируются.
  • Положить бизнес-логику внутрь generated-кода.
  • Забыть timeout на клиентском вызове.
  • Возвращать fmt.Errorf("division by zero") наружу вместо status.Error(codes.InvalidArgument, ...).
  • Использовать insecure credentials в продакшене без понимания инфраструктурной защиты.
  • Не читать generated interface и пытаться угадать сигнатуры.

Источники


Интерактивная практика

Quiz+10 XP

Какие файлы нельзя редактировать руками после генерации gRPC-кода?

  • cmd/server/main.go
  • *.pb.go и *_grpc.pb.go
  • internal/calculator/server.go
  • README.md
Predict+15 XP

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

go
package main import "fmt" func divideCode(divisor int) string { if divisor == 0 { return "InvalidArgument" } return "OK" } func main() { fmt.Println(divideCode(0)) fmt.Println(divideCode(2)) }
Задача+20 XP

Реализуй ClientCallReview: для client call без deadline верни "timeout", для успешного вызова "ok", для ошибки "error".