Раздел 24 · Микросервисы (Go)

Неделя 4: testcontainers, env config, платформенная библиотека, DI

middle-senior~60 мин

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

Неделя 4: testcontainers, env config, платформенная библиотека, DI

Сейчас сервисы работают, но конфигурация захардкожена, логгер вызывается напрямую, миграции дублируются по сервисам. Это превращается в боль на пятом сервисе. Эта неделя про инфраструктурный фундамент, на котором будут расти Auth, Assembly, Notification.

Цели недели

  1. Перевести конфигурацию на переменные окружения по 12-factor.
  2. Завести /platform/ пакет с переиспользуемыми компонентами: logger, migrator, healthcheck.
  3. Реорганизовать сервисы вокруг internal/app/ с явным DI.
  4. Добавить healthcheck-эндпоинты.
  5. Написать интеграционные и e2e-тесты через testcontainers.

12-factor конфигурация

Всё, что отличается между dev/staging/prod, в переменные окружения, не в код. Шаблоны лежат в репозитории, реальные значения нет.

deploy/env/
├── inventory.env.template
├── order.env.template
├── payment.env.template
├── assembly.env.template
├── auth.env.template
├── notification.env.template
├── analytics.env.template
└── generate-env.sh

Шаблон, например order.env.template:

# OrderService
ORDER_GRPC_PORT=50051
ORDER_HTTP_PORT=8080

POSTGRES_HOST=postgres-order
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=order

LOG_LEVEL=info
LOG_FORMAT=json

generate-env.sh копирует шаблоны в .env файлы рядом с сервисами, чтобы они подхватывались docker compose автоматически:

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
for tpl in *.env.template; do
    target="../../$(basename "${tpl%.env.template}")/.env"
    if [[ -f "$target" ]]; then
        echo "skip $target (already exists)"
        continue
    fi
    cp "$tpl" "$target"
    echo "created $target"
done

.env в gitignore. Шаблоны в git.

internal/config с интерфейсами

Соблазн, читать os.Getenv прямо из main или из конструкторов. Это ломает тесты: чтобы проверить что сервис ведёт себя правильно при отсутствии переменной, нужно её мокать на уровне ОС.

Идиоматичный путь, интерфейсы конфигов:

// order/internal/config/interfaces.go
type GRPCConfig interface {
    Address() string
}

type PostgresConfig interface {
    DSN() string
    MaxConns() int32
}

type LoggerConfig interface {
    Level() string
    Format() string // json | text
}
// order/internal/config/env/postgres.go
type PostgresFromEnv struct{}

func (PostgresFromEnv) DSN() string {
    return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
        getenv("POSTGRES_USER", "postgres"),
        getenv("POSTGRES_PASSWORD", "postgres"),
        getenv("POSTGRES_HOST", "localhost"),
        getenv("POSTGRES_PORT", "5432"),
        getenv("POSTGRES_DB", "order"),
    )
}

func (PostgresFromEnv) MaxConns() int32 {
    return int32(getenvInt("POSTGRES_MAX_CONNS", 10))
}

В сервисе зависим от интерфейса:

type Repository struct { /* ... */ }

func New(ctx context.Context, cfg config.PostgresConfig) (*Repository, error) { ... }

В тестах подсовываем mock или фиксированный config-объект.

Платформенная библиотека

/platform/ это отдельный go-модуль в монорепо, переиспользуемый всеми сервисами:

platform/
├── go.mod
├── logger/
│   ├── logger.go         # type Logger interface { Info, Warn, Error, ... }
│   ├── zap.go            # реализация на zap
│   └── config.go
├── migrator/
│   ├── migrator.go       # тип Migrator с методом Up(ctx)
│   └── goose.go
├── healthcheck/
│   ├── server.go         # HTTP-сервер с /healthz и /readyz
│   └── checker.go        # тип Checker interface { Check(ctx) error }
└── kafka/                # на следующей неделе

platform/logger

package logger

type Logger interface {
    Debug(msg string, fields ...Field)
    Info(msg string, fields ...Field)
    Warn(msg string, fields ...Field)
    Error(msg string, fields ...Field)
    With(fields ...Field) Logger
}

type Field = zap.Field // переиспользуем zap.Field как структуру поля

Главная идея, сервис видит интерфейс, не zap напрямую. Если завтра меняем zap на slog, сервисы не правим.

platform/migrator

package migrator

type Migrator interface {
    Up(ctx context.Context) error
    Down(ctx context.Context) error
    Status(ctx context.Context) ([]Migration, error)
}

Реализация на goose, обёрнутая, чтобы сервис не знал про конкретный пакет.

platform/healthcheck

/healthz, “процесс жив”. Просто 200 OK без проверок. Используется k8s liveness.

/readyz, “готов принимать трафик”. Проверяет что:

  • БД отвечает на Ping,
  • gRPC-клиенты к зависимостям доступны,
  • (в будущем) Kafka-брокеры доступны.
type Checker interface {
    Name() string
    Check(ctx context.Context) error
}

type Server struct {
    checkers []Checker
}

func (s *Server) ReadyzHandler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
    defer cancel()

    failed := []string{}
    for _, c := range s.checkers {
        if err := c.Check(ctx); err != nil {
            failed = append(failed, c.Name())
        }
    }
    if len(failed) > 0 {
        w.WriteHeader(http.StatusServiceUnavailable)
        json.NewEncoder(w).Encode(map[string]any{
            "status":         "not_ready",
            "failed_checks":  failed,
        })
        return
    }
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"status":"ready"}`))
}

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

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