概述

本章将学习到:如何在Flask应用程序中进行错误处理(策略)。

这里将暂时停止为microblog添加新功能,而是讨论处理bug的策略,因为它们可能总是无处不在。为了帮助说明此主题,故意在上一节的代码中遗留一个bug。等待着我们去发现它。

在Flask中的错误处理

在Flask应用程序中发生error时会发生什么?找到问题的最佳方法是亲自体验,即重现它。启动应用程序,并确保已有两个注册用户。用其中一个用户身份登录后(在此以用户名+密码 susan cat为例),打开【Profile】页面并点击【Edit you profile】链接。在个人资料编辑页面,尝试将用户名更改为已注册的另一个用户的用户名(以 belen为例)并提交,这将带来一个可怕的“Internal Server Error”页面:
file

file

运行应用程序的终端会话,将看到这个Error的堆栈跟踪(stack trace)。它在调试Error时很有用,因为它们会显示这个堆栈中的调用序列,一直到产生Error的行:

(venv) [root@python blog]#  flask run
 * Environment: production
   WARNING: Do not use the development server in a production environment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
127.0.0.1 - - [13/Aug/2018 14:26:04] "GET /login?next=%2Fedit_profile HTTP/1.1" 200 -
127.0.0.1 - - [13/Aug/2018 14:26:08] "GET /login HTTP/1.1" 200 -
127.0.0.1 - - [13/Aug/2018 14:26:17] "POST /login HTTP/1.1" 302 -
127.0.0.1 - - [13/Aug/2018 14:26:17] "GET /index HTTP/1.1" 200 -
127.0.0.1 - - [13/Aug/2018 14:26:50] "GET /user/susan HTTP/1.1" 200 -
127.0.0.1 - - [13/Aug/2018 14:27:24] "GET /edit_profile HTTP/1.1" 200 -
[2018-08-13 14:28:45,549] ERROR in app: Exception on /edit_profile [POST]
Traceback (most recent call last):
  File "/root/blog/venv/lib/site-packages/sqlalchemy/engine/base.py", line 1193, in _execute_context
    context)
  File "/root/blog/venv/lib/site-packages/sqlalchemy/engine/default.py", line 509, in do_execute
    cursor.execute(statement, parameters)
sqlite3.IntegrityError: UNIQUE constraint failed: user.username
...
...

堆栈跟踪表明了bug是什么。目前应用程序允许用户更改用户名,且并不会验证新用户名是否跟系统中已有的其他用户名发生冲突。这个Error来自SQLAlchemy,它视图将新用户名写入数据库,但数据库拒绝了它,因为username列定义了unique=True。

重要的是要注意,呈现给用户的错误页面没有提供有关错误的大量信息,这很好!我们绝对不希望用户知道崩溃是由于数据库错误,或我正在使用数据库,又或在我的数据库中某些表和字段名称引起。所有这些信息都应该保留在内部。

有一些事情远非理想。上述错误页面很难看,跟应用程序布局不匹配。终端上的日志不断刷新,导致重要的堆栈跟踪信息被淹没,而我们不得不不断回顾它,以免遗漏。当然,我们有一个bug需要修复(fix)。我们将解决所有这些问题,但首先,来讨论下Flask的调试模式。

Debug模式(调试模式)

上述处理错误的方式对于在生产服务器上运行的系统来说非常有用。如果出现Error,用户会得到一个模糊的错误页面,并且错误的重要细节在服务器进程输出或日志文件中。

但在开发应用程序时,可启用调试模式,这是Flask在浏览器上直接运行一个友好调试器的模式。要激活调试模式,得先停止应用程序,然后设置以下环境变量:

(venv) [root@python blog]#  set FLASK_DEBUG=1

在设置FLASK_DEBUG后,重新启动服务器。终端上的打印(输出)将与之前看到的略有不同:

(venv) [root@python blog]#  flask run
 * Environment: production
   WARNING: Do not use the development server in a production environment.
   Use a production WSGI server instead.
 * Debug mode: on
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 216-201-609
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

现在再次让应用程序崩溃,以便在浏览器中查看交互式调试器(the interactive debugger ):
file

调试器允许我们展开每个堆栈帧并查看相应的源代码。还可以在任何框架上打开一个Python提示符,执行任何有效的Python表达式,如检查变量的值。

决不在生产服务器上以调试模式运行一个Flask应用程序 是非常重要的。调试器允许用户远程执行服务器中的代码,因此对于想侵入你的应用程序或服务器的恶意用户来说,这可能是一个意外礼物。作为额外的安全措施,在浏览器中运行的调试器开始锁定,并在第一次使用时将询问PIN号(flask run命令运行后可看到打印中的PIN号)

由于我们处于调试模式的主题,应该提到使用调试模式启用的第二个重要功能,即 重新加载器 reloader。这是一个非常有用的开发功能,可在修改源文件时自动重新启动应用程序。如果在调试模式下运行flask run命令,则可以在应用程序上运行,并且每次保存文件时,应用程序都将重新启动以获取新代码。

