Неделя 4: testcontainers, env config, платформенная библиотека, DI
Сейчас сервисы работают, но конфигурация захардкожена, логгер вызывается напрямую, миграции дублируются по сервисам. Это превращается в боль на пятом сервисе. Эта неделя про инфраструктурный фундамент, на котором будут расти Auth, Assembly, Notification.
Цели недели
- Перевести конфигурацию на переменные окружения по 12-factor.
- Завести
/platform/пакет с переиспользуемыми компонентами: logger, migrator, healthcheck. - Реорганизовать сервисы вокруг
internal/app/с явным DI. - Добавить healthcheck-эндпоинты.
- Написать интеграционные и 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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.