iT邦幫忙

2024 iThome 鐵人賽

DAY 9
0
Python

Python 錦囊密技系列 第 9

【Python錦囊㊙️技9】例外處理(Exception)實務

  • 分享至 

  • xImage
  •  

前言

不管是智商157或更高的程式設計師撰寫程式碼時,都無法保證系統不會出現錯誤,因此,我們只能在程式出現非預期的錯誤時,進行善後處理,顯示錯誤原因或修正錯誤的建議,這種機制稱之為【例外處理】(Exception),也稱為try/catch機制,即捕捉(catch)錯誤後,作出適當回應。本文將介紹以下內容:

  1. try/catch機制:熟悉Python的try/except/finally指令,並討論應用實務及使用的注意事項。
  2. raise指令:主動引發例外(Exception)。
  3. 斷言(assert)指令:在不太可能發生錯誤的地方作進一步檢查。

許多運行多年的應用系統也會發現缺乏例外處理的機制或處理不當的案例,輕則造成系統不穩定,嚴重的可能暴露系統資訊,提供駭客可乘之機,例如輸入不完整的網址,竟然出現ASP.NET範例網頁:
https://ithelp.ithome.com.tw/upload/images/20240921/20001976zmveP3ONbX.png
圖一. 路由設定不當,出現非預期的錯誤

輸入錯誤網址,竟然出現原生的錯誤訊息:
https://ithelp.ithome.com.tw/upload/images/20240921/20001976wg9aJBu4oo.png
圖二. 未使用try/catch機制,網頁出現原始的錯誤訊息

try/catch機制

Python例外處理分為3塊:

  1. try:放置處理一般商業邏輯的程式碼。
  2. except:放置程式發生錯誤時要如何處理的方式,例如取消交易(Rollback)。
  3. finally:不管程式正確或錯誤,都會執行這段程式碼,主要用於釋放資源,例如關閉檔案、資料庫連線、網路連線...等。
    https://ithelp.ithome.com.tw/upload/images/20240921/200019762mRBn4gQ2Z.png

我們直接以實際範例說明try/except/finally用法。
範例1. 簡單測試,程式名稱為try_catch1.py。

  1. 程式內容:
try:
    result = 1 / 0 #除以0,發生錯誤
except Exception as e:
    print(e)
finally:
    print('done.')
  1. 執行結果:【1 / 0】造成除以0的錯誤,except會捕捉到錯誤,目前處理方式是列印錯誤訊息,之後執行finally內的程式段落,反之,若改為【1 / 2】則僅會顯示【done.】。
division by zero
done.
  1. 上述訊息未標示【錯誤】字眼,另外實務上最好對錯誤訊息給予編碼,以利使用者回報,因此,print(e)可改為print(f'錯誤:(10001) {e}')。
try:
    result = 1 / 0 #除以0,發生錯誤
except Exception as e:
    print(f'錯誤:(10001) {e}') # 修飾
  1. 執行結果:使用者只要回報10001錯誤,開發者即可透過搜尋程式碼,很快找到哪裡出錯。
錯誤:(10001) division by zero

範例2. 範例1並沒有顯示錯誤行號,要列印完整錯誤訊息,可使用traceback.print_exc(),程式名稱為try_catch2.py。

  1. 程式內容:
import traceback

try:
    result = 1 / 0 #除以0,發生錯誤
except Exception as e:
    traceback.print_exc()
  1. 執行結果:會顯示錯誤的檔名及行號。
Traceback (most recent call last):
  File "try_catch2.py", line 4, in <module>
    result = 1 / 0 #除以0,發生錯誤
ZeroDivisionError: division by zero
  1. 可搭配print(e)及traceback.print_exc(),同時標示錯誤及詳細內容。
print(f'錯誤:(10001) {e}')
print(f'{"-"*10} 詳細內容 {"-"*10}')
traceback.print_exc()
print(f'{"-"*30}')
  1. 執行結果:
錯誤:(10001) division by zero
---------- 詳細內容 ----------
Traceback (most recent call last):
  File "try_catch2.py", line 4, in <module>
    result = 1 / 0 #除以0,發生錯誤
ZeroDivisionError: division by zero
------------------------------

範例3. 通常使用者不喜歡看到太多的錯誤訊息以及一堆英文術語,因此可以使用logging.exception指令把詳細錯誤內容寫入工作日誌,完整程式請參閱try_catch_log.py。

  1. 程式內容:
try:
    result = 1 / 0 #除以0,發生錯誤
except Exception as e:
    print(f'錯誤:(10001) {e}')
    logging.exception(f'錯誤:(10001) {e}\n{"-"*10} 詳細內容 {"-"*10}')
  1. 執行結果:工作日誌檔內容如下。
04:29:52 ERROR:錯誤:(10001) division by zero
---------- 詳細內容 ----------
Traceback (most recent call last):
  File "try_catch_log.py", line 17, in <module>
    result = 1 / 0 #除以0,發生錯誤
ZeroDivisionError: division by zero

raise指令

raise指令可主動引發例外,有時候並非程式錯誤,而是商業邏輯錯誤,可以使用raise指令主動引發例外,交由外層的程式try/except處理,或者希望改寫預設的錯誤訊息,可以在捕捉到錯誤後,在except段落內,執行raise指令重新定義新的錯誤訊息。

範例4. raise指令測試,程式名稱為raise1.py。

  1. 程式內容:
x = -1

try:
    if x <= 0:
        raise Exception('訂購數量不得小於或等於0 !!')
except Exception as e:
    print(f'錯誤:(10001) {e}')
  1. 執行結果:
錯誤:(10001) 訂購數量不得小於或等於0 !!

範例5. 改寫預設的錯誤訊息,程式名稱為raise2.py。

  1. 程式內容:
import sys, os

def test_raise():
    try:
        result = 1 / 0 
    except Exception as e:
        file_name = os.path.basename(__file__) # 錯誤檔名
        line_no = sys.exc_info()[-1].tb_lineno # 錯誤發生行號
        raise Exception(f'{file_name} 第 {line_no} 行錯誤:不可以除以0 !!')

if __name__ == '__main__':
    # 測試
    try:
        test_raise()
    except Exception as e:
        print(f'錯誤:(10001) {e}')
  1. 執行結果:
錯誤:(10001) raise2.py 第 5 行錯誤:不可以除以0 !!

try/except 應用實務

try/catch機制使用很容易,但一定要注意使用時機:

  1. 只在最上層程式碼(main主程式、與UI直接溝通的函數,例如按鈕點擊事件處理函數或路由指派的網頁)才使用try/except機制,避免在公用函數庫使用,因為錯誤會被屏蔽,除非函數有傳回錯誤訊息,而且上層的程式碼會檢查錯誤訊息,或者在except中再使用raise,引發例外。
  2. Python內建的例外有許多類別,大部分的文章都建議except應依例外類別顯示不同的錯誤,錯誤訊息可以定義的較明確,不過筆者認為將程式碼切成多個try/except段落,會比較容易識別發生錯誤的程式碼。
  3. 對錯誤訊息編碼,以利使用者回報,例如前一碼代表類別,後四碼代表錯誤訊息編碼。
  4. 所有錯誤訊息可統一管理,例如使用全局變數維護,例如字典(dict),key為編碼,value為錯誤訊息。
  5. 為避免上層程式碼漏寫try/except機制,可使用Decorator,在Decorator wrapper內使用try/except,就可防止【圖二】所發生的錯誤。

針對以上各項一一舉例說明。

公用函數庫使用try/except機制的潛在危機

範例6. 在公用函數庫使用try/except機制的副作用,程式名稱為try_except_misuse.py、common_functions.py。

  1. 假設公用函數庫common_functions.py程式內容如下,內有divide函數使用try/except機制:
def divide(a, b):
    try:
        result = a / b
        return result
    except Exception as e:
        print(f'錯誤:(10001) {e}')
        return 0
  1. 主程式try_except_misuse.py內容如下,呼叫divide函數。
from common_functions import divide

if __name__ == '__main__':
    # 測試
    try:
        c = divide(1, 0) # 發生錯誤,但divide
        print(c+5)
    except Exception as e:
        print(f'錯誤:(10001) {e}')
  1. 執行結果:divide(1, 0)發生錯誤,但divide函數使用try/except機制屏蔽錯誤,程式會繼續執行,產生不可預期的副作用,造成系統的不穩定。
錯誤:(10001) division by zero
5
  1. 如果在divide函數使用raise就不會有問題。
    ...
    except Exception as e:
        print(f'錯誤:(10001) {e}')
        raise Exception(f'{file_name} 第 {line_no} 行錯誤:不可以除以0 !!')        
        return 0
  1. 或者把try/except機制刪除,由主程式負責例外處理。
