Впровадження залежностей в Go

Volodymyr Kupriienko
4 min readJan 3, 2024

--

Впровадження залежностей (Dependency Injection, DI) — шаблон проектування, суть якого полягає у передачі залежностей ззовні, замість створення їх в місці, де вони використовуються.

Проблематика

Розберемо на прикладі.

У нас є хендлер, якому потрібен логер. Він може створити його для себе:

type HelloHandler struct{
logger *log.Logger
}

func NewHelloHandler() *HelloHandler {
logger := log.Default()

return &HelloHandler{
logger: logger,
}
}

func (h *HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {...}

Але з таким підходом доведеться створювати логер в усіх місцях, де він потрібний.

Тобто, кожен наступний хендлер буде створювати його для себе. Але що буде якщо ми захочемо писати логи у файл замість os.Stderr? Нам доведеться внести зміни в усі місця, де ми створюємо логер:

type HelloHandler struct{
logger *log.Logger
}

func NewHelloHandler() *HelloHandler {
// create log file
logger := log.New(logFile, "", log.LstdFlags)

return &HelloHandler{
logger: logger,
}
}
type EchoHandler struct{
logger *log.Logger
}

func NewEchoHandler() *EchoHandler {
// create log file
logger := log.New(logFile, "", log.LstdFlags)

return &EchoHandler{
logger: logger,
}
}

Ми б могли винести логіку створення логера в окрему функцію та викликати її, але в такому варіанті виникає інша проблема — тести. Ми не зможемо використати іншу реалізацію логера в тестах, так як він створюється всередині хендлера і ми на це не можемо повпливати ззовні.

Як вирішити ці проблеми? Впровадити потрібний компонент ззовні!

Рішення

// create log file
logger := log.New(logFile, "", log.LstdFlags)

helloHandler := handler.NewHelloHandler(logger)
echoHandler := handler.NewEchoHandler(logger)
type HelloHandler struct{
logger *log.Logger
}

func NewHelloHandler(logger *log.Logger) *HelloHandler {
return &HelloHandler{
logger: logger,
}
}
type EchoHandler struct{
logger *log.Logger
}

func NewEchoHandler(logger *log.Logger) *EchoHandler {
return &EchoHandler{
logger: logger,
}
}

Тепер, якщо захочемо змінити конфігурацію логера — ми це зробимо в одному місці. Зазвичай всі компоненти системи створюються у вхідній точці в додаток, тобто в main.go.

Також для тестів буде можливість створити інший логер, який, наприклад, не бути нікуди писати логи (реалізація шаблону Null Object).

Це і є впровадження залежностей.

Дерево залежностей

Що таке залежності? Це набір компонентів, які потрібні іншому компоненту системи для його роботи. У прикладі вище хендлерам для роботи потрібний логер. Вони не можуть коректно працювати без логера, отже вони від нього залежать.

На практиці залежностей більше і це дерево набагато складніше.

Наприклад, хендлеру окрім логера ще може знадобитись сервіс, який виконує логіку. Сервісу потрібен репозиторій для роботи з моделями і зазвичай не один, а декілька. Репозиторіям потрібне підключення до бази даних.

Візуалізація дерева залежностей

В результаті main.go може виглядати наступним чином:

// create log file
logger := log.New(logFile, "", log.LstdFlags)

conn := db.NewConnection()

userRepository := repository.NewUserRepository(conn)
messageRepository := repository.NewMessageRepository(conn)

greetingService := service.NewGreetingService(userRepository, messageRepository)

helloHandler := handler.NewHelloHandler(logger, greetingService)
echoHandler := handler.NewEchoHandler(logger)

apiServer := server.NewAPIServer(
helloHandler,
echoHandler,
)

if err := apiServer.Start(); err != nil {
log.Fatal(err)
}

Поки виглядає зрозуміло, але на практиці таких компонентів в додатку десятки.

Як тільки у певного компонента системи змінюється набір залежностей (додаються нові, видаляються вже непотрібні) — потрібно також внести зміни в файл вище. Але якщо у вас декілька вхідних точок в додаток, наприклад: веб-сервер, конс’юмер подій з брокера повідомлень, консольна команда або крон — ми муситимемо внести зміни в усі ці місця

cmd/
server/
main.go
consumer/
main.go
cron_job/
main.go

Контейнер впровадження залежностей

В такому випадку на поміч приходять бібліотеки, які автоматизують впровадження залежностей. Прикладом таких бібліотек є dig та fx від компанії Uber, wire від Google.

Ми детальніше розглянемо dig. Вона забирає на себе обов’язок побудови дерева залежностей та створення інстансів структур.

Перепишемо main.go для веб-сервера.

Спершу потрібно створити контейнер:

container := dig.New()

контейнер збирає в собі інформацію про те, як створити певний компонент, та створює для нас інстанси цих компонентів.

Тож надамо контейнеру інформацію про наші компоненти додавши їх конструктори в контейнер за допомогою методу Provide():

container.Provide(func() *log.Logger {
// create log file
return log.New(logFile, "", log.LstdFlags)
})
container.Provide(db.NewConnection)
container.Provide(repository.NewUserRepository)
container.Provide(repository.NewMessageRepository)
container.Provide(service.NewGreetingService)
container.Provide(handler.NewHelloHandler)
container.Provide(handler.NewEchoHandler)
container.Provide(server.NewAPIServer)

як бачимо, можна передавати оголошені або анонімні функції.

На останок візьмемо створений інстанс серверу та запустимо його за допомогою методу Invoke():

container.Invoke(func (apiServer *server.APIServer) error {
return apiServer.Start()
})

Таким чином dig створить усі необхідні інстанси і передасть їх в якості аргументів. З нашої сторони залишиться тільки реєстрація нових конструкторів в контейнері.

Також, якщо, наприклад, підключенню до БД знадобиться логер — нам потрібно буде тільки додати його в сигнатуру конструктора. Оскільки контейнер вже має інформацію про те, як створити логер — він може його впровадити в будь-який компонент системи.

Перевикористання контейнера

Далі ми можемо винести побудову контейнера в окрему функцію, яку зможемо викликати з усіх вхідних точок в наш додаток:

package di

func BuildContainer() *dig.Container {
container := dig.New()

container.Provide(func() *log.Logger {
// create log file
return log.New(logFile, "", log.LstdFlags)
})
container.Provide(db.NewConnection)
container.Provide(repository.NewUserRepository)
container.Provide(repository.NewMessageRepository)
container.Provide(service.NewGreetingService)

return container
}
package main

import "di"

func main() {
if err := runApp(); err != nil {
log.Fatal(err)
}
}

func runApp() error {
c := di.BuildContainer()
c = addAppSpecificDependencies(c)

return c.Invoke(func (apiServer *server.APIServer) error {
return apiServer.Start()
})
}

func addAppSpecificDependencies(container *dig.Container) *dig.Container {
container.Provide(handler.NewHelloHandler)
container.Provide(handler.NewEchoHandler)
container.Provide(server.NewAPIServer)

return container
}

Окрім перевикористання дерева залежностей між точками входу в додаток, ми маємо можливість написати тест, який дасть впевненість в тому, що дерево побудоване коректно:

package main

func TestRunApp(t *testing.T) {
if err := runApp(); err != nil {
t.Fatal(err)
}
}

Справа в тому, що дерево будується після запуску програми (in runtime), а не під час компіляції. Тому може виникнути ситуація, коли ми запускаємо додаток і отримуємо помилку побудови дерева.

Тест гарантує, що помилок не буде і наш додаток успішно запуститься з коректно побудованим деревом залежностей.

Висновки

Для маленьких проектів можна використовувати підхід з ручним впровадженням залежностей.

Для середніх та великих проектів рекомендую використовувати DI-контейнери, які спрощують впровадження залежностей.

Головне не створювати залежності в місцях, де вони використовуються, а впроваджувати їх ззовні.

Розширену версію коду для самостійного опрацюванню у вигляді повністю робочого додатку можна знати на моєму GitHub: https://github.com/greeflas/go_di_example

--

--