Урок 20: Внедрение зависимостей и конфигурация

Урок 20. Внедрение зависимостей и конфигурация (env, yaml)

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

📋 Что изучаем

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

mkdir go-di-config && cd go-di-config
go mod init go-di-config

# Устанавливаем зависимости
go get github.com/caarlos0/env/v11
go get gopkg.in/yaml.v3
go get github.com/joho/godotenv
go get github.com/redis/go-redis/v9
go get github.com/jackc/pgx/v5/pgxpool
go mod tidy

# Создаём структуру
mkdir -p internal/config
mkdir -p internal/provider
mkdir -p cmd/server

💻 Файл: config.yaml

# Конфигурация по умолчанию (development)
server:
  port: 8080
  host: "0.0.0.0"
  read_timeout: 5s
  write_timeout: 10s
  idle_timeout: 120s

database:
  host: "localhost"
  port: 5432
  user: "postgres"
  password: "secret"
  dbname: "myapp"
  sslmode: "disable"
  pool_max_conns: 20
  pool_min_conns: 5

redis:
  addr: "localhost:6379"
  password: ""
  db: 0
  pool_size: 20

logging:
  level: "debug"    # debug, info, warn, error
  format: "json"    # json, text
  output: "stdout"  # stdout, file

auth:
  jwt_secret: "change-me-in-production"
  jwt_ttl: 24h
  bcrypt_cost: 12

# Функциональные флаги
features:
  enable_cache: true
  enable_metrics: true
  enable_tracing: false

💻 Файл: .env (пример)

# Переменные окружения переопределяют config.yaml
APP_ENV=development
DATABASE_URL=postgres://postgres:secret@localhost:5432/myapp?sslmode=disable
REDIS_URL=redis://localhost:6379/0
AUTH_JWT_SECRET=super-secret-key-here

💻 Файл: internal/config/config.go

package config

import (
    "fmt"
    "os"
    "time"

    "github.com/caarlos0/env/v11"
    "gopkg.in/yaml.v3"
)

// Config — корневая структура конфигурации
type Config struct {
    AppEnv   string       `yaml:"app_env" env:"APP_ENV" envDefault:"development"`
    Server   ServerConfig `yaml:"server"`
    Database DBConfig     `yaml:"database"`
    Redis    RedisConfig  `yaml:"redis"`
    Logging  LogConfig    `yaml:"logging"`
    Auth     AuthConfig   `yaml:"auth"`
    Features Features     `yaml:"features"`
}

type ServerConfig struct {
    Host         string        `yaml:"host" env:"SERVER_HOST" envDefault:"0.0.0.0"`
    Port         int           `yaml:"port" env:"SERVER_PORT" envDefault:"8080"`
    ReadTimeout  time.Duration `yaml:"read_timeout" env:"SERVER_READ_TIMEOUT" envDefault:"5s"`
    WriteTimeout time.Duration `yaml:"write_timeout" env:"SERVER_WRITE_TIMEOUT" envDefault:"10s"`
    IdleTimeout  time.Duration `yaml:"idle_timeout" env:"SERVER_IDLE_TIMEOUT" envDefault:"120s"`
}

// Addr — адрес для HTTP-сервера
func (s ServerConfig) Addr() string {
    return fmt.Sprintf("%s:%d", s.Host, s.Port)
}

type DBConfig struct {
    Host        string `yaml:"host" env:"DATABASE_HOST"`
    Port        int    `yaml:"port" env:"DATABASE_PORT" envDefault:"5432"`
    User        string `yaml:"user" env:"DATABASE_USER"`
    Password    string `yaml:"password" env:"DATABASE_PASSWORD"`
    DBName      string `yaml:"dbname" env:"DATABASE_NAME"`
    SSLMode     string `yaml:"sslmode" env:"DATABASE_SSLMODE" envDefault:"disable"`
    PoolMaxConn int    `yaml:"pool_max_conns" env:"DATABASE_POOL_MAX" envDefault:"20"`
    PoolMinConn int    `yaml:"pool_min_conns" env:"DATABASE_POOL_MIN" envDefault:"5"`
    // URL — полный DSN (имеет приоритет)
    URL string `yaml:"url" env:"DATABASE_URL"`
}

