Русский
Русский
English
Статистика
Реклама

Object oriented design

Flask Dependency Injector руководство по применению dependency injection

25.07.2020 06:07:48 | Автор: admin
Привет,

Я создатель Dependency Injector. Это dependency injection фреймворк для Python.

В этом руководстве хочу показать как применять Dependency Injector для разработки Flask приложений.

Руководство состоит из таких частей:

  1. Что мы будем строить?
  2. Подготовим окружение
  3. Структура проекта
  4. Hello world!
  5. Подключаем стили
  6. Подключаем Github
  7. Сервис поиска
  8. Подключаем поиск
  9. Немного рефакторинга
  10. Добавляем тесты
  11. Заключение

Завершенный проект можно найти на Github.

Для старта необходимо иметь:

  • Python 3.5+
  • Virtual environment

И желательно иметь:

  • Начальные навыки разработки с помощью Flask
  • Общее представление о принципе dependency injection

Что мы будем строить?


Мы будем строить приложение, которое помогает искать репозитории на Github. Назовем его Github Navigator.

Как работает Github Navigator?

  • Пользователь открывает веб-страницу где ему предлагают ввести поисковый запрос.
  • Пользователь вводит запрос и нажимает Enter.
  • Github Navigator ищет подходящие репозитории на Github.
  • По окончанию поиска Github Navigator показывает пользователю веб-страницу с результатами.
  • Страница результатов показывает все найденные репозитории и поисковый запрос.
  • Для каждого репозитория пользователь видит:
    • имя репозитория
    • владельца репозитория
    • последний коммит в репозиторий
  • Пользователь может нажать на любой из элементов чтобы открыть его страницу на Github.



Подготовим окружение


В первую очередь нам нужно создать папку проекта и virtual environment:

mkdir ghnav-flask-tutorialcd ghnav-flask-tutorialpython3 -m venv venv

Теперь давайте активируем virtual environment:

. venv/bin/activate

Окружение готово, теперь займемся структурой проекта.

Структура проекта


Создадим в текущей папке следующую структуру. Все файлы пока оставляем пустыми. Это пока не критично.

Начальная структура:

./ githubnavigator/    __init__.py    application.py    containers.py    views.py venv/ requirements.txt

Пришло время установить Flask и Dependency Injector.

Добавим следующие строки в файл requirements.txt:

dependency-injectorflask

Теперь давайте их установим:

pip install -r requirements.txt

И проверим что установка прошла успешно:

python -c "import dependency_injector; print(dependency_injector.__version__)"python -c "import flask; print(flask.__version__)"

Вы увидите что-то вроде:

(venv) $ python -c "import dependency_injector; print(dependency_injector.__version__)"3.22.0(venv) $ python -c "import flask; print(flask.__version__)"1.1.2

Hello world!


Давайте создадим минимальное hello world приложение.

Добавим следующие строки в файл views.py:

"""Views module."""def index():    return 'Hello, World!'

Теперь добавим контейнер зависимостей (дальше просто контейнер). Контейнер будет содержать все компоненты приложения. Добавим первые два компонента. Это Flask приложение и представление index.

Добавим следующее в файл containers.py:

"""Application containers module."""from dependency_injector import containersfrom dependency_injector.ext import flaskfrom flask import Flaskfrom . import viewsclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    app = flask.Application(Flask, __name__)    index_view = flask.View(views.index)

Теперь нам нужно создать фабрику Flask приложения. Ее обычно называют create_app(). Она будет создавать контейнер. Контейнер будет использован для создания Flask приложения. Последним шагом настроим маршрутизацию мы назначим представление index_view из контейнера обрабатывать запросы к корню "/" нашего приложения.

Отредактируем application.py:

"""Application module."""from .containers import ApplicationContainerdef create_app():    """Create and return Flask application."""    container = ApplicationContainer()    app = container.app()    app.container = container    app.add_url_rule('/', view_func=container.index_view.as_view())    return app

Контейнер первый объект в приложении. Он используется для получения всех остальных объектов.

Теперь наше приложение готово сказать Hello, World!.

Выполните в терминале:

export FLASK_APP=githubnavigator.applicationexport FLASK_ENV=developmentflask run

Вывод должен выглядеть приблизительно так:

* Serving Flask app "githubnavigator.application" (lazy loading)* Environment: development* Debug mode: on* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)* Restarting with fsevents reloader* Debugger is active!* Debugger PIN: 473-587-859

Откройте браузер и зайдите на http://127.0.0.1:5000/.

Вы увидите Hello, World!.

Отлично. Наше минимальное приложение успешно стартует и работает.

Давайте сделаем его немного красивее.

Подключаем стили


Мы будем использовать Bootstrap 4. Используем для этого расширение Bootstrap-Flask. Оно поможет нам добавить все нужные файлы в несколько кликов.

Добавим bootstrap-flask в requirements.txt:

dependency-injectorflaskbootstrap-flask

и выполним в терминале:

pip install --upgrade -r requirements.txt

Теперь добавим расширение bootstrap-flask в контейнер.

Отредактируйте containers.py:

"""Application containers module."""from dependency_injector import containersfrom dependency_injector.ext import flaskfrom flask import Flaskfrom flask_bootstrap import Bootstrapfrom . import viewsclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    app = flask.Application(Flask, __name__)    bootstrap = flask.Extension(Bootstrap)    index_view = flask.View(views.index)

Давайте инициализируем расширение bootstrap-flask. Нам нужно будет изменить create_app().

Отредактируйте application.py:

"""Application module."""from .containers import ApplicationContainerdef create_app():    """Create and return Flask application."""    container = ApplicationContainer()    app = container.app()    app.container = container    bootstrap = container.bootstrap()    bootstrap.init_app(app)    app.add_url_rule('/', view_func=container.index_view.as_view())    return app

Теперь нужно добавить шаблоны. Для этого нам понадобится добавить папку templates/ в пакет githubnavigator. Внутри папки с шаблонами добавим два файла:

  • base.html базовый шаблон
  • index.html шаблон основной страницы

Создаем папку templates и два пустых файла внутри base.html и index.html:

./ githubnavigator/    templates/       base.html       index.html    __init__.py    application.py    containers.py    views.py venv/ requirements.txt

Теперь давайте наполним базовый шаблон.

Добавим следующие строки в файл base.html:

<!doctype html><html lang="en">    <head>        {% block head %}        <!-- Required meta tags -->        <meta charset="utf-8">        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">        {% block styles %}            <!-- Bootstrap CSS -->            {{ bootstrap.load_css() }}        {% endblock %}        <title>{% block title %}{% endblock %}</title>        {% endblock %}    </head>    <body>        <!-- Your page content -->        {% block content %}{% endblock %}        {% block scripts %}            <!-- Optional JavaScript -->            {{ bootstrap.load_js() }}        {% endblock %}    </body></html>

Теперь наполним шаблон основной страницы.

Добавим следующие строки в файл index.html:

{% extends "base.html" %}{% block title %}Github Navigator{% endblock %}{% block content %}<div class="container">    <h1 class="mb-4">Github Navigator</h1>    <form>        <div class="form-group form-row">            <div class="col-10">                <label for="search_query" class="col-form-label">                    Search for:                </label>                <input class="form-control" type="text" id="search_query"                       placeholder="Type something to search on the GitHub"                       name="query"                       value="{{ query if query }}">            </div>            <div class="col">                <label for="search_limit" class="col-form-label">                    Limit:                </label>                <select class="form-control" id="search_limit" name="limit">                    {% for value in [5, 10, 20] %}                    <option {% if value == limit %}selected{% endif %}>                        {{ value }}                    </option>                    {% endfor %}                </select>            </div>        </div>    </form>    <p><small>Results found: {{ repositories|length }}</small></p>    <table class="table table-striped">        <thead>            <tr>                <th>#</th>                <th>Repository</th>                <th class="text-nowrap">Repository owner</th>                <th class="text-nowrap">Last commit</th>            </tr>        </thead>        <tbody>        {% for repository in repositories %} {{n}}            <tr>              <th>{{ loop.index }}</th>              <td><a href="{{ repository.url }}">                  {{ repository.name }}</a>              </td>              <td><a href="{{ repository.owner.url }}">                  <img src="{{ repository.owner.avatar_url }}"                       alt="avatar" height="24" width="24"/></a>                  <a href="{{ repository.owner.url }}">                      {{ repository.owner.login }}</a>              </td>              <td><a href="{{ repository.latest_commit.url }}">                  {{ repository.latest_commit.sha }}</a>                  {{ repository.latest_commit.message }}                  {{ repository.latest_commit.author_name }}              </td>            </tr>        {% endfor %}        </tbody>    </table></div>{% endblock %}

