Принцип инверсии зависимостей в Go: Что это такое и как это использовать
В этой статье мы обсудим принцип инверсии зависимостей. Вкратце мы расскажем о том, что это такое, и рассмотрим этот принцип на примере простого Go-приложения.
Что такое принцип инверсии зависимостей?
Принцип инверсии зависимостей (DIP) — это один из пяти принципов SOLID объектно-ориентированного программирования (ООП), впервые представленный Робертом К. Мартином. Он гласит:
- Модули высокого уровня не должны ничего импортировать из модулей низкого уровня. И те, и другие должны зависеть от абстракций (например, интерфейсов).
- Абстракции не должны зависеть от деталей. Детали (конкретные реализации) должны зависеть от абстракций.
Это очень известный принцип в мире проектирования ООП, но если вы никогда с ним не сталкивались, то на первый взгляд он может показаться непонятным, поэтому давайте разберем этот принцип на конкретном примере.
Пример
Давайте рассмотрим, как реализация принципа DI может выглядеть в Go. Начнем с простого примера HTTP-приложения с одной конечной точкой/книгой, которая возвращает информацию о книге на основе её ID. Чтобы получить информацию о книге, приложение будет взаимодействовать с внешним HTTP-сервисом.
Структура проекта
cmd
- папка с командами Go. Здесь будет находиться основная функция.
internal
- папка с внутренним кодом приложения. Здесь будет находиться весь наш код.
Пример "спагетти-кода" без DI
main.go
просто запускает HTTP-сервер.
package main
import (
"log"
"net/http"
"example.com/books/internal/app/httpbookapi"
)
func main() {
http.Handle("/book", &httpbookapi.Handler{})
log.Print("server listening at 9090")
log.Fatal(http.ListenAndServe(":9090", nil))
}
Вот код для работы с нашей конечной точкой HTTP:
package httpbookapi
import (
"encoding/json"
"fmt"
"net/http"
"example.com/books/internal/model"
)
type Handler struct {
}
func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
var (
ctx = request.Context()
id = request.URL.Query().Get("id")
book model.Book
)
url := fmt.Sprintf("http://localhost:8080/book?id=%s", id)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&book); err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
book.Price = 10.12
if book.Title == "Pride and Prejudice" {
book.Price += 2
}
writer.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(writer).Encode(book); err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
}
Как вы можете видеть, в настоящее время весь код находится непосредственно внутри обработчика (за исключением модели книги). В обработчике мы создаем HTTP-клиента и делаем запрос к внешнему сервису. Затем мы назначаем книге определенную цену. Здесь, я думаю, любому разработчику очевидно, что это не лучший дизайн, и код для вызова внешнего сервиса нужно извлечь из обработчика. Давайте сделаем это.
Первый шаг к улучшению
В качестве первого шага давайте перенесем этот код в отдельное место. Для этого мы создадим файл internal/pkg/getbook/usecase.go
, в котором будет находиться логика получения и обработки нашей книги, и internal/pkg/getbook/types.go
, в котором мы будем хранить необходимые типы getbook
.
Код usecase.go
package getbook
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
type UseCase struct {
bookServiceClient *http.Client
}
func NewUseCase() *UseCase {
return &UseCase{}
}
func (u *UseCase) GetBook(ctx context.Context, id string) (*Book, error) {
var (
book Book
url = fmt.Sprintf("http://localhost:8080/book?id=%s", id)
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := u.bookServiceClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&book); err != nil {
return nil, err
}
book.Price = 10.12
if book.Title == "Pride and Prejudice" {
book.Price += 2
}
return &book, nil
}
Код types.go
package getbook
type Book struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Price float64 `json:"price"`
}
Код нового обработчика
package httpbookapi
import (
"encoding/json"
"net/http"
"example.com/books/internal/pkg/getbook"
)
type Handler struct {
getBookUseCase *getbook.UseCase
}
func NewHandler(getBookUseCase *getbook.UseCase) *Handler {
return &Handler{
getBookUseCase: getBookUseCase,
}
}
func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
var (
ctx = request.Context()
id = request.URL.Query().Get("id")
)
book, err := h.getBookUseCase.GetBook(ctx, id)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
writer.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(writer).Encode(book); err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
}
Как видите, код обработчика стал намного чище, но теперь нам гораздо интереснее взглянуть на getbook/usecase.go
.
type UseCase struct {
bookServiceClient *http.Client
}
UseCase имеет зависимость в виде *http.Client
, который мы сейчас никак не инициализируем. Мы могли бы передать *http.Client
в конструктор NewUseCase()
или создать *http.Client
непосредственно в конструкторе. Однако давайте ещё раз вспомним, что говорит нам принцип DI.
Модули высокого уровня не должны импортировать ничего из модулей низкого уровня. Оба модуля должны зависеть от абстракций (например, интерфейсов).
Однако при таком подходе мы сделали как раз наоборот. Наш высокоуровневый модуль, getbook
, импортирует низкоуровневый модуль, HTTP.
Внедрение инверсии зависимостей
Давайте подумаем, как это можно исправить. Для начала создадим файл internal/pkg/bookserviceclient/client.go
. Этот файл будет содержать реализацию HTTP-запросов к внешнему сервису и соответствующий интерфейс.
package bookserviceclient
import (
"context"
"fmt"
"io"
"net/http"
)
type Client interface {
GetBook(ctx context.Context, id string) ([]byte, error)
}
type client struct {
httpClient *http.Client
}
func NewClient() Client {
return &client{
httpClient: http.DefaultClient,
}
}
func (c *client) GetBook(ctx context.Context, id string) ([]byte, error) {
var (
url = fmt.Sprintf("http://localhost:8080/book?id=%s", id)
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return b, nil
}
Далее нам нужно обновить наш UseCase
, чтобы он начал использовать интерфейс из пакета bookserviceclient
.
package getbook
import (
"context"
"encoding/json"
"example.com/books/internal/pkg/bookserviceclient"
)
type UseCase struct {
bookClient bookserviceclient.Client
}
func NewUseCase(bookClient bookserviceclient.Client) *Usecase {
return &UseCase{
bookClient: bookClient,
}
}
func (u *UseCase) GetBook(ctx context.Context, id string) (*Book, error) {
var (
book Book
)
b, err := u.bookClient.GetBook(ctx, id)
if err != nil {
return nil, err
}
if err := json.Unmarshal(b, &book); err != nil {
return nil, err
}
book.Price = 10.12
if book.Title == "Pride and Prejudice" {
book.Price += 2
}
return &book, nil
}
Кажется, что ситуация значительно улучшилась, и мы решили проблему зависимости usecase
от низкоуровневого модуля. Однако это ещё не всё. Давайте сделаем ещё один шаг вперед. Сейчас для объявления зависимостей useсase
использует интерфейс из низкоуровневого модуля. Можно ли это улучшить? Что, если мы объявим необходимые нам интерфейсы в pkg/getbook/types.go
?
Таким образом, мы избавимся от явных зависимостей от низкоуровневых модулей. То есть наш высокоуровневый модуль объявит все интерфейсы, необходимые для его работы, и тем самым устранит все зависимости от низкоуровневых модулей. На верхнем уровне приложения main.go
мы реализуем все интерфейсы, необходимые для работы useсase
.
Также давайте вспомним об экспортируемых и неэкспортируемых типах в Go. Нужно ли нам делать интерфейсы useсase
экспортируемыми? Эти интерфейсы нужны только для указания зависимостей, необходимых данному пакету для работы, поэтому лучше их не экспортировать.
Финальный код
Код usecase.go
package getbook
import (
"context"
"encoding/json"
)
type UseCase struct {
bookClient bookClient
}
func NewUseCase(bookClient bookClient) *UseCase {
return &UseCase{
bookClient: bookClient,
}
}
func (u *UseCase) GetBook(ctx context.Context, id string) (*Book, error) {
var (
book Book
)
b, err := u.bookClient.GetBook(ctx, id)
if err != nil {
return nil, err
}
if err := json.Unmarshal(b, &book); err != nil {
return nil, err
}
book.Price = 10.12
if book.Title == "Pride and Prejudice" {
book.Price += 2
}
return &book, nil
}
Код types.go
package getbook
import "context"
type bookClient interface {
GetBook(ctx context.Context, id string) ([]byte, error)
}
type Book struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Price float64 `json:"price"`
}
Код client.go
package bookserviceclient
import (
"context"
"fmt"
"io"
"net/http"
)
type Client struct {
httpClient *http.Client
}
func NewClient() *Client {
return &Client{
httpClient: http.DefaultClient,
}
}
func (c *Client) GetBook(ctx context.Context, id string) ([]byte, error) {
var (
url = fmt.Sprintf("http://localhost:8080/book?id=%s", id)
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return b, nil
}
Код main.go
package main
import (
"log"
"net/http"
"example.com/books/internal/app/httpbookapi"
"example.com/books/internal/pkg/bookserviceclient"
"example.com/books/internal/pkg/getbook"
)
func main() {
bookServiceClient := bookserviceclient.NewClient()
useCase := getbook.NewUsecase(bookServiceClient)
handler := httpbookapi.NewHandler(useCase)
http.Handle("/book", handler)
log.Print("server listening at 9090")
log.Fatal(http.ListenAndServe(":9090", nil))
}
Заключение
В этой статье мы рассмотрели, как реализовать принцип инверсии зависимостей в Go. Реализация этого принципа может помочь предотвратить превращение вашего кода в "спагетти" и сделать его более легким для сопровождения и чтения. Понимание зависимостей ваших классов и их правильного объявления может значительно упростить вам жизнь при дальнейшей поддержке вашего приложения.
Спасибо за прочтение! Счастливого кодинга!