// DSN — формирует строку подключения
func (d DBConfig) DSN() string {
    if d.URL != "" {
        return d.URL
    }
    return fmt.Sprintf(
        "postgres://%s:%s@%s:%d/%s?sslmode=%s",
        d.User, d.Password, d.Host, d.Port, d.DBName, d.SSLMode,
    )
}

type RedisConfig struct {
    Addr     string `yaml:"addr" env:"REDIS_ADDR" envDefault:"localhost:6379"`
    Password string `yaml:"password" env:"REDIS_PASSWORD"`
    DB       int    `yaml:"db" env:"REDIS_DB" envDefault:"0"`
    PoolSize int    `yaml:"pool_size" env:"REDIS_POOL_SIZE" envDefault:"20"`
    // URL — полный URL (имеет приоритет)
    URL string `yaml:"url" env:"REDIS_URL"`
}

type LogConfig struct {
    Level  string `yaml:"level" env:"LOG_LEVEL" envDefault:"info"`
    Format string `yaml:"format" env:"LOG_FORMAT" envDefault:"json"`
    Output string `yaml:"output" env:"LOG_OUTPUT" envDefault:"stdout"`
}

type AuthConfig struct {
    JWTSecret  string        `yaml:"jwt_secret" env:"AUTH_JWT_SECRET"`
    JWTTTL     time.Duration `yaml:"jwt_ttl" env:"AUTH_JWT_TTL" envDefault:"24h"`
    BcryptCost int           `yaml:"bcrypt_cost" env:"AUTH_BCRYPT_COST" envDefault:"12"`
}

type Features struct {
    EnableCache    bool `yaml:"enable_cache" env:"FEATURE_CACHE" envDefault:"true"`
    EnableMetrics  bool `yaml:"enable_metrics" env:"FEATURE_METRICS" envDefault:"true"`
    EnableTracing  bool `yaml:"enable_tracing" env:"FEATURE_TRACING" envDefault:"false"`
}

// ╔══════════════════════════════════════════════════════════╗
// ║  ЗАГРУЗКА КОНФИГУРАЦИИ                                 ║
// ╚══════════════════════════════════════════════════════════╝

// Load — загружает конфигурацию (12-factor: env > yaml > defaults)
func Load(configPath string) (*Config, error) {
    cfg := &Config{}

    // 1. Загружаем значения по умолчанию из YAML
    if configPath != "" {
        if err := loadYAML(configPath, cfg); err != nil {
            return nil, fmt.Errorf("load yaml: %w", err)
        }
    }

    // 2. Переопределяем из переменных окружения
    if err := env.Parse(cfg); err != nil {
        return nil, fmt.Errorf("parse env: %w", err)
    }

    // 3. Валидация
    if err := cfg.Validate(); err != nil {
        return nil, fmt.Errorf("validate config: %w", err)
    }

    return cfg, nil
}

// MustLoad — загружает конфиг или паникует (для использования в main)
func MustLoad(configPath string) *Config {
    cfg, err := Load(configPath)
    if err != nil {
        panic(fmt.Sprintf("config: %v", err))
    }
    return cfg
}

// loadYAML — читает YAML-файл
func loadYAML(path string, cfg *Config) error {
    data, err := os.ReadFile(path)
    if err != nil {
        if os.IsNotExist(err) {
            return nil // Файл не обязателен
        }
        return err
    }
    return yaml.Unmarshal(data, cfg)
}

// Validate — проверяет обязательные поля
func (c *Config) Validate() error {
    if c.Auth.JWTSecret == "" || c.Auth.JWTSecret == "change-me-in-production" {
        return fmt.Errorf("AUTH_JWT_SECRET must be set and not default")
    }

    if c.Database.URL == "" && c.Database.Host == "" {
        return fmt.Errorf("database host or DATABASE_URL must be set")
    }

    if c.Server.Port < 1 || c.Server.Port > 65535 {
        return fmt.Errorf("invalid server port: %d", c.Server.Port)
    }

    if c.Auth.BcryptCost < 4 || c.Auth.BcryptCost > 31 {
        return fmt.Errorf("bcrypt cost must be between 4 and 31")
    }

    return nil
}

// IsDevelopment — проверяет окружение
func (c *Config) IsDevelopment() bool {
    return c.AppEnv == "development"
}

// IsProduction — проверяет окружение
func (c *Config) IsProduction() bool {
    return c.AppEnv == "production"
}

💻 Файл: internal/provider/provider.go

package provider

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

    "github.com/redis/go-redis/v9"
    "github.com/jackc/pgx/v5/pgxpool"
    "go-di-config/internal/config"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  ПРОВАЙДЕРЫ — ленивая инициализация зависимостей       ║
// ╚══════════════════════════════════════════════════════════╝

// Provider — контейнер зависимостей (DI-контейнер)
type Provider struct {
    cfg        *config.Config
    dbPool     *pgxpool.Pool
    redisClient *redis.Client
}

// NewProvider — создаёт провайдер (без инициализации!)
func NewProvider(cfg *config.Config) *Provider {
    return &Provider{cfg: cfg}
}

// Config — возвращает конфигурацию
func (p *Provider) Config() *config.Config {
    return p.cfg
}

// DBPool — лениво инициализирует пул PostgreSQL
func (p *Provider) DBPool(ctx context.Context) (*pgxpool.Pool, error) {
    if p.dbPool != nil {
        return p.dbPool, nil
    }

    poolCfg, err := pgxpool.ParseConfig(p.cfg.Database.DSN())
    if err != nil {
        return nil, fmt.Errorf("parse pg config: %w", err)
    }

    poolCfg.MaxConns = int32(p.cfg.Database.PoolMaxConn)
    poolCfg.MinConns = int32(p.cfg.Database.PoolMinConn)

    pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
    if err != nil {
        return nil, fmt.Errorf("create pg pool: %w", err)
    }

    // Проверяем соединение
    if err := pool.Ping(ctx); err != nil {
        return nil, fmt.Errorf("ping pg: %w", err)
    }

    log.Println("✅ PostgreSQL подключён")
    p.dbPool = pool
    return pool, nil
}

// RedisClient — лениво инициализирует Redis-клиент
func (p *Provider) RedisClient() (*redis.Client, error) {
    if p.redisClient != nil {
        return p.redisClient, nil
    }

    opts := &redis.Options{
        Addr:         p.cfg.Redis.Addr,
        Password:     p.cfg.Redis.Password,
        DB:           p.cfg.Redis.DB,
        PoolSize:     p.cfg.Redis.PoolSize,
        DialTimeout:  5 * time.Second,
        ReadTimeout:  3 * time.Second,
        WriteTimeout: 3 * time.Second,
        MaxRetries:   3,
    }

    client := redis.NewClient(opts)

    // Проверяем соединение
    if err := client.Ping(context.Background()).Err(); err != nil {
        return nil, fmt.Errorf("redis ping: %w", err)
    }

    log.Println("✅ Redis подключён")
    p.redisClient = client
    return client, nil
}

// Close — освобождает все ресурсы
func (p *Provider) Close() {
    if p.dbPool != nil {
        p.dbPool.Close()
        log.Println("PostgreSQL закрыт")
    }
    if p.redisClient != nil {
        p.redisClient.Close()
        log.Println("Redis закрыт")
    }
}

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

package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "syscall"

    "github.com/joho/godotenv"
    "go-di-config/internal/config"
    "go-di-config/internal/provider"
)

