В этой статье мы обсудим принцип инверсии зависимостей. Вкратце, мы поговорим о том, что это такое, и рассмотрим этот принцип на примере простого приложения Go.
Принцип инверсии зависимостей (DIP) — один из пяти принципов SOLID объектно-ориентированного программирования (ООП), впервые представленный Робертом К. Мартином. Говорится:
Модули высокого уровня не должны ничего импортировать из модулей низкого уровня. Оба должны зависеть от абстракций (например, интерфейсов).
Абстракции не должны зависеть от деталей. Детали (конкретные реализации) должны зависеть от абстракций.
Это очень известный принцип в мире ООП-проектирования, но если вы никогда раньше с ним не сталкивались, на первый взгляд он может показаться неясным, поэтому давайте разберем этот принцип на конкретном примере.
Давайте рассмотрим, как могла бы выглядеть реализация принципа DI в Go. Мы начнем с простого примера HTTP-приложения с одной конечной точкой/книгой, которое возвращает информацию о книге на основе ее идентификатора. Чтобы получить информацию о книге, приложение будет взаимодействовать с внешним HTTP-сервисом.
cmd — папка с командами Go. Основная функция будет находиться здесь.
Internal — папка с внутренним кодом приложения. Весь наш код будет находиться здесь.
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 } }
Как видите, на данный момент весь код находится непосредственно внутри обработчика (за исключением модели Book). В обработчике создаем 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 }
код типов.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 }
Кажется, ситуация значительно улучшилась, и мы решили проблему зависимости useсase от низкоуровневого модуля. Однако это еще не совсем так. Давайте сделаем еще один шаг вперед. Сейчас для объявления зависимостей useсase использует интерфейс низкоуровневого модуля. Можем ли мы улучшить это? Что, если мы объявим нужные нам интерфейсы в pkg/getbook/types.go ?
Таким образом мы удалим явные зависимости от низкоуровневых модулей. То есть наш высокоуровневый модуль объявил бы все необходимые для его работы интерфейсы, тем самым убрав все зависимости от низкоуровневых модулей. Затем на верхнем уровне приложения ( main.go ) мы реализуем все интерфейсы, необходимые для работы useсase.
Также давайте вспомним экспортируемые и неэкспортируемые типы в Go. Нужно ли экспортировать интерфейсы вариантов использования? Эти интерфейсы нужны только для указания зависимостей, необходимых этому пакету для его работы, поэтому их лучше не экспортировать.
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 }
типы.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. Реализация этого принципа может помочь предотвратить превращение вашего кода в спагетти и упростить его поддержку и чтение. Понимание зависимостей ваших классов и того, как их правильно объявлять, может значительно упростить вам жизнь при дальнейшей поддержке вашего приложения.