https://habr.com/ru/post/513510/- Python
- Проектирование и рефакторинг
Привет,
Я создатель
Dependency Injector. Это dependency injection фреймворк для Python.
Продолжаю серию руководств по применению Dependency Injector для построения приложений.
В этом руководстве хочу показать как применять Dependency Injector для разработки
aiohttp
приложений.
Руководство состоит из таких частей:
- Что мы будем строить?
- Подготовка окружения
- Структура проекта
- Установка зависимостей
- Минимальное приложение
- Giphy API клиент
- Сервис поиска
- Подключаем поиск
- Немного рефакторинга
- Добавляем тесты
- Заключение
Завершенный проект можно найти на
Github.
Для старта необходимо иметь:
- Python 3.5+
- Virtual environment
И желательно иметь:
- Начальные навыки разработки с помощью aiohttp
- Общее представление о принципе dependency injection
Что мы будем строить?
Мы будем строить REST API приложение, которое ищет забавные гифки на
Giphy. Назовем его Giphy Navigator.
Как работает Giphy Navigator?
- Клиент отправляет запрос указывая что искать и сколько результатов вернуть.
- Giphy Navigator возвращает ответ в формате json.
- Ответ включает:
- поисковый запрос
- количество результатов
- список url гифок
Пример ответа:
{
"query": "Dependency Injector",
"limit": 10,
"gifs": [
{
"url": "https://giphy.com/gifs/boxes-dependent-swbf2-6Eo7KzABxgJMY"
},
{
"url": "https://giphy.com/gifs/depends-J56qCcOhk6hKE"
},
{
"url": "https://giphy.com/gifs/web-series-ccstudios-bro-dependent-1lhU8KAVwmVVu"
},
{
"url": "https://giphy.com/gifs/TheBoysTV-friends-friend-weneedeachother-XxR9qcIwcf5Jq404Sx"
},
{
"url": "https://giphy.com/gifs/netflix-a-series-of-unfortunate-events-asoue-9rgeQXbwoK53pcxn7f"
},
{
"url": "https://giphy.com/gifs/black-and-white-sad-skins-Hs4YzLs2zJuLu"
},
{
"url": "https://giphy.com/gifs/always-there-for-you-i-am-here-PlayjhCco9jHBYrd9w"
},
{
"url": "https://giphy.com/gifs/stream-famous-dollar-YT2dvOByEwXCdoYiA1"
},
{
"url": "https://giphy.com/gifs/i-love-you-there-for-am-1BhGzgpZXYWwWMAGB1"
},
{
"url": "https://giphy.com/gifs/life-like-twerk-9hlnWxjHqmH28"
}
]
}
Подготовим окружение
Начнём с подготовки окружения.
В первую очередь нам нужно создать папку проекта и virtual environment:
mkdir giphynav-aiohttp-tutorial
cd giphynav-aiohttp-tutorial
python3 -m venv venv
Теперь давайте активируем virtual environment:
. venv/bin/activate
Окружение готово, теперь займемся структурой проекта.
Структура проекта
В этом разделе организуем структуру проекта.
Создадим в текущей папке следующую структуру. Все файлы пока оставляем пустыми.
Начальная структура:
./
├── giphynavigator/
│ ├── __init__.py
│ ├── application.py
│ ├── containers.py
│ └── views.py
├── venv/
└── requirements.txt
Установка зависимостей
Пришло время установить зависимости. Мы будем использовать такие пакеты:
dependency-injector
— dependency injection фреймворк
aiohttp
— веб фреймворк
aiohttp-devtools
— библиотека-помогатор, которая предоставляет сервер для разработки с live-перезагрузкой
pyyaml
— библиотека для парсинга YAML файлов, используется для чтения конфига
pytest-aiohttp
— библиотека-помогатор для тестирования aiohttp
приложений
pytest-cov
— библиотека-помогатор для измерения покрытия кода тестами
Добавим следующие строки в файл
requirements.txt
:
dependency-injector
aiohttp
aiohttp-devtools
pyyaml
pytest-aiohttp
pytest-cov
И выполним в терминале:
pip install -r requirements.txt
Дополнительно установим
httpie
. Это HTTP клиент для командной строки. Мы будем
использовать его для ручного тестирования API.
Выполним в терминале:
pip install httpie
Зависимости установлены. Теперь построим минимальное приложение.
Минимальное приложение
В этом разделе построим минимальное приложение. У него будет эндпоинт, который будет возвращать пустой ответ.
Отредактируем
views.py
:
"""Views module."""
from aiohttp import web
async def index(request: web.Request) -> web.Response:
query = request.query.get('query', 'Dependency Injector')
limit = int(request.query.get('limit', 10))
gifs = []
return web.json_response(
{
'query': query,
'limit': limit,
'gifs': gifs,
},
)
Теперь добавим контейнер зависимостей (дальше просто контейнер). Контейнер будет содержать все компоненты приложения. Добавим первые два компонента. Это
aiohttp
приложение и представление
index
.
Отредактируем
containers.py
:
"""Application containers module."""
from dependency_injector import containers
from dependency_injector.ext import aiohttp
from aiohttp import web
from . import views
class ApplicationContainer(containers.DeclarativeContainer):
"""Application container."""
app = aiohttp.Application(web.Application)
index_view = aiohttp.View(views.index)
Теперь нам нужно создать фабрику
aiohttp
приложения. Ее обычно называют
create_app()
. Она будет создавать контейнер. Контейнер будет использован для создания
aiohttp
приложения. Последним шагом настроим маршрутизацию — мы назначим представление
index_view
из контейнера обрабатывать запросы к корню
"/"
нашего приложения.
Отредактируем
application.py
:
"""Application module."""
from aiohttp import web
from .containers import ApplicationContainer
def create_app():
"""Create and return aiohttp application."""
container = ApplicationContainer()
app: web.Application = container.app()
app.container = container
app.add_routes([
web.get('/', container.index_view.as_view()),
])
return app
Контейнер — первый объект в приложении. Он используется для получения всех остальных объектов.
Теперь мы готовы запустить наше приложение:
Выполните команду в терминале:
adev runserver giphynavigator/application.py --livereload
Вывод должен выглядеть так:
[18:52:59] Starting aux server at http://localhost:8001 ◆
[18:52:59] Starting dev server at http://localhost:8000 ●
Используем
httpie
чтобы проверить работу сервера:
http http://127.0.0.1:8000/
Вы увидите:
HTTP/1.1 200 OK
Content-Length: 844
Content-Type: application/json; charset=utf-8
Date: Wed, 29 Jul 2020 21:01:50 GMT
Server: Python/3.8 aiohttp/3.6.2
{
"gifs": [],
"limit": 10,
"query": "Dependency Injector"
}
Минимальное приложение готово. Давайте подключим Giphy API.
Giphy API клиент
В этом разделе мы интегрируем наше приложение с Giphy API. Мы создадим собственный API клиент используя клиентскую часть
aiohttp
.
Создайте пустой файл
giphy.py
в пакете
giphynavigator
:
./
├── giphynavigator/
│ ├── __init__.py
│ ├── application.py
│ ├── containers.py
│ ├── giphy.py
│ └── views.py
├── venv/
└── requirements.txt
и добавьте в него следующие строки:
"""Giphy client module."""
from aiohttp import ClientSession, ClientTimeout
class GiphyClient:
API_URL = 'http://api.giphy.com/v1'
def __init__(self, api_key, timeout):
self._api_key = api_key
self._timeout = ClientTimeout(timeout)
async def search(self, query, limit):
"""Make search API call and return result."""
if not query:
return []
url = f'{self.API_URL}/gifs/search'
params = {
'q': query,
'api_key': self._api_key,
'limit': limit,
}
async with ClientSession(timeout=self._timeout) as session:
async with session.get(url, params=params) as response:
if response.status != 200:
response.raise_for_status()
return await response.json()
Теперь нам нужно добавить GiphyClient в контейнер. У GiphyClient есть две зависимости, которые нужно передать при его создании: API ключ и таймаут запроса. Для этого нам нужно будет воспользоваться двумя новыми провайдерами из модуля
dependency_injector.providers
:
- Провайдер
Factory
будет создавать GiphyClient.
- Провайдер
Configuration
будет передавать API ключ и таймаут GiphyClient.
Отредактируем
containers.py
:
"""Application containers module."""
from dependency_injector import containers, providers
from dependency_injector.ext import aiohttp
from aiohttp import web
from . import giphy, views
class ApplicationContainer(containers.DeclarativeContainer):
"""Application container."""
app = aiohttp.Application(web.Application)
config = providers.Configuration()
giphy_client = providers.Factory(
giphy.GiphyClient,
api_key=config.giphy.api_key,
timeout=config.giphy.request_timeout,
)
index_view = aiohttp.View(views.index)
Мы использовали параметры конфигурации перед тем как задали их значения. Это принцип, по которому работает провайдер Configuration
.
Сначала используем, потом задаем значения.
Теперь давайте добавим файл конфигурации.
Будем использовать YAML.
Создайте пустой файл
config.yml
в корне проекта:
./
├── giphynavigator/
│ ├── __init__.py
│ ├── application.py
│ ├── containers.py
│ ├── giphy.py
│ └── views.py
├── venv/
├── config.yml
└── requirements.txt
И заполните его следующими строками:
giphy:
request_timeout: 10
Для передачи API ключа мы будем использовать переменную окружения
GIPHY_API_KEY
.
Теперь нам нужно отредактировать
create_app()
чтобы сделать 2 действие при старте приложения:
- Загрузить конфигурацию из
config.yml
- Загрузить API ключ из переменной окружения
GIPHY_API_KEY
Отредактируйте
application.py
:
"""Application module."""
from aiohttp import web
from .containers import ApplicationContainer
def create_app():
"""Create and return aiohttp application."""
container = ApplicationContainer()
container.config.from_yaml('config.yml')
container.config.giphy.api_key.from_env('GIPHY_API_KEY')
app: web.Application = container.app()
app.container = container
app.add_routes([
web.get('/', container.index_view.as_view()),
])
return app
Теперь нам нужно создать API ключ и установить его в переменную окружения.
Чтобы не тратить на это время сейчас используйте вот этот ключ:
export GIPHY_API_KEY=wBJ2wZG7SRqfrU9nPgPiWvORmloDyuL0
Для создания собственного ключа Giphy API следуйте этому руководству.
Создание Giphy API клиента и установка конфигурации завершена. Давайте перейдем к сервису поиска.
Сервис поиска
Пришло время добавить сервис поиска
SearchService
. Он будет:
- Выполнять поиск
- Форматировать полученный ответ
SearchService
будет использовать
GiphyClient
.
Создайте пустой файл
services.py
в пакете
giphynavigator
:
./
├── giphynavigator/
│ ├── __init__.py
│ ├── application.py
│ ├── containers.py
│ ├── giphy.py
│ ├── services.py
│ └── views.py
├── venv/
└── requirements.txt
и добавьте в него следующие строки:
"""Services module."""
from .giphy import GiphyClient
class SearchService:
def __init__(self, giphy_client: GiphyClient):
self._giphy_client = giphy_client
async def search(self, query, limit):
"""Search for gifs and return formatted data."""
if not query:
return []
result = await self._giphy_client.search(query, limit)
return [{'url': gif['url']} for gif in result['data']]
При создании
SearchService
нужно передавать
GiphyClient
. Мы укажем это при добавлении
SearchService
в контейнер.
Отредактируем
containers.py
:
"""Application containers module."""
from dependency_injector import containers, providers
from dependency_injector.ext import aiohttp
from aiohttp import web
from . import giphy, services, views
class ApplicationContainer(containers.DeclarativeContainer):
"""Application container."""
app = aiohttp.Application(web.Application)
config = providers.Configuration()
giphy_client = providers.Factory(
giphy.GiphyClient,
api_key=config.giphy.api_key,
timeout=config.giphy.request_timeout,
)
search_service = providers.Factory(
services.SearchService,
giphy_client=giphy_client,
)
index_view = aiohttp.View(views.index)
Создание сервиса поиска
SearchService
завершено. В следующем разделе мы подключим его к нашему представлению.
Подключаем поиск
Теперь мы готовы чтобы поиск заработал. Давайте используем
SearchService
в
index
представлении.
Отредактируйте
views.py
:
"""Views module."""
from aiohttp import web
from .services import SearchService
async def index(
request: web.Request,
search_service: SearchService,
) -> web.Response:
query = request.query.get('query', 'Dependency Injector')
limit = int(request.query.get('limit', 10))
gifs = await search_service.search(query, limit)
return web.json_response(
{
'query': query,
'limit': limit,
'gifs': gifs,
},
)
Теперь изменим контейнер чтобы передавать зависимость
SearchService
в представление
index
при его вызове.
Отредактируйте
containers.py
:
"""Application containers module."""
from dependency_injector import containers, providers
from dependency_injector.ext import aiohttp
from aiohttp import web
from . import giphy, services, views
class ApplicationContainer(containers.DeclarativeContainer):
"""Application container."""
app = aiohttp.Application(web.Application)
config = providers.Configuration()
giphy_client = providers.Factory(
giphy.GiphyClient,
api_key=config.giphy.api_key,
timeout=config.giphy.request_timeout,
)
search_service = providers.Factory(
services.SearchService,
giphy_client=giphy_client,
)
index_view = aiohttp.View(
views.index,
search_service=search_service,
)
Убедитесь что приложение работает или выполните:
adev runserver giphynavigator/application.py --livereload
и сделайте запрос к API в терминале:
http http://localhost:8000/ query=="wow,it works" limit==5
Вы увидите:
HTTP/1.1 200 OK
Content-Length: 850
Content-Type: application/json; charset=utf-8
Date: Wed, 29 Jul 2020 22:22:55 GMT
Server: Python/3.8 aiohttp/3.6.2
{
"gifs": [
{
"url": "https://giphy.com/gifs/discoverychannel-nugget-gold-rush-rick-ness-KGGPIlnC4hr4u2s3pY"
},
{
"url": "https://giphy.com/gifs/primevideoin-ll1hyBS2IrUPLE0E71"
},
{
"url": "https://giphy.com/gifs/jackman-works-jackmanworks-l4pTgQoCrmXq8Txlu"
},
{
"url": "https://giphy.com/gifs/cat-massage-at-work-l46CzMaOlJXAFuO3u"
},
{
"url": "https://giphy.com/gifs/everwhatproductions-fun-christmas-3oxHQCI8tKXoeW4IBq"
},
],
"limit": 10,
"query": "wow,it works"
}
Поиск работает.
Немного рефакторинга
Наше представление
index
содержит два hardcoded значения:
- Поисковый запрос по умолчанию
- Лимит количества результатов
Давайте сделаем небольшой рефакторинг. Мы перенесем эти значения в конфигурацию.
Отредактируйте
views.py
:
"""Views module."""
from aiohttp import web
from .services import SearchService
async def index(
request: web.Request,
search_service: SearchService,
default_query: str,
default_limit: int,
) -> web.Response:
query = request.query.get('query', default_query)
limit = int(request.query.get('limit', default_limit))
gifs = await search_service.search(query, limit)
return web.json_response(
{
'query': query,
'limit': limit,
'gifs': gifs,
},
)
Теперь нам нужно чтобы эти значения передавались при вызове. Давайте обновим контейнер.
Отредактируйте
containers.py
:
"""Application containers module."""
from dependency_injector import containers, providers
from dependency_injector.ext import aiohttp
from aiohttp import web
from . import giphy, services, views
class ApplicationContainer(containers.DeclarativeContainer):
"""Application container."""
app = aiohttp.Application(web.Application)
config = providers.Configuration()
giphy_client = providers.Factory(
giphy.GiphyClient,
api_key=config.giphy.api_key,
timeout=config.giphy.request_timeout,
)
search_service = providers.Factory(
services.SearchService,
giphy_client=giphy_client,
)
index_view = aiohttp.View(
views.index,
search_service=search_service,
default_query=config.search.default_query,
default_limit=config.search.default_limit,
)
Теперь давайте обновим конфигурационный файл.
Отредактируйте
config.yml
:
giphy:
request_timeout: 10
search:
default_query: "Dependency Injector"
default_limit: 10
Рефакторинг закончен. Мы сделали наше приложение чище — перенесли hardcoded значения в конфигурацию.
В следующем разделе мы добавим несколько тестов.
Добавляем тесты
Было бы неплохо добавить несколько тестов. Давай сделаем это. Мы будем использовать
pytest и
coverage.
Создайте пустой файл
tests.py
в пакете
giphynavigator
:
./
├── giphynavigator/
│ ├── __init__.py
│ ├── application.py
│ ├── containers.py
│ ├── giphy.py
│ ├── services.py
│ ├── tests.py
│ └── views.py
├── venv/
└── requirements.txt
и добавьте в него следующие строки:
"""Tests module."""
from unittest import mock
import pytest
from giphynavigator.application import create_app
from giphynavigator.giphy import GiphyClient
@pytest.fixture
def app():
return create_app()
@pytest.fixture
def client(app, aiohttp_client, loop):
return loop.run_until_complete(aiohttp_client(app))
async def test_index(client, app):
giphy_client_mock = mock.AsyncMock(spec=GiphyClient)
giphy_client_mock.search.return_value = {
'data': [
{'url': 'https://giphy.com/gif1.gif'},
{'url': 'https://giphy.com/gif2.gif'},
],
}
with app.container.giphy_client.override(giphy_client_mock):
response = await client.get(
'/',
params={
'query': 'test',
'limit': 10,
},
)
assert response.status == 200
data = await response.json()
assert data == {
'query': 'test',
'limit': 10,
'gifs': [
{'url': 'https://giphy.com/gif1.gif'},
{'url': 'https://giphy.com/gif2.gif'},
],
}
async def test_index_no_data(client, app):
giphy_client_mock = mock.AsyncMock(spec=GiphyClient)
giphy_client_mock.search.return_value = {
'data': [],
}
with app.container.giphy_client.override(giphy_client_mock):
response = await client.get('/')
assert response.status == 200
data = await response.json()
assert data['gifs'] == []
async def test_index_default_params(client, app):
giphy_client_mock = mock.AsyncMock(spec=GiphyClient)
giphy_client_mock.search.return_value = {
'data': [],
}
with app.container.giphy_client.override(giphy_client_mock):
response = await client.get('/')
assert response.status == 200
data = await response.json()
assert data['query'] == app.container.config.search.default_query()
assert data['limit'] == app.container.config.search.default_limit()
Теперь давайте запустим тестирование и проверим покрытие:
py.test giphynavigator/tests.py --cov=giphynavigator
Вы увидите:
platform darwin -- Python 3.8.3, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
plugins: cov-2.10.0, aiohttp-0.3.0, asyncio-0.14.0
collected 3 items
giphynavigator/tests.py ... [100%]
---------- coverage: platform darwin, python 3.8.3-final-0 -----------
Name Stmts Miss Cover
---------------------------------------------------
giphynavigator/__init__.py 0 0 100%
giphynavigator/__main__.py 5 5 0%
giphynavigator/application.py 10 0 100%
giphynavigator/containers.py 10 0 100%
giphynavigator/giphy.py 16 11 31%
giphynavigator/services.py 9 1 89%
giphynavigator/tests.py 35 0 100%
giphynavigator/views.py 7 0 100%
---------------------------------------------------
TOTAL 92 17 82%
Обратите внимание как мы заменяем giphy_client
моком с помощью метода .override()
. Таким образом можно переопределить возвращаемое значения любого провайдера.
Работа закончена. Теперь давайте подведем итоги.
Заключение
Мы построили
aiohttp
REST API приложение применяя принцип dependency injection. Мы использовали Dependency Injector в качестве dependency injection фреймворка.
Преимущество, которое вы получаете с Dependency Injector — это контейнер.
Контейнер начинает окупаться, когда вам нужно понять или изменить структуру приложения. С контейнером это легко, потому что все компоненты приложения и их зависимости в одном месте:
"""Application containers module."""
from dependency_injector import containers, providers
from dependency_injector.ext import aiohttp
from aiohttp import web
from . import giphy, services, views
class ApplicationContainer(containers.DeclarativeContainer):
"""Application container."""
app = aiohttp.Application(web.Application)
config = providers.Configuration()
giphy_client = providers.Factory(
giphy.GiphyClient,
api_key=config.giphy.api_key,
timeout=config.giphy.request_timeout,
)
search_service = providers.Factory(
services.SearchService,
giphy_client=giphy_client,
)
index_view = aiohttp.View(
views.index,
search_service=search_service,
default_query=config.search.default_query,
default_limit=config.search.default_limit,
)
Что дальше?