Flask 教程 第七章:錯誤處理


本文翻譯自The Flask Mega-Tutorial Part VII: Error Handling

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

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

本章的GitHub鏈接為:BrowseZipDiff.

Flask中的錯誤處理機制

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

Internal Server Error

如果你查看運行應用的終端會話,將看到stack trace(堆棧跟蹤)。 堆棧跟蹤在調試錯誤時非常有用,因為它們顯示堆棧中調用的順序,一直到產生錯誤的行:

 1 (venv) $ flask run
 2  * Serving Flask app "microblog"
 3  * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 4 [2017-09-14 22:40:02,027] ERROR in app: Exception on /edit_profile [POST]
 5 Traceback (most recent call last):
 6   File "/home/miguel/microblog/venv/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1182, in _execute_context
 7     context)
 8   File "/home/miguel/microblog/venv/lib/python3.6/site-packages/sqlalchemy/engine/default.py", line 470, in do_execute
 9     cursor.execute(statement, parameters)
10 sqlite3.IntegrityError: UNIQUE constraint failed: user.username

堆棧跟蹤指示了BUG在何處。 本應用允許用戶更改用戶名,但卻沒有驗證所選的新用戶名與系統中已有的其他用戶有沒有沖突。 這個錯誤來自SQLAlchemy,它嘗試將新的用戶名寫入數據庫,但數據庫拒絕了它,因為username列是用unique=True定義的。

值得注意的是,提供給用戶的錯誤頁面並沒有提供關於錯誤的豐富信息,這是正確的做法。 我絕對不希望用戶知道崩潰是由數據庫錯誤引起的,或者我正在使用什么數據庫,或者是我的數據庫中的一些表和字段名稱。 所有這些信息都應該對外保密。

但是也有一些不盡人意之處。錯誤頁面簡陋不堪,與應用布局不匹配。 終端上的日志不斷刷新,導致重要的堆棧跟蹤信息被淹沒,但我卻需要不斷回顧它,以免有漏網之魚。 當然,我有一個BUG需要修復。 我將解決所有的這些問題,但首先,讓我們來談談Flask的調試模式

調試模式

你在上面看到的處理錯誤的方式對在生產服務器上運行的系統非常有用。 如果出現錯誤,用戶將得到一個隱晦的錯誤頁面(盡管我打算使這個錯誤頁面更友好),錯誤的重要細節在服務器進程輸出或存儲到日志文件中。

但是當你正在開發應用時,可以啟用調試模式,它是Flask在瀏覽器上直接運行一個友好調試器的模式。 要激活調試模式,請停止應用程序,然后設置以下環境變量:

1 (venv) $ export FLASK_DEBUG=1

如果你使用Microsoft Windows,記得將export替換成set

設置環境變量FLASK_DEBUG后,重啟服務。相比之前,終端上的輸出信息會有所變化:

1 (venv) microblog2 $ flask run
2  * Serving Flask app "microblog"
3  * Forcing debug mode on
4  * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
5  * Restarting with stat
6  * Debugger is active!
7  * Debugger PIN: 177-562-960

現在讓應用再次崩潰,以在瀏覽器中查看交互式調試器:

Flask調試器

該調試器允許你展開每個堆棧框來查看相應的源代碼上下文。 你也可以在任意堆棧框上打開Python提示符並執行任何有效的Python表達式,例如檢查變量的值。

永遠不要在生產服務器上以調試模式運行Flask應用,這一點非常重要。 調試器允許用戶遠程執行服務器中的代碼,因此對於想要滲入應用或服務器的惡意用戶來說,這可能是開門揖盜。 作為附加的安全措施,運行在瀏覽器中的調試器開始被鎖定,並且在第一次使用時會要求輸入一個PIN碼(你可以在flask run命令的輸出中看到它)。

談到調試模式的話題,我不得不提到的第二個重要的調試模式下的功能,就是重載器。 這是一個非常有用的開發功能,可以在源文件被修改時自動重啟應用。 如果在調試模式下運行flask run,則可以在開發應用時,每當保存文件,應用都會重新啟動以加載新的代碼。

自定義錯誤頁面

Flask為應用提供了一個機制來自定義錯誤頁面,這樣用戶就不必看到簡單而枯燥的默認頁面。 作為例子,讓我們為HTTP的404錯誤和500錯誤(兩個最常見的錯誤頁面)設置自定義錯誤頁面。 為其他錯誤設置頁面的方式與之相同。

使用@errorhandler裝飾器來聲明一個自定義的錯誤處理器。 我將把我的錯誤處理程序放在一個新的app/errors.py模塊中。

app / errors.py:自定義錯誤處理程序
1
from flask import render_template 2 from app import app, db 3 4 @app.errorhandler(404) 5 def not_found_error(error): 6 return render_template('404.html'), 404 7 8 @app.errorhandler(500) 9 def internal_error(error): 10 db.session.rollback() 11 return render_template('500.html'), 500

錯誤函數與視圖函數非常類似。 對於這兩個錯誤,我將返回各自模板的內容。 請注意這兩個函數在模板之后返回第二個值,這是錯誤代碼編號。 對於之前我創建的所有視圖函數,我不需要添加第二個返回值,因為我想要的是默認值200(成功響應的狀態碼)。 本處,這些是錯誤頁面,所以我希望響應的狀態碼能夠反映出來。

500錯誤的錯誤處理程序應當在引發數據庫錯誤后調用,而上面的用戶名重復實際上就是這種情況。 為了確保任何失敗的數據庫會話不會干擾模板觸發的其他數據庫訪問,我執行會話回滾來將會話重置為干凈的狀態。

404錯誤的模板如下:

app / templates / 404.html:找不到錯誤模板
1
{% extends "base.html" %} 2 3 {% block content %} 4 <h1>File Not Found</h1> 5 <p><a href="{{ url_for('index') }}">Back</a></p> 6 {% endblock %}

 

500錯誤的模板如下:

app / templates / 500.html:內部服務器錯誤模板
1
{% extends "base.html" %} 2 3 {% block content %} 4 <h1>An unexpected error has occurred</h1> 5 <p>The administrator has been notified. Sorry for the inconvenience!</p> 6 <p><a href="{{ url_for('index') }}">Back</a></p> 7 {% endblock %}

這兩個模板都從base.html基礎模板繼承而來,所以錯誤頁面與應用的普通頁面有相同的外觀布局。

為了讓這些錯誤處理程序在Flask中注冊,我需要在應用實例創建后導入新的app/errors.py模塊:

app / __ init__.py:導入錯誤處理程序
1
# ... 2 3 from app import routes, models, errors

如果在終端界面設置環境變量FLASK_DEBUG=0,然后再次出發重復用戶名的BUG,你將會看到一個更加友好的錯誤頁面。

自定義500錯誤頁面

通過電子郵件發送錯誤

Flask提供的默認錯誤處理機制的另一個問題是沒有通知機制,錯誤的堆棧跟蹤只是被打印到終端,這意味着需要監視服務器進程的輸出才能發現錯誤。 在開發時,這是非常好的,但是一旦將應用部署在生產服務器上,沒有人會關心輸出,因此需要采用更強大的解決方案。

我認為對錯誤發現采取積極主動的態度是非常重要的。 如果生產環境的應用發生錯誤,我想立刻知道。 所以我的第一個解決方案是配置Flask在發生錯誤之后立即向我發送一封電子郵件,郵件正文中包含錯誤堆棧跟蹤的正文。

第一步,添加郵件服務器的信息到配置文件中:

config.py:電子郵件配置
1
class Config(object): 2 # ... 3 MAIL_SERVER = os.environ.get('MAIL_SERVER') 4 MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25) 5 MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None 6 MAIL_USERNAME = os.environ.get('MAIL_USERNAME') 7 MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') 8 ADMINS = ['your-email@example.com']

電子郵件的配置變量包括服務器和端口,啟用加密連接的布爾標記以及可選的用戶名和密碼。 這五個配置變量來源於環境變量。 如果電子郵件服務器沒有在環境中設置,那么我將禁用電子郵件功能。 電子郵件服務器端口也可以在環境變量中給出,但是如果沒有設置,則使用標准端口25。 電子郵件服務器憑證默認不使用,但可以根據需要提供。 ADMINS配置變量是將收到錯誤報告的電子郵件地址列表,所以你自己的電子郵件地址應該在該列表中。

Flask使用Python的logging包來寫它的日志,而且這個包已經能夠通過電子郵件發送日志了。 我所需要做的就是為Flask的日志對象app.logger添加一個SMTPHandler的實例:

app / __ init__.py:通過電子郵件記錄錯誤
1
import logging 2 from logging.handlers import SMTPHandler 3 4 # ... 5 6 if not app.debug: 7 if app.config['MAIL_SERVER']: 8 auth = None 9 if app.config['MAIL_USERNAME'] or app.config['MAIL_PASSWORD']: 10 auth = (app.config['MAIL_USERNAME'], app.config['MAIL_PASSWORD']) 11 secure = None 12 if app.config['MAIL_USE_TLS']: 13 secure = () 14 mail_handler = SMTPHandler( 15 mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']), 16 fromaddr='no-reply@' + app.config['MAIL_SERVER'], 17 toaddrs=app.config['ADMINS'], subject='Microblog Failure', 18 credentials=auth, secure=secure) 19 mail_handler.setLevel(logging.ERROR) 20 app.logger.addHandler(mail_handler)

如你所見,僅當應用未以調試模式運行,且配置中存在郵件服務器時,我才會啟用電子郵件日志記錄器。

設置電子郵件日志記錄器的步驟因為處理安全可選項而稍顯繁瑣。 本質上,上面的代碼創建了一個SMTPHandler實例,設置它的級別,以便它只報告錯誤及更嚴重級別的信息,而不是警告,常規信息或調試消息,最后將它附加到Flask的app.logger對象中。

有兩種方法來測試此功能。 最簡單的就是使用Python的SMTP調試服務器。 這是一個模擬的電子郵件服務器,它接受電子郵件,然后打印到控制台。 要運行此服務器,請打開第二個終端會話並在其上運行以下命令:

1 (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帳戶的電子郵件服務器的配置:

1 export MAIL_SERVER=smtp.googlemail.com
2 export MAIL_PORT=587
3 export MAIL_USE_TLS=1
4 export MAIL_USERNAME=<your-gmail-username>
5 export MAIL_PASSWORD=<your-gmail-password>

如果你使用的是Microsoft Windows,記住在每一條語句中用set替換掉export

Gmail帳戶中的安全功能可能會阻止應用通過它發送電子郵件,除非你明確允許“安全性較低的應用程序”訪問你的Gmail帳戶。 可以閱讀此處來了解具體情況,如果你擔心帳戶的安全性,可以創建一個輔助郵箱帳戶,配置它來僅用於測試電子郵件功能,或者你可以暫時啟用允許不太安全的應用程序來運行此測試,完成后恢復為默認值。

記錄日志到文件中

通過電子郵件來接收錯誤提示非常棒,但在其他場景下,有時候就有些不足了。有些錯誤條件既不是一個Python異常又不是重大事故,但是他們在調試的時候也是有足夠用處的。為此,我將會為本應用維持一個日志文件。

為了啟用另一個基於文件類型RotatingFileHandler的日志記錄器,需要以和電子郵件日志記錄器類似的方式將其附加到應用的logger對象中。

app / __ init__.py:登錄到文件
1
# ... 2 from logging.handlers import RotatingFileHandler 3 import os 4 5 # ... 6 7 if not app.debug: 8 # ... 9 10 if not os.path.exists('logs'): 11 os.mkdir('logs') 12 file_handler = RotatingFileHandler('logs/microblog.log', maxBytes=10240, 13 backupCount=10) 14 file_handler.setFormatter(logging.Formatter( 15 '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]')) 16 file_handler.setLevel(logging.INFO) 17 app.logger.addHandler(file_handler) 18 19 app.logger.setLevel(logging.INFO) 20 app.logger.info('Microblog startup')

日志文件的存儲路徑位於頂級目錄下,相對路徑為logs/microblog.log,如果其不存在,則會創建它。

RotatingFileHandler類非常棒,因為它可以切割和清理日志文件,以確保日志文件在應用運行很長時間時不會變得太大。 本處,我將日志文件的大小限制為10KB,並只保留最后的十個日志文件作為備份。

logging.Formatter類為日志消息提供自定義格式。 由於這些消息正在寫入到一個文件,我希望它們可以存儲盡可能多的信息。 所以我使用的格式包括時間戳、日志記錄級別、消息以及日志來源的源代碼文件和行號。

為了使日志記錄更有用,我還將應用和文件日志記錄器的日志記錄級別降低到INFO級別。 如果你不熟悉日志記錄類別,則按照嚴重程度遞增的順序來認識它們就行了,分別是DEBUGINFOWARNINGERRORCRITICAL

日志文件的第一個有趣用途是,服務器每次啟動時都會在日志中寫入一行。 當此應用在生產服務器上運行時,這些日志數據將告訴你服務器何時重新啟動過。

修復用戶名重復的BUG

利用用戶名重復BUG這么久, 現在時候向你展示如何修復它了。

你是否還記得,RegistrationForm已經實現了對用戶名的驗證,但是編輯表單的要求稍有不同。 在注冊期間,我需要確保在表單中輸入的用戶名不存在於數據庫中。 在編輯個人資料表單中,我必須做同樣的檢查,但有一個例外。 如果用戶不改變原始用戶名,那么驗證應該允許,因為該用戶名已經被分配給該用戶。 下面你可以看到我為這個表單實現了用戶名驗證:

 app / forms.py:在編輯配置文件表單中驗證用戶名。
1
class EditProfileForm(FlaskForm): 2 username = StringField('Username', validators=[DataRequired()]) 3 about_me = TextAreaField('About me', validators=[Length(min=0, max=140)]) 4 submit = SubmitField('Submit') 5 6 def __init__(self, original_username, *args, **kwargs): 7 super(EditProfileForm, self).__init__(*args, **kwargs) 8 self.original_username = original_username 9 10 def validate_username(self, username): 11 if username.data != self.original_username: 12 user = User.query.filter_by(username=self.username.data).first() 13 if user is not None: 14 raise ValidationError('Please use a different username.')

該實現使用了一個自定義的驗證方法,接受表單中的用戶名作為參數。 這個用戶名保存為一個實例變量,並在validate_username()方法中被校驗。 如果在表單中輸入的用戶名與原始用戶名相同,那么就沒有必要檢查數據庫是否有重復了。

為了使得新增的驗證方法生效,我需要在對應視圖函數中添加當前用戶名到表單的username字段中:

app / routes.py:以編輯配置文件形式驗證用戶名。
1
@app.route('/edit_profile', methods=['GET', 'POST']) 2 @login_required 3 def edit_profile(): 4 form = EditProfileForm(current_user.username) 5 # ...

現在這個BUG已經修復了,大多數情況下,以后在編輯個人資料時出現用戶名重復的提交將被友好地阻止。 但這不是一個完美的解決方案,因為當兩個或更多進程同時訪問數據庫時,這可能不起作用。假如存在驗證通過的進程A和B都嘗試修改用戶名為同一個,但稍后進程A嘗試重命名時,數據庫已被進程B更改,無法重命名為該用戶名,會再次引發數據庫異常。 除了有很多服務器進程並且非常繁忙的應用之外,這種情況是不太可能的,所以現在我不會為此擔心。

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM