golang

Еще раз про Di-контейнеры в golang

  • четверг, 24 апреля 2025 г. в 00:00:05
https://habr.com/ru/articles/903300/

В предыдущей статье я попросил — «Расскажите, зачем вам DI‑контейнер в golang». Большое спасибо всем, кто оставил коммент и проголосовал. Общий вывод такой: используем контейнер, потому что с ним удобно писать тесты. Тесты — весомый аргумент, особенно в контексте того, что тест — это часть кода. Получается, мы все таки «тащим» Di‑контейнер в проект. Ну, хорошо....

Вероятно, это будет uber‑fx, ведь у него хорошая документация, самое простое и понятное API по сравнению с другими..., или нет — не «тащим»?

Мой ответ — нет, uber‑fx не «тащим», потому что можно еще проще и понятнее.

Делаем проще и понятнее

Да... uber‑fx простой, я согласен, но слишком сложный для задачи получения дерева зависимостей и подмены на моки. Есть еще недостатки, но сейчас не об этом.

В предыдущей статье я привел пример максимально простого контейнера, написанного на коленке за 5 минут. Он работает, но не решает проблему с тестами. Попробуем «научить». Вот полный код ядра.

package dc

type providerAbstract interface {
	Reset()
}

type provider[T any] struct {
	createDependency func() T
	instance T
	mock T
	isPending bool
	hasInstance bool
	hasMock bool
}

var providers = make([]providerAbstract, 0)

func Provider[T any](createDependency func() T) *provider[T] {
	p := &provider[T]{
		createDependency: createDependency,
		mock: *new(T),
		hasMock: false,
		isPending: false,
		hasInstance: false,
	}
	providers = append(providers, p)
	
	return p
}

func (p *provider[T]) Use() T {
	if p.hasMock {
		return p.mock
	}
	
	if p.isPending {
		panic("detected circular dependency, please check your code")
	}
	
	if !p.hasInstance {
		p.isPending = true
		p.instance = p.createDependency()
		p.isPending = false
		p.hasInstance = true
	}

	return p.instance
}

func (p *provider[T]) Reset() {
	p.mock = *new(T)
	p.hasMock = false
	p.instance = *new(T)
	p.hasInstance = false
	p.isPending = false
}

func (p *provider[T]) Mock(mockObject T) {
	p.mock = mockObject
	p.hasMock = true
}

func Reset() {
	for _, p := range providers {
		p.Reset()
	}
}

Объём кода увеличился: было 25 строк, стало 67. Но он всё еще простой и понятный. Пакет маленький, но я сделал его модулем. Лежит здесь https://github.com/abratko/dc.

В результате получили контейнер, который:

  • сохраняет информацию о типах. Джинерик позволяет правильно указывать типы.

  • создает зависимости по запросу. Провайдер создаст экземпляр только при вызове Use().

  • блокирует циклические зависимости. Паникует при обнаружении циклических зависимостей. Паника будет при старте приложения.

  • поддерживает подмену на моки.

  • поддерживает полную очистку контейнера. Очистка удалит созданные экземпляры и моки.

Дополнительно:

  • Конфигурация — это код. Вы создаете композицию компонент, а не конфигурацию.

  • Легко отлаживать. Ставите точку останова в фабричной функции и это работает, потому что это обычный golang код.

  • Модульность. Фабричная функция работает как конструктор модуля: внутренние зависимости — это локальные переменные, внешние зависимости — провайдеры. Создаем провайдер, если планируем использовать повторно, иначе — делаем локальную переменную.

  • Нет «магии»: нет рефлексии, кодогенерации, не хранит мета информацию о связях и не вычисляет её.

Обзор API

Весь API — это 2 функций и 2 метода:
 — var Service = dc.Provider(functoryFuntion) — создает провайдер.
 — Service.Use() — выполняет фабричную функцию или возвращает готовый экземпляр.
 — Service.Mock(&MockInstance) — подменяет сервис на мок‑объект,
 — dc.Reset() — очищает контейнер.

Применяем в реальном проекте

Задача: сделать фасетный поиск в сервисе каталога.