func main() {
    log.SetFlags(log.LstdFlags | log.Lmicroseconds)
    log.Println("⚙️ Загрузка конфигурации и DI...")

    // 1. Загружаем .env (для локальной разработки)
    if err := godotenv.Load(); err != nil {
        log.Println("  .env не найден (используем системные переменные)")
    }

    // 2. Загружаем конфигурацию
    cfg := config.MustLoad("config.yaml")

    // 3. Выводим сводку
    log.Printf("  Окружение: %s", cfg.AppEnv)
    log.Printf("  Сервер: %s", cfg.Server.Addr())
    log.Printf("  БД: %s (max_conns=%d)", cfg.Database.DSN(), cfg.Database.PoolMaxConn)
    log.Printf("  Redis: %s (pool_size=%d)", cfg.Redis.Addr, cfg.Redis.PoolSize)
    log.Printf("  Логи: level=%s format=%s", cfg.Logging.Level, cfg.Logging.Format)
    log.Printf("  Фичи: cache=%v metrics=%v tracing=%v",
        cfg.Features.EnableCache,
        cfg.Features.EnableMetrics,
        cfg.Features.EnableTracing,
    )

    // 4. Создаём провайдер
    prov := provider.NewProvider(cfg)
    defer prov.Close()

    // 5. Инициализируем зависимости (лениво)
    ctx := context.Background()

    if cfg.Features.EnableCache {
        if _, err := prov.RedisClient(); err != nil {
            log.Printf("  ⚠️ Redis недоступен: %v (продолжаем без кэша)", err)
        }
    }

    dbPool, err := prov.DBPool(ctx)
    if err != nil {
        log.Fatalf("  ❌ БД недоступна: %v", err)
    }
    _ = dbPool

    // 6. Запускаем сервер (в реальности — HTTP/gRPC)
    log.Println("✅ Сервер готов к запуску")
    log.Println("  (в реальном проекте здесь был бы server.ListenAndServe())")

    // 7. Graceful shutdown
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("🛑 Завершение работы...")
}

💻 БОНУС: паттерн Options (функциональные опции)

// Паттерн Options — альтернативный способ конфигурации.
// Позволяет создавать объекты с опциональными параметрами.

package config

// ServerOption — функция, модифицирующая ServerConfig
type ServerOption func(*ServerConfig)

// WithPort — опция: установить порт
func WithPort(port int) ServerOption {
    return func(s *ServerConfig) {
        s.Port = port
    }
}

// WithTimeouts — опция: установить таймауты
func WithTimeouts(read, write time.Duration) ServerOption {
    return func(s *ServerConfig) {
        s.ReadTimeout = read
        s.WriteTimeout = write
    }
}

// NewServerConfig — конструктор с опциями
func NewServerConfig(opts ...ServerOption) *ServerConfig {
    // Значения по умолчанию
    s := &ServerConfig{
        Host:         "0.0.0.0",
        Port:         8080,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }
    // Применяем опции
    for _, opt := range opts {
        opt(s)
    }
    return s
}

// Пример использования:
// srv := NewServerConfig(
//     WithPort(9090),
//     WithTimeouts(10*time.Second, 30*time.Second),
// )

🚀 Запуск программы

# Создаём .env для локальной разработки
cat > .env << 'EOF'
APP_ENV=development
AUTH_JWT_SECRET=dev-secret-key-12345
DATABASE_URL=postgres://postgres:secret@localhost:5432/myapp?sslmode=disable
EOF

# Запускаем
go run ./cmd/server/main.go

# Переопределение через env (12-factor)
APP_ENV=production \
SERVER_PORT=9090 \
AUTH_JWT_SECRET=prod-secret \
  go run ./cmd/server/main.go

# Без YAML-файла (только env)
rm config.yaml
SERVER_PORT=3000 \
DATABASE_URL=postgres://user:pass@host/db \
AUTH_JWT_SECRET=supersecret \
  go run ./cmd/server/main.go

📊 Приоритет конфигурации (12-factor app)

ПриоритетИсточникПример
1 (высший)Переменные окруженияexport SERVER_PORT=9090
2.env файлSERVER_PORT=9090 в .env
3YAML-файлconfig.yaml
4 (низший)Значения по умолчаниюenvDefault:"8080"

📊 Способы DI в Go

СпособСложностьКогда использовать
Ручная DI (конструкторы)НизкаяМалые/средние проекты
Provider (этот урок)СредняяСредние проекты с ленивой инициализацией
google/wireСредняяБольшие проекты (кодогенерация)
uber-go/fxВысокаяКрупные проекты (runtime DI)
⚠️ Типичные ошибки:
💡 Практический совет:

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

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

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

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

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