НейроАгент

Правильная структура внедрения зависимостей с несколькими БД в Go

Оптимальная архитектура внедрения зависимостей для работы с PostgreSQL, MongoDB и Redis в Go. Разделение интерфейсов, ленивая инициализация и лучшие практики.

Вопрос

Как должна выглядеть правильная структура внедрения зависимостей при работе с несколькими базами данных в Go?

Добрый день! Я начинаю изучать разработку серверов на Go с использованием фреймворка Gin. Сейчас настраиваю вспомогательные структуры и зависимости. В первом проекте я использовал только одну базу данных, но теперь планирую масштабный проект, который будет включать PostgreSQL, MongoDB и Redis.

Проблема в следующем:

При работе с одной базой данных мой контейнер выглядел так:

go
type container struct {
    config *config.Config
    rep    repository.Repository
    logger logger.Logger
}

А репозиторий реализовывал методы для PostgreSQL:

go
type Repository interface {
    Model(value interface{}) *gorm.DB
    Select(query interface{}, args ...interface{}) *gorm.DB
    Find(out interface{}, where ...interface{}) *gorm.DB
    Exec(sql string, values ...interface{}) *gorm.DB
    First(out interface{}, where ...interface{}) *gorm.DB
    Raw(sql string, values ...interface{}) *gorm.DB
    Create(value interface{}) *gorm.DB
    Save(value interface{}) *gorm.DB
    Updates(value interface{}) *gorm.DB
    Delete(value interface{}) *gorm.DB
    Where(query interface{}, args ...interface{}) *gorm.DB
    Preload(column string, conditions ...interface{}) *gorm.DB
    Scopes(funcs ...func(*gorm.DB) *gorm.DB) *gorm.DB
    ScanRows(rows *sql.Rows, result interface{}) error
    Transaction(fc func(tx Repository) error) (err error)
    Close() error
    DropTableIfExists(value interface{}) error
    GetSQLDB() (*sql.DB, error)
    Clauses(conds ...clause.Expression) *gorm.DB
    AutoMigrate(value interface{}) error
}

type repository struct {
    db *gorm.DB
}

type filmRepository struct {
    *repository
}

Если сейчас все помещать в один репозиторий, получится перегруженный интерфейс. Я придумал такую реализацию:

go
type Repository interface {
    Postgres() PostgresRepository
    Mongo() MongoRepository
    Redis() RedisRepository
}

То есть в основном интерфейсе хранить три вложенных интерфейса для каждой базы данных. Насколько это разумная структура? Я хочу сделать все правильно с самого начала, а не переделывать потом. Если есть альтернативные решения для таких задач, готов выслушать и принять к сведению.

НейроАгент

Правильная структура внедрения зависимостей при работе с несколькими базами данных в Go должна обеспечивать разделение ответственности и удобное тестирование. Исходя из вашего подхода, я могу предложить несколько улучшенных решений.

Основной принцип: каждая база данных должна иметь свой собственный интерфейс репозитория и внедряться отдельно. Это следует принципу разделения интерфейсов (Interface Segregation Principle).

Содержание


Основные подходы к структуре зависимостей

1. Отдельные интерфейсы для каждой базы данных

Этот подход заключается в создании отдельных интерфейсов для каждой базы данных и их внедрении в контейнер зависимостей независимо:

go
// Интерфейсы для каждой базы данных
type PostgresRepository interface {
    FindUser(id uint) (*User, error)
    CreateUser(user *User) error
    // другие специфичные для PostgreSQL методы
}

type MongoRepository interface {
    FindDocument(collection string, filter interface{}) (interface{}, error)
    InsertDocument(collection string, document interface{}) error
    // другие специфичные для MongoDB методы
}

type RedisRepository interface {
    Set(key string, value interface{}, expiration time.Duration) error
    Get(key string) (string, error)
    // другие специфичные для Redis методы
}

2. Ваш текущий подход с вложенными интерфейсами

Ваш подход с единым интерфейсом и методами Postgres(), Mongo(), Redis() - это компоновщик (facade), который может быть полезен, но имеет недостатки:

go
type Repository interface {
    Postgres() PostgresRepository
    Mongo() MongoRepository
    Redis() RedisRepository
}

