Разделение ответственности, слабосвязанная система и принцип инверсии зависимостей — это ключевые концепции в программной инженерии. Эти идеи играют важную роль при разработке качественного программного обеспечения. В этой статье мы рассмотрим технику, которая объединяет все три принципа. Она называется внедрение зависимостей.
Мы будем концентрироваться на практическом аспекте. Основное внимание будет уделено тому, как реализовать внедрение зависимостей, особенно в приложениях на Go. Прежде чем углубляться в детали, давайте вернемся к основному вопросу: что такое внедрение зависимостей?
Внедрение зависимостей — это техника, которая помогает разделить ответственность за создание и использование объектов или функций. Объект или функция, нуждающиеся в определенном сервисе, не должны знать, как этот сервис создается. Это делает программу слабосвязанной.
Зависимости поступают от внешнего инжектора, о котором объекты не знают. Данный подход также реализует принцип инверсии зависимостей. Это означает, что объект объявляет интерфейсы сервисов, которые он использует, вместо конкретных реализаций.
Роберт ван Ген в одной из своих статей отметил, что внедрение зависимостей эффективно в небольших проектах. Однако в более крупных системах с комплексной структурой зависимостей оно может привести к созданию объемного инициализационного кода. Здесь на помощь приходят инструменты, такие как Wire.
Wire — это генератор кода для внедрения зависимостей в Go. Этот инструмент автоматически создает инициализационный код. Вам нужно только определить провайдеры и инжекторы.
Провайдеры — это функции на Go, которые возвращают значения на основе зависимостей. Инжекторы вызывают провайдеры в правильной последовательности. Теперь давайте посмотрим на пример, чтобы лучше понять процесс.
Настройка окружения
Допустим, мы разрабатываем HTTP-сервер, который предоставляет конечную точку для регистрации пользователей. Несмотря на единственный эндпоинт, система построена по классической трёхслойной архитектуре: Репозиторий, Бизнес-логика (Usecase) и Контроллер. Предположим также, что у нашего приложения следующая структура директорий.
. ├── go.mod ├── go.sum ├── internal │ ├── domain │ │ ├── model │ │ │ └── user.go │ │ └── repository │ │ └── user.go │ ├── handler │ │ └── handler.go │ ├── interface │ │ └── datastore │ │ └── user.go │ └── usecase │ ├── request │ │ └── user.go │ ├── user │ │ └── user.go │ └── user.go └── main.go
Теперь давайте перейдем к определению нашего первого провайдера. Он будет находиться в файле internal/interface/datastore/user.go. В приведённом ниже коде функция New выступает как провайдер. Она принимает зависимость в виде объекта *sql.DB и возвращает конкретную реализацию интерфейса Repository.
// internal/interface/datastore/user.go package datastore import ( "context" "database/sql" "inject/internal/domain/model" ) type Repository struct { db *sql.DB } func New(db *sql.DB) *Repository { return &Repository{db: db} } func (r Repository) Create(ctx context.Context, user model.User) error { // TODO: implement me return nil }
Эта реализация Repository используется в слое бизнес-логики (Usecase), но через интерфейс. Это означает, что слой Usecase взаимодействует с Repository через абстракцию, а не напрямую с конкретной реализацией. Технически, интерфейс должен быть частью потребляющего слоя. Однако это не обязательно требует, чтобы интерфейс и реализация находились в одном пакете.
В нашем примере интерфейс Repository и провайдер для слоя Usecase определены в разных файлах: internal/domain/repository/user.go для интерфейса и internal/usecase/user/user.go для провайдера.
// internal/usecase/user/user.go package user import ( "context" "inject/internal/domain/repository" "inject/internal/usecase/request" ) type Usecase struct { repository repository.Repository } func New(repository repository.Repository) *Usecase { return &Usecase{repository: repository} } func (u Usecase) Create(ctx context.Context, req request.CreateUserRequest) error { // TODO: implement me return nil }
Также, как и провайдер для Repository, наш провайдер для слоя Usecase возвращает конкретную реализацию.
// internal/domain/repository/user.go package repository import ( "context" "inject/internal/domain/model" ) type Repository interface { Create(ctx context.Context, user model.User) error }
Следующий шаг — это использование этой реализации слоя Usecase контроллером. Взаимодействие снова происходит через интерфейс. Провайдер для контроллера и интерфейс слоя Usecase находятся в файлах internal/handler/handler.go и internal/usecase/user.go соответственно.
// internal/interface/datastore/user.go package handler import ( "inject/internal/usecase" "net/http" ) type Handler struct { usecase usecase.Usecase } func New(usecase usecase.Usecase) *Handler { return &Handler{usecase: usecase} } func (h Handler) Create() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // TODO: implement me w.WriteHeader(http.StatusOK) } }
// internal/usecase/user.go package usecase import ( "context" "inject/internal/usecase/request" ) type Usecase interface { Create(ctx context.Context, req request.CreateUserRequest) error }
Теперь, когда все необходимые провайдеры настроены, можно вручную внедрить зависимости в файле main.go. Пример кода приведён ниже.
// main.go package main import ( "database/sql" "log" "net/http" "inject/internal/handler" "inject/internal/interface/datastore" "inject/internal/usecase/user" _ "github.com/go-sql-driver/mysql" ) func main() { db, err := sql.Open("mysql", "dataSourceName") if err != nil { log.Fatalf("sql.Open: %v", err) } repository := datastore.New(db) usecase := user.New(repository) handler := handler.New(usecase) mux := http.NewServeMux() mux.HandleFunc("POST /users", handler.Create()) log.Fatalf("http.ListenAndServe: %v", http.ListenAndServe(":8000", mux)) }
Использование Wire
Как использовать Wire для генерации кода инициализации? Этот инструмент помогает упростить наш файл main.go, как показано далее.
// main.go package main import ( "log" "net/http" ) func main() { handler, err := InitializeHandler() if err != nil { log.Fatal(err) } log.Fatal(http.ListenAndServe(":8000", handler)) }
Для начала создадим файл wire.go. Его можно разместить в отдельном пакете. Однако в данном примере мы будем хранить его в корневой директории проекта. Перед созданием wire.go полезно провести небольшой рефакторинг. Это касается инициализации подключения к базе данных и регистрации API маршрутов. Для этого создадим несколько новых провайдеров, которые будут решать эти задачи.
// pkg/mysql/mysql.go package mysql import ( "database/sql" _ "github.com/go-sql-driver/mysql" ) func New() (*sql.DB, error) { db, err := sql.Open("mysql", "dataSourceName") if err != nil { return nil, err } return db, nil }
// internal/handler/route.go package handler import "net/http" func Register(handler *Handler) *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("POST /users", handler.Create()) return mux }
Функция-провайдер Register, приведённая выше, принимает конкретную реализацию Handler. Конечно, можно было бы использовать интерфейс или абстракцию. Однако в данном примере мы оставляем это так, как есть. Это похоже на то, как функция-провайдер для Repository принимает реализацию типа *sql.DB.
Такой подход не противоречит Принципу инверсии зависимостей, о котором мы говорили ранее. На самом деле, это хороший пример того, что создание абстракций не всегда обязательно. Если в этом нет веской необходимости, лучше обойтись без них.
Теперь давайте вернёмся к файлу wire.go. Вы могли заметить, что функция InitializeHandler из упрощённого main.go могла быть сгенерирована с помощью Wire. И это действительно так! Чтобы сгенерировать такую функцию, нужно настроить wire.go определённым образом.
//go:build wireinject // +build wireinject package main import ( "net/http" "inject/internal/domain/repository" "inject/internal/handler" "inject/internal/interface/datastore" "inject/internal/usecase" "inject/internal/usecase/user" "inject/pkg/mysql" "github.com/google/wire" ) func InitializeHandler() (*http.ServeMux, error) { wire.Build( mysql.New, datastore.New, wire.Bind(new(repository.Repository), new(*datastore.Repository)), user.New, wire.Bind(new(usecase.Usecase), new(*user.Usecase)), handler.New, handler.Register, ) return &http.ServeMux{}, nil }
Настройка wire.go
В файле wire.go мы задаем шаблон для инжекторной функции InitializeHandler. Эта функция возвращает *http.ServeMux и ошибку. Заметьте, что значения, возвращаемые как (&http.ServeMux{}, nil), нужны только для компиляции. Для корректного возврата значений мы объявляем все необходимые провайдеры. Это такие функции, как mysql.New, datastore.New, user.New, handler.New и handler.Register.
Хотя Wire может автоматически определить граф зависимостей, ему необходимо указать, что конкретная реализация удовлетворяет определённому интерфейсу. Например, datastore.New и user.New возвращают конкретные реализации с типами *datastore.Repository и *user.Usecase, которые соответствуют интерфейсам repository.Repository и usecase.Usecase. Чтобы указать это явно, используется функция Bind.
Также стоит помнить, что файл wire.go нужно исключить из финального бинарного файла. Это достигается добавлением директивы сборки в начало файла wire.go.
Генерация кода с Wire
Теперь можно выполнить команду Wire в корневой директории проекта.
Если Wire не установлен, используйте следующую команду:
Эта команда создаст файл wire_gen.go с сгенерированным кодом для функции InitializeHandler. Код будет очень похож на то, что мы писали вручную в main.go ранее.
// Code generated by Wire. DO NOT EDIT. //go:generate go run -mod=mod github.com/google/wire/cmd/wire //go:build !wireinject // +build !wireinject package wire import ( "inject/internal/handler" "inject/internal/interface/datastore" "inject/internal/usecase/user" "inject/pkg/mysql" "net/http" ) // Injectors from wire.go: //go:generate wire func InitializeHandler() (*http.ServeMux, error) { db, err := mysql.New() if err != nil { return nil, err } repository := datastore.New(db) usecase := user.New(repository) handlerHandler := handler.New(usecase) serveMux := handler.Register(handlerHandler) return serveMux, nil }
Модификация зависимостей
Предположим, что мы хотим изменить функцию mysql.New, чтобы она принимала структуру конфигурации. Это избавит нас от необходимости жёстко прописывать имя источника данных в коде — что является плохой практикой. Для этого создаём специальную директорию для хранения файла конфигурации и добавляем новый провайдер, который будет читать файл и возвращать структуру конфигурации.
Теперь структура директорий будет выглядеть так:
. ├── config │ ├── config.go │ └── file │ └── config.json ├── go.mod ├── go.sum ├── internal │ ├── domain │ │ ├── model │ │ │ └── user.go │ │ └── repository │ │ └── user.go │ ├── handler │ │ ├── handler.go │ │ └── route.go │ ├── interface │ │ └── datastore │ │ └── user.go │ └── usecase │ ├── request │ │ └── user.go │ ├── user │ │ └── user.go │ └── user.go ├── main.go ├── pkg │ └── mysql │ └── mysql.go ├── wire_gen.go └── wire.go
В файле config/config.go мы определяем структуру Config и её провайдер.
package config type Config struct { DatabaseDSN string AppPort string } func Load() (Config, error) { // TODO: implement me return Config{}, nil }
Добавление нового провайдера в wire.go
Нам осталось только добавить новый провайдер в файл wire.go. Его нужно включить в цепочку вызовов функции Build.
Запустите команду Wire снова (или используйте go generate). Это заставит Wire сгенерировать обновлённый код инициализации.
//go:build wireinject // +build wireinject package wire import ( "net/http" "inject/config" "inject/internal/domain/repository" "inject/internal/handler" "inject/internal/interface/datastore" "inject/internal/usecase" "inject/internal/usecase/user" "inject/pkg/mysql" "github.com/google/wire" ) func InitializeHandler() (*http.ServeMux, error) { wire.Build( config.Load, mysql.New, datastore.New, wire.Bind(new(repository.Repository), new(*datastore.Repository)), user.New, wire.Bind(new(usecase.Usecase), new(*user.Usecase)), handler.New, handler.Register, ) return &http.ServeMux{}, nil }
Вот и всё!
Заключение
Мы рассмотрели пример использования Wire, который демонстрирует, как этот инструмент помогает автоматически генерировать код инициализации зависимостей. Однако это лишь часть возможностей Wire. В нём есть ещё несколько полезных функций, которые мы здесь не упомянули. Чтобы узнать больше и максимально использовать Wire, изучите документацию по этой ссылке.