Отлично, почти готово. Последним шагом изменим представление index чтобы оно использовало шаблон index.html.

Отредактируем views.py:

"""Views module."""from flask import request, render_templatedef index():    query = request.args.get('query', 'Dependency Injector')    limit = request.args.get('limit', 10, int)    repositories = []    return render_template(        'index.html',        query=query,        limit=limit,        repositories=repositories,    )

Готово.

Убедитесь что приложение работает или выполните flask run и откройте http://127.0.0.1:5000/.

Вы должны увидите:



Подключаем Github


В этом разделе интегрируем наше приложение с Github API.
Мы будем использовать библиотеку PyGithub.

Добавим её в requirements.txt:

dependency-injectorflaskbootstrap-flaskpygithub

и выполним в терминале:

pip install --upgrade -r requirements.txt

Теперь нам нужно добавить Github API клиент в контейнер. Для этого нам нужно будет воспользоваться двумя новыми провайдерами из модуля dependency_injector.providers:

  • Провайдер Factory будет создавать Github клиент.
  • Провайдер Configuration будет передавать API токен и таймаут Github клиенту.

Сделаем это.

Отредактируем containers.py:

"""Application containers module."""from dependency_injector import containers, providersfrom dependency_injector.ext import flaskfrom flask import Flaskfrom flask_bootstrap import Bootstrapfrom github import Githubfrom . import viewsclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    app = flask.Application(Flask, __name__)    bootstrap = flask.Extension(Bootstrap)    config = providers.Configuration()    github_client = providers.Factory(        Github,        login_or_token=config.github.auth_token,        timeout=config.github.request_timeout,    )    index_view = flask.View(views.index)

Мы использовали параметры конфигурации перед тем как задали их значения. Это принцип, по которому работает провайдер Configuration.

Сначала используем, потом задаем значения.

Теперь давайте добавим файл конфигурации.
Будем использовать YAML.

Создайте пустой файл config.yml в корне проекта:

./ githubnavigator/    templates/       base.html       index.html    __init__.py    application.py    containers.py    views.py venv/ config.yml requirements.txt

И заполните его следующими строками:

github:  request_timeout: 10

Для работы с конфигурационным файлом мы будем использовать библиотеку PyYAML. Добавим ее в файл с зависимостями.

Отредактируйте requirements.txt:

dependency-injectorflaskbootstrap-flaskpygithubpyyaml

и установите зависимость:

pip install --upgrade -r requirements.txt

Для передачи API токена мы будем использовать переменную окружения GITHUB_TOKEN.

Теперь нам нужно отредактировать create_app() чтобы сделать 2 действие при старте приложения:

  • Загрузить конфигурацию из config.yml
  • Загрузить API токен из переменной окружения GITHUB_TOKEN

Отредактируйте application.py:

"""Application module."""from .containers import ApplicationContainerdef create_app():    """Create and return Flask application."""    container = ApplicationContainer()    container.config.from_yaml('config.yml')    container.config.github.auth_token.from_env('GITHUB_TOKEN')    app = container.app()    app.container = container    bootstrap = container.bootstrap()    bootstrap.init_app(app)    app.add_url_rule('/', view_func=container.index_view.as_view())    return app

Теперь нам нужно создать API токен.

Для это нужно:

  • Следовать этому руководству на Github
  • Установить токен в переменную окружения:

    export GITHUB_TOKEN=<your token>
    

Этот пункт можно временно пропустить.

Приложение будет работать без токена, но с ограниченной пропускной способностью. Ограничение для неаутентифицированных клиентов: 60 запросов в час. Токен нужен чтобы увеличить эту квоту до 5000 в час.

Готово.

Установка Github API клиента завершена.

Сервис поиска


Пришло время добавить сервис поиска SearchService. Он будет:

  • Выполнять поиск на Github
  • Получать дополнительные данные о коммитах
  • Преобразовывать формат результат

SearchService будет использовать Github API клиент.

Создайте пустой файл services.py в пакете githubnavigator:

./ githubnavigator/    templates/       base.html       index.html    __init__.py    application.py    containers.py    services.py    views.py venv/ config.yml requirements.txt

и добавьте в него следующие строки:

"""Services module."""from github import Githubfrom github.Repository import Repositoryfrom github.Commit import Commitclass SearchService:    """Search service performs search on Github."""    def __init__(self, github_client: Github):        self._github_client = github_client    def search_repositories(self, query, limit):        """Search for repositories and return formatted data."""        repositories = self._github_client.search_repositories(            query=query,            **{'in': 'name'},        )        return [            self._format_repo(repository)            for repository in repositories[:limit]        ]    def _format_repo(self, repository: Repository):        commits = repository.get_commits()        return {            'url': repository.html_url,            'name': repository.name,            'owner': {                'login': repository.owner.login,                'url': repository.owner.html_url,                'avatar_url': repository.owner.avatar_url,            },            'latest_commit': self._format_commit(commits[0]) if commits else {},        }    def _format_commit(self, commit: Commit):        return {            'sha': commit.sha,            'url': commit.html_url,            'message': commit.commit.message,            'author_name': commit.commit.author.name,        }

Теперь добавим SearchService в контейнер.

Отредактируйте containers.py:

"""Application containers module."""from dependency_injector import containers, providersfrom dependency_injector.ext import flaskfrom flask import Flaskfrom flask_bootstrap import Bootstrapfrom github import Githubfrom . import services, viewsclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    app = flask.Application(Flask, __name__)    bootstrap = flask.Extension(Bootstrap)    config = providers.Configuration()    github_client = providers.Factory(        Github,        login_or_token=config.github.auth_token,        timeout=config.github.request_timeout,    )    search_service = providers.Factory(        services.SearchService,        github_client=github_client,    )    index_view = flask.View(views.index)

Подключаем поиск


Теперь мы готовы чтобы поиск заработал. Давайте используем SearchService в index представлении.

Отредактируйте views.py:

"""Views module."""from flask import request, render_templatefrom .services import SearchServicedef index(search_service: SearchService):    query = request.args.get('query', 'Dependency Injector')    limit = request.args.get('limit', 10, int)    repositories = search_service.search_repositories(query, limit)    return render_template(        'index.html',        query=query,        limit=limit,        repositories=repositories,    )

Теперь изменим контейнер чтобы передавать зависимость SearchService в представление index при его вызове.

Отредактируйте containers.py:

"""Application containers module."""from dependency_injector import containers, providersfrom dependency_injector.ext import flaskfrom flask import Flaskfrom flask_bootstrap import Bootstrapfrom github import Githubfrom . import services, viewsclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    app = flask.Application(Flask, __name__)    bootstrap = flask.Extension(Bootstrap)    config = providers.Configuration()    github_client = providers.Factory(        Github,        login_or_token=config.github.auth_token,        timeout=config.github.request_timeout,    )    search_service = providers.Factory(        services.SearchService,        github_client=github_client,    )    index_view = flask.View(        views.index,        search_service=search_service,    )

Убедитесь что приложение работает или выполните flask run и откройте http://127.0.0.1:5000/.

Вы увидите:



Немного рефакторинга


Наше представление index содержит два hardcoded значения:

  • Поисковый запрос по умолчанию
  • Лимит количества результатов

Давайте сделаем небольшой рефакторинг. Мы перенесем эти значения в конфигурацию.

Отредактируйте views.py:

"""Views module."""from flask import request, render_templatefrom .services import SearchServicedef index(        search_service: SearchService,        default_query: str,        default_limit: int,):    query = request.args.get('query', default_query)    limit = request.args.get('limit', default_limit, int)    repositories = search_service.search_repositories(query, limit)    return render_template(        'index.html',        query=query,        limit=limit,        repositories=repositories,    )

Теперь нам нужно чтобы эти значения передавались при вызове. Давайте обновим контейнер.

Отредактируйте containers.py:

"""Application containers module."""from dependency_injector import containers, providersfrom dependency_injector.ext import flaskfrom flask import Flaskfrom flask_bootstrap import Bootstrapfrom github import Githubfrom . import services, viewsclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    app = flask.Application(Flask, __name__)    bootstrap = flask.Extension(Bootstrap)    config = providers.Configuration()    github_client = providers.Factory(        Github,        login_or_token=config.github.auth_token,        timeout=config.github.request_timeout,    )    search_service = providers.Factory(        services.SearchService,        github_client=github_client,    )    index_view = flask.View(        views.index,        search_service=search_service,        default_query=config.search.default_query,        default_limit=config.search.default_limit,    )

Теперь давайте обновим конфигурационный файл.

Отредактируйте config.yml:

github:  request_timeout: 10search:  default_query: "Dependency Injector"  default_limit: 10

Готово.

Рефакторинг закончен. Му сделали код чище.

Добавляем тесты


Было бы хорошо добавить немного тестов. Давайте это сделаем.

Мы будем использовать pytest и coverage.

Отредактируйте requirements.txt:

dependency-injectorflaskbootstrap-flaskpygithubpyyamlpytest-flaskpytest-cov

и установите новые пакеты:

pip install -r requirements.txt

Создайте пустой файл tests.py в пакете githubnavigator:

./ githubnavigator/    templates/       base.html       index.html    __init__.py    application.py    containers.py    services.py    tests.py    views.py venv/ config.yml requirements.txt

и добавьте в него следующие строки:

"""Tests module."""from unittest import mockimport pytestfrom github import Githubfrom flask import url_forfrom .application import create_app@pytest.fixturedef app():    return create_app()def test_index(client, app):    github_client_mock = mock.Mock(spec=Github)    github_client_mock.search_repositories.return_value = [        mock.Mock(            html_url='repo1-url',            name='repo1-name',            owner=mock.Mock(                login='owner1-login',                html_url='owner1-url',                avatar_url='owner1-avatar-url',            ),            get_commits=mock.Mock(return_value=[mock.Mock()]),        ),        mock.Mock(            html_url='repo2-url',            name='repo2-name',            owner=mock.Mock(                login='owner2-login',                html_url='owner2-url',                avatar_url='owner2-avatar-url',            ),            get_commits=mock.Mock(return_value=[mock.Mock()]),        ),    ]    with app.container.github_client.override(github_client_mock):        response = client.get(url_for('index'))    assert response.status_code == 200    assert b'Results found: 2' in response.data    assert b'repo1-url' in response.data    assert b'repo1-name' in response.data    assert b'owner1-login' in response.data    assert b'owner1-url' in response.data    assert b'owner1-avatar-url' in response.data    assert b'repo2-url' in response.data    assert b'repo2-name' in response.data    assert b'owner2-login' in response.data    assert b'owner2-url' in response.data    assert b'owner2-avatar-url' in response.datadef test_index_no_results(client, app):    github_client_mock = mock.Mock(spec=Github)    github_client_mock.search_repositories.return_value = []    with app.container.github_client.override(github_client_mock):        response = client.get(url_for('index'))    assert response.status_code == 200    assert b'Results found: 0' in response.data

Теперь давайте запустим тестирование и проверим покрытие:

py.test githubnavigator/tests.py --cov=githubnavigator

Вы увидите:

platform darwin -- Python 3.8.3, pytest-5.4.3, py-1.9.0, pluggy-0.13.1plugins: flask-1.0.0, cov-2.10.0collected 2 itemsgithubnavigator/tests.py ..                                     [100%]---------- coverage: platform darwin, python 3.8.3-final-0 -----------Name                             Stmts   Miss  Cover----------------------------------------------------githubnavigator/__init__.py          0      0   100%githubnavigator/application.py      11      0   100%githubnavigator/containers.py       13      0   100%githubnavigator/services.py         14      0   100%githubnavigator/tests.py            32      0   100%githubnavigator/views.py             7      0   100%----------------------------------------------------TOTAL                               77      0   100%

Обратите внимание как мы заменяем github_client моком с помощью метода .override(). Таким образом можно переопределить возвращаемое значения любого провайдера.

Заключение


Мы построили Flask приложения применяя принцип dependency injection. Мы использовали Dependency Injector в качестве dependency injection фреймворка.

Основная часть нашего приложения это контейнер. Он содержит все компоненты приложения и их зависимости в одном месте. Это предоставляет контроль над структурой приложения. Её легко понимать и изменять:

"""Application containers module."""from dependency_injector import containers, providersfrom dependency_injector.ext import flaskfrom flask import Flaskfrom flask_bootstrap import Bootstrapfrom github import Githubfrom . import services, viewsclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    app = flask.Application(Flask, __name__)    bootstrap = flask.Extension(Bootstrap)    config = providers.Configuration()    github_client = providers.Factory(        Github,        login_or_token=config.github.auth_token,        timeout=config.github.request_timeout,    )    search_service = providers.Factory(        services.SearchService,        github_client=github_client,    )    index_view = flask.View(        views.index,        search_service=search_service,        default_query=config.search.default_query,        default_limit=config.search.default_limit,    )


Контейнер как карта вашего приложения. Вы всегда знайте что от чего зависит.

Что дальше?


Подробнее..

Aiohttp Dependency Injector руководство по применению dependency injection

02.08.2020 22:16:32 | Автор: admin
Привет,

Я создатель Dependency Injector. Это dependency injection фреймворк для Python.

Продолжаю серию руководств по применению Dependency Injector для построения приложений.

В этом руководстве хочу показать как применять Dependency Injector для разработки aiohttp приложений.

Руководство состоит из таких частей:

  1. Что мы будем строить?
  2. Подготовка окружения
  3. Структура проекта
  4. Установка зависимостей
  5. Минимальное приложение
  6. Giphy API клиент
  7. Сервис поиска
  8. Подключаем поиск
  9. Немного рефакторинга
  10. Добавляем тесты
  11. Заключение

Завершенный проект можно найти на 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-tutorialcd giphynav-aiohttp-tutorialpython3 -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-injectoraiohttpaiohttp-devtoolspyyamlpytest-aiohttppytest-cov

И выполним в терминале:

pip install -r requirements.txt

Дополнительно установим httpie. Это HTTP клиент для командной строки. Мы будем
использовать его для ручного тестирования API.

Выполним в терминале:

pip install httpie

Зависимости установлены. Теперь построим минимальное приложение.

Минимальное приложение


В этом разделе построим минимальное приложение. У него будет эндпоинт, который будет возвращать пустой ответ.

Отредактируем views.py:

"""Views module."""from aiohttp import webasync 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 containersfrom dependency_injector.ext import aiohttpfrom aiohttp import webfrom . import viewsclass 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 webfrom .containers import ApplicationContainerdef 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 OKContent-Length: 844Content-Type: application/json; charset=utf-8Date: Wed, 29 Jul 2020 21:01:50 GMTServer: 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, ClientTimeoutclass 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, providersfrom dependency_injector.ext import aiohttpfrom aiohttp import webfrom . import giphy, viewsclass 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 webfrom .containers import ApplicationContainerdef 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 GiphyClientclass 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, providersfrom dependency_injector.ext import aiohttpfrom aiohttp import webfrom . import giphy, services, viewsclass 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 webfrom .services import SearchServiceasync 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, providersfrom dependency_injector.ext import aiohttpfrom aiohttp import webfrom . import giphy, services, viewsclass 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 OKContent-Length: 850Content-Type: application/json; charset=utf-8Date: Wed, 29 Jul 2020 22:22:55 GMTServer: 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 webfrom .services import SearchServiceasync 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, providersfrom dependency_injector.ext import aiohttpfrom aiohttp import webfrom . import giphy, services, viewsclass 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: 10search:  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 mockimport pytestfrom giphynavigator.application import create_appfrom giphynavigator.giphy import GiphyClient@pytest.fixturedef app():    return create_app()@pytest.fixturedef 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.1plugins: cov-2.10.0, aiohttp-0.3.0, asyncio-0.14.0collected 3 itemsgiphynavigator/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, providersfrom dependency_injector.ext import aiohttpfrom aiohttp import webfrom . import giphy, services, viewsclass 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,    )

Что дальше?


Подробнее..

