Flask教程第七章:錯誤處理

天降攻城獅發表於2019-02-22

本文轉載自:https://www.jianshu.com/p/9368fa845bba

這是Flask Mega-Tutorial系列的第七部分,我將告訴你如何在Flask應用中進行錯誤處理。

本章將暫停為microblog應用開發新功能,轉而討論處理BUG的策略,因為它們總是無處不在。為了幫助本章的演示,我故意在第六章新增的程式碼中遺留了一處BUG。 在繼續閱讀之前,看看你能不能找到它!

本章的GitHub連結為:BrowseZipDiff.

Flask中的錯誤處理機制

在Flask應用中爆發錯誤時會發生什麼? 得到答案的最好的方法就是親身體驗一下。 啟動應用,並確保至少有兩個使用者註冊,以其中一個使用者身份登入,開啟個人主頁並單擊“編輯”連結。 在個人資料編輯器中,嘗試將使用者名稱更改為已經註冊的另一個使用者的使用者名稱,boom!(爆炸聲) 這將帶來一個可怕的“Internal Server Error”頁面:

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

現在讓應用再次崩潰,以在瀏覽器中檢視互動式偵錯程式:

Flask偵錯程式

該偵錯程式允許你展開每個堆疊框來檢視相應的原始碼上下文。 你也可以在任意堆疊框上開啟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,你將會看到一個更加友好的錯誤頁面。

自定義500錯誤頁面

通過電子郵件傳送錯誤

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=localhostMAIL_PORT=8025

譯者注:本段中去處了說明設定該埠需要管理員許可權的部分,因為這和實際情況不符。原文如下: 
To test the application with this server, then you will set MAIL_SERVER=localhost and MAIL_PORT=8025. If you are on a Linux or Mac OS system, you will likely need to prefix the command with sudo, 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 set MAIL_PORTvariable to your chosen port in the environment, and that will not require administration rights.

保持除錯SMTP伺服器執行並返回到第一個終端,在環境中設定export MAIL_SERVER=localhostMAIL_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級別。 如果你不熟悉日誌記錄類別,則按照嚴重程度遞增的順序來認識它們就行了,分別是DEBUGINFOWARNINGERRORCRITICAL

日誌檔案的第一個有趣用途是,伺服器每次啟動時都會在日誌中寫入一行。 當此應用在生產伺服器上執行時,這些日誌資料將告訴你伺服器何時重新啟動過。

修復使用者名稱重複的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更改,無法重新命名為該使用者名稱,會再次引發資料庫異常。 除了有很多伺服器程式並且非常繁忙的應用之外,這種情況是不太可能的,所以現在我不會為此擔心。

此時,你可以嘗試再次重現該錯誤,以瞭解新的表單驗證方法如何防止該錯誤。


相關文章