Dependency injection в GO

21.04.2020 dependency injection контейнер singleton multiton


Dependency injection в GO

Поговорим о том, что такое зависимости в коде программы — что это такое, что бывает когда в большой программе разрастается код инициализации и как можно с этим бороться.

Пример с логгером

В любой программе в 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 у нас нет нужды иметь более одного подключение к БД из двух наших сервисов.

Singleton

В случае с 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
}

Пул одиночек (multiton)

Также у нас могут быть подключения к разным серверам баз данных, но каждого из подключений должно быть по одному. Мы хотим переиспользовать наше решение с singleton — реализуем пул одиночек (muliton).



package 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)

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

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

При использовании контейнера, зачастую программирование перетекает в конфигурирование - вместо написания кода мы можем описать XML или YAML документ со структурой зависимостей.

В Symfony (PHP) service container является центральным компонентом фреймворка - сам Symfony использует контейнер для инстанцирования и получения ключевых сущностей.
Symfony поддерживает конфигурацию контейнера как через декларативные XML, YAML, так и через программирование структуры зависимостей через код PHP.

В Spring (JAVA) контейнер зависимостей можно сконфигурировать с помощью XML, а также аннотаций.

В GO существует несколько библиотек, реализующих абсолютно по-разному функционал контейнера зависимостей.

Я изучил большинство наиболее популярных из них, и ниже опишу опыт применения. Все исходные коды от использования библиотек в примерах доступны также в отдельном репозитории github.

uber-go/dig

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

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

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
	}
})

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



builder, 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.

google/wire

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 будет только в повторном написании имен конструкторов.

Ниже можно видеть сводную таблицу по всем рассмотренным библиотекам:

uber-go/dig

  • Формат представления зависимостей: GO-код, анонимные функции с аргументами-зависимостями.
  • Генерация GO-кода: Нет.
  • Типизация: Строгая, но также используется reflect.
  • Минимизация кода: Максимальная.

elliotchance/dingo

  • Формат представления зависимостей: YAML.
  • Генерация GO-кода: Да.
  • Типизация: Строгая.
  • Минимизация кода: Максимальная, но GO-код может быть в YAML.

sarulabs/di

  • Формат представления зависимостей: GO-код, описание структур с функциями Build и получением зависимостей из контейнера.
  • Генерация GO-кода: Нет, только при дополнительном использовании sarulabs/dingo.
  • Типизация: Все значения хранятся как interface{}. Однако, sarulabs/dingo поможет сгенерировать типизированный контейнер.
  • Минимизация кода: Получение зависимостей из контейнера — повторяющийся код.

google/wire

  • Формат представления зависимостей: GO-код — шаблоны функций конструкторов.
  • Генерация GO-кода: Да.
  • Типизация: Строгая.
  • Минимизация кода: Максимальная.