Все равно не обойдетесь! — Использование интерфейсов и внедрение зависимостей для долговечного проектирования

https://medium.com/dm03514-tech-blog/you-are-going-to-need-it-using-interfaces-and-dependency-injection-to-future-proof-your-designs-2cf6f58db192
  • Перевод
Всем привет!

У нас наконец-то есть контракт на обновление книги Марка Симана "Dependency Injection in .NET" — главное, чтобы он поскорее ее дописал. А еще у нас в редактуре книга уважаемого Динеша Раджпута о паттернах проектирования в Spring 5, где одна из глав также посвящена внедрению зависимостей.

Мы давно искали интересный материал, который напомнит о сильных сторонах парадигмы DI и пояснит наш интерес к ней — и вот он нашелся. Правда, автор предпочел привести примеры на языке Go. Надеемся, это не помешает вам следить за ходом его мыслей и поможет разобраться в общих принципах инверсии управления и работы с интерфейсами, если данная тема вам близка.

Эмоциональная окраска оригинала немного утихомирена, количество восклицательных знаков в переводе сокращено. Приятного чтения!

Использование интерфейсов – понятная техника, позволяющая создавать удобный в тестировании и легко расширяемый код. Я неоднократно убеждался, что это самый мощный инструмент проектирования архитектур из всех существующих.

Цель этой статьи – рассказать, что такое интерфейсы, как они используются и как обеспечивают расширяемость и тестируемость кода. Наконец, статья должна показать, как интерфейсы могут помочь в оптимизации управления доставкой ПО и упростить планирование!

Интерфейсы

Интерфейс описывает контракт. В зависимости от языка или фреймворка, использование интерфейсов может диктоваться явно или неявно. Так, в языке Go интерфейсы диктуются явно. Если вы попытаетесь использовать сущность в качестве интерфейса, но она не будет полностью согласовываться с правилами данного интерфейса, то возникнет ошибка времени компиляции. Например, выполнив вышеприведенный пример, получим следующую ошибку:

prog.go:22:85: cannot use BadPricer literal (type BadPricer) as type StockPricer in argument to isPricerHigherThan100:
	BadPricer does not implement StockPricer (missing CurrentPrice method)
Program exited.

Интерфейсы – это инструмент, помогающий открепить вызывающую сторону от вызываемой, это делается при помощи контракта.

Давайте конкретизируем эту проблему на примере программы для автоматического биржевого трейдинга. Программа-трейдер будет вызываться с установленной ценой покупки и тикерным символом. Затем программа обратится на биржу, чтобы узнать актуальную котировку данного тикера. Далее, если цена покупки для данного тикера не превышает заданной, программа совершит покупку.



В упрощенном виде архитектуру этой программы можно изобразить вот так. Из вышеприведенного примера понятно, что операция получения актуальной цены напрямую зависит от протокола HTTP, по которому программа контактирует с биржевой службой.

Состояние Action также напрямую зависит от HTTP. Таким образом, оба состояния должны полностью представлять, как использовать HTTP для извлечения биржевых данных и/или совершения сделок.

Вот как может выглядеть реализация:

func analyze(ticker string, maxTradePrice float64) (bool, err) {
  resp, err := http.Get(
      "http://stock-service.com/currentprice/" + ticker
  )
  if err != nil {
  	// обработать ошибку
  }
  defer resp.Body.Close()
  body, err := ioutil.ReadAll(resp.Body)
  // ...
  currentPrice := parsePriceFromBody(body)
  var hasTraded bool
  var err error
  if currentPrice <= maximumTradePrice {
    err = doTrade(ticker, currentPrice)
    if err == nil {
      hasTraded = true
    }
  }
  return hasTraded, err
}

Здесь вызывающая сторона (analyze) имеет прямую жесткую зависимость от HTTP. Ей необходимо знать, как формулируются HTTP-запросы. Как делается их синтаксический разбор. Как обращаться с повторными попытками, таймаутами, аутентификацией, т.д. У нее тесное сцепление с http. Всякий раз при вызове analyze мы должны вызывать и библиотеку http.

