golang

Расскажите, зачем вам DI-контейнер в golang

  • вторник, 8 апреля 2025 г. в 00:00:09
https://habr.com/ru/articles/898290/

Я много писал на PHP + Symfony, писал на Angular, Vue. Я понимаю зачем DI-контейнер в Symfony, могу понять зачем он на фронте, особенно PWA. Я понимаю, какую проблему/задачу он там решает, почему он там нужен.

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

Например PHP + Symfony

При каждом запросе PHP-приложение создаётся «с нуля» и потом «умирает». Каждый запрос обрабатывается контроллером. Контроллер имеет зависимости, которые имеют ещё зависимости и т.д. Разных запросов много, дерево зависимостей большое, нет необходимости создавать все зависимости сразу. Для запроса нужна только одна ветвь дерева. Именно это и делает контейнер в PHP: при каждом запросе создаёт ветку зависимостей. При завершении запроса контейнер «умирает» вместе с веткой зависимостей.

Возьмём PWA на фронте.

Допустим, у вас «всё по красоте»: слоёная архитектура, бизнес-логика в сервисах отдельно от компонентов представления. Пользователь ходит по разделам сайта: каталог, корзина и т.п. Когда пользователь заходит в каталог, то компонент каталога хочет получить готовый сервис бизнес-логики, а не создавать его самостоятельно и не «думать» о его зависимостях. Если пользователь уходит в корзину, и сервис каталога нигде не используется, то он не нужен. Сервис каталога, все его зависимости + данные можно удалить. Это и делает контейнер на фронте: создаёт сервис по необходимости и удаляет его, если он больше не нужен.

Резюмируем: и в PHP, и на фронте контейнер хранит информацию о зависимостях и создаёт поддерево всех зависимостей по запросу и удаляет после использования.

Что же в Go?

Приложение на Go — это скомпилированный бинарник. Бинарник запускается и «живёт вечно», если его не прибить. Предположу: чаще всего на Go пишут grpc или HTTP-сервисы. Когда сервис стартует, он регистрирует все свои обработчики запросов, тем самым создавая сразу всё дерево зависимостей. Зависимости «живут», пока «живёт» сервис, ничего удалять и повторно создавать не нужно.

В приложении на Go нет проблемы, которая есть в PHP-приложении или PWA на фронте.

Ок, но зависимости внедрять нужно

Согласен. Сойдёмся на том, что мы хотим внедрить зависимости.

Хотя ещё нужно разобраться, что и куда внедрять, но это тема отдельного разговора.

Ну… мы же не просто хотим внедрить зависимости, хочется чтобы:

  • конфигурация была максимально маленькой, желательно автоматической,

  • отслеживала циклические зависимости,

  • была типобезопасной,

  • позволила сосредоточиться на создании бизнес-кода,

  • увеличила читаемость,

  • упрощала вхождение в проект новых разработчиков и т.п.

У меня лично нет желания бороться со сложностью в конфигурации. Мне и так есть над чем подумать. Я видел config-hell в ранних версиях Symfony, когда ещё не было autowire, этого совсем не хочется.

Какие есть варианты?

Давайте использовать готовое

Давайте. Что-то от серьёзных компаний. Например это:

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

Я вот посмотрел документацию, API, примеры. Мой вывод: нет, результат будет противоположным нашим желаниям по всем пунктам.

Из документации я понял далеко не всё, хотя у меня есть опыт использования DI-контейнеров в разных контекстах, да я и сам писал DI-контейнеры. Что будет с начинающими разработчиками, я не представляю. А мы ведь хотим просто внедрить зависимости.

Тогда зачем это использовать?

Ну, тогда сделаем своё

Т.е. вы сможете сделать так, чтобы автоматически, минимально, читаемо и т.п.?

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

Мне кажется, что «по-простому» это технически невозможно из-за особенностей языка. Как минимум:

  • Интерфейсы не связаны со структурами.

  • Одна структура неявно может подходить многим интерфейсам.

