07.错误处理

在本章我们讨论如何在 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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,444评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,421评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,036评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,363评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,460评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,502评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,511评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,280评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,736评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,014评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,190评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,848评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,531评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,159评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,411评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,067评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,078评论 2 352

推荐阅读更多精彩内容