Skip to content

【Python 工匠】第六章 - 异常处理的三个好习惯,阅读笔记

作者总结了三个Python异常处理的最佳实践。

1. 只做最精确的异常捕获

应当摒弃"异常是有害的,好的程序应该捕获所有异常以确保平稳运行"这一错误的编程理念。异常处理的核心目标并非是无差别地捕获所有异常,而是应该专注于那些确实可能抛出异常的特定代码块,并且要尽可能使用具体的异常类型,而不是模糊地使用 Exception 类。

Bad Case:

python
# -*- 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()

Good Practise:

python
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

2. 别让异常破坏抽象一致性

Bad Case:

python
# 在某个处理图像的模块内部
# <PROJECT_ROOT>/util/image/processor.py
def process_image(...):
    try:
        image = Image.open(fp)
    except Exception:
        # 说明(非项目原注释):该异常将会被 Django 的中间件捕获,往前端返回
        # "上传的图片格式有误" 信息
        raise error_codes.INVALID_IMAGE_UPLOADED
    ... ...

在处理用户上传图片的 POST 请求时,process_image 函数会尝试解析文件对象。如果该文件无法作为图片正常打开,函数会抛出 error_codes.INVALID_IMAGE_UPLOADED (APIErrorCode 的子类) 异常,并向调用方返回错误代码 JSON。然而,当你试图在离线脚本中复用这个方法时,会面临两个技术限制:

  1. 你必须捕获一个名为 INVALID_IMAGE_UPLOADED 的异常,即使你处理的图片并非来自用户上传
  2. 你必须引入 APIErrorCode 异常类作为依赖来捕获异常,即使你的脚本与 Django API 完全无关

这个设计问题反映了异常处理机制中的层级混乱。APIErrorCode 异常类原本设计用于表达面向终端用户的、可识别的"错误代码",它在项目架构中处于最高层。然而,出于实现便捷的考虑,我们在底层模块中直接使用了这个高层异常。这种跨层级的异常处理方式,影响了 image.processor 模块的独立性,使其难以在其他场景中复用。

要解决"模块抛出了高于所属抽象层级的异常"的问题,我们可以采取以下方案:

  • 确保模块只抛出与其抽象层级相符的异常。例如,image.processer 模块应该抛出自己封装的 ImageOpenError 异常
  • 在适当的位置进行异常转换。比如,应该在更接近高层抽象的地方 (如视图 View 函数),将底层的 ImageOpenError 异常转换为高层的 APIErrorCode 异常

同时,我们也要注意避免在高层代码中暴露底层异常。例如,不应该让 Web API 直接向用户返回底层文件系统的异常信息,而是应该将其转换为合适的高层业务异常。这样可以保持各层级的职责清晰,提高代码的可维护性。

Good Practise:

python
# <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

3. 异常处理不应该喧宾夺主

当代码中充斥着大量的异常处理语句 (try、except、raise) 时,往往会模糊代码的主要业务逻辑。这种情况在处理文件操作、网络请求等容易出错的场景中尤为常见。

Bad Case:

python
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({})

Python 的 上下文管理器 (Context Manager) 提供了一种优雅的解决方案。通过使用 with 语句,我们可以将异常处理逻辑从核心业务代码中分离出来。

Good Practise:

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


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({})

这种方式不仅让代码更加清晰易读,还能确保资源的正确释放和异常的统一处理。上下文管理器特别适合处理需要配对操作的场景,如文件操作、数据库连接、锁的获取与释放等。