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

от автора

Я много писал на 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-контейнер используете?

76.92% Не используем, конфигурация в отдельном пакете20
0% Написали свой. (расскажите в комментах какую задачу решает)0
23.08% Используем контейнер от «гигантов IT»6

Проголосовали 26 пользователей. Воздержались 7 пользователей.

ссылка на оригинал статьи https://habr.com/ru/articles/898290/