Конфигурация gRPC‑контроллера для API поиска лежит в ./config/dc/search.go. Выглядит она так:

package dc 

import ....

import "github.com/abratko/dc"

// Здесь мы определяем Reset как функцию текущего пакета.
// Это нужно что бы снаружи можно было очистить контейнер,
// без импорта пакета ядра.
// Это нужно только для тестов.
var Reset = dc.Reset

var SearchGrpcController = dc.Provider(func() *grpc.Controller {
	var optsFetchers = []service.OptFetcher{
		opt_fetcher.NewCategoryOptFetcher(CategoryRepo.Use().FindByIds),
		opt_fetcher.NewLocationOptFetcher(LocationRepo.Use().FindByIds),
	}

	var filtersFactory = service.NewFiltersFactory(optsFetchers)
	var esFetchFilters = es.NewFetchFilters(filtersFactory.Exec)
	var esFetchFilterOpts = es.NewFetchFilterOpts(ReadProductDb.Use())
	var appFetchFiltersWithOpts = app.NewFetchFiltersWithOpts(
		esFetchFilters.Exec,
		esFetchFilterOpts.Exec,
	)

  	var esFetchItems = es.NewFetchItems(ReadProductDb.Use())
	var appFetchItems = app.NewFetchItems(
		esFetchFilters.Exec,
		esFetchItems.Exec,
	)

	return grpc.NewController(
		appFetchFiltersWithOpts.Exec,
		appFetchItems.Exec,
	)
})

В main.go делаем так:

package main
...
import "/config/dc"
...

func main() {
	...
	searchController := dc.SearchGrpcController.Use()
	...
}

Внутренние зависимости

Все локальные переменные в фабричной функции — это внутренние зависимости. Вероятность повторного использования этих объектов вне контроллера крайне низкая. Поэтому:

  • строим композицию из локальных переменных, не выносим в контейнер. Если понадобится — вынесем;

  • в местах внедрения внутренних зависимостей используем конкретные типы, а не интерфейсы. Если понадобится — сделаем интерфейсы. Здесь на меня должны накинуться фанатичные последователи DIP секты. Пожалуйста, давайте не здесь, я напишу по этому поводу отдельную статью, там и «минусуйте».

Внешние зависимости

Все внешние зависимости имеют вызов .Use(). Их легко найти. В контроллере их 3: CategoryRepo.Use, LocationRepo.Use, ReadProductDb.Use.

CategoryRepo и LocationRepo одинаковые по реализации, поэтому в примерах ниже будет только LocationRepo.Use, ReadProductDb.Use. Они отличаются.

Внешние зависимости — это всегда провайдеры. Создаем их вот так:

package dc 

import "github.com/abratko/dc"

// Интерфесы нужны только для тестов,
// чтобы была возможность создать мок-объекты по интрефесу.
// По-хорошему, их нужно определить в месте использования.
// Здесь они определены для примера.
type LocRepo interface {
	FindByIds(ctx context.Context, ids []string) ([]dto.Location, error)
}

// Создаем провайдеры 
var LocationRepo = dc.Provider(func() LocRepo {
...
})

...
// Здесь мы делаем провайдер метода.
// Провайдер возвращает метод как функцию,
// для него не нужен интерфейс. 
var ReadProductDb = dc.Provider(
	func() func(context.Context, map[string]any) (*esapi.Response, error) {
		return ProductReader.Use().Read
	}
)

Провайдер возвращает то, что отдает фабричная функция. LocationRepo возвращают интерфейс, а ReadProductDb — функцию. Лучше всегда возвращать интерфейс или функцию. Если вернуть тип, то someProvider.Mock(mockObject) примет только объект того же типа, поэтому в тестах будут проблемы с подменой.

Внедрение выглядит вот так:

...
opt_fetcher.NewLocationOptFetcher(LocationRepo.Use().FindByIds),
...
es.NewFetchItems(ReadProductDb.Use())
...	

Здесь мы внедрили методы. Это безопасно. Go сравнит функциональные типы по сигнатуре, а для метода сохранит ссылку на хостовую структуру. Интерфейс здесь не нужен. И тут в меня начинают лететь минусы, я начинаю «уклоняться».

Удалим лишние абстракции

Проведем эксперимент: вынесем LocationRepo.Use().FindByIds в провайдер.

var FindLocationByIds = dc.Provider(
	func() func(context.Context, ids []string) ([]dto.Location, error) {
		return LocationRepo.Use().FindById
	}
	...
})

Внедрение станет таким:

...
opt_fetcher.NewLocationOptFetcher(FindLocationByIds.Use()),
...

Тогда интерфейс LocRepo больше не нужен. Удалим его и получим внедрение внешних зависимостей без интерфейсов.

Результат

В результате имеем модуль:

  • с четкими и видимыми границами, внешними и внутренними зависимостями;

  • с сильной внутренней прочностью за счет использования типов;

  • с слабой внешней связанностью за счет использования интерфейсов и функциональных типов;

  • с минимальным количеством интерфейсов при использовании функциональных типов.

А, что с тестами?

Ах, да... тесты. В тестах делаем так:

...
import "/config/dc"

type LocationRepoMock struct {
	mock.Mock
}

func (m *LocationRepoMock) FindByIds(ctx context.Context, ids []string) ([]dto.Location, error) {
	args := m.Called(ctx, ids)
	
	return args.Get(0).([]dto.Location), args.Error(1)
}

func TestSearchServer_Exec(t *testing.T) {

	t.Run("input request with invalid json", func(t *testing.T) {
		...
		mockLocationRepo := &LocationRepoMock{}
		mockLocationRepo.
			On("FindByIds", mock.Anything, mock.Anything).
			Return(fixtures.LocationsFixture, nil)
		
		// так подменяем интерфейс 
		dc.LocationRepo.Mock(mockLocationRepo)
		
		// так подменяем функциональный тип 
		dc.FindLocationByIds.Mock(mockLocationRepo.FindByIds)
		
		service := dc.SearchGrpcController.Use()
		
		// не забываем сбрасывать контейнер после кажго теста
		dc.Reset()	
	}
}

Здесь у нас модульное тестирование. Мы подменяем только внешние зависимости.
Если нужно тестировать внутренние зависимости, то делаем это обычными юнит‑тестами.

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

Параллельные тесты

Параллельный запуск тестов сейчас не возможен. Но задача выглядит решаемой без изменения API. Возможно решение - добавить правильные локи в методы провайдела Use и Mock, Reset. Хотя не факт.


dc.SubService.Mock(mock1) 
service1 = dc.Myservice.Use()
dc.Reset()
// и тут же сделать 
dc.SubService.Mock(mock2) 
service2 = dc.Myservice.Use()
dc.Reset()

В примере `service1` и `service2` это разные инстансы одного и того же сервиса в одном тесте, они не будут конфликторать. Но если запустить тест параллельно будут проблемы. Возможно, если лочить сброс контейнера и лочить запись моков и извлечение, то проблему можно решить. Нужно подумать.

Спасибо @alexac за обратную связь

С тестами тоже разобрались без использования uber‑fx.

Давайте продолжим разговор

Если есть какие‑то особые случаи и причины использовать uber‑fx или другие контейнеры — пишите в комментариях.

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

Контейнер хранит зависимости, предоставляет интерфейс для их регистрации и извлечения, остальное не его ответственность.

UPD:

Ошибки в фабричных функциях

Фабричные функции не возвращают ошибок, и это осознанное решение. Это связанно с особенностями разработки на go в отличии от PHP. Об этом я упоминал в «Расскажите, зачем вам DI‑контейнер в golang». Все дерево зависимостей должно быть построено сразу при запуске приложения. Нет динамиского создания зависимсотей в рантайме. Если фабрика не может создать зависимость, она должна запаниковать, а приложение упасть.
Если у вас какие-то динамические подключения к портам, базам даных и т.п., которые меняются в рантайме, то управление этим хозяйством - это ответсвенность отдельного сервиса, а не контейнера. Не нужно это туда пихать, там и так много всего.