后端开发做久了,你会发现一个特别真实的差距:同样一个订单接口,初级工程师三天写出来能跑,高级工程师五天写出来除了能跑,还能抗住流量波动、快速定位线上问题、在异常发生时给出体面的错误提示,而不是让调用方收到一坨看不懂的堆栈。这个差距不在业务逻辑本身,而在工程化能力。
今天这篇就以“Python 分层架构 + 中间件 + 日志 / 异常统一处理”为主线,从零讲清楚怎么把一个能用的小服务,升级成接近企业级标准的后端。内容偏实操,贴合真实业务场景,适合已经写过一些 Python Web 接口、想往项目工程化方向提升的同学,也适合团队里需要搭建后端基础框架的开发者。
1. 分层架构:先把代码的“地盘”划清楚
1.1 为什么必须分层
先聊一个最常见的反面案例。新项目起步时,时间紧、人少,大家图省事,业务逻辑直接写在路由函数里:
python复制# 反面教材
@app.post("/order/create")
def create_order(user_id: int, item_id: int, count: int):
# 校验参数
if count <= 0:
return {"code": 400, "msg": "count必须大于0"}
# 查数据库
item = db.execute(f"SELECT * FROM item WHERE id={item_id}").fetchone()
if not item:
return {"code": 404, "msg": "商品不存在"}
# 查用户
user = db.execute(f"SELECT * FROM user WHERE id={user_id}").fetchone()
if user["balance"] < item["price"] * count:
return {"code": 402, "msg": "余额不足"}
# 扣钱
db.execute(f"UPDATE user SET balance=balance-{item['price']*count} WHERE id={user_id}")
# 生成订单
db.execute(f"INSERT INTO order(user_id, item_id, count, total) VALUES(...)")
db.commit()
return {"code": 0, "data": {"order_id": 123}}
这段代码看着没毛病,但三个月之后你会想打自己。因为你会遇到:
- 另一处也要用“校验用户余额”,你会复制粘贴一遍;
- 下单接口里加了库存锁定,但库存服务在另一个工程,代码该放哪?
- 测试时发现每次都要手动构造数据库数据,根本没法做单元测试;
- 你甚至分不清哪些是 HTTP 协议的活儿、哪些是业务规则、哪些是数据存取。
分层的本质,是把“变化点”隔离开。谁容易变?外部协议(HTTP 接口)容易变,业务规则容易变,数据存储方式容易变。如果这一堆东西混在一个函数里,任何一方变动都会连坐其他代码。分层之后,每一层只干一件事,而且通过明确的依赖方向来控制变更的影响范围。
1.2 企业级 Python 项目最常见的四层
具体到 Python 后端,社区实践已经收敛出一套比较稳定的结构:
| 层次 | 职责 | 典型目录/模块 | 依赖方向 |
|---|---|---|---|
| 接口层 | 参数解析、认证、路由分发、HTTP 响应 | api/ 或 views.py、controllers/ |
依赖 service 层 |
| 业务层 | 业务规则、流程编排、事务边界 | service/ 或 services.py、use_cases/ |
依赖 repository 层 |
| 数据层 | SQL 拼装、ORM 操作、缓存访问 | repository/ 或 repositories.py、dal/ |
只依赖模型定义 |
| 模型层 | 数据表映射、领域对象、公共类型 | models/、schemas/ |
无上层依赖 |
项目大了之后,有些人还会在 service 之上再切一个 domain 领域层放纯逻辑(不加 IO),或者把 service 拆成 command/query。但入门工程化,先掌握这四层就够用。
有人会问:这和 MVC 有什么区别?MVC 更面向全栈框架,而这里的分层更强调依赖方向——这是工程化的关键。依赖必须是单向的:接口层调业务层,业务层调数据层,数据层不回头依赖上层。一旦出现业务层直接被路由函数绕过的情况,就是架构腐化的信号,就得靠 code review 或架构测试来守。
改造后,刚才的下单逻辑会变成这样:
python复制# 接口层只做参数解析,把业务交给 service
@app.post("/order/create")
def create_order(payload: CreateOrderRequest):
order_id = order_service.create_order(payload)
return {"code": 0, "data": {"order_id": order_id}}
python复制# 业务层做规则编排
class OrderService:
def __init__(self, user_repo, item_repo, order_repo):
self.user_repo = user_repo
self.item_repo = item_repo
self.order_repo = order_repo
def create_order(self, payload):
user = self.user_repo.get_by_id(payload.user_id)
item = self.item_repo.get_by_id(payload.item_id)
if not user or not item:
raise BizError("用户或商品不存在")
if user.balance < item.price * payload.count:
raise BizError("余额不足")
with db.transaction():
self.user_repo.deduct_balance(user.id, item.price * payload.count)
order_id = self.order_repo.create(user.id, item.id, payload.count, item.price * payload.count)
return order_id
换一个说法,分层就是在给团队立规矩:路由里不准写 SQL,service 里不准碰 request.headers,repository 里不准抛和业务相关的异常。规矩立住了,代码才能成为资产,而不是负债。
1.3 分层的副作用:代码量增加,换来的是可控性
分层不是免费的,最明显的代价是“代码变多”。原来 20 行搞定的事,分层后可能要拆到三个文件里。但如果你把这部分理解为“为每个类付一笔保险费”,遇到下面这些情况时你就会觉得值:
- 需求方说,商品表从 MySQL 迁到 Redis 缓存前置,你只需改 repository 层,接口层和 service 层一行不动;
- 支付回调接口要加一个签名验签逻辑,你加一个 service 的方法再在接口层挂上,不会影响其他调用方;
- 要写单元测试,你可以 mock 掉 repository,直接把 service 拉出来测,根本不需要起一个 Flask/FastAPI 服务。
经历过一次“接口层改需求、数据层跟着遭殃”的人,才会真正认同分层。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 中间件:请求进出的“安检通道”
2.1 什么是中间件,为什么需要它
分层的代码解决的是“业务代码内部”的组织问题,而中间件解决的是“业务代码外部”的横切问题。什么叫横切?就是每个接口都需要、但又不想在每个接口里重复写的那些逻辑:请求日志、跨域 CORS、登录态校验、限流、请求 ID 注入、响应耗时统计。
这些逻辑本质上是黑白名单式的“闸门”或“通道”,放在业务函数里既污染代码,又容易漏写。中间件机制允许你在请求进入路由之前、以及响应返回给客户端之前,统一插一段处理逻辑。
在 FastAPI 中,中间件基于 Starlette,写法是:
python复制@app.middleware("http")
async def request_context_middleware(request: Request, call_next):
request_id = uuid.uuid4().hex
request.state.request_id = request_id
start = time.time()
try:
response = await call_next(request)
except Exception as e:
# 统一兜底,避免异常堆栈泄露给客户端
return JSONResponse(status_code=500, content={"code": 500, "msg": "服务器开小差了"})
finally:
cost_ms = (time.time() - start) * 1000
logger.info("request_id=%s method=%s path=%s cost_ms=%.2f status=%s",
request_id, request.method, request.url.path, cost_ms, response.status_code)
return response
在 Flask 中则用 before_request 和 after_request 两个钩子来实现类似效果。无论哪个框架,核心思想都一样:在调用栈外圈做横切。
2.2 业务里最常用的五类中间件
-
请求 ID 注入中间件:这是日志追踪的基石。每个请求进来时生成一个唯一 ID,塞进 request context,后续所有日志只要带上这个 ID,就能把一次请求的完整链路串起来。尤其是在多服务调用场景下,这个 ID 会顺着 HTTP header(比如
X-Request-Id)透传到下游,形成全链路追踪。 -
访问日志中间件:记录“谁在什么时间调用了哪个接口、参数是什么、耗时多久、返回状态码是多少”。注意参数不要打全量,尤其不要打密码、支付密钥等敏感字段,否则日志仓库一泄露就是事故。
-
统一异常捕获中间件:在路由外兜底。业务代码里万一漏了 try/except,不能让框架返回默认的 HTML 堆栈或者裸奔的内部错误,要统一转成 JSON 错误结构并记录全量堆栈到日志。
-
CORS 中间件:对接前端时必不可少。跨域配置看似简单,坑也不少——比如
allow_headers没写好,前端带自定义 header 直接请求失败;expose_headers没配置,前端拿不到响应头等。 -
限流中间件:接口被刷或者被脚本频繁调用时,在网关层或应用层做一个简单的令牌桶限流。所有流量先过闸门,被限流的请求直接返回 429,而不是继续往下打数据库。
2.3 中间件的执行顺序,踩坑重灾区
这是一个非常容易踩坑的点。多个中间件之间的执行顺序,决定了同样的逻辑在不同环境下结果是否一致。以 FastAPI/Starlette 为例,中间件的执行顺序是“洋葱模型”——通过 call_next 往下层传递时,实际执行顺序和声明顺序相反(后声明的先执行外圈处理)。
举个例子:
python复制@app.middleware("http")
async def middleware_a(request, call_next):
print("A before")
response = await call_next(request)
print("A after")
return response
@app.middleware("http")
async def middleware_b(request, call_next):
print("B before")
response = await call_next(request)
print("B after")
return response
打印结果是:B before → A before → 路由处理 → A after → B after。如果你把“登录校验中间件”写在“请求日志中间件”前面,那么未登录的请求根本到不了日志中间件,有些该记录的访问记录就丢了。
我的建议是:认证中间件尽量靠外,请求日志中间件次之,业务上下文中间件靠内。也就是先做安全拦截,再做记录,最后再注业务上下文,这样既不会放过未授权请求,也不会遗漏已放行请求的日志。排序这事写进团队文档里,比每个新人都踩一遍坑强得多。
3. 日志体系:从 print 到大盘监控
3.1 Python logging 的底层逻辑
很多人写 Python 日志,停留在 print("xxx") 的水平。print 在开发时确实爽,但它的输出目标只有一个 stdout,到生产环境根本无法分级、无法控制格式、无法轮转、无法接入日志采集。真正的工程级日志,靠的是标准库 logging 的四个组件配合:
| 组件 | 角色 | 类比 |
|---|---|---|
| Logger | 日志记录器,代码里直接调用的对象 | 一个公司 |
| Handler | 日志处理器,决定日志去哪 | 快递员 |
| Formatter | 格式化器,决定日志长什么样 | 包装盒 |
| Filter | 过滤器(可选),决定哪些日志放行 | 保安 |
具体关系是:代码里拿一个 Logger,发出一条记录,Logger 会把它交给绑定的一个或多个 Handler,Handler 再用绑定的 Formatter 把这条记录塞进模板里,最后输出到控制台、文件、或者远端。
一个常见的坑是重复输出。很多新手在多个配置地方反复调用 basicConfig 或者在每个模块里重新创建 handler,导致一条日志打三遍。工程上应该全局只配置一次 Logger,模块里用 logger = logging.getLogger(__name__) 拿一个按模块名命名的子 Logger,不自己加 Handler,统一由根 Logger 分发。
3.2 一套可以直接上生产的基础日志配置
下面是我现在项目里直接用的一套配置,结构清晰且能开箱即用:
python复制import logging
import sys
from logging.handlers import TimedRotatingFileHandler
from pythonjsonlogger import jsonlogger
class RequestIdFilter(logging.Filter):
def filter(self, record):
record.request_id = getattr(contextvars.request_id_cv, "value", "-")
return True
def setup_logging(level: str = "INFO", log_path: str = "logs/app.log"):
fmt = "%(asctime)s | %(levelname)s | %(name)s | %(request_id)s | %(message)s"
formatter = logging.Formatter(fmt)
root = logging.getLogger()
root.setLevel(level.upper())
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
root.addHandler(console_handler)
if log_path:
file_handler = TimedRotatingFileHandler(log_path, when="midnight", backupCount=30, encoding="utf-8")
file_handler.setFormatter(jsonlogger.JsonFormatter(fmt))
root.addHandler(file_handler)
# 给 handler 挂上 request_id 过滤器
root.addFilter(RequestIdFilter())
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
这里有两个细节很多人忽略:
第一,文件输出用 JSON 格式,控制台输出用纯文本格式。JSON 格式方便生产环境通过容器日志采集(比如 Filebeat/Loki/ELK),纯文本格式方便本地调试时肉眼阅读。同一个 logger,同一个 formatter 模板,但不同 handler 可以绑不同格式,这也是 logging 设计的妙处。
第二,TimedRotatingFileHandler 按天切割文件,并保留 30 天。日志文件如果无限增长,轻则把磁盘打满,重则让应用崩溃。这里的 backupCount=30 就是保险。
3.3 日志分级和上下文字段,决定排障效率
线上排障效率不取决于日志多不多,而取决于日志全不全、结构不结构。我强烈建议关键业务流水都打 INFO 级,排除正常路径后只留 ERROR 和 WARNING,同时在每次方法入口出口补上关键字段。
最核心的字段是 request_id。没有这个 ID,多并发下的日志交错在一起,你根本分不清哪条是哪一次请求的。有了 request_id,从收到请求到 DB 查询再到响应返回,所有日志都是按请求维度聚合的。
另一个字段是 trace_id / user_id。如果系统里已经接了 OpenTelemetry 之类的链路追踪,那 tace_id 可以直接融进日志。用户 ID 对排查特定用户的问题尤其重要,比如用户投诉“我下单失败了”,你拿着 user_id 一捞日志,立刻就知道他走的哪条链路、卡在哪个环节。
多个进程的日志按时间排序后依然会交叉,但只要统一格式并带上必要字段,排障效率完全是两个级别。这也是为什么我不建议用 print 输出:print 没有 request_id,没有 level,没有时间戳,出了问题你只能“看运气”。
3.4 收集端:本地文件不该是终点
应用日志打到本地文件只是第一步。微服务架构下,应用实例不止一台,每台机器上的日志文件都是“信息孤岛”。这时候就需要集中采集:比如用 Loki 拉取容器标准输出,用 Filebeat 配 Logstash 写到 ES,或者接云厂商的日志服务。
注意,集中采集这件事最好在日志格式阶段就预设好。如果一开始就是乱糟糟的纯文本,后面做分析还得写一堆正则,清洗成本极高。所以我的建议就是:打日志时就把结构化做好,别指望采集端能把你的脏数据洗干净。
4. 异常统一处理:把“崩溃”变成“可预期的错误码”
4.1 为什么不能默认抛堆栈
有些新手觉得:出异常就让框架返回堆栈,这不是很方便么?对测试环境确实方便,但生产环境这样就是灾难。堆栈里包含文件路径、调用关系甚至参数信息,既可能泄露内部实现细节,又会让前端拿到一串毫无意义的英文报错。
还有一个更实际的问题:框架默认的 500 响应不带统一结构。前端没法统一处理,每种错误都长不一样,那前端就得写一堆 if-else 去猜结构。企业级系统的 API 响应结构必须稳定统一。
我们团队内部定的最小响应结构是:
json复制{
"code": 500,
"msg": "user-friendly error message",
"request_id": "8f4c2e1a0d3b4f7e"
}
无论返回什么业务错误,这个结构都保持不变,code 是业务错误码,msg 是对用户友好的提示,request_id 是排查关联用的,客户端一旦看到 500 默认错误,可以把这串 ID 反馈给后端,后端拿着直接查日志。
4.2 自定义异常类 + 全局异常处理器
工程上核心是定义一套业务异常体系,然后注册一个全局异常处理器,把所有“已知异常”翻译成上面的响应结构,把“未知异常”记日志后转成统一的 500。
定义一个基础业务异常:
python复制class BizError(Exception):
def __init__(self, code: int = 400, msg: str = "业务错误", http_status: int = 400):
super().__init__(msg)
self.code = code
self.msg = msg
self.http_status = http_status
class NotFoundError(BizError):
def __init__(self, msg: str = "资源不存在"):
super().__init__(code=404, msg=msg, http_status=404)
class PermissionDeniedError(BizError):
def __init__(self, msg: str = "没有权限"):
super().__init__(code=403, msg=msg, http_status=403)
然后在 FastAPI 中注册全局处理器:
python复制from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(BizError)
async def biz_error_handler(request: Request, exc: BizError):
return JSONResponse(
status_code=exc.http_status,
content={"code": exc.code, "msg": exc.msg, "request_id": request.state.request_id}
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
logger.exception("unhandled error, request_id=%s, path=%s", request.state.request_id, request.url.path)
return JSONResponse(
status_code=500,
content={"code": 500, "msg": "服务器内部错误,请联系管理员", "request_id": request.state.request_id}
)
Flask 里则是 @app.errorhandler(NotFoundError) 加 @app.errorhandler(Exception) 的写法,思路完全一样。关键在于:捕获未知异常时,用 logger.exception 带着堆栈打日志,但响应给客户端的信息绝不能暴露堆栈内容。
还有一个容易忽略的:事务回滚要放在异常处理之前做好。如果 service 层依赖 SQLAlchemy,需要确认 session 在异常时进入 rollback 而不是继续挂着。常见做法是把 session 的生命周期绑在请求 scope 里,用 contextmanager 来保证事务关闭和回滚。这属于“异常处理边界”的范畴——只搞定响应结构,没搞定数据一致性,异常处理就是纸糊的。
4.3 异常处理的分层策略:底层抛异常,顶层换响应
需要明确一点:不是所有层都直接抛 HTTPException。我见过一些项目,在 repository 层直接用 raise HTTPException(400, "xxx"),这就把“存储层”和“HTTP 协议”耦合死了。将来这个 repository 如果被命令行脚本或 RPC 服务复用,还得带着 FastAPI 的 HTTP 异常,非常别扭。
正确的分层策略是:
- repository 层抛存储异常(如
DataAccessError); - service 层负责把存储异常翻译成业务异常(如
NotFoundError、BizError); - 接口层或者全局异常处理器负责把业务异常翻译成 HTTP 响应。
这样,底层只关心“这步失败了”这个事实,顶层才关心“失败以什么身份面向调用方”。体系的优雅程度差异,就在这种微妙的分界上。
5. 常见问题与排查技巧实录
5.1 日志重复打印
症状是每行日志打印两三遍。原因通常是多模块配置 logger 时,每个模块都给根 logger 加了一次 handler。解决办法是:把日志初始化收敛到一个函数里,只在程序启动时调用一次;模块内部只使用 logger = logging.getLogger(__name__),不要手动 addHandler。初始化函数内部要加一个判断,比如 if root.handlers: return,防止在测试环境重复初始化。
5.2 中间件里拿不到 request_id
很多新手在路由里只是 read header,但自己写的中间件里根本还没把 request_id 注入上下文。这里要留意 context 的传递方式。FastAPI 里可以用 request.state 或 contextvars.ContextVar 来跨层传递;普通函数执行上下文里不要直接从全局变量去拿,因为异步并发下全局变量会相互覆盖。线程/协程本地变量才是安全的。
5.3 异常被 try/except 吞掉
这是排障最忌惮的一种代码。遇到有些同事“害怕异常”就笼统地 except Exception as e: print(e),最后真正出问题时日志里什么有效信息都没有。正确的做法是:如果暂时无法处理该异常,至少用 logger.warning("xx failed", exc_info=True) 把堆栈留底,再决定是否向上抛出或降级处理。永远不要空 try/except,否则线上故障排查时你会对着日志抓瞎。
5.4 SQL 语句慢查询与日志监控
接入分层架构后,数据库层一定要记录 SQL 的执行时间。比如给 SQLAlchemy 引擎挂 before_cursor_execute 和 after_cursor_execute 事件,把超过 200ms 的慢 SQL 自动打到 WARNING 及以上,并带上 request_id。这一步投入小、收益大,很多系统性性能问题的第一现场都是从慢 SQL 日志挖出来的。
5.5 多应用实例下日志时间不同步
多台机器如果没做 NTP 时间同步,日志时间戳各自漂移,集中分析时会出现“同一个请求在不同机器上的日志时间对不上”。建议除了记录当前机器时间,再加一条 time_ms(毫秒级时间戳)字段,必要时基于 request_id 聚合时用单调递增时间戳来排序,不要只靠 asctime。
6. 一条线串起来:从请求进来到异常返回的全流程
把这几个部分拼起来,一次请求的完整旅程是这样的:
- 请求进入中间件链,最外层中间件先生成 request_id,并注入上下文;
- 认证中间件校验 token,不合法直接返回 401,不往下走;
- 请求日志中间件记录开始时间并继续向下调用;
- 路由层接收参数,调用 service 层;
- service 层执行业务,调用 repository 层完成数据读写;如果余额不足,抛
BizError; - 全局异常处理器捕获
BizError,把它翻译成统一错误响应,并记录当前请求的 request_id; - 返回响应时,外层中间件计算耗时,把链路日志打完。
这整个流程下来,代码里的每个节点都没有直接跨层耦合,日志里每个环节都有据可查,出现任何异常最终都能落到一个稳定的响应结构和一条完整的日志链路上。
在实际做这一课的落地练习时,我的建议是不要一上来就把架构铺得很大,先拿一个最简单的商品列表接口,手动把它从单文件拆成四层,然后把中间件和异常处理挂上去,再把日志工具接好,跑一遍单测,你就能真切感受到“工程化”这三个字的分量了。之后再铺业务,心里就有底了。
