Что такое Werkzeug?

В этой статье рассказывается о том, что такое Werkzeug и как Flask использует его для своей основной HTTP-функциональности. Изучая материал статьи, вы попутно разработаете собственное WSGI-совместимое приложение с использованием Werkzeug, чтобы создать похожий на Flask веб-фреймворк!

Эта статья предполагает, что у вас уже есть опыт работы с Flask.

Содержание

Зависимости Flask

Вероятно, вы заметили, что при каждой установке Flask вы также устанавливаете следующие зависимости:

Flask — это обертка вокруг всех этих компонентов.

$ pip install Flask
$ pip freeze

blinker==1.7.0
click==8.1.7
Flask==3.0.0
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.3
Werkzeug==3.0.1  # !!!

Что собой представляет Werkzeug?

Werkzeug — это набор библиотек, которые можно использовать для создания WSGI-совместимых веб-приложений на Python.

WSGI (Web Server Gateway Interface) — это интерфейс между веб-сервером и веб-приложением на базе Python. Он необходим для веб-приложений на Python, поскольку веб-сервер не может напрямую взаимодействовать с Python.

Иными словами, Werkzeug предоставляет набор утилит для создания приложения на Python, которое может взаимодействовать с сервером WSGI, например, с Gunicorn.

Werkzeug предоставляет следующую функциональность, используемую Flask:

  • Обработка запросов
  • Обработка ответов
  • Маршрутизация URL
  • Middleware
  • HTTP-утилиты
  • Обработка исключений

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

Давайте рассмотрим пример создания веб-приложения с помощью Werkzeug. Мы также разберем, как Flask реализует аналогичную функциональность.

От редакции Pythonist: возможно, вас заинтересует статья «Лучшие книги по Flask для Python-разработчиков».

Приложение Hello World

В качестве введения в Werkzeug давайте начнем с создания приложения «Hello World», использующего некоторые из ключевых функций, предоставляемых Werkzeug.

Исходный код проекта, о котором пойдет речь в этой статье, вы можете найти на GitLab: https://gitlab.com/patkennedy79/werkzeug_movie_app.

Установка

Начните с создания нового проекта:

$ mkdir werkzeug_movie_app
$ cd werkzeug_movie_app
$ python3 -m venv venv
$ source venv/bin/activate
(venv)$

Установите Werkzeug, Jinja2 и redis-py:

(venv)$ pip install Werkzeug Jinja2 redis
(venv)$ pip freeze > requirements.txt

Redis будет использоваться в качестве хранилища данных о фильмах.

Приложение

Werkzeug — это набор библиотек, используемых для создания WSGI-совместимого веб-приложения. Он не предоставляет высокоуровневых классов, как Flask, для создания полноценного веб-приложения. Вместо этого вам нужно самостоятельно создать приложение из библиотек Werkzeug.

Создайте новый файл app.py в папке верхнего уровня вашего проекта:

from werkzeug.wrappers import Request, Response


class MovieApp(object):
    """Implements a WSGI application for managing your favorite movies."""
    def __init__(self):
        pass

    def dispatch_request(self, request):
        """Dispatches the request."""
        return Response('Hello World!')

    def wsgi_app(self, environ, start_response):
        """WSGI application that processes requests and returns responses."""
        request = Request(environ)
        response = self.dispatch_request(request)
        return response(environ, start_response)

    def __call__(self, environ, start_response):
        """The WSGI server calls this method as the WSGI application."""
        return self.wsgi_app(environ, start_response)


def create_app():
    """Application factory function that returns an instance of MovieApp."""
    app = MovieApp()
    return app

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

Схема взаимодействия браузера, веб-сервера, WSGI-сервера и приложения MovieApp

Когда поступает запрос, он обрабатывается в функции wsgi_app():

def wsgi_app(self, environ, start_response):
    """WSGI application that processes requests and returns responses."""
    request = Request(environ)
    response = self.dispatch_request(request)
    return response(environ, start_response)

Окружение (environ) автоматически обрабатывается в классе Request для создания объекта request. Затем request обрабатывается в dispatch_request(). Для этого примера функция dispatch_request() возвращает ответ ‘Hello World!’. Этот ответ затем возвращается из функции wsgi_app().

Сравнение с Flask

MovieApp — это упрощенная версия класса Flask.

В классе Flask функция wsgi_app() фактически является WSGI-приложением, которое взаимодействует с WSGI-сервером. Кроме того, dispatch_request() и full_dispatch_request() используются для диспетчеризации запросов. Диспетчеризация сопоставляет URL с соответствующей функцией представления и обрабатывает исключения.

Сервер разработки

Добавьте следующий код в нижнюю часть файла app.py, чтобы запустить сервер разработки Werkzeug:

if __name__ == '__main__':
    # Run the Werkzeug development server to serve the WSGI application (MovieApp)
    from werkzeug.serving import run_simple
    app = create_app()
    run_simple('127.0.0.1', 5000, app, use_debugger=True, use_reloader=True)

Запустите приложение:

(venv)$ python app.py

Перейдите по адресу http://127.0.0.1:5000, чтобы увидеть сообщение «Hello World!».

Сравнение с Flask

В классе Flask есть эквивалентный метод run(), который использует сервер разработки Werkzeug.

Связующее ПО для обслуживания статических файлов

В веб-приложениях связующее ПО (англ. middleware) — это программный компонент, который может быть добавлен в конвейер обработки запросов/ответов для выполнения определенной функции.

Одной из важных функций, которую должен выполнять веб-сервер/приложение, является обслуживание статических файлов (CSS, JavaScript и файлы изображений). Werkzeug предоставляет middleware для этой функциональности — SharedDataMiddleware.

SharedDataMiddleware идеально подходит для работы с сервером разработки Werkzeug для обслуживания статических файлов.

В производственной среде вы должны заменить сервер разработки Werkzeug и SharedDataMiddleware на веб-сервер, такой как Nginx, и сервер WSGI, такой как Gunicorn.

Чтобы использовать SharedDataMiddleware, добавьте в проект новую папку «static» вместе с папками «css» и «img»:

├── app.py
├── requirements.txt
└── static
    ├── css
    └── img

В папку «static/img» добавьте логотип Flask (адрес — https://gitlab.com/patkennedy79/werkzeug_movie_app/-/blob/main/static/img/flask.png). Сохраните его под именем flask.png.

Далее разверните функцию фабрики приложения:

def create_app():
    """Application factory function that returns an instance of MovieApp."""
    app = MovieApp()
    app.wsgi_app = SharedDataMiddleware(app.wsgi_app, {
        '/static': os.path.join(os.path.dirname(__file__), 'static')
    })
    return app

Обновите импорты в верхней части:

import os

from werkzeug.middleware.shared_data import SharedDataMiddleware
from werkzeug.wrappers import Request, Response

Теперь при обработке запроса приложением Werkzeug (app), он сначала направляется в SharedDataMiddleware, чтобы определить, был ли запрошен статический файл:

Схема middleware Werkzeug

Если запрашивается статический файл, SharedDataMiddleware сгенерирует ответ со статическим файлом. В противном случае запрос передается по цепочке в приложение Werkzeug для обработки в wsgi_app().

Чтобы увидеть SharedDataMiddleware в действии, запустите сервер и перейдите по адресу http://127.0.0.1:5000/static/img/flask.png. Вы увидите логотип Flask.

Полный список middleware-решений, предоставляемых Werkzeug, можно найти в документации по связующему ПО.

Сравнение с Flask

Flask не использует SharedDataMiddleware. У него другой подход к обслуживанию статических файлов. По умолчанию, если статическая папка существует, Flask автоматически добавляет новое правило URL для обслуживания статических файлов.

Чтобы проиллюстрировать эту концепцию, запустите flask routes в проекте верхнего уровня приложения Flask, и вы увидите:

(venv)$ flask routes

Endpoint     Methods  Rule
-----------  -------  -----------------------
index        GET      /
static       GET      /static/<path:filename>

Шаблоны

Как обычно делается в проектах Flask, в качестве шаблонизатора для нашего приложения мы будем использовать Jinja.

Начнем с добавления в проект новой папки под названием «templates»:

├── app.py
├── requirements.txt
├── static
│   ├── css
│   └── img
│       └── flask.png
└── templates

Чтобы использовать Jinja, расширьте конструктор класса MovieApp:

def __init__(self):
    """Initializes the Jinja templating engine to render from the 'templates' folder."""
    template_path = os.path.join(os.path.dirname(__file__), 'templates')
    self.jinja_env = Environment(loader=FileSystemLoader(template_path),
                                 autoescape=True)

Добавьте импорт:

from jinja2 import Environment, FileSystemLoader

Сравнение с Flask

Flask также использует Jinja Environment для создания шаблонизатора.


В классе MovieApp добавьте новый метод render_template():

def render_template(self, template_name, **context):
    """Renders the specified template file using the Jinja templating engine."""
    template = self.jinja_env.get_template(template_name)
    return Response(template.render(context), mimetype='text/html')

Этот метод принимает template_name и любые переменные для передачи шаблонизатору (**context). Затем он генерирует Response, используя метод render() из Jinja.


Сравнение с Flask

Функция render_template() выглядит знакомо, не так ли? Это одна из самых используемых функций во Flask.


Чтобы увидеть render_template() в действии, обновите dispatch_request() для рендеринга шаблона:

def dispatch_request(self, request):
    """Dispatches the request."""
    return self.render_template('base.html')

Теперь все запросы к приложению будут отрисовывать шаблон templates/base.html.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Werkzeug Movie App</title>

    <!-- CSS file for styling the application -->
    <link rel="stylesheet" href="/static/css/style.css" type="text/css">
</head>
<body>
    <h1>Werkzeug Movie App</h1>
    {% block body %}
    {% endblock %}
</body>
</html>

Обязательно добавьте этот шаблон в папку «templates» и сохраните копию https://gitlab.com/patkennedy79/werkzeug_movie_app/-/blob/main/static/css/style.css в static/css/style.css.

Запустите сервер. Перейдите на страницу http://127.0.0.1:5000. Теперь вы должны увидеть следующее:

На странице выводится текст "Werkzeug Movie App"

Маршрутизация

Маршрутизация означает сопоставление URL с соответствующей функцией представления. Werkzeug предоставляет класс Map, который позволяет сопоставлять URL с функциями представления с помощью объектов Rule.

Давайте создадим объект Map в конструкторе MovieApp, чтобы проиллюстрировать, как это работает:

def __init__(self):
    """Initializes the Jinja templating engine to render from the 'templates' folder."""
    template_path = os.path.join(os.path.dirname(__file__), 'templates')
    self.jinja_env = Environment(loader=FileSystemLoader(template_path),
                                 autoescape=True)
    self.url_map = Map([
        Rule('/', endpoint='index'),
        Rule('/movies', endpoint='movies'),
    ])

Не забудьте про импорт:

from werkzeug.routing import Map, Rule

Каждый объект Rule определяет URL и функцию представления (endpoint), которую нужно вызвать, если URL совпадает:

    self.url_map = Map([
        Rule('/', endpoint='index'),
        Rule('/movies', endpoint='movies'),
    ])

Например, когда запрашивается домашняя страница (‘/’), должна быть вызвана функция представления index.


Сравнение с Flask

Одна из удивительных особенностей Flask — декоратор @route, который используется для назначения URL для функции представления. Этот декоратор обновляет url_map для приложения Flask, аналогично прописанной вручную url_map, которую мы определили выше.


Чтобы использовать сопоставление URL, необходимо обновить функцию dispatch_request():

def dispatch_request(self, request):
    """Dispatches the request."""
    adapter = self.url_map.bind_to_environ(request.environ)
    try:
        endpoint, values = adapter.match()
        return getattr(self, endpoint)(request, **values)
    except HTTPException as e:
        return e

Теперь при поступлении запроса в dispatch_request(), для попытки сопоставления (match()) URL с записью будет использоваться url_map. Если запрашиваемый URL включен в url_map, то будет вызвана соответствующая функция представления (endpoint). Если URL не будет найден в url_map, то будет вызвано исключение. Об обработке исключений речь пойдет чуть позже.

Добавьте импорт:

from werkzeug.exceptions import HTTPException

Мы указали две функции представления в url_map, поэтому давайте создадим их в классе MovieApp:

def index(self, request):
    return self.render_template('base.html')

def movies(self, request):
    return self.render_template('movies.html')

templates/base.html мы создали в предыдущем разделе, а templates/movies.html должен быть создан сейчас:

{% extends "base.html" %}

{% block body %}
<div class="table-container">
    <table>
        <!-- Table Header -->
        <thead>
            <tr>
                <th>Index</th>
                <th>Movie Title</th>
            </tr>
        </thead>

        <!-- Table Elements (Rows) -->
        <tbody>
            <tr>
                <td>1</td>
                <td>Knives Out</td>
            </tr>
            <tr>
                <td>2</td>
                <td>Pirates of the Caribbean</td>
            </tr>
            <tr>
                <td>3</td>
                <td>Inside Man</td>
            </tr>
        </tbody>
    </table>
</div>
{% endblock %}

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

Страница http://127.0.0.1:5000 должна выглядеть так же:

На странице выводится текст "Werkzeug Movie App"

Однако если вы перейдете на http://127.0.0.1:5000/movies, то увидите таблицу фильмов:

На странице выводится текст "Werkzeug Movie App", а под ним - таблица с тремя фильмами и их индексами

Обработка исключений

Попробуйте перейти на http://127.0.0.1:5000/movies2:

Стандартная страница 404

Возвращаемая страница — это страница ошибки по умолчанию, когда URL не найден в url_map:

def dispatch_request(self, request):
    """Dispatches the request."""
    adapter = self.url_map.bind_to_environ(request.environ)
    try:
        endpoint, values = adapter.match()
        return getattr(self, endpoint)(request, **values)
    except HTTPException as e:
        return e

Кроме того, в консоли вы должны увидеть следующее:

127.0.0.1 - - [07/Mar/2021 12:13:17] "GET /movies2 HTTP/1.1" 404 -

Давайте создадим пользовательскую страницу ошибки, расширив dispatch_request():

def dispatch_request(self, request):
    """Dispatches the request."""
    adapter = self.url_map.bind_to_environ(request.environ)
    try:
        endpoint, values = adapter.match()
        return getattr(self, endpoint)(request, **values)
    except NotFound:
        return self.error_404()
    except HTTPException as e:
        return e

Обновите импорт:

from werkzeug.exceptions import HTTPException, NotFound

Теперь, если URL не будет найден в url_map, он будет обрабатываться вызовом error_404(). Создайте этот новый метод в классе MovieApp:

def error_404(self):
    response = self.render_template("404.html")
    response.status_code = 404
    return response

Создайте templates/404.html:

{% extends "base.html" %}

{% block body %}
<div class="error-description">
    <h2>Page Not Found (404)</h2>
    <h4>What you were looking for is just not there!</h4>
    <h4><a href="/">Werkzeug Movie App</a></h4>
</div>
{% endblock %}

Теперь при переходе на сайт http://127.0.0.1:5000/movies2 вы должны увидеть дружелюбное сообщение:

На странице выводится текст: Werkzeug Movie App
Page Not Found (404)
What you were looking for is just not there!

Сравнение с Flask

Когда full_dispatch_request() в классе Flask обнаруживает исключение, оно изящно обрабатывается в handle_user_exceptions(). Flask также позволяет создавать пользовательские страницы ошибок для всех кодов ошибок HTTP.

Обработка запросов

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

Redis

Как уже говорилось, для хранения фильмов мы будем использовать Redis — хранилище структур данных в памяти — из-за его высокой скорости чтения/записи и простоты настройки.

Установка

Установите и запустите Redis.

Самый быстрый способ запустить Redis — это использовать Docker:

$ docker run --name some-redis -d -p 6379:6379 redis

Проверка, что контейнер Redis запущен:

$ docker ps

Остановка работающего контейнера Redis:

$ docker stop some-redis  # Use name of Docker container

Если вы не используете Docker, обратите внимание на следующие ресурсы:

Использование

Чтобы использовать Redis, начните с обновления конструктора MovieApp для создания экземпляра StrictRedis:

def __init__(self, config):  # Updated!!
    """Initializes the Jinja templating engine to render from the 'templates' folder,
    defines the mapping of URLs to view methods, and initializes the Redis interface."""
    template_path = os.path.join(os.path.dirname(__file__), 'templates')
    self.jinja_env = Environment(loader=FileSystemLoader(template_path),
                                 autoescape=True)
    self.url_map = Map([
        Rule('/', endpoint='index'),
        Rule('/movies', endpoint='movies'),
    ])
    self.redis = StrictRedis(config['redis_host'], config['redis_port'],
                             decode_responses=True)  # New!!

Конструктор (__init__()) также имеет дополнительный аргумент (config), который используется для создания экземпляра StrictRedis.

Импорт:

from redis import StrictRedis

Параметры конфигурации, передаваемые в конструктор, должны быть указаны в функции фабрики приложения:

def create_app():
    """Application factory function that returns an instance of MovieApp."""
    app = MovieApp({'redis_host': '127.0.0.1', 'redis_port': 6379})
    app.wsgi_app = SharedDataMiddleware(app.wsgi_app, {
        '/static': os.path.join(os.path.dirname(__file__), 'static')
    })
    return app

Обработка форм

Чтобы пользователь мог добавить фильм в хранилище Redis, нам нужно добавить новую функцию представления в url_map:

def __init__(self, config):
    """Initializes the Jinja templating engine to render from the 'templates' folder,
    defines the mapping of URLs to view methods, and initializes the Redis interface."""

    ...

    self.url_map = Map([
        Rule('/', endpoint='index', methods=['GET']),
        Rule('/movies', endpoint='movies', methods=['GET']),
        Rule('/add', endpoint='add_movie', methods=['GET', 'POST']),  # !!!
    ])

    ...

Записи Rule в url_map были расширены, чтобы указать HTTP-методы, разрешенные для каждого URL. Кроме того, был добавлен URL ‘/add’:

Rule('/add', endpoint='add_movie', methods=['GET', 'POST']),

Если URL-адрес ‘/add’ будет запрошен с помощью методов GET или POST, то будет вызвана функция представления add_movie().

Далее нам нужно создать функцию представления add_movie() в классе MovieApp:

def add_movie(self, request):
    """Adds a movie to the list of favorite movies."""
    if request.method == 'POST':
        movie_title = request.form['title']
        self.redis.lpush('movies', movie_title)
        return redirect('/movies')
    return self.render_template('add_movie.html')

Импорт:

from werkzeug.utils import redirect

Если к ‘/add’ будет сделан GET-запрос, то функция add_movie() отобразит файл templates/add_movie.html. Если к ‘/add’ будет сделан POST-запрос, то данные формы будут сохранены в хранилище Redis в списке movies, и пользователь будет перенаправлен к списку фильмов.

Создайте файл шаблона templates/add_movie.html:

{% extends "base.html" %}

{% block body %}
<div class="form-container">
    <form method="post">
        <div class="field">
            <label for="movieTitle">Movie Title:</label>
            <input type="text" id="movieTitle" name="title"/>
        </div>
        <div class="field">
            <button type="submit">Submit</button>
        </div>
    </form>
</div>
{% endblock %}

Отображение фильмов

Поскольку теперь мы храним фильмы в Redis, функцию представления movie() нужно обновить, чтобы она считывала данные из списка movies в Redis:

def movies(self, request):
    """Displays the list of favorite movies."""
    movies = self.redis.lrange('movies', 0, -1)
    return self.render_template('movies.html', movies=movies)

Список фильмов передается в файл шаблона templates/movies.html, который необходимо обновить, чтобы он перебирал этот список для создания таблицы фильмов:

{% extends "base.html" %}

{% block body %}
<div class="table-container">
    <table>
        <!-- Table Header -->
        <thead>
            <tr>
                <th>Index</th>
                <th>Movie Title</th>
            </tr>
        </thead>

        <!-- Table Elements (Rows) -->
        <tbody>
            {% for movie in movies %}
            <tr>
                <td>{{ loop.index }}</td>
                <td>{{ movie }}</td>
            </tr>
            {% endfor %}
        </tbody>
    </table>
</div>
{% endblock %}

Чтобы увидеть обработку формы в действии, перейдите на сайт http://127.0.0.1:5000/add и добавьте новый фильм:

На странице выводтся текст "Werkzeug Movie App", а под ним окошко для ввода названия фильма и кнопка Submit

После отправки формы вы должны быть автоматически перенаправлены в список фильмов (который может включать ранее добавленные фильмы):

На странице выводится текст "Werkzeug Movie App", а под ним - таблица с фильмами, куда добавился четвертый фильм

Вот и все!

Почему бы не использовать Werkzeug вместо Flask?

Werkzeug предоставляет большую часть ключевых функций Flask, но Flask добавляет ряд мощных возможностей, таких как:

  • Сессии
  • Контексты приложений и запросов
  • Блюпринты
  • Функции обратного вызова запросов
  • Утилиты:
    • декоратор @route
    • функция url_for()
  • Команды CLI
  • Обработка исключений
  • Тестовый клиент
  • Оболочка Flask
  • Логирование
  • Сигналы
  • Расширения

Как и в случае с любым веб-фреймворком, не изобретайте велосипед! Flask является гораздо лучшим вариантом (по сравнению с Werkzeug) для веб-разработки благодаря своему богатому набору функций и большой коллекции расширений.

Заключение

В этой статье мы сделали обзор Werkzeug, который является одним из ключевых компонентов Flask, и показали, как с его помощью создать простое веб-приложение. Безусловно, важно понимать, как работают базовые библиотеки во Flask. Но сложность создания веб-приложения с помощью Werkzeug должна проиллюстрировать, насколько легко разрабатывать веб-приложения с помощью Flask!

А если вам интересно узнать, как тестировать приложения Werkzeug, ознакомьтесь с тестами для Werkzeug Movie App: https://gitlab.com/patkennedy79/werkzeug_movie_app/-/tree/main/tests.

Перевод статьи «What is Werkzeug?».