Принцип инверсии зависимостей (DIP)
Принцип инверсии зависимостей (DIP):
- Высокоуровневые компоненты не должны зависеть от низкоуровневых компонентов. Вместо этого и высокоуровневые, и низкоуровневые компоненты должны зависеть от абстракций.
- Абстракции не должны зависеть от деталей реализации. Детали реализации должны зависеть от абстракций.
Высокоуровневые компоненты – это компоненты приложения, которые представляют основную функциональность и бизнес-логику. Они отвечают за оркестрацию поведения приложения и носят обобщённый и абстрактный характер. Эти компоненты работают на более высоком уровне абстракции и меньше связаны с конкретными деталями реализации.
Напротив, низкоуровневые компоненты – это компоненты, которые работают с деталями имплементации. Они часто отвечают за работу с внешними системами, хранением данных или другими базовыми операциями, поддерживающими высокоуровневые модули.
Пример нарушения принципа
Представим, что у нас есть приложение интернет-магазин с возможностью онлайн-оплаты. У нас есть пакет payment с типом Processor, который отвечает за оркестрацию логики по обработке платежа. И есть пакет gateway с типом PayPalGateway, который является конкретным платёжным шлюзом.
package gateway
type PayPalGateway struct{}
func (g *PayPalGateway) ProcessPayment(orderID string, amount int) error {
// Процессинг, специфичный для PayPal
return nil
}gopackage payment
import "example.com/payment/gateway"
type Processor struct {
payPalGateway *gateway.PayPalGateway
}
func NewProcessor() *Processor {
return &Processor{
payPalGateway: &gateway.PayPalGateway{},
}
}
func (p *Processor) ProcessPayment(details Details) error {
return p.payPalGateway.ProcessPayment(details.OrderID, details.Amount)
}goУ этого кода есть несколько проблем. Во-первых, на Processor сложно написать юнит-тесты. Когда мы пишем юнит-тест, мы хотим протестировать компонент изолированно от его зависимостей. В коде выше мы не сможем изолировать Processor, поскольку он зависит от конкретного типа PayPalGateway и сам создаёт его экземпляр в конструкторе. Мы не сможем подменить PayPalGateway на стаб – будет тестироваться и Processor, и PayPalGateway одновременно.
В реальном коде PayPalGateway будет делать сетевой запрос к API PayPal, поэтому мы вообще не сможем написать юнит-тест на Processor. Юнит-тесты должны быть быстрыми и максимально изолированными – API-запросы, запросы в базу и прочее I/O-взаимодействие в них неуместно.
Попробуем решить проблему тестирования с помощью инъекции зависимости (dependency injection) через конструктор:
package payment
import "example.com/payment/gateway"
type Processor struct {
gateway *gateway.PayPalGateway
}
func NewProcessor(gw *gateway.PayPalGateway) *Processor {
return &Processor{gateway: gw}
}
func (p *Processor) ProcessPayment(details Details) error {
return p.gateway.ProcessPayment(details.OrderID, details.Amount)
}goКод выглядит лучше. Мы можем сами настроить экземпляр PayPalGateway и передать его через конструктор.
Однако есть и другая проблема. Processor является высокоуровневым компонентом. Интернет-магазин в любом случае должен поддерживать онлайн-оплату вне зависимости от того, через какой шлюз оплата проходит. Таким образом, Processor является частью основной функциональности нашего приложения.
PayPalGateway – это низкоуровневый компонент (деталь реализации). Сегодня компания может использовать PayPal, а завтра переключиться на Stripe.
Таким образом, в нашем приложении высокоуровневый компонент зависит от низкоуровневого компонента: пакет payment импортирует пакет gateway.
Почему это плохо? Для того чтобы заменить PayPal на Stripe, придётся лезть в код высокоуровневого компонента. А изменения в коде – это всегда риск. В идеале нам хотелось бы вообще не редактировать код Processor при изменении реализации шлюза.
Инвертируем зависимости
Очевидно, что Processor важна функциональность шлюза, но не важна конкретная реализация. Мы можем создать интерфейс Gateway:
package gateway
type Gateway interface {
ProcessPayment(orderID string, amount int) error
}gopackage gateway
type PayPalGateway struct{}
func (g *PayPalGateway) ProcessPayment(orderID string, amount int) error {
// Процессинг, специфичный для PayPal
return nil
}gopackage payment
import "example.com/payment/gateway"
type Processor struct {
gateway gateway.Gateway
}
func NewProcessor(gw gateway.Gateway) *Processor {
return &Processor{gateway: gw}
}
func (p *Processor) ProcessPayment(details Details) error {
return p.gateway.ProcessPayment(details.OrderID, details.Amount)
}goТеперь и PayPalGateway, и Processor зависят от интерфейса Gateway.
Теперь мы можем легко подменять имплементацию платёжного шлюза, и Processor не будет знать об этом.
Также Processor теперь проще покрыть юнит-тестами. Для этого достаточно создать стаб, удовлетворяющий интерфейсу Gateway:
package payment_test
import (
"testing"
"example.com/payment/payment"
)
type stubGateway struct {
called bool
}
func (s *stubGateway) ProcessPayment(details payment.Details) error {
s.called = true
return nil
}
func TestProcessPayment(t *testing.T) {
gw := &stubGateway{}
processor := payment.NewProcessor(gw)
err := processor.ProcessPayment(payment.Details{
OrderID: "order-1",
Amount: 1000,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gw.called {
t.Fatal("expected gateway to be called")
}
}goИтак, оба компонента зависят от абстракции и мы соблюли DIP. Или нет?
Снова обратим внимание на диаграмму выше. Направление зависимости не изменилось – стрелка по-прежнему указывает от высокоуровневого компонента к низкоуровневому. Мы ввели абстракцию, но не инвертировали зависимость.
Владение абстракцией
Хоть мы и ввели абстракцию (интерфейс), Processor всё ещё зависит от пакета gateway, поскольку интерфейс определён в нём. Классическая реализация DIP подразумевает, что высокоуровневые компоненты владеют абстракцией, от которой они зависят.
Если верхнеуровневому компоненту нужен какой-то абстрактный функционал, то логичнее, если он и будет владеть этой абстракцией, а также менять её под свои нужды. В данном случае Processor нуждается в функциональности платёжного шлюза, а PayPalGateway лишь предоставляет этот функционал.
Перенесём интерфейс Gateway в пакет payment:
package payment
type Gateway interface {
ProcessPayment(details Details) error
}gopackage payment
type Processor struct {
gateway Gateway
}
func NewProcessor(gw Gateway) *Processor {
return &Processor{gateway: gw}
}
func (p *Processor) ProcessPayment(details Details) error {
return p.gateway.ProcessPayment(details)
}gopackage gateway
import "example.com/payment/payment"
type PayPalGateway struct{}
func (g *PayPalGateway) ProcessPayment(details payment.Details) error {
// Процессинг, специфичный для PayPal
return nil
}goТеперь Processor зависит от интерфейса Gateway внутри своего пакета. А PayPalGateway неявно удовлетворяет этому интерфейсу – в Go для этого не нужен явный implements, что делает инверсию зависимостей особенно естественной.
Обратите внимание – именно здесь соблюдается вторая часть принципа: абстракция Gateway не зависит от деталей реализации PayPalGateway. Наоборот, PayPalGateway подстраивается под контракт, определённый высокоуровневым компонентом. Детали реализации зависят от абстракции, а не наоборот.
При этом важно, чтобы каждая реализация интерфейса Gateway корректно соблюдала его контракт. Это гарантируется принципом подстановки Барбары Лисков (LSP) – любая реализация должна быть безопасно подставлена там, где ожидается интерфейс Gateway, не нарушая работу Processor.
Также обратите внимание на сигнатуру метода ProcessPayment. До инверсии интерфейс Gateway находился в пакете gateway и не мог использовать тип payment.Details – это привело бы к циклической зависимости между пакетами. Поэтому метод принимал примитивные параметры (orderID string, amount int), продиктованные низкоуровневым компонентом. После инверсии интерфейс переехал в пакет payment, где Details – локальный тип. Теперь высокоуровневый компонент сам определяет контракт в удобных ему терминах – это ещё одно практическое преимущество DIP.
Мы свели к минимуму зависимость Processor от PayPalGateway. Изменения в PayPalGateway не затронут высокоуровневый компонент (если, конечно, мы не сломаем контракт – например, начнём возвращать ошибку, которую Processor не ожидает). Теперь можно сказать, что мы соблюли принцип DIP в его классическом варианте.
Тем не менее и у такой инверсии есть минусы. Теперь PayPalGateway импортирует пакет payment, поскольку использует тип payment.Details в сигнатуре метода. Это приводит к тому, что низкоуровневый компонент сложнее переиспользовать в других пакетах, поскольку он будет тянуть за собой транзитивную зависимость на пакет payment.
Чтобы решить эту проблему, можно пойти дальше и выделить интерфейс Gateway в отдельный пакет.
Интерфейс в отдельном пакете
package paygate
type Details struct {
OrderID string
Amount int // cents
}
type Gateway interface {
ProcessPayment(details Details) error
}gopackage payment
import "example.com/payment/paygate"
type Processor struct {
gateway paygate.Gateway
}
func NewProcessor(gw paygate.Gateway) *Processor {
return &Processor{gateway: gw}
}
func (p *Processor) ProcessPayment(details paygate.Details) error {
return p.gateway.ProcessPayment(details)
}gopackage gateway
import "example.com/payment/paygate"
type PayPalGateway struct{}
func (g *PayPalGateway) ProcessPayment(details paygate.Details) error {
// Процессинг, специфичный для PayPal
return nil
}goТеперь интерфейсом не владеет ни один из пакетов. Такая инверсия нацелена на максимальное переиспользование всех зависимостей.
Однако если вы работаете над обычным приложением с закрытым кодом, такая декомпозиция скорее всего доставит больше проблем, чем решит. Выделение интерфейсов в отдельный пакет – это дополнительная работа, которая ещё и усложнит понимание проекта. А от переиспользования вы вряд ли выиграете, поскольку платёжный шлюз вряд ли будет во многих местах. Поэтому для стандартных проектов достаточно ограничиться классическим соблюдением DIP с абстракцией внутри высокоуровневого компонента.
Тем не менее есть случаи, когда выделение абстракций в отдельные пакеты имеет смысл. Об этом поговорим далее.
Реальный пример: log/slog
Есть функциональность, которая требуется во многих проектах и библиотеках, независимо от бизнес-домена. К такой функциональности относится, например, логгирование.
Пакет log/slog из стандартной библиотеки Go решает проблему переиспользования такой общей функциональности. В его основе лежит интерфейс slog.Handler:
type Handler interface {
Enabled(context.Context, Level) bool
Handle(context.Context, Record) error
WithAttrs(attrs []Attr) Handler
WithGroup(name string) Handler
}goПоскольку slog.Handler определён в стандартной библиотеке и не привязан к конкретному проекту, любая библиотека логгирования может предоставить свою реализацию. Стандартная библиотека поставляет slog.JSONHandler и slog.TextHandler, а сторонние библиотеки предлагают адаптеры – например, slog-zerolog для zerolog или slog-zap для zap.
Добавим логгирование в Processor:
package payment
import "log/slog"
type Gateway interface {
ProcessPayment(details Details) error
}
type Processor struct {
gateway Gateway
logger *slog.Logger
}
func NewProcessor(gw Gateway, logger *slog.Logger) *Processor {
return &Processor{gateway: gw, logger: logger}
}
func (p *Processor) ProcessPayment(details Details) error {
p.logger.Info("processing payment",
slog.String("order_id", details.OrderID),
slog.Int("amount", details.Amount),
)
return p.gateway.ProcessPayment(details)
}goТеперь Processor зависит от *slog.Logger из стандартной библиотеки. В точке сборки приложения мы можем выбрать конкретный хендлер:
zerologLogger := zerolog.New(os.Stdout)
handler := slogzerolog.Option{Logger: &zerologLogger}.NewZerologHandler()
logger := slog.New(handler)goОбратите внимание – Processor зависит от конкретного типа *slog.Logger, а не от интерфейса. Однако абстракция здесь всё равно присутствует, просто на один уровень глубже: Logger внутри делегирует работу интерфейсу slog.Handler. Именно благодаря Handler мы можем подменять реализацию логгирования – например, переключиться с zerolog на zap или стандартный JSONHandler – не трогая код Processor.
Зависимость от конкретного *slog.Logger здесь допустима, потому что log/slog – стабильный пакет стандартной библиотеки, который вряд ли будет меняться (подробнее о стабильности зависимостей – в следующем разделе). А поскольку slog.Handler тоже находится в стандартной библиотеке, конкретные реализации (zerolog, zap) ничего не знают о пакете payment. Это позволяет переиспользовать библиотеку логгирования в любых других проектах.
Стабильность зависимостей
У каждого пакета могут быть входящие (афферентные) и исходящие (эфферентные) зависимости. Из отношения зависимостей можно вывести метрику нестабильности пакета:
Метрика принимает значения от 0 до 1, где 0 – максимально стабильный пакет (ни одной исходящей зависимости) и 1 – максимально нестабильный пакет (только исходящие зависимости).
На рисунке у пакета D три входящие зависимости и нет исходящих зависимостей. Подставим в формулу и выясним, что нестабильность этого пакета равна 0.
Такой пакет максимально стабилен. У него нет никаких зависимостей, которые могли бы заставить его поменяться. Но при этом в этот пакет тяжело вносить изменения, ведь от него зависят три других пакета, которые могут сломаться при изменении пакета D.
По этой причине стабильные пакеты рекомендуется делать максимально абстрактными. Абстракция сама по себе стабильна и редко требует изменений. Хорошим примером стабильного абстрактного пакета является io из стандартной библиотеки Go. У этого пакета нет исходящих зависимостей на нестабильные пакеты (но при этом много входящих) и сам он почти целиком состоит из абстракций (интерфейсов).
Стоит отметить, что стабильные пакеты не всегда состоят из абстракций. Главная проблема стабильных пакетов – сложность внесения изменений. Однако если пакет в целом не требует изменений, то он может по большей части состоять из конкретных (неабстрактных) компонентов. Также нет проблемы в зависимости от такого пакета, ведь он не изменяется, а значит и поломать входящие зависимости не может.
Примером стабильного неабстрактного пакета является net/http из стандартной библиотеки Go. Он содержит основной функционал для работы с HTTP – обработку запросов, маршрутизацию, заголовки и статусы. Эта функциональность базовая и вряд ли будет меняться. Поэтому пакет можно назвать стабильным и нет проблемы в том, чтобы напрямую зависеть от него.
Стандартную библиотеку Go в целом можно назвать набором стабильных зависимостей. Например, нет проблемы в том, чтобы напрямую использовать encoding/json, sort, strings и прочие пакеты.
Рассмотрим пример нестабильного пакета.
У пакета A нет входящих зависимостей, но есть 3 исходящие зависимости.
Пакет максимально нестабилен. Изменения в любом из пакетов B, C или D могут заставить A тоже поменяться. Но при этом в сам пакет A можно легко вносить изменения, поскольку у него нет входящих зависимостей.
Мы рассмотрели 2 пограничных случая стабильности/нестабильности. Как уже говорилось выше, метрика нестабильности может принимать значения от 0 до 1. Поэтому между пограничными случаями есть много случаев «посредине», когда у пакета есть зависимости как входящие, так и исходящие.
Заключение
При проектировании приложения важно думать о зависимостях. Необходимо разделять высокоуровневые и низкоуровневые компоненты и инвертировать зависимости между ними путём введения абстракции. Это поможет избежать каскадных изменений в высокоуровневых компонентах, когда меняются детали имплементации. Это тесно связано с принципом открытости-закрытости (OCP) – абстракция, введённая при инверсии зависимостей, делает высокоуровневый компонент открытым для расширения новыми реализациями без модификации его кода.
Мы рассмотрели несколько вариантов инверсии. Классический вариант – создать интерфейс в том же пакете, в котором он будет использоваться. Ещё один вариант – вынести интерфейс в отдельный пакет. Он хорошо подходит для общего функционала, например, для логгирования.
В Go инверсия зависимостей особенно естественна благодаря неявному удовлетворению интерфейсов. Низкоуровневый компонент не обязан «знать» об интерфейсе высокоуровневого компонента – достаточно того, что он реализует нужные методы. Это делает пакеты слабо связанными и упрощает подмену реализаций.
Также мы разобрались, что у каждого пакета есть метрика стабильности, и рассмотрели правило – пакет не должен зависеть от менее стабильного пакета.