Мониторинг демон на Asyncio Dependency Injector руководство по применению dependency injection

09.08.2020 08:06:15 | Автор: admin
Привет,

Я создатель Dependency Injector. Это dependency injection фреймворк для Python.

Это еще одно руководство по построению приложений с помощью Dependency Injector.

Сегодня хочу показать как можно построить асинхронный демон на базе модуля asyncio.

Руководство состоит из таких частей:

  1. Что мы будем строить?
  2. Проверка инструментов
  3. Структура проекта
  4. Подготовка окружения
  5. Логирование и конфигурация
  6. Диспетчер
  7. Мониторинг example.com
  8. Мониторинг httpbin.org
  9. Тесты
  10. Заключение

Завершенный проект можно найти на Github.

Для старта желательно иметь:

  • Начальные знания по asyncio
  • Общее представление о принципе dependency injection

Что мы будем строить?


Мы будем строить мониторинг демон, который будет следить за доступом к веб-сервисам.

Демон будет посылать запросы к example.com и httpbin.org каждые несколько секунд. При получении ответа он будет записывать в лог такие данные:

  • Код ответа
  • Количество байт в ответе
  • Время, затраченное на выполнение запроса



Проверка инструментов


Мы будем использовать Docker и docker-compose. Давайте проверим, что они установлены:

docker --versiondocker-compose --version

Вывод должен выглядеть приблизительно так:

Docker version 19.03.12, build 48a66213fedocker-compose version 1.26.2, build eefe0d31

Если Docker или docker-compose не установлены, их нужно установить перед тем как продолжить. Следуйте этим руководствам:


Инструменты готовы. Переходим к структуре проекта.

Структура проекта


Создаем папку проекта и переходим в нее:

mkdir monitoring-daemon-tutorialcd monitoring-daemon-tutorial

Теперь нам нужно создать начальную структуру проекта. Создаем файлы и папки следуя структуре ниже. Все файлы пока будут пустыми. Мы наполним их позже.

Начальная структура проекта:

./ monitoringdaemon/    __init__.py    __main__.py    containers.py config.yml docker-compose.yml Dockerfile requirements.txt

Начальная структура проекта готова. Мы расширим ее с следующих секциях.

Дальше нас ждет подготовка окружения.

Подготовка окружения


В этом разделе мы подготовим окружение для запуска нашего демона.

Для начала нужно определить зависимости. Мы будем использовать такие пакеты:

  • dependency-injector dependency injection фреймворк
  • aiohttp веб фреймворк (нам нужен только http клиент)
  • pyyaml библиотека для парсинга YAML файлов, используется для чтения конфига
  • pytest фреймворк для тестирования
  • pytest-asyncio библиотека-помогатор для тестирования asyncio приложений
  • pytest-cov библиотека-помогатор для измерения покрытия кода тестами

Добавим следующие строки в файл requirements.txt:

dependency-injectoraiohttppyyamlpytestpytest-asynciopytest-cov

И выполним в терминале:

pip install -r requirements.txt

Далее создаем Dockerfile. Он будет описывать процесс сборки и запуска нашего демона. Мы будем использовать python:3.8-buster в качестве базового образа.

Добавим следующие строки в файл Dockerfile:

FROM python:3.8-busterENV PYTHONUNBUFFERED=1WORKDIR /codeCOPY . /code/RUN apt-get install openssl \ && pip install --upgrade pip \ && pip install -r requirements.txt \ && rm -rf ~/.cacheCMD ["python", "-m", "monitoringdaemon"]

Последним шагом определим настройки docker-compose.

Добавим следующие строки в файл docker-compose.yml:

version: "3.7"services:  monitor:    build: ./    image: monitoring-daemon    volumes:      - "./:/code"

Все готово. Давайте запустим сборку образа и проверим что окружение настроено верно.

Выполним в терминале:

docker-compose build

Процесс сборки может занять несколько минут. В конце вы должны увидеть:

Successfully built 5b4ee5e76e35Successfully tagged monitoring-daemon:latest

После того как процесс сборки завершен запустим контейнер:

docker-compose up

Вы увидите:

Creating network "monitoring-daemon-tutorial_default" with the default driverCreating monitoring-daemon-tutorial_monitor_1 ... doneAttaching to monitoring-daemon-tutorial_monitor_1monitoring-daemon-tutorial_monitor_1 exited with code 0

Окружение готово. Контейнер запускается и завершает работу с кодом 0.

Следующим шагом мы настроим логирование и чтение файла конфигурации.

Логирование и конфигурация


В этом разделе мы настроим логирование и чтение файла конфигурации.

Начнем с добавления основной части нашего приложения контейнера зависимостей (дальше просто контейнера). Контейнер будет содержать все компоненты приложения.

Добавим первые два компонента. Это объект конфигурации и функция настройки логирования.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )

Мы использовали параметры конфигурации перед тем как задали их значения. Это принцип, по которому работает провайдер Configuration.

Сначала используем, потом задаем значения.

Настройки логирования будут содержаться в конфигурационном файле.

Отредактируем config.yml:

log:  level: "INFO"  format: "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"

Теперь определим функцию, которая будет запускать наш демон. Её обычно называют main(). Она будет создавать контейнер. Контейнер будет использован для чтения конфигурационного файла и вызова функции настройки логирования.

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main() -> None:    """Run the application."""    container = ApplicationContainer()    container.config.from_yaml('config.yml')    container.configure_logging()if __name__ == '__main__':    main()

Контейнер первый объект в приложении. Он используется для получения всех остальных объектов.

Логирование и чтение конфигурации настроено. В следующем разделе мы создадим диспетчер мониторинговых задач.

Диспетчер


Пришло время добавить диспетчер мониторинговых задач.

Диспетчер будет содержать список мониторинговых задач и контролировать их выполнение. Он будет выполнять каждую задачу в соответствии с расписанием. Класс Monitor базовый класс для мониторинговых задач. Для создания конкретных задач нужно добавлять дочерние классы и реализовывать метод check().


Добавим диспетчер и базовый класс мониторинговой задачи.

Создадим dispatcher.py и monitors.py в пакете monitoringdaemon:

./ monitoringdaemon/    __init__.py    __main__.py    containers.py    dispatcher.py    monitors.py config.yml docker-compose.yml Dockerfile requirements.txt

Добавим следующие строки в файл monitors.py:

"""Monitors module."""import loggingclass Monitor:    def __init__(self, check_every: int) -> None:        self.check_every = check_every        self.logger = logging.getLogger(self.__class__.__name__)    async def check(self) -> None:        raise NotImplementedError()

и в файл dispatcher.py:

""""Dispatcher module."""import asyncioimport loggingimport signalimport timefrom typing import Listfrom .monitors import Monitorclass Dispatcher:    def __init__(self, monitors: List[Monitor]) -> None:        self._monitors = monitors        self._monitor_tasks: List[asyncio.Task] = []        self._logger = logging.getLogger(self.__class__.__name__)        self._stopping = False    def run(self) -> None:        asyncio.run(self.start())    async def start(self) -> None:        self._logger.info('Starting up')        for monitor in self._monitors:            self._monitor_tasks.append(                asyncio.create_task(self._run_monitor(monitor)),            )        asyncio.get_event_loop().add_signal_handler(signal.SIGTERM, self.stop)        asyncio.get_event_loop().add_signal_handler(signal.SIGINT, self.stop)        await asyncio.gather(*self._monitor_tasks, return_exceptions=True)        self.stop()    def stop(self) -> None:        if self._stopping:            return        self._stopping = True        self._logger.info('Shutting down')        for task, monitor in zip(self._monitor_tasks, self._monitors):            task.cancel()        self._logger.info('Shutdown finished successfully')    @staticmethod    async def _run_monitor(monitor: Monitor) -> None:        def _until_next(last: float) -> float:            time_took = time.time() - last            return monitor.check_every - time_took        while True:            time_start = time.time()            try:                await monitor.check()            except asyncio.CancelledError:                break            except Exception:                monitor.logger.exception('Error executing monitor check')            await asyncio.sleep(_until_next(last=time_start))

Диспетчер нужно добавить в контейнер.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            # TODO: add monitors        ),    )

Каждый компонент добавляется в контейнер.

В завершении нам нужно обновить функцию main(). Мы получим диспетчер из контейнера и вызовем его метод run().

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main() -> None:    """Run the application."""    container = ApplicationContainer()    container.config.from_yaml('config.yml')    container.configure_logging()    dispatcher = container.dispatcher()    dispatcher.run()if __name__ == '__main__':    main()

Теперь запустим демон и проверим его работу.

Выполним в терминале:

docker-compose up

Вывод должен выглядеть так:

Starting monitoring-daemon-tutorial_monitor_1 ... doneAttaching to monitoring-daemon-tutorial_monitor_1monitor_1  | [2020-08-08 16:12:35,772] [INFO] [Dispatcher]: Starting upmonitor_1  | [2020-08-08 16:12:35,774] [INFO] [Dispatcher]: Shutting downmonitor_1  | [2020-08-08 16:12:35,774] [INFO] [Dispatcher]: Shutdown finished successfullymonitoring-daemon-tutorial_monitor_1 exited with code 0

Все работает верно. Диспетчер запускается и выключается так как мониторинговых задач нет.

К концу этого раздела каркас нашего демона готов. В следующем разделе мы добавим первую мониторинговую задачу.

Мониторинг example.com


В этом разделе мы добавим мониторинговую задачу, которая будет следить за доступом к http://example.com.

Мы начнем с расширения нашей модели классов новым типом мониторинговой задачи HttpMonitor.

HttpMonitor это дочерний класс Monitor. Мы реализуем метод check(). Он будет отправлять HTTP запрос и логировать полученный ответ. Детали выполнения HTTP запроса будут делегированы классу HttpClient.


Сперва добавим HttpClient.

Создадим файл http.py в пакете monitoringdaemon:

./ monitoringdaemon/    __init__.py    __main__.py    containers.py    dispatcher.py    http.py    monitors.py config.yml docker-compose.yml Dockerfile requirements.txt

И добавим в него следующие строки:

"""Http client module."""from aiohttp import ClientSession, ClientTimeout, ClientResponseclass HttpClient:    async def request(self, method: str, url: str, timeout: int) -> ClientResponse:        async with ClientSession(timeout=ClientTimeout(timeout)) as session:            async with session.request(method, url) as response:                return response

Далее нужно добавить HttpClient в контейнер.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import http, dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    http_client = providers.Factory(http.HttpClient)    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            # TODO: add monitors        ),    )

Теперь мы готовы добавить HttpMonitor. Добавим его в модуль monitors.

Отредактируем monitors.py:

"""Monitors module."""import loggingimport timefrom typing import Dict, Anyfrom .http import HttpClientclass Monitor:    def __init__(self, check_every: int) -> None:        self.check_every = check_every        self.logger = logging.getLogger(self.__class__.__name__)    async def check(self) -> None:        raise NotImplementedError()class HttpMonitor(Monitor):    def __init__(            self,            http_client: HttpClient,            options: Dict[str, Any],    ) -> None:        self._client = http_client        self._method = options.pop('method')        self._url = options.pop('url')        self._timeout = options.pop('timeout')        super().__init__(check_every=options.pop('check_every'))    @property    def full_name(self) -> str:        return '{0}.{1}(url="{2}")'.format(__name__, self.__class__.__name__, self._url)    async def check(self) -> None:        time_start = time.time()        response = await self._client.request(            method=self._method,            url=self._url,            timeout=self._timeout,        )        time_end = time.time()        time_took = time_end - time_start        self.logger.info(            'Response code: %s, content length: %s, request took: %s seconds',            response.status,            response.content_length,            round(time_took, 3)        )

У нас все готово для добавления проверки http://example.com. Нам нужно сделать два изменения в контейнере:

  • Добавить фабрику example_monitor.
  • Передать example_monitor в диспетчер.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import http, monitors, dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    http_client = providers.Factory(http.HttpClient)    example_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.example,    )    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            example_monitor,        ),    )

Провайдер example_monitor имеет зависимость от значений конфигурации. Давайте добавим эти значения:

Отредактируем config.yml:

log:  level: "INFO"  format: "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"monitors:  example:    method: "GET"    url: "http://example.com"    timeout: 5    check_every: 5

Все готово. Запускаем демон и проверяем работу.

Выполняем в терминале:

docker-compose up

И видим подобный вывод:

Starting monitoring-daemon-tutorial_monitor_1 ... doneAttaching to monitoring-daemon-tutorial_monitor_1monitor_1  | [2020-08-08 17:06:41,965] [INFO] [Dispatcher]: Starting upmonitor_1  | [2020-08-08 17:06:42,033] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET http://example.commonitor_1  |     response code: 200monitor_1  |     content length: 648monitor_1  |     request took: 0.067 secondsmonitor_1  |monitor_1  | [2020-08-08 17:06:47,040] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET http://example.commonitor_1  |     response code: 200monitor_1  |     content length: 648monitor_1  |     request took: 0.073 seconds

Наш демон может следить за наличием доступа к http://example.com.

Давайте добавим мониторинг https://httpbin.org.

Мониторинг httpbin.org


В этом разделе мы добавим мониторинговую задачу, которая будет следить за доступом к http://example.com.

Добавление мониторинговой задачи для https://httpbin.org будет сделать легче, так как все компоненты уже готовы. Нам просто нужно добавить новый провайдер в контейнер и обновить конфигурацию.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import http, monitors, dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    http_client = providers.Factory(http.HttpClient)    example_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.example,    )    httpbin_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.httpbin,    )    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            example_monitor,            httpbin_monitor,        ),    )

Отредактируем config.yml:

log:  level: "INFO"  format: "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"monitors:  example:    method: "GET"    url: "http://example.com"    timeout: 5    check_every: 5  httpbin:    method: "GET"    url: "https://httpbin.org/get"    timeout: 5    check_every: 5

Запустим демон и проверим логи.

Выполним в терминале:

docker-compose up

И видим подобный вывод:

Starting monitoring-daemon-tutorial_monitor_1 ... doneAttaching to monitoring-daemon-tutorial_monitor_1monitor_1  | [2020-08-08 18:09:08,540] [INFO] [Dispatcher]: Starting upmonitor_1  | [2020-08-08 18:09:08,618] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET http://example.commonitor_1  |     response code: 200monitor_1  |     content length: 648monitor_1  |     request took: 0.077 secondsmonitor_1  |monitor_1  | [2020-08-08 18:09:08,722] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET https://httpbin.org/getmonitor_1  |     response code: 200monitor_1  |     content length: 310monitor_1  |     request took: 0.18 secondsmonitor_1  |monitor_1  | [2020-08-08 18:09:13,619] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET http://example.commonitor_1  |     response code: 200monitor_1  |     content length: 648monitor_1  |     request took: 0.066 secondsmonitor_1  |monitor_1  | [2020-08-08 18:09:13,681] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET https://httpbin.org/getmonitor_1  |     response code: 200monitor_1  |     content length: 310monitor_1  |     request took: 0.126 seconds

Функциональная часть завершена. Демон следит за наличием доступа к http://example.com и https://httpbin.org.

В следующем разделе мы добавим несколько тестов.

Тесты


Было бы неплохо добавить несколько тестов. Давайте сделаем это.

Создаем файл tests.py в пакете monitoringdaemon:

./ monitoringdaemon/    __init__.py    __main__.py    containers.py    dispatcher.py    http.py    monitors.py    tests.py config.yml docker-compose.yml Dockerfile requirements.txt

и добавляем в него следующие строки:

"""Tests module."""import asyncioimport dataclassesfrom unittest import mockimport pytestfrom .containers import ApplicationContainer@dataclasses.dataclassclass RequestStub:    status: int    content_length: int@pytest.fixturedef container():    container = ApplicationContainer()    container.config.from_dict({        'log': {            'level': 'INFO',            'formant': '[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s',        },        'monitors': {            'example': {                'method': 'GET',                'url': 'http://fake-example.com',                'timeout': 1,                'check_every': 1,            },            'httpbin': {                'method': 'GET',                'url': 'https://fake-httpbin.org/get',                'timeout': 1,                'check_every': 1,            },        },    })    return container@pytest.mark.asyncioasync def test_example_monitor(container, caplog):    caplog.set_level('INFO')    http_client_mock = mock.AsyncMock()    http_client_mock.request.return_value = RequestStub(        status=200,        content_length=635,    )    with container.http_client.override(http_client_mock):        example_monitor = container.example_monitor()        await example_monitor.check()    assert 'http://fake-example.com' in caplog.text    assert 'response code: 200' in caplog.text    assert 'content length: 635' in caplog.text@pytest.mark.asyncioasync def test_dispatcher(container, caplog, event_loop):    caplog.set_level('INFO')    example_monitor_mock = mock.AsyncMock()    httpbin_monitor_mock = mock.AsyncMock()    with container.example_monitor.override(example_monitor_mock), \            container.httpbin_monitor.override(httpbin_monitor_mock):        dispatcher = container.dispatcher()        event_loop.create_task(dispatcher.start())        await asyncio.sleep(0.1)        dispatcher.stop()    assert example_monitor_mock.check.called    assert httpbin_monitor_mock.check.called

Для запуска тестов выполним в терминале:

docker-compose run --rm monitor py.test monitoringdaemon/tests.py --cov=monitoringdaemon

Должен получиться подобный результат:

platform linux -- Python 3.8.3, pytest-6.0.1, py-1.9.0, pluggy-0.13.1rootdir: /codeplugins: asyncio-0.14.0, cov-2.10.0collected 2 itemsmonitoringdaemon/tests.py ..                                    [100%]----------- coverage: platform linux, python 3.8.3-final-0 -----------Name                             Stmts   Miss  Cover----------------------------------------------------monitoringdaemon/__init__.py         0      0   100%monitoringdaemon/__main__.py         9      9     0%monitoringdaemon/containers.py      11      0   100%monitoringdaemon/dispatcher.py      43      5    88%monitoringdaemon/http.py             6      3    50%monitoringdaemon/monitors.py        23      1    96%monitoringdaemon/tests.py           37      0   100%----------------------------------------------------TOTAL                              129     18    86%

Обратите внимание как в тесте test_example_monitor мы подменяем HttpClient моком с помощью метода .override(). Таким образом можно переопределить возвращаемое значения любого провайдера.

Такие же действия выполняются в тесте test_dispatcher для подмены моками мониторинговых задач.


Заключение


Мы построили мониторинг демон на базе asyncio применяя принцип dependency injection. Мы использовали Dependency Injector в качестве dependency injection фреймворка.

Преимущество, которое вы получаете с Dependency Injector это контейнер.

Контейнер начинает окупаться, когда вам нужно понять или изменить структуру приложения. С контейнером это легко, потому что все компоненты приложения и их зависимости в одном месте:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import http, monitors, dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    http_client = providers.Factory(http.HttpClient)    example_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.example,    )    httpbin_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.httpbin,    )    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            example_monitor,            httpbin_monitor,        ),    )


Контейнер как карта вашего приложения. Вы всегда знайте что от чего зависит.

Что дальше?


Подробнее..

CLI приложение Dependency Injector руководство по применению dependency injection Вопросы ответы

14.08.2020 02:18:06 | Автор: admin
Привет,

Я создатель Dependency Injector. Это dependency injection фреймворк для Python.

Это завершающее руководство по построению приложений с помощью Dependency Injector. Прошлые руководства рассказывают как построить веб-приложение на Flask, REST API на Aiohttp и мониторинг демона на Asyncio применяя принцип dependency injection.

Сегодня хочу показать как можно построить консольное (CLI) приложение.

Дополнительно я подготовил ответы на часто задаваемые вопросы и опубликую их постскриптум.

Руководство состоит из таких частей:

  1. Что мы будем строить?
  2. Подготовка окружения
  3. Структура проекта
  4. Установка зависимостей
  5. Фикстуры
  6. Контейнер
  7. Работа с csv
  8. Работа с sqlite
  9. Провайдер Selector
  10. Тесты
  11. Заключение
  12. PS: вопросы и ответы

Завершенный проект можно найти на Github.

Для старта необходимо иметь:

  • Python 3.5+
  • Virtual environment

И желательно иметь общее представление о принципе dependency injection.

Что мы будем строить?


Мы будем строить CLI (консольное) приложение, которое ищет фильмы. Назовем его Movie Lister.

Как работает Movie Lister?

  • У нас есть база данных фильмов
  • О каждом фильме известна такая информация:
    • Название
    • Год выпуска
    • Имя режиссёра
  • База данных распространяется в двух форматах:
    • Csv файл
    • Sqlite база данных
  • Приложение выполняет поиск по базе данных по таким критериям:
    • Имя режиссёра
    • Год выпуска
  • Другие форматы баз данных могут быть добавлены в будущем

Movie Lister это приложение-пример, которое используется в статье Мартина Фаулера о dependency injection и inversion of control.

Вот как выглядит диаграмма классов приложения Movie Lister:


Обязанности между классами распределены так:

  • MovieLister отвечает за поиск
  • MovieFinder отвечает за извлечение данных из базы
  • Movie класс сущности фильм

Подготовка окружения


Начнём с подготовки окружения.

В первую очередь нам нужно создать папку проекта и virtual environment:

mkdir movie-lister-tutorialcd movie-lister-tutorialpython3 -m venv venv

Теперь давайте активируем virtual environment:

. venv/bin/activate

Окружение готово. Теперь займемся структурой проекта.

Структура проекта


В этом разделе организуем структуру проекта.

Создадим в текущей папке следующую структуру. Все файлы пока оставляем пустыми.

Начальная структура:

./ movies/    __init__.py    __main__.py    containers.py venv/ config.yml requirements.txt

Установка зависимостей


Пришло время установить зависимости. Мы будем использовать такие пакеты:

  • dependency-injector dependency injection фреймворк
  • pyyaml библиотека для парсинга YAML файлов, используется для чтения конфига
  • pytest фреймворк для тестирования
  • pytest-cov библиотека-помогатор для измерения покрытия кода тестами

Добавим следующие строки в файл requirements.txt:

dependency-injectorpyyamlpytestpytest-cov

И выполним в терминале:

pip install -r requirements.txt

Установка зависимостей завершена. Переходим к фикстурам.

Фикстуры


В это разделе мы добавим фикстуры. Фикстурами называют тестовые данные.

Мы создадим скрипт, который создаст тестовые базы данных.

Добавляем директорию data/ в корень проекта и внутрь добавляем файл fixtures.py:

./ data/    fixtures.py movies/    __init__.py    __main__.py    containers.py venv/ config.yml requirements.txt

Далее редактируем fixtures.py:

"""Fixtures module."""import csvimport sqlite3import pathlibSAMPLE_DATA = [    ('The Hunger Games: Mockingjay - Part 2', 2015, 'Francis Lawrence'),    ('Rogue One: A Star Wars Story', 2016, 'Gareth Edwards'),    ('The Jungle Book', 2016, 'Jon Favreau'),]FILE = pathlib.Path(__file__)DIR = FILE.parentCSV_FILE = DIR / 'movies.csv'SQLITE_FILE = DIR / 'movies.db'def create_csv(movies_data, path):    with open(path, 'w') as opened_file:        writer = csv.writer(opened_file)        for row in movies_data:            writer.writerow(row)def create_sqlite(movies_data, path):    with sqlite3.connect(path) as db:        db.execute(            'CREATE TABLE IF NOT EXISTS movies '            '(title text, year int, director text)'        )        db.execute('DELETE FROM movies')        db.executemany('INSERT INTO movies VALUES (?,?,?)', movies_data)def main():    create_csv(SAMPLE_DATA, CSV_FILE)    create_sqlite(SAMPLE_DATA, SQLITE_FILE)    print('OK')if __name__ == '__main__':    main()

Теперь выполним в терминале:

python data/fixtures.py

Скрипт должен вывести OK при успешном завершении.

Проверим, что файлы movies.csv и movies.db появились в директории data/:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py venv/ config.yml requirements.txt

Фикстуры созданы. Продолжаем.

Контейнер


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

Контейнер позволяет описать структуру приложения в декларативном стиле. Он будет содержать все компоненты приложения их зависимости. Все зависимости будут указаны явно. Для добавления компонентов приложения в контейнер используются провайдеры. Провайдеры управляют временем жизни компонентов. При создании провайдера не происходит создание компонента. Мы указываем провайдеру как создавать объект, и он создаст его как только в этом будет необходимость. Если зависимостью одного провайдера является другой провайдер, то он будет вызван и так далее по цепочке зависимостей.

