Объектно-ориентированное программирование (ООП), парадигма, возникшая более полувека назад (я не шучу — первым языком программирования, считающимся объектно-ориентированным, была Simula 67 — угадайте, в каком году она была создана?), в настоящее время является фундаментальной подход к созданию масштабируемого и поддерживаемого программного обеспечения. Несмотря на то, что с момента его изобретения прошло так много времени, большая часть его применимости по-прежнему зависит от опыта и интуиции разработчика (темной и непонятной для джуниора), а не от каких-то твердыхоснов и правил. К счастью, за прошедшие годы некоторые умные люди выяснили и описали несколько шаблонов — почти «готовых к использованию» рецептов и советов о том, как создавать действительно объектно-ориентированные программы. В этой статье я покажу одно из этих правил, а именно Принцип инверсии зависимостей (DIP), я опишу Внедрение зависимостей(DI)—техника применения этого правила на практике, и я расскажу обо всем этом в контексте тестирования программного обеспечения. Пойдем!
Принцип инверсии зависимостей — один из принципов SOLID, разработанный и продвигаемый дядей Бобом — Робертом С. Мартином, одним из самых известных в мире инженеров-программистов, автором многих книг по ООП. и программная архитектура вообще. Но прежде чем мы начнем с его определения, я бы хотел, чтобы мы взглянули на следующий фрагмент псевдокода:
Допустим, ваш босс попросил вас добавить некоторые тесты для ведения журнала, сделанные этим классом, — мы хотим знать, в правильном ли порядке записываются правильные сообщения журнала. После быстрого осмотра вы узнали, что функция `log` из модуля `utils` выполняет запись в некоторый файл журнала на основе конфигурации, извлеченной из файла конфигурации. Теперь проблема — как добавить тесты на запись в файл? И как обращаться с этим файлом конфигурации во время тестов?
Честно говоря, если нам нужно заботиться о внешних зависимостях — файлах, базах данных, серверах и т. д. — во время юнит-тестов — это уже симптом плохого дизайна. Почему мы должны помнить о таких низкоуровневых деталях при тестировании высокоуровневой бизнес-логики? Если нам нужно, это означает, что в нашем бизнесе логика высокого уровня зависит от деталей низкого уровня. И это именно та ситуация, в которой DIP запрещает нам находиться. Вместо этого мы должны стремиться к ситуации, в которой:
- Модули высокого уровня не зависят от модулей низкого уровня. Оба зависят от абстракций.
или, другими словами:
- Абстракции не зависят от деталей. Детали зависят от абстракций.
На данный момент весь наш код усыпан вызовами модуля логирования низкого уровня — он зависит от него.В этом настоящая проблема, и это делает наше тестирование таким неблагодарным и неприятная задача. Реальное решение (к сожалению, так редко выбираемое разработчиками, желающими выполнить свои задачи JIRA как можно быстрее) — сначала исправить это.
Итак, как нам сделать это правильно? Во-первых, чтобы следовать DIP, нам нужно определить абстракцию, от которой будут зависеть наши модули. В нашем случае эта абстракция будет очень простым интерфейсом:
class Logger: def log(self, msg): raise NotImplementedError()
Простой регистратор, верно? Это все, что нам нужно. Давайте определим его в отдельном файле с именем loggers.py:
Эта базовая реализация просто делегирует вызовы `log` функции `utils.log`, так что на данный момент поведение остается прежним. Затем мы должны реорганизовать наш бизнес-класс, чтобы использовать этот новый интерфейс. Как мы это делаем? Мы должны создать экземпляр регистратора и заставить SomeBusinessClass использовать его для ведения журнала. Как насчет того, чтобы SomeBusinessClass содержал ссылку на регистратор (в качестве его атрибута) и использовал его метод log? Нравится:
self._logger.log("Some logging") # instead of log("Some logging")
Поскольку SomeBusinessClass знает только интерфейс объекта, который он использует, и ничего не знает о деталях его реализации, теперь этот код действительно полиморфен и объектно-ориентирован — мы полагаемся на абстрактный интерфейс, а не на вызов конкретной функции. Мы изменили направление зависимостей — теперь и бизнес-класс (поскольку он использует свой интерфейс), и модуль ведения журнала (поскольку он должен реализовать свой интерфейс) зависят от абстракции `Logger`. Прямая зависимость от низкоуровневых модулей теперь (к счастью) устранена.
Как мы увидим через минуту, также можно будет заменить этот интерфейс совершенно новой реализацией, которая не будет писать в файл, но будет хранить весь журнал в памяти, что позволит писать модульные тесты без необходимости возиться с файлами, а просто делать утверждения в этом журнале в памяти. Прохладный.
Есть еще одна небольшая проблема. Как мы передаем (или скорее «внедряем») экземпляр `Logger` в `SomeBusinessClass`? Ну, это довольно просто — мы можем просто добавить еще один параметр конструктора в `SomeBusinessClass`, и все (конечно, не забудьте заменить все прямые вызовы `log` вызовом `Logger.log`):
ВНИМАНИЕ: у вас может возникнуть соблазн создать экземпляр `Logger` внутри конструктора – это НЕПРАВИЛЬНО, так как это не позволит нам передать другую реализацию `Logger` – фактически, это сведет на нет все преимущества нашего рефакторинга. !
То, что мы только что сделали выше, — это известная внедрение зависимостей. Несмотря на пугающее название, я нахожу его довольно простым, а вам? :) Все дело в передаче зависимости в качестве аргумента. И просто для записи, мы использовали внедрение зависимости в конструкторе, но есть еще два способа добиться этого результата (каждый из них имеет свое приложение):
- внедрение через сеттер — это позволяет нам изменять реализацию зависимости во время жизни объекта (требуется определение метода, называемого, например, `set_logger`)
- внедрение при вызове метода — это позволяет нам использовать разные реализации каждый раз, когда мы вызываем какой-либо метод, но за счет передачи дополнительного аргумента каждый раз (например, `some_business_method(arg, logger)`)
Конечно, проблема все еще остается — мы должны найти каждое место, где мы создаем экземпляр этого класса, — и изменить вызов конструктора, чтобы получить регистратор. Однако простой обходной путь позволит нам обойтись и без этого — мы добавим значение по умолчанию в параметр конструктора:
def __init__(self, logger=SimpleLogger()): # same as before...
Это сделает наш рефакторинг прозрачным для клиентов SomeBusinessClass — они не узнают, что что-то изменилось.
На данный момент все должно работать как прежде (поэтому, возможно, самое время запустить тесты для этого класса, если они у нас есть).
Теперь мы можем написать упомянутую реализацию регистратора в памяти:
И в итоге мы можем написать сам тест, передав эту реализацию тестируемому классу:
который должен пройти, убедившись, что ведение журнала (с точки зрения его логики) выполняется правильно. Конечно — этот тест пройдет только до тех пор, пока никто не коснется логирования (даже просто переведет сообщение в верхний регистр). Тестирование в реальном мире потребует больше усилий и сообразительности — здесь это просто пример.
И просто чтобы успокоить тех, кто не очень уверен в тестировании такой тривиальной «фичи» как ведение журнала — этот подход отлично работает с любой зависимостью, такой как база данных, внешний сервис, файловая система и т. д. Используя его, у нас всегда есть возможность внедрить поддельная реализация и, например, в случае базы данных мы можем вернуть из этой поддельной базы данных все, что захотим — например, некоторые фиксированные данные.
Внедрение зависимостей также используется многими фреймворками и библиотеками — например, Angular, где, определяя параметр конструктора определенного типа, мы объявляем зависимость и заставляем систему управления зависимостями Angular автоматически предоставлять экземпляр, или pytest — где объявляется аргумент функции с определенным именем и регистрируя это имя в pytest (это называется «fixtures»), мы управляем зависимостями для тестов (что, на мой взгляд, намного лучше, чем старомодный стиль установки/разборки xUnit).
Все эти рефакторинги привели к большей развязке кода и, косвенно, к лучшему дизайну нашей системы. Теперь изменения, внесенные в модуль логгеров, не влияют на SomeBusinessClass. Мы могли бы даже создать реализацию `Logger`, которая, например. отправляет журналы через интернет-соединение в какую-либо службу аналитики или выполняет некоторый анализ самостоятельно. До тех пор, пока это удается сделать с помощью интерфейса «Логгер» с одним методом — это не потребует никаких изменений в бизнес-классе. Мы бы сказали, что он «открыт для изменений» в модуле ведения журнала (что, в свою очередь, отсылает к другому принципу SOLID — Принципу открытия-закрытия, который настаивает на том, что программное обеспечение должно быть «открыто для расширений, но закрыто для модификаций»). , Это действительно так, потому что нам нужно только добавить новый код, чтобы добавить новую функцию, а не изменить существующую). Единственное место, которое нам нужно будет обновить, когда будет написан новый конкретный класс, реализующий интерфейс Logger, — это место, где мы создаем экземпляр регистратора. Если логика его создания станет больше и сложнее, мы можем даже вынести его в отдельный метод — скажем, `make_logger`, и вообще не вызывать конструктор напрямую в клиентском коде. Затем, если добавляется новая реализация, необходимо изменить только этот фабричный метод.
И последнее, что не менее важно, все эти изменения были внесены потому, что они требовались для тестов — чтобы их можно было запускать аккуратно, красиво, в отдельной, чистой среде тестирования. Тесты способствуют хорошему дизайну. Тесты имеют двойную ценность — они обеспечивают обратную связь о том, как система работает сегодня, и, отделяя компоненты друг от друга, они облегчают работу системы завтра, когда требования изменятся. И требования изменятся. Единственное, что не меняется в разработке программного обеспечения, это то, что требования всегда меняются;)
Надеюсь, вам понравилась статья, и я надеюсь, что вы найдете эти техники полезными в своих проектах. Похлопайте, если она вам понравилась, и передайте ее, если вы знаете кого-то, кому было бы полезно ее прочитать. Удачи и счастливого кодирования!