Недавно я создал небольшой проект на языке Go. После нескольких лет работы с Java я был сильно удивлён тем, как вяло внедрение зависимостей (Dependency Injection, DI) применяется в экосистеме Go. Для своего проекта я решил использовать библиотеку dig от компании Uber, и она меня по-настоящему впечатлила.
Я обнаружил, что внедрение зависимостей позволяет решить множество проблем, с которыми я сталкивался в работе над Go-приложениями: злоупотребление функцией
init
и глобальными переменными, чрезмерная сложность настройки приложений и др.В этой статье я расскажу об основах внедрения зависимостей, а также покажу пример приложения до и после применения этого механизма (посредством библиотеки
dig
).Краткий обзор механизма внедрения зависимостей
Механизм DI предполагает, что зависимости предоставляются компонентам (
struct
в Go) при их создании извне. Это противопоставляется антипаттерну компонентов, которые сами формируют свои зависимости при инициализации. Давайте обратимся к примеру.Предположим, у вас есть структура
Server
, которая требует Config
для реализации своего поведения. Как один из вариантов, Server может создать собственную структуру Config во время инициализации.type Server struct {
config *Config
}
func New() *Server {
return &Server{
config: buildMyConfigSomehow(),
}
}
Выглядит удобно. Вызывающему оператору не обязательно знать о том, что
Server
требует доступ к Config
. Подобная информация скрыта от пользователя функции.Однако здесь есть и недостатки. Прежде всего, если мы решим изменить функцию создания
Config
, то вместе с ней придётся менять все те места, которые вызывают соответствующий код. Предположим, что функция buildMyConfigSomehow
теперь запрашивает аргумент. А значит, доступ к этому аргументу теперь нужен при любом вызове этой функции.Кроме того, в такой ситуации будет трудно смоделировать структуру
Config
для её тестирования без учёта зависимостей. Чтобы тестировать создание Config
с использованием произвольных данных (monkey-тестирование), нам придётся каким-то образом забраться в недра функции New
.А вот как решить эту задачу с помощью DI:
type Server struct {
config *Config
}
func New(config *Config) *Server {
return &Server{
config: config,
}
}
Теперь структуры
Server
и Config
создаются отдельно друг от друга. Мы можем использовать любую подходящую логику для создания Config
, а затем передать полученные данные в функцию New
.Кроме того, если
Config
является интерфейсом, то мы сможем с легкостью провести для него mock-тестирование. Любой аргумент, который позволяет реализовать наш интерфейс, мы можем передать в функцию New
. Это упрощает тестирование структуры Server
с помощью mock-объектов Config
.Основной недостаток этого подхода связан с необходимостью вручную создавать структуру
Config
, прежде чем мы сможем создать Server
. Это очень неудобно. Здесь у нас появляется граф зависимостей: сначала нужно создавать структуру Config
, потому что Server
зависит от неё. В реальных приложениях такие графы могут слишком сильно разрастаться, что усложняет логику создания всех компонентов, необходимых для правильной работы приложения.Ситуацию может исправить DI за счёт следующих двух механизмов:
- Механизм «предоставления» новых компонентов. Если коротко, он сообщает фреймворку DI, какие компоненты вам необходимы для создания объекта (ваши зависимости), а также как создать этот объект после получения всех нужных компонентов.
- Механизм «извлечения» созданных компонентов.
Фреймворк DI строит граф зависимостей на основе «поставщиков», о которых вы ему сообщаете, а затем определяет способ создания ваших объектов. Это сложно объяснить теоретически, поэтому давайте рассмотрим один относительно небольшой практический пример.
Пример приложения
В качестве примера давайте использовать код HTTP-сервера, который возвращает ответ в формате JSON, когда клиент делает запрос
GET
к /people
. Мы будем рассматривать его по частям. Чтобы упростить этот пример, весь наш код будет находиться в одном пакете (main
). В реальных приложениях Go так делать не следует. Полную версию кода из этого примера вы можете найти здесь.Для начала обратимся к структуре
Person
. В ней не реализовано никакого поведения, только объявлены несколько тегов JSON.type Person struct {
Id int `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
}
В структуре
Person
есть теги Id
, Name
и Age
. И всё.А теперь посмотрим на
Config
. Как и у Person
, у этой структуры нет никаких зависимостей. Однако, в отличие от Person
, у неё есть конструктор.type Config struct {
Enabled bool
DatabasePath string
Port string
}
func NewConfig() *Config {
return &Config{
Enabled: true,
DatabasePath: "./example.db",
Port: "8000",
}
}
Поле
Enabled
определяет, будет ли наше приложение возвращать реальные данные. Поле DatabasePath
указывает путь к базе данных (мы используем SQlite). Поле Port
задаёт порт, на котором будет выполняться наш сервер.Для подключения к базе данных мы будем использовать следующую функцию. Она работает с
Config
и возвращает *sql.D
B.func ConnectDatabase(config *Config) (*sql.DB, error) {
return sql.Open("sqlite3", config.DatabasePath)
}
Теперь посмотрим на структуру
PersonRepository
. Она будет отвечать за извлечение информации о людях из нашей базы данных и её десериализацию по соответствующим структурам Person
.type PersonRepository struct {
database *sql.DB
}
func (repository *PersonRepository) FindAll() []*Person {
rows, _ := repository.database.Query(
`SELECT id, name, age FROM people;`
)
defer rows.Close()
people := []*Person{}
for rows.Next() {
var (
id int
name string
age int
)
rows.Scan(&id, &name, &age)
people = append(people, &Person{
Id: id,
Name: name,
Age: age,
})
}
return people
}
func NewPersonRepository(database *sql.DB) *PersonRepository {
return &PersonRepository{database: database}
}
Структура
PersonRepository
требует подключения к базе данных. Она предоставляет лишь одну функцию — FindAll
, которая использует это подключение для возвращения списка структур Person
, соотносящихся с информацией в базе данных.Нам понадобится структура
PersonService
, чтобы создать слой между HTTP-сервером и PersonRepository
.type PersonService struct {
config *Config
repository *PersonRepository
}
func (service *PersonService) FindAll() []*Person {
if service.config.Enabled {
return service.repository.FindAll()
}
return []*Person{}
}
func NewPersonService(config *Config, repository *PersonRepository) *PersonService {
return &PersonService{config: config, repository: repository}
}
PersonService
зависит не только от Config
, но и от PersonRepository
. Она содержит функцию FindAll
, которая условно вызывает PersonRepository
, если приложение включено.И наконец, структура
Server
. Она отвечает за выполнение HTTP-сервера и передачу соответствующих запросов в PersonService
.type Server struct {
config *Config
personService *PersonService
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/people", s.people)
return mux
}
func (s *Server) Run() {
httpServer := &http.Server{
Addr: ":" + s.config.Port,
Handler: s.Handler(),
}
httpServer.ListenAndServe()
}
func (s *Server) people(w http.ResponseWriter, r *http.Request) {
people := s.personService.FindAll()
bytes, _ := json.Marshal(people)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(bytes)
}
func NewServer(config *Config, service *PersonService) *Server {
return &Server{
config: config,
personService: service,
}
}
Server
зависит от структур PersonService
и Config
.Итак, нам известны все компоненты. Так как же их теперь инициализировать и запустить нашу систему?
Великий и ужасный main()
Для начала давайте напишем функцию
main()
традиционным образом.func main() {
config := NewConfig()
db, err := ConnectDatabase(config)
if err != nil {
panic(err)
}
personRepository := NewPersonRepository(db)
personService := NewPersonService(config, personRepository)
server := NewServer(config, personService)
server.Run()
}
Сначала мы задаём структуру
Config
. Затем с её помощью создаём подключение к базе данных. После этого можно создать структуру PersonRepository
, а на её основе — структуру PersonService
. Наконец, мы используем всё это для создания и запуска Server
.Довольно сложный процесс. А что ещё хуже — по мере усложнения нашего приложения функция
main
будет становиться всё запутаннее. Каждый раз при добавлении зависимости к какому-либо из наших компонентов нам придётся дописывать логику и заново пересматривать функцию main
.Как вы могли догадаться, решить эту проблему можно с помощью механизма внедрения зависимостей. Давайте выясним, как этого добиться.
Создание контейнера
В рамках фреймворка DI «контейнеры» — это то место, куда вы добавляете «поставщиков» и откуда запрашиваете полностью готовые объекты. Библиотека
dig
предоставляет нам функции Provide
и Invoke
. Первая используется для добавления поставщиков, вторая — для извлечения полностью готовых объектов из контейнера.Сначала создадим новый контейнер.
container := dig.New()
Теперь мы можем добавить поставщиков. Для этого вызовем функцию контейнера
Provide
. У неё один аргумент: функция, которая может иметь любое количество аргументов (они отражают зависимости создаваемого компонента), а также одно или два возвращаемых значения (предоставляемый функцией компонент и при необходимости ошибка). container.Provide(func() *Config {
return NewConfig()
})
Этот код сообщает: «Я предоставляю контейнеру тип
Config
. Для его создания мне больше ничего не требуется». Теперь, когда наш контейнер знает, как создавать тип Config
, мы можем использовать его для создания других типов.container.Provide(func(config *Config) (*sql.DB, error) {
return ConnectDatabase(config)
})
Код сообщает: «Я предоставляю контейнеру тип
*sql.DB
. Для его создания мне необходим Config
. Кроме того, при необходимости я могу вернуть ошибку».В обоих случаях мы чересчур многословны. Так как у нас есть уже определённые функции
NewConfig
и ConnectDatabase
, мы можем напрямую использовать их в качестве поставщиков для контейнера.container.Provide(NewConfig)
container.Provide(ConnectDatabase)
Теперь можно попросить контейнер предоставить нам полностью готовый компонент любого из предложенных типов. Для этого мы используем функцию
Invoke
. Аргументом функции Invoke
служит функция с любым количеством аргументов. В их качестве выступают типы, создать которые мы и просим наш контейнер.container.Invoke(func(database *sql.DB) {
})
Контейнер выполняет действительно небанальные действия. Вот что происходит:
- контейнер определяет, что нам нужен тип
*sql.DB
; - он выясняет, что данный тип предоставляет функция
ConnectDatabase
; - затем он определяет, что функция
ConnectDatabase
зависит от типа Config; - контейнер находит поставщика типа
Config
— функциюNewConfig
; - у
NewConfig
нет никаких зависимостей, поэтому эту функцию можно вызвать; - полученный в результате работы функции
NewConfig
типConfig
передаётся в функциюConnectDatabase
; - результат работы функции
ConnectionDatabase
, тип*sql.DB
, возвращается к вызвавшему функциюInvoke
.
Контейнер выполняет для нас целую гору работы, а на самом деле даже делает ещё больше. Он достаточно умён, чтобы создавать только один экземпляр каждого предоставленного типа. А это означает, что мы никогда случайно не создадим лишнее подключение к базе данных, если используем его в нескольких местах (например, в нескольких репозиториях).
Улучшенная версия main()
Теперь, когда мы знаем, как работает контейнер
dig
, давайте использовать его, чтобы оптимизировать функцию main.func BuildContainer() *dig.Container {
container := dig.New()
container.Provide(NewConfig)
container.Provide(ConnectDatabase)
container.Provide(NewPersonRepository)
container.Provide(NewPersonService)
container.Provide(NewServer)
return container
}
func main() {
container := BuildContainer()
err := container.Invoke(func(server *Server) {
server.Run()
})
if err != nil {
panic(err)
}
}
Единственная вещь, с которой мы ещё не сталкивались, — возвращаемое функцией
Invoke
значение error
. Если какой-либо из поставщиков, используемых функцией Invoke
, возвращает ошибку, выполнение функции будет приостановлено, и эта ошибка будет возвращена вызывающему оператору.Несмотря на небольшие размеры указанного примера, легко отметить преимущества этого подхода по сравнению со стандартным
main
. Чем больше становится наше приложение, тем очевиднее будут и эти преимущества.Один из самых важных положительных моментов — разделение процессов создания компонентов и их зависимостей. Предположим, что нашему
PersonRepository
теперь необходим доступ к Config
. Всё, что нам нужно сделать, — это добавить Config
в качестве аргумента в конструктор NewPersonRepository
. Никаких дополнительных изменений в коде не потребуется.В числе других важных преимуществ можно назвать снижение числа используемых глобальных переменных и объектов, а также вызовов функции init (зависимости создаются лишь однажды, когда это необходимо, поэтому больше не нужно использовать подверженные ошибкам механизмы
init
). Кроме того, этот подход позволяет упростить тестирование отдельных компонентов. Представьте, что при тестировании вы создаёте контейнер и запрашиваете полностью готовый объект. Или что вам нужно создать объект с фиктивными реализациями всех его зависимостей (mock-объект). Всё это гораздо проще сделать с помощью механизма внедрения зависимостей.Идея, достойная распространения
Я уверен, что механизм внедрения зависимостей позволяет создавать более надёжные приложения, которые к тому же проще тестировать. Чем больше приложение, тем сильнее это проявляется. Язык Go отлично подходит для создания больших приложений, а библиотека
dig
— прекрасный инструмент для внедрения зависимостей. Думаю, что сообществу программистов на Go следует обратить больше внимания на DI и чаще использовать этот механизм в приложениях.