Урок 40: Финальный проект — микросервисная система из 4 сервисов

Урок 40. Финальный проект — микросервисная система из 4 сервисов

🎯 Что мы построим:

Полноценную микросервисную систему: API Gateway, сервис заказов, сервис платежей, сервис уведомлений.

Стек: Go + gRPC + Kafka + PostgreSQL + Redis + Docker + Prometheus + Jaeger.

Все паттерны из 39 уроков в одном проекте!

📋 Архитектура системы

┌──────────────────────────────────────────────────────────────────┐ │ КЛИЕНТ (браузер / мобильное приложение) │ └──────────────────────────────┬───────────────────────────────────┘ │ HTTP REST ▼ ┌──────────────────────────────────────────────────────────────────┐ │ API GATEWAY (:8080) │ │ • Принимает HTTP-запросы │ │ • Валидация (go-playground/validator) │ │ • Аутентификация (JWT) │ │ • Rate limiting │ │ • Проксирует в gRPC-сервисы │ └──────┬──────────────┬──────────────┬─────────────────────────────┘ │ gRPC │ gRPC │ gRPC ▼ ▼ ▼ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ ORDER │ │ PAYMENT │ │ USER │ │ SERVICE │ │ SERVICE │ │ SERVICE │ │ (:50051) │ │ (:50052) │ │ (:50053) │ │ │ │ │ │ │ │ PostgreSQL │ │ PostgreSQL │ │ PostgreSQL │ └─────┬──────┘ └─────┬──────┘ └────────────┘ │ Kafka │ Kafka ▼ ▼ ┌──────────────────────────────────────────┐ │ KAFKA │ │ order.created, payment.completed, … │ └────────────────────┬─────────────────────┘ │ ▼ ┌──────────────────────────────────────────┐ │ NOTIFICATION SERVICE │ │ • Слушает Kafka │ │ • Отправляет email/push │ │ • Redis для идемпотентности │ └──────────────────────────────────────────┘

┌──────────────────────────────────────────┐ │ ИНФРАСТРУКТУРА │ │ • Prometheus (метрики) │ │ • Jaeger (трассировка) │ │ • Docker Compose (локально) │ │ • Kubernetes (production) │ └──────────────────────────────────────────┘

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

mkdir final-project && cd final-project

# Создаём все сервисы
mkdir -p api-gateway/{cmd/server,internal/{handler,middleware,config},proto}
mkdir -p order-service/{cmd/server,internal/{service,repository,domain},proto}
mkdir -p payment-service/{cmd/server,internal/{service,repository,domain},proto}
mkdir -p notification-service/{cmd/consumer,internal/{consumer,email,idempotency}}
mkdir -p proto/{order,payment,user}
mkdir -p k8s
mkdir -p docker

💻 Файл: docker/docker-compose.yml

version: '3.9'
services:
  # ==========================================
  # API Gateway
  # ==========================================
  api-gateway:
    build:
      context: ..
      dockerfile: docker/Dockerfile.api-gateway
    ports: ["8080:8080"]
    environment:
      - ORDER_SERVICE_ADDR=order-service:50051
      - PAYMENT_SERVICE_ADDR=payment-service:50052
      - JWT_SECRET=${JWT_SECRET}
    depends_on: [order-service, payment-service]

  # ==========================================
  # Order Service
  # ==========================================
  order-service:
    build:
      context: ..
      dockerfile: docker/Dockerfile.service
    environment:
      - DATABASE_URL=postgres://postgres:secret@order-db:5432/orders?sslmode=disable
      - KAFKA_BROKERS=kafka:9092
    depends_on:
      order-db: {condition: service_healthy}
      kafka: {condition: service_healthy}

  order-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: orders
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s

  # ==========================================
  # Payment Service
  # ==========================================
  payment-service:
    build:
      context: ..
      dockerfile: docker/Dockerfile.service
    environment:
      - DATABASE_URL=postgres://postgres:secret@payment-db:5432/payments?sslmode=disable
      - KAFKA_BROKERS=kafka:9092
    depends_on:
      payment-db: {condition: service_healthy}
      kafka: {condition: service_healthy}

  payment-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: payments
      POSTGRES_PASSWORD: secret

  # ==========================================
  # Notification Service
  # ==========================================
  notification-service:
    build:
      context: ..
      dockerfile: docker/Dockerfile.notification
    environment:
      - KAFKA_BROKERS=kafka:9092
      - REDIS_URL=redis://redis:6379/0
    depends_on: [kafka, redis]

  # ==========================================
  # Инфраструктура
  # ==========================================
  kafka:
    image: confluentinc/cp-kafka:7.5.0
    environment:
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
    depends_on: [zookeeper]

  zookeeper:
    image: confluentinc/cp-zookeeper:7.5.0
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]

  prometheus:
    image: prom/prometheus
    ports: ["9090:9090"]
    volumes: ["./prometheus.yml:/etc/prometheus/prometheus.yml"]

  jaeger:
    image: jaegertracing/all-in-one:latest
    ports: ["16686:16686", "14268:14268"]

💻 Файл: api-gateway/cmd/server/main.go

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/prometheus/client_golang/prometheus/promhttp"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    pb_order "final-project/proto/order"
    pb_payment "final-project/proto/payment"
    "final-project/api-gateway/internal/handler"
    "final-project/api-gateway/internal/middleware"
)

func main() {
    log.Println("🚀 API Gateway запускается...")

    // Подключение к gRPC-сервисам
    orderConn, _ := grpc.Dial(
        os.Getenv("ORDER_SERVICE_ADDR"),
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        grpc.WithBlock(),
        grpc.WithTimeout(5*time.Second),
    )
    defer orderConn.Close()

    paymentConn, _ := grpc.Dial(
        os.Getenv("PAYMENT_SERVICE_ADDR"),
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        grpc.WithBlock(),
        grpc.WithTimeout(5*time.Second),
    )
    defer paymentConn.Close()

    orderClient := pb_order.NewOrderServiceClient(orderConn)
    paymentClient := pb_payment.NewPaymentServiceClient(paymentConn)

    // Инициализация JWT-сервиса
    jwtService := auth.NewJWTService(
        os.Getenv("JWT_SECRET"),
        15*time.Minute,
        7*24*time.Hour,
    )

    // Создание обработчиков
    orderHandler := handler.NewOrderHandler(orderClient, paymentClient)

    // Настройка роутера
    mux := http.NewServeMux()

    // Health
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("OK"))
    })

    // Метрики Prometheus
    mux.Handle("/metrics", promhttp.Handler())

    // API v1
    apiV1 := http.NewServeMux()
    apiV1.HandleFunc("POST /api/v1/orders", orderHandler.CreateOrder)
    apiV1.HandleFunc("GET /api/v1/orders/{id}", orderHandler.GetOrder)
    apiV1.HandleFunc("POST /api/v1/login", orderHandler.Login)
    mux.Handle("/api/v1/", apiV1)

    // Middleware
    handler := middleware.Chain(
        middleware.RequestID,
        middleware.Logging,
        middleware.Recovery,
        middleware.Metrics,
        middleware.CORS([]string{"*"}),
        middleware.RateLimit(100, 200),
    )(mux)

    server := &http.Server{Addr: ":8080", Handler: handler}

    go func() {
        log.Println("✅ Gateway на :8080")
        server.ListenAndServe()
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    server.Shutdown(ctx)
}

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

syntax = "proto3";
package order;
option go_package = "final-project/proto/order;orderpb";

import "google/protobuf/timestamp.proto";

service OrderService {
  rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse);
  rpc GetOrder(GetOrderRequest) returns (GetOrderResponse);
  rpc CancelOrder(CancelOrderRequest) returns (CancelOrderResponse);
}

message CreateOrderRequest {
  string user_id = 1;
  repeated OrderItem items = 2;
}

message OrderItem {
  string product_id = 1;
  int32 quantity = 2;
  int64 price = 3; // в центах
}

message CreateOrderResponse {
  string id = 1;
  int64 total_price = 2;
  string status = 3;
}

message GetOrderRequest { string id = 1; }
message GetOrderResponse {
  string id = 1;
  string user_id = 2;
  repeated OrderItem items = 3;
  int64 total_price = 4;
  string status = 5;
  google.protobuf.Timestamp created_at = 6;
}

message CancelOrderRequest { string id = 1; }
message CancelOrderResponse { bool success = 1; }

💻 Файл: order-service/internal/service/order_service.go

package service

import (
    "context"
    "fmt"
    "time"

    "github.com/google/uuid"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"

    "final-project/order-service/internal/domain"
    "final-project/order-service/internal/kafka"
)

type OrderService struct {
    repo      domain.OrderRepository
    producer  *kafka.Producer
}

func NewOrderService(repo domain.OrderRepository, producer *kafka.Producer) *OrderService {
    return &OrderService{repo: repo, producer: producer}
}

func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderReq) (*domain.Order, error) {
    // Валидация
    if req.UserID == "" {
        return nil, status.Error(codes.InvalidArgument, "user_id required")
    }
    if len(req.Items) == 0 {
        return nil, status.Error(codes.InvalidArgument, "at least one item required")
    }

    // Рассчитываем сумму
    var totalPrice int64
    for _, item := range req.Items {
        totalPrice += item.Price * int64(item.Quantity)
    }

    // Создаём заказ в БД
    order := &domain.Order{
        ID:         uuid.New().String(),
        UserID:     req.UserID,
        Items:      req.Items,
        TotalPrice: totalPrice,
        Status:     "pending",
        CreatedAt:  time.Now(),
    }

    if err := s.repo.Create(ctx, order); err != nil {
        return nil, fmt.Errorf("create order: %w", err)
    }

    // Публикуем событие в Kafka
    s.producer.PublishEvent(ctx, "order.created", map[string]interface{}{
        "order_id":    order.ID,
        "user_id":     order.UserID,
        "total_price": order.TotalPrice,
        "status":      order.Status,
        "created_at":  order.CreatedAt,
    })

    return order, nil
}

func (s *OrderService) GetOrder(ctx context.Context, id string) (*domain.Order, error) {
    order, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return nil, status.Error(codes.NotFound, "order not found")
    }
    return order, nil
}

func (s *OrderService) CancelOrder(ctx context.Context, id string) error {
    if err := s.repo.UpdateStatus(ctx, id, "cancelled"); err != nil {
        return fmt.Errorf("cancel order: %w", err)
    }

    s.producer.PublishEvent(ctx, "order.cancelled", map[string]string{
        "order_id": id,
    })

    return nil
}

type CreateOrderReq struct {
    UserID string
    Items  []domain.OrderItem
}

💻 Файл: notification-service/internal/consumer/order_consumer.go

package consumer

import (
    "context"
    "encoding/json"
    "log"

    "final-project/notification-service/internal/email"
    "final-project/notification-service/internal/idempotency"
    "github.com/segmentio/kafka-go"
)

type OrderConsumer struct {
    reader   *kafka.Reader
    idemp    *idempotency.RedisStore
    emailSvc *email.Service
}

func NewOrderConsumer(
    brokers []string,
    idemp *idempotency.RedisStore,
    emailSvc *email.Service,
) *OrderConsumer {
    reader := kafka.NewReader(kafka.ReaderConfig{
        Brokers:       brokers,
        Topic:         "order.created",
        GroupID:       "notification-service",
        CommitInterval: -1, // Ручной коммит
    })

    return &OrderConsumer{reader: reader, idemp: idemp, emailSvc: emailSvc}
}

