21.04.2020 dependency injection контейнер singleton multiton
Поговорим о том, что такое зависимости в коде программы — что это такое, что бывает когда в большой программе разрастается код инициализации и как можно с этим бороться.
В любой программе в main.go
создается и запускается тот или иной сервис.
Можно сказать, что любой существенный сервис не выполняет всю логику работы сам, а полагается на вызовы некоторых других сущностей.
Пример — логгирование (вывод информации о работе программы). Рассмотрим пример логгирования
при помощи zap
:
type Server struct { logger *zap.Logger } func NewServer(logger *zap.Logger) *Server { return &Server{logger:logger} } func (s *Server) Handle() { // do some work s.logger.Info("request processed") } logger := //... инициализация логгера NewServer(logger).Run() // запуск сервиса с логгером
Переиспользование кода повышает его эффективность. Использовать логгер, хорошо решающий свою задачу, вместо написания самому — это плюс.
Но теперь наш сервис не сам реализует логгирование, а полагается на logger
.
Другими словами, теперь logger
стал зависимостью нашего сервиса Server
.
Мы сохранили logger
как свойство Server
и тем самым внедрили его
как зависимость.
Dependency injection (внедрение зависимости) — компонование сущностей, таким образом, что одна сущность(зависимость) становится частью состояния другой(родительской сущности). Родительская сущность затем использует зависимость при необходимости.
Интересно, что русскоязычная и англоязычная версии статьи Википедии по данной теме дают абсолютные разные определения с противоречивыми примерами кода.
Далее я полагаюсь на версию англоязычной статьи,
так как в ней имеется важное уточнение
о том, что зависимость становится частью состояния родительской сущности.
Таким образом зависимость должна быть сохранена в состояние родителя.
В ином случае за внедрение зависимости можно было бы посчитать стандартный hello world, так как в нем
используется вызов библиотеки fmt
:
func main() { fmt.Println("hello world") }
В функции main отсутствует какое-либо состояние. Происходит статичный вызов функции из библиотеки. Это не внедрение зависимости.
Какие проблемы могут быть в коде иницилизации и почему это стоит обсуждать?
Проблемы появляются в сервисах с большим количеством сущностей
при наличии общих зависимостей.
При большом количестве сущностей и связей появляется
много кода для их инициализации.
Данный код без должной организации может существенно разрастаться,
повторяться и всячески усложнять поддержку сервиса.
Рассмотрим сервис, выполняющий следующие действия:
Конструктор сервиса будет выглядеть так:
func NewService( db *sql.DB, bankClient *client.Bank, cfg *config.Config, logger *zap.Logger, )
Каждая из этих зависимостей для своей инициализации также может потребовать другие зависимости. Например, для подключении к БД также потребуется конфиг:
// Получаем коннект к бд db, err := sql.Open("postgres", fmt.Sprintf( "host=%s port=%s user=%s dbname=%s sslmode=disable password=%s", configStruct.DbHost, configStruct.DbPort, configStruct.DbUser, configStruct.DbName, configStruct.DbPass) if err != nil { log.Fatal(err) }
Для создания bankClient
нам также потребуется cfg
и logger
.
Теперь представьте, что в системе в том же проекте должен теперь появиться второй сервис,
которому также потребуются зависимости — db
, cfg
, logger
.
Построим актуальную схему зависимостей.
Код инициализации первого сервиса уже занимает немало места и в нем непросто ориентироваться. Но теперь появился второй сервис и для него также необходимо написать код инициализации.
Рассмотрим варианты реализации этого кода.
Можем просто скопировать код инициализации db
, cfg
, logger
при инициализации второго сервиса.
Это будет работать, но у нас в системе появится 2 места в коде для создания этих сущностей. Это плохо — больше кода для поддержки, больше вероятность ошибки, риск исправить код неполностью.
Данный вариант не подходит, рассмотрим другие.
К примеру, мы можем написать код для инициализации БД и использовать его повторно, например так:
func GetDB(cfg *config.Config) (*sql.DB, error) { db, err := sql.Open("postgres", fmt.Sprintf( "host=%s port=%s user=%s dbname=%s sslmode=disable password=%s", configStruct.DbHost, configStruct.DbPort, configStruct.DbUser, configStruct.DbName, configStruct.DbPass) if err != nil { return nil, err } return db, nil }
Данное решение выглядит хорошо и проблема дублирования кода устранена. Однако нам все еще надо написать такой код для всех нащих сущностей, это довольно много.
Но и с базой данных мы пока не закончили — наш код GetDB
будет создавать новое подключение при каждом обращении.
Однако, в GO у нас нет нужды иметь более одного подключение к БД
из двух наших сервисов.
В случае с db
достаточно лишь одного экземпляра.
Реализуем единственное подключение с помощью паттерна одиночка (singleton):
package db var db *sql.DB func GetDB(cfg *config.Config) (*sql.DB, error) { if db != nil { return db, nil } var err error db, err = sql.Open("postgres", fmt.Sprintf( "host=%s port=%s user=%s dbname=%s sslmode=disable password=%s", configStruct.DbHost, configStruct.DbPort, configStruct.DbUser, configStruct.DbName, configStruct.DbPass) if err != nil { return nil, err } return db, nil }
Также у нас могут быть подключения к разным серверам баз данных, но каждого из подключений должно быть по одному. Мы хотим переиспользовать наше решение с singleton — реализуем пул одиночек (muliton).
показать длинный код паттерна multitonpackage db var pool *DBPool type DBPool struct { lock sync.Mutex pool map[string]*sql.DB } func init() { if pool == nil { pool = &DBPool{ pool:make(map[string]*sql.DB) } } } func GetDB(dbAlias string, cfg *config.Config) (*sql.DB, error) { pool.lock.Lock() defer pool.lock.Unlock() db, ok := pool[dbAlias] if ok { return db, nil } var err error db, err = sql.Open("postgres", fmt.Sprintf( "host=%s port=%s user=%s dbname=%s sslmode=disable password=%s", configStruct[dbAlias].DbHost, configStruct[dbAlias].DbPort, configStruct[dbAlias].DbUser, configStruct[dbAlias].DbName, configStruct[dbAlias].DbPass) if err != nil { return nil, err } pool[dbAlias] = db return db, nil }
На небольшом количестве зависимостей и простых связях между ними
приведенные выше паттерны работают отлично и ничего более не требуется.
Однако, при значетельном усложнении системы,
при наличии десятков типов сущностей,
имеет смысл централизации кода управления зависимостями.
Проблема менеджмента зависимостей с помощью отдельной сущности контейнера или injector
применяется во многих языках программирования.
Контейнер берет на себя функции создания и получения сущностей,
это помогает описывать код создания каждой сущности единожды.
Также контейнер решает проблему хранения сущности и использует multiton подход.
Суть работы с контейнером сводится к созданию всех сущностей силами контейнера, и использование контейнера затем в программе для получения всех сущностей.
При использовании контейнера, зачастую программирование перетекает в конфигурирование - вместо написания кода мы можем описать XML или YAML документ со структурой зависимостей.
В Symfony (PHP) service container является центральным компонентом фреймворка -
сам Symfony использует контейнер для инстанцирования и получения ключевых сущностей.
Symfony поддерживает конфигурацию контейнера как через декларативные XML, YAML,
так и через программирование структуры зависимостей
через код PHP.
В Spring (JAVA) контейнер зависимостей можно сконфигурировать с помощью XML, а также аннотаций.
В GO существует несколько библиотек, реализующих абсолютно по-разному функционал контейнера зависимостей.
Я изучил большинство наиболее популярных из них, и ниже опишу опыт применения. Все исходные коды от использования библиотек в примерах доступны также в отдельном репозитории github.
dig позволяет сконфигурировать контейнер с помощью анонимных функций на GO
и использует reflect
.
Для добавлении используем метод Provide
.
Передаем ананонимную функцию, которая должна вернуть только сущность,
либо же сущность и ошибку.
c := dig.New() err := c.Provide(func() (*Config, error) { // В реальной программе содержимое конфига будет браться из файла. var cfg Config err := json.Unmarshal([]byte('{"prefix": "[foo] "}''), &cfg) return &cfg, err }) if err != nil { panic(err) } // Функция создания логгера с зависимостью от конфига err = c.Provide(func(cfg *Config) *log.Logger { return log.New(os.Stdout, cfg.Prefix, 0) }) if err != nil { panic(err) }
Далее через пакет reflect
dig анализирует тип параметров и возвращаемых значений
Provide
.
По типам параметров определяются необходимые зависимости.
Эти типы и являются идентификаторами сущности, благодаря им dig
выстраивает связи между сущностями.
Для получения сущности используется метод Invoke
:
err = c.Invoke(func(l *log.Logger) { l.Print("You've been invoked") }) if err != nil { panic(err) }
Поскольку dig
идентифицирует сущность по типу ее значения,
а также по типам значений ее зависимостей, то создание второго идентичного логгера вернет ошибку.
Для решения этой проблемы достаточно указать опцию name
при вызове Provide
:
// создаем еще один логгер err = c.Provide( func(cfg *Config) *log.Logger { return log.New(os.Stdout, cfg.Prefix, 0) }, dig.Name("logger2"), // передаем опцию имени ) if err != nil { panic(err) }
Однако получение именованной сущности будет не столь легким — в функцию Invoke
нельзя передать имя желаемой сущности в контейнере.
В соответствующем issue разработчики уверяют, что данный функционал реализован, однако релиз с ним не выпущен,
так что на данный момент для получения именнованных сущностей необходимо использовать структуру,
в полях которой тегами name
указать имена сущностей:
c := dig.New() c.Provide(username, dig.Name("username")) c.Provide(password, dig.Name("password")) err := c.Invoke(func(p struct { dig.In U string `name:"username"` P string `name:"password"` }) { fmt.Println("user >>>", p.U) fmt.Println("pwd >>>", p.P) })
dig
(впрочем, как и все остальные библиотеки здесь)
использует "ленивую" загрузку — сущность и все ее зависимости
будут проинициализированы только при вызове Invoke
.
Можно было бы придраться к использованию reflect
,
так как такой код работает достаточно долго. Но, вся логика работы с
контейнером будет работать при инициализации сервиса и всего 1 раз -
при старте программы.
Итог: ситуацию с получением именованных параметров
хорошо бы задокументировать в основном readme библиотеки.
В остальном dig
— отличный контейнер зависимостей.
elliotchance/dingo работает совершенно иначе.
Нам предлагается в формате YAML описать зависимости, после чего сгенерировать
файл контейнера.
Продолжим в качестве примера использовать связку из логгера с конфигом.
YAML для нашего примера будет выглядеть так:
services: Config: type: '*Config' error: return nil returns: NewConfig() Logger: type: '*log.Logger' import: — 'os' returns: log.New(os.Stdout, @{Config}.Prefix, 0)
Мне использование YAML как единственного источника конфигурации в данном случае неудобно, так как в этом YAML придется указывать фрагменты GO-кода. Однако GO-код удобнее видеть в файлах *.go, чтобы редактор подсветил в них ошибки.
Для каждой сущности придется указать следующие параметры:
imports
— импорты, которые надо добавить в сгенерированный GO файл;error
— фрагмент GO-кода, который надо выполнить при проверке ошибки;returns
— фрагмент GO-кода, который инициализирует и вернет нашу сущность;С returns
у меня возникла неопределенность — писать ли мне в YAML
код конструктора, или же вынести эту логику в функцию, так как библиотека
разрешает писать в YAML любой GO-код.
В итоге я вынес логику по создания конфига в функцию NewConfig
:
func NewConfig() (*Config, error) { var cfg Config err := json.Unmarshal([]byte(`{"prefix": "[foo] "}`), &cfg) return &cfg, err }
Для генерации необходимо запустить go get -u github.com/elliotchance/dingo; dingo
в
папке проекта.
Генерация работает очень быстро. Большинство настроек в YAML напрямую транслируются в
сгенерированный GO-код, поэтому полученный GO-код может быть невалиден.
Сгенерированный GO-код помещается в файл dingo.go
.
Контейнер представляет собой структуру с полями для каждой сущности и singleton-логику
для их получения:
type Container struct { Config *Config Logger *log.Logger } func (container *Container) GetLogger() *log.Logger { if container.Logger == nil { service := log.New(os.Stdout, container.GetConfig().Prefix, 0) container.Logger = service } return container.Logger }
Вывод: elliotchance/dingo
позволяет быстро описать и сгенерировать типизированный контейнер,
но у меня постоянно возникает неопределенность с тем, в каком виде мой код лучше подойдет для YAML.
sarulabs/di
имеет сходства с dig
, однако не использует reflect
.
Все зависимости в di
должны иметь уникальные имена.
С dig
не было необходимости
писать код инициализации зависимостей для вашей сущности,
они приходили уже инициализированными в аргументы функции.
С di
необходимо получать зависимости из контейнера:
err = builder.Add(di.Def{ Name: "logger", Build: func(ctn di.Container) (interface{}, error) { // Получаем конфиг из контейнера для инициализации логгера var cfg *Config err = ctn.Fill("config", &cfg) if err != nil { return nil, err } // Инициализируем сам логгер return log.New(os.Stdout, cfg.Prefix, 0), nil } })
Кода по получению зависимостей из контейнера вряд ли будет больше, чем оригинального кода по их инициализации. Тем не менее, получение зависимостей из конфига — повторяющийся код, если наша зависимость требуется в нескольких местах. При изменении связей сущностей, придется менять этот код в нескольких местах.
много кода по созданию контейнера через sarulabs/dibuilder, err := di.NewBuilder() if err != nil { panic(err) } err = builder.Add(di.Def{ Name: "config", Build: func(ctn di.Container) (interface{}, error) { var cfg Config err := json.Unmarshal([]byte(`{"prefix": "[foo] "}`), &cfg) return &cfg, err }, }) if err != nil { panic(err) } err = builder.Add(di.Def{ Name: "logger", Build: func(ctn di.Container) (interface{}, error) { var cfg *Config err = ctn.Fill("config", &cfg) return log.New(os.Stdout, cfg.Prefix, 0), nil }, Close: func(obj interface{}) error { if _, ok := obj.(*log.Logger); ok { fmt.Println("logger close") } return nil }, }) if err != nil { panic(err) }
Однако sarulabs/di
имеет бонус — можно указать не только функцию создания сущности,
но и функцию, вызываемую перед уничтожением контейнера.
Уничтожение контейнера вызывается методом DeleteWithSubContainers
и может быть привязано к завершению программы.
Close: func(obj interface{}) error { if _, ok := obj.(*log.Logger); ok { fmt.Println("logger close") // этот код вызовется при уничтожении контейнера } return nil }
Так как di
не использует reflect
и не хранит информацию
о типах сущностей, в методе Close
нам придется использовать type assertion для приведения
сущности к ее истинному типу.
Существует библиотека-надстройка sarulabs/dingo,
(которая также может использоваться независимо от di
),
реализующая кодогонерацию жестко типизированного контейнера.
Итог: di
решает задачи injector и предоставляет бонусный функционал.
Однако, принцип работы
с di
требует дублирование кода получения зависимостей из контейнера,
в результате чего кода получается немного больше, чем при работе с dig
.
C wire
мы должны написать шаблон функции-конструктора каждой сущности с
использованием вызова wire.Build
. В начало файлов с такими шаблонами
необходимо добавить комментарий //+build wireinject
.
Далее запуск консольной команды go get github.com/google/wire/cmd/wire; wire
сгенерирует файл *_gen.go
рядом с каждым файлом-шаблоном.
Сгенерированный код будет содержать функцию-конструктор, готовую к использованию.
Шаблон конструктора нашего логгера с конфигом будет выглядеть так:
//+build wireinject package main import ( "log" "github.com/google/wire" ) // Шаблон для генерации func GetLogger() (*log.Logger, error) { panic(wire.Build(NewLogger, NewConfig)) }
Сгенерированный код добавляется из файла *_gen.go
выглядит так:
import ( "log" ) // Injectors from wire.go: func GetLogger() (*log.Logger, error) { config, err := NewConfig() if err != nil { return nil, err } logger := NewLogger(config) return logger, nil }
Здесь, как в elliotchance/dingo
присутствует генерация GO-кода. Однако мне не удалось
сгенерировать невалидный GO-код. В случае проблем с моим шаблоном,
команда wire
выводит ошибки и файл не появляется.
У wire
существует недостаток — из-за
необходимости описывать логику поведения конструктора
через вызовы пакета wire
, намного менее выразительные,
чем код на GO, любой код инстанцирования необходимо выносить в отдельную функцию.
Но есть и плюс — так как весь код в функциях-конструкторах,
дублирование его в шаблонах wire
будет только в повторном написании
имен конструкторов.
Ниже можно видеть сводную таблицу по всем рассмотренным библиотекам:
reflect
.Build
и получением
зависимостей из контейнера.interface{}
.
Однако, sarulabs/dingo
поможет сгенерировать типизированный контейнер.