Написание чистого, поддерживаемого кода — сложная задача. К счастью, существует множество шаблонов, техник и многоразовых решений, которые значительно облегчают выполнение этой задачи. Внедрение зависимостей — это одна из тех техник, которая используется для написания слабосвязанного, но очень связанного кода.
Хотите научиться программировать на Python, но не знаете, с чего начать? Посетите нашу страницу, на которой мы собрали лучшие курсы Python для начинающих от ведущих платформ. На странице вы найдете полезные ресурсы, которые помогут вам освоить основы языка и начать писать свой первый код!
В этой статье мы покажем вам, как реализовать внедрение зависимостей при разработке приложения для отображения исторических данных о погоде. После разработки первоначального приложения с помощью разработки через тестирование (Test-Driven Development) вы проведете его рефакторинг с помощью внедрения зависимостей (Dependency Injection), чтобы отделить части приложения и упростить его тестирование, расширение и поддержку.
К концу этой статьи вы сможете объяснить, что такое внедрение зависимостей, и реализовать его на Python с помощью разработки через тестирование (TDD).
Что такое внедрение зависимостей?
В программной инженерии внедрение зависимостей Dependency injection — это метод, при котором объект получает другие объекты, от которых он зависит.
- Метод был введен для управления сложностью кодовой базы.
- Он помогает упростить тестирование, расширение кода и обслуживание.
- Большинство языков, допускающих передачу объектов и функций в качестве параметров, поддерживают этот метод. Однако вы больше слышали о внедрении зависимостей в Java и C#, поскольку его сложно реализовать. С другой стороны, благодаря динамической типизации Python и системе утиной типизации ее легко реализовать и, следовательно, она менее заметна. Django, Django REST Framework и FastAPI используют внедрение зависимостей.
Преимущества
- Методы легче тестировать
- Зависимости легче имитировать
- Тесты не должны меняться каждый раз, когда мы расширяем наше приложение.
- Проще расширить приложение
- Легче поддерживать приложение
Чтобы увидеть это в действии, давайте взглянем на несколько реальных примеров.
Построение графика о погоде на основе исторических данных
Сценарий
- Мы решили создать приложение для рисования графиков на основе исторических данных о погоде.
- Загрузили данные о часовой график изменения температуры в Лондоне за 2009 год.
- Цель - нарисовать график этих данных, чтобы увидеть, как температура менялась с течением времени.
Приступим
Сначала создадим (и активируем) виртуальное окружение для 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 здесь.
Давайте перейдем к реализации метода:
- Он принимает параметр,
temperatures_by_hour
который должен быть словарем той же структуры, что и результат методаread
. - Он должен преобразовать этот словарь в два вектора, которые можно использовать на графике: даты и температуры.
- Даты следует преобразовать в числа с помощью
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.
Это пример второго преимущества внедрения зависимостей: расширять код намного проще. Мы видим, что:
- Существующие тесты не изменились
- Написать тест для нового источника данных просто
- Реализация интерфейса для нового источника данных также довольно проста (вам просто нужно знать форму данных).
- Нам не нужно было вносить какие-либо изменения в
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
представляет собой контейнер с простым интерфейсом, который соединяет части чтения и рисования. Фактическая логика чтения и рисования обрабатывается в специализированных классах, которые отвечают только за одну вещь.
Преимущества
- Методы легче тестировать
- Зависимости легче имитировать
- Тесты не должны меняться каждый раз, когда мы расширяем наше приложение.
- Проще расширить приложение
- Легче поддерживать приложение
Мы сделали что-то особенное? Не совсем. Идея, лежащая в основе внедрения зависимостей, довольно распространена в инженерном мире, за пределами разработки программного обеспечения.
Например, плотник, который строит фасад дома, обычно оставляет пустые прорези для окон и дверей, чтобы их мог установить специалист, специализирующийся на установке окон и дверей. Когда дом построен и в него въезжают владельцы, нужно ли им сносить половину дома только для того, чтобы заменить существующее окно? Нет. Они могут просто починить разбитое окно. Пока окна имеют одинаковый интерфейс (например, ширину, высоту, глубину и т. д.), их можно установить и использовать. Могут ли они открыть окно до того, как оно будет установлено? Конечно. Могут ли они проверить, не разбито ли окно перед его установкой? Да. Это тоже форма внедрения зависимостей.
Возможно, не так естественно видеть и использовать внедрение зависимостей в программной инженерии, но это так же эффективно, как и в любой другой инженерной профессии.
Вывод
В этой статье показано, как реализовать внедрение зависимостей в реальном приложении.
Несмотря на то, что это мощная техника, внедрение зависимостей не панацея. Подумайте еще раз об аналогии с домом: оболочка дома, окна и двери слабо связаны. Можно ли то же самое сказать о палатке? Нет. Если дверь палатки повреждена и не подлежит ремонту, вы, вероятно, захотите купить новую палатку, а не пытаться починить поврежденную дверь. Таким образом, вы не можете отделить и применить внедрение зависимостей ко всему. На самом деле, это может втянуть вас в ад преждевременной оптимизации, если сделать это слишком рано. Хотя его легче поддерживать, у него больше поверхности, а несвязанный код может быть труднее понять новичкам в проекте.
Поэтому, прежде чем прыгать, спросите себя:
- Является ли мой код «палаткой» или «домом»?
- Каковы преимущества (и недостатки) использования внедрения зависимостей в этой конкретной области?
- Как объяснить это новичку в проекте?
Если вы можете легко ответить на эти вопросы и преимущества перевешивают недостатки, дерзайте. В противном случае его использование в данный момент может оказаться нецелесообразным.
Удачного кодирования!
Автор: Jan Giacomelli