Перевод: Jan Giacomelli — Python Dependency Injection
Написание чистого, поддерживаемого кода — сложная задача. К счастью, нам доступно множество шаблонов, техник и решений для многократного использования ранее созданного кода, которые значительно облегчают выполнение этой задачи. Внедрение зависимостей (Dependency Injection) — один из таких методов (или как еще можно сказать — шаблонов), который используется для написания loosely-coupled (слабо связанного), но highly-cohesive (очень сплоченный) кода.
В этой статье я покажу вам, как реализовать внедрение зависимостей при разработке приложения для построения графиков данных о погоде. После разработки начального приложения с помощью методологии Test-Driven Development мы проведем его рефакторинг, используя Dependency Injection, чтобы разделить части приложения, для улучшения тестирование, поддержки и возможности расширения.
К концу этого поста вы сможете объяснить, что такое внедрение зависимостей, и реализовать его на Python с помощью Test-Driven Development (TDD).
В программной инженерии внедрение зависимостей (Dependency Injection) — это метод, при котором объект получает другие объекты, от которых он зависит.
Преимущества его использования:
Для получения дополнительной информации см. статью Мартина Фаулера «Forms of Dependency Injection».
Чтобы понять как работает этот шаблон рассмотрим его в действии, давайте взглянем на несколько реальных примеров.
Задача:
Сначала создайте (и активируйте) виртуальную среду. Затем установите pytest и Matplotlib:
(venv)$ pip install pytest matplotlib
Представляется разумным начать с класса у которого будут два метода:
read
— для чтения данные из CSVdraw
— отображающий графикПоскольку нам нужно читать исторические данные о погоде из файла CSV, метод read должен соответствовать следующим критериям:
App
read
вызывается с именем CSV файла'%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
Итак, этот тест проверяет, что:
Метод 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 должен соответствовать следующим критериям:
App
Добавим тест для 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 здесь.
Перейдем к реализации метода:
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
Вы должны увидеть такой график:
Если вы столкнет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.
Это пример второго преимущества использования внедрения зависимостей: расширение кода стало намного проще.
Результаты изменений:
Итак, теперь мы можем расширить кодовую базу с помощью простых и предсказуемых шагов без необходимости касаться уже написанных тестов или изменения основного приложения. Теперь разработчик может сосредоточиться исключительно на добавлении новых источников данных, и ему никогда не нужно понимать как работает основное приложение.
Пойдем дальше и отделим часть построения графиков от приложения, чтобы нам было проще добавлять новые библиотеки построения графиков. Поскольку этот процесс будет аналогичен процессу разделения источника данных, продумайте шаги самостоятельно, прежде чем читать оставшуюся часть этого раздела.
Взгляните на тест для метода 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 принимает два аргумента:
hours
— список объектов datetimetemperatures
— список температурВот как будет выглядеть наш интерфейс для всех будущих классов 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:
(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
На этом этапе мы можем легко добавлять и использовать различные источники данных и библиотеки построения графиков в нашем приложении. Наши тесты больше не связаны с реализацией. При этом нам все еще нужно внести изменения в код, чтобы добавить новый источник данных или библиотеку построения графиков:
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 теперь представляет собой контейнер с простым интерфейсом, который соединяет части чтения и рисования. Фактическая логика чтения и рисования обрабатывается в специализированных классах, которые отвечают только за одну вещь.
Преимущества которые мы получили:
Мы сделали что-то особенное? На самом деле, нет. Идея внедрения зависимостей довольно распространена в мире инженерии, помимо разработки программного обеспечения.
Например, плотник, который строит фасады дома, обычно оставляет пустые места для окон и дверей, чтобы их мог установить кто-то, специализирующийся на установке окон и дверей. Когда дом построен и в него въезжают владельцы, нужно ли им снести половину дома, чтобы заменить существующее окно? Нет. Они могут просто починить разбитое окно. Пока окна имеют одинаковый интерфейс (например, ширину, высоту, глубину и т. д.), Они могут их устанавливать и использовать. Могут ли они открыть окно до его установки? Конечно. Могут ли они перед установкой проверить, не разбито ли окно? Да. Это тоже форма внедрения зависимостей.
Может быть, не так естественно видеть и использовать внедрение зависимостей в разработке программного обеспечения, но это так же эффективно, как и в любых других инженерных профессиях.
Хотите больше идей для развития приложения?
В этом посте показано, как реализовать внедрение зависимостей в реальном приложении.
Несмотря на то, что это мощный метод, инъекция зависимостей — не серебряная пуля. Подумайте снова об аналогии с домом: оболочка дома, окна и двери слабо связаны. Можно ли то же самое сказать о палатке? Нет. Если дверь палатки повреждена и не подлежит ремонту, вы, вероятно, захотите купить новую палатку, а не будете пытаться починить поврежденную дверь. Таким образом, вы не можете разделить и применить внедрение зависимостей ко всему. Фактически, это может затянуть вас в ад преждевременной оптимизации, если сделать это слишком рано. Несмотря на то, что его легче поддерживать, он имеет большую площадь поверхности, и развязанный код может быть труднее понять новичкам в проекте.
Поэтому, прежде чем приступить к делу, спросите себя:
Если вы сможете легко ответить на эти вопросы, а преимущества перевешивают недостатки, дерзайте. В противном случае он может оказаться непригодным для использования в данный момент.
Happy coding!
Краткий перевод: https://vuejs.org/guide/components/v-model.html Основное использование v-model используется для реализации двусторонней привязки в компоненте. Начиная с Vue…
Сегодня мы рады объявить о выпуске Vue 3.4 «🏀 Slam Dunk»! Этот выпуск включает в…
Vue.js — это универсальный и адаптируемый фреймворк. Благодаря своей отличительной архитектуре и системе реактивности Vue…
Недавно, у меня истек сертификат и пришлось заказывать новый и затем устанавливать на хостинг с…
Каким бы ни было ваше мнение о JavaScript, но всем известно, что работа с датами…
Все, кто следит за последними событиями в мире адаптивного дизайна, согласятся, что введение контейнерных запросов…