Раздел 22 · Go

gRPC, protobuf, Buf и connect-go

middle-senior~50 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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/JSONgRPCGraphQL
Между сервисамиок, но schema driftдефолтредко
От браузерадефолт (или GraphQL)через gRPC-Web или connect-goдефолт для UI
КонтрактOpenAPI (опционально)proto-схема (обязательно)SDL
Бинарный форматнет (JSON)да (protobuf)нет (JSON)
Streamingчерез WebSocket/SSEвстроенный, 4 типаsubscriptions
Учитьсямалосреднеприлично
Дебаг рукамиcurlgrpcurlcurl плюс 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-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.