Урок 17: gRPC и Protocol Buffers — основы

Урок 17. gRPC и Protocol Buffers — основы

🔄 Node.js → Go (ключевые аналоги):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-grpc-basics && cd go-grpc-basics
go mod init go-grpc-basics

# Устанавливаем gRPC и protobuf
go get google.golang.org/grpc
go get google.golang.org/protobuf
go get github.com/google/uuid
go mod tidy

# Устанавливаем protoc (компилятор .proto) — macOS
brew install protobuf

# Или Linux
apt-get install -y protobuf-compiler

# Устанавливаем Go-плагины для protoc
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

# Убедитесь, что $GOPATH/bin в PATH
export PATH="$PATH:$(go env GOPATH)/bin"

# Создаём директории
mkdir -p proto
mkdir -p server
mkdir -p client

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

go-grpc-basics/ ├── proto/ │ └── tasks/ │ └── tasks.proto # Определение сервиса и сообщений ├── server/ │ └── main.go # gRPC сервер ├── client/ │ └── main.go # gRPC клиент ├── go.mod └── go.sum

💻 Файл: proto/tasks/tasks.proto

// Версия синтаксиса protobuf (proto3 — современный стандарт)
syntax = "proto3";

// Пакет (для генерации кода)
package tasks;

// go_package определяет путь импорта в Go
// формат: полный_путь_модуля/относительный_путь;имя_пакета
option go_package = "go-grpc-basics/gen/tasks;taskspb";

// Импорт для google.protobuf.Timestamp
import "google/protobuf/timestamp.proto";

// ╔══════════════════════════════════════════════════════════╗
// ║  СЕРВИС (RPC методы)                                   ║
// ╚══════════════════════════════════════════════════════════╝

// TaskService — сервис для управления задачами
service TaskService {
  // Unary RPC: простой запрос-ответ
  // rpc ИмяМетода(Запрос) returns (Ответ);
  rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse);
  rpc GetTask(GetTaskRequest) returns (GetTaskResponse);
  rpc ListTasks(ListTasksRequest) returns (ListTasksResponse);
  rpc UpdateTask(UpdateTaskRequest) returns (UpdateTaskResponse);
  rpc DeleteTask(DeleteTaskRequest) returns (DeleteTaskResponse);
}

// ╔══════════════════════════════════════════════════════════╗
// ║  СООБЩЕНИЯ (Messages)                                  ║
// ╚══════════════════════════════════════════════════════════╝

// Task — основная сущность
message Task {
  string id = 1;                        // Номер поля важен для бинарной сериализации
  string title = 2;
  string description = 3;
  TaskStatus status = 4;                // enum
  Priority priority = 5;                // вложенный enum
  google.protobuf.Timestamp created_at = 6;
  google.protobuf.Timestamp updated_at = 7;
  optional google.protobuf.Timestamp completed_at = 8; // optional = может быть null
  repeated string tags = 9;             // repeated = массив
  string assignee = 10;
}

// TaskStatus — перечисление (enum)
enum TaskStatus {
  TASK_STATUS_UNSPECIFIED = 0;  // Всегда начинайте enum с 0 (значение по умолчанию)
  TASK_STATUS_PENDING = 1;
  TASK_STATUS_IN_PROGRESS = 2;
  TASK_STATUS_DONE = 3;
  TASK_STATUS_CANCELLED = 4;
}

// Priority — приоритет
enum Priority {
  PRIORITY_UNSPECIFIED = 0;
  PRIORITY_LOW = 1;
  PRIORITY_MEDIUM = 2;
  PRIORITY_HIGH = 3;
  PRIORITY_CRITICAL = 4;
}

// ==========================================
// Запросы и ответы для каждого RPC
// ==========================================

message CreateTaskRequest {
  string title = 1;
  string description = 2;
  Priority priority = 3;
  repeated string tags = 4;
  string assignee = 5;
}

message CreateTaskResponse {
  Task task = 1;
}

message GetTaskRequest {
  string id = 1;
}

message GetTaskResponse {
  Task task = 1;
}

message ListTasksRequest {
  // Фильтры (опциональные)
  optional TaskStatus status = 1;
  optional Priority priority = 2;
  string assignee = 3;

  // Пагинация
  int32 page_size = 10;   // Сколько элементов на странице
  string page_token = 11;  // Токен для следующей страницы
}

message ListTasksResponse {
  repeated Task tasks = 1;
  string next_page_token = 2;  // Токен для следующей страницы (пустой = конец)
  int32 total_count = 3;       // Общее количество
}

message UpdateTaskRequest {
  string id = 1;
  // Поля, которые можно обновить (все опциональные)
  optional string title = 2;
  optional string description = 3;
  optional TaskStatus status = 4;
  optional Priority priority = 5;
  repeated string tags = 6;
  optional string assignee = 7;
}

message UpdateTaskResponse {
  Task task = 1;
}

message DeleteTaskRequest {
  string id = 1;
}

message DeleteTaskResponse {
  bool success = 1;
}

💻 Генерация кода

# Создаём директорию для сгенерированного кода
mkdir -p gen/tasks

# Генерируем Go-код из .proto
protoc \
  --proto_path=proto \
  --go_out=gen/tasks \
  --go_opt=paths=source_relative \
  --go-grpc_out=gen/tasks \
  --go-grpc_opt=paths=source_relative \
  proto/tasks/*.proto

# После генерации появятся файлы:
# gen/tasks/tasks.pb.go      — сгенерированные структуры (сообщения)
# gen/tasks/tasks_grpc.pb.go — сгенерированный сервер/клиент

💻 Файл: server/main.go — gRPC Сервер

package main

import (
    "context"
    "fmt"
    "log"
    "net"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"

    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    "google.golang.org/protobuf/types/known/timestamppb"
    "github.com/google/uuid"

    // Импорт сгенерированного кода
    pb "go-grpc-basics/gen/tasks"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  РЕАЛИЗАЦИЯ СЕРВЕРА                                    ║
// ╚══════════════════════════════════════════════════════════╝

// TaskServer — реализует интерфейс TaskServiceServer (сгенерирован)
type TaskServer struct {
    // UnimplementedTaskServiceServer ВСТРАИВАЕТСЯ для прямой совместимости.
    // Если в будущем в .proto добавятся новые методы, старый код не сломается.
    pb.UnimplementedTaskServiceServer

    mu    sync.RWMutex
    tasks map[string]*pb.Task // In-memory хранилище
}

// NewTaskServer — конструктор
func NewTaskServer() *TaskServer {
    return &TaskServer{
        tasks: make(map[string]*pb.Task),
    }
}

// CreateTask — реализация RPC метода
func (s *TaskServer) CreateTask(
    ctx context.Context,
    req *pb.CreateTaskRequest,
) (*pb.CreateTaskResponse, error) {
    // Валидация
    if req.Title == "" {
        // status.Error создаёт gRPC-ошибку с кодом
        return nil, status.Error(codes.InvalidArgument, "title must not be empty")
    }

    // Создаём задачу
    now := timestamppb.Now() // Текущее время в protobuf-формате
    task := &pb.Task{
        Id:          uuid.New().String(),
        Title:       req.Title,
        Description: req.Description,
        Status:      pb.TaskStatus_TASK_STATUS_PENDING,
        Priority:    req.Priority,
        CreatedAt:   now,
        UpdatedAt:   now,
        Tags:        req.Tags,
        Assignee:    req.Assignee,
    }

    s.mu.Lock()
    s.tasks[task.Id] = task
    s.mu.Unlock()

    log.Printf("📝 Задача создана: %s [%s]", task.Title, task.Id)
    return &pb.CreateTaskResponse{Task: task}, nil
}

// GetTask — получение задачи по ID
func (s *TaskServer) GetTask(
    ctx context.Context,
    req *pb.GetTaskRequest,
) (*pb.GetTaskResponse, error) {
    if req.Id == "" {
        return nil, status.Error(codes.InvalidArgument, "id must not be empty")
    }

    s.mu.RLock()
    task, ok := s.tasks[req.Id]
    s.mu.RUnlock()

    if !ok {
        return nil, status.Errorf(codes.NotFound, "task %q not found", req.Id)
    }

    return &pb.GetTaskResponse{Task: task}, nil
}

// ListTasks — список задач с фильтрацией
func (s *TaskServer) ListTasks(
    ctx context.Context,
    req *pb.ListTasksRequest,
) (*pb.ListTasksResponse, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    var filtered []*pb.Task
    for _, task := range s.tasks {
        // Применяем фильтры
        if req.Status != nil && task.Status != *req.Status {
            continue
        }
        if req.Priority != nil && task.Priority != *req.Priority {
            continue
        }
        if req.Assignee != "" && task.Assignee != req.Assignee {
            continue
        }
        filtered = append(filtered, task)
    }

    // Пагинация
    totalCount := int32(len(filtered))
    pageSize := req.PageSize
    if pageSize <= 0 {
        pageSize = 10 // По умолчанию
    }

    // Простая offset-пагинация
    start := 0
    if req.PageToken != "" {
        // В реальности — декодируем токен в offset
        fmt.Sscanf(req.PageToken, "%d", &start)
    }

    end := start + int(pageSize)
    if end > len(filtered) {
        end = len(filtered)
    }

    var nextPageToken string
    if end < len(filtered) {
        nextPageToken = fmt.Sprintf("%d", end)
    }

    // Возвращаем пустой слайс, а не nil
    result := filtered[start:end]
    if result == nil {
        result = []*pb.Task{}
    }

    return &pb.ListTasksResponse{
        Tasks:         result,
        NextPageToken: nextPageToken,
        TotalCount:    totalCount,
    }, nil
}

// UpdateTask — обновление задачи
func (s *TaskServer) UpdateTask(
    ctx context.Context,
    req *pb.UpdateTaskRequest,
) (*pb.UpdateTaskResponse, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    task, ok := s.tasks[req.Id]
    if !ok {
        return nil, status.Errorf(codes.NotFound, "task %q not found", req.Id)
    }

    // Обновляем только переданные поля
    if req.Title != nil {
        task.Title = *req.Title
    }
    if req.Description != nil {
        task.Description = *req.Description
    }
    if req.Status != nil {
        task.Status = *req.Status
        if *req.Status == pb.TaskStatus_TASK_STATUS_DONE {
            task.CompletedAt = timestamppb.Now()
        }
    }
    if req.Priority != nil {
        task.Priority = *req.Priority
    }
    if req.Tags != nil {
        task.Tags = req.Tags
    }
    if req.Assignee != nil {
        task.Assignee = *req.Assignee
    }

    task.UpdatedAt = timestamppb.Now()

    log.Printf("✏️ Задача обновлена: %s", task.Id)
    return &pb.UpdateTaskResponse{Task: task}, nil
}

// DeleteTask — удаление задачи
func (s *TaskServer) DeleteTask(
    ctx context.Context,
    req *pb.DeleteTaskRequest,
) (*pb.DeleteTaskResponse, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, ok := s.tasks[req.Id]; !ok {
        return nil, status.Errorf(codes.NotFound, "task %q not found", req.Id)
    }

    delete(s.tasks, req.Id)
    log.Printf("🗑️ Задача удалена: %s", req.Id)
    return &pb.DeleteTaskResponse{Success: true}, nil
}

// ╔══════════════════════════════════════════════════════════╗
// ║  MAIN (СЕРВЕР)                                          ║
// ╚══════════════════════════════════════════════════════════╝

func main() {
    log.SetFlags(log.LstdFlags | log.Lmicroseconds)
    log.Println("🚀 Запуск gRPC сервера...")

    // Создаём TCP listener
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("Не удалось открыть порт: %v", err)
    }

    // Создаём gRPC сервер
    grpcServer := grpc.NewServer(
        // Можно добавить интерцепторы (логирование, трейсинг — урок 18)
    )

    // Регистрируем наш сервис
    taskServer := NewTaskServer()
    pb.RegisterTaskServiceServer(grpcServer, taskServer)

    log.Println("✅ gRPC сервер слушает на :50051")
    log.Println("Доступные методы:")
    log.Println("  - CreateTask")
    log.Println("  - GetTask")
    log.Println("  - ListTasks")
    log.Println("  - UpdateTask")
    log.Println("  - DeleteTask")

    // Graceful shutdown
    go func() {
        sigCh := make(chan os.Signal, 1)
        signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
        <-sigCh
        log.Println("🛑 Выключение сервера...")
        grpcServer.GracefulStop()
    }()

    if err := grpcServer.Serve(lis); err != nil {
        log.Fatalf("Ошибка сервера: %v", err)
    }

    log.Println("✅ Сервер остановлен")
}

💻 Файл: client/main.go — gRPC Клиент

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    "google.golang.org/grpc/status"

    pb "go-grpc-basics/gen/tasks"
)

func main() {
    log.SetFlags(log.LstdFlags | log.Lmicroseconds)
    log.Println("🔌 Подключение к gRPC серверу...")

    // Устанавливаем соединение
    // insecure — без TLS (для разработки!)
    conn, err := grpc.Dial(
        "localhost:50051",
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        grpc.WithBlock(), // Ждать подключения
        grpc.WithTimeout(5*time.Second),
    )
    if err != nil {
        log.Fatalf("Ошибка подключения: %v", err)
    }
    defer conn.Close()

    // Создаём клиент (stub)
    client := pb.NewTaskServiceClient(conn)

    ctx := context.Background()

    // ==========================================
    // 1. CREATE — создаём задачи
    // ==========================================
    log.Println("\n── CREATE ──")

    tasks := []struct {
        title    string
        priority pb.Priority
        assignee string
    }{
        {"Изучить gRPC", pb.Priority_PRIORITY_HIGH, "alice"},
        {"Написать микросервис", pb.Priority_PRIORITY_CRITICAL, "bob"},
        {"Настроить CI/CD", pb.Priority_PRIORITY_MEDIUM, "alice"},
    }

    var createdIDs []string
    for _, t := range tasks {
        resp, err := client.CreateTask(ctx, &pb.CreateTaskRequest{
            Title:    t.title,
            Priority: t.priority,
            Assignee: t.assignee,
            Tags:     []string{"demo", "grpc"},
        })
        if err != nil {
            log.Printf("  ❌ Ошибка: %v", err)
            continue
        }
        createdIDs = append(createdIDs, resp.Task.Id)
        log.Printf("  ✓ Создана: [%s] %s", resp.Task.Id[:8], resp.Task.Title)
    }

    if len(createdIDs) == 0 {
        log.Fatal("Не создано ни одной задачи")
    }

    // ==========================================
    // 2. GET — получаем задачу
    // ==========================================
    log.Println("\n── GET ──")
    resp, err := client.GetTask(ctx, &pb.GetTaskRequest{Id: createdIDs[0]})
    if err != nil {
        // Извлекаем gRPC статус
        st, _ := status.FromError(err)
        log.Printf("  ❌ Ошибка: код=%s сообщение=%s", st.Code(), st.Message())
    } else {
        task := resp.Task
        log.Printf("  ✓ Задача: %s", task.Title)
        log.Printf("    Статус: %s", task.Status)
        log.Printf("    Приоритет: %s", task.Priority)
        log.Printf("    Создана: %s", task.CreatedAt.AsTime().Format(time.RFC3339))
    }

    // ==========================================
    // 3. LIST — список задач
    // ==========================================
    log.Println("\n── LIST ──")
    listResp, err := client.ListTasks(ctx, &pb.ListTasksRequest{
        Assignee: "alice",
        PageSize: 10,
    })
    if err != nil {
        log.Printf("  ❌ Ошибка: %v", err)
    } else {
        log.Printf("  ✓ Задачи Alice (всего %d):", listResp.TotalCount)
        for _, task := range listResp.Tasks {
            log.Printf("    - [%s] %s (%s)", task.Id[:8], task.Title, task.Status)
        }
    }

    // ==========================================
    // 4. UPDATE — обновляем задачу
    // ==========================================
    log.Println("\n── UPDATE ──")
    doneStatus := pb.TaskStatus_TASK_STATUS_DONE
    updateResp, err := client.UpdateTask(ctx, &pb.UpdateTaskRequest{
        Id:     createdIDs[0],
        Status: &doneStatus,
    })
    if err != nil {
        log.Printf("  ❌ Ошибка: %v", err)
    } else {
        task := updateResp.Task
        log.Printf("  ✓ Обновлена: [%s] %s%s", task.Id[:8], task.Title, task.Status)
        if task.CompletedAt != nil {
            log.Printf("    Завершена: %s", task.CompletedAt.AsTime().Format(time.RFC3339))
        }
    }

    // ==========================================
    // 5. DELETE — удаляем задачу
    // ==========================================
    log.Println("\n── DELETE ──")
    deleteResp, err := client.DeleteTask(ctx, &pb.DeleteTaskRequest{
        Id: createdIDs[len(createdIDs)-1],
    })
    if err != nil {
        log.Printf("  ❌ Ошибка: %v", err)
    } else {
        log.Printf("  ✓ Удалена: success=%v", deleteResp.Success)
    }

    // ==========================================
    // 6. ОШИБКИ — демонстрация
    // ==========================================
    log.Println("\n── ОШИБКИ ──")
    // Попытка получить несуществующую задачу
    _, err = client.GetTask(ctx, &pb.GetTaskRequest{Id: "nonexistent"})
    if err != nil {
        st, _ := status.FromError(err)
        log.Printf("  Ожидаемая ошибка: код=%s, сообщение=%q", st.Code(), st.Message())
    }

    // Попытка создать задачу без заголовка
    _, err = client.CreateTask(ctx, &pb.CreateTaskRequest{
        Title: "", // Пустой заголовок
    })
    if err != nil {
        st, _ := status.FromError(err)
        log.Printf("  Валидация: код=%s, сообщение=%q", st.Code(), st.Message())
    }

    log.Println("\n✅ Клиент завершил работу")
}

🚀 Запуск

# Терминал 1: Запускаем сервер
go run ./server/main.go

# Терминал 2: Запускаем клиент
go run ./client/main.go

📊 gRPC vs REST

ХарактеристикаREST (JSON/HTTP)gRPC (Protobuf/HTTP2)
ФорматJSON (текстовый)Protobuf (бинарный, в 5-10x меньше)
ТранспортHTTP 1.1HTTP/2 (мультиплексирование)
КонтрактOpenAPI (опционально).proto (обязательно)
Генерация кодаОпциональноВстроена (protoc)
СтримингWebSocket (отдельно)Встроен (unary, server, client, bidirectional)
Обратная совместимостьСложнееВстроена (новые поля игнорируются)
⚠️ Типичные ошибки:
💡 Практический совет:

💡 Best practices от сеньоров:

🔑 Ключевые концепции

Что нужно запомнить из этого урока:

💡 Для Node.js разработчика:

← Предыдущий урок Следующий урок →