Еще в 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)

Вот и все!