func (c *OrderConsumer) Start(ctx context.Context) error {
    log.Println("📥 Consumer запущен: order.created")

    for {
        msg, err := c.reader.FetchMessage(ctx)
        if err != nil {
            if err == context.Canceled {
                return nil
            }
            log.Printf("Ошибка чтения: %v", err)
            continue
        }

        // Декодируем событие
        var event struct {
            OrderID string `json:"order_id"`
            UserID  string `json:"user_id"`
            Amount  int64  `json:"total_price"`
        }
        json.Unmarshal(msg.Value, &event)

        // Проверяем идемпотентность
        isDup, _, _ := c.idemp.IsDuplicate(ctx, msg.Key)
        if isDup {
            log.Printf("Дубликат: %s", string(msg.Key))
            c.reader.CommitMessages(ctx, msg)
            continue
        }

        // Отмечаем как "в обработке"
        c.idemp.MarkProcessing(ctx, string(msg.Key))

        // Отправляем email
        err = c.emailSvc.SendOrderConfirmation(ctx, event.UserID, event.OrderID)
        if err != nil {
            log.Printf("Ошибка отправки email: %v", err)
            // Не коммитим — попробуем снова
            continue
        }

        // Отмечаем как выполненный
        c.idemp.MarkCompleted(ctx, string(msg.Key), "sent")

        // Коммитим
        c.reader.CommitMessages(ctx, msg)
        log.Printf("✅ Уведомление отправлено для заказа %s", event.OrderID)
    }
}

func (c *OrderConsumer) Close() error {
    return c.reader.Close()
}

💻 Файл: k8s/deployment.yaml (Kubernetes)

apiVersion: apps/v1
kind: Deployment
metadata: {name: order-service}
spec:
  replicas: 3
  selector:
    matchLabels: {app: order-service}
  template:
    metadata:
      labels: {app: order-service}
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "9090"
    spec:
      containers:
      - name: order-service
        image: ghcr.io/myorg/order-service:latest
        ports:
        - {containerPort: 50051, name: grpc}
        - {containerPort: 9090, name: metrics}
        envFrom:
        - configMapRef: {name: order-service-config}
        - secretRef: {name: order-service-secrets}
        resources:
          requests: {cpu: "50m", memory: "64Mi"}
          limits: {cpu: "200m", memory: "256Mi"}
        livenessProbe:
          grpc: {port: 50051}
          initialDelaySeconds: 5
        readinessProbe:
          grpc: {port: 50051}
          initialDelaySeconds: 2
---
apiVersion: v1
kind: Service
metadata: {name: order-service}
spec:
  selector: {app: order-service}
  ports:
  - {port: 50051, name: grpc}
  - {port: 9090, name: metrics}

🚀 Запуск всей системы

# 1. Клонируем / создаём проект
# (все файлы из этого урока)

# 2. Генерируем proto-код
for proto in proto/*/; do
  protoc --go_out=. --go-grpc_out=. ${proto}*.proto
done

# 3. Запускаем docker-compose
cd docker
docker-compose up -d

# 4. Проверяем
curl http://localhost:8080/health
curl -X POST http://localhost:8080/api/v1/orders \
  -H "Content-Type: application/json" \
  -d '{"user_id":"user-1","items":[{"product_id":"prod-1","quantity":2,"price":9999}]}'

# 5. Метрики
curl http://localhost:8080/metrics | grep orders_created_total

# 6. Jaeger
open http://localhost:16686

# 7. Prometheus
open http://localhost:9090

# 8. Деплой в K8s
kubectl apply -f k8s/

✅ Что мы покрыли за 40 уроков

🎉 Поздравляю! Ты прошёл путь от «нулевого Go» до production-ready микросервисного разработчика.

Что дальше?

🔑 Ключевые концепции всего курса

Главные принципы Go-разработчика:

💡 Для Node.js разработчика (финальные мысли):

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