Впровадження залежностей в Go
Впровадження залежностей (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