def divide(a, b):
    result = a / b
    return result

try/except段落分割

範例7. 以下程式碼可能發生多種錯誤,包括【無此檔案】(FileNotFoundError)、【除以0】(ZeroDivisionError)或【資料型別錯誤】(ValueError),大部份的文章都建議以下寫法,程式名稱為multiple_exception_type.py,區分錯誤原因。

try:
    f = open('./data.txt', encoding='utf8')
    content = f.read()
    a, b = int(content.split()[0]), int(content.split()[1]) 
    result = a / b
except FileNotFoundError as e:  # 無 data.txt 檔案
    print(f'錯誤:(10001) {e}')
except ValueError as e:  # 資料型別轉換錯誤,例如 'a' 要轉換為數值
    print(f'錯誤:(10002) {e}')
except ZeroDivisionError as e:  # 除以0,例如 1/0
    print(f'錯誤:(10003) {e}')
except Exception as e:  # 其他錯誤
    print(f'錯誤:(10004) {e}')

筆者作法是將程式碼切成多個try/except段落,會比較容易識別發生錯誤的地方,且程式段落比較明確,程式名稱為multiple_exception_type2.py。

import sys

# 開啟檔案
try:
    f = open('./data.txt', encoding='utf8')
    content = f.read()
except Exception as e:
    print(f'錯誤:(10001) {e}')
    sys.exit(1)     

# 計算
try:
    a, b = int(content.split()[0]), int(content.split()[1]) 
    result = a / b
except Exception as e:
    print(f'錯誤:(10002) {e}')
    sys.exit(2)     

sys.exit(0)     

範例8. 以Flask為例,在事件處理函數內編程,程式名稱為multiple_exception_type_flask.py。

  1. 程式內容:
import sys
from flask import Flask, request
 
app = Flask(__name__)
 
@app.route("/", methods=['GET'])
def test1():
    # 開啟檔案
    try:
        f = open('./data.txt', encoding='utf8')
        content = f.read()
    except Exception as e:
        return f'錯誤:(10001) {e}'    

    # 計算
    try:
        a, b = int(content.split()[0]), int(content.split()[1]) 
        result = a / b
        return result
    except Exception as e:
        return f'錯誤:(10002) {e}'    
 
if __name__ == '__main__':
    app.run()
  1. 執行結果:從錯誤訊息代碼去搜尋程式碼,很快就可以找到發生錯誤的小段落,易於除錯。
錯誤:(10002) division by zero

assert指令

assert指令可用在不太可能發生錯誤的地方作進一步檢查,一旦斷言(assert)不成立(False),assert會主動觸發例外(raise)。與數學定理類似,任何定理都有假設(Assumption),應用前我們必須先審視假設是否成立。

範例9. assert簡單測試,程式名稱為assert1.py。

  1. 程式內容:假設在呼叫divide函數前,參數a、b均已經過嚴密檢查其正確性,但為確保假設是沒有問題的,還是再進一步驗證,
def divide(a, b):
    assert (b != 0), f"{b}不可為0 !!"
    result = a / b
    return result
    
if __name__ == '__main__':
    divide(1, 0)
  1. 執行結果:程式會直接產生例外,若上層的程式碼無try/except,程式會直接結束。
Traceback (most recent call last):
  File "F:\0_python\00_MY\0_ITHome\src\9\assert1.py", line 7, in <module>
    divide(1, 0)
  File "F:\0_python\00_MY\0_ITHome\src\9\assert1.py", line 2, in divide
    assert (b != 0), f"{b}不可為0 !!"
AssertionError: 0不可為0 !!

更多的assert用法可參閱【Python's assert: Debug and Test Your Code Like a Pro】

結語

例外處理可避免應用程式直接當掉或顯示原生的錯誤訊息,是系統開發不可或缺的技能,缺乏例外處理或過度使用都會造成系統隱憂,輕則造成使用者不良觀感,重則會造成系統不穩定、曝露資安漏洞,釀成大禍,不可不慎。

本系列的程式碼會統一放在GitHub,本篇的程式放在src/9資料夾,歡迎讀者下載測試,如有錯誤或疏漏,請不吝指正。


上一篇
【Python錦囊㊙️技8】工作日誌(Log)應用實務及監視器
下一篇
【Python錦囊㊙️技10】OOA、OOD and OOP
系列文
Python 錦囊密技14
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言