Преимущества:

  • Упрощает передачу зависимостей в сервисы
  • Обеспечивает единый точку доступа к репозиториям

Недостатки:

  • Создает тесную связанность между компонентами
  • Усложняет тестирование отдельных репозиториев
  • Может привести к избыточным зависимостям

Наилучшей практикой является использование отдельных интерфейсов для каждой базы данных с внедрением их через контейнер зависимостей:

Структура контейнера зависимостей

go
type container struct {
    config     *config.Config
    logger     logger.Logger
    postgresRepo PostgresRepository
    mongoRepo    MongoRepository
    redisRepo    RedisRepository
}

func NewContainer(cfg *config.Config, log logger.Logger) *container {
    return &container{
        config: cfg,
        logger: log,
    }
}

func (c *container) Postgres() PostgresRepository {
    if c.postgresRepo == nil {
        // Инициализация PostgreSQL
        db, err := gorm.Open(postgres.Open(c.config.Database.URL), &gorm.Config{})
        if err != nil {
            c.logger.Fatal("Failed to connect to PostgreSQL", "error", err)
        }
        c.postgresRepo = &postgresRepository{db: db}
    }
    return c.postgresRepo
}

func (c *container) Mongo() MongoRepository {
    if c.mongoRepo == nil {
        // Инициализация MongoDB
        client, err := mongo.Connect(context.Background(), options.Client().ApplyURI(c.config.MongoDB.URI))
        if err != nil {
            c.logger.Fatal("Failed to connect to MongoDB", "error", err)
        }
        c.mongoRepo = &mongoRepository{client: client}
    }
    return c.mongoRepo
}

func (c *container) Redis() RedisRepository {
    if c.redisRepo == nil {
        // Инициализация Redis
        rdb := redis.NewClient(&redis.Options{
            Addr: c.config.Redis.Address,
        })
        c.redisRepo = &redisRepository{client: rdb}
    }
    return c.redisRepo
}

Пример реализации репозиториев

PostgreSQL репозиторий:

go
type postgresRepository struct {
    db *gorm.DB
}

func (r *postgresRepository) FindUser(id uint) (*User, error) {
    var user User
    result := r.db.First(&user, id)
    if result.Error != nil {
        return nil, result.Error
    }
    return &user, nil
}

func (r *postgresRepository) CreateUser(user *User) error {
    return r.db.Create(user).Error
}

// Реализация интерфейса PostgresRepository

MongoDB репозиторий:

go
type mongoRepository struct {
    client *mongo.Client
}

func (r *mongoRepository) FindDocument(collection string, filter interface{}) (interface{}, error) {
    coll := r.client.Database("app").Collection(collection)
    var result bson.M
    err := coll.FindOne(context.Background(), filter).Decode(&result)
    if err != nil {
        return nil, err
    }
    return result, nil
}

func (r *mongoRepository) InsertDocument(collection string, document interface{}) error {
    coll := r.client.Database("app").Collection(collection)
    _, err := coll.InsertOne(context.Background(), document)
    return err
}

// Реализация интерфейса MongoRepository

Redis репозиторий:

go
type redisRepository struct {
    client *redis.Client
}

func (r *redisRepository) Set(key string, value interface{}, expiration time.Duration) error {
    return r.client.Set(context.Background(), key, value, expiration).Err()
}

func (r *redisRepository) Get(key string) (string, error) {
    return r.client.Get(context.Background(), key).Result()
}

// Реализация интерфейса RedisRepository

Альтернативные решения

1. Использование фреймворков внедрения зависимостей

Для более сложных проектов можно использовать специализированные библиотеки:

go
import "go.uber.org/dig"

func BuildContainer() *dig.Container {
    container := dig.New()
    
    container.Provide(func() (PostgresRepository, error) {
        // Инициализация PostgreSQL
        db, err := gorm.Open(postgres.Open("..."), &gorm.Config{})
        return &postgresRepository{db: db}, err
    })
    
    container.Provide(func() (MongoRepository, error) {
        // Инициализация MongoDB
        client, err := mongo.Connect(context.Background(), options.Client().ApplyURI("..."))
        return &mongoRepository{client: client}, err
    })
    
    container.Provide(func() (RedisRepository, error) {
        // Инициализация Redis
        rdb := redis.NewClient(&redis.Options{Addr: "..."})
        return &redisRepository{client: rdb}, nil
    })
    
    return container
}

2. Сервисный слой с агрегацией

Создайте сервисы, которые используют несколько репозиториев:

go
type UserService struct {
    postgresRepo PostgresRepository
    mongoRepo    MongoRepository
    redisRepo    RedisRepository
}

func NewUserService(postgres PostgresRepository, mongo MongoRepository, redis RedisRepository) *UserService {
    return &UserService{
        postgresRepo: postgres,
        mongoRepo:    mongo,
        redisRepo:    redis,
    }
}

func (s *UserService) GetUserProfile(userID uint) (*UserProfile, error) {
    // Получаем данные из PostgreSQL
    user, err := s.postgresRepo.FindUser(userID)
    if err != nil {
        return nil, err
    }
    
    // Получаем доп. данные из MongoDB
    profileData, err := s.mongoRepo.FindDocument("profiles", bson.M{"user_id": userID})
    if err != nil {
        return nil, err
    }
    
    // Кэшируем в Redis
    s.redisRepo.Set(fmt.Sprintf("user:%d", userID), profileData, time.Hour)
    
    // Составляем объединенный профиль
    return &UserProfile{
        User:    user,
        Profile: profileData,
    }, nil
}

Практическая реализация контейнера зависимостей

Вот полная реализация контейнера зависимостей с ленивой инициализацией:

go
package di

import (
    "context"
    "log"
    "time"

    "github.com/go-redis/redis/v8"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

type Container struct {
    config     *Config
    logger     Logger
    
    postgresRepo PostgresRepository
    mongoRepo    MongoRepository
    redisRepo    RedisRepository
}

type Config struct {
    Database DatabaseConfig
    MongoDB  MongoDBConfig
    Redis    RedisConfig
}

type DatabaseConfig struct {
    URL string
}

type MongoDBConfig struct {
    URI string
}

type RedisConfig struct {
    Address string
}

type Logger interface {
    Fatal(msg string, fields ...interface{})
}

type PostgresRepository interface {
    FindUser(id uint) (*User, error)
    CreateUser(user *User) error
    // Другие методы для PostgreSQL
}

type MongoRepository interface {
    FindDocument(collection string, filter interface{}) (interface{}, error)
    InsertDocument(collection string, document interface{}) error
    // Другие методы для MongoDB
}

type RedisRepository interface {
    Set(key string, value interface{}, expiration time.Duration) error
    Get(key string) (string, error)
    // Другие методы для Redis
}

func NewContainer(cfg *Config, log Logger) *Container {
    return &Container{
        config: cfg,
        logger: log,
    }
}

// PostgreSQL
func (c *Container) Postgres() PostgresRepository {
    if c.postgresRepo == nil {
        db, err := gorm.Open(postgres.Open(c.config.Database.URL), &gorm.Config{})
        if err != nil {
            c.logger.Fatal("Failed to connect to PostgreSQL", "error", err)
        }
        c.postgresRepo = &postgresRepository{db: db}
    }
    return c.postgresRepo
}

// MongoDB
func (c *Container) Mongo() MongoRepository {
    if c.mongoRepo == nil {
        client, err := mongo.Connect(context.Background(), options.Client().ApplyURI(c.config.MongoDB.URI))
        if err != nil {
            c.logger.Fatal("Failed to connect to MongoDB", "error", err)
        }
        c.mongoRepo = &mongoRepository{client: client}
    }
    return c.mongoRepo
}

// Redis
func (c *Container) Redis() RedisRepository {
    if c.redisRepo == nil {
        rdb := redis.NewClient(&redis.Options{
            Addr: c.config.Redis.Address,
        })
        c.redisRepo = &redisRepository{client: rdb}
    }
    return c.redisRepo
}

// Закрытие всех соединений
func (c *Container) Close() error {
    var errors []error
    
    if c.redisRepo != nil {
        if err := c.redisRepo.(*redisRepository).client.Close(); err != nil {
            errors = append(errors, err)
        }
    }
    
    if c.mongoRepo != nil {
        if err := c.mongoRepo.(*mongoRepository).client.Disconnect(context.Background()); err != nil {
            errors = append(errors, err)
        }
    }
    
    if len(errors) > 0 {
        return errors[0] // Возвращаем первую ошибку
    }
    return nil
}

Рекомендации по организации кода

1. Структура проекта

/project
  /cmd
    /server
      main.go
  /internal
    /config
      config.go
    /di
      container.go
    /repositories
      /postgres
        repository.go
        user_repository.go
      /mongo
        repository.go
        document_repository.go
      /redis
        repository.go
        cache_repository.go
    /services
      user_service.go
      document_service.go
    /models
      user.go
      document.go
  /pkg
    /database
      postgres.go
      mongo.go
      redis.go

2. Правильное тестирование

Используйте моки для тестирования сервисов:

go
type MockPostgresRepository struct {
    users map[uint]*User
}

func (m *MockPostgresRepository) FindUser(id uint) (*User, error) {
    user, exists := m.users[id]
    if !exists {
        return nil, gorm.ErrRecordNotFound
    }
    return user, nil
}

func (m *MockPostgresRepository) CreateUser(user *User) error {
    m.users[user.ID] = user
    return nil
}

// Использование в тесте
func TestUserService_GetUser(t *testing.T) {
    mockPostgres := &MockPostgresRepository{
        users: map[uint]*User{
            1: {ID: 1, Name: "Test User"},
        },
    }
    
    userService := NewUserService(mockPostgres, nil, nil)
    
    user, err := userService.GetUser(1)
    assert.NoError(t, err)
    assert.Equal(t, "Test User", user.Name)
}

3. Конфигурация

Используйте структурированную конфигурацию:

go
type Config struct {
    Server   ServerConfig   `yaml:"server"`
    Database DatabaseConfig `yaml:"database"`
    MongoDB  MongoDBConfig  `yaml:"mongodb"`
    Redis    RedisConfig    `yaml:"redis"`
}

type ServerConfig struct {
    Port int `yaml:"port"`
}

// Загрузка конфигурации
func LoadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    
    var config Config
    if err := yaml.Unmarshal(data, &config); err != nil {
        return nil, err
    }
    
    return &config, nil
}