Не получится сделать autowire, частичную замену зависимостей, как в Symfony или Laravel. Всегда придётся регистрировать всё в конфигурации вручную, связывать структуры с интерфейсами или описывать «хитрые конфиги» для кодогенерации.

Причём там будут не объекты, а типы и интерфейсы. Просто так не продебажишь. Постепенно начнут «прорастать» дженерики. Эх... Вот красота-то будет, читай, перечитайся…

Аналогичная проблема есть на фронте: в TypeScript есть интерфейсы, но в JavaScript их нет, нельзя простым способом связать класс с интерфейсом.

Здесь нужно уточнить: я исхожу из того, что все зависимости должны описываться через интерфейсы. Если вы используете конкретные типы, то необходимость DI-контейнера становится еще более сомнительной.

Несмотря на это, я открываю очередной проект и вижу там что?... Правильно — свой контейнер зависимостей.

Зачем всё это?

Короче… Критикуешь — предлагай

Вариантов мало, точнее один

Делать композицию сервисов вручную в отдельном пакете. Явных преимуществ перед предыдущими вариантами нет. Согласен. Проблемы остались все те же:

  • Конфигурация большая.

  • Всё нужно описывать вручную.

  • Нет автоматики.

  • Нужно самому отслеживать циклические зависимости и т.п.

Но есть плюсы:

  • Это стандартный Go-код, понятный любому разработчику, даже не знающему Go.

  • Не нужно учить дополнительный API.

  • Легко дебажится.

  • Можно управлять публичным интерфейсом пакета.

  • Нет «магии», всё явно прописано.

На самом деле, можно и контейнер

Но только максимально простой. Например, такой:

package dc

import "github.com/google/uuid"

var container = map[uuid.UUID]any{}
var isPending = map[uuid.UUID]bool{}

func provider[T any](factoryFunc func() T) func() T {
	providerId := uuid.New()
	return func() T {
		if pending, ok := isPending[providerId]; ok && pending {
			panic("Detected circular dependency, please check your code")
		}

		if _, ok := container[providerId]; !ok {
			isPending[providerId] = true
			container[providerId] = factoryFunc()
			isPending[providerId] = false
		}
		return container[providerId].(T)
	}
}

func Reset() {
	container = map[uuid.UUID]any{}
	isPending = map[uuid.UUID]bool{}
}

Внимание, код написан на коленке и не тестирован.

Это весь контейнер. Пусть файл называется core.go, положим его в папку dc. Там же будут другие конфиги по областям, примерно так:

dc
 |- core.go
 |- catalog.go
 \- cart.go
main.go

В файле catalog.go пишем:

// ... Ещё зависимости

var useFetchItemsService = provider(
	func() domain.Command[dto.SearchCriteria, *dto.ItemsCollection] {
		return service.NewFetchItemsService(
			// ... Ещё зависимости
		)
	},
)

var useFetchFiltersService = provider(
	func() domain.Command[map[string]any, *model.Filters] {
		return service.NewFetchFiltersService(
			// ... Ещё зависимости
		)
	},
)

var UseFacetedSearchGrpcService = provider(
	func() *faceted_search.FacetedSearchGrpcService {
		return faceted_search.NewFacetedSearchGrpcService(
			useFetchItemsService(),
			useFetchFiltersService(),
		)
	},
)

Теперь можно использовать публичный UseFacetedSearchGrpcService в main.go:

import "/dc"

func main() {
	...
	searchService := dc.UseFacetedSearchGrpcService()
	...
}

Получили:

  • Условное разрешение циклических зависимостей: приложение упадёт при старте, если они есть.

  • Соглашение об именовании, т.е. некоторую стандартизацию и читаемость.

  • Конфигурация как код в отдельном слое.

  • Простой и понятный код.

  • Типобезопасность.

  • Максимально маленький и простой API.

Предлагаю не воевать с ветряными мельницами, когда вокруг столько других задач.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какой DI-контейнер используете?
78.57% Не используем, конфигурация в отдельном пакете22
0% Написали свой. (расскажите в комментах какую задачу решает)0
21.43% Используем контейнер от «гигантов IT»6
Проголосовали 28 пользователей. Воздержались 7 пользователей.