gRPC в Go: сервер, клиент и codegen
В gRPC разработчик обычно не пишет HTTP handler руками. Он описывает контракт в .proto, генерирует Go-код, реализует server interface и использует generated client.
Цепочка такая:
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-плагинов:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
Генерация:
protoc \
--go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
proto/calculator/v1/calculator.proto
После этого обычно появляются:
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:
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
Минимальный buf.gen.yaml:
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: .
opt:
- paths=source_relative
- remote: buf.build/grpc/go
out: .
opt:
- paths=source_relative
Тогда обычный цикл разработки выглядит так:
buf lint
buf generate
go test ./...
Если проект не использует remote plugins, можно поставить protoc-gen-go и protoc-gen-go-grpc локально и указать local plugins в buf.gen.yaml. Смысл тот же: .proto меняется руками, generated Go-код обновляется командой, а не редактируется вручную.
Контракт
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-интерфейсы.
Разработчик реализует примерно такой интерфейс:
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.
Структура проекта
Для маленького учебного сервиса достаточно такой раскладки:
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-маппинга.
Реализация сервера
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:
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: нормальные запросы успевают завершиться, зависшие не держат процесс бесконечно.
Клиент
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. Он использует тот же контракт, что и настоящий потребитель:
go run ./cmd/server
В другом терминале:
go run ./cmd/client
Для автоматической проверки можно поднять сервер на случайном порту в тесте:
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
Каждый сетевой вызов должен иметь ограничение по времени:
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.
Практика
- Создайте
CalculatorServiceс методамиAddиDivide. - Сгенерируйте Go-код.
- Реализуйте сервер на порту
:50051. - Напишите CLI-клиент, который вызывает
Add. - Добавьте
Divideи вернитеInvalidArgumentпри делении на ноль. - На клиенте обработайте ошибку через
status.FromError.
Типичные ошибки
- Редактировать
*.pb.goруками. Эти файлы перегенерируются. - Положить бизнес-логику внутрь generated-кода.
- Забыть timeout на клиентском вызове.
- Возвращать
fmt.Errorf("division by zero")наружу вместоstatus.Error(codes.InvalidArgument, ...). - Использовать insecure credentials в продакшене без понимания инфраструктурной защиты.
- Не читать generated interface и пытаться угадать сигнатуры.
Источники
- gRPC Go Quick Start
- gRPC Go Basics
- gRPC Go Generated Code Reference
- google.golang.org/grpc
- google.golang.org/grpc/credentials/insecure
Интерактивная практика
Какие файлы нельзя редактировать руками после генерации gRPC-кода?
Что выведет этот код?
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))
}
Реализуй ClientCallReview: для client call без deadline верни "timeout", для успешного вызова "ok", для ошибки "error".