Отредактируем containers.py:

"""Containers module."""from dependency_injector import containersclass ApplicationContainer(containers.DeclarativeContainer):    ...

Контейнер пока пуст. Мы добавим провайдеры в следующих секциях.

Давайте еще добавим функцию main(). Её обязанность запускать приложение. Пока она будет только создавать контейнер.

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main():    container = ApplicationContainer()if __name__ == '__main__':    main()

Контейнер первый объект в приложении. Он используется для получения всех остальных объектов.

Работа с csv


Теперь добавим все что нужно для работы с csv файлами.

Нам понадобится:

  • Сущность Movie
  • Базовый класс MovieFinder
  • Его реализация CsvMovieFinder
  • Класс MovieLister

После добавления каждого компонента будем добавлять его в контейнер.



Создаем файл entities.py в пакете movies:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py    entities.py venv/ config.yml requirements.txt

и добавляем внутрь следующие строки:

"""Movie entities module."""class Movie:    def __init__(self, title: str, year: int, director: str):        self.title = str(title)        self.year = int(year)        self.director = str(director)    def __repr__(self):        return '{0}(title={1}, year={2}, director={3})'.format(            self.__class__.__name__,            repr(self.title),            repr(self.year),            repr(self.director),        )

Теперь нам нужно добавить фабрику Movie в контейнер. Для этого нам понадобиться модуль providers из dependency_injector.

Отредактируем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import entitiesclass ApplicationContainer(containers.DeclarativeContainer):    movie = providers.Factory(entities.Movie)

Не забудьте убрать эллипсис (...). В контейнере уже есть провайдеры и он больше не нужен.

Переходим к созданию finders.

Создаем файл finders.py в пакете movies:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py    entities.py    finders.py venv/ config.yml requirements.txt

и добавляем внутрь следующие строки:

"""Movie finders module."""import csvfrom typing import Callable, Listfrom .entities import Movieclass MovieFinder:    def __init__(self, movie_factory: Callable[..., Movie]) -> None:        self._movie_factory = movie_factory    def find_all(self) -> List[Movie]:        raise NotImplementedError()class CsvMovieFinder(MovieFinder):    def __init__(            self,            movie_factory: Callable[..., Movie],            path: str,            delimiter: str,    ) -> None:        self._csv_file_path = path        self._delimiter = delimiter        super().__init__(movie_factory)    def find_all(self) -> List[Movie]:        with open(self._csv_file_path) as csv_file:            csv_reader = csv.reader(csv_file, delimiter=self._delimiter)            return [self._movie_factory(*row) for row in csv_reader]

Теперь добавим CsvMovieFinder в контейнер.

Отредактируем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )

У CsvMovieFinder есть зависимость от фабрики Movie. CsvMovieFinder нуждается в фабрике так как будет создавать объекты Movie по мере того как будет читать данные из файла. Для того чтобы передать фабрику мы используем атрибут .provider. Это называется делегирование провайдеров. Если мы укажем фабрику movie как зависимость, она будет вызвана когда csv_finder будет создавать CsvMovieFinder и в качестве инъекции будет передан объект Movie. Используя атрибут .provider в качестве инъекции будет передам сам провайдер.

У csv_finder еще есть зависимость от нескольких опций конфигурации. Мы добавили провайдер Сonfiguration чтобы передать эти зависимости.

Мы использовали параметры конфигурации перед тем как задали их значения. Это принцип, по которому работает провайдер Configuration.

Сначала используем, потом задаем значения.

Теперь давайте добавим значения конфигурации.

Отредактируем config.yml:

finder:  csv:    path: "data/movies.csv"    delimiter: ","

Значения установлены в конфигурационный файл. Обновим функцию main() чтобы указать его расположение.

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main():    container = ApplicationContainer()    container.config.from_yaml('config.yml')if __name__ == '__main__':    main()

Переходим к listers.

Создаем файл listers.py в пакете movies:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py    entities.py    finders.py    listers.py venv/ config.yml requirements.txt

и добавляем внутрь следующие строки:

"""Movie listers module."""from .finders import MovieFinderclass MovieLister:    def __init__(self, movie_finder: MovieFinder):        self._movie_finder = movie_finder    def movies_directed_by(self, director):        return [            movie for movie in self._movie_finder.find_all()            if movie.director == director        ]    def movies_released_in(self, year):        return [            movie for movie in self._movie_finder.find_all()            if movie.year == year        ]

Обновляем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, listers, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )    lister = providers.Factory(        listers.MovieLister,        movie_finder=csv_finder,    )

Все компоненты созданы и добавлены в контейнер.

В завершение обновляем функцию main().

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main():    container = ApplicationContainer()    container.config.from_yaml('config.yml')    lister = container.lister()    print(        'Francis Lawrence movies:',        lister.movies_directed_by('Francis Lawrence'),    )    print(        '2016 movies:',        lister.movies_released_in(2016),    )if __name__ == '__main__':    main()

Все готово. Теперь запустим приложение.

Выполним в терминале:

python -m movies

Вы увидите:

Francis Lawrence movies: [Movie(title='The Hunger Games: Mockingjay - Part 2', year=2015, director='Francis Lawrence')]2016 movies: [Movie(title='Rogue One: A Star Wars Story', year=2016, director='Gareth Edwards'), Movie(title='The Jungle Book', year=2016, director='Jon Favreau')]

Наше приложение работает с базой данных фильмов в формате csv. Нам нужно еще добавить поддержку формата sqlite. Разберемся с этим в следующем разделе.

Работа с sqlite


В это разделе мы добавим другой тип MovieFinder SqliteMovieFinder.

Отредактируем finders.py:

"""Movie finders module."""import csvimport sqlite3from typing import Callable, Listfrom .entities import Movieclass MovieFinder:    def __init__(self, movie_factory: Callable[..., Movie]) -> None:        self._movie_factory = movie_factory    def find_all(self) -> List[Movie]:        raise NotImplementedError()class CsvMovieFinder(MovieFinder):    def __init__(            self,            movie_factory: Callable[..., Movie],            path: str,            delimiter: str,    ) -> None:        self._csv_file_path = path        self._delimiter = delimiter        super().__init__(movie_factory)    def find_all(self) -> List[Movie]:        with open(self._csv_file_path) as csv_file:            csv_reader = csv.reader(csv_file, delimiter=self._delimiter)            return [self._movie_factory(*row) for row in csv_reader]class SqliteMovieFinder(MovieFinder):    def __init__(            self,            movie_factory: Callable[..., Movie],            path: str,    ) -> None:        self._database = sqlite3.connect(path)        super().__init__(movie_factory)    def find_all(self) -> List[Movie]:        with self._database as db:            rows = db.execute('SELECT title, year, director FROM movies')            return [self._movie_factory(*row) for row in rows]

Добавляем провайдер sqlite_finder в контейнер и указываем его в качестве зависимости для провайдера lister.

Отредактируем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, listers, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )    sqlite_finder = providers.Singleton(        finders.SqliteMovieFinder,        movie_factory=movie.provider,        path=config.finder.sqlite.path,    )    lister = providers.Factory(        listers.MovieLister,        movie_finder=sqlite_finder,    )

У провайдера sqlite_finder есть зависимость от опций конфигурации, которые мы еще не определили. Обновим файл конфигурации:

Отредактируем config.yml:

finder:  csv:    path: "data/movies.csv"    delimiter: ","  sqlite:    path: "data/movies.db"

Готово. Давайте проверим.

Выполняем в терминале:

python -m movies

Вы увидите:

Francis Lawrence movies: [Movie(title='The Hunger Games: Mockingjay - Part 2', year=2015, director='Francis Lawrence')]2016 movies: [Movie(title='Rogue One: A Star Wars Story', year=2016, director='Gareth Edwards'), Movie(title='The Jungle Book', year=2016, director='Jon Favreau')]

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

Провайдер Selector


В этом разделе мы сделаем наше приложение более гибким.

Больше не нужно будет делать изменения в коде для переключения между csv и sqlite форматами. Мы реализуем переключатель на базе переменной окружения MOVIE_FINDER_TYPE:

  • Когда MOVIE_FINDER_TYPE=csv приложения использует формат csv.
  • Когда MOVIE_FINDER_TYPE=sqlite приложения использует формат sqlite.

В этом нам поможет провайдер Selector. Он выбирает провайдер на основе опции конфигурации (документация).

Отредактрируем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, listers, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )    sqlite_finder = providers.Singleton(        finders.SqliteMovieFinder,        movie_factory=movie.provider,        path=config.finder.sqlite.path,    )    finder = providers.Selector(        config.finder.type,        csv=csv_finder,        sqlite=sqlite_finder,    )    lister = providers.Factory(        listers.MovieLister,        movie_finder=finder,    )

Мы создали провайдер finder и указали его в качестве зависимости для провайдера lister. Провайдер finder выбирает между провайдерами csv_finder и sqlite_finder во время выполнения. Выбор зависит от значения переключателя.

Переключателем является опция конфигурации config.finder.type. Когда ее значение csv используется провайдер из ключа csv. Аналогично для sqlite.

Теперь нам нужно считать значение config.finder.type из переменной окружения MOVIE_FINDER_TYPE.

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main():    container = ApplicationContainer()    container.config.from_yaml('config.yml')    container.config.finder.type.from_env('MOVIE_FINDER_TYPE')    lister = container.lister()    print(        'Francis Lawrence movies:',        lister.movies_directed_by('Francis Lawrence'),    )    print(        '2016 movies:',        lister.movies_released_in(2016),    )if __name__ == '__main__':    main()

Готово.

Выполним в терминале следующие команды:

MOVIE_FINDER_TYPE=csv python -m moviesMOVIE_FINDER_TYPE=sqlite python -m movies

Вывод при выполнении каждой команды будет выглядеть так:

Francis Lawrence movies: [Movie(title='The Hunger Games: Mockingjay - Part 2', year=2015, director='Francis Lawrence')]2016 movies: [Movie(title='Rogue One: A Star Wars Story', year=2016, director='Gareth Edwards'), Movie(title='The Jungle Book', year=2016, director='Jon Favreau')]

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

Подсказка:
Переопределение значения конфигурации из другого провайдера позволяет реализовать перегрузку конфигурации в приложении без перезапуска, на горячую.
Для этого нужно использовать делегирование провайдеров и метод .override().

В следующем разделе добавим несколько тестов.

Тесты


В завершение добавим несколько тестов.

Создаём файл tests.py в пакете movies:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py    entities.py    finders.py    listers.py    tests.py venv/ config.yml requirements.txt

и добавляем в него следующие строки:

"""Tests module."""from unittest import mockimport pytestfrom .containers import ApplicationContainer@pytest.fixturedef container():    container = ApplicationContainer()    container.config.from_dict({        'finder': {            'type': 'csv',            'csv': {                'path': '/fake-movies.csv',                'delimiter': ',',            },            'sqlite': {                'path': '/fake-movies.db',            },        },    })    return containerdef test_movies_directed_by(container):    finder_mock = mock.Mock()    finder_mock.find_all.return_value = [        container.movie('The 33', 2015, 'Patricia Riggen'),        container.movie('The Jungle Book', 2016, 'Jon Favreau'),    ]    with container.finder.override(finder_mock):        lister = container.lister()        movies = lister.movies_directed_by('Jon Favreau')    assert len(movies) == 1    assert movies[0].title == 'The Jungle Book'def test_movies_released_in(container):    finder_mock = mock.Mock()    finder_mock.find_all.return_value = [        container.movie('The 33', 2015, 'Patricia Riggen'),        container.movie('The Jungle Book', 2016, 'Jon Favreau'),    ]    with container.finder.override(finder_mock):        lister = container.lister()        movies = lister.movies_released_in(2015)    assert len(movies) == 1    assert movies[0].title == 'The 33'

Теперь запустим тестирование и проверим покрытие:

pytest movies/tests.py --cov=movies

Вы увидите:

platform darwin -- Python 3.8.3, pytest-5.4.3, py-1.9.0, pluggy-0.13.1plugins: cov-2.10.0collected 2 itemsmovies/tests.py ..                                              [100%]---------- coverage: platform darwin, python 3.8.3-final-0 -----------Name                   Stmts   Miss  Cover------------------------------------------movies/__init__.py         0      0   100%movies/__main__.py        10     10     0%movies/containers.py       9      0   100%movies/entities.py         7      1    86%movies/finders.py         26     13    50%movies/listers.py          8      0   100%movies/tests.py           24      0   100%------------------------------------------TOTAL                     84     24    71%

Мы использовали метод .override() провайдера finder. Провайдер переопределяется моком. При обращении к провайдеру finder теперь будет возвращен переопределяющий мок.

Работа закончена. Теперь давайте подведем итоги.

Заключение


Мы построили консольное (CLI) приложение применяя принцип dependency injection. Мы использовали Dependency Injector в качестве dependency injection фреймворка.

Преимущество, которое вы получаете с Dependency Injector это контейнер.

Контейнер начинает окупаться, когда вам нужно понять или изменить структуру приложения. С контейнером это легко, потому что все компоненты приложения и их зависимости определены явно и в одном месте:

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, listers, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )    sqlite_finder = providers.Singleton(        finders.SqliteMovieFinder,        movie_factory=movie.provider,        path=config.finder.sqlite.path,    )    finder = providers.Selector(        config.finder.type,        csv=csv_finder,        sqlite=sqlite_finder,    )    lister = providers.Factory(        listers.MovieLister,        movie_finder=finder,    )


Контейнер как карта вашего приложения. Вы всегда знайте что от чего зависит.

PS: вопросы и ответы


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

Я подготовил ответы:

Что такое dependency injection?

  • это принцип который уменьшает связывание (coupling) и увеличивает сцепление (cohesion)

Зачем мне применять dependency injection?

  • твой код становится более гибким, понятным и лучше поддается тестированию
  • у тебя меньше проблем когда тебе нужно понять как он работает или изменить его

Как мне начать применять dependency injection?

  • ты начинаешь писать код следуя принципу dependency injection
  • ты регистрируешь все компоненты и их зависимости в контейнере
  • когда тебе нужен компонент, ты получаешь его из контейнера

Зачем мне для этого фреймворк?

  • тебе нужен фреймворк для того чтобы не создавать свой. Код создания объектов будет дублироваться и его тяжело будет менять. Для того чтобы этого не было тебе нужен контейнер.
  • фреймворк дает тебе контейнер и провайдеры
  • провайдеры управляют временем жизни объектов. Тебе понадобятся фабрики, синглтоны и объекты конфигурации
  • контейнер служит коллекцией провайдеров

Какаю цену я плачу?

  • тебе нужно явно указывать зависимости в контейнере
  • это дополнительная работа
  • это начнет приносить дивиденды когда проект начнет расти
  • или через 2 недели после его завершения (когда ты забудешь какие решения принимал и какова структура проекта)

Концепция Dependency Injector


В дополнение опишу концепцию Dependency Injector как фреймворка.

Dependency Injector основан на двух принципах:

  • Явное лучше неявного (PEP20).
  • Не делать никакой магии с вашим кодом.

Чем Dependency Injector отличается от другим фреймворков?

  • Нет автоматического связывания. Фреймворк не делает автоматического связывания зависимостей. Не используется интроспекция, связывание по именам аргументов и / или типам. Потому что явное лучше неявного (PEP20).
  • Не загрязняет код вашего приложения. Ваше приложение не знает о наличии Dependency Injector и не зависит от него. Никаких @inject декораторов, аннотаций, патчинга или других волшебных трюков.

Dependency Injector предлагает простой контракт:

  • Вы показываете фреймворку как собирать объекты
  • Фреймворк их собирает

Сила Dependency Injector в его простоте и прямолинейности. Это простой инструмент для реализации мощного принципа.

Что дальше?


Если вы заинтересовались, но сомневайтесь, моя рекомендация такая:

Попробуйте применить этот подход на протяжении 2-х месяцев. Он неинтуитивный. Нужно время чтобы привыкнуть и прочувствовать. Польза стает ощутимой, когда проект вырастает до 30+ компонентов в контейнере. Если не понравится много не потеряйте. Если понравится получите существенное преимущество.


Буду рад фидбеку и отвечу на вопросы в комментариях.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru