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

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

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

К концу этой статьи вы должны быть в состоянии объяснить, что такое внедрение зависимостей, и реализовать его на Python с помощью Test-Driven Development (TDD).

Содержимое

Что такое внедрение зависимостей?

В программной инженерии Внедрение зависимостей - это метод, при котором объект получает другие объекты, от которых он зависит.

  1. Это было введено для управления сложностью кодовой базы пользователя.
  2. Это помогает упростить тестирование, расширение кода и техническое обслуживание.
  3. Большинство языков, которые допускают передачу объектов и функций в качестве параметров, поддерживают это. Однако вы больше узнаете о внедрении зависимостей в Java и C#, поскольку это сложно реализовать. С другой стороны, благодаря динамической типизации в Python, а также системе duck typing, ее легко реализовать и, следовательно, она менее заметна. Django, Django REST Framework и FastAPI используют внедрение зависимостей.

Преимущества:

  1. Методы проще тестировать
  2. Зависимости проще имитировать
  3. Тесты не нужно менять каждый раз, когда мы расширяем наше приложение
  4. Расширять приложение проще
  5. Это упрощает обслуживание приложения

Для получения дополнительной информации обратитесь к статье Мартина Фаулера "Формы внедрения зависимостей".

Чтобы увидеть это в действии, давайте рассмотрим несколько примеров из реальной жизни.

Отображение исторических данных о погоде

Сценарий:

  1. Вы решили создать приложение для построения графиков на основе исторических данных о погоде.
  2. Вы загрузили Данные о температуре воздуха в Лондоне за 2009 год по часам.
  3. Ваша цель - построить график этих данных, чтобы увидеть, как менялась температура с течением времени.

Основная идея

Сначала создайте (и активируйте) виртуальную среду. Затем установите pytest и Matplotlib:

(venv)$ pip install pytest matplotlib

Кажется разумным начать с класса с двумя методами:

  1. read - считывание данных из CSV
  2. draw - построение графика

Чтение данных из формата CSV

Поскольку нам нужно считывать исторические данные о погоде из CSV-файла, метод read должен соответствовать следующим критериям:

  • ЗАДАН App класс
  • КОГДА вызывается метод read с именем файла CSV
  • ЗАТЕМ данные из CSV должны быть возвращены в словаре, где ключи являются строками даты и времени в формате ISO 8601 ('%Y-%m-%dT%H:%M:%S.%f'), а значения измеряются ли температуры в этот момент

Создайте файл с именем test_app.py:

import datetime
from pathlib import Path

from app import App


BASE_DIR = Path(__file__).resolve(strict=True).parent


def test_read():
    app = App()
    for key, value in app.read(file_name=Path(BASE_DIR).joinpath('london.csv')).items():
        assert datetime.datetime.fromisoformat(key)
        assert value - 0 == value

Итак, этот тест проверяет, что:

  1. каждый ключ представляет собой строку даты и времени в формате ISO 8601 (с использованием функции fromisoformat из пакета datetime)
  2. каждое значение является числом (используя свойство чисел x - 0 = x)

Метод fromisoformat из пакета datetime был добавлен в Python 3.7. Обратитесь к официальной документации по Python для получения дополнительной информации.

Запустите тест, чтобы убедиться в его сбое:

(venv)$ python -m pytest .

Вы должны увидеть:

E   ModuleNotFoundError: No module named 'app'

Теперь для реализации метода read, чтобы тест прошел успешно, добавьте новый файл с именем app.py:

import csv
import datetime
from pathlib import Path


BASE_DIR = Path(__file__).resolve(strict=True).parent


class App:

    def read(self, file_name):
        temperatures_by_hour = {}
        with open(Path(BASE_DIR).joinpath(file_name), 'r') as file:
            reader = csv.reader(file)
            next(reader)  # Skip header row.
            for row in reader:
                hour = datetime.datetime.strptime(row[0], '%d/%m/%Y %H:%M').isoformat()
                temperature = float(row[2])
                temperatures_by_hour[hour] = temperature

        return temperatures_by_hour

Здесь мы добавили класс App с методом read, который принимает имя файла в качестве параметра. После открытия и считывания содержимого CSV-файла соответствующие ключи (дата) и значения (температура) добавляются в словарь, который в конечном итоге возвращается.

Предполагая, что вы загрузили данные о погоде в формате london.csv, теперь тест должен пройти:

(venv)$ python -m pytest .

================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael.herman/repos/testdriven/dependency-injection-python/app
collected 1 item

test_app.py .                                                                 [100%]

================================= 1 passed in 0.11s =====================================

Построение графика

Далее, метод draw должен соответствовать следующим критериям:

  • ЗАДАН App класс
  • КОГДА вызывается метод draw со словарем, в котором ключи являются строками даты и времени в формате ISO 8601 ('%Y-%m-%dT%H:%M:%S.%f') и значения - это температуры, измеренные в данный момент
  • ЗАТЕМ данные должны быть перенесены на линейный график с указанием времени по оси X и температуры по оси Y

Добавьте тест для этого в test_app.py:

def test_draw(monkeypatch):
    plot_date_mock = MagicMock()
    show_mock = MagicMock()
    monkeypatch.setattr(matplotlib.pyplot, 'plot_date', plot_date_mock)
    monkeypatch.setattr(matplotlib.pyplot, 'show', show_mock)

    app = App()
    hour = datetime.datetime.now().isoformat()
    temperature = 14.52
    app.draw({hour: temperature})

    _, called_temperatures = plot_date_mock.call_args[0]
    assert called_temperatures == [temperature]  # check that plot_date was called with temperatures as second arg
    show_mock.assert_called()  # check that show is called

Обновите импорт следующим образом:

import datetime
from pathlib import Path
from unittest.mock import MagicMock

import matplotlib.pyplot

from app import App

Поскольку мы не хотим показывать фактические графики во время тестовых запусков, мы использовали monkeypatch, чтобы имитировать функцию plot_date из matplotlib. Затем тестируемый метод вызывается с одной температурой. В конце мы проверили, что plot_date был вызван правильно (по осям X и Y) и что был вызван show.

Вы можете прочитать больше о исправлении ошибок с помощью pytest здесь и больше о насмешках здесь.

Перейдем к реализации метода:

  1. Он принимает параметр temperatures_by_hour, который должен быть словарем той же структуры, что и выходные данные метода read.
  2. Он должен преобразовать этот словарь в два вектора, которые можно использовать на графике: даты и температуры.
  3. Даты следует преобразовать в числа с помощью matplotlib.dates.date2num, чтобы их можно было использовать на графике.
def draw(self, temperatures_by_hour):
    dates = []
    temperatures = []

    for date, temperature in temperatures_by_hour.items():
        dates.append(datetime.datetime.fromisoformat(date))
        temperatures.append(temperature)

    dates = matplotlib.dates.date2num(dates)
    matplotlib.pyplot.plot_date(dates, temperatures, linestyle='-')
    matplotlib.pyplot.show()

Импорт:

import csv
import datetime
from pathlib import Path

import matplotlib.dates
import matplotlib.pyplot

Теперь тесты должны пройти:

(venv)$ python -m pytest .

================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/python-dependency-injection
collected 2 items

test_app.py ..                                                                [100%]

================================= 2 passed in 0.37s =====================================

app.py:

import csv
import datetime
from pathlib import Path

import matplotlib.dates
import matplotlib.pyplot


BASE_DIR = Path(__file__).resolve(strict=True).parent


