Расскажите, зачем вам DI-контейнер в golang
- вторник, 8 апреля 2025 г. в 00:00:09
Я много писал на PHP + Symfony, писал на Angular, Vue. Я понимаю зачем DI-контейнер в Symfony, могу понять зачем он на фронте, особенно PWA. Я понимаю, какую проблему/задачу он там решает, почему он там нужен.
Но никак не могу понять, зачем он в микросервисах и даже сервисах большого размера на Go.
При каждом запросе PHP-приложение создаётся «с нуля» и потом «умирает». Каждый запрос обрабатывается контроллером. Контроллер имеет зависимости, которые имеют ещё зависимости и т.д. Разных запросов много, дерево зависимостей большое, нет необходимости создавать все зависимости сразу. Для запроса нужна только одна ветвь дерева. Именно это и делает контейнер в PHP: при каждом запросе создаёт ветку зависимостей. При завершении запроса контейнер «умирает» вместе с веткой зависимостей.
Допустим, у вас «всё по красоте»: слоёная архитектура, бизнес-логика в сервисах отдельно от компонентов представления. Пользователь ходит по разделам сайта: каталог, корзина и т.п. Когда пользователь заходит в каталог, то компонент каталога хочет получить готовый сервис бизнес-логики, а не создавать его самостоятельно и не «думать» о его зависимостях. Если пользователь уходит в корзину, и сервис каталога нигде не используется, то он не нужен. Сервис каталога, все его зависимости + данные можно удалить. Это и делает контейнер на фронте: создаёт сервис по необходимости и удаляет его, если он больше не нужен.
Резюмируем: и в PHP, и на фронте контейнер хранит информацию о зависимостях и создаёт поддерево всех зависимостей по запросу и удаляет после использования.
Приложение на Go — это скомпилированный бинарник. Бинарник запускается и «живёт вечно», если его не прибить. Предположу: чаще всего на Go пишут grpc или HTTP-сервисы. Когда сервис стартует, он регистрирует все свои обработчики запросов, тем самым создавая сразу всё дерево зависимостей. Зависимости «живут», пока «живёт» сервис, ничего удалять и повторно создавать не нужно.
В приложении на Go нет проблемы, которая есть в PHP-приложении или PWA на фронте.
Согласен. Сойдёмся на том, что мы хотим внедрить зависимости.
Хотя ещё нужно разобраться, что и куда внедрять, но это тема отдельного разговора.
Ну… мы же не просто хотим внедрить зависимости, хочется чтобы:
конфигурация была максимально маленькой, желательно автоматической,
отслеживала циклические зависимости,
была типобезопасной,
позволила сосредоточиться на создании бизнес-кода,
увеличила читаемость,
упрощала вхождение в проект новых разработчиков и т.п.
У меня лично нет желания бороться со сложностью в конфигурации. Мне и так есть над чем подумать. Я видел config-hell в ранних версиях Symfony, когда ещё не было autowire, этого совсем не хочется.
Какие есть варианты?
Давайте. Что-то от серьёзных компаний. Например это:
https://github.com/google/wire/blob/main/_tutorial/README.md
https://pkg.go.dev/github.com/facebookgo/inject#example-package
Хорошо... Т.е., если мы будем их использовать, то конфигурация будет маленькой, мы сможем сосредоточиться на бизнес-коде и т.д.?
Я вот посмотрел документацию, 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.
Предлагаю не воевать с ветряными мельницами, когда вокруг столько других задач.