Как нам здесь может помочь интерфейс? В контракте, предоставляемом интерфейсом, можно описать поведение, а не конкретную реализацию.

type StockExchange interface {
  CurrentPrice(ticker string) float64
}

Выше определяется концепция StockExchange. Здесь написано, что StockExchange поддерживает вызов единственной функции CurrentPrice. Эти три строки кажутся мне самым мощным архитектурным приемом из всех. Они помогают нам гораздо увереннее контролировать зависимости приложения. Обеспечивают тестирование. Обеспечивают расширяемость.

Внедрение зависимостей

Чтобы в полной мере понять ценность интерфейсов, нужно освоить прием, именуемый «внедрение зависимостей».

Внедрение зависимостей означает, что вызывающая сторона предоставляет нечто, необходимое вызываемой. Обычно это выглядит так: вызывающая сторона конфигурирует объект, а затем передает его вызываемой. Тогда вызываемая сторона абстрагируется от конфигурации и реализации. В данном случае присутствует известная опосредованность. Рассмотрим запрос к службе HTTP Rest. Для реализации клиента нам потребуется использовать HTTP-библиотеку, умеющую формулировать, отправлять и получать HTTP-запросы.

Если бы мы разместили HTTP-запрос за интерфейсом, то вызывающую сторону можно было бы открепить, и она была бы «не в курсе», что HTTP-запрос действительно состоялся.

Вызывающая сторона должна сделать лишь обобщенный вызов функции. Это может быть локальный вызов, удаленный вызов, HTTP-вызов, RPC-вызов, т.д. Вызывающая сторона не в курсе происходящего, и обычно это ее совершенно устраивает, до тех пор, пока она получает ожидаемые результаты. Ниже показано, как может выглядеть внедрение зависимостей в нашем методе analyze.

func analyze(se StockExchange, ticker string, maxTradePrice float64) (bool, error) {
  currentPrice := se.CurrentPrice(ticker)
  var hasTraded bool
  var err error
  if currentPrice <= maximumTradePrice {
    err = doTrade(ticker, currentPrice)
    if err == nil {
      hasTraded = true
    }
  }
  return hasTraded, err
}

Не перестаю удивляться тому, что здесь происходит. Мы полностью обратили наше дерево зависимостей и стали лучше контролировать всю программу. Более того, даже визуально вся реализация стала чище и понятнее. Мы четко видим, что метод анализа должен выбрать актуальную цену, проверить, подходит ли нам эта цена, и если так – совершить сделку.

Наиболее важно, что в данном случае мы открепляем вызывающую сторону от вызываемой. Поскольку вызывающая сторона и вся реализация отделены от вызываемой при помощи интерфейса, можно расширять интерфейс, создавая множество разных его реализаций. Интерфейсы позволяют создать множество разных конкретных реализаций, не требуя при этом менять код вызываемой стороны!



Состояние «get current price» в этой программе зависит только от интерфейса StockExchange. Этой реализации ничего не известно о том, как связываться с биржевой службой, как хранятся цены или как делаются запросы. Настоящее блаженное неведение. Причем двустороннее. Реализации HTTPStockExchange также ничего не известно об анализе. О контексте, в котором выполнятся анализ, когда он выполняется – поскольку вызовы происходят опосредованно.

Поскольку фрагменты программы (те, что зависят от интерфейсов) не требуется менять при изменении/добавлении/удалении конкретных реализаций, такой дизайн получается долговечным. Допустим, мы обнаружим, что StockService очень часто недоступен.

Чем вышеприведенный пример отличается от вызова функции? При применении вызова функции реализация также станет чище. Разница в том, что при вызове функции нам все равно придется прибегать к HTTP. Метод analyze просто будет делегировать задачу функции, которая должна вызывать http, а не станет вызывать http сам напрямую. Вся сила этого приема заключается в «инъекции», то есть, в том, что вызывающая сторона предоставляет интерфейс вызываемой. Именно так и происходит инверсия зависимости, где get prices зависит только от интерфейса, а не от реализации.

Множественные реализации «из коробки»