Выводы и лучшие практики

Ключевые рекомендации:

  1. Используйте отдельные интерфейсы для каждой базы данных вместо одного большого интерфейса
  2. Внедряйте зависимости через контейнер с ленивой инициализацией
  3. Следуйте принципу разделения интерфейсов (Interface Segregation Principle)
  4. Тестируйте каждый репозиторий отдельно с использованием моков
  5. Используйте сервисный слой для бизнес-логики, которая объединяет данные из разных источников

Почему ваш текущий подход может быть неоптимальным:

  • Тесная связанность: Сервисы, которым нужен только Redis, будут вынуждены зависеть от интерфейса, включающего MongoDB и PostgreSQL
  • Усложненное тестирование: Тестирование сервисов становится сложнее из-за необходимости мокировать все три репозитория
  • Проблемы с масштабированием: При добавлении новой базы данных придется изменять основной интерфейс

Альтернативные подходы для рассмотрения:

  1. Фабричный подход: Создайте фабрику для репозиториев
  2. Адаптеры: Используйте паттерн адаптеров для унификации интерфейсов
  3. Контекстные зависимости: Передавайте необходимые репозитории через контекст HTTP запроса

According to the Three Dots Labs blog, “The repository pattern works pretty nicely with Golang” when properly implemented with separate interfaces for different database types.

Правильная структура внедрения зависимостей с несколькими базами данных в Go должна быть гибкой, тестируемой и легко расширяемой. Разделение интерфейсов и ленивая инициализация через контейнер зависимостей обеспечивают эти качества.

Источники

  1. Three Dots Labs - Repository pattern in Go
  2. Stack Overflow - How to handle DB connection in Go when using Repository pattern
  3. Golang Samples - PostgreSQL in Go: Repository pattern
  4. Medium - Repository Pattern in Golang
  5. Golang for All - Dependency injection in GO