Я много писал на 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, этого совсем не хочется.
Какие есть варианты?
Давайте использовать готовое
Давайте. Что-то от серьёзных компаний. Например это:
-
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.
Предлагаю не воевать с ветряными мельницами, когда вокруг столько других задач.
ссылка на оригинал статьи https://habr.com/ru/articles/898290/
Добавить комментарий