Практический SOLID в Golang: принцип инверсии зависимостей

Мы продолжаем путешествие по принципам SOLID, представляя тот, который оказывает наиболее значительное влияние на модульное тестирование в Go – The Dependency InversionPrinciple.

Изучение нового языка программирования часто является несложным процессом. Я часто слышу: «Первый язык программирования, который вы выучите за год. Второй через месяц. Третий через неделю, потом каждый следующий через день ».

Сказать это – преувеличение, но в некоторых случаях это не так уж далеко от истины. Например, переход на язык, относительно похожий на предыдущий, такой как Java и C #, может быть простым процессом.

Но иногда переключиться сложно, даже когда мы переключаемся с одного объектно-ориентированного языка на другой. На такие переходы влияют многие функции, такие как сильные или слабые типы, если у языка есть интерфейсы, абстрактные классы или классы вообще.

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

Когда мы не уважаем инверсию зависимостей

Модули высокого уровня не должны зависеть от модулей низкого уровня. Оба должны зависеть от абстракций. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Выше мы видим определение DIP. Дядя Боб представил это в своей газете . Подробности есть в его блоге .

Итак, как это понимать, особенно в контексте Go? Во-первых, мы должны принять абстракцию как концепцию ООП . Мы используем такую ​​концепцию, чтобы выявить основные поведения и скрыть детали их реализации.

Во-вторых, что такое модули высокого и низкого уровня? Модули высокого уровня в контексте Go – это программные компоненты, используемые в верхней части приложения, например код, используемый для представления.

Это также может быть код, близкий к верхнему уровню, например код бизнес-логики или некоторые компоненты варианта использования. Важно понимать это как слой, который придает нашему приложению реальную ценность для бизнеса.

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

Например, это может быть структура, которая хранит логику для получения данных из базы данных, отправки сообщения SQS, получения значения из Redis или отправки HTTP-запроса к внешнему API.

Итак, как это выглядит, когда мы нарушаем принцип инверсии зависимостей и наш высокоуровневый компонент зависит от одного низкоуровневого? Разберем следующий пример:

// infrastructure layer

