HangOps.ru

Внедрение зависимостей (Dependency Injection) в Python

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

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

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

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

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

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

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

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

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

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

Построение графика о погоде на основе исторических данных

Сценарий

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

Приступим

Сначала создадим (и активируем) виртуальное окружение для python. Затем установим 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 вызван.

Вы можете прочитать больше об monkeypatch с помощью pytest здесь и больше о mocking здесь.

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

  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

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

================================ 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

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

График

Разделение источника данных

Хорошо. Мы завершили первоначальную итерацию нашего приложения для отображения исторических данных о погоде. Оно работает, как и ожидалось. Тем не менее, приложение прочно связано с 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.

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

Добавим новый файл с именем 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 - список объектов datetime 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 был вызван, как и ожидалось. Мы также внедрили plot в 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 в объекты даты и времени. 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

Это в основном то же самое, что и тест Plot для matplotlib. Основное изменение заключается в том, как имитируются объекты и методы из 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

temperature_by_hour_moscow.png

график

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

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

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)

App здесь мы проверили, что из метода возвращается экземпляр configure. Этот метод прочитает файл конфигурации и загрузит выбранные 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. Является ли мой код «палаткой» или «домом»?
  2. Каковы преимущества (и недостатки) использования внедрения зависимостей в этой конкретной области?
  3. Как объяснить это новичку в проекте?

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

Удачного кодирования!

Автор: Jan Giacomelli

Python