概要
在前面的章節裡我們專註於在我們的小應用程式上一步步的添加功能上。到現在為止我們有了一個帶有資料庫的應用程式,可以註冊使用者,記錄使用者登陸退出日誌以及查看修改設定檔。
在本節中,我們不為應用程式添加任何新功能,相反,我們要尋找一種方法來增加我們已寫代碼的穩定性,我們還將建立一個測試架構來協助我們防止將來程式中出現的失敗和復原。
讓我們來找bug
在上一章的結尾談到,我故意在應用程式中引入一個bug。接下來讓我描述一下它是什麼樣的bug,然後看看當我們的程式不按照我們意願執行的時候,它在其中又起了什麼樣的影響。
應用程式的問題在於,沒有保證使用者暱稱的唯一性。使用者暱稱是由應用程式自動初始化的。我們首先會考慮使用OpenID provider給出的使用者的暱稱,然後再考慮使用Email資訊中的使用者名稱部分作為使用者的暱稱。但如果出現重複的暱稱,則後面的使用者將無法註冊成功。更糟糕的是,在修改使用者配置的表單中,我們允許使用者任意更改他們的暱稱,但我們仍然沒有對暱稱衝突進行檢查。
當我們分析完錯誤產生時應用程式的行為之後,我們將會定位這些問題。
Flask 的調試功能
那麼讓我們看看當bug被觸發時,會出現什麼現象。
讓我們從建立一個嶄新的資料庫,在linux下,執行:
rm app.db./db_create.py
在Windows下,執行:
del app.dbflask/Scripts/python db_create.py
我們需要兩個OpenID的帳號來重現這個bug。當然這兩個帳號最理想的狀態是來自來個不同的擁有者,那樣可以避免他們的cookie把情況搞的更複雜。通過如下步驟建立衝突的暱稱:
- 用第一個帳號登陸
- 進入使用者資訊屬性編輯頁面,將暱稱改為“dup”
- 登出系統
- 用第二個帳號登陸
- 修改第二個帳號的使用者資訊屬性,將暱稱改為“dup”
哎喲!sqlalchemy中拋出了一個異常,來看一下錯誤資訊:
lalchemy.exc.IntegrityErrorIntegrityError: (IntegrityError) column nickname is not unique u'UPDATE user SET nickname=?, about_me=? WHERE user.id = ?' (u'dup', u'', 2)
錯誤的後面是這個錯誤的堆棧資訊,事實上,這是一個相當不錯的錯誤提示,你可以轉向任何架構檢查代碼或者在瀏覽器裡執行正確的運算式。
這個錯誤資訊相當明確,我們試圖在資料插入一個重複的暱稱,資料庫的暱稱欄位是一個衛衣鍵,因此這樣的操作是無效的。
除了實際的錯誤,在我們手頭上還有一個次要的錯誤。如果一個使用者不注意在我們應用程式裡引起了一個錯誤(這一個錯誤或者任何其他原因引起的異常),應用程式將向他/她暴漏錯誤資訊和堆棧資訊,而不是暴露給我們。對於我們開發人員來說這是個很好的特性,但是很多時候我們不想讓使用者看到這些資訊。
這麼長時間以來,我們一直在debug模式下運行我們的應用程式,我們通過設定debug=True的參數來啟用應用程式的debug模式。這裡我們在運行指令碼run.py裡配置。
當我們這樣開發應用是方便的,但是我們需要在生產環境上關閉debug模式。 讓我們建立另一個啟動指令檔設定關閉dubug模式(filerunp.py):
#!flask/bin/pythonfrom app import appapp.run(debug = False)
現在重新啟動應用:
./runp.py
並且現在再嘗試重新命名第二個帳號nickname成‘dup'
這次我們沒有擷取到一個錯誤資訊,取而代之,我們得到了一個HTTP 500錯誤碼,這是個內部伺服器錯誤。雖然這不容易定位錯誤,但至少沒有暴露我們應用程式的任何細節給陌生人。當調試關閉後出現一個異常時,Flask會產生一個500頁面。
雖然這樣好些了,但現在仍存在兩個問題。首先美化問題:預設的500頁面很醜陋。第二個問題更重要些,當使用者操作失敗時,我們無法擷取到錯誤資訊了,因為錯誤在後台默默的處理了。幸運的是有個簡單方式來處理這兩個問題。
定製HTTP錯誤處理程式
Flask為應用程式提供了一個機制來安裝他們自己的錯誤頁面,作為例子,讓我們定義兩個最常見的HTTP 404和500錯誤的自訂頁面。定製其他錯誤頁面也是同樣的方式。
使用一個修飾來聲明一個定製的錯誤處理程式 (fileapp/views.py):
@app.errorhandler(404)def internal_error(error): return render_template('404.html'), 404 @app.errorhandler(500)def internal_error(error): db.session.rollback() return render_template('500.html'), 500
這地方無需多言,因為他們都是不言而喻的。唯一有趣的地方時錯誤500處理中的rollack語句,這個地方是不可缺少的因為這個方法會被當做一個異常調用。如果因為資料庫錯誤導致一個異常,那麼資料庫的會話將變成一個無效狀態,因此我們需要復原它,以防止一個會話轉向一個500錯誤的模板。
這是一個404錯誤在模版
{% extends "base.html" %} {% block content %}File Not Found
Back
{% endblock %}
這是一個500錯誤的模版
{% extends "base.html" %} {% block content %}An unexpected error has occurred
The administrator has been notified. Sorry for the inconvenience!
Back
{% endblock %}
注意,我們會繼續使用我們base.html 布局, 這樣我們的錯誤頁看起來比較舒服
通過email發送錯誤記錄檔
為了處理第二個問題我們需要配置應用的錯誤報表機制。
第一個是每當有錯誤發生時把錯誤記錄檔通過郵件發送給我們。
首先,我們需要在我們的應用配置郵件伺服器和管理員列表 (fileconfig.py):
# mail server settingsMAIL_SERVER = 'localhost'MAIL_PORT = 25MAIL_USERNAME = NoneMAIL_PASSWORD = None # administrator listADMINS = ['you@example.com']
當然,你要把上面的配置改成你自己的才有意義
Flask 使用通用的Python logging模組, 所以設定發送錯誤記錄檔郵件非常簡單. (fileapp/__init__.py):
from config import basedir, ADMINS, MAIL_SERVER, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD if not app.debug: import logging from logging.handlers import SMTPHandler credentials = None if MAIL_USERNAME or MAIL_PASSWORD: credentials = (MAIL_USERNAME, MAIL_PASSWORD) mail_handler = SMTPHandler((MAIL_SERVER, MAIL_PORT), 'no-reply@' + MAIL_SERVER, ADMINS, 'microblog failure', credentials) mail_handler.setLevel(logging.ERROR) app.logger.addHandler(mail_handler)
Note that we are only enabling the emails when we run without debugging.
注意,我們要的非dubug模式下開啟郵件功能.
在沒有郵件伺服器的pc上測試郵件功能也很容易,幸好Python有SMTP的測試排錯的伺服器(SMTP debugging server)。開啟一個控制台視窗,並且運行下面的命令:
python -m smtpd -n -c DebuggingServer localhost:25
當程式啟動並執行時候,應用接收和發送郵件會在控制台視窗中顯示出來。
列印日誌到檔案
通過郵件接收錯誤記錄檔非常不錯,但是,這是不夠的。有些導致失敗的條件不會觸發異常並且不是主要的問題,所以我們需要將日誌儲存到log檔案中,在某些情況下,需要日誌來進行排錯。
出於這個原因,我們的應用需要一個記錄檔。
開啟檔案日誌和郵件日誌很相似(fileapp/__init__.py):
if not app.debug: import logging from logging.handlers import RotatingFileHandler file_handler = RotatingFileHandler('tmp/microblog.log', 'a', 1 * 1024 * 1024, 10) file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]')) app.logger.setLevel(logging.INFO) file_handler.setLevel(logging.INFO) app.logger.addHandler(file_handler) app.logger.info('microblog startup')
記錄檔將在tmp目錄下產生,檔案名稱叫microblog.log。我們使用的RotatingFileHandler方法中有個限制日誌數量的參數。在這種情況下,我們限制了一個記錄檔大小為1M,並且把最後的十個檔案作為備份。
logging.Formatter類提供了日誌資訊的定義格式,由於這些資訊將寫到一個檔案中,我們想擷取到儘可能多的資訊,因此除了日誌資訊和堆棧資訊,我們還寫了個時間戳記,記錄層級和檔案名稱、資訊的行號。
為了使日誌更有用,我們降低了應用程式記錄檔和檔案Tlog程式的記錄層級,因為這樣我們將有機會在沒有錯誤情況下把有用資訊寫入到日誌中。作為一個例子,我們啟動時將日誌的層級設定為資訊層級。從現在開始,每次你啟動應用程式將記錄你的調試資訊。
當我們沒有使用日誌時,調試一個線上和使用中的web服務時是件非常困難的事,把日誌資訊寫入到檔案中,將是我們診斷和解決問題的一個有用工具,所以現在讓我們準備好使用這個功能吧。
bug修複
讓我們來修複下暱稱重複的bug.
前面討論過,有兩個地方目前還沒有處理重複。首先是Flask-Login的after_login處理,這個方法將在使用者成功登陸到系統後調用,我們需要建立一個新的User執行個體。這是受影響的一個程式碼片段,我們做了修複 (fileapp/views.py):
if user is None: nickname = resp.nickname if nickname is None or nickname == "": nickname = resp.email.split('@')[0] nickname = User.make_unique_nickname(nickname) user = User(nickname = nickname, email = resp.email, role = ROLE_USER) db.session.add(user) db.session.commit()
我們解決這個問題的方法是讓User類選擇一個唯一的名字給我們,這也是 make_unique_nickname方法所做的(fileapp/models.py):
class User(db.Model): # ... @staticmethod def make_unique_nickname(nickname): if User.query.filter_by(nickname = nickname).first() == None: return nickname version = 2 while True: new_nickname = nickname + str(version) if User.query.filter_by(nickname = new_nickname).first() == None: break version += 1 return new_nickname # ...
這個方法簡單的添加一個計數器來產生一個唯一的暱稱名。例如,如果使用者名稱“miguel”存在,這個方法將建議你使用“miguel2”,但是如果它也存在就會產生“miguel3”···。注意我們把這個方法設定為靜態方法,因為這個操作不適用於任何類的執行個體。
第二個導致重複暱稱的地方是編輯整頁模式函數,這算是使用者選擇暱稱的一個小惡作劇,正確的方式是不允許使用者輸入重複名稱,讓使用者更換為另一個名稱。我們通過添加form表單驗證來解決這個問題,如果使用者輸入一個無效的暱稱,將會得到一個欄位驗證失敗資訊,添加我們的驗證只需重寫form的validate方法 (fileapp/forms.py):
class EditForm(Form): nickname = TextField('nickname', validators = [Required()]) about_me = TextAreaField('about_me', validators = [Length(min = 0, max = 140)]) def __init__(self, original_nickname, *args, **kwargs): Form.__init__(self, *args, **kwargs) self.original_nickname = original_nickname def validate(self): if not Form.validate(self): return False if self.nickname.data == self.original_nickname: return True user = User.query.filter_by(nickname = self.nickname.data).first() if user != None: self.nickname.errors.append('This nickname is already in use. Please choose another one.') return False return True
表單的建構函式增加了一個新的參數original_nickname,驗證方法validate使用這個參數來判斷暱稱是否修改了,如果沒有修改就直接返回它,如果已經修改了,方法會確認下新的暱稱在資料庫是否已經存在。
接下來我們在視圖函數中添加新的構造器參數:
@app.route('/edit', methods = ['GET', 'POST'])@login_requireddef edit(): form = EditForm(g.user.nickname) # ...
完成這個修改我們還必須在表單的模板中啟用錯誤顯示欄位 (檔案app/templates/edit.html):
Your nickname: {{form.nickname(size = 24)}} {% for error in form.errors.nickname %}
[{{error}}] {% endfor %}
現在這個bug已經修複了,阻止了重複資料的出現···除非這些驗證方法不能正常工作了。在兩個或者多個線程/進程並行存取資料庫時,這仍然存在一個潛在的問題,但這些都是以後我們文章討論的主題。
在這裡你可以嘗試選擇一個重複的名稱來看看錶單如何處理這些錯誤的。
單元測試架構
先把上面關於測試的會話放一下,咱們來討論下關於自動化測試的話題。
隨著應用程式規模的增長,越來越難以確定代碼的改變是否會影響到現有的功能。
傳統的方法防止迴歸是一個很好的方式,你通過編寫單元測試來測試應用程式所有不同功能,每一個測試集中於一個點來驗證結果是否和預期的一致。測試程式通過週期性執行來確認應用程式是否在正常工作。當測試覆蓋率變大時,你就可以自信的修改和添加新功能,只需通過測試程式來驗證下是否影響到了應用程式現有功能。
現在我們使用python的unittest測試組件來建立個簡單的測試架構 (tests.py):
#!flask/bin/pythonimport unittest from config import basedirfrom app import app, dbfrom app.models import User class TestCase(unittest.TestCase): def setUp(self): app.config['TESTING'] = True app.config['CSRF_ENABLED'] = False app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'test.db') self.app = app.test_client() db.create_all() def tearDown(self): db.session.remove() db.drop_all() def test_avatar(self): u = User(nickname = 'john', email = 'john@example.com') avatar = u.avatar(128) expected = 'http://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6' assert avatar[0:len(expected)] == expected def test_make_unique_nickname(self): u = User(nickname = 'john', email = 'john@example.com') db.session.add(u) db.session.commit() nickname = User.make_unique_nickname('john') assert nickname != 'john' u = User(nickname = nickname, email = 'susan@example.com') db.session.add(u) db.session.commit() nickname2 = User.make_unique_nickname('john') assert nickname2 != 'john' assert nickname2 != nickname if __name__ == '__main__': unittest.main()
unittest測試組件的討論超出了本文的範圍了,我們這裡只需知道TestCase是我們的測試類別。setUp和tearDown方法有些特殊,它們分別在每個測試方法前後執行,複雜點的設定可以包含幾組測試,每個代表一個單元測試,TestCase的子類和每個組都將擁有獨立的setUp和tearDown方法。
這些特殊的setUp和tearDown方法都是非常通用的,在setUp可以方便的修改配置,例如,我們想測試不同的資料庫作為主要資料庫,在tearDown裡面只需簡單設定下資料庫內容就可以。
測試作為方法被實現,一個測試應該運行一些已知結果的應用程式方法,也應當能夠斷言出結果和預期的不同。
到目前為止,在我們的測試架構裡有兩個測試。第一個驗證來自於上一篇文章的Gravatar avatar URLs產生的是否正確,注意預期的avatar被寫入程式碼在測試中,和User類中返回的對象作比較。
第二個測實驗證是test_make_unique_nickname方法,同樣也是在User類中。這個測試有點詳細,它建立了一個新的使用者並且寫入資料庫中,同時確定名字的唯一性。接下來建立第二個使用者,建議使用唯一名稱,你可以嘗試下使用第一個使用者名稱稱。在第二部分測試預期結果是建議使用與之前不同的名稱。
運行這個測試套件你只需運行tests.py指令碼:
./tests.py
如果出現錯誤資訊,你將會在控制台得到一個報告。
結語
今天關於調試,錯誤和測試的討論到此為止,我希望這篇文章能對你有用。
老規矩,如果你有任何評論請寫在下面.
微博應用程式的代碼今天修改的更新,你可以在這裡下載:
下載 microblog-0.7.zip.