Pull to refresh

Comments 15

используем контейнер, потому что с ним удобно писать тесты

Я вообще не понимаю, каким образом связаны тесты и DI-контейнер. Если я прокидываю все зависимости через конструктор, то что мне мешаем замокать все зависимости?

DI-контейнер изначально появился как решение проблемы IoC (inversion of control). Тесты можно и нужно писать без контейнера.

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

Преимущество использования DI для меня в том, что он заставляет разработчиков использовать инверсию зависимостей и работать над уменьшением связности кода. И именно инверсия зависимостей, и слабая связность — то, что помогает писать тесты, а не DI как таковой. Использовать ли DI контейнер внутри тестов или нет — вопрос десятый. Если конкретный тест легко написать без DI контейнера — нужно писать без контейнера. Если у компонента 100500 зависимостей, для каждой из которых нужно подсунуть фикстуры/моки, бывает гораздо проще использовать DI-контейнер прямо внутри теста, и переиспользовать часть кода инициализации приложения, чем писать это все руками.

Кодогенерация — тоже отличный подход. Со своими плюсами и минусами относительно DI. И не использовать ничего из этого вообще — тоже вариант, если это устраивает в конкретном случае.

если " у компонента 100500 зависимостей", то проблема в архитектуре компонента, а не в потребности в автоматическом (через либы) DI

ЗЫ: и да, "ручной" DI через конструкторы - тоже DI...

Это весьма спорный подход. Да, может быть для какого-то кейса он будет работать.

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

При применении инверсии зависимостей, каждая фабричная функция будет принимать все зависимости через параметры, а значит каждая фабричная функция:

  1. будет иметь дело с обработкой только тех ошибок, которые появляются внутри этой функции и имеют непосредственное отношение к созданию компонента.

  2. может быть написана с предположением, что все аргументы уже проинициализированы и поставляются в готовом виде, без необходимости дополнительных проверок (опциональные зависимости — исключения из правил).

  3. становится максимально простой и имеет минимум ветвлений, причем имеющиеся ветвления релевантны тому, что эта функция делает, а значит тесты будут осмысленными и писать их будет довольно легко.

  4. не зависит от глобального состояния, что позволяет вызвать ее множество раз с разными параметрами для инициализации большого количества компонентов, которые разделяют имплементацию, но отличаются конфигурацией. А еще это позволяет запускать тесты параллельно.

Разбираю проблемы конкретно:

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

Если мы добавляем в сигнатуру фабричной функции возможность вернуть ошибку, то провайдер становится гораздо сложнее и простой Provider.Use(), чтобы получить значение — тоже перестает работать. Если внутри этого провайдеры фабричная функция вернет ошибку, что должно происходить? Пометить провайдер как сломанный, и вернуть ошибку из Use()? Тогда в каждой фабричной функции, будет вереница из if err != nil { return err } на каждую зависимость, что приведет к большому количеству бойлерплейта и ветвлений, которые муторно (и немного бесполезно) проверить в тестах, но которые испортят метрики покрытия. Другой вариант — провайдер может сохранить ошибку и выбросить панику из Use(), а потом можно поймать ее где-нибудь через recover и обработать ошибку полноценно. Тоже так себе решение, это подразумевает, что либо фабричные функции не производят деинициализацию при ошибках, либо делают ее исключительно внутри defer. причем Это же фабричная функция, если ошибок не было, деинициализация вызываться не должна, то есть внутри defer еще и ветвления будут. Шикарно, я не захочу писать для такого тесты, а они нужны даже больше, чем при подходе с явными ошибками.

А теперь главная проблема — при этом подходе используется много глобального состояния, и прямые зависимости. Нельзя запускать тесты параллельно — они будут конфликтовать. Каждая фабричная функция, должна точно знать, какой провайдер вызвать, чтобы получить конкретную зависимость. Это ничем не лучше прямого вызова одной фабричной функции из другой, просто добавился слой мемоизации и возможность подменить возвращаемое значение.

Если пытаться решить эти проблемы, в вашем решении, то:

  1. придется сначала вытащить провайдеры зависимостей в аргументы функции (собственно вот на этом этапе появляется инверсия зависимостей). Без этого не появится возможность вызывать фабричную функцию многократно с разными зависимостями, или запускать тесты параллельно.

  2. затем, чтобы избавиться от просачивания ошибок сквозь провайдер и вынести их обработку из фабричных функций, заменить в параметрах функций провайдеры на собственно объекты, которые возвращаются этими провайдерами. Это потребует вызова провайдеров в правильном порядке. Тут есть два варианта:

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

    b. Используем рефлексию, по сигнатурам фабричных функций и каким-то дополнительным аннотациям, строим граф зависимостей, топологически его сортируем, и вызываем каждый провайдер в цикле, обрабатывая ошибки. Поздравляю, получился DI-контейнер. Накручиваем сверху совсем немножко фич, чтобы это было удобно конфигурировать, и получаем uber-fx. Оттестировать такой подход проще, потому что он не привязан к конкретному набору компонентов. Можно отдельно написать тесты на маленькие фабричные функции, отдельно на DI, и отдельно на функцию, которая создает все провайдеры для DI.

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

