這是 「Python 工匠」系列的第 6 篇文章。[查看系列全部文章]javascript
若是你用 Python 編程,那麼你就沒法避開異常,由於異常在這門語言裏無處不在。打個比方,當你在腳本執行時按 ctrl+c
退出,解釋器就會產生一個 KeyboardInterrupt
異常。而 KeyError
、ValueError
、TypeError
等更是平常編程裏隨處可見的老朋友。html
異常處理工做由「捕獲」和「拋出」兩部分組成。「捕獲」指的是使用 try ... except
包裹特定語句,穩當的完成錯誤流程處理。而恰當的使用 raise
主動「拋出」異常,更是優雅代碼裏必不可少的組成部分。前端
在這篇文章裏,我會分享與異常處理相關的 3 個好習慣。繼續閱讀前,我但願你已經瞭解了下面這些知識點:java
假如你不夠了解異常機制,就不免會對它有一種自然恐懼感。你可能會以爲:*異常是一種很差的東西,好的程序就應該捕獲全部的異常,讓一切都平平穩穩的運行。*而抱着這種想法寫出的代碼,裏面一般會出現大段含糊的異常捕獲邏輯。python
讓咱們用一段可執行腳本做爲樣例:git
# -*- coding: utf-8 -*-
import requests
import re
def save_website_title(url, filename):
"""獲取某個地址的網頁標題,而後將其寫入到文件中 :returns: 若是成功保存,返回 True,不然打印錯誤,返回 False """
try:
resp = requests.get(url)
obj = re.search(r'<title>(.*)</title>', resp.text)
if not obj:
print('save failed: title tag not found in page content')
return False
title = obj.grop(1)
with open(filename, 'w') as fp:
fp.write(title)
return True
except Exception:
print(f'save failed: unable to save title of {url} to {filename}')
return False
def main():
save_website_title('https://www.qq.com', 'qq_title.txt')
if __name__ == '__main__':
main()
複製代碼
腳本里的 save_website_title
函數作了好幾件事情。它首先經過網絡獲取網頁內容,而後利用正則匹配出標題,最後將標題寫在本地文件裏。而這裏有兩個步驟很容易出錯:網絡請求 與 本地文件操做。因此在代碼裏,咱們用一個大大的 try ... except
語句塊,將這幾個步驟都包裹了起來。安全第一 ⛑。github
那麼,這段看上去簡潔易懂的代碼,裏面藏着什麼問題呢?web
若是你旁邊恰好有一臺安裝了 Python 的電腦,那麼你能夠試着跑一遍上面的腳本。你會發現,上面的代碼是不能成功執行的。並且你還會發現,不管你如何修改網址和目標文件的值,程序仍然會報錯 「save failed: unable to...」。爲何呢?編程
問題就藏在這個碩大無比的 try ... except
語句塊裏。假如你把眼睛貼近屏幕,很是仔細的檢查這段代碼。你會發如今編寫函數時,我犯了一個小錯誤,我把獲取正則匹配串的方法錯打成了 obj.grop(1)
,少了一個 'u'(obj.group(1)
)。json
但正是由於那個過於龐大、含糊的異常捕獲,這個由打錯方法名致使的本來該被拋出的 AttibuteError
卻被吞噬了。從而給咱們的 debug 過程增長了沒必要要的麻煩。
異常捕獲的目的,不是去捕獲儘量多的異常。假如咱們從一開始就堅持:只作最精準的異常捕獲。那麼這樣的問題就根本不會發生,精準捕獲包括:
Exception
依照這個原則,咱們的樣例應該被改爲這樣:
from requests.exceptions import RequestException
def save_website_title(url, filename):
try:
resp = requests.get(url)
except RequestException as e:
print(f'save failed: unable to get page content: {e}')
return False
# 這段正則操做自己就是不該該拋出異常的,因此咱們不必使用 try 語句塊
# 假如 group 被誤打成了 grop 也不要緊,程序立刻就會經過 AttributeError 來
# 告訴咱們。
obj = re.search(r'<title>(.*)</title>', resp.text)
if not obj:
print('save failed: title tag not found in page content')
return False
title = obj.group(1)
try:
with open(filename, 'w') as fp:
fp.write(title)
except IOError as e:
print(f'save failed: unable to write to file {filename}: {e}')
return False
else:
return True
複製代碼
大約四五年前,當時的我正在開發某移動應用的後端 API 項目。若是你也有過開發後端 API 的經驗,那麼你必定知道,這樣的系統都須要制定一套**「API 錯誤碼規範」**,來爲客戶端處理調用錯誤時提供方便。
一個錯誤碼返回大概長這個樣子:
// HTTP Status Code: 400
// Content-Type: application/json
{
"code": "UNABLE_TO_UPVOTE_YOUR_OWN_REPLY",
"detail": "你不能推薦本身的回覆"
}
複製代碼
在制定好錯誤碼規範後,接下來的任務就是如何實現它。當時的項目使用了 Django 框架,而 Django 的錯誤頁面正是使用了異常機制實現的。打個比方,若是你想讓一個請求返回 404 狀態碼,那麼只要在該請求處理過程當中執行 raise Http404
便可。
因此,咱們很天然的從 Django 得到了靈感。首先,咱們在項目內定義了錯誤碼異常類:APIErrorCode
。而後依據「錯誤碼規範」,寫了不少繼承該類的錯誤碼。當須要返回錯誤信息給用戶時,只須要作一次 raise
就能搞定。
raise error_codes.UNABLE_TO_UPVOTE
raise error_codes.USER_HAS_BEEN_BANNED
... ...
複製代碼
毫無心外,全部人都很喜歡用這種方式來返回錯誤碼。由於它用起來很是方便,不管調用棧多深,只要你想給用戶返回錯誤碼,調用 raise error_codes.ANY_THING
就好。
隨着時間推移,項目也變得愈來愈龐大,拋出 APIErrorCode
的地方也愈來愈多。有一天,我正準備複用一個底層圖片處理函數時,忽然碰到了一個問題。
我看到了一段讓我很是糾結的代碼:
# 在某個處理圖像的模塊內部
# <PROJECT_ROOT>/util/image/processor.py
def process_image(...):
try:
image = Image.open(fp)
except Exception:
# 說明(非項目原註釋):該異常將會被 Django 的中間件捕獲,往前端返回
# "上傳的圖片格式有誤" 信息
raise error_codes.INVALID_IMAGE_UPLOADED
... ...
複製代碼
process_image
函數會嘗試解析一個文件對象,若是該對象不能被做爲圖片正常打開,就拋出 error_codes.INVALID_IMAGE_UPLOADED (APIErrorCode 子類)
異常,從而給調用方返回錯誤代碼 JSON。
讓我給你從頭理理這段代碼。最初編寫 process_image
時,我雖然把它放在了 util.image
模塊裏,但當時調這個函數的地方就只有 「處理用戶上傳圖片的 POST 請求」 而已。爲了偷懶,我讓函數直接拋出 APIErrorCode
異常來完成了錯誤處理工做。
再來講當時的問題。那時我須要寫一個在後臺運行的批處理圖片腳本,而它恰好能夠複用 process_image
函數所實現的功能。但這時不對勁的事情出現了,若是我想複用該函數,那麼:
INVALID_IMAGE_UPLOADED
的異常
APIErrorCode
異常類做爲依賴來捕獲異常
**這就是異常類抽象層級不一致致使的結果。**APIErrorCode 異常類的意義,在於表達一種可以直接被終端用戶(人)識別並消費的「錯誤代碼」。**它在整個項目裏,屬於最高層的抽象之一。**可是出於方便,咱們卻在底層模塊裏引入並拋出了它。這打破了 image.processor
模塊的抽象一致性,影響了它的可複用性和可維護性。
這類狀況屬於「模塊拋出了高於所屬抽象層級的異常」。避免這類錯誤須要注意如下幾點:
image.processer
模塊應該拋出本身封裝的 ImageOpenError
異常ImageOpenError
低級異常包裝轉換爲 APIErrorCode
高級異常修改後的代碼:
# <PROJECT_ROOT>/util/image/processor.py
class ImageOpenError(Exception):
pass
def process_image(...):
try:
image = Image.open(fp)
except Exception as e:
raise ImageOpenError(exc=e)
... ...
# <PROJECT_ROOT>/app/views.py
def foo_view_function(request):
try:
process_image(fp)
except ImageOpenError:
raise error_codes.INVALID_IMAGE_UPLOADED
複製代碼
除了應該避免拋出高於當前抽象級別的異常外,咱們一樣應該避免泄露低於當前抽象級別的異常。
若是你用過 requests
模塊,你可能已經發現它請求頁面出錯時所拋出的異常,並非它在底層所使用的 urllib3
模塊的原始異常,而是經過 requests.exceptions
包裝過一次的異常。
>>> try:
... requests.get('https://www.invalid-host-foo.com')
... except Exception as e:
... print(type(e))
...
<class 'requests.exceptions.ConnectionError'>
複製代碼
這樣作一樣是爲了保證異常類的抽象一致性。由於 urllib3 模塊是 requests 模塊依賴的底層實現細節,而這個細節有可能在將來版本發生變更。因此必須對它拋出的異常進行恰當的包裝,避免將來的底層變動對 requests
用戶端錯誤處理邏輯產生影響。
在前面咱們提到異常捕獲要精準、抽象級別要一致。但在現實世界中,若是你嚴格遵循這些流程,那麼頗有可能會碰上另一個問題:異常處理邏輯太多,以致於擾亂了代碼核心邏輯。具體表現就是,代碼裏充斥着大量的 try
、except
、raise
語句,讓核心邏輯變得難以辨識。
讓咱們看一段例子:
def upload_avatar(request):
"""用戶上傳新頭像"""
try:
avatar_file = request.FILES['avatar']
except KeyError:
raise error_codes.AVATAR_FILE_NOT_PROVIDED
try:
resized_avatar_file = resize_avatar(avatar_file)
except FileTooLargeError as e:
raise error_codes.AVATAR_FILE_TOO_LARGE
except ResizeAvatarError as e:
raise error_codes.AVATAR_FILE_INVALID
try:
request.user.avatar = resized_avatar_file
request.user.save()
except Exception:
raise error_codes.INTERNAL_SERVER_ERROR
return HttpResponse({})
複製代碼
這是一個處理用戶上傳頭像的視圖函數。這個函數內作了三件事情,而且針對每件事都作了異常捕獲。若是作某件事時發生了異常,就返回對用戶友好的錯誤到前端。
這樣的處理流程縱然合理,可是顯然代碼裏的異常處理邏輯有點「喧賓奪主」了。一眼看過去全是代碼縮進,很難提煉出代碼的核心邏輯。
早在 2.5 版本時,Python 語言就已經提供了對付這類場景的工具:「上下文管理器(context manager)」。上下文管理器是一種配合 with
語句使用的特殊 Python 對象,經過它,可讓異常處理工做變得更方便。
那麼,如何利用上下文管理器來改善咱們的異常處理流程呢?讓咱們直接看代碼吧。
class raise_api_error:
"""captures specified exception and raise ApiErrorCode instead :raises: AttributeError if code_name is not valid """
def __init__(self, captures, code_name):
self.captures = captures
self.code = getattr(error_codes, code_name)
def __enter__(self):
# 剛方法將在進入上下文時調用
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# 該方法將在退出上下文時調用
# exc_type, exc_val, exc_tb 分別表示該上下文內拋出的
# 異常類型、異常值、錯誤棧
if exc_type is None:
return False
if exc_type == self.captures:
raise self.code from exc_val
return False
複製代碼
在上面的代碼裏,咱們定義了一個名爲 raise_api_error
的上下文管理器,它在進入上下文時什麼也不作。可是在退出上下文時,會判斷當前上下文中是否拋出了類型爲 self.captures
的異常,若是有,就用 APIErrorCode
異常類替代它。
使用該上下文管理器後,整個函數能夠變得更清晰簡潔:
def upload_avatar(request):
"""用戶上傳新頭像"""
with raise_api_error(KeyError, 'AVATAR_FILE_NOT_PROVIDED'):
avatar_file = request.FILES['avatar']
with raise_api_error(ResizeAvatarError, 'AVATAR_FILE_INVALID'),\
raise_api_error(FileTooLargeError, 'AVATAR_FILE_TOO_LARGE'):
resized_avatar_file = resize_avatar(avatar_file)
with raise_api_error(Exception, 'INTERNAL_SERVER_ERROR'):
request.user.avatar = resized_avatar_file
request.user.save()
return HttpResponse({})
複製代碼
Hint:建議閱讀 PEP 343 -- The "with" Statement | Python.org,瞭解與上下文管理器有關的更多知識。
模塊 contextlib 也提供了很是多與編寫上下文管理器相關的工具函數與樣例。
在這篇文章中,我分享了與異常處理相關的三個建議。最後再總結一下要點:
看完文章的你,有沒有什麼想吐槽的?請留言或者在 項目 Github Issues 告訴我吧。
系列其餘文章: