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

Оглавление

В этой главе я делаю перерыв в написании новых функций в своем приложении для ведения микроблогов и вместо этого расскажу о нескольких стратегиях устранения ошибок, которые неизменно появляются в каждом программном проекте. Чтобы проиллюстрировать эту тему, я намеренно пропустил ошибку в коде, который я добавил в главе 6. Прежде чем продолжить чтение, посмотрите, сможете ли вы ее найти!

Ссылки на GitHub для этой главы: BrowseZipDiff.

Обработка ошибок в Flask

Что происходит, когда в приложении Flask возникает ошибка? Лучший способ узнать об этом - испытать это на собственном опыте. Запустите приложение и убедитесь, что у вас зарегистрировано как минимум два пользователя. Войдите в систему как один из пользователей, откройте страницу профиля и нажмите ссылку "Редактировать". В редакторе профиля попробуйте изменить имя пользователя на имя другого пользователя, который уже зарегистрирован, и бум! Появится пугающе выглядящая страница "Внутренняя ошибка сервера":

Если вы посмотрите в сеансе терминала, где запущено приложение, вы увидите трассировку стека ошибки. Трассировки стека чрезвычайно полезны при отладке ошибок, потому что они показывают последовательность вызовов в этом стеке, вплоть до строки, вызвавшей ошибку:

([2023-04-28 23:59:42,300] ERROR in app: Exception on /edit_profile [POST]
Traceback (most recent call last):
  File "venv/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1963, in _exec_single_context
    self.dialect.do_execute(
  File "venv/lib/python3.11/site-packages/sqlalchemy/engine/default.py", line 918, in do_execute
    cursor.execute(statement, parameters)
sqlite3.IntegrityError: UNIQUE constraint failed: user.username

Трассировка стека помогает определить, в чем ошибка. Приложение позволяет пользователю изменить имя пользователя, но не проверяет, что выбранное новое имя пользователя не сталкивается с другим пользователем, уже находящимся в системе. Ошибка возникает из SQLAlchemy, которая пытается записать новое имя пользователя в базу данных, но база данных отклоняет ее, потому что username столбец определен с помощью опции unique=True.

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

Но есть несколько вещей, которые далеки от идеала. У меня есть страница с ошибкой, которая очень уродлива и не соответствует макету приложения. У меня также есть важные трассировки стека приложений, которые сбрасываются на терминал, и мне нужно постоянно следить за ними, чтобы убедиться, что я не пропустил ни одной ошибки. И, конечно, мне нужно исправить ошибку. Я собираюсь рассмотреть все эти проблемы, но сначала давайте поговорим о режиме отладки в Flask.

Режим отладки

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

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

(venv) $ export FLASK_DEBUG=1

Если вы используете Microsoft Windows, не забудьте использовать set вместо export.

После настройки FLASK_DEBUG перезапустите сервер. Выходные данные на вашем терминале будут немного отличаться от того, что вы привыкли видеть:

(venv) $ flask run
 * Serving Flask app 'microblog.py' (lazy loading)
 * Environment: development
 * Debug mode: on
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 118-204-854

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

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

Чрезвычайно важно, чтобы вы никогда не запускали приложение Flask в режиме отладки на рабочем сервере. Отладчик позволяет пользователю удаленно выполнять код на сервере, поэтому он может стать неожиданным подарком злоумышленнику, который хочет проникнуть в ваше приложение или на ваш сервер. В качестве дополнительной меры безопасности запущенный в браузере отладчик запускается заблокированным, и при первом использовании он запрашивает PIN-код, который вы можете увидеть в выходных данных команды flask run.

Поскольку я затронул тему режима отладки, я должен упомянуть вторую важную функцию, которая включается в режиме отладки, а именно перезагрузчик. Это очень полезная функция разработки, которая автоматически перезапускает приложение при изменении исходного файла. Если вы запустите flask run в режиме отладки, вы сможете работать над своим приложением, и каждый раз, когда вы сохраняете файл, приложение перезапускается для получения нового кода.

Настраиваемые страницы ошибок

Flask предоставляет приложению механизм установки собственных страниц ошибок, чтобы вашим пользователям не приходилось видеть простые и скучные страницы по умолчанию. В качестве примера давайте определим свои страницы ошибок для HTTP-ошибок 404 и 500, двух наиболее распространенных. Определение страниц для других ошибок работает таким же образом.

Для объявления обработчика ошибок используется декоратор @errorhandler. Я собираюсь поместить свои обработчики ошибок в новый модуль app/errors.py.

app/errors.py: Настраиваемые обработчики ошибок

from flask import render_template
from app import app, db

@app.errorhandler(404)
def not_found_error(error):
    return render_template('404.html'), 404

@app.errorhandler(500)
def internal_error(error):
    db.session.rollback()
    return render_template('500.html'), 500

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

Обработчик ошибок для ошибки 500 мог быть вызван после ошибки базы данных, что на самом деле имело место с дубликатом имени пользователя выше. Чтобы убедиться, что любые неудачные сеансы работы с базой данных не мешают каким-либо обращениям к базе данных, инициируемым шаблоном, я выполняю откат сеанса. Это переводит сеанс в чистое состояние.

Вот шаблон для ошибки 404:

app/templates/404.html: Запрашиваемый файл не найден

{% extends "base.html" %}

{% block content %}
    <h1>File Not Found</h1>
    <p><a href="{{ url_for('index') }}">Back</a></p>
{% endblock %}

А вот и тот, что касается ошибки 500:

app/templates/500.html: Внутренняя ошибка сервера

{% extends "base.html" %}

{% block content %}
    <h1>An unexpected error has occurred</h1>
    <p>The administrator has been notified. Sorry for the inconvenience!</p>
    <p><a href="{{ url_for('index') }}">Back</a></p>
{% endblock %}

Оба шаблона наследуются от шаблона base.html, так что страница с ошибкой имеет тот же внешний вид, что и обычные страницы приложения.

Чтобы зарегистрировать эти обработчики ошибок в Flask, мне нужно импортировать новый модуль app/errors.py после создания экземпляра приложения:

app/__init__.py: Импорт обработчиков ошибок

# ...

from app import routes, models, errors

Если вы установите в сеансе терминала FLASK_DEBUG=0 (или удалите переменную FLASK_DEBUG), а затем еще раз запустите ошибку с дублированием имени пользователя, вы увидите немного более понятную страницу с ошибкой.

Отправка ошибок по электронной почте

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

Я думаю, что очень важно проявлять упреждающий подход в отношении ошибок. Если ошибка возникает в рабочей версии приложения, я хочу знать об этом немедленно. Итак, моим первым решением будет настроить Flask на отправку мне электронного письма сразу после ошибки с отслеживанием стека ошибки в теле электронного письма.

Первым шагом является добавление сведений о сервере электронной почты в файл конфигурации:

config.py: Настройка электронной почты

class Config:
    # ...
    MAIL_SERVER = os.environ.get('MAIL_SERVER')
    MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25)
    MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
    MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
    MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
    ADMINS = ['your-email@example.com']

Переменные конфигурации для электронной почты включают сервер и порт, логический флаг для включения зашифрованных подключений и необязательные имя пользователя и пароль. Пять переменных конфигурации получены из их аналогов переменных среды. Если сервер электронной почты не настроен в среде, я буду использовать это как признак того, что необходимо отключить отправку ошибок по электронной почте. Порт почтового сервера также может быть указан в переменной окружения, но если он не задан, используется стандартный порт 25. Учетные данные почтового сервера по умолчанию не используются, но могут быть предоставлены при необходимости. Переменная конфигурации ADMINS представляет собой список адресов электронной почты, на которые будут приходить сообщения об ошибках, поэтому ваш собственный адрес электронной почты должен быть в этом списке.

Flask использует пакет Python logging для записи своих журналов, и в этом пакете уже есть возможность отправлять журналы по электронной почте. Все, что мне нужно сделать, чтобы получать электронные письма, отправленные с ошибками, - это добавить экземпляр SMTPHandler к объекту Flask logger, который доступен через app.logger:

app/__init__.py: Регистрация ошибок через электронную почту

import logging
from logging.handlers import SMTPHandler

# ...

if not app.debug:
    if app.config['MAIL_SERVER']:
        auth = None
        if app.config['MAIL_USERNAME'] or app.config['MAIL_PASSWORD']:
            auth = (app.config['MAIL_USERNAME'], app.config['MAIL_PASSWORD'])
        secure = None
        if app.config['MAIL_USE_TLS']:
            secure = ()
        mail_handler = SMTPHandler(
            mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']),
            fromaddr='no-reply@' + app.config['MAIL_SERVER'],
            toaddrs=app.config['ADMINS'], subject='Microblog Failure',
            credentials=auth, secure=secure)
        mail_handler.setLevel(logging.ERROR)
        app.logger.addHandler(mail_handler)

from app import routes, models, errors

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

Настройка регистратора электронной почты несколько утомительна из-за необходимости обрабатывать дополнительные параметры безопасности, которые присутствуют на многих почтовых серверах. Но, по сути, приведенный выше код создает экземпляр SMTPHandler, устанавливает его уровень так, чтобы он сообщал только об ошибках, а не о предупреждениях, информационных или отладочных сообщениях, и, наконец, присоединяет его к объекту app.logger из Flask.

Есть два подхода к тестированию этой функции. Самый простой - использовать сервер отладки SMTP. Это поддельный почтовый сервер, который принимает электронные письма, но вместо отправки их выводит на консоль. Чтобы запустить этот сервер, откройте второй сеанс терминала, активируйте виртуальную среду и установите пакет aiosmtpd:

(venv) $ pip install aiosmtpd

Затем выполните следующую команду, чтобы запустить отладочный почтовый сервер:

(venv) $ aiosmtpd -n -c aiosmtpd.handlers.Debugging -l localhost:8025

Эта команда пока ничего не напечатает, но будет ждать подключения клиентов. Оставьте запущенный отладочный SMTP-сервер и вернитесь к своему первому терминалу и настройте свой почтовый сервер следующим образом:

export MAIL_SERVER=localhost
export MAIL_PORT=8025

Как всегда, используйте set вместо export, если вы используете Microsoft Windows. Убедитесь, что для переменной FLASK_DEBUG установлено значение 0 или не установлено вообще, поскольку приложение не будет отправлять электронные письма в режиме отладки. Запустите приложение и вызовите ошибку SQLAlchemy еще раз, чтобы увидеть, как сеанс терминала, на котором запущен поддельный почтовый сервер, показывает электронное письмо с полной трассировкой стека ошибки.

Второй способ тестирования этой функции - настроить реальный почтовый сервер. Ниже приведена конфигурация для использования почтового сервера вашей учетной записи Gmail:

export MAIL_SERVER=smtp.googlemail.com
export MAIL_PORT=587
export MAIL_USE_TLS=1
export MAIL_USERNAME=<your-gmail-username>
export MAIL_PASSWORD=<your-gmail-password>

Если вы используете Microsoft Windows, не забудьте использовать set вместо export в каждой из приведенных выше инструкций.

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

Примечание от переводчика

Бывают случаи, когда иностранные сервисы заблокированы, поэтому в качестве примера предлагаю сервис Яндекс Почта. В справке подробно описана инструкция по настройке, пункт "Настроить только отправку по протоколу SMTP"

Конфигурация при использовании сервиса Яндекс Почта:

export MAIL_SERVER=smtp.yandex.ru
export MAIL_PORT=587
export MAIL_USE_TLS=1
export MAIL_USERNAME=<your-gmail-username>
export MAIL_PASSWORD=<your-gmail-password>

Так же необходимо внести изменения в создание экземпляраSMTPHandler, в аргументfromaddr нужно передать реально существующий адрес, иначе почтовый сервис вернёт ошибку и сообщение не будет отправлено, в нашем случае проще всего в качестве значения передать переменную конфигурации MAIL_USERNAME:

import logging
from logging.handlers import SMTPHandler

# ...

if not app.debug:
    if app.config['MAIL_SERVER']:
        auth = None
        if app.config['MAIL_USERNAME'] or app.config['MAIL_PASSWORD']:
            auth = (app.config['MAIL_USERNAME'], app.config['MAIL_PASSWORD'])
        secure = None
        if app.config['MAIL_USE_TLS']:
            secure = ()
        mail_handler = SMTPHandler(
            mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']),
            fromaddr=app.config['MAIL_USERNAME'],
            toaddrs=app.config['ADMINS'], subject='Microblog Failure',
            credentials=auth, secure=secure)
        mail_handler.setLevel(logging.ERROR)
        app.logger.addHandler(mail_handler)

from app import routes, models, errors

Еще одной альтернативой является использование специального почтового сервиса, такого как SendGrid, который позволяет отправлять до 100 электронных писем в день с помощью бесплатной учетной записи.

Запись в файл

Получать ошибки по электронной почте приятно, но иногда этого недостаточно. Существуют некоторые условия сбоя, которые не заканчиваются исключением Python и не являются серьезной проблемой, но они все равно могут быть достаточно интересными, чтобы сохранить их для целей отладки. По этой причине я также собираюсь вести файл журнала для приложения.

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

app/__init__.py: Запись в файл

# ...
from logging.handlers import RotatingFileHandler
import os

# ...

if not app.debug:
    # ...

    if not os.path.exists('logs'):
        os.mkdir('logs')
    file_handler = RotatingFileHandler('logs/microblog.log', maxBytes=10240,
                                       backupCount=10)
    file_handler.setFormatter(logging.Formatter(
        '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'))
    file_handler.setLevel(logging.INFO)
    app.logger.addHandler(file_handler)

    app.logger.setLevel(logging.INFO)
    app.logger.info('Microblog startup')

Я записываю файл журнала с именем microblog.log в каталог logs, который создаю, если он еще не существует.

Класс RotatingFileHandler хорош тем, что он циклически перезаписывает журналы, гарантируя, что файлы журналов не станут слишком большими при длительной работе приложения. В этом случае я ограничиваю размер файла журнала 10 КБ, а последние десять файлов журнала сохраняю в качестве резервной копии.

Класс logging.Formatter предоставляет пользовательское форматирование сообщений журнала. Поскольку эти сообщения отправляются в файл, я хочу, чтобы в них было как можно больше информации. Итак, я использую формат, который включает метку времени, уровень ведения журнала, сообщение, а также исходный файл и номер строки, откуда была взята запись в журнале.

Чтобы сделать ведение журнала более полезным, я также понижаю уровень ведения журнала до категории INFO, как в регистраторе приложения, так и в обработчике файлового регистратора. На случай, если вы не знакомы с категориями ведения журнала, они представлены в порядке возрастания степени серьезности DEBUG, INFO, WARNING, ERROR и CRITICAL.

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

Исправление ошибки с дублированием имени пользователя

Я слишком долго использовал ошибку с дублированием имени пользователя. Теперь, когда я показал вам, как подготовить приложение для обработки ошибок такого типа, я могу пойти дальше и исправить это.

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

app/forms.py: Проверка имени пользователя в форме редактирования профиля.

class EditProfileForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired()])
    about_me = TextAreaField('About me', validators=[Length(min=0, max=140)])
    submit = SubmitField('Submit')

    def __init__(self, original_username, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.original_username = original_username

    def validate_username(self, username):
        if username.data != self.original_username:
            user = db.session.scalar(sa.select(User).where(
                User.username == self.username.data))
            if user is not None:
                raise ValidationError('Please use a different username.')

Реализация сделана в пользовательском методе проверки, но есть перегруженный конструктор, который принимает исходное имя пользователя в качестве аргумента. Это имя пользователя сохраняется как переменная экземпляра и проверяется в методе validate_username() . Если имя пользователя, введенное в форму, совпадает с исходным именем пользователя, то нет причин проверять базу данных на наличие дубликатов.

Чтобы использовать этот новый метод проверки, мне нужно добавить исходный аргумент username в функцию view , где создается объект form:

app/routes.py: Передача имени пользователя в форму редактирования профиля.

@app.route('/edit_profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
    form = EditProfileForm(current_user.username)
    # ...

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

На этом этапе вы можете попробовать воспроизвести ошибку еще раз, чтобы увидеть, как новый метод проверки формы предотвращает ее.

Постоянное включение режима отладки

Режим отладки Flask настолько полезен, что вы можете захотеть включить его по умолчанию. Это можно сделать, добавив переменную среды FLASK_DEBUG в файл .flaskenv .

.flaskenv: Переменные среды для команды flask

FLASK_APP=microblog.py
FLASK_DEBUG=1

С этим изменением режим отладки будет включен при запуске сервера с помощью команды flask run.

Комментарии (0)