Внедрение зависимостей в GO
Идея внедрения зависимости проста: объект, зависящий от другого объекта, делегирует управление его жизненным циклом внешнему коду.
Здесь объект самостоятельно управляет жизненным циклом своей зависимости:
func NewGreeter(name string) (*Greeter, error) {
sender, err := NewSender()
if err != nil {
return nil, err
}
return Greeter{name, sender}, nil
}
func (g Greeter) Greet() error {
return g.sender.Send("Hello, " + g.name + "!")
}
func (g *Greeter) Close() error {
return o.sender.Close()
}
g, err := NewGreeter("Go")
if err != nil {
panic(err)
}
defer g.Close()
g.Greet()
А здесь он делегирует эту задачу — это и есть Dependency Injection:
func NewGreeter(name string, sender *Sender) *Greeter {
return Greeter{name, sender}
}
func (g Greeter) Greet() error {
return g.sender.Send("Hello, " + g.name + "!")
}
s, err := NewSender()
if err != nil {
panic(err)
}
defer s.Close()
g := NewGreeter("Go", s)
g.Greet()
Такой подход, несмотря на свою простоту, даёт существенные преимущества:
Однако платой за всё это является дополнительное требование — зависимостями нужно управлять. Для небольших кодовых баз это не представляет проблемы. Но сложность со временем обычно только возрастает — для Go, конечно, в отличие от той же Java не характерно использование фабрик фабрик, но однажды отслеживание графа зависимостей может превратиться в отдельную задачу даже для микросервиса: конфигурация, логи, метрики, кэши, базы данных, брокеры, сервера протоколов, клиенты внутренних и внешних сервисов и т.д., не говоря уже про всевозможные разветвлённые внутренние абстракции в виде роутеров, сервисных слоёв или репозиториев.
Решить эту проблему призваны DI-контейнеры. В общих чертах контейнер работает следующим образом:
Сначала необходимо сконфигурировать контейнер — сообщить ему способы создания объектов определённых типов (в более общем случае правильнее говорить об абстрактных идентификаторах объектов, но всё же обычно в этой роли выступают именно типы). Разные реализации предлагают для этого различные пути — причём некоторые из них предполагают использование специальных файлов конфигурации вместо кода.
После этого контейнер по запросу может предоставить уже полностью готовый к использованию объект нужного типа. При этом контейнер рекурсивно разрешает зависимости — если для создания объекта нужны объекты других типов, они будут неявно созданы внутри контейнера и затем использованы при создании основного объекта. Обычно каждый объект рассматривается как синглтон — контейнер создаёт его один раз и затем использует везде, где он требуется. Но опять же, для разных реализаций существуют нюансы.
В конце жизненного цикла каждого созданного им объекта контейнер автоматически уничтожает его, если это необходимо. И снова — понятие «конца жизненного цикла» различается от реализации к реализации. Как минимум, можно быть уверенным в том, что оно совпадает с концом жизненного цикла самого контейнера, который в свою очередь гарантированно совпадёт с завершением программы.
Здесь я хочу упомянуть и после навсегда забыть Service Locator. По большому счёту это то, с чего мы начинали: объект зависит от локатора, из которого сам извлекает (pull) свои зависимости. Хотя такой подход несколько снижает связность кода и повышает его тестируемость за счёт отсутствия взаимодействия конструктора зависимого объекта с конструкторами зависимостей, но сами зависимости при этом скрыты: нет никакого иного пути узнать их, кроме как «подсмотреть» в документации (если она есть) или непосредственно в коде объекта. На мой взгляд, SL заслуженно считается многими анти-паттерном.
В основном же, когда речь идёт о DI, имеется в виду IoC-контейнер. Инверсия управления заключается в том, что программист просто объявляет зависимости в форме аргументов функций (в том числе конструкторов), а контейнер вызывает эти функций, передавая (push) в них нужные значения (собственно говоря, «внедряя зависимости»). Также контейнер может присваивать значения свойствам объектов, однако этот способ рекомендуется использовать только для опциональных зависимостей, тогда как обязательные принимать исключительно через аргументы конструктора. Другими словами, когда нам нужно вызвать функцию, мы передаём её контейнеру, а он уже разрешает её зависимости и вызывает её, когда всё готово.
Тут много магии. Однако зависимость от контейнера весьма слабая — все объекты по-прежнему могут быть созданы, а функции вызваны, вручную (например, при тестировании). В то же время, для больших графов зависимостей от контейнера есть ощутимая польза — он полностью автоматизирует сложную рутинную задачу, при этом не требуя (или почти не требуя) изменений в коде. Таким образом, можно начать с программы из базы данных и логгера, управляемых вручную, а затем подключить контейнер, когда в нём появится реальная необходимость.
Что там с этим в GO?
Многие фреймворки в других языках поддерживают внедрение зависимостей из коробки — некоторые даже основаны на нём. В качестве примеров можно привести Spring, .NET Framework, Symfony, Laravel, Angular, Dagger. Даже для C++ и Rust можно что-то найти, но глядя на список невольно обращаешь внимание, что темой DI в основном интересуется кровавый энтерпрайз :)
В сообществе Go эта тема не очень популярна, но тем не менее представлена Wire от Google и Fx от Uber (там внутри используется dig). Их можно считать основными, хотя есть ещё ряд проектов (аттеншн! в списке по ссылке не тот wire).
Итак, давайте взглянем на эти проекты, чтобы оценить их преимущества и недостатки. Я также скромно добавлю к обзору и свой проект KInit.
Uber Fx
github.com/uber-go/fx
github.com/uber-go/dig
Начнём с dig, который исторически был одной из первых реализаций DI в Go (Fx использует его под капотом). Механизм работы этого контейнера основан на рефлексии. С точки зрения производительности это не так плохо, как может показаться — в подавляющем большинстве случаев DI работает только на этапе инициализации приложения, и медленная рефлексия в этот момент лишь капля в море. Основной недостаток здесь скорее в том, что об ошибках в графе зависимостей (в частности, о циклических и неудовлетворённых зависимостях) можно узнать только запустив программу, а не во время компиляции. Но при наличии проблем программа сломается при запуске, а не во время работы, так что это можно принять.
Для начала работы с dig потребуется явно создать контейнер:
c := dig.New()
Затем нужно сконфигурировать контейнер:
if err := c.Provide(NewLogger); err != nil {
...
}
if err := c.Provide(NewDB); err != nil {
...
}
После этого можно вызвать точку входа программы:
if err := c.Invoke(func (logger *log.Logger, db *sql.DB) error { ... }); err != nil {
...
}
Пока выглядит даже несколько менее удобно, чем ручное управление зависимостями — как минимум, зависимости конструкторов NewLogger
и NewDB
из кода не очевидны, хотя способ их создания нужно по-прежнему явно сообщить контейнеру. То есть если NewDB
зависит, скажем, от конфигурации, то программа успешно скомпилируется, но при запуске сломается с ошибкой вида:
missing dependencies for function ... (path/to/file.go:42): missing type: *Config
Кроме того, *sql.DB
реализует интерфейс io.Closer
, но метод db.Close
нигде не вызывается. Хотя Go в состоянии самостоятельно освободить системные ресурсы при завершении программы, это всё же не очень хорошо.
Тем не менее, давайте посмотрим, что можно со всем этим сделать.
Для начала, имеет смысл вынести инициализацию контейнера в отдельную функцию. Можно также сделать контейнер глобальным, что позволит регистрировать конструкторы непосредственно в соответствующих пакетах:
package object
import (
".../container"
".../other"
)
func init() {
if err := container.Provide(New); err != nil {
panic(err)
}
}
type Object struct { ... }
func New(o *other.Other) (*Object, error) { ... }
Тогда при импорте пакета object
в контейнере автоматически окажется его конструктор вместе с конструкторами всех импортируемых им зависимостей. Это одновременно распределит когнитивную нагрузку по управлению зависимостями и задействует все преимущества системы импортов Go (например, очень сложно будет создать циклическую зависимость, а неиспользуемые зависимости просто не попадут в контейнер).
Но давайте предположим, что мы не хотим связываться с глобальным состоянием, и остановимся на выделенной функции. Она делает возможным следующее:
func run(logger *log.Logger, db *sql.DB) error { ... }
// +build !validate
func main() {
c, err := NewContainer()
if err != nil {
panic(err)
}
if err := c.Invoke(run); err != nil {
panic(err)
}
}
// +build validate
func main() {
c, err := NewContainer(dig.DryRun(true)) // DryRun указывает dig не выполнять
// функции, а просто анализировать
// их сигнатуры.
if err != nil {
panic(err)
}
if err := c.Invoke(run); err != nil {
panic(err)
}
}
Теперь можно просто запустить команду
go run -tags validate
и получить результат валидации графа зависимостей без фактического запуска программы.
Но можно и не усложнять и вместо тэгов просто использовать тесты :)
А вот заставить dig вызвать db.Close
не получится — он просто этого не умеет. Чтобы справиться с этой проблемой, нужно использовать Fx.
Fx вводит понятие приложения, в котором контейнер — лишь одна из составных частей. Другой его составной частью является fx.Lifecycle
, который и позволит зарегистрировать хук для этапа остановки приложения:
func NewDB(lc fx.Lifecycle, logger *log.Logger) (*sql.DB, error) {
db, err := sql.Open("...")
if err != nil {
return nil, err
}
logger.Print("database initialized")
lc.Append(fx.Hook{
OnStop: func(context.Context) error {
logger.Print("database finalized")
return db.Close()
},
})
return db, nil
}
app := fx.New(
fx.Provide(
NewLogger,
NewDB,
),
fx.Invoke(run),
)
app.Run() // Блокируется в ожидании SIGINT, можно использовать Start/Stop.
Мы теряем возможность использовать DryRun, но зато можем наконец закрыть базу данных (но не в случае паники).
Зависимость конструктора от fx.Lifecycle
выглядит неприятно — она автоматически делает Fx несовместимым со стандартными (и нормально тестируемыми) конструкторами, которые придётся оборачивать специально для фреймворка.
Аналогично в dig есть dig.In
и dig.Out
, расширяющие возможности сигнатур функций -, но с ними можно ни разу не столкнуться за всё время использования контейнера, поэтому это скорее безобидные костыли. А вот освобождение ресурсов — задача довольно типовая, и для меня выглядит странным, что для неё dig не предлагает простого решения.
Больше информации про dig и Fx можно найти в документации к ним. Я же предлагаю рассмотреть следующий проект.
Google Wire
github.com/google/wire
Этот проект кардинально отличается от предыдущего тем, что предлагаемый им механизм DI основан на кодогенерации. Это снимает все вопросы, связанные с производительностью, и позволяет обнаруживать проблемы с графом зависимостей во время компиляции.
Также преимуществом Wire является то, что контейнер конфигурируется не с помощью каких-то специальных файлов, а непосредственно с помощью кода Go… Ну, тут, как мне кажется, требуется небольшое уточнение — в формате кода Go. Дело в том, что все функции пакета wire
, используемые для конфигурирования, по сути представляют собой просто декларативные маркеры и не содержат никакого кода. Настоящий код генерируется утилитой wire, которая интерпретирует эти маркеры согласно своим правилам — от пакета wire
результат генерации уже никак не зависит. Это одновременно и плюс, и минус: с одной стороны, мы в результате получаем нативный код, но с другой — за его появлением стоит даже больше магии, чем обычно и так привносят DI-контейнеры. Впрочем, для Go кодогенерация давно стала привычным инструментом, поэтому плюс в данном случае более существенен.
Wire основан на двух понятиях — провайдерах и инжекторах. Программист описывает функцию-инжектор на Go-совместимом языке Wire, декларируя функции-провайдеры, а Wire на основе описания генерирует реальный код инжектора, эти провайдеры использующий.
Создаём файл wire.go (имя не принципиально):
// +build wireinject
package main
import (
"database/sql"
"log"
"github.com/google/wire"
)
type Container struct {
Logger *log.Logger
DB *sql.DB
}
func NewContainer() (*Container, func(), error) {
panic(wire.Build(
NewLogger,
NewDB,
wire.Struct(new(Container), "*"),
))
}
Запускаем Wire:
> wire github.com/user/module
< wire: github.com/user/module/wire: wrote path/to/module/wire_gen.go
Получаем сгенерированный код в файле wire_gen.go (исходное имя с постфиксом _gen):
// Code generated by Wire. DO NOT EDIT.
//go:generate go run github.com/google/wire/cmd/wire
//+build !wireinject
package main
import (
"database/sql"
"log"
)
// Injectors from wire.go:
func NewContainer() (*Container, func(), error) {
logger, cleanup, err := NewLogger()
if err != nil {
return nil, nil, err
}
db, cleanup2, err := NewDB(logger)
if err != nil {
cleanup()
return nil, nil, err
}
container := &Container{
Logger: logger,
DB: db,
}
return container, func() {
cleanup2()
cleanup()
}, nil
}
// wire.go:
type Container struct {
Logger *log.Logger
DB *sql.DB
}
Сразу остановлюсь на функциях cleanup. Это способ очистки ресурсов, предлагаемый Wire. Для базы данных провайдер может выглядеть так:
func NewDB(logger *log.Logger) (*sql.DB, func(), error) {
db, err := sql.Open("...")
if err != nil {
return nil, nil, err
}
logger.Print("database initialized")
return db, func() {
_ = db.Close()
logger.Print("database finalized")
}, nil
}
Wire принимает и стандартные конструкторы вида func(...) T
и func(...) (T, error)
, но для них никакой финализации не выполняется, даже если T
имплементирует io.Closer
.
Это конечно лучше, чем зависимость конструктора от fx.Lifecycle
, но всё же создаёт те же проблемы: несовместимость со стандартными конструкторами и отсутствие гарантий финализации при панике. Однако наличие такой функциональности из коробки, причём весьма просто реализованной, лично меня обрадовало.
По большому счёту, это и есть весь Wire — простой для понимания и полезный в работе. О его дополнительных фичах вы можете подробнее прочитать в документации.
А что с распределением конфигурации по пакетам, которую для dig получилось реализовать с помощью глобального контейнера? В какой-то мере это можно реализовать с помощью Provider Sets, но необходимо помнить, что для одного типа в wire.Build
может существовать только один провайдер. Это может оказаться проблемой для разделяемых транзитивных зависимостей: если, скажем, клиент базы данных и консьюмер сообщений оба зависят от логгера, который кроме них больше никому не нужен, то оба объекта не могут включить его провайдер в свой Provider Set — в этом случае возникнет конфликт между двумя провайдерами для одного типа. Использовать же какие-то динамические структуры типа массива провайдеров мешает тот факт, что код Wire — это не код Go, а значит, допустим, оператор распаковки массива генератору ни о чём не скажет. Так что по большому счёту конфигурировать контейнер можно только в одном месте — в описании инжектора.
Итак, Wire выглядит довольно хорошо в качестве реализации DI в Go, даже несмотря на то, что пока ещё остаётся в бета-версии (на момент написания статьи последней версией была 0.5.0). Я думаю, что часть его недостатков, если не они все, вполне могут быть устранены в будущих версиях.
В заключение я предлагаю рассмотреть мой собственный проект DI-контейнера.
KInit
github.com/go-kata/kinit
github.com/go-kata/examples
Я исходил из следующих требований, разрабатывая эту библиотеку:
Гарантия очистки ресурсов даже в случае паники. Мне кажется весьма странным то, что ни одна из реализаций DI в Go не уделила этому должного внимания.
Совместимость со стандартными конструкторами. Опять же — весьма странно, что во всех реализациях с этим возникают проблемы.
Возможность распределения конфигурации по пакетам.
Возможность валидации графа зависимостей без (реального) запуска программы. Опционально — возможность визуализации графа.
Пошаговая инициализация, при которой во время выполнения основной функции (не конструктора) можно в зависимости от текущих условий (например, указанной в строке запуска субкоманды) программно определить, какие дополнительные зависимости потребуются для продолжения работы. В существующих реализациях подобное сделать если и возможно, то скорее не с помощью библиотеки, а вопреки ей.
В результате получилось следующее.
Библиотека по умолчанию предлагает глобальный основанный на рефлексии DI/IoC-контейнер, который пакеты могут конфигурировать аналогично тому, как это было сделано для dig. Контейнер может быть проверен на отсутствие циклических и неудовлетворённых зависимостей опять же аналогично способу, описанному для dig.
Я выбрал рефлексию, поскольку нашёл способ преодолеть её основной недостаток (ошибки только в рантайме) и посчитал, что она позволяет реализовать более гибкий механизм, нежели кодогенерация. Кроме того, я решил, что никогда не поздно добавить генерацию кода на основе конфигурации глобального контейнера, тогда как проделать обратную трансформацию механизма куда сложнее.
При необходимости может быть создан, вручную сконфигурирован и провалидирован локальный контейнер. Однако я сам вижу для него единственное применение — тесты. В остальных случаях глобальный контейнер использовать намного удобнее.
Конфигурирование контейнера заключается в регистрации в нём конструкторов и процессоров. Процессор — это такая штука, которая запускается после создания объекта конструктором, причём для одного типа может быть зарегистрировано сразу несколько процессоров (конструктор же может быть только один). В самом начале я говорил про внедрение опциональных зависимостей через свойства объекта — процессоры предназначены как раз для этого.
KInit рассматривает и конструкторы, и процессоры как интерфейсы. Реализует эти интерфейсы (причём в нескольких вариантах) набор расширений KInitX. Их может реализовать и пользователь, если у него возникнет потребность в специфичном механизме. Например, конструкторов существует два вида — один основан на функциях, в то время как другой похож на wire.Struct
и инициализирует структуры на месте (актуально для структур с большим количеством полей). Если потребуется сделать специфический конструктор, использующий что-то типа dig.In
или именованных типов — его можно реализовать и использовать в KInit наряду с библиотечными.
Сконфигурировать глобальный контейнер можно как-то так:
// Конструктор рассматривает экспортируемые поля структуры
// как её зависимости.
kinitx.MustProvide((*Config)(nil))
// Процессор выполняет загрузку уже созданной структуры
// со значениями по умолчанию из файла.
kinitx.MustAttach((*Config).Load)
// Конструктор создаёт клиент базы данных, метод Close которого
// будет гарантированно вызван даже в случае паники.
kinitx.MustProvide(NewDB) // func NewDB(...) (*sql.DB, error)
// Псевдо-конструктор связывает интерфейс с реализацией.
kinitx.MustBind((*StorageInterface)(nil), (*PostgresStrorage)(nil))
После того, как контейнер сконфигурирован, в нём может быть запущен функтор — некоторая функция, также представленная интерфейсом. Функтор может возвратить другие функторы, которые должны быть запущены после него. Зависимости функторов разрешаются по мере необходимости и уничтожаются после того, как не останется функторов, ожидающих выполнения.
Работать с функторами можно примерно так:
func run(logger *log.Logger, db *sql.DB) (further kinit.Functor, err error) {
...
}
// Функтор регистрируется для учёта
// его зависимостей при валидации контейнера.
kinitx.MustConsider(run)
// Последовательность событий:
// 1. Создаются зависимости функтора run.
// 2. Функтор run запускается.
// 3. Создаются недостающие зависимости функтора further.
// 4. Функтор further запускается.
// 5. Зависимости, созданные на шагах 1 и 3, уничтожаются.
kinitx.MustRun(run)
Ещё есть недокументированные функции, позволяющие запустить в контейнере функцию, пока вы запускаете функцию -, но на то они и недокументированные:) Если они покажутся вам интересными, то отправная точка исследования здесь.
Спасибо за внимание! Я надеюсь, что эта статья была для вас полезна. Давайте обсудим DI в Go в комментариях