Руководство по Werkzeug
Добро пожаловать в руководство по Werkzeug, в котором мы создадим клон сервиса TinyURL, хранящий URL-адреса в экземпляре redis. Библиотеки, которые мы будем использовать для этого приложения, — Jinja 2 для шаблонов, redis для уровня базы данных и, конечно же, Werkzeug для уровня WSGI.
Вы можете использовать pip для установки необходимых библиотек:
pip install Jinja2 redis Werkzeug
Также убедитесь, что на вашем локальном компьютере запущен сервер redis. Если вы работаете на OS X, вы можете использовать brew для его установки:
brew install redis
Если вы работаете на Ubuntu или Debian, вы можете использовать apt-get:
sudo apt-get install redis-server
Redis был разработан для UNIX-систем и никогда по-настоящему не был предназначен для работы в Windows. Однако неофициальные порты работают достаточно хорошо в целях разработки. Вы можете получить их из github.
Представление сервиса Shortly
В этом руководстве мы вместе создадим простую службу сокращения URL-адресов с помощью Werkzeug. Пожалуйста, имейте в виду, что Werkzeug — это не фреймворк, это библиотека с инструментами для создания собственного фреймворка или приложения, и поэтому она очень гибкая. Использованный здесь подход — лишь один из многих возможных.
В качестве хранилища данных мы будем использовать redis вместо реляционной базы данных, чтобы всё было проще, и потому что redis отлично подходит для таких задач.
Конечный результат будет выглядеть примерно так:
Шаг 0: Введение в WSGI
Werkzeug — это библиотека утилит для WSGI. Сам WSGI — это протокол или соглашение, которое гарантирует, что ваше веб-приложение может общаться с веб-сервером и, что более важно, что веб-приложения хорошо работают вместе.
Базовое приложение «Hello World» в WSGI без помощи Werkzeug выглядит так:
def application(environ, start_response):
start_response('200 OK', [('Content-Type', 'text/plain')])
return ['Hello World!'.encode('utf-8')]
Веб-приложение WSGI — это нечто, что можно вызвать и передать ему словарь environ и start_response вызываемую функцию. Environ содержит всю поступающую информацию, функция start_response используется для обозначения начала ответа. С помощью Werkzeug вам не нужно иметь дело напрямую ни с тем, ни с другим, так как предоставляются объекты запроса и ответа для работы с ними.
Данные запроса берут объект environ и позволяют получить доступ к данным из этого environ удобным способом. Объект ответа сам по себе является приложением WSGI и предоставляет гораздо более удобный способ создания ответов.
Вот как вы написали бы это приложение с использованием объектов ответа:
from werkzeug.wrappers import Response
def application(environ, start_response):
response = Response('Hello World!', mimetype='text/plain')
return response(environ, start_response)
И вот расширенная версия, которая анализирует строку запроса в URL (более важно, параметр name в URL для подстановки «World» другим словом):
from werkzeug.wrappers import Request, Response
def application(environ, start_response):
request = Request(environ)
text = f"Hello {request.args.get('name', 'World')}!"
response = Response(text, mimetype='text/plain')
return response(environ, start_response)
И это всё, что вам нужно знать о WSGI.
Шаг 1: Создание папок
Прежде чем начать, давайте создадим папки, необходимые для этого приложения:
/shortly
/static
/templates
Папка shortly — это не пакет Python, а просто место, куда мы помещаем наши файлы. Непосредственно в эту папку мы затем поместим наш основной модуль на следующих шагах. Файлы в папке static доступны пользователям приложения через HTTP. Это место, куда попадают файлы CSS и JavaScript. В папке templates мы сделаем так, чтобы Jinja2 искал шаблоны. Шаблоны, которые вы создадите позже в этом руководстве, будут помещены в эту папку.
Шаг 2: Основная структура
Теперь давайте перейдём к делу и создадим модуль для нашего приложения. Давайте создадим файл, названный shortly.py в папке shortly. Сначала нам понадобится много импортов. Я внесу все импорты сюда, даже если они не используются сразу, чтобы не запутаться:
import os import redis from werkzeug.urls import url_parse from werkzeug.wrappers import Request, Response from werkzeug.routing import Map, Rule from werkzeug.exceptions import HTTPException, NotFound from werkzeug.middleware.shared_data import SharedDataMiddleware from werkzeug.utils import redirect from jinja2 import Environment, FileSystemLoader
Затем мы можем создать основную структуру для нашего приложения и функцию для создания нового экземпляра, необязательно с фрагментом WSGI-среды, экспортирующей все файлы в папке static в веб:
class Shortly(object):
def __init__(self, config):
self.redis = redis.Redis(
config['redis_host'], config['redis_port'], decode_responses=True
)
def dispatch_request(self, request):
return Response('Hello World!')
def wsgi_app(self, environ, start_response):
request = Request(environ)
response = self.dispatch_request(request)
return response(environ, start_response)
def __call__(self, environ, start_response):
return self.wsgi_app(environ, start_response)
def create_app(redis_host='localhost', redis_port=6379, with_static=True):
app = Shortly({
'redis_host': redis_host,
'redis_port': redis_port
})
if with_static:
app.wsgi_app = SharedDataMiddleware(app.wsgi_app, {
'/static': os.path.join(os.path.dirname(__file__), 'static')
})
return app
Наконец, мы можем добавить фрагмент кода, который запустит локальный сервер разработки с автоматической перезагрузкой кода и отладчиком:
if __name__ == '__main__':
from werkzeug.serving import run_simple
app = create_app()
run_simple('127.0.0.1', 5000, app, use_debugger=True, use_reloader=True)
Основная идея заключается в том, что наш класс Shortly — это фактическое WSGI-приложение. Метод __call__ напрямую перенаправляет вызов на wsgi_app. Это сделано для того, чтобы мы могли обернуть wsgi_app для применения middleware, как мы делаем в функции create_app. Фактический метод wsgi_app затем создаёт объект Request и вызывает метод dispatch_request, который должен вернуть объект Response, который затем оценивается как WSGI-приложение снова. Как вы можете видеть: черепахи до бесконечности. И класс Shortly, который мы создаём, а также любой объект запроса в Werkzeug реализуют интерфейс WSGI. В результате вы даже можете вернуть другое WSGI-приложение из метода dispatch_request.
Функция-фабрика create_app может быть использована для создания нового экземпляра нашего приложения. Она не только передаёт некоторые параметры в качестве конфигурации приложению, но также необязательно добавляет middleware WSGI, который экспортирует статические файлы. Таким образом, мы можем получить доступ к файлам из папки static даже тогда, когда мы не настраиваем наш сервер для их предоставления, что очень полезно для разработки.
Интермедия: Запуск приложения
Теперь вы должны иметь возможность выполнить файл с помощью python и увидеть сервер на вашем локальном компьютере:
$ python shortly.py * Running on http://127.0.0.1:5000/ * Restarting with reloader: stat() polling
Он также сообщает вам, что релоадер активен. Он будет использовать различные методы, чтобы определить, изменился ли какой-либо файл на диске, и затем автоматически перезапустится.
Просто перейдите по URL-адресу, и вы должны увидеть «Hello World!».
Шаг 3: Среда
Теперь, когда у нас есть базовый класс приложения, мы можем заставить конструктор выполнять полезную работу и предоставить несколько вспомогательных методов, которые могут пригодиться. Нам нужно будет отображать шаблоны и подключаться к redis, поэтому давайте немного расширим класс:
def __init__(self, config):
self.redis = redis.Redis(config['redis_host'], config['redis_port'])
template_path = os.path.join(os.path.dirname(__file__), 'templates')
self.jinja_env = Environment(loader=FileSystemLoader(template_path),
autoescape=True)
def render_template(self, template_name, **context):
t = self.jinja_env.get_template(template_name)
return Response(t.render(context), mimetype='text/html')
Шаг 4: Маршрутизация
Следующим шагом является маршрутизация. Маршрутизация — это процесс сопоставления и анализа URL-адреса, чтобы получить данные, которые мы можем использовать. Werkzeug предоставляет гибкую встроенную систему маршрутизации, которую мы можем использовать для этого. Способ её работы заключается в том, что вы создаёте экземпляр Map и добавляете несколько объектов Rule. Каждый правило имеет шаблон, который он пытается сопоставить с URL-адресом, и «точку входа». Точка входа — это обычно строка и может быть использована для уникальной идентификации URL-адреса. Мы также могли бы использовать это для автоматического обратного перехода к URL-адресу, но в этом руководстве мы этого делать не будем.
Просто добавьте это в конструктор:
self.url_map = Map([
Rule('/', endpoint='new_url'),
Rule('/<short_id>', endpoint='follow_short_link'),
Rule('/<short_id>+', endpoint='short_link_details')
])
Здесь мы создаём карту URL-адресов с тремя правилами. / для корня адресного пространства, где мы просто перенаправим вызов на функцию, которая реализует логику создания нового URL-адреса. Затем одно, которое следует за короткой ссылкой до целевого URL-адреса, и ещё одно с тем же правилом, но с плюсом (+) в конце, чтобы показать подробности ссылки.
Итак, как мы можем найти путь от точки входа к функции? Это зависит от вас. В этом руководстве мы будем делать это, вызывая метод on_ + точка входа в самом классе. Вот как это работает:
def dispatch_request(self, request):
adapter = self.url_map.bind_to_environ(request.environ)
try:
endpoint, values = adapter.match()
return getattr(self, f'on_{endpoint}')(request, **values)
except HTTPException as e:
return e
Мы привязываем карту URL-адресов к текущей среде и получаем обратно URLAdapter. Адаптер может быть использован для сопоставления запроса, а также для обратного перехода к URL-адресам. Метод match вернёт точку входа и словарь значений в URL-адресе. Например, правило для follow_short_link имеет переменную часть, называемую short_id. Когда мы перейдём по адресу http://localhost:5000/foo, мы получим следующие значения:
endpoint = 'follow_short_link'
values = {'short_id': 'foo'}
Если ничего не совпадёт, будет выброшено исключение NotFound, которое является HTTPException. Все исключения HTTP также являются WSGI-приложениями сами по себе, которые отображают страницу ошибки по умолчанию. Так что мы просто ловим их и возвращаем саму ошибку.
Если всё пройдёт хорошо, мы вызываем функцию on_ + точка входа и передаём ей запрос в качестве аргумента, а также все аргументы URL-адреса в качестве именованных аргументов, и возвращаем объект ответа, который метод возвращает.
Шаг 5: Первый вид
Давайте начнём с первого вида: того, который предназначен для новых URL-адресов:
def on_new_url(self, request):
error = None
url = ''
if request.method == 'POST':
url = request.form['url']
if not is_valid_url(url):
error = 'Please enter a valid URL'
else:
short_id = self.insert_url(url)
return redirect(f"/{short_id}+")
return self.render_template('new_url.html', error=error, url=url)
Эта логика должна быть понятна. В основном мы проверяем, является ли метод запроса POST, в этом случае мы проверяем URL-адрес и добавляем новую запись в базу данных, а затем перенаправляем на страницу подробностей. Это означает, что нам нужно написать функцию и вспомогательный метод. Для проверки URL-адресов этого достаточно:
def is_valid_url(url):
parts = url_parse(url)
return parts.scheme in ('http', 'https')
Для вставки URL-адреса всё, что нам нужно, это этот маленький метод в нашем классе:
def insert_url(self, url):
short_id = self.redis.get(f'reverse-url:{url}')
if short_id is not None:
return short_id
url_num = self.redis.incr('last-url-id')
short_id = base36_encode(url_num)
self.redis.set(f'url-target:{short_id}', url)
self.redis.set(f'reverse-url:{url}', short_id)
return short_id
reverse-url: + URL-адрес будет хранить короткий идентификатор. Если URL-адрес уже был отправлен, это не будет None, и мы можем просто вернуть это значение, которое будет коротким идентификатором. В противном случае мы увеличиваем ключ last-url-id и преобразуем его в базу-36. Затем мы сохраняем ссылку и обратную запись в redis. И вот функция преобразования в базу-36:
def base36_encode(number):
assert number >= 0, 'positive integer required'
if number == 0:
return '0'
base36 = []
while number != 0:
number, i = divmod(number, 36)
base36.append('0123456789abcdefghijklmnopqrstuvwxyz'[i])
return ''.join(reversed(base36))
Итак, для работы этого вида отсутствует шаблон. Мы создадим его позже, сначала напишем другие виды, а затем создадим шаблоны все вместе.
Шаг 6: Вид перенаправления
Вид перенаправления прост. Всё, что он должен сделать, — это найти ссылку в redis и перенаправить на неё. Кроме того, мы также увеличим счётчик, чтобы знать, сколько раз ссылка была нажата:
def on_follow_short_link(self, request, short_id):
link_target = self.redis.get(f'url-target:{short_id}')
if link_target is None:
raise NotFound()
self.redis.incr(f'click-count:{short_id}')
return redirect(link_target)
В этом случае мы вручную выбросим исключение NotFound, если URL-адрес не существует, что приведет к функции dispatch_request и будет преобразовано в стандартный ответ 404.
Шаг 7: Вид детали
Вид детали ссылки очень похож, мы просто снова отображаем шаблон. В дополнение к поиску целевого объекта, мы также запрашиваем в Redis количество кликов по ссылке и устанавливаем значение по умолчанию 0, если такой ключ ещё не существует:
def on_short_link_details(self, request, short_id):
link_target = self.redis.get(f'url-target:{short_id}')
if link_target is None:
raise NotFound()
click_count = int(self.redis.get(f'click-count:{short_id}') or 0)
return self.render_template('short_link_details.html',
link_target=link_target,
short_id=short_id,
click_count=click_count
)
Обратите внимание, что Redis всегда работает со строками, поэтому вам необходимо вручную преобразовать счётчик кликов в int.
Шаг 8: Шаблоны
И вот все шаблоны. Просто поместите их в папку templates. Jinja2 поддерживает наследование шаблонов, поэтому первым делом мы создадим шаблон макета с блоками, которые выступают в качестве заполнителей. Мы также настроили Jinja2 так, что он автоматически экранирует строки с помощью правил HTML, чтобы нам не приходилось тратить время на это самостоятельно. Это предотвращает атаки XSS и ошибки отображения.
layout.html:
<!doctype html>
<title>{% block title %}{% endblock %} | shortly</title>
<link rel=stylesheet href=/static/style.css type=text/css>
<div class=box>
<h1><a href=/>shortly</a></h1>
<p class=tagline>Shortly is a URL shortener written with Werkzeug
{% block body %}{% endblock %}
</div>
new_url.html:
{% extends "layout.html" %}
{% block title %}Create New Short URL{% endblock %}
{% block body %}
<h2>Submit URL</h2>
<form action="" method=post>
{% if error %}
<p class=error><strong>Error:</strong> {{ error }}
{% endif %}
<p>URL:
<input type=text name=url value="{{ url }}" class=urlinput>
<input type=submit value="Shorten">
</form>
{% endblock %}
short_link_details.html:
{% extends "layout.html" %}
{% block title %}Details about /{{ short_id }}{% endblock %}
{% block body %}
<h2><a href="/{{ short_id }}">/{{ short_id }}</a></h2>
<dl>
<dt>Full link
<dd class=link><div>{{ link_target }}</div>
<dt>Click count:
<dd>{{ click_count }}
</dl>
{% endblock %}
Шаг 9: Стиль
Чтобы это выглядело лучше, чем уродливый черно-белый вариант, вот простой стильный файл:
static/style.css:
body { background: #E8EFF0; margin: 0; padding: 0; }
body, input { font-family: 'Helvetica Neue', Arial,
sans-serif; font-weight: 300; font-size: 18px; }
.box { width: 500px; margin: 60px auto; padding: 20px;
background: white; box-shadow: 0 1px 4px #BED1D4;
border-radius: 2px; }
a { color: #11557C; }
h1, h2 { margin: 0; color: #11557C; }
h1 a { text-decoration: none; }
h2 { font-weight: normal; font-size: 24px; }
.tagline { color: #888; font-style: italic; margin: 0 0 20px 0; }
.link div { overflow: auto; font-size: 0.8em; white-space: pre;
padding: 4px 10px; margin: 5px 0; background: #E5EAF1; }
dt { font-weight: normal; }
.error { background: #E8EFF0; padding: 3px 8px; color: #11557C;
font-size: 0.9em; border-radius: 2px; }
.urlinput { width: 300px; }
Бонус: Усовершенствования
Посмотрите на реализацию в примере словаря в репозитории Werkzeug, чтобы увидеть версию этого учебника с некоторыми незначительными усовершенствованиями, такими как пользовательская страница 404.
© 2007–2022 Pallets
Licensed under the BSD 3-clause License.
https://werkzeug.palletsprojects.com/en/2.1.x/tutorial/