gRPC, protobuf, Buf и connect-go
Урок про межсервисный RPC в Go. Цель: после него ты соберёшь .proto,
сгенеришь код через Buf, поднимешь сервер и клиент, разберёшь четыре
типа стриминга, добавишь интерсепторы и поймёшь, когда брать
connect-go вместо grpc-go.
Считаем, что 07-http-services уже пройден, и общая идея HTTP-handler-а понятна. Контекст и cancellation, как в 04-concurrency-deep.
1. Когда HTTP/JSON, когда gRPC, когда GraphQL
Короткая таблица, чтобы не выбирать всякий раз “вкусом”:
| HTTP/JSON | gRPC | GraphQL | |
|---|---|---|---|
| Между сервисами | ок, но schema drift | дефолт | редко |
| От браузера | дефолт (или GraphQL) | через gRPC-Web или connect-go | дефолт для UI |
| Контракт | OpenAPI (опционально) | proto-схема (обязательно) | SDL |
| Бинарный формат | нет (JSON) | да (protobuf) | нет (JSON) |
| Streaming | через WebSocket/SSE | встроенный, 4 типа | subscriptions |
| Учиться | мало | средне | прилично |
| Дебаг руками | curl | grpcurl | curl плюс query |
Эвристика для pet-сервиса: между сервисами, gRPC. На edge (то, что видит браузер или мобайл), HTTP/JSON или GraphQL. Если хочется один handler для обоих, connect-go (см. секцию 9).
2. protobuf, схема как источник правды
Protocol Buffers это формат сериализации и
DSL для описания типов. Схема в .proto-файле, генератор делает Go-код.
// proto/user/v1/user.proto
syntax = "proto3";
package user.v1;
option go_package = "github.com/USERNAME/app/gen/user/v1;userv1";
import "google/protobuf/timestamp.proto";
message User {
int64 id = 1;
string email = 2;
google.protobuf.Timestamp created_at = 3;
}
message GetUserRequest {
int64 id = 1;
}
message GetUserResponse {
User user = 1;
}
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
Что в этой схеме критично:
- Номера полей (
= 1,= 2). Это wire format. Однажды присвоенный номер нельзя менять и нельзя переиспользовать. package user.v1плюсgo_package = "...;userv1". Версия в пакете, обычная конвенция:v1,v2,v3, не “обновлять на месте”.google.protobuf.Timestampдля дат. Свойint64 unix_msтоже работает, но Timestamp удобно мапится наtime.Timeв большинстве языков.- Все поля опциональные на wire. В proto3 нет required, есть
optional-keyword (с 1.15) для явного nullable. Иначе нулевые значения это zero-value.
Идиома: один .proto-файл, один service, типы рядом. Всё, что
шейрится между сервисами, в общем модуле или в общем buf-репозитории
(см. ниже).
3. Buf, замена protoc
Раньше для генерации использовали protoc плюс плагины. Сейчас
индустриальный стандарт это Buf:
brew install bufbuild/buf/buf
Конфиг buf.yaml в корне:
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
Конфиг генерации buf.gen.yaml:
version: v2
managed:
enabled: true
override:
- file_option: go_package_prefix
value: github.com/USERNAME/app/gen
plugins:
- remote: buf.build/protocolbuffers/go
out: gen
opt: paths=source_relative
- remote: buf.build/grpc/go
out: gen
opt: paths=source_relative
Команды:
buf lint # проверить .proto на стиль
buf breaking # сравнить с прошлой версией, не сломал ли wire
buf generate # сгенерировать Go-код в gen/
Что Buf даёт сверх protoc:
- Lint уровня “никогда не делай так в схеме” (имена пакетов, версии, перечисления).
- Breaking change detection, отдельная команда против прошлого коммита.
- Remote plugins, не нужно ставить
protoc-gen-goлокально. buf pushв BSR, public-private registry схем (если делаешь публичный API).
В CI прокидываем buf lint плюс buf breaking. Это два бесплатных
guard-rail-а от случайных нарушений совместимости.
4. Сервер и клиент, unary
После buf generate появляется пакет gen/user/v1 со типами User,
GetUserRequest, GetUserResponse и интерфейсом
UserServiceServer. Реализуем:
// internal/user/grpc.go
package user
import (
"context"
userv1 "github.com/USERNAME/app/gen/user/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
type GRPCServer struct {
userv1.UnimplementedUserServiceServer
svc *Service
}
func NewGRPCServer(svc *Service) *GRPCServer {
return &GRPCServer{svc: svc}
}
func (s *GRPCServer) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
u, err := s.svc.GetByID(ctx, req.GetId())
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "user %d not found", req.GetId())
}
return nil, status.Errorf(codes.Internal, "get user: %v", err)
}
return &userv1.GetUserResponse{
User: &userv1.User{
Id: u.ID,
Email: u.Email,
CreatedAt: timestamppb.New(u.CreatedAt),
},
}, nil
}
Что важно:
UnimplementedUserServiceServerчерез embedding обязателен. Без него при добавлении нового метода в.protoстарый код перестаёт компилироваться. С embed-ом, старый сервер просто возвращаетUnimplementedна новые методы.status.Errorf(codes.X, ...)идиома для возврата gRPC-ошибки. Никаких “обычных”errors.New, иначе клиент увидитcodes.Unknownи не сможет обработать.req.GetId()вместоreq.Id: getter не паникует на nil-receiver, возвращает zero-value.
Сервер:
// cmd/server/main.go
lis, err := net.Listen("tcp", ":9090")
if err != nil { return err }
srv := grpc.NewServer(
grpc.UnaryInterceptor(unaryInterceptor),
)
userv1.RegisterUserServiceServer(srv, user.NewGRPCServer(svc))
reflection.Register(srv) // для grpcurl
go func() { _ = srv.Serve(lis) }()
<-ctx.Done()
srv.GracefulStop()
Клиент:
conn, err := grpc.NewClient(
"localhost:9090",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil { return err }
defer conn.Close()
client := userv1.NewUserServiceClient(conn)
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, &userv1.GetUserRequest{Id: 7})
if err != nil {
if status.Code(err) == codes.NotFound {
// обработать
}
return err
}
fmt.Println(resp.GetUser().GetEmail())
grpc.NewClient (новое имя, deprecated grpc.Dial), создаёт
ленивое подключение. Реальный TCP открывается при первом вызове.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.