Я создатель Dependency Injector. Это dependency injection фреймворк для Python.
В этом руководстве хочу показать как применять Dependency Injector для разработки Flask приложений.
Руководство состоит из таких частей:
- Что мы будем строить?
- Подготовим окружение
- Структура проекта
- Hello world!
- Подключаем стили
- Подключаем Github
- Сервис поиска
- Подключаем поиск
- Немного рефакторинга
- Добавляем тесты
- Заключение
Завершенный проект можно найти на 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, )
Контейнер как карта вашего приложения. Вы всегда знайте что от чего зависит.
Что дальше?
- Узнайте больше о Dependency Injector на GitHub
- Ознакомтесь с документацией на Read the Docs
- Есть вопрос или нашли баг? Откройте issue на Github