На данном этапе у нас есть функция analyze и интерфейс StockExchange, но мы фактически не можем сделать что-либо полезного. Просто объявили нашу программу. В настоящий момент вызвать ее невозможно, так как у нас еще нет ни одной конкретной реализации, которая соответствовала бы требованиям нашего интерфейса.

Основной акцент в следующей схеме сделан на состоянии «get current price» и на его зависимости от интерфейса StockExchange. Ниже показано, как сосуществуют две совершенно разные реализации, а get current price «не в курсе» этого. Вдобавок обе реализации не связаны друг с другом, каждая из них зависит только от интерфейса StockExchange.



Производство

Исходная реализация HTTP уже существует в первичной реализации analyze; нам остается лишь извлечь ее и инкапсулировать за конкретной реализацией интерфейса.

type HTTPStockExchange struct {}
func (se HTTPStockExchange) CurrentPrice(ticker string) float64 {
  resp, err := http.Get(
      "http://stock-service.com/currentprice/" + ticker
  )
  if err != nil {
  	// обработать ошибку
  }
  defer resp.Body.Close()
  body, err := ioutil.ReadAll(resp.Body)
  // ...
  return parsePriceFromBody(body)
}

Код, который мы ранее привязывали к функции analyze, теперь автономен и удовлетворяет интерфейсу StockExchange, то есть, теперь мы можем передать его analyze. Как вы помните из вышеприведенных схем, analyze больше не связан зависимостью с HTTP. При использовании интерфейса analyze «не представляет», что происходит за кулисами. Он лишь знает, что ему гарантированно будет передан объект, с которым он сможет вызвать CurrentPrice.

Также здесь мы пользуемся типичными достоинствами инкапсуляции. Прежде, когда http-запросы были завязаны на analyze, единственный способ коммуникации с биржей по http был опосредованным – через метод analyze. Да, мы могли инкапсулировать эти вызовы в функции и выполнять функцию независимо, однако интерфейсы вынуждают нас открепить вызывающую сторону от вызываемой. Теперь мы можем тестировать HTTPStockExchange независимо от вызывающей стороны. Это кардинальным образом сказывается на области применения наших тестов и на том, как мы понимаем провалы тестов и реагируем на них.

Тестирование

В имеющемся коде у нас есть структура HTTPStockService, позволяющая нам отдельно убедиться, что она может связываться с биржевой службой и разбирать получаемые от нее отклики. Но теперь давайте удостоверимся, что analyze может правильно обрабатывать и отклик от интерфейса StockExchange, причем, что эта операция надежна и воспроизводима.

currentPrice := se.CurrentPrice(ticker)
 if currentPrice <= maxTradePrice {
    err := doTrade(ticker, currentPrice)
  }

Мы МОГЛИ БЫ использовать реализацию с HTTP, но у нее было бы множество недостатков. Выполнение сетевых вызовов при модульном тестировании могло бы получаться медленным, особенно это касается внешних сервисов. Из-за задержек и нестабильного сетевого соединения тесты могли получиться ненадежными. Кроме того, если бы нам понадобились тесты с утверждением, что мы можем совершить сделку, и тесты с утверждением, что мы можем отфильтровать такие случаи, в которых сделку заключать НЕ СЛЕДУЕТ, то было бы сложно найти реальные производственные данные, которые надежно удовлетворяют двум этим условиям. Можно было бы выбрать maxTradePrice, искусственно сымитировав таким образом каждое из условий, например, при maxTradePrice := -100 сделка не должна совершаться, а maxTradePrice := 10000000 очевидно должна завершаться сделкой.

Но что произойдет, если на биржевом сервисе нам выделена некоторая квота? Либо если нам придется платить за доступ? Будем ли мы в самом деле (и должны) ли платить или тратить нашу квоту, когда речь идет всего лишь о модульных тестах? В идеале тесты нужно прогонять как можно чаще, так что они должны быть быстрыми, дешевыми и надежными. Думаю, из этого абзаца понятно, почему использовать версию с чистым HTTP нерационально с точки зрения тестирования!

Есть более оптимальный способ, и он связан с использованием интерфейсов!

Имея интерфейс, можно аккуратно изготовить реализацию StockExchange, которая позволит нам выполнять analyze быстро, безопасно и надежно.

type StubExchange struct {
   Price float64
}
func (se StubExchange) CurrentPrice(ticker string) float64 {
   return se.Price
}
func TestAnalyze_MakeTrade(t *testing.T) {
  se := StubExchange{Price: 10}
  maxTradePrice := 11
  traded, err := analyze(se, "TSLA", maxTradePrice)
  if err != nil {
     t.Errorf("expected err == nil received: %s", err)
  }
  if !traded {
    t.Error("expected traded == true")
  } 
}
func TestAnalyze_DontTrade(t *testing.T) {
  se := StubExchange{Price: 10}
  maxTradePrice := 9
  traded, err := analyze(se, "TSLA", maxTradePrice)
  // утверждение
}

Выше используется заглушка обменного сервиса, благодаря которой запускается интересующая нас ветка в analyze. Затем в каждом из тестов делаются утверждения, позволяющие убедиться, что analyze делает то, что надо. Хотя, это и тестовая программа, мой опыт подсказывает, что компоненты/архитектура, где интерфейсы используются примерно таким образом, именно таким образом проверяются на прочность и в боевом коде!!! Благодаря интерфейсам, мы можем использовать контролируемый в памяти StockExchange, что обеспечивает надежные, легко конфигурируемые, простые для понимания, воспроизводимые, молниеносные тесты!!!

Открепление — конфигурация вызывающей стороны

Теперь, обсудив, как применять интерфейсы для открепления вызывающей стороны от вызываемой, и как делать множественные реализации, мы до сих пор не затронули критически важный аспект. Как сконфигурировать и предоставить специфическую реализацию в строго определенное время? Можно напрямую вызвать функцию analyze, но что же делать в продакшен-конфигурации?

Здесь-то нам и пригодится внедрение зависимостей.

func main() {
   var ticker = flag.String("ticker", "", "stock ticker symbol to trade for")
   var maxTradePrice = flag.Float64("maxtradeprice", "", "max price to pay for a share of the ticker symbol."
   se := HTTPStockExchange{}
  analyze(se, *ticker, *maxTradePrice)
}

Примерно как и в нашем тестовом случае, специфическая конкретная реализация StockExchange, которая будет использоваться с analyze, конфигурируется вызывающей стороной вне analyze. Затем она передается (внедряется) в analyze. Таким образом обеспечивается, что analyze НИЧЕГО не известно о том, как сконфигурирована HTTPStockExchange. Пожалуй, мы хотели бы предоставить http-домен, которым собираемся пользоваться, в виде флага командной строки, и тогда analyze не придется меняться. Либо, что делать, если нам понадобилось бы обеспечить ту или иную аутентификацию или токен для доступа к HTTPStockExchange, который будет извлекаться из окружения? Опять же, analyze не должен при этом меняться.

Конфигурация происходит на уровне за пределами analyze, тем самым полностью освобождая analyze от необходимости конфигурировать собственные зависимости. Так достигается строгое разделение обязанностей.



Откладывание решений

Возможно, вышеприведенных примеров вполне достаточно, но ведь у интерфейсов и внедрения зависимостей еще много других достоинств. Интерфейсы позволяют откладывать на потом решения о конкретных реализациях. Хотя, решения требуют от нас решать, какое поведение мы будем поддерживать, они все-таки позволяют принимать решения о конкретных реализациях попозже. Допустим, мы знали, что хотим совершать автоматизированные сделки, но еще не были уверены, какой поставщик котировок будем использовать. С похожим классом решений постоянно приходится иметь дело при работе с хранилищами данных. Чем должна пользоваться наша программа: mysql, postgres, redis, файловой системой, cassandra? В конечном итоге, все это – детали реализации, а интерфейсы позволяют нам откладывать окончательные решения по данным вопросам. Они позволяют развивать бизнес-логику наших программ, а к специфическим технологическим решениям переходить в последний момент!

Притом, что этот прием сам по себе оставляет на множество возможностей, на уровне планирования проекта происходит нечто магическое. Представьте себе, что будет, если мы добавим к обменному интерфейсу и еще одну зависимость.



Здесь мы перекомпонуем нашу архитектуру в виде направленного ациклического графа, так, что как только мы согласуем детали обменного интерфейса, мы сможем КОНКУРЕНТНО продолжать работу с конвейером, воспользовавшись HTTPStockExchange. Мы создали ситуацию, в которой дополнение новой персоны в проект помогает нам двигаться быстрее. Подправив таким образом нашу архитектуру, мы лучше видим, где, когда и как долго мы сможем задействовать на проекте дополнительных людей ради ускорения доставки всего проекта. Кроме того, поскольку связь между нашими интерфейсами слабая, обычно легко втянуться в работу, начиная с интерфейсов реализации. Можно разрабатывать, тестировать и проверять HTTPStockExchange совершенно независимо от нашей программы!

Анализ архитектурных зависимостей и планирование в соответствии с этими зависимостями может радикально ускорять проекты. Пользуясь именно этой техникой мне удавалось очень быстро завершать проекты, на которые отводилось по несколько месяцев.

Задел на будущее

Теперь уже должно быть понятнее, как интерфейсы и внедрение зависимостей обеспечивают долговечность спроектированной программы. Допустим, мы изменим наш поставщик котировок, либо начнем потоковую передачу квот и их сохранение в режиме реального времени; есть и сколько угодно других возможностей. Метод analyze в нынешнем виде будет поддерживать любую реализацию, пригодную для объединения с интерфейсом StockExchange.

se.CurrentPrice(ticker)

Таким образом во множестве случаев можно обойтись без изменений. Не во всех, но в тех предсказуемых случаях, с которыми мы можем столкнуться. Мы не только застрахованы от необходимости менять код analyze и перепроверять его ключевой функционал, но и можем с легкостью предлагать новые реализации или переключаться между поставщиками. Также мы можем гладко расширять или обновлять уже имеющиеся у нас конкретные реализации без необходимости менять или перепроверять analyze!

Надеюсь, вышеприведенные примеры убедительно демонстрируют, как ослабление связи между сущностями в программе благодаря использованию интерфейсов полностью переориентирует зависимости и отделяет вызывающую сторону от вызываемой. Благодаря такому откреплению, программа не зависит от конкретной реализации, зато сильно зависит от специфического поведения. Такое поведение может обеспечиваться самыми разнообразными реализациями. Этот важнейший принцип проектирования также называется утиная типизация.

Концепция интерфейсов и зависимость от поведения, а не от реализации настолько мощная, что я расцениваю интерфейсы в качестве языкового примитива – да, это довольно радикально. Надеюсь, что рассмотренные выше примеры получились достаточно убедительными, и вы согласитесь, что интерфейсы и внедрение зависимостей нужно использовать с самого начала проекта. Почти во всех проектах, над которыми мне доводилось работать, требовались не одна, а как минимум две реализации: для продакшена и для тестирования.

Издательский дом «Питер»

221,00

Компания

Поделиться публикацией

Похожие публикации

Комментарии 3
    0
    Если читателям интересно посмотреть на инструменты для DI в Go поближе, вот свежий пост на эту тему.
      0
      Спасибо за ссылку :). рассмотрение библиотеки — это всегда интересно, но мы искали статью более обзорного плана. Тема Go вообще требует гораздо более пристального внимания, работаем над этим.
      0

      Для того, чтобы хорошо проектировать интерфейсы, надо мыслить действиями, а не категориями. Это бывает непросто, и это важный момент при обдумывании реализации какой-нибудь задачи. А еще интерфейсами можно разрезать функционал между разными программистами, и тогда это уже инструмент архитектора. В Go еще есть интеллектуальные напряги в части параллелизма, когда надо поддержать жизненный цикл сущности, с которой работают несколько воркеров. Эта тема уже слабо реализуется интерфейсами, по крайней мере, я пока не придумал, как красиво интерфейсом отцепить от структуры стандартный набор из RWMutex, WaitGroup, done chan bool и sync.Once.Do(close).

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое