Фреймворк Google Wire: автоматическое внедрение зависимостей в Go

В статье мы обсудим один вопрос, который считается фундаментальным принципом разработки ПО и программирования в целом: внедрение зависимостей (англ. Dependency Injection, сокр. DI). Всегда полезно освежить в памяти первоосновы, поскольку они уже апробированы, проверены на практике и утверждены в качестве стандартов индустрии.

Данный материал раскрывает тему DI, акцентируя внимание на реализации этой концепции в Go. По итогам ее изучения вы получите полное понимание целей и принципов работы DI. Кроме того, вы узнаете, как именно фреймворк Wire помогает экономить время при настройке зависимостей. 

Что такое внедрение зависимостей?

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

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

В такой ситуации особую актуальность приобретает DI. Это широко применяемый паттерн проектирования ПО, обеспечивающий слабую связанность и гибкость. Вместо того, чтобы обязывать объекты или методы создавать необходимые им зависимости, эти зависимости внедряются через конструкторы или параметры. 

Такой подход повышает удобство сопровождения, улучшает тестируемость кода и упрощает процесс управления зависимостями по мере роста системы ПО. 

В Go можно работать с инструментом Wire, который еще больше облегчает процесс настройки зависимостей. 

type Logger interface{}

type Repository interface{}

type Service struct{
logger Logger
repository Repository
}

func NewService(logger Logger, repository Repository) *Service {
return &Service{
logger,
repository,
}
}

Данный пример иллюстрирует наиболее распространенный пример DI: внедрение через конструктор (англ. Constructor Injection). Структура Service имеет две зависимости: Logger и Repository. Для создания нового экземпляра Service мы передаем обе зависимости в ее конструктор. 

Обратите внимание на одну важную деталь: внедряются интерфейсы. Обходясь без внедрения конкретной реализации зависимостей, мы повышаем модульность и гибкость кода. Поскольку интерфейс является абстракцией, реализация легко меняется на имитированную или абсолютно новую реализацию без нарушения контракта. 

Использование интерфейсов приобретает важное значение в связи с тестированием. Такой подход позволяет сосредоточиться на основном тестируемом поведении и управлении зависимостями в соответствии с обозначенными намерениями. 

Один из простых способов реализации DI состоит в ручном внедрении зависимостей. Принцип работы “чистый”, понятный и без наворотов: 

func (s *Service) Marco() string {
return "Polo!"
}

func main() {
logger := MyLogger{}
repository := MyRepository{}
s := NewService(logger, repository)
fmt.Println(s.Marco())
}

Теперь представим, что вместо одного Service с двумя зависимостями у нас их 100, и у каждого из них произвольное число зависимостей. Было бы непросто управлять ими вручную. 

В таких ситуациях выручают фреймворки DI. Они предоставляют способ определения и настройки зависимостей. Как правило, фреймворки DI либо работают с возможностью рефлексии и обеспечивают внедрение зависимостей во время выполнения (например, Dig от Uber), либо генерируют код для внедрения зависимостей во время компиляции (например, Wire от Google). 

Жизнь состоит из компромиссов, и ситуация с выбором фреймворка DI не является исключением. Следует учитывать все преимущества и недостатки, не забывая про конечные цели: упрощение процесса управления зависимостями, улучшение организации кода и повышение удобства его сопровождения. 

В следующем разделе познакомимся с Google Wire, фреймворком для внедрения зависимостей во время компиляции. Его разработчики утверждают, что лучше проводить DI в процессе компиляции, чем во время выполнения. Они приводят следующие аргументы: 

  • процесс сопровождения и отладки DI во время выполнения усложняется по мере разрастания графа зависимостей;  
  • забывание зависимости становится ошибкой этапа компиляции, а не выполнения; 
  • упрощается задача по предотвращению разрастания зависимостей; 
  • статический граф зависимостей, обеспечивающий возможности для применения инструментов разработки и визуализации. 

Google Wire

Google Wire  —  фреймворк DI для Go, который генерирует код для внедрения зависимостей во время компиляции. Это значит, что создаваемый код является статическим и проверяется перед выполнением. Такой принцип обеспечивает наглядность и уменьшает вероятность ошибок. 

Wire задействует провайдеры для создания и предоставления экземпляров зависимостей, которые внедряются в другие компоненты по мере необходимости. Провайдерами выступают функции, возвращающие экземпляр зависимости. 

Wire также задействует инжекторы для соединения различных компонентов в графе зависимостей. Инжектор  —  это функция, которая принимает на вход зависимости, требуемые компонентом, и возвращает полностью созданный экземпляр этого компонента. Определяя провайдеры для каждой зависимости и инжекторы для каждого компонента, Wire способен генерировать код, который автоматически связывает все зависимости в приложении. Действуя таким образом, он упрощает управление даже сложными графами зависимостей. 

Посмотрим этот фреймворк в действии на примере простого кода, представленного выше. Сначала создаем новый файл wire.go и определяем провайдер: 

//go:build wireinject

package main

import "github.com/google/wire"
func InitializeService() Service {
wire.Build(NewService, NewMyLogger, NewMyRepository)
return Service{}
}

Теперь запускаем генерацию кода Wire для создания нового файла wire_gen.go. Обратите внимание на сходство между предыдущим кодом инициализации и сгенерированным кодом: 

// Код, сгенерированный Wire. Не редактировать.

//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject

package main

// Инжекторы от wire.go:

func InitializeService() Service {
logger := NewMyLogger()
repository := NewMyRepository()
service := NewService(logger, repository)
return service
}

В завершении работы обновляем файл main и применяем новый провайдер: 

func main() {
s := InitializeService()
fmt.Println(s.Marco())
}

Мы привели простой пример использования Wire, но несложно угадать степень его эффективности в работе с большой базой кода. Помимо рассмотренных возможностей Wire предоставляет ряд других мощных функциональностей, способных еще больше упростить процесс внедрения зависимостей в проекты Go. Перечислим некоторые из них: поддержка именованных экземпляров, функции очистки и привязка интерфейса. 

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

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

Освоение и применение DI в коде играет важную роль в вопросах качества и сопровождения ПО. Независимо от того, используем ли мы фреймворк или нет, данный паттерн способствует созданию более чистого модульного кода, который впоследствии легко тестировать и обслуживать.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Nelson Parente: Google’s Wire: Automated Dependency Injection in Go

Предыдущая статьяНужны ли нам веб-компоненты?
Следующая статья8 лучших техник программирования в Kotlin