type UserRepository struct {
	db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *UserRepository {
	return &UserRepository{
		db: db,
	}
}

func (r *UserRepository) GetByID(id uint) (*domain.User, error) {
	user := domain.User{}
	err := r.db.Where("id = ?", id).First(&user).Error
	if err != nil {
		return nil, err
	}

	return &user, nil
}

// domain layer

type User struct {
	ID uint `gorm:"primaryKey;column:id"`
	// some fields
}

// application layer

type EmailService struct {
	repository *infrastructure.UserRepository
	// some email sender
}

func NewEmailService(repository *infrastructure.UserRepository) *EmailService {
	return &EmailService{
		repository: repository,
	}
}

func (s *EmailService) SendRegistrationEmail(userID uint) error {
	user, err := s.repository.GetByID(userID)
	if err != nil {
		return err
	}
	// send email
	return nil
}

В приведенном выше фрагменте кода мы определили высокоуровневый компонент EmailService. Эта структура относится к прикладному уровню и отвечает за отправку электронной почты новым зарегистрированным клиентам.

Идея состоит в том, чтобы иметь метод SendRegistrationEmail, который ожидает идентификатор User. В фоновом режиме он извлекает Userиз UserRepository, а позже (возможно) доставляет его в какую-то EmailSender службу для выполнения доставки электронной почты.

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

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

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

import (
	"testing"
	// some dependencies
	"github.com/DATA-DOG/go-sqlmock"
	"github.com/stretchr/testify/assert"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

func TestEmailService_SendRegistrationEmail(t *testing.T) {
	db, mock, err := sqlmock.New()
	assert.NoError(t, err)

	dialector := mysql.New(mysql.Config{
		DSN:        "dummy",
		DriverName: "mysql",
		Conn:       db,
	})
	finalDB, err := gorm.Open(dialector, &gorm.Config{})
	
	repository := infrastructure.NewUserRepository(finalDB)
	service := NewEmailService(repository)
	//
	// a lot of code to define mocked SQL queries
	//
	// and then actual test
}

В отличие от некоторых языков, таких как PHP , мы не можем просто имитировать в Go все, что захотим. Мокинг в Go основан на использовании интерфейсов, для которых мы можем определить фиктивную реализацию, но не можем сделать то же самое для структур.

Итак, мы не можем имитировать UserRepository, поскольку это структура. В таком случае нам нужно сделать имитацию на нижнем уровне, в данном случае, на объекте подключения Gorm , что мы можем сделать с помощью пакета SQLMock .

Но даже с ним это не является ни надежным, ни эффективным способом тестирования. Нам нужно имитировать слишком много SQL-запросов и слишком много знать о схеме базы данных. Любое изменение внутри базы данных требует адаптации модульных тестов.

Модульное тестирование на стороне, теперь у нас есть еще большая проблема. Что будет, если мы решим переключить хранилище на что-то другое, например, Cassandra ? В первую очередь, если наше хранилище для клиентов мы планируем сделать распределенным в будущем?

Если такой сценарий появится, и мы будем использовать эту реализацию UserRepository, последуют многие рефакторинги.

Теперь мы видим, что значение компонента высокого уровня зависит от компонента низкого уровня. Но как насчет абстракций, основанных на деталях? Давайте проверим код ниже:

// domain layer

type User struct {
	ID uint `gorm:"primaryKey;column:id"`
	// some fields
}

type UserRepository interface {
	GetByID(id uint) (*User, error)
}

Чтобы исправить первую проблему с компонентами высокого и низкого уровня, мы должны начать с определения некоторых интерфейсов. В этом случае мы можем определить UserRepository как интерфейс на уровне домена.

Таким образом, это дает возможность отвязаться EmailService от базы данных, но еще не полностью. Посмотрите на User структуру. Он по-прежнему представляет собой определение для сопоставления с базой данных.

И даже если такая структура находится внутри уровня предметной области, она все равно обладает инфраструктурными деталями. Наш новый интерфейс UserRepository (абстракция) зависит от User структуры со схемой базы данных (детали), и мы по-прежнему нарушаем DIP.

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

В конце концов, с этим рефакторингом мы ничего не получили. Мы все еще в неправильном положении. Со многими последствиями:

  1. Мы не можем правильно протестировать нашу бизнес-логику или логику приложения.
  2. Любое изменение ядра базы данных или структуры таблицы влияет на наши самые высокие уровни.
  3. Мы не можем легко переключиться на другой тип хранилища.
  4. Наша модель сильно привязана к хранилищу.

Итак, давайте еще раз проведем рефакторинг этого фрагмента кода.

Как мы уважаем инверсию зависимостей

Модули высокого уровня не должны зависеть от модулей низкого уровня. Оба должны зависеть от абстракций . Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций .

Давайте вернемся к исходной директиве для инверсии зависимостей и рассмотрим выделенные жирным шрифтом предложения. Они уже дали некоторые направления рефакторинга.

Мы должны определить некоторую абстракцию (интерфейс), от которой будут зависеть оба наших компонента, EmailService и UserRepository. Кроме того, такая абстракция не должна полагаться на какие-либо технические детали (например, объект Gorm).

Разберем код снизу:

// infrastructure layer

type UserGorm struct {
	// some fields
}

func (g UserGorm) ToUser() *domain.User {
	return &domain.User{
		// some fields
	}
}

type UserDatabaseRepository struct {
	db *gorm.DB
}

var _ domain.UserRepository = &UserDatabaseRepository{}

/*
type UserRedisRepository struct {
	
}
type UserCassandraRepository struct {
}
*/

func NewUserDatabaseRepository(db *gorm.DB) UserRepository {
	return &UserDatabaseRepository{
		db: db,
	}
}

func (r *UserDatabaseRepository) GetByID(id uint) (*domain.User, error) {
	user := UserGorm{}
	err := r.db.Where("id = ?", id).First(&user).Error
	if err != nil {
		return nil, err
	}

	return user.ToUser(), nil
}

// domain layer

type User struct {
	// some fields
}

type UserRepository interface {
	GetByID(id uint) (*User, error)
}

// application layer

type EmailService struct {
	repository domain.UserRepository
	// some email sender
}

func NewEmailService(repository domain.UserRepository) *EmailService {
	return &EmailService{
		repository: repository,
	}
}

func (s *EmailService) SendRegistrationEmail(userID uint) error {
	user, err := s.repository.GetByID(userID)
	if err != nil {
		return err
	}
	// send email
	return nil
}

В новой структуре кода мы можем видеть UserRepository интерфейс как компонент, зависящий от User структуры, и оба они находятся внутри уровня домена.

Структура User больше не отражает схему базы данных, но мы используем UserGorm для этого структуру. Эта структура находится на уровне инфраструктуры. Он предоставляет метод, ToUser который отображает его на фактическую User структуру.

В этом сценарии мы можем использовать UserGormкак часть деталей, используемых внутри UserDatabaseRepository, как фактическую реализацию для UserRepository.

Внутри уровня домена и приложения мы зависим только от UserRepository интерфейса и User сущности , как от домена.

Внутри уровня инфраструктуры мы можем определить столько реализаций UserRepository, сколько захотим. Это может быть UserFileRepository или UserCassandraRepository, например.

Компонент высокого уровня ( EmailService) зависит от абстракции – он содержит поле с типом UserRepository. Тем не менее, как низкоуровневый компонент зависит от абстракции?

В Go структуры неявно реализуют интерфейсы . Это означает, что нам не нужно добавлять код, который UserDatabaseRepository явно реализует UserRepository, но мы можем добавить проверку с пустым идентификатором .

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

Этот метод распространен в любом фреймворке, и мы решаем его с помощью шаблона внедрения зависимостей . В Go есть много библиотек DI, таких как Facebook , Wire или Dingo .

Как выглядит наша ситуация с модульным тестированием? Давайте это проверим.

import (
	"errors"
	"testing"
)

type GetByIDFunc func(id uint) (*User, error)

func (f GetByIDFunc) GetByID(id uint) (*User, error) {
	return f(id)
}

func TestEmailService_SendRegistrationEmail(t *testing.T) {
	service := NewEmailService(GetByIDFunc(func(id uint) (*User, error) {
		return nil, errors.New("error")
	}))
	//
	// and just to call the service
}

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

Теперь наше тестирование намного элегантнее и эффективнее. Мы можем внедрить различные реализации для UserRepository любого варианта использования и контролировать результат теста.

Еще несколько примеров

Мы можем столкнуться с нарушением DIP в других компонентах, а не только в структурах. Например, это может быть с чистыми независимыми функциями:

type User struct {
	// some fields
}

type UserJSON struct {
	// some fields
}

func (j UserJSON) ToUser() *User {
	return &User{
		// some fields
	}
}

func GetUser(id uint) (*User, error) {
	filename := fmt.Sprintf("user_%d.json", id)
	data, err := ioutil.ReadFile(filename)
	if err != nil {
		return nil, err
	}
	
	var user UserJSON
	err = json.Unmarshal(data, &user)
	if err != nil {
		return nil, err
	}
	
	return user.ToUser(), nil
}

Итак, мы хотим прочитать данные для файла User. Для этого мы можем использовать файлы и формат JSON. Метод GetUserчитает из файла и преобразует содержимое файла в фактическое User.

Сам метод зависит от наличия файлов, и если мы хотим правильно его протестировать, нам нужно полагаться на такие файлы. Так что для этого метода неудобно писать тесты, например, правила проверки тестов, если мы добавим их позже в GetUser метод.

Опять же, наш код зависит от слишком большого количества деталей, и было бы неплохо сделать некоторые абстракции:

type User struct {
// some fields
}

type UserJSON struct {
	// some fields
}

func (j UserJSON) ToUser() *User {
	return &User{
		// some fields
	}
}

func GetUserFile(id uint) (io.Reader, error) {
	filename := fmt.Sprintf("user_%d.json", id)
	file, err := os.Open(filename)
	if err != nil {
		return nil, err
	}

	return file, nil
}

func GetUserHTTP(id uint) (io.Reader, error) {
	uri := fmt.Sprintf("http://some-api.com/users/%d", id)
	resp, err := http.Get(uri)
	if err != nil {
		return nil, err
	}

	return resp.Body, nil
}

func GetDummyUser(userJSON UserJSON) (io.Reader, error) {
	data, err := json.Marshal(userJSON)
	if err != nil {
		return nil, err
	}

	return bytes.NewReader(data), nil
}

func GetUser(reader io.Reader) (*User, error) {
	data, err := ioutil.ReadAll(reader)
	if err != nil {
		return nil, err
	}

	var user UserJSON
	err = json.Unmarshal(data, &user)
	if err != nil {
		return nil, err
	}

	return user.ToUser(), nil
}

В новой реализации мы заставили метод GetUserполагаться на экземпляр Reader интерфейса. Это интерфейс из основного пакета Go, IO .

Здесь мы можем определить множество различных способов , которые обеспечат реализацию для Reader интерфейса, как GetUserFileGetUserHTTPGetDummyUser (который мы можем использовать для тестирования методы GetUser).

Этот подход мы можем использовать во многих различных ситуациях. Всякий раз, когда мы испытываем трудности с созданием надлежащего модульного теста или даже испытываем цикл зависимости в Go, мы должны попытаться отделить его, предоставив интерфейс и столько реализаций, сколько захотим.

Заключение

Принцип инверсии зависимостей – это последний принцип SOLID, и он обозначает букву D в слове SOLID . Он утверждает, что компоненты высокого уровня не должны зависеть от компонентов низкого уровня.

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

Ответить