Недавно я создал небольшой проект на языке 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(),
}
}
Однако здесь есть и недостатки. Прежде всего, если мы решим изменить функцию создания Config, то вместе с ней придётся менять все те места, которые вызывают соответствующий код. Предположим, что функция buildMyConfigSomehow теперь запрашивает аргумент. А значит, доступ к этому аргументу теперь нужен при любом вызове этой функции.
Кроме того, в такой ситуации будет трудно смоделировать структуру Config для её тестирования без учёта зависимостей. Чтобы тестировать создание Configс использованием произвольных данных (monkey-тестирование), нам придётся каким-то образом забраться в недра функции New.
А вот как решить эту задачу с помощью DI:
Код:
type Server struct {
config *Config
}
func New(config *Config) *Server {
return &Server{
config: config,
}
}
Кроме того, если Config является интерфейсом, то мы сможем с легкостью провести для него mock-тестирование. Любой аргумент, который позволяет реализовать наш интерфейс, мы можем передать в функцию New. Это упрощает тестирование структуры Server с помощью mock-объектов Config.
Основной недостаток этого подхода связан с необходимостью вручную создавать структуру Config, прежде чем мы сможем создать Server. Это очень неудобно. Здесь у нас появляется граф зависимостей: сначала нужно создавать структуру Config, потому что Server зависит от неё. В реальных приложениях такие графы могут слишком сильно разрастаться, что усложняет логику создания всех компонентов, необходимых для правильной работы приложения.
Ситуацию может исправить 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"`
}
А теперь посмотрим на Config. Как и у Person, у этой структуры нет никаких зависимостей. Однако, в отличие от Person, у неё есть конструктор.
Код:
type Config struct {
Enabled bool
DatabasePath string
Port string
}
Код:
func NewConfig() *Config {
return &Config{
Enabled: true,
DatabasePath: "./example.db",
Port: "8000",
}
}
Для подключения к базе данных мы будем использовать следующую функцию. Она работает с 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}
}
Нам понадобится структура 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}
}
И наконец, структура 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,
}
}
Итак, нам известны все компоненты. Так как же их теперь инициализировать и запустить нашу систему?
Великий и ужасный main()
Для начала давайте напишем функцию main() традиционным образом.
Код:
func main() {
config := NewConfig()
db, err := ConnectDatabase(config)
if err != nil {
panic(err)
}
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)
}
}
Несмотря на небольшие размеры указанного примера, легко отметить преимущества этого подхода по сравнению со стандартным main. Чем больше становится наше приложение, тем очевиднее будут и эти преимущества.
Один из самых важных положительных моментов — разделение процессов создания компонентов и их зависимостей. Предположим, что нашему PersonRepository теперь необходим доступ к Config. Всё, что нам нужно сделать, — это добавить Config в качестве аргумента в конструктор NewPersonRepository. Никаких дополнительных изменений в коде не потребуется.
В числе других важных преимуществ можно назвать снижение числа используемых глобальных переменных и объектов, а также вызовов функции init (зависимости создаются лишь однажды, когда это необходимо, поэтому больше не нужно использовать подверженные ошибкам механизмы init). Кроме того, этот подход позволяет упростить тестирование отдельных компонентов. Представьте, что при тестировании вы создаёте контейнер и запрашиваете полностью готовый объект. Или что вам нужно создать объект с фиктивными реализациями всех его зависимостей (mock-объект). Всё это гораздо проще сделать с помощью механизма внедрения зависимостей.
Идея, достойная распространения
Я уверен, что механизм внедрения зависимостей позволяет создавать более надёжные приложения, которые к тому же проще тестировать. Чем больше приложение, тем сильнее это проявляется. Язык Go отлично подходит для создания больших приложений, а библиотека dig — прекрасный инструмент для внедрения зависимостей. Думаю, что сообществу программистов на Go следует обратить больше внимания на DI и чаще использовать этот механизм в приложениях.
Копипаст.