Внедрение зависимостей в Go с помощью Google Wire

golang and google wire

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

Мы будем концентрироваться на практическом аспекте. Основное внимание будет уделено тому, как реализовать внедрение зависимостей, особенно в приложениях на 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 не установлен, используйте следующую команду:

go get github.com/google/wire/cmd/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, изучите документацию по этой ссылке.

Добавить комментарий

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