在本章我们讨论如何在 Flask 应用中进行错误处理。
本章将暂停为 microblog 应用开发新功能,转而讨论处理 BUG 的策略,因为它们总是无处不在。为了帮助本章的演示,我故意在第六章新增的代码中遗留了一处 BUG。 在继续阅读之前,看看你能不能找到它!
Flask 中的错误处理机制
在 Flask 应用中发生错误时会发生什么? 得到答案的最好的方法就是亲身体验一下。
启动应用,并确保至少有两个用户注册,以其中一个用户身份登录。然后在编辑用户信息的页面里的 usernane
输入框输入另一个用户的名字,点击提交。这时你就看到一个 “Internal Server Erro” 的错误出现在浏览器页面。
我们在控制台寻找下蛛丝马迹,可以发现是因为 username
列设置了 unique=True
,而我们的表单并没有对 username
的唯一性作校验,所以导致了用户名的冲突。
sqlalchemy.exc.IntegrityError: (sqlite3.IntegrityError) UNIQUE constraint failed: user.username
[SQL: UPDATE user SET username=?, about_me=? WHERE user.id = ?]
[parameters: ('tom', 'learning Flask!', 1)]
(Background on this error at: http://sqlalche.me/e/14/gkpj)
127.0.0.1 - - [08/Jul/2021 10:29:14] "POST /edit_profile HTTP/1.1" 500 -
值得注意的是,提供给用户的错误页面并没有提供关于错误的具体信息,这是正确的做法。我绝对不希望用户知道崩溃是由数据库错误引起的,或者我正在使用什么数据库,或者是我的数据库中的一些表和字段名称。 所有这些信息都应该对外保密。
但是也有一些不尽人意之处。错误页面简陋不堪,与应用布局不匹配。 终端上的日志不断刷新,导致重要的堆栈跟踪信息被淹没。 当然,我有一个 BUG 需要修复。 我将解决所有的这些问题,但首先,让我们来谈谈 Flask 的调试模式。
调试模式
在上面的处理错误的方式对在生产环境服务器上运行的系统非常有用。如果出现错误,用户将得到一个隐晦的错误页面(当然我们可以使这个错误页面看起来更友好),错误的重要细节在服务器进程输出或存储到日志文件中。
但是当正在开发应用时,可以启用调试模式,它是 Flask 在浏览器上直接运行一个友好调试器的模式。要激活调试模式,请停止应用程序,然后设置以下环境变量:
(venv) $ export FLASK_DEBUG=1
如果你使用 Microsoft Windows,记得将 export
替换成 set
。
再启动应用,终端上的输出信息会有所变化:
(venv) $ flask run
* Serving Flask app 'microblog.py' (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: on
* Restarting with stat
* Debugger is active!
* Debugger PIN: 892-934-550
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
现在让应用再次崩溃,以在浏览器中查看交互式调试器:
该调试器允许你展开每个堆栈框来查看相应的源代码上下文。 你也可以在任意堆栈框上打开 Python 提示符并执行任何有效的 Python 表达式,例如检查变量的值。
记住,永远不要在生产服务器上以调试模式运行 Flask 应用。调试器允许用户远程执行服务器中的代码,因此对于想要渗入应用或服务器的恶意用户来说,这可能是开门揖盗。作为附加的安全措施,运行在浏览器中的调试器开始被锁定,并且在第一次使用时会要求输入一个 PIN
码(你可以在 flask run
命令的输出中看到它)。
调试模式的第二个重要功能,就是重载器。 这是一个非常有用的开发功能,可以在源文件被修改时自动重启应用。
自定义错误页面
Flask 为应用提供了一个机制来自定义错误页面,作为例子,让我们为 HTTP 的 404 错误和 500 错误(两个最常见的错误页面)设置自定义错误页面。
因为错误处理属于一个相对独立的功能,我会按照上一章的逻辑把这部分包装为一个 errors
子模块。
文件结构如下:
app/
auth/ # 与用户相关的逻辑
main/ # 与动态相关的逻辑
errors/ # 与错误处理相关的逻辑
templates/
auth/ # 与用户相关的模板文件
errors/ # 与错误处理相关的模板文件
base.html
# ...
__init__.py
models.py
migrations/
venv/
app.db
config.py
microblog.py
第一步,我们为错误处理的逻辑创建统一的蓝图,在 app\errors\__init__.py
中编写代码:
# app\errors\__init__.py
from flask import Blueprint
errors_routes = Blueprint('errors', __name__)
之后创建 app\errors\routes.py
文件来编写处理错误的逻辑,使用 app_errorhandler
装饰器来声明一个自定义的错误处理器。
# app\errors\routes.py
from flask import render_template
from app import db
from app.errors import errors_routes
@errors_routes.app_errorhandler(404)
def not_found_error(error):
return render_template('errors/404.html'), 404
@errors_routes.app_errorhandler(500)
def internal_error(error):
db.session.rollback()
return render_template('errors/500.html'), 500
错误函数与视图函数非常类似。 对于这两个错误,我将返回各自模板的内容。注意这两个函数 return
的第二个值,这是错误的状态码。之前所创建的视图函数,不需要添加第二个返回值,因为它的默认值是 200(成功响应的状态码)。
500 错误的错误处理程序应当在引发数据库错误后调用,而上面的用户名重复实际上就是这种情况。为了确保任何失败的数据库会话不会干扰模板触发的其他数据库访问,执行会话回滚来将会话重置为干净的状态。
404 错误的模板如下:
# app\templates\errors\404.html
{% extends "base.html" %}
{% block content %}
<h1>File Not Found</h1>
<p><a href="{{ url_for('main.index') }}">Back</a></p>
{% endblock %}
500 错误的模板如下:
{% 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('main.index') }}">Back</a></p>
{% endblock %}
这两个模板都从 base.html
基础模板继承而来,所以错误页面与应用的普通页面有相同的基本布局和导航栏。
最后,我们要把 errors
模块在工厂函数里注册:
# app\__init__.py
# ...
def create_app():
app = Flask(__name__)
# ...
from app.errors.routes import errors_routes
app.register_blueprint(errors_routes)
return app
from app import models
在终端界面设置环境变量 FLASK_DEBUG=0
,然后再次激发重复用户名的 BUG,你将会看到一个更加友好的错误页面。
记录日志到文件中
Flask 中封装了 Python 的 logging
模块,用法和 Python 的日志模块完全一致,在 app.logger
设定即可。
下面在工厂函数中设置日志输出:
# app\__init__.py
import os
import logging
from logging.handlers import RotatingFileHandler
ef create_app():
app = Flask(__name__)
# ...
if not app.debug:
if not os.path.exists('logs'):
os.mkdir('logs')
app.logger.setLevel(logging.INFO)
handler = RotatingFileHandler(
'logs/microblog.log',
maxBytes=10240,
backupCount=10
)
handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
app.logger.addHandler(handler)
app.logger.info("Microblog startup!")
注意,我们设置了 if not app.debug
,这要求环境变量中 FLASK_DEBUG=1
时候(0 表示 false
,1 表示 true
),日志输出才会执行。
然后检查根目录下是否有 logs
路径,如果不存在,os.mkdir
方法会为其创建一个。其余的日志设定参见 Python 的 logging 官方文档。
我们现在设定环境变量:
$ set FLASK_DEBUG=1
再次运行我们的应用,并且再次激发名字重复冲突的错误,观察我们的 logs\microblog.log
文件,发现里面记录了错误的记录。
# ...
sqlalchemy.exc.IntegrityError: (sqlite3.IntegrityError) UNIQUE constraint failed: user.username
[SQL: UPDATE user SET username=? WHERE user.id = ?]
[parameters: ('tom', 1)]
(Background on this error at: http://sqlalche.me/e/14/gkpj)
修复用户名重复的 BUG
要修复姓名重复引发冲突这个 BUG,我们需要在 EditProfileForm
表单类添加一个验证器,来验证输入的名字是否已经存在。
# app\auth\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.')
这里我们增加了一个 original_username
参数,表示原来的用户名,如果新输入的用户名和原来的用户名不一致,再执行验证。
这个 original_username
我们在 EditProfileForm
实例化的时候传递给它:
# app\auth\routes.py
@auth_routes.route('/edit_profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
form = EditProfileForm(current_user.username)
# ...
现在这个 BUG 已经被修复,当我们在编辑个人信息页面输入了已存在的用户名时,应用会给出一个提示而非崩溃了。
本文源码:https://github.com/SingleDiego/Flask-Tutorial-Source-Code/tree/SingleDiego-patch-07