Я понимаю, что DI ощущается довольно сложной концепцией для того чтобы использовать его в маленьких сервисах, и согласен, что тащить его куда попало, просто потому что он существует — ужасный подход. Но пожалуйста, не надо никому доказывать, что DI не нужен и его можно заменить каким-то гораздо более простым кодом на сотню строк. При попытках добавить довольно простые, но реальные требования вроде обработки ошибок и переиспользования компонентов, подход начинает рассыпаться, и либо превращается в отсутствие структуры вообще, либо вырастает в полноценный DI.

У Вас сигнатура фабричной функции подразумевает, что она не может вернуть ошибку.

да, это осознанное решение

Но в реальности, фабричные функции вполне себе возвращают ошибки.

в реальности будет так как вы напишите, тут нет магии.

Не удалось распарсить конфиг — ошибка,

Вы должны запаниковать и не запуститься.

Остальное по поводу ошибок - это борьба с проблемой, которой нет, она надуманна.

Нельзя запускать тесты параллельно — они будут конфликтовать.

с этим согласен - нельзя

Но пожалуйста, не надо никому доказывать, что DI не нужен и его можно заменить каким-то гораздо более простым кодом на сотню строк.

факт в том - что можно, текущее решение это делает. И многие это делают.

DI-контейнер используется не сам по себе, а как часть подхода инверсии зависимостей.

DI-контейнер не часть принципа, а инверсия зависимостей не предполагает использования контейнера.
Ручное вредрение не противоречит принципу внедрения заимостей.
DI-контейнер - это один из инструментов, который можно не использовать, а можно использовать.

Можно брать кувалду и забивать гвозди..., а можно молоток.

п 2.b из Вашего коммента. Зачем все это? что бы что?

Единственная реальная проблема такого простого решения - параллельный запуск тестов.

Но это не проблема для очень, очень многих проектов

Каждая фабричная функция, должна точно знать, какой провайдер вызвать, чтобы получить конкретную зависимость.

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

Паралельный запуск условно возможен без изменений API, если добавить правильные локи в метод Use и Mock. Нужно над этим поразмыслить.


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

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

@alexac спасибо за конструктивный диалог

Инверсия зависимостей — это не про контейнеры DI, а про внешние зависимости внедрять через абстракции / интерфейсы на вход функции(конструктора).
В реалиях Go объявляется внешняя зависимость через локальный интерфейс и присваивается структуре через функцию конструктор.

package service1

type outer interface {
    Operation()
}

type myservice struct {
  o outer
}

func New(o outer) *myservice {
    return &myservice{o:o}
}




где-то ещё возникает потребность в lazy-зависимостях... когда их потребность нужна будет потом, а не сейчас

"Общий вывод такой: используем контейнер, потому что с ним удобно писать тесты"

В прошлом голосовании 74% опрошенных ответило, что не использует DI-контейнеры, как-то вы не корректно сделали выводы.

  1. Точно сформулируйте, какую проблему решаете DI-контейнерами, впечатление, что вы используете контейнер ради контейнера.

  2. Вы точно уверены, что то, что в ./config/dc/search.go, сильно упрощает код, а если таких сервисов будет 20? И нет примера, как собирается сам контейнер со всеми зависимостями (или я не увидел), и, предполагаю, там такая же портянка плохо понимаемого кода будет.

  3. Почему всё экспортируемое как глобальные переменные типа var SearchGrpcController? С такими особенностями никакой контейнер не спасет от «комка грязи» в итоге.

  1. Контейнер решает именно ту задачу, которая описана в предыдущей статье и которая нужна для тестов: создает под дерево зависимостей по требованию с возможностью подмены зависимости на любом уровне. Например глубина дерева зависимостей 5, можно подменить только листовые зависимости.

  2. Никакой контейнер в go не упрощает код. Максимум делает его более структурированным. Потому что все равно придётся регистрировать все зависимости руками. Какой бы контейнер не был. Обьем пакета с зависимостями или конфигурации контейнера всегда примерно одинаков. Autowire невозможен, как в symfony.

  3. По сути это решение НЕ контейнер в классическом его понимании. Он не хранит мета информацию о связях и т.п. Это обычный пакет который создает зависимости. Только фабричные функции там обернуты в провайдеры. Провайдеры позволяют создавать зависимости лениво. Это позволяет в тестах создавать часть дерева зависимостей, и подменять зависимости на любом уровне.

Я так и не понял, в чем преимущество этих помоек.FX перед сгенеренными или ручками созданными объявлением интерфейса + структура/конструктор. Видимо, чтобы иде не нашла автоматом имплементацию и пришлось писать убожество аля "var _ = myinterface(&externalPackage.Blablabla)".

Sign up to leave a comment.

Articles