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

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

Привет,

Я создатель 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,    )


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

Что дальше?


Источник: habr.com
К списку статей
Опубликовано: 25.07.2020 06:07:48
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Flask

Python

Проектирование и рефакторинг

Python3

Dependency injection

Inversion of control

Refactoring

Object oriented design

Категории

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

© 2006-2020, personeltest.ru