class App:

    def read(self, file_name):
        temperatures_by_hour = {}
        with open(Path(BASE_DIR).joinpath(file_name), 'r') as file:
            reader = csv.reader(file)
            next(reader)  # Skip header row.
            for row in reader:
                hour = datetime.datetime.strptime(row[0], '%d/%m/%Y %H:%M').isoformat()
                temperature = float(row[2])
                temperatures_by_hour[hour] = temperature

        return temperatures_by_hour

    def draw(self, temperatures_by_hour):
        dates = []
        temperatures = []

        for date, temperature in temperatures_by_hour.items():
            dates.append(datetime.datetime.fromisoformat(date))
            temperatures.append(temperature)

        dates = matplotlib.dates.date2num(dates)
        matplotlib.pyplot.plot_date(dates, temperatures, linestyle='-')
        matplotlib.pyplot.show()

test_app.py:

import datetime
from pathlib import Path
from unittest.mock import MagicMock

import matplotlib.pyplot

from app import App


BASE_DIR = Path(__file__).resolve(strict=True).parent


def test_read():
    app = App()
    for key, value in app.read(file_name=Path(BASE_DIR).joinpath('london.csv')).items():
        assert datetime.datetime.fromisoformat(key)
        assert value - 0 == value


def test_draw(monkeypatch):
    plot_date_mock = MagicMock()
    show_mock = MagicMock()
    monkeypatch.setattr(matplotlib.pyplot, 'plot_date', plot_date_mock)
    monkeypatch.setattr(matplotlib.pyplot, 'show', show_mock)

    app = App()
    hour = datetime.datetime.now().isoformat()
    temperature = 14.52
    app.draw({hour: temperature})

    _, called_temperatures = plot_date_mock.call_args[0]
    assert called_temperatures == [temperature]  # check that plot_date was called with temperatures as second arg
    show_mock.assert_called()  # check that show is called

Запуск приложения

У вас есть все необходимое для запуска приложения для построения графика температуры по часам из выбранного CSV-файла.

Давайте сделаем наше приложение доступным для запуска.

Откройте app.py и добавьте следующий фрагмент внизу:

if __name__ == '__main__':
    import sys
    file_name = sys.argv[1]
    app = App()
    temperatures_by_hour = app.read(file_name)
    app.draw(temperatures_by_hour)

При запуске app.py сначала программа считывает CSV-файл из аргумента командной строки, присвоенного file_name, а затем рисует график.

Запустите приложение:

(venv)$ python app.py london.csv

Вы должны увидеть график, подобный этому:

Если вы столкнетесь с Matplotlib is currently using agg, which is a non-GUI backend, so cannot show the figure., проверьте этот ответ на Stack Overflow.

Отключение источника данных

Хорошо. Мы завершили начальную итерацию нашего приложения для отображения исторических данных о погоде. Оно работает так, как ожидалось, и мы рады его использовать. Тем не менее, оно тесно связано с CSV. Что, если бы вы захотели использовать другой формат данных? Например, полезную нагрузку JSON из API. Вот тут-то и вступает в действие внедрение зависимостей.

Давайте отделим часть для чтения от нашего основного приложения.

Сначала создайте новый файл с именем test_urban_climate_csv.py:

import datetime
from pathlib import Path

from app import App
from urban_climate_csv import DataSource


BASE_DIR = Path(__file__).resolve(strict=True).parent


def test_read():
    app = App()
    for key, value in app.read(file_name=Path(BASE_DIR).joinpath('london.csv')).items():
        assert datetime.datetime.fromisoformat(key)
        assert value - 0 == value

Тест здесь такой же, как и в нашем тесте для test_read в test_app.py.

Во-вторых, добавьте новый файл с именем urban_climate_csv.py . Внутри этого файла создайте класс с именем DataSource с помощью метода read:

import csv
import datetime
from pathlib import Path


BASE_DIR = Path(__file__).resolve(strict=True).parent


class DataSource:

    def read(self, **kwargs):
        temperatures_by_hour = {}
        with open(Path(BASE_DIR).joinpath(kwargs['file_name']), 'r') as file:
            reader = csv.reader(file)
            next(reader)  # Skip header row.
            for row in reader:
                hour = datetime.datetime.strptime(row[0], '%d/%m/%Y %H:%M').isoformat()
                temperature = float(row[2])
                temperatures_by_hour[hour] = temperature

        return temperatures_by_hour

Это то же самое, что и метод read в нашем первоначальном приложении, с одним отличием: мы используем kwargs, потому что хотим иметь одинаковый интерфейс для всех наших источников данных. Таким образом, мы могли бы добавлять новых читателей по мере необходимости в зависимости от источника данных.

Например:

from open_weather_csv import DataSource
from open_weather_json import DataSource
from open_weather_api import DataSource


csv_reader = DataSource()
reader.read(file_name='foo.csv')

json_reader = DataSource()
reader.read(file_name='foo.json')

api_reader = DataSource()
reader.read(url='https://foo.bar')

Теперь тест должен пройти:

(venv)$ python -m pytest .

================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/python-dependency-injection
collected 2 items

test_app.py ..                                                                [ 66%]
test_urban_climate_csv.py .                                                   [100%]

================================= 3 passed in 0.48s =====================================

Теперь нам нужно обновить наш класс App.

Сначала обновите тест для read в test_app.py:

def test_read():
    hour = datetime.datetime.now().isoformat()
    temperature = 14.52
    temperature_by_hour = {hour: temperature}

    data_source = MagicMock()
    data_source.read.return_value = temperature_by_hour
    app = App(
        data_source=data_source
    )
    assert app.read(file_name='something.csv') == temperature_by_hour

Что же изменилось? Мы добавили data_source в наш App. Это упрощает тестирование, поскольку метод read выполняет единственную задачу: возвращает результаты из источника данных. Это пример первого преимущества внедрения зависимостей: Тестирование упрощается, поскольку мы можем внедрить базовые зависимости.

Обновите тест и для draw. Опять же, нам нужно ввести источник данных в App, который может быть "любым" с ожидаемым интерфейсом - так что MagicMock сделает:

def test_draw(monkeypatch):
    plot_date_mock = MagicMock()
    show_mock = MagicMock()
    monkeypatch.setattr(matplotlib.pyplot, 'plot_date', plot_date_mock)
    monkeypatch.setattr(matplotlib.pyplot, 'show', show_mock)

    app = App(MagicMock())
    hour = datetime.datetime.now().isoformat()
    temperature = 14.52
    app.draw({hour: temperature})

    _, called_temperatures = plot_date_mock.call_args[0]
    assert called_temperatures == [temperature]  # check that plot_date was called with temperatures as second arg
    show_mock.assert_called()  # check that show is called

Также обновите класс App:

import datetime

import matplotlib.dates
import matplotlib.pyplot


class App:

    def __init__(self, data_source):
        self.data_source = data_source

    def read(self, **kwargs):
        return self.data_source.read(**kwargs)

    def draw(self, temperatures_by_hour):
        dates = []
        temperatures = []

        for date, temperature in temperatures_by_hour.items():
            dates.append(datetime.datetime.fromisoformat(date))
            temperatures.append(temperature)

        dates = matplotlib.dates.date2num(dates)
        matplotlib.pyplot.plot_date(dates, temperatures, linestyle='-')
        matplotlib.pyplot.show(block=True)

Во-первых, мы добавили метод __init__, чтобы можно было вводить источник данных. Во-вторых, мы обновили метод read, чтобы использовать self.data_source и **kwargs. Посмотрите, насколько упростился этот интерфейс. App он больше не связан со считыванием данных.

Наконец, нам нужно ввести наш источник данных в App при создании экземпляра.

if __name__ == '__main__':
    import sys
    from urban_climate_csv import DataSource
    file_name = sys.argv[1]
    app = App(DataSource())
    temperatures_by_hour = app.read(file_name=file_name)
    app.draw(temperatures_by_hour)

Запустите приложение еще раз, чтобы убедиться, что оно по-прежнему работает должным образом:

(venv)$ python app.py london.csv

Обновить test_read в test_urban_climate_csv.py:

import datetime

from urban_climate_csv import DataSource


def test_read():
    reader = DataSource()
    for key, value in reader.read(file_name='london.csv').items():
        assert datetime.datetime.fromisoformat(key)
        assert value - 0 == value

Пройдены ли тесты?

(venv)$ python -m pytest .

================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/python-dependency-injection
collected 2 items

test_app.py ..                                                                [ 66%]
test_urban_climate_csv.py .                                                   [100%]

================================= 3 passed in 0.40s =====================================

Добавление нового источника данных

Теперь, когда мы отделили наш App от источника данных, мы можем легко добавить новый источник.

Давайте воспользуемся данными из OpenWeather API. Продолжайте и загрузите предварительно загруженный ответ из API: здесь. Сохраните его как moscow.json.

Не стесняйтесь регистрироваться с помощью OpenWeather API и получать исторические данные по другому городу, если хотите.

Добавьте новый файл с именем test_open_weather_json.py и напишите тест для метода read:

import datetime

from open_weather_json import DataSource


def test_read():
    reader = DataSource()
    for key, value in reader.read(file_name='moscow.json').items():
        assert datetime.datetime.fromisoformat(key)
        assert value - 0 == value

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

В языках со статической типизацией, таких как Java и C#, все источники данных должны реализовывать один и тот же интерфейс, т.е. IDataSource. Благодаря утиной типизации в Python мы можем просто реализовать методы с одинаковыми именами, которые принимают одинаковые аргументы (**kwargs) для каждого из наших источников данных:

def read(self, **kwargs):
    return self.data_source.read(**kwargs)

Далее давайте перейдем к реализации.

Добавить новый файл с именем open_weather_json.py.:

import json
import datetime


class DataSource:

    def read(self, **kwargs):
        temperatures_by_hour = {}
        with open(kwargs['file_name'], 'r') as file:
            json_data = json.load(file)['hourly']
            for row in json_data:
                hour = datetime.datetime.fromtimestamp(row['dt']).isoformat()
                temperature = float(row['temp'])
                temperatures_by_hour[hour] = temperature

        return temperatures_by_hour

Итак, мы использовали модуль json для чтения и загрузки файла JSON. Затем мы извлекли данные аналогичным образом, как делали это ранее. На этот раз мы использовали функцию fromtimestamp, поскольку время измерений записывается в формате временных меток Unix.

Тесты должны пройти успешно.

Затем обновите app.py , чтобы вместо этого использовать этот источник данных:

if __name__ == '__main__':
    import sys
    from open_weather_json import DataSource
    file_name = sys.argv[1]
    app = App(DataSource())
    temperatures_by_hour = app.read(file_name=file_name)
    app.draw(temperatures_by_hour)

Здесь мы только что изменили импорт.

Запустите свое приложение еще раз, указав moscow.json в качестве аргумента:

(venv)$ python app.py moscow.json

Вы должны увидеть график с данными из выбранного файла JSON.

Это пример второго преимущества внедрения зависимостей: Расширять код намного проще.

Мы видим, что:

  1. Существующие тесты не изменились
  2. Написать тест для нового источника данных просто
  3. Реализация интерфейса для нового источника данных также довольно проста (вам просто нужно знать форму данных)
  4. Нам не нужно было вносить никаких изменений в класс App

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

Отключение библиотеки построения графиков

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

Взгляните на тест для метода draw в test_app.py:

def test_draw(monkeypatch):
    plot_date_mock = MagicMock()
    show_mock = MagicMock()
    monkeypatch.setattr(matplotlib.pyplot, 'plot_date', plot_date_mock)
    monkeypatch.setattr(matplotlib.pyplot, 'show', show_mock)

    app = App(MagicMock())
    hour = datetime.datetime.now().isoformat()
    temperature = 14.52
    app.draw({hour: temperature})

    _, called_temperatures = plot_date_mock.call_args[0]
    assert called_temperatures == [temperature]  # check that plot_date was called with temperatures as second arg
    show_mock.assert_called()  # check that show is called

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

Итак, как мы можем это улучшить?

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

Добавьте новый файл с именем test_matplotlib_plot.py:

import datetime
from unittest.mock import MagicMock

import matplotlib.pyplot

from matplotlib_plot import Plot


def test_draw(monkeypatch):
    plot_date_mock = MagicMock()
    show_mock = MagicMock()
    monkeypatch.setattr(matplotlib.pyplot, 'plot_date', plot_date_mock)
    monkeypatch.setattr(matplotlib.pyplot, 'show', show_mock)

    plot = Plot()
    hours = [datetime.datetime.now()]
    temperatures = [14.52]
    plot.draw(hours,  temperatures)

    _, called_temperatures = plot_date_mock.call_args[0]
    assert called_temperatures == temperatures  # check that plot_date was called with temperatures as second arg
    show_mock.assert_called()  # check that show is called

Для реализации класса Plot добавьте новый файл с именем matplotlib_plot.py:

import matplotlib.dates
import matplotlib.pyplot


class Plot:

    def draw(self, hours, temperatures):

        hours = matplotlib.dates.date2num(hours)
        matplotlib.pyplot.plot_date(hours, temperatures, linestyle='-')
        matplotlib.pyplot.show(block=True)

Здесь метод draw принимает два аргумента:

  1. hours - список объектов даты и времени
  2. temperatures - список чисел

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

Запустите тесты:

(venv)$ python -m pytest .

================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/python-dependency-injection
collected 2 items

test_app.py ..                                                                [ 40%]
test_matplotlib_plot.py .                                                     [ 60%]
test_open_weather_json.py .                                                   [ 80%]
test_urban_climate_csv.py .                                                   [100%]

================================= 5 passed in 0.38s =====================================

Далее, давайте обновим класс App.

Сначала обновите test_app.py вот так:

import datetime
from unittest.mock import MagicMock

from app import App


def test_read():
    hour = datetime.datetime.now().isoformat()
    temperature = 14.52
    temperature_by_hour = {hour: temperature}

    data_source = MagicMock()
    data_source.read.return_value = temperature_by_hour
    app = App(
        data_source=data_source,
        plot=MagicMock()
    )
    assert app.read(file_name='something.csv') == temperature_by_hour


def test_draw():
    plot_mock = MagicMock()
    app = App(
        data_source=MagicMock,
        plot=plot_mock
    )
    hour = datetime.datetime.now()
    iso_hour = hour.isoformat()
    temperature = 14.52
    temperature_by_hour = {iso_hour: temperature}

    app.draw(temperature_by_hour)
    plot_mock.draw.assert_called_with([hour], [temperature])

Поскольку test_draw больше не связан с Matplotlib, мы ввели plot в App перед вызовом метода draw. Пока интерфейс введенного Plot соответствует ожиданиям, тест должен быть пройден. Следовательно, мы можем использовать MagicMock в нашем тесте. Затем мы проверили, что метод draw был вызван, как ожидалось. Мы также добавили сюжет в test_read. Вот и все.

Обновите класс App:

import datetime


class App:

    def __init__(self, data_source, plot):
        self.data_source = data_source
        self.plot = plot

    def read(self, **kwargs):
        return self.data_source.read(**kwargs)

    def draw(self, temperatures_by_hour):
        dates = []
        temperatures = []

        for date, temperature in temperatures_by_hour.items():
            dates.append(datetime.datetime.fromisoformat(date))
            temperatures.append(temperature)

        self.plot.draw(dates, temperatures)

Переработанный метод draw теперь намного проще. Это просто:

  1. Преобразует словарь в два списка
  2. Преобразует строки даты ISO в объекты datetime
  3. Вызывает draw метод экземпляра Plot

Тест:

(venv)$ python -m pytest .

================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/python-dependency-injection
collected 2 items

test_app.py ..                                                                [ 40%]
test_matplotlib_plot.py .                                                     [ 60%]
test_open_weather_json.py .                                                   [ 80%]
test_urban_climate_csv.py .                                                   [100%]

================================= 5 passed in 0.39s =====================================

Обновите фрагмент кода для повторного запуска приложения:

if __name__ == '__main__':
    import sys
    from open_weather_json import DataSource
    from matplotlib_plot import Plot
    file_name = sys.argv[1]
    app = App(DataSource(), Plot())
    temperatures_by_hour = app.read(file_name=file_name)
    app.draw(temperatures_by_hour)

Мы добавили новый импорт для Plot и внедрили его в App.

Запустите приложение еще раз, чтобы убедиться, что оно все еще работает:

(venv)$ python app.py moscow.json

Добавление графически

Начните с установки Plotly:

(venv)$ pip install plotly

Затем добавьте новый тест в новый файл с именем test_plotly_plot.py:

import datetime
from unittest.mock import MagicMock

import plotly.graph_objects

from plotly_plot import Plot


def test_draw(monkeypatch):
    figure_mock = MagicMock()
    monkeypatch.setattr(plotly.graph_objects, 'Figure', figure_mock)
    scatter_mock = MagicMock()
    monkeypatch.setattr(plotly.graph_objects, 'Scatter', scatter_mock)

    plot = Plot()
    hours = [datetime.datetime.now()]
    temperatures = [14.52]
    plot.draw(hours,  temperatures)

    call_kwargs = scatter_mock.call_args[1]
    assert call_kwargs['y'] == temperatures  # check that plot_date was called with temperatures as second arg
    figure_mock().show.assert_called()  # check that show is called

Это в основном то же самое, что и тест matplotlib Plot. Основное изменение заключается в том, как имитируются объекты и методы из Plotly.

Во-вторых, добавьте файл с именем plotly_plot.py:

import plotly.graph_objects


class Plot:

    def draw(self, hours, temperatures):

        fig = plotly.graph_objects.Figure(
            data=[plotly.graph_objects.Scatter(x=hours, y=temperatures)]
        )
        fig.show()

Здесь мы использовали plotly, чтобы нарисовать график с датами. Вот и все.

Тесты должны пройти:

(venv)$ python -m pytest .

================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/python-dependency-injection
collected 6 items

test_app.py ..                                                                [ 33%]
test_matplotlib_plot.py .                                                     [ 50%]
test_open_weather_json.py .                                                   [ 66%]
test_plotly_plot.py .                                                         [ 83%]
test_urban_climate_csv.py .                                                   [100%]

================================= 6 passed in 0.46s =====================================

Обновите фрагмент запуска, чтобы использовать plotly:

if __name__ == '__main__':
    import sys
    from open_weather_json import DataSource
    from plotly_plot import Plot
    file_name = sys.argv[1]
    app = App(DataSource(), Plot())
    temperatures_by_hour = app.read(file_name=file_name)
    app.draw(temperatures_by_hour)

Запустите свое приложение с помощью moscow.json, чтобы увидеть новый график в вашем браузере:

(venv)$ python app.py moscow.json

Добавление конфигурации

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

if __name__ == '__main__':
    import sys
    from open_weather_json import DataSource
    from plotly_plot import Plot
    file_name = sys.argv[1]
    app = App(DataSource(), Plot())
    temperatures_by_hour = app.read(file_name=file_name)
    app.draw(temperatures_by_hour)

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

Мы будем использовать простой объект JSON для настройки нашего приложения:

{
  "data_source": {
    "name": "urban_climate_csv"
  },
  "plot": {
    "name": "plotly_plot"
  }
}

Добавьте это в новый файл с именем config.json.

Добавить новый тест в test_app.py:

def test_configure():
    app = App.configure(
        'config.json'
    )

    assert isinstance(app, App)

Здесь мы проверили, что из метода configure возвращается экземпляр App. Этот метод будет считывать конфигурационный файл и загружать выбранные DataSource и Plot.

Добавить configure в класс App:

import datetime
import json


class App:

    ...

    @classmethod
    def configure(cls, filename):
        with open(filename) as file:
            config = json.load(file)

        data_source = __import__(config['data_source']['name']).DataSource()

        plot = __import__(config['plot']['name']).Plot()

        return cls(data_source, plot)


if __name__ == '__main__':
    import sys
    from open_weather_json import DataSource
    from plotly_plot import Plot
    file_name = sys.argv[1]
    app = App(DataSource(), Plot())
    temperatures_by_hour = app.read(file_name=file_name)
    app.draw(temperatures_by_hour)

Итак, после загрузки JSON-файла мы импортировали DataSource и Plot из соответствующих модулей, определенных в конфигурационном файле.

__import__ используется для динамического импорта модулей. Например, значение config['data_source']['name'] равно urban_climate_csv, что эквивалентно:

import urban_climate_csv

data_source = urban_climate_csv.DataSource()

Запустите тесты:

(venv)$ python -m pytest .

================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/python-dependency-injection
collected 6 items

test_app.py ...                                                               [ 42%]
test_matplotlib_plot.py .                                                     [ 57%]
test_open_weather_json.py .                                                   [ 71%]
test_plotly_plot.py .                                                         [ 85%]
test_urban_climate_csv.py .                                                   [100%]

================================= 6 passed in 0.46s =====================================

Наконец, обновите фрагмент в app.py , чтобы использовать недавно добавленный метод:

if __name__ == '__main__':
    import sys
    config_file = sys.argv[1]
    file_name = sys.argv[2]
    app = App.configure(config_file)
    temperatures_by_hour = app.read(file_name=file_name)
    app.draw(temperatures_by_hour)

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

Запустите свое приложение еще раз:

(venv)$ python app.py config.json london.csv

Обновите конфигурацию, чтобы использовать open_weather_json в качестве источника данных:

{
  "data_source": {
    "name": "open_weather_json"
  },
  "plot": {
    "name": "plotly_plot"
  }
}

Запустите приложение:

(venv)$ python app.py config.json moscow.json

Другой взгляд

Основной класс App изначально был всезнающим объектом, отвечающим за считывание данных из CSV и построение графика. Мы использовали внедрение зависимостей, чтобы разделить функции чтения и рисования. Класс App теперь представляет собой контейнер с простым интерфейсом, который соединяет части чтения и рисования. Собственно логика чтения и рисования обрабатывается в специализированных классах, которые отвечают только за одно.

Преимущества:

  1. Методы проще тестировать
  2. Зависимости проще имитировать
  3. Тесты не нужно менять каждый раз, когда мы расширяем наше приложение
  4. Расширять приложение проще
  5. Это упрощает обслуживание приложения

Мы сделали что-то особенное? Не совсем. Идея, лежащая в основе внедрения зависимостей, довольно распространена в инженерном мире, за пределами разработки программного обеспечения.

Например, плотник, который занимается отделкой фасадов домов, обычно оставляет пустые прорези для окон и дверей, чтобы их мог установить кто-то, кто специализируется именно на установке окон и дверей. Когда дом будет достроен и владельцы въедут в него, нужно ли им сносить половину дома только для того, чтобы заменить существующее окно? Нет. Они могут просто починить разбитое окно. Пока окна имеют одинаковый интерфейс (например, ширину, высоту, глубину и т.д.), пользователи могут устанавливать и использовать их. Могут ли они открыть окно до его установки? Конечно. Могут ли они проверить, не разбито ли окно, прежде чем устанавливать его? Да. Это тоже форма внедрения зависимостей.

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

Следующие шаги

Хотите узнать больше?

  1. Расширьте приложение, чтобы использовать новый источник данных с именем open_weather_api. Этот источник берет город, выполняет вызов API, а затем возвращает данные в правильном виде для метода draw.
  2. Добавьте Боке для построения графика.

Заключение

В этой статье показано, как реализовать внедрение зависимостей в реальном приложении.

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

Итак, прежде чем приступить к делу, спросите себя:

  1. Является ли мой код "палаткой" или "домом"?
  2. Каковы преимущества (и недостатки) использования внедрения зависимостей в этой конкретной области?
  3. Как я могу объяснить это новичку в проекте?

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

Счастливого кодирования!

Back to Top