后端写久了你会发现,真正拉开项目差距的,往往不是某个库玩得有多花哨,而是代码的组织方式。今天这堂课,我们聊的是 Python 后端工程化进阶里最核心的四个抓手:分层架构、中间件、日志、异常统一处理。这四个东西单独拿出来都不难,难的是把它们组合成一套可以跨项目复用的骨架。这篇文章我会用 FastAPI 作为样例,带你从零搭一个“类企业级”的后端底座,让你后面的每个业务功能都能直接往这个骨架里填。不管你是刚写过后端接口的初级开发,还是已经带过一两个项目的工程师,这套思路都能帮你解决一个很现实的问题:项目代码越来越多时,怎么保证它不烂掉。
1. 第5课到底在解决什么问题:从“能跑”到“高可用”
1.1 你迟早会碰到的三个工程化痛点
我见过太多次这种场景:项目第一版上线时,所有逻辑都堆在路由文件里,一个接口函数两三百行,里面既要解析参数、又要调数据库、还要拼返回结构。当时跑得好好的,等第二个月需求叠加进来,这个文件开始膨胀到几千行,改一个字段要全局搜索好几处,新增一个接口小心翼翼地复制粘贴老代码。这种状态就是“能跑”,但它和“高可用”之间隔着一整条工程化鸿沟。
高可用后端不只是“服务不挂”,它还包含三层含义:一是代码结构稳定,改一处业务不会震碎另外三处;二是问题可以在生产环境被快速定位,而不是靠开发者远程连服务器猜;三是接口行为可预期,前端拿到每个错误都能知道下一步该做什么。这三件事,恰好分别对应分层架构、日志、异常统一处理。中间件则是把这些能力串联起来的“总装线”,所有请求进出的通用逻辑都可以在中间件层收口。
1.2 三个关键词拆解:分层、中间件、日志与异常
先给这一课定个基调,三个关键词分别解决不同层次的问题:
- 分层架构:解决“代码该放在哪里”。它把输入输出、业务规则、数据访问拆成相互独立又单向依赖的模块,让你避免在一个函数里干所有事。
- 中间件:解决“通用逻辑该在哪里统一处理”。比如全链路请求ID、接口耗时统计、跨域、登录状态校验,这些逻辑不适合塞进业务代码里,放中间件是最合理的。
- 日志与异常统一处理:解决“出了问题怎么发现、怎么反馈”。日志让你看得见系统内部发生了什么,异常处理让你把错误转成规范的响应结构,两者配合,前端用户、后端开发、运维人员才能在同一套语言下协作。
这三个关键词不是孤立的。分层架构给了代码一个清晰的“地图”,中间件和日志在这张地图上划出“主干道”,异常处理则给所有可能的失败点装了统一的路标。后面第 6 节实操中你会看到它们怎么咬合在一起。
1.3 技术选型:为什么用 FastAPI 做演示骨架
标题没限定框架,但我选 FastAPI 当演示骨架,原因有三个。第一,FastAPI 原生支持依赖注入,这跟分层架构配合得特别舒服,你可以在路由函数里声明依赖,而不用在每个 controller 里手动 new 一个 service。第二,它的中间件机制基于 Starlette,既支持简单的装饰器写法,也支持 BaseHTTPMiddleware 的完整中间件类,演示“请求->中间件->路由->响应”这条链路很直观。第三,FastAPI 自带 OpenAPI 文档,你每写一个接口,浏览器打开 /docs 就能看到请求和响应结构,方便验证我们后面做的统一异常处理和响应包装。
当然,如果你团队里用的是 Flask 或 Django,文章里的分层思想、日志策略、中间件思路一样适用。Flask 用 before_request / after_request,Django 用 MIDDLEWARE 配置,底层逻辑殊途同归。我会把重点放在“为什么这么设计”上,代码细节以 FastAPI 为主,其他框架的读者也能按图索骥。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分层架构:让每一行代码都待在该待的地方
2.1 经典三层/多层架构的职责边界
后端项目最常见的分层是三层:API 层、Service 层、Repository 层,外面再挂一层 Models 和 Schemas。它的核心规则是“职责单一、单向依赖”。我习惯用一个比喻:API 层是前台服务员,只负责接单、传菜;Service 层是后厨团队,负责按菜谱做菜;Repository 层是仓库管理员,负责从货架上拿原材料。服务员不能跑去仓库自己搬货,仓库管理员也不该跑到前厅跟客人解释菜品。
拆开来看,每一层的职责边界是这样的:
| 层次 | 核心职责 | 典型内容 | 禁止事项 |
|---|---|---|---|
| API 层 | 接收请求、参数校验、调用 Service、返回响应 | 路由函数、Pydantic Schema、Depends | 不写业务规则、不直接操作数据库 |
| Service 层 | 业务规则、事务控制、领域逻辑 | 业务判断、数据组装、异常抛出 | 不出现 HTTP Request/Response、不直接拼 SQL |
| Repository 层 | 数据访问、持久化、ORM 操作 | SQLAlchemy Session、数据库模型映射 | 不返回复杂业务结果、不判业务规则 |
| Models/Schemas | 数据结构约定 | ORM Model、Pydantic Model | 不含业务逻辑、不做数据加工 |
这个表格看起来简单,真正实施的时候,破坏边界的诱惑无处不在。最典型的场景就是“图省事”:在 API 层顺手写一句 db.query(User).filter_by(...),而不是去 Repository 里封装。一个两个接口这么干还能忍受,等数量上来,数据库表结构一变,你会想穿越回去把自己打一顿。
2.2 一套能直接上手的项目目录结构
理论讲完,给出一套我实际在项目里用过的目录结构。它不算最精简,但足够清晰,适合作为中型项目的起点:
code复制my_project/
├── app/
│ ├── api/
│ │ ├── __init__.py
│ │ ├── dependencies.py
│ │ └── v1/
│ │ ├── __init__.py
│ │ ├── router.py
│ │ └── endpoints/
│ │ └── users.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ └── logging.py
│ ├── middleware/
│ │ ├── __init__.py
│ │ ├── request_id.py
│ │ └── timing.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── user.py
│ ├── repositories/
│ │ ├── __init__.py
│ │ └── user_repository.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ └── user.py
│ ├── services/
│ │ ├── __init__.py
│ │ └── user_service.py
│ ├── utils/
│ │ ├── __init__.py
│ │ └── response.py
│ ├── exceptions.py
│ └── main.py
├── .env
├── requirements.txt
└── README.md
注意几个细节。core 放配置和日志这类“基础设施”,middleware 单独建目录放中间件类,api/v1 表示接口版本,以后要做 v2 直接平行加一个包就行。dependencies.py 放依赖注入方法,比如怎么获取一个带数据库连接的 UserService。这样整个项目从 main.py 读进去是自解释的:路由在 api 里,逻辑在 service 里,数据在 repository 里,公共配置在 core 里。
2.3 分层实践中的三条铁律和一个常见破防现场
第一条铁律:上层可以依赖下层,下层绝不反向依赖。也就是说 API 层可以 import Service 层,Service 层可以 import Repository 层,但 Repository 层绝对不能去 import 某个 Service 或者某个路由。一旦出现反向依赖,分层就会退化成一堆模块互相 import,代码耦合度瞬间拉满。
第二条铁律:同层之间尽量通过“上层协调”,不要平级直接互相 import。比如用户注册后要发通知,UserService 需要调用 NotifyService,这不是平级调用,而是 UserService 作为业务编排者主动调用另一个 Service 的公开方法。但如果你在 UserRepository 里直接调 OrderRepository,那就要警惕了,通常这种跨领域的数据访问应该提到 Service 层去编排。
第三条铁律:控制器里不放业务规则。判断邮箱是否重复、余额是否足够、订单状态能否流转,这些都是业务规则,必须放进 Service 层。API 层只负责“传输”和“转换”。
破防现场我见得最多的是:路由函数里写了一大堆 if else 做参数校验和业务判断,最后直接把 ORM 对象返回给前端。这个操作短期看“挺方便”,但前端拿到的时间格式、字段命名、嵌套结构全被 ORM 模型绑死了。后面要改响应结构,只能挨个接口找。正确做法是 API 层用 Pydantic Schema 定义响应模型,在返回时做一次显式转换。
2.4 依赖注入:让层与层之间松耦合
分层之后,有人问:“UserService 要用 UserRepository,是不是在 UserService 里直接 import 一个单例就行?”技术上没问题,但可维护性差。更推荐的做法是依赖注入,在 API 层的 dependencies 里把 UserService 组装好,再传给路由函数。
FastAPI 的 Depends 让这一步实现得非常优雅。比如我在 app/api/dependencies.py 里写:
python复制# app/api/dependencies.py
from app.repositories.user_repository import UserRepository
from app.services.user_service import UserService
def get_user_service():
repo = UserRepository()
return UserService(repo)
然后在路由函数中声明:
python复制@router.post("/register")
def register(payload: UserCreate, user_service: UserService = Depends(get_user_service)):
...
这样做的价值是“替换无忧”。将来你想把 UserRepository 换成 Redis 缓存版,或者给它加一个抽象接口,只需要修改 get_user_service 这一个工厂函数,路由和 Service 完全不用动。测试的时候更爽,你可以直接 mock 掉 UserService 传给路由,接口测试不再依赖真实数据库。
3. 中间件:请求流水线上最好用的“统一处理工位”
3.1 先把概念说清楚:应用中间件不是消息中间件
很多人看到“中间件”三个字容易想到 Kafka、RabbitMQ、RocketMQ 这类“消息中间件”。本课标题里的“中间件”,指的是 Web 框架里的 Application Middleware,也就是在 HTTP 请求进入路由之前、响应离开应用之前执行的那一层钩子。两者的作用完全不同:消息中间件解决的是系统之间的异步通信和削峰填谷,Web 中间件解决的是请求处理链路中的通用逻辑。
所以如果你在一个 Python 后端团队里,说“我们用到了中间件”,大多数时候指的是后者。别在概念上打架,这也是我踩过的坑。当年我在面试里被问“用过哪些中间件”,我说用过 FastAPI 中间件,面试官追问那你怎么做消息队列,场面就很尴尬。两个都是中间件这个中文词,但一个是 Middleware,一个是 Message Queue Broker。
3.2 FastAPI 中间件的底层逻辑与执行顺序
FastAPI 的中间件机制继承自 Starlette,本质是一个“洋葱模型”。请求从最外层中间件进入,依次向内,到达路由和视图函数,响应再依次向外返回。每个中间件可以在调用内部应用之前做预处理,也可以在内部应用返回之后做后处理。
它的执行顺序用代码来展示最直观。假设我们注册了两个中间件:
python复制app.add_middleware(MiddlewareA)
app.add_middleware(MiddlewareB)
请求实际经过的顺序是 MiddlewareA -> MiddlewareB -> 路由 -> MiddlewareB -> MiddlewareA。后注册的中间件先接触到路由,这种嵌套逻辑和 Python 闭包一模一样。新手写中间件最容易在这里翻车:比如把耗时统计和请求ID生成都做成中间件,但注册顺序搞反了,导致统计日志里拿不到请求ID。
我在工作中一直坚持一个原则:与基础链路有关的中间件,例如请求ID、耗时统计,注册顺序要固定,并在注释里明确标注。不要今天加一个跨域中间件插在中间,明天加一个认证中间件又插到前面,顺序一乱,排查问题的成本立刻翻倍。
3.3 三个高价值中间件的实战代码
我挑三个写得比较多的中间件来说明:请求ID注入、耗时统计、基础日志记录。这三个组合起来,就是微服务场景下链路追踪的最原始形态。
第一个是请求ID中间件。它会给每个请求分配一个唯一ID,放进 request.state,同时写进响应头,这样前端或者运维工具拿着 X-Request-ID 就能在日志里定位一次完整请求。
python复制# app/middleware/request_id.py
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("X-Request-ID", uuid.uuid4().hex)
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
第二个是耗时统计中间件。它用性能计数器记录接口总耗时,无论接口是否抛异常都要在 finally 中收尾。在实际生产里,我会把耗时超过阈值的请求单独打一条 WARNING 日志,这对定位慢接口特别有用。
python复制# app/middleware/timing.py
import logging
import time
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
logger = logging.getLogger(__name__)
class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start = time.perf_counter()
try:
response = await call_next(request)
finally:
duration_ms = (time.perf_counter() - start) * 1000
logger.info(
"request finished",
extra={
"method": request.method,
"path": request.url.path,
"status_code": getattr(response, "status_code", 500),
"duration_ms": round(duration_ms, 2),
},
)
response.headers["X-Process-Time-MS"] = f"{duration_ms:.2f}"
return response
第三个是 CORS 中间件,大多数项目会用到,但配置细节容易踩坑。FastAPI 内置了 CORSMiddleware,允许哪些源、哪些请求方法、哪些请求头都要显式配置。尤其要注意 allow_credentials=True 时,allow_origins 不能使用 ["*"],否则浏览器会直接拒绝带 Cookie 的跨域请求。
python复制from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://your-frontend.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
3.4 中间件使用中的性能和异常陷阱
中间件虽然好用,但不是越多越好。每加一个中间件,请求链路就多一层函数调用和对象创建,虽然单层开销通常很小,但上百层的调用户量下,累积的损耗会被放大。我的建议是:能用路由装饰器或依赖注入解决的问题,别优先做成全局中间件。中间件只放“绝大多数接口都必须走”的逻辑,例如日志、请求ID、鉴权。
异常陷阱是最容易踩的。在中间件中调用 call_next 之前如果抛了异常,这个异常不会经过 FastAPI 的全局异常处理器,而是直接冒泡到最外层,导致一个不受控的 500 响应。我处理的方式很简单:中间件内部自己做异常捕获,至少要在 finally 中打日志或者做兜底响应。另一个常见问题是,在中间件里拿到了请求体之后忘记重新赋值,导致后续路由读不到 body。这在跟文件上传、流式请求打交道时尤其烦人。如果你需要在中间件里读 body,请记住要先把 body 缓存下来,再替换 request 的 _body 属性,否则后面的业务代码会面对一个空请求体。
4. 日志体系:建立后端的第一条“可观测性”生命线
4.1 告别 print:logging 模块的工程级配置
给新项目搭日志,我见过最痛心的操作就是全项目用 print() 输出信息,上线后把日志打到 nohup.out 或者 Docker 容器标准输出里。一旦请求量大,print 的内容混在一起,既没有时间格式,也没有日志级别,生产事故复盘时根本没法定位。Python 标准库的 logging 模块完全够用,关键是把它配置成一套“正规军”。
一个基础的工程级日志配置,至少要做到两件事:同时输出到控制台和文件;文件按大小滚动,避免无限膨胀。下面这个配置是我常用模板的简化版:
python复制# app/core/logging.py
import logging
from logging.handlers import RotatingFileHandler
def setup_logging(level: str = "INFO", log_file: str = "app.log"):
formatter = logging.Formatter(
"%(asctime)s %(levelname)s %(name)s %(message)s"
)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
file_handler = RotatingFileHandler(
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
)
file_handler.setFormatter(formatter)
root_logger = logging.getLogger()
root_logger.setLevel(level.upper())
root_logger.addHandler(console_handler)
root_logger.addHandler(file_handler)
解释几个关键点。RotatingFileHandler 会在文件达到 10MB 后自动轮转,保留最近 5 个备份文件,这比一个日志文件涨到几个 GB 再手动清要舒服得多。root_logger.setLevel 是全局兜底,但实际项目里我会按模块去调整级别,比如调试某个 service 时只把那个模块调成 DEBUG,不影响其他模块。encoding="utf-8" 尤其重要,Windows 环境配少了容易乱码,Linux 上处理中文日志也可能踩坑。
4.2 结构化日志:为什么你的日志最好长成 JSON
大多数团队日志停留在“一段字符串”的形态,例如 2025-01-01 10:00:00 INFO user_service.py register success。这种日志人眼看着还行,但一旦要把日志接入 Loki、ELK、ClickHouse 这类集中式日志设施,或者用脚本做统计分析,解析纯文本就是一场噩梦。更合理的方案是结构化日志,常见做法是输出 JSON 格式。
JSON 日志里每一行是一个 JSON 对象,字段包括时间、级别、模块、消息、trace_id、业务参数等。接入日志系统的时候,查询面板可以直接按字段过滤,比如 level == "ERROR" 或者 duration_ms > 500,不需要写正则硬抠。
Python 里可以用 python-json-logger 库实现 JSON 输出:
bash复制pip install python-json-logger
python复制from pythonjsonlogger import jsonlogger
formatter = jsonlogger.JsonFormatter(
"%(asctime)s %(levelname)s %(name)s %(message)s"
)
配置好之后,日志输出长这样:
json复制{"asctime": "2025-01-01 10:00:00,123", "levelname": "INFO", "name": "app.services.user_service", "message": "user register success"}
别小看这个格式转变,它几乎零成本,却能让你的日志从“给人看”升级成“给机器也看”。我把老项目日志改成 JSON 格式之后,再排查生产问题时舒服多了,直接在日志系统里拖几个字段出来就能看聚合趋势。
4.3 日志分级与环境配置:开发看得爽,线上不刷屏
日志级别不是摆设。我在项目里坚持一套分级习惯:DEBUG 记录详细调试信息,比如入参、出参、SQL 语句;INFO 记录关键的流程节点,比如用户注册成功、订单支付成功;WARNING 记录潜在的隐患,比如重试超过三次、外部接口响应变慢;ERROR 记录异常和业务失败,比如数据库连接失败、扣款失败。
关键是不同环境要用不同级别。开发环境开 DEBUG,方便随时打印各种参数;测试环境开 INFO,保证日志能覆盖主要流程;生产环境至少是 INFO,如果你对磁盘空间敏感,可以只记录 WARNING 以上。用环境变量来控制这个开关:
python复制import os
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
setup_logging(level=LOG_LEVEL)
还有一个很常见的坑:在生产环境把日志级别设成 DEBUG,结果日志量暴增,磁盘被打满。这不是危言耸听,我亲眼见过一个团队上线时忘了改配置,一晚上把 100GB 数据盘写爆。所以日志级别一定要作为部署配置的一部分,明确审查。
4.4 把 trace_id 贯穿所有日志:一条链路追踪的雏形
日志光结构化还不够,要能在一次用户请求中把所有日志串联起来,才具备真正的排查价值。实现思路是用 contextvars 保存一个 trace_id,在请求进入时生成并写入,在日志过滤器里读取,这样整个请求处理过程中打的每一条日志都会自动带上这个 ID。
核心代码分三部分。第一部分在 logging.py 中定义一个 ContextVar 和日志过滤器:
python复制# app/core/logging.py
import contextvars
request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
class RequestIDFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = request_id_var.get()
return True
第二部分在 RequestIDMiddleware 中设置这个 ContextVar:
python复制# app/middleware/request_id.py
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.logging import request_id_var
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("X-Request-ID", uuid.uuid4().hex)
request.state.request_id = request_id
request_id_var.set(request_id)
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
第三部分在 formatter 中加入 request_id 字段。如果是 JSON 日志,直接给 JsonFormatter 加上 request_id;如果是纯文本,就把 formatter 改成 "%(asctime)s %(levelname)s %(request_id)s %(name)s %(message)s",并给 logger 添加 RequestIDFilter。
实现后,一次请求的日志就像这样:
json复制{"asctime": "2025-01-01 10:00:00,123", "levelname": "ERROR", "request_id": "9f8b7c6d", "name": "app.services.user_service", "message": "user register failed"}
排查问题的时候,拿响应头里的 X-Request-ID 去日志系统搜一下,整个链路一目了然,谁先调用、谁报错、耗时多少,全部串成一条线。这就是微服务链路追踪的雏形,如果你有多个服务,只要在所有服务里都用同一个 trace_id 生成规则和日志字段,就能把跨服务调用串起来。
4.5 日后往集中式日志设施迁移的思路
单机日志文件只适合单体项目初期,服务节点一多,或者需要多人共享查询,就必须把日志从服务器磁盘搬到一个集中的地方。市面上常见的方案有 ELK(Elasticsearch + Logstash + Kibana)、Loki、ClickHouse + Grafana 等。Loki 因为跟 Prometheus 生态契合,检索又是轻量级的方式,这几年很多中小团队采用。
迁移思路其实不复杂:保证日志格式是结构化的 JSON,然后选一个采集侧代理。比如 Loki 生态常用 Promtail,它可以监控日志文件目录,把新写入的 JSON 日志推送到 Loki;再比如 ELK 生态常用 Filebeat。还有一种思路是应用直接把日志通过 HTTP Handler 推给日志服务,不需要依赖采集代理。
我在迁移的时候最大的体会是:只要日志早就是 JSON 结构化、字段命名规范、 trace_id 串联完整,迁移到哪一个集中式设施都是改几行配置的事。反过来,如果日志还是混沌的一坨纯文本,迁移时就要先制定解析规则,成本会高出几倍。所以工程化的收益是前置的,越早做越省事。
5. 异常统一处理:把错误变成可预期的响应
5.1 混乱的异常处理会带来什么问题
没有统一异常处理的项目,问题一般在两个地方暴露。第一个是前端很痛苦:A 接口参数错误返回 400 加上一个字符串,B 接口业务失败返回 200 加一个 {"success": false},C 接口直接 500 给出一堆后端堆栈。前端交互层为了兼容这些乱七八糟的错误结构,要写一坨分支判断,稍有不慎漏掉一种情况,用户就看到一个半死不活的页面。第二个是后端自己很痛苦:业务代码里 try/except 满天飞,每个 except 里都亲自拼一段错误响应返回,重复代码多不说,还容易漏掉异常导致 500。
统一异常处理的核心思路是“框架兜底 + 业务显式抛错”:开发者在业务代码里只负责抛一个明确的异常,框架通过全局异常处理器把这个异常转成统一的响应结构,并自动记录日志。这样业务代码里几乎没有 try/except,错误处理逻辑收口到极少数几个地方。
5.2 自定义业务异常与错误码设计
在设计异常体系时,我喜欢先定义一个基础业务异常 BizException,再派生出一些常见类型,比如资源不存在、参数非法、权限不足。业务异常自带两个关键属性:错误码和用户可读消息。错误码应该跨接口唯一,这样前端拿到错误码而不是猜测错误消息文本就能知道怎么处理。
python复制# app/exceptions.py
class BizException(Exception):
def __init__(self, code: int = 40000, message: str = "业务异常"):
self.code = code
self.message = message
super().__init__(message)
class NotFoundException(BizException):
def __init__(self, message: str = "资源不存在"):
super().__init__(code=40400, message=message)
class PermissionDeniedException(BizException):
def __init__(self, message: str = "权限不足"):
super().__init__(code=40300, message=message)
错误码的规划建议带上业务前缀,但不要做得太复杂。比如 40000 表示通用业务错误,40001 表示唯一冲突,40002 表示状态不允许;40400 表示资源不存在;40300 表示没有权限。这样前端看到一个错误码,不用查文档也能大致猜到是参数问题、状态问题还是权限问题。
5.3 FastAPI 全局异常处理器完整实现
FastAPI 允许通过 exception_handler 装饰器注册全局异常处理器。我通常会在 main.py 里集中注册几类:自定义业务异常、参数校验异常、HTTP 异常、未知异常。
python复制# app/main.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.exceptions import BizException
app = FastAPI()
@app.exception_handler(BizException)
async def biz_exception_handler(request: Request, exc: BizException):
return JSONResponse(
status_code=400,
content={"code": exc.code, "message": exc.message, "data": None},
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={"code": 42200, "message": "参数校验失败", "data": exc.errors()},
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
return JSONResponse(
status_code=exc.status_code,
content={"code": exc.status_code * 100, "message": str(exc.detail), "data": None},
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
logger.exception("unhandled exception", exc_info=exc)
return JSONResponse(
status_code=500,
content={"code": 50000, "message": "服务器内部错误", "data": None},
)
这个处理器的设计有几个关键点。业务异常统一返回 HTTP 400,但响应体的 code 不一定 400,而是具体的业务错误码;参数校验异常把 exc.errors() 放进 data 里,前端可以直接展示字段级错误;未知异常必须完整记录堆栈,但响应体只返回“服务器内部错误”,绝不向用户泄露内部细节。还要注意 FastAPI 的 HTTPException 与 Starlette 的有点历史渊源,实战中兼容处理比只注册一个更稳妥。
5.4 统一响应体:前后端之间的“对账暗号”
异常响应统一了,成功响应也不能两套风格。我在团队里定的规矩是:所有接口无论成功失败,响应体都用同一个结构:
json复制{
"code": 0,
"message": "ok",
"data": {}
}
成功时 code 为 0,message 为 ok;业务失败时 code 为具体错误码,message 为人类可读描述;data 为 null 或业务数据。这个约定看着简单,真正落地时要注意别用中间件内置去包装响应体,我建议在路由函数里显式返回这个字典,或者更好用 Pydantic 定义响应模型,这样 Swagger 文档里也能看到结构,前后端联调时不会出现“文档和实际返回不一样”。
统一响应体最大的价值是“可预期”。前端 axios 拦截器里只需要判断一次 code,等于 0 走成功分支,否则走错误分支,剩下所有分支判断都可以省掉。这样省下来的沟通成本,用过的团队都懂。
6. 完整实操:组装一个最小可运行的企业级骨架
6.1 环境准备与依赖清单
到这里,我们把前面几张图拼起来。我会用一个“用户注册”接口串起分层、中间件、日志、异常处理。先准备环境,假设你本机已经有 Python 3.10 以上版本,建议用虚拟环境隔离依赖。
bash复制mkdir backend-engineering-demo
cd backend-engineering-demo
python -m venv venv
source venv/bin/activate # Windows 上执行 venv\Scripts\activate
依赖清单很简单:
bash复制pip install fastapi uvicorn python-json-logger pydantic[email]
这些包在 requirements.txt 里固化下来。FastAPI 负责 Web 框架,uvicorn 负责 ASGI 服务器,python-json-logger 负责结构化日志,pydantic 的 email 扩展用来做邮箱格式校验。
6.2 从 main.py 开始搭骨架
main.py 是应用的入口,它的职责是组装:先初始化日志,再创建 FastAPI 实例,然后注册中间件、注册路由、注册异常处理器。顺序上有一点讲究,日志要先初始化,因为后面注册中间件时耗时统计中间件可能立刻就能用到 logger。
python复制# app/main.py
from fastapi import FastAPI
from app.api.v1.router import api_router
from app.core.logging import setup_logging
from app.middleware.request_id import RequestIDMiddleware
from app.middleware.timing import TimingMiddleware
from app.core.logging import logger
from app.exceptions import register_exception_handlers
def create_app() -> FastAPI:
setup_logging()
app = FastAPI(title="Backend Engineering Demo", version="0.1.0")
app.add_middleware(RequestIDMiddleware)
app.add_middleware(TimingMiddleware)
app.include_router(api_router, prefix="/api/v1")
register_exception_handlers(app)
return app
app = create_app()
如果你不想把异常处理器剥出去,也可以直接在 main.py 里用装饰器写,怎么方便怎么来,关键是保证 create_app() 函数存在。有了创建函数,单元测试时可以反复调用产生独立的应用实例,不会因为全局状态互相干扰。
6.3 实现一个“用户注册”完整链路
我先把模型和仓库写出来。为了不引入数据库依赖,这里用内存字典模拟持久化,实际项目换成 SQLAlchemy 即可。
python复制# app/models/user.py
class User:
def __init__(self, user_id: int, name: str, email: str):
self.user_id = user_id
self.name = name
self.email = email
python复制# app/repositories/user_repository.py
from app.models.user import User
class UserRepository:
def __init__(self):
self._users = {}
self._next_id = 1
def create(self, name: str, email: str) -> User:
user = User(self._next_id, name, email)
self._users[user.user_id] = user
self._next_id += 1
return user
def get_by_email(self, email: str):
return next((u for u in self._users.values() if u.email == email), None)
然后是 Service 层,业务规则在这里体现:注册前检查邮箱是否已被注册,如果有则抛出业务异常。
python复制# app/services/user_service.py
from app.exceptions import BizException
from app.repositories.user_repository import UserRepository
class UserService:
def __init__(self, repo: UserRepository):
self._repo = repo
def register(self, name: str, email: str):
if self._repo.get_by_email(email):
raise BizException(code=40001, message=f"邮箱 {email} 已被注册")
user = self._repo.create(name, email)
return user
接着是 Schemas,定义请求和响应的结构。这里我单独定义了一个 UserData 作为 data 字段内部的模型,保证响应结构清晰。
python复制# app/schemas/user.py
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
name: str
email: EmailStr
class UserData(BaseModel):
id: int
name: str
email: str
class UserOut(BaseModel):
code: int
message: str
data: UserData
最后是路由和依赖组装。路由函数里没有一行业务逻辑,它只负责把参数交给 Service,然后把返回结果包装成统一响应体。
python复制# app/api/dependencies.py
from app.repositories.user_repository import UserRepository
from app.services.user_service import UserService
def get_user_service():
return UserService(UserRepository())
python复制# app/api/v1/endpoints/users.py
from fastapi import APIRouter, Depends
from app.schemas.user import UserCreate, UserOut
from app.services.user_service import UserService
from app.api.dependencies import get_user_service
router = APIRouter(prefix="/users", tags=["users"])
@router.post("/register", response_model=UserOut)
def register(payload: UserCreate, user_service: UserService = Depends(get_user_service)):
user = user_service.register(payload.name, payload.email)
return {"code": 0, "message": "ok", "data": {"id": user.user_id, "name": user.name, "email": user.email}}
6.4 启动、请求、观察日志:一次完整的走查
启动服务只需要一条命令:
bash复制uvicorn app.main:app --reload --port 8000
然后打开浏览器访问 http://127.0.0.1:8000/docs,会看到 FastAPI 自动生成的 Swagger 文档,注册接口的请求和响应结构都能直接试。我先正常注册一个用户:
bash复制curl -X POST "http://127.0.0.1:8000/api/v1/users/register" \
-H "Content-Type: application/json" \
-d '{"name": "zhangsan", "email": "zhangsan@example.com"}'
响应应该是:
json复制{
"code": 0,
"message": "ok",
"data": {"id": 1, "name": "zhangsan", "email": "zhangsan@example.com"}
}
再重复请求一次同一个邮箱,业务异常处理器会介入,返回:
json复制{
"code": 40001,
"message": "邮箱 zhangsan@example.com 已被注册",
"data": null
}
此时打开运行 uvicorn 的那个终端窗口,会看到结构化日志里记录了一次请求的完成信息,包含了 method、path、status_code、duration_ms、request_id 等字段。拿着最外层返回的 X-Request-ID,再去日志里搜,这一条请求从进入到返回的完整记录都能串起来。这就是整套骨架运转起来的样子。
7. 常见问题与排查技巧实录
7.1 日志不输出、重复输出的排查
日志不输出,十有八九是级别配置问题。如果你的 root logger 级别是 WARNING,那代码里的 logger.info(...) 自然不显示,先检查 setLevel 是不是被某个地方覆盖了。重复输出则更常见:setup_logging 被调用了多次,或者每个模块都创建了自己的 handler,导致同一条日志打两遍。我的习惯是只在应用启动时调用一次 setup_logging,并且每次给 root logger 添加 handler 之前先清空旧的:root_logger.handlers.clear()。
7.2 中间件注册顺序引发的问题
中间件顺序问题我在第 3 节提过,真正的坑出现在“请求ID中间件”和“异常处理中间件”同时存在时。如果你把请求ID中间件注册在异常中间件的外层,而异常处理器里又要读取 request.state.request_id,这时可能取不到值,因为外层中间件还没执行到设置 state 的代码。解决方法是把请求ID中间件放在最外层,让它最先执行;同时异常处理器里读取 request_id 时要做默认值兜底,不要假设一定有值。
7.3 异常处理器不生效的三种原因
第一,你注册的是 HTTPException 处理器,但导入路径写成了 fastapi.HTTPException,而实际抛出来的是 starlette.exceptions.HTTPException,两者在部分场景下不完全等价。第二,你在某个子路由上又单独注册了相同的异常处理器,覆盖了全局配置,排查时可以先搜索所有 exception_handler。第三,中间件内部自己吞掉了异常,请求根本没走到异常处理器就返回了。遇到这种问题,最有效的办法是在已知会出错的接口里手动 raise 一个测试异常,看看有没有走到全局处理器,用二分法确认断裂点。
7.4 异步场景下请求上下文丢失怎么办
如果你用 asyncio.create_task 去后台执行任务,子协程里可能拿不到主请求协程中设置好的 request_id_var。这是 contextvars 的机制决定的:默认情况下子任务不会继承父任务的上下文。解决方案是在创建任务时显式传递 contextvars.copy_context(),或者用库的绑定机制,例如 FastAPI 后台任务支持把 context 一并携带。总之在异步任务里打印日志之前,先确认 trace_id 是否还在,否则排查问题时会发现有些日志缺了请求ID。
7.5 一套简单高效的日志排查实战方法
最后分享一个我在生产环境常用的排查套路。前端或者测试反馈某个接口报错时,我第一时间问的是“响应头里的 X-Request-ID 是什么”。拿到这个 ID 之后,在日志系统或者服务器日志文件里执行:
bash复制grep "9f8b7c6d" app.log
一次请求从进入到退出的相关日志就全出来了。然后按时间顺序看级别是 ERROR 的那条,定位是系统异常还是业务异常:如果 code 是 4 开头,多半是前端参数或业务状态问题;如果 code 是 5 开头且伴随堆栈,就是后端代码或外部依赖问题。这套流程听着朴素,但它比“在测试环境复现一遍”快得多,尤其在生产问题需要马上止损的时候,它能帮你把定位时间从小时级压缩到分钟级。
把这套骨架搭完,你再看手头的项目,思路应该完全不一样了。我自己这几年带后端项目的体会是:工程化不是一次大重构,而是从第一天起就守住分层、中间件、日志和异常这些“底线设施”。先把这套骨架搭好,后续不管是加缓存、加消息队列,还是拆微服务,主心骨都不会散。