自定义Error页面

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错误的错误处理程序,实际上是上面的用户名重复情况。为确保任何失败的数据库会话不会干扰模板触发的任何数据库访问,我们发出一个会话回滚(session rollback)。这将会使得会话重置为干净状态。

以下是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,然后再次触发重复的用户错误,将看到一个稍微友好的错误页面。

(venv) [root@python blog]# set FLASK_DEBUG=0

(venv) [root@python blog]# flask run
 * Environment: production
   WARNING: Do not use the development server in a production environment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
[2018-08-13 17:39:21,022] ERROR in app: Exception on /edit_profile [POST]
Traceback (most recent call last):
  File "/root/blog/venv/lib/site-packages/sqlalchemy/engine/base.py", line 1193, in _execute_context
    context)
  File "/root/blog/venv/lib/site-packages/sqlalchemy/engine/default.py", line 509, in do_execute
    cursor.execute(statement, parameters)
sqlite3.IntegrityError: UNIQUE constraint failed: user.username

file

日志(log)写到文件中

通过电子邮件接收错误虽然很好,但有时这还不够。有些失败条件既不是Python异常,也不是主要问题,但它们在调试时,也是有足够用处的。因此,为应用程序维护一个日志文件。

为了启用另一个基于文件类型RotatingFileHandler的日志记录器,需要以和电子邮件日志记录器类似的方式将其附加到应用程序的logger对象中。

app/__init__.py:电子邮件配置

# ...
import logging
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')

在logs目录中写入带有名称microblog.log的日志文件,如果它尚不存在,那么将创建这个日志文件。

这个RotatingFileHandler类很棒,因为它可切割、清理日志文件,以确保日志文件在应用程序运行很长一段时间也不会变得太大。在这种情况下,将日志文件大小限制为10kb,并且将最后10个日志文件保留为备份。

这个logging.Formatter类提供自定义格式的日志消息。由于这些消息正在写入到一个文件,我们希望它们可存储尽可能多的信息。所以我们使用的格式包括 时间戳、日志记录级别、消息、日志来源的源代码文件、行号。

为使日志记录更有用,还将应用程序、日志记录级别降低到INFO。如果不熟悉日志记录类别,它们分别是DEBUG、INFO、WARNING、ERROR、CRITICAL(按严重程度递增)

日志文件第一个有趣的用途是 服务器每次启动时都会在日志中写入一行。当这个应用程序在生产服务器上运行时,这些日志数据将告诉我们服务器何时重新启动过。

修复重复的用户名bug

利用用户名重复这个BUG很久了,接下来将展示如何修复它。

应该还记得,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(EditProfileForm, self).__init__(*args, **kwargs)
        self.original_username = original_username

    def validate_username(self, username):
        if username.data != self.original_username:
            user = User.query.filter_by(username=self.username.data).first()
            if user is not None:
                raise ValidationError('Please use a different username.')

上述实现使用了一个自定义的验证方法,有一个重载的构造函数接受原始用户名作为参数。这个用户名保存为实例变量,并在validate_username()方法中进行检查。如果在表单中输入的用户名与原始用户名相同,那么就没必要检查数据库是否有重复了。

要使用这个新验证方法,还需要在对应的视图函数中添加原始用户名到表单的username参数中:

app/routes.py:在编辑个人资料表单中验证用户名

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

现在修复了这个bug,并在大多数情况下,在编辑个人资料表单中出现用户名重复的提交将被友好地阻止。不过这不是一个完美的解决方案,因为当两个或多个进程同时访问数据库时,它可能不起作用。在这种情形下,竞争条件可能导致验证通过,但稍后当重命名时,数据库已经被另一个进程更改,并且无法重命名用户。除了非常繁忙的具有大量服务器进程的应用程序之外,这种情况不太可能发生,所以现在我们不用为此担心。

flask run运行程序,再次重现错误,查看新表单验证方法如何阻止这个bug。
file

尝试更改不重名的username,效果:图略

查看数据库,看是否成功更改:

(venv) [root@python blog]# sqlite3 app.db
SQLite version 3.16.2 2017-01-06 16:32:41
Enter ".help" for usage hints.
sqlite> select * from user;
1|susan2018|susan@example.com|pbkdf2:sha256:50000$OONOkVyy$8d008c6647ab95a5793cf60bf57eaa3bb1123d6e5b3135c5cc5e42e02eddae32|I rename my name.|2018-08-14 09:09:35.986028
2|belen|belen@example.com|pbkdf2:sha256:50000$PEDt5NxS$cf6c958c97b6ad28d9495d138cb5a310f6f2389534b0cafa3002dd3cec9af9d1|学 习Flask超级教程,Python Web开发学习,坚持!|2018-08-13 03:54:02.884780
sqlite> .quit

目前为止,项目结构

    microblog/
    app/
        templates/
            _post.html
            404.html
            500.html
            base.html
            edit_profile.html
            index.html
            login.html
            register.html
            user.html
        __init__.py
        errors.py
        forms.py
        models.py
        routes.py
    logs/
        microblog.log
    migrations/
    venv/
    app.db
    config.py
    microblog.py