一、为什么我们需要一套统一的异常处理方案
咱们平时写Django接口,最头疼的事情之一就是异常处理。你想想,每个接口都有可能出错:参数没传全、数据库查不到数据、权限不够、第三方服务超时……如果每个接口都自己写try-except,那代码里到处是重复的异常分支,而且返回给前端的错误格式五花八门。今天这个接口报错返回一个字符串,明天那个接口返回一个字典,后天再给你来个HTML错误页。前端同学想统一处理错误信息,简直想掀桌子。
这时候就需要一套“统一编排”的方案。说白了,就是咱们提前把各种错误情况分门别类,定义成一个个“懂规矩”的异常类,然后在一个集中的地方统一“接住”它们,再统一转换成规范的JSON响应。这样一来,不管后端怎么出错,前端拿到的都是同一副面孔:一个清晰的错误码,一句能看懂的消息,外加一个标准的HTTP状态码。是不是舒服多了?
接下来,咱们就一步步把这套方案搭起来。我会用纯Python + Django REST Framework(简称DRF)来演示,整个过程中代码会直接跑在Django项目里。
# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# 假设你已经创建了一个Django项目,并且安装了djangorestframework
二、Django内置异常处理机制回顾
在搞自定义方案之前,咱们先看看Django和DRF原生是怎么处理异常的。Django自己有一套异常,比如Http404、PermissionDenied、RequestDataTooBig等等。正常情况下,如果在视图里抛出了这些异常,Django会生成对应的HTTP响应(比如404页面,403页面)。对于API接口来说,这种HTML响应显然不太友好。
DRF比Django更进一步,它有一个自带的exception_handler,作用就是捕获视图里抛出的异常,然后转成规范的JSON。DRF默认能处理APIException的子类,比如ValidationError、AuthenticationFailed、NotFound等等。我们来看一下DRF默认的返回结构:
# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# 在DRF的settings中,默认异常处理器是:
# 'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler'
# 当视图抛出NotFound时,响应大致长这样:
# {
# "detail": "Not found."
# }
看到没有,DRF默认只给一个detail字段。如果咱们想让错误信息更丰富,比如加上错误码、状态码、时间戳,就得自己动手改写这个处理器。这也是咱们今天要做的核心工作。
三、自定义异常类的设计思路
要统一编排,第一步就是设计一个“根异常”。咱们管它叫BizException(业务异常),专门用来表示那些你可以预料到的、业务层面的错误。比如“用户名已被占用”、“订单金额不对”之类的。它需要携带三个关键信息:
code:给前端看的错误码,比如1001代表“参数错误”,1002代表“资源不存在”。message:给用户看的提示信息,要友好,别甩一个英文栈给人家。http_status_code:HTTP状态码,比如400、404、409。
咱们还可以让这个异常支持嵌套细节,如果错误需要更精细的字段级别信息,可以用一个details字典塞进去。代码如下:
# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# 新建一个exceptions.py文件,放自定义异常类
class BizException(Exception):
"""
业务异常基类。
所有业务相关的异常都应该继承这个类。
"""
def __init__(
self,
code: int,
message: str,
http_status_code: int = 400,
details: dict | list | None = None,
):
self.code = code # 业务错误码,例如 10001
self.message = message # 用户友好提示
self.http_status_code = http_status_code # 对应的HTTP状态码
self.details = details # 可选的细节信息
super().__init__(self.message)
def to_dict(self):
"""把异常信息转成字典,方便后面序列化"""
payload = {
"code": self.code,
"message": self.message,
"status_code": self.http_status_code,
}
if self.details:
payload["details"] = self.details
return payload
有了这个基类,咱们就可以派生出各种各样的具体异常。比如:
# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# 在exceptions.py里继续加
class InvalidArgumentError(BizException):
"""参数不合法"""
def __init__(self, message="参数有误", details=None):
super().__init__(
code=10001,
message=message,
http_status_code=400,
details=details,
)
class ResourceNotFoundError(BizException):
"""资源不存在"""
def __init__(self, message="请求的资源不存在", details=None):
super().__init__(
code=10002,
message=message,
http_status_code=404,
details=details,
)
class PermissionForbiddenError(BizException):
"""没有权限执行该操作"""
def __init__(self, message="没有权限执行此操作", details=None):
super().__init__(
code=10003,
message=message,
http_status_code=403,
details=details,
)
这样一来,咱们在代码里抛异常就很优雅了:直接raise ResourceNotFoundError("订单不存在"),一句话搞定。不需要每个视图里都写return Response(status=404)这种臃肿的代码。
四、DRF异常处理器的协同作战
异常类只是“抛”的那一侧,真正的“接”要靠DRF的异常处理器。默认的处理器不能满足咱们的需求,所以要写一个自定义的custom_exception_handler。这个处理器会接管整个DRF视图抛出的所有异常,包括我们自定义的BizException,也包括DRF自带的异常,甚至还包括所有Python的Exception。
咱们需要搞清楚处理顺序:
- 先调用DRF自带的
exception_handler,让它处理那些DRF已经能处理的东西(比如ValidationError、NotFound)。 - 如果DRF没接住(返回
None),说明是别的异常,咱们再一层层去判断:是不是咱们的BizException?是不是Python内置的Exception? - 最后统一组织成标准格式返回。
看代码:
# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# 新建一个handlers.py文件
from rest_framework.views import exception_handler as drf_exception_handler
from rest_framework.response import Response
from rest_framework import status
from django.http import Http404
from rest_framework.exceptions import APIException
from .exceptions import BizException
import logging
logger = logging.getLogger(__name__)
def custom_exception_handler(exc, context):
"""
自定义的DRF异常处理器。
exc:实际抛出的异常对象
context:包含request、view等信息的字典
"""
# 第一步:先让DRF默认的处理器试试,它能处理ValidationError、NotFound等自带异常
response = drf_exception_handler(exc, context)
if response is not None:
# DRF已经处理了,但返回格式是它自带的,咱们需要统一改一下格式
# 原来的response.data里通常只有detail字段,也可能是字段错误字典
# 咱们重新包装一下
error_data = response.data
# 拼一个标准的统一错误结构
data = {
"code": _extract_drf_code(exc),
"message": _extract_drf_message(exc, error_data),
"status_code": response.status_code,
"details": error_data, # 把原始信息塞进details,供前端调试
}
return Response(data=data, status=response.status_code)
# 第二步:DRF没处理的,看看是不是咱们自定义的业务异常
if isinstance(exc, BizException):
return Response(
data=exc.to_dict(),
status=exc.http_status_code,
)
# 第三步:处理Django自带的Http404、PermissionDenied
if isinstance(exc, Http404):
return Response(
data={
"code": 10002,
"message": "资源不存在",
"status_code": 404,
"details": str(exc),
},
status=404,
)
# 第四步:兜底处理所有未知异常(比如TypeError、KeyError之类的)
# 这样能保证任何异常都不会“白屏”,但要注意记录日志
logger.error(f"未捕获的异常: {exc}", exc_info=exc)
return Response(
data={
"code": 50000,
"message": "服务器开小差了,请稍后再试",
"status_code": 500,
"details": str(exc) if _is_debug() else None,
},
status=500,
)
def _extract_drf_code(exc):
"""从DRF异常里提取咱们想要的业务码"""
# 常见情况:ValidationError对应的业务码是10001
if isinstance(exc, APIException):
return 10001 if exc.status_code == 400 else exc.status_code * 100
return 50000
def _extract_drf_message(exc, error_data):
"""从DRF异常里提取用户提示信息"""
if isinstance(exc, APIException):
if isinstance(error_data, dict):
# 比如参数验证错误:{"name": ["这个字段不能为空。"]}
# 咱们取第一个字段的第一个错误信息,更友好
for field, errors in error_data.items():
if isinstance(errors, (list, tuple)):
return f"{field}: {errors[0]}"
return f"{field}: {errors}"
# 如果是一个字符串(比如detail是字符串)
return str(exc)
return str(exc)
def _is_debug():
"""是否开启调试模式,决定要不要把原始异常信息返回给前端"""
from django.conf import settings
return settings.DEBUG
光写这个处理器还不行,咱们得让DRF知道用这个,而不是默认的那个。在settings.py里配置:
# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# settings.py
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
],
'EXCEPTION_HANDLER': 'myapp.handlers.custom_exception_handler',
# 其他配置...
}
注意替换成你实际的app路径。到这里,咱们的“协同作战”框架已经搭好了。接下来通过一个实际的接口来看效果。
五、处理DRF内置异常的映射
很多人会忽略一个场景:DRF的ValidationError非常常见,比如用序列化器(Serializer)校验参数不通过时,DRF会抛出ValidationError。咱们的处理器能把它接住,但要注意一件事:默认情况下,DRF会把字段错误直接放在response.data里,而且是一个字典。咱们上面用details字段把原始信息保留了下来,这样前端既能拿到友好的message,又能拿到字段级的错误明细,两者兼顾。
再比如DRF的AuthenticationFailed,它返回的状态码是401,在咱们的_extract_drf_code里,会变成40100这种码。这个码一看就知道是认证问题,挺直观。咱们还可以在映射方法里做更细的定制,比如单独给AuthenticationFailed一个code=20001:
# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# 扩展一下_extract_drf_code
def _extract_drf_code(exc):
from rest_framework.authentication import AuthenticationFailed
if isinstance(exc, AuthenticationFailed):
return 20001
if isinstance(exc, APIException):
return 10001 if exc.status_code == 400 else exc.status_code * 100
return 50000
这样一来,DRF的异常和咱们自定义的异常就在一个体系里了。前端只需要看code就能知道错误类型,不需要再猜。
六、实战:一个完整的统一编排示例
光说不练假把式。咱们来造一个小场景:一个简单的订单查询接口。如果订单ID不是数字,就抛InvalidArgumentError;如果订单不存在,就抛ResourceNotFoundError。然后在DRF的APIView里调用。
先定义序列化器和视图:
# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# 假设app名叫orders
# orders/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.request import Request
from .exceptions import InvalidArgumentError, ResourceNotFoundError
# 模拟一个订单数据库(真实开发用ORM)
FAKE_ORDERS = {
1: {"id": 1, "amount": 99.9, "goods_name": "T恤"},
2: {"id": 2, "amount": 199.9, "goods_name": "牛仔裤"},
}
class OrderDetailView(APIView):
"""订单详情接口:/api/orders/<order_id>/"""
def get(self, request: Request, order_id):
# 1. 检查order_id是否是整数
try:
order_id = int(order_id)
except (TypeError, ValueError):
# 不是整数,抛自定义业务异常
raise InvalidArgumentError(
message="订单号必须是一个整数",
details={"order_id": "传入了非数字的订单号"},
)
# 2. 查询订单
order = FAKE_ORDERS.get(order_id)
if order is None:
# 订单不存在
raise ResourceNotFoundError(
message="找不到这笔订单",
details={"order_id": order_id},
)
# 3. 正常返回
return Response(data={"code": 0, "data": order, "message": "success"})
这个视图里没有一行try-except,是不是特别清爽?异常全被抛出去了,最后会统一到咱们的处理器里。咱们测试一下不同请求会得到什么结果。假设项目跑在localhost:8000。
第一个请求:GET /api/orders/abc/
响应会是这样:
{
"code": 10001,
"message": "订单号必须是一个整数",
"status_code": 400,
"details": {
"order_id": "传入了非数字的订单号"
}
}
第二个请求:GET /api/orders/999/
响应:
{
"code": 10002,
"message": "找不到这笔订单",
"status_code": 404,
"details": {
"order_id": 999
}
}
第三个请求:GET /api/orders/1/
响应:
{
"code": 0,
"data": {
"id": 1,
"amount": 99.9,
"goods_name": "T恤"
},
"message": "success"
}
注意成功时的code: 0并不是异常,只是咱们响应体的一个业务标志。这样整个API的响应格式就统一了:成功时有data,失败时有details,永远都有code和message。前端只需要判断code是否为0就能决定是否进入错误分支,非常方便。
七、应用场景与优缺点分析
7.1 应用场景
- 前后端分离项目:后端只提供JSON API,前端需要稳定的错误结构,这套方案是标配。
- 微服务间调用:服务A调服务B,B返回的标准错误码能帮助A快速定位是哪种错误,比如参数问题还是服务不可用。
- 日志与监控:在自定义handler里统一打日志,把异常栈、请求路径、用户信息都记录进去,后续排查问题特别方便。
- 第三方接入:你给别人提供API,对方需要根据错误码做自动化处理,一套规范的错误体系能降低沟通成本。
7.2 技术优点
- 代码简洁:业务视图里不用再写大量的try-except,逻辑更聚焦于正常流程。
- 返回统一:所有的错误都遵循一个模子,前端处理逻辑简单到飞起。
- 可扩展性强:想加一种新错误,只需要新写一个继承
BizException的类,不改动已有代码。 - 安全:兜底异常不会把Python内部堆栈直接暴露给用户,避免信息泄露。
7.3 技术缺点
- 学习成本:团队新人需要理解整套异常继承体系,否则可能不知道该抛什么异常。
- 过度抽象风险:如果项目很小,只有两三个接口,搞一堆异常类反而显得“杀鸡用牛刀”。
- 错误码需要维护:当错误码越来越多,如果没有文档或统一登记表,会变得难以管理。
八、注意事项与避坑指南
- 不要吞异常:在自定义handler中,对于未知异常一定要记录日志,然后兜底返回500。不要为了“友好”把异常细节全部掩盖,否则线上出了问题你连原因都查不到。
- 一定要调用DRF的默认handler:咱们的
custom_exception_handler第一行就是调用drf_exception_handler,这样DRF的APIException不会被咱们误伤。如果你直接把所有异常按BizException处理,可能会丢失很多DRF原本已经处理好的逻辑。 - DEBUG模式下适当暴露细节:开发环境可以返回
details: str(exc),生产环境直接写details: null。用之前写的_is_debug()来控制,别把敏感信息漏出去。 - 注意异常链上下文:当你在
except块里手动抛出自定义异常时,建议使用raise ... from exc,否则看不到原始异常栈。比如:
# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
try:
do_something()
except DatabaseError as exc:
# 保留原始异常链,方便排查
raise BizException(
code=30001,
message="数据库操作失败",
http_status_code=500,
) from exc
- 别把HttpResponse和DRF的Response混了:如果你的项目里有普通Django视图(非DRF),那些视图抛出的异常不会被DRF的handler捕获,需要另外处理,比如中间件统一处理。咱们这里的方案是针对DRF视图的。
- 错误码设计要有规律:比如前两位代表模块(10:用户模块,20:订单模块),后三位代表具体错误。千万别随手乱编。
九、总结
通过自定义异常类和DRF异常处理器的配合,咱们把原本七零八落的异常处理变成了一套“流水线”。从抛异常到接异常,再到生成标准化响应,每一步都清清楚楚。这不仅让代码更好看,更让前后端协作的双方都松了一口气。
当然,没有一个方案是万能的,这套编排也并非适合所有项目。但如果你正在做一个API比较多、错误类型复杂、需要长期演进的项目,花点时间把这套基础设施打好,绝对是值得的。咱们写代码,不就是图个“省心”吗?早点统一规划,后面就能少加班,多陪陪家人。希望今天的分享能给你的项目带来一点灵感。
评论
围绕“Django异常处理统一编排:自定义异常类与REST Framework异常处理的协同作战”参与讨论