Главная » Хабрахабр » [Перевод] Внедрение зависимостей в Go

[Перевод] Внедрение зависимостей в Go

Недавно я создал небольшой проект на языке 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
}

Выглядит удобно. Вызывающему оператору не обязательно знать о том, что Server требует доступ к Config. Подобная информация скрыта от пользователя функции.

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

Чтобы тестировать создание Config с использованием произвольных данных (monkey-тестирование), нам придётся каким-то образом забраться в недра функции New. Кроме того, в такой ситуации будет трудно смоделировать структуру Config для её тестирования без учёта зависимостей.

А вот как решить эту задачу с помощью DI:

type Server struct { config *Config
} func New(config *Config) *Server { return &Server{ config: config, }
}

Теперь структуры Server и Config создаются отдельно друг от друга. Мы можем использовать любую подходящую логику для создания Config, а затем передать полученные данные в функцию New.

Любой аргумент, который позволяет реализовать наш интерфейс, мы можем передать в функцию New. Кроме того, если Config является интерфейсом, то мы сможем с легкостью провести для него mock-тестирование. Это упрощает тестирование структуры Server с помощью mock-объектов Config.

Это очень неудобно. Основной недостаток этого подхода связан с необходимостью вручную создавать структуру Config, прежде чем мы сможем создать Server. В реальных приложениях такие графы могут слишком сильно разрастаться, что усложняет логику создания всех компонентов, необходимых для правильной работы приложения. Здесь у нас появляется граф зависимостей: сначала нужно создавать структуру Config, потому что Server зависит от неё.

Ситуацию может исправить DI за счёт следующих двух механизмов:

  1. Механизм «предоставления» новых компонентов. Если коротко, он сообщает фреймворку DI, какие компоненты вам необходимы для создания объекта (ваши зависимости), а также как создать этот объект после получения всех нужных компонентов.
  2. Механизм «извлечения» созданных компонентов.

Фреймворк 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. И всё.

Как и у Person, у этой структуры нет никаких зависимостей. А теперь посмотрим на Config. Однако, в отличие от 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. Для подключения к базе данных мы будем использовать следующую функцию. DB.

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, если приложение включено.

Она отвечает за выполнение HTTP-сервера и передачу соответствующих запросов в PersonService. И наконец, структура Server.

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. Первая используется для добавления поставщиков, вторая — для извлечения полностью готовых объектов из контейнера.

Сначала создадим новый контейнер.

New() container := dig.

Для этого вызовем функцию контейнера 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 и чаще использовать этот механизм в приложениях.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Частичка программы HolyJS 2018 Moscow

Конференция состоится 24–25 ноября. HolyJS 2018 Moscow уже совсем скоро. В этот раз программа получилась весьма разнообразной, однако несложно выделить главные тенденции: Доклады из первых рук (#firsthand) — доклады о инструментах/решениях от их авторов. Мы особенно тщательно подошли к выбору ...

[Перевод] GPU консоли Nintendo DS и его интересные особенности

Я хотел бы рассказать вам о работе GPU консоли Nintendo DS, об его отличиях от современных GPU, а также выразить своё мнение о том, почему использование Vulkan вместо OpenGL в эмуляторах не принесёт никаких преимуществ. Это может пригодиться для эмуляции ...