Еще в 90-х Боб Мартин придумал особенно простой, но полезный принцип разделения программных компонентов:
- Модули высокого уровня не должны зависеть от модулей низкого уровня. Оба должны зависеть от абстракций.
- Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
В среде разработки программного обеспечения SOLID это стало известно как принцип инверсии зависимостей . Внедрение зависимостей - это шаблон проектирования, который поддерживает проектирование программных компонентов, которые следуют этому принципу.
В этой статье мы рассмотрим несколько вариантов внедрения зависимостей в Python, среди которых - полезный фреймворк для Python 3 с поддержкой PEP484. В качестве рабочего примера мы напишем программу, которая удаленно управляет роботом, охраняющим забор, через веб-API.
Для этого мы напишем небольшой клиент API, использующий библиотеку requests
:
import requests class RobotApi: url = 'https://robot-api.com' def send_command(self, command, data): formatted_url = f'{self.url}/{command}' requests.post(formatted_url, data=data)
Для управления роботом мы пишем класс общего назначения для операций перемещения высокого уровня:
class RobotControls: def __init__(self, api): self.api = api def move_west(self): self.api.send_command('move_west', data={'meters': 1}) def move_east(self): self.api.send_command('move_east', data={'meters': 1}) def move_north(self): self.api.send_command('move_north', data={'meters': 1}) def move_south(self): self.api.send_command('move_south', data={'meters': 1})
В этом примере класс RobotControls
неявно зависит от абстрактного интерфейса, который определяет метод send_command
. Поскольку RobotControls
не использует импорт, а вместо этого получает эту зависимость через свой конструктор, мы довольно грандиозно говорим, что зависимость является внедренной.
Такой способ внедрения зависимостей имеет ряд преимуществ:
- Класс
RobotControls
становится настраиваемым и, следовательно, более пригодным для повторного использования. Изменяяapi
экземпляр, введенный через__init__
, мы можем изменить общее поведениеRobotControls
- Класс
RobotControls
становится проще для модульного тестирования, особенно еслиapi
или какая-либо изapi
зависимостей использует операции ввода-вывода, потому что мы можем легко заменить эту зависимость версией, которая имитирует или блокирует ввод-вывод. - Использование внедрения зависимостей побуждает нас уделять больше внимания тому, какие обязанности принадлежат
RobotControls
и какие обязанности принадлежат его зависимостям, тем самым повышая общую согласованность и инкапсуляцию модулей в нашей программе.
Главный недостаток внедрения зависимостей через __init__
состоит в том, что он требует от нас создания объекта с send_command
методом, прежде чем мы сможем создать экземпляр RobotControls
. В нашем примере это может не показаться большой проблемой, но рассмотрим случай, когда мы хотим внедрить RobotControls
в новый класс, и мы хотим внедрить этот класс как зависимость в другой класс и так далее, и так далее. Возьмем, к примеру, наш класс FenceGuardingRobot
:
class FenceGuardingRobot: fence_length = 20 def __init__(self, robot_controls): self.robot_controls = robot_controls def guard(self): for _ in range(self.fence_length): self.robot_controls.move_north() for _ in range(self.fence_length): self.robot_controls.move_south()
Чтобы создать экземпляр этого класса, нам теперь нужно написать:
fence_guarding_robot = FenceGuardingRobot(RobotControls(RobotApi()))
Раздражение, связанное с созданием такого графа зависимостей вручную, является одной из причин, по которой добрые намерения повсеместно использовать внедрение зависимостей во многих проектах часто умирают по мере роста базы кода. Вы, конечно, можете использовать значения параметров по умолчанию, чтобы уменьшить эту проблему, но тогда вы рискуете запрограммировать конкреции, а не абстракции.
Один из способов избежать этой боли - использовать шаблон, построенный вокруг ключевого слова super
в Python. Ключевое слово super
в Python имеет несколько иное значение, чем во многих других объектно-ориентированных языках. При вызове super
Python вычисляет так называемую линеаризацию базовых классов вызывающего класса, что просто означает, что Python определяет, в каком порядке он должен искать имена в базовых классах. В Python это называется порядком разрешения методов . Мы можем изменить наш пример, чтобы использовать внедрение зависимостей через super
следующим образом:
import requests class RobotApi: url = 'https://robot-api.com' def send_command(self, command, data): formatted_url = f'{self.url}/{command}' requests.post(formatted_url, data=data) class RobotControls(RobotApi): def move_west(self): super().send_command('move_west', data={'meters': 1}) def move_east(self): super().send_command('move_east', data={'meters': 1}) def move_north(self): super().send_command('move_north', data={'meters': 1}) def move_south(self): super().send_command('move_south', data={'meters': 1}) class FenceGuardingRobot(RobotControls): fence_length = 20 def guard(self): for _ in range(self.fence_length): super().move_north() for _ in range(self.fence_length): super().move_south()
Вы можете увидеть порядок разрешения методов типа с помощью встроенного метода help
:
help(FenceGuardingRobot)
Это выводит:
class FenceGuardingRobot(RobotControls) | Method resolution order: | FenceGuardingRobot | RobotControls | RobotApi | builtins.object | | Methods defined here: | | guard(self) | | ---------------------------------------------------------------------- | Data and other attributes defined here: | | fence_length = 20 | | ---------------------------------------------------------------------- | Methods inherited from RobotControls: | | move_east(self) | | move_north(self) | | move_south(self) | | move_west(self) | | ---------------------------------------------------------------------- | Methods inherited from RobotApi: | | send_command(self, command, data) | | ---------------------------------------------------------------------- | Data descriptors inherited from RobotApi: | | __dict__ | dictionary for instance variables (if defined) | | __weakref__ | list of weak references to the object (if defined) | | ---------------------------------------------------------------------- | Data and other attributes inherited from RobotApi: | | url = 'https://robot-api.com'
В разделе Method resolution order
мы видим, что при поиске имен с помощью super
Python будет искать имя сначала в FenceGuardingRobot
, затем в RobotControls
, затем в RobotApi
, затем в object
.
Теперь, чтобы создать экземпляр FenceGuardRobot
, все, что нам нужно сделать, это написать FenceGuardRobot()
. Если мы хотим заменить зависимость для FenceGuardRobot
, например, чтобы имитировать класс RobotApi
, все, что нам нужно сделать, это определить новый тип, который добавляет новый метод send_command
ранее в линеаризации:
from unittest.mock import Mock class MockRobotApi(RobotApi): send_command = Mock() class MockedFenceGuardingRobot(FenceGuardingRobot, MockRobotApi): pass
Если мы проверим порядок разрешения методов для MockedFenceGuardingRobot
, мы увидим, что MockRobotApi
предшествует RobotApi
:
help(MockedFenceGuardingRobot)
выходы:
class MockedFenceGuardingRobot(FenceGuardingRobot, MockRobotApi) | Method resolution order: | MockedFenceGuardingRobot | FenceGuardingRobot | RobotControls | MockRobotApi | RobotApi | builtins.object ...
Обратите внимание, что MockRobotApi
должен наследовать от RobotApi
, чтобы разрешение метода было в этом порядке. Это следствие алгоритма, используемого python для построения линеаризации.
Основное преимущество этого шаблона, использующего super
для внедрения зависимостей по сравнению с простым подходом, при котором зависимости вводятся через __init__
, заключается в том, что нам не нужно создавать граф зависимостей вручную. Все соединения обрабатываются семантикой super
.
У этого подхода есть несколько недостатков. Прежде всего, становится довольно непонятно, какие имена предоставляются какими зависимостями. В нашем примере это очевидно, потому что все классы имеют ровно одну зависимость, но когда у вас более одной зависимости для каждого класса, все может стать довольно неопределенным. Например, если бы FenceGuardingRobot
имел более одной зависимости, было бы невозможно определить, какая часть класса высмеялась MockedFenceGuardingRobot
, просто взглянув на определения.
Во-вторых, все классы, участвующие в этом шаблоне, должны быть предназначены для множественного наследования. В частности, если класс принимает аргументы через __init__
, он должен также принять *args
и **kwargs
и вызвать super().__init__(*args, **kwargs)
, даже если этот класс ни от чего не наследуется, поскольку ваш внедренный класс может не быть последним классом в линеаризации:
class A: def __init__(self, a, *args, **kwargs): self.a = a super().__init__(*args, **kwargs) class B: def __init__(self, b): self.b = b super().__init__(*args, **kwargs) class C(A, B): pass
Если вам нужно внедрить класс, который не предназначен для множественного наследования таким образом, вам нужно написать для него класс адаптера. См. Пример в исходной статье.
Наконец, вы больше не кодируете абстракции, а скорее против конкреций. Вы можете использовать модуль abc
для написания абстрактных классов в Python, но в зависимости от того, какой инструмент вы используете для проверки нереализованных членов, вам может потребоваться повторно объявить абстрактные члены ваших зависимостей как абстрактные в зависимом классе.
В качестве третьего варианта вы можете использовать библиотеку. Существует ряд возможностей, но я представлю ту, с которой я наиболее знаком: serum
(полное раскрытие: я автор).
serum
пытается извлечь лучшее из обоих предыдущих подходов к внедрению зависимостей: он заботится обо всех связях, поэтому вам не нужно вручную создавать граф зависимостей самостоятельно, но делает это с использованием композиции, а не наследования.
Мы можем переписать наш пример для использования serum
следующим образом:
from serum import inject, Component, Environment class RobotApi(Component): url = 'https://robot-api.com' def send_command(self, command, data): formatted_url = f'{self.url}/{command}' requests.post(formatted_url, data=data) class RobotControls(Component): api = inject(RobotApi) def move_west(self): self.api.send_command('move_west', data={'meters': 1}) def move_east(self): self.api.send_command('move_east', data={'meters': 1}) def move_north(self): self.api.send_command('move_north', data={'meters': 1}) def move_south(self): self.api.send_command('move_south', data={'meters': 1}) class FenceGuardingRobot: robot_controls = inject(RobotControls) fence_length = 20 def guard(self): for _ in range(self.fence_length): self.robot_controls.move_north() for _ in range(self.fence_length): self.robot_controls.move_south()
Чтобы высмеять RobotApi
, мы могли бы сделать:
class MockRobotApi(RobotApi): send_command = Mock() with Environment(MockRobotApi): assert isinstance( FenceGuardingRobot().robot_controls.robot_api, MockRobotApi )
Или даже
from serum import mock from unittest.mock import MagicMock with Environment(): mock(RobotApi) assert isinstance( FenceGuardingRobot().robot_controls.robot_api, MagicMock )
Основным недостатком использования serum
вместо такой языковой функции, как super
для внедрения зависимостей, является то, что внедряемые классы должны наследовать от serum.Component
, что в первую очередь добавляет ограничение, заключающееся в том, что метод __init__
класса может принимать только параметр self
. Это необходимо, чтобы сделать возможным механизм ленивого внедрения зависимостей, используемый serum
.
Вы можете обойти это, вводя зависимости по именам строк, а не по типам, но это отключает некоторые связанные с PEP 484 функции serum
:
from serum import Environment, inject class NotAComponent: pass instance = NotAComponent() with Environment(dependency=instance): assert inject('dependency') is instance
На этом мы завершаем обзор механизмов внедрения зависимостей в Python. Подводя итог, вы можете использовать __init__
, но это часто приводит к раздражающему коду для подключения вашего приложения. Вы можете использовать super
, но это часто приводит к косвенным зависимостям в том смысле, что трудно сказать, какие зависимости предоставляют какие имена. Наконец, вы можете использовать такую библиотеку, как serum
, которая сочетает в себе лучшее из обоих миров. Дополнительную информацию, отслеживание проблем и запросы на вытягивание для serum
см. На странице GitHub.
Обновление 18.04.18
Я только что выпустил serum
версию 4.0.0. Этот выпуск призван сделать фреймворк менее инвазивным в клиентском коде. В этой версии вы можете определять свои зависимые классы и зависимости, используя синтаксис аннотации, который является чистым Python:
class RobotApi: ... class FenceGuardingRobot: api: RobotAPi ...
А затем используйте декораторы, чтобы начать использовать serum
:
from serum import dependency, inject @dependency class RobotApi: ... @inject class FenceGuardingRobot: api: RobotApi assert isinstance(FenceGuardingRobot().api, RobotApi)
Вот и все!