本文翻译自The Flask Mega-Tutorial Part VII: Error Handling
这是Flask Mega-Tutorial系列的第七部分,我将告诉你如何在Flask应用中进行错误处理。
本章将暂停为microblog应用开发新功能,转而讨论处理BUG的策略,因为它们总是无处不在。为了帮助本章的演示,我故意在第六章新增的代码中遗留了一处BUG。 在继续阅读之前,看看你能不能找到它!
本章的GitHub链接为:Browse, Zip, Diff.
Flask中的错误处理机制
在Flask应用中爆发错误时会发生什么? 得到答案的最好的方法就是亲身体验一下。 启动应用,并确保至少有两个用户注册,以其中一个用户身份登录,打开个人主页并单击“编辑”链接。 在个人资料编辑器中,尝试将用户名更改为已经注册的另一个用户的用户名,boom!(爆炸声) 这将带来一个可怕的“Internal Server Error”页面:
如果你查看运行应用的终端会话,将看到stack trace(堆栈跟踪)。 堆栈跟踪在调试错误时非常有用,因为它们显示堆栈中调用的顺序,一直到产生错误的行:
(venv) $ flask run
* Serving Flask app "microblog"
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
[2017-09-14 22:40:02,027] ERROR in app: Exception on /edit_profile [POST]
Traceback (most recent call last):
File "/home/miguel/microblog/venv/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1182, in _execute_context
context)
File "/home/miguel/microblog/venv/lib/python3.6/site-packages/sqlalchemy/engine/default.py", line 470, in do_execute
cursor.execute(statement, parameters)
sqlite3.IntegrityError: UNIQUE constraint failed: user.username
堆栈跟踪指示了BUG在何处。 本应用允许用户更改用户名,但却没有验证所选的新用户名与系统中已有的其他用户有没有冲突。 这个错误来自SQLAlchemy,它尝试将新的用户名写入数据库,但数据库拒绝了它,因为username
列是用unique=True
定义的。
值得注意的是,提供给用户的错误页面并没有提供关于错误的丰富信息,这是正确的做法。 我绝对不希望用户知道崩溃是由数据库错误引起的,或者我正在使用什么数据库,或者是我的数据库中的一些表和字段名称。 所有这些信息都应该对外保密。
但是也有一些不尽人意之处。错误页面简陋不堪,与应用布局不匹配。 终端上的日志不断刷新,导致重要的堆栈跟踪信息被淹没,但我却需要不断回顾它,以免有漏网之鱼。 当然,我有一个BUG需要修复。 我将解决所有的这些问题,但首先,让我们来谈谈Flask的调试模式。
调试模式
你在上面看到的处理错误的方式对在生产服务器上运行的系统非常有用。 如果出现错误,用户将得到一个隐晦的错误页面(尽管我打算使这个错误页面更友好),错误的重要细节在服务器进程输出或存储到日志文件中。
但是当你正在开发应用时,可以启用调试模式,它是Flask在浏览器上直接运行一个友好调试器的模式。 要激活调试模式,请停止应用程序,然后设置以下环境变量:
(venv) $ export FLASK_DEBUG=1
如果你使用Microsoft Windows,记得将export
替换成set
。
设置环境变量FLASK_DEBUG
后,重启服务。相比之前,终端上的输出信息会有所变化:
(venv) microblog2 $ flask run
* Serving Flask app "microblog"
* Forcing debug mode on
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 177-562-960
现在让应用再次崩溃,以在浏览器中查看交互式调试器:
该调试器允许你展开每个堆栈框来查看相应的源代码上下文。 你也可以在任意堆栈框上打开Python提示符并执行任何有效的Python表达式,例如检查变量的值。
永远不要在生产服务器上以调试模式运行Flask应用,这一点非常重要。 调试器允许用户远程执行服务器中的代码,因此对于想要渗入应用或服务器的恶意用户来说,这可能是开门揖盗。 作为附加的安全措施,运行在浏览器中的调试器开始被锁定,并且在第一次使用时会要求输入一个PIN码(你可以在flask run
命令的输出中看到它)。
谈到调试模式的话题,我不得不提到的第二个重要的调试模式下的功能,就是重载器。 这是一个非常有用的开发功能,可以在源文件被修改时自动重启应用。 如果在调试模式下运行flask run
,则可以在开发应用时,每当保存文件,应用都会重新启动以加载新的代码。
自定义错误页面
Flask为应用提供了一个机制来自定义错误页面,这样用户就不必看到简单而枯燥的默认页面。 作为例子,让我们为HTTP的404错误和500错误(两个最常见的错误页面)设置自定义错误页面。 为其他错误设置页面的方式与之相同。
使用@errorhandler
装饰器来声明一个自定义的错误处理器。 我将把我的错误处理程序放在一个新的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错误的模板如下:
{% extends "base.html" %}
{% block content %}
<h1>File Not Found</h1>
<p><a href="{{ url_for('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('index') }}">Back</a></p>
{% endblock %}
这两个模板都从base.html
基础模板继承而来,所以错误页面与应用的普通页面有相同的外观布局。
为了让这些错误处理程序在Flask中注册,我需要在应用实例创建后导入新的app/errors.py模块:
# ...
from app import routes, models, errors
如果在终端界面设置环境变量FLASK_DEBUG=0
,然后再次出发重复用户名的BUG,你将会看到一个更加友好的错误页面。
通过电子邮件发送错误
Flask提供的默认错误处理机制的另一个问题是没有通知机制,错误的堆栈跟踪只是被打印到终端,这意味着需要监视服务器进程的输出才能发现错误。 在开发时,这是非常好的,但是一旦将应用部署在生产服务器上,没有人会关心输出,因此需要采用更强大的解决方案。
我认为对错误发现采取积极主动的态度是非常重要的。 如果生产环境的应用发生错误,我想立刻知道。 所以我的第一个解决方案是配置Flask在发生错误之后立即向我发送一封电子邮件,邮件正文中包含错误堆栈跟踪的正文。
第一步,添加邮件服务器的信息到配置文件中:
class Config(object):
# ...
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
包来写它的日志,而且这个包已经能够通过电子邮件发送日志了。 我所需要做的就是为Flask的日志对象app.logger
添加一个SMTPHandler的实例:
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)
如你所见,仅当应用未以调试模式运行,且配置中存在邮件服务器时,我才会启用电子邮件日志记录器。
设置电子邮件日志记录器的步骤因为处理安全可选项而稍显繁琐。 本质上,上面的代码创建了一个SMTPHandler
实例,设置它的级别,以便它只报告错误及更严重级别的信息,而不是警告,常规信息或调试消息,最后将它附加到Flask的app.logger
对象中。
有两种方法来测试此功能。 最简单的就是使用Python的SMTP调试服务器。 这是一个模拟的电子邮件服务器,它接受电子邮件,然后打印到控制台。 要运行此服务器,请打开第二个终端会话并在其上运行以下命令:
(venv) $ python -m smtpd -n -c DebuggingServer localhost:8025
要用这个模拟邮件服务器来测试应用,那么你将设置MAIL_SERVER=localhost
和MAIL_PORT=8025
。
译者注:本段中去处了说明设置该端口需要管理员权限的部分,因为这和实际情况不符。原文如下:
To test the application with this server, then you will setMAIL_SERVER=localhost
andMAIL_PORT=8025
. If you are on a Linux or Mac OS system, you will likely need to prefix the command withsudo
, so that it can execute with administration privileges. If you are on a Windows system, you may need to open your terminal window as an administrator. Administrator rights are needed for this command because ports below 1024 are administrator-only ports. Alternatively, you can change the port to a higher port number, say 5025, and setMAIL_PORT
variable to your chosen port in the environment, and that will not require administration rights.
保持调试SMTP服务器运行并返回到第一个终端,在环境中设置export MAIL_SERVER=localhost
和MAIL_PORT=8025
(如果使用的是Microsoft Windows,则使用set而不是export)。 确保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帐户。 可以阅读此处来了解具体情况,如果你担心帐户的安全性,可以创建一个辅助邮箱帐户,配置它来仅用于测试电子邮件功能,或者你可以暂时启用允许不太安全的应用程序来运行此测试,完成后恢复为默认值。
记录日志到文件中
通过电子邮件来接收错误提示非常棒,但在其他场景下,有时候就有些不足了。有些错误条件既不是一个Python异常又不是重大事故,但是他们在调试的时候也是有足够用处的。为此,我将会为本应用维持一个日志文件。
为了启用另一个基于文件类型RotatingFileHandler的日志记录器,需要以和电子邮件日志记录器类似的方式将其附加到应用的logger对象中。
# ...
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,并只保留最后的十个日志文件作为备份。
logging.Formatter类为日志消息提供自定义格式。 由于这些消息正在写入到一个文件,我希望它们可以存储尽可能多的信息。 所以我使用的格式包括时间戳、日志记录级别、消息以及日志来源的源代码文件和行号。
为了使日志记录更有用,我还将应用和文件日志记录器的日志记录级别降低到INFO
级别。 如果你不熟悉日志记录类别,则按照严重程度递增的顺序来认识它们就行了,分别是DEBUG
、INFO
、WARNING
、ERROR
和CRITICAL
。
日志文件的第一个有趣用途是,服务器每次启动时都会在日志中写入一行。 当此应用在生产服务器上运行时,这些日志数据将告诉你服务器何时重新启动过。
修复用户名重复的BUG
利用用户名重复BUG这么久, 现在时候向你展示如何修复它了。
你是否还记得,RegistrationForm
已经实现了对用户名的验证,但是编辑表单的要求稍有不同。 在注册期间,我需要确保在表单中输入的用户名不存在于数据库中。 在编辑个人资料表单中,我必须做同样的检查,但有一个例外。 如果用户不改变原始用户名,那么验证应该允许,因为该用户名已经被分配给该用户。 下面你可以看到我为这个表单实现了用户名验证:
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.route('/edit_profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
form = EditProfileForm(current_user.username)
# ...
现在这个BUG已经修复了,大多数情况下,以后在编辑个人资料时出现用户名重复的提交将被友好地阻止。 但这不是一个完美的解决方案,因为当两个或更多进程同时访问数据库时,这可能不起作用。假如存在验证通过的进程A和B都尝试修改用户名为同一个,但稍后进程A尝试重命名时,数据库已被进程B更改,无法重命名为该用户名,会再次引发数据库异常。 除了有很多服务器进程并且非常繁忙的应用之外,这种情况是不太可能的,所以现在我不会为此担心。
此时,你可以尝试再次重现该错误,以了解新的表单验证方法如何防止该错误。