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

Spread the love

Перевод: Jan GiacomelliPython Dependency Injection

Написание чистого, поддерживаемого кода – сложная задача. К счастью, нам доступно множество шаблонов, техник и решений для многократного использования ранее созданного кода, которые значительно облегчают выполнение этой задачи. Внедрение зависимостей (Dependency Injection) – один из таких методов (или как еще можно сказать – шаблонов), который используется для написания loosely-coupled (слабо связанного), но highly-cohesive (очень сплоченный) кода.

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

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

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

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

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

Преимущества его использования:

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

Для получения дополнительной информации см. статью Мартина Фаулера «Forms of Dependency Injection».

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

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

Задача:

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

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

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

(venv)$ pip install pytest matplotlib

Представляется разумным начать с класса у которого будут два метода:

  1. read – для чтения данные из CSV
  2. draw – отображающий график

Чтение данных из CSV

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

  • GIVEN класс App
  • WHEN метод read вызывается с именем CSV файла
  • THEN данные из CSV должны возвращаться со словарем в котором ключи строки datetime в формате 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.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/michael.herman/repos/testdriven/dependency-injection-python/app
collected 1 item

test_app.py .                                                                 [100%]

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

Отображение графика

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

  • GIVEN класс App
  • WHEN метод draw вызывается со словарем, где ключи представляют собой строки даты и времени в формате ISO 8601 (‘% Y-% m-% dT% H:% M:% S.% f’), а значения представляют собой температуры, измеренные в этот момент
  • THEN данные должны быть нарисованы на линейном графике со временем по оси X и c температурой по оси 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 был вызван.

Вы можете узнать больше об monkeypatching с помощью pytest здесь и больше о mocking здесь.

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

  1. Он принимает параметр temperature_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.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
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

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

Temperature by hour

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

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

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

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

Сначала создайте новый файл с именем 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.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
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 в наше приложение. Это упрощает тестирование, поскольку метод read выполняет единственную задачу: возвращать результаты из источника данных. Это пример первого преимущества внедрения зависимостей: тестирование упрощается, поскольку мы можем внедрять базовые зависимости.

Обновите тест для draw. Опять же, нам нужно внедрить источник данных в приложение, которое может быть «чем угодно» с ожидаемым интерфейсом. Мы используем 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. Посмотрите, насколько этот интерфейс стал проще. Приложение больше не связано со способом чтением данных.

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

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.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
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 =====================================

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

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

Воспользуемся данными из 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. Благодаря утиной типизации (duck typing) в 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. Изменение библиотеки графических изображений потребует изменения тестов. Это то, чего мы хотим избежать.

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

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

Добавим новый файл с именем 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.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
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 в приложение перед вызовом метода draw. Пока интерфейс введенного графика соответствует ожиданиям, тест должен проходить. Поэтому мы можем использовать 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 стал намного проще.

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

Тест:

(venv)$ python -m pytest .

================================ test session starts ====================================
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
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 и вставили его в приложение.

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

(venv)$ python app.py moscow.json

Добавление Plotly

Начните с установки 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.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
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

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

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

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.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
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. Добавьте Bokeh для графиков.

Заключение

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

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

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

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

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

Happy coding!

Была ли вам полезна эта статья?
[3 / 5]

Spread the love
Подписаться
Уведомление о
guest
0 Комментарий
Inline Feedbacks
View all comments