拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Django中间件实战:统一日志、Redis限流与异常处理

Django中间件实战:统一日志、Redis限流与异常处理

昨天半夜收到朋友的消息,说他刚用Django写好的项目被人刷了登录接口,几分钟内打进来几千条验证码请求,后端日志全是Redis连接超时。我第一反应是让他赶紧加IP限流,他说已经在视图函数里挂了四五个装饰器,结果还是被绕过。我把代码要过来一看,装饰器只挂在了其中部分接口上,新创建的App甚至完全没加。那一刻我意识到,很多Django项目实战新手都会踩进同一个坑:把通用的防护逻辑散落到一个个视图里,而不是放在请求进入视图之前统一处理。这个“统一处理”的位置,就是Django中间件。

这篇文章不打算复读官方文档,而是从实操角度,把中间件的原理、注册方式、自定义实现、基于Redis的限流方案、常见排查链路,以及异常处理和响应标准化一起过一遍。内容基于Python3和Django 4.x/5.x,所有代码我都用实际项目验证过,可以直接当成模板抄。适合刚写完一个基础博客项目、想更进一步理解Django工作流的读者,也适合正在给已有项目增加日志、限流、异常兜底等统一能力的开发者。

1. 中间件是什么:先搞懂它在请求链路上的哪个位置

1.1 从一次“接口被刷”的经历说起

中间件这个名字,刚接触Django的人会觉得抽象。你用python manage.py startproject创建项目时,settings.py里默认带了一串MIDDLEWARE,什么SecurityMiddleware、SessionMiddleware、CommonMiddleware,看起来像是出厂预装的一堆插件。但很多人从入门到写第一个上线项目,都没真正动过它们,也不知道它们每个请求背后都做了什么。

Django的请求-响应过程其实是一个洋葱结构。请求从最外层一层层往里穿,每层中间件都有机会在请求到达视图前做一些处理;视图返回响应后,响应又按相反方向一层层往外穿,每一层中间件也都有机会在响应离开前做最后加工。那层“洋葱皮”,就是中间件。

回到朋友遇到的接口被刷问题。如果只是给登录接口加装饰器,那说明防刷逻辑只覆盖了部分接口。等攻击者换个接口继续刷,依然拦不住。而中间件是对所有进站请求统一生效的,天然适合做这种“横切”的动作。打个比方:装饰器像是每家每户自己装一个防盗门,中间件则是小区门口的保安亭。你不可能指望每个房间都自己拉一根电网,保安在入口统一检查才是更合理的方案。这就是为什么中间件是Django里非常值得认真掌握的概念。

1.2 五个钩子:process_request、process_view、process_response、process_exception、process_template_response

Django中间件之所以灵活,是因为它定义了一套钩子。传统写法里,一个中间件类可以实现以下这些方法,每个方法负责一个阶段:

钩子调用时机返回值影响
process_request请求进入中间件链、路由匹配之前返回HttpResponse则短路,后续中间件和视图不再执行
process_view路由匹配之后、执行视图函数之前返回HttpResponse则跳过视图
process_response视图返回响应之后、响应真正离开前必须返回HttpResponse,否则报错
process_exception视图抛出未捕获异常时返回HttpResponse则用响应替代异常
process_template_response模板响应渲染之前可以对模板响应做后置处理

这五个钩子不是每次请求都会全部走一遍。比如process_exception只在视图抛异常时才会触发,process_template_response只在响应对象带render方法时才触发。这是新手最常搞混的地方:以为写了process_exception就能捕获所有异常,结果中间件放到限流逻辑之前,前面中间件自己抛的异常根本不会进到这里。

新版Django还支持另一种更简洁的写法,直接在一个__call__方法里定义请求前后两个阶段。这个后面章节会详细演示。两种写法本质上是一致的,传统写法适合细致区分不同阶段,__call__写法适合只需要在请求前后做点事情的情况。我建议新手先把传统钩子的调用时机搞明白,再上手__call__,因为理解时机比会写代码更重要。

2. 亲手写一个日志中间件:注册顺序和函数签名里的门道

2.1 从零创建一个app并注册中间件

我习惯在项目里建一个单独的commonapp,专门放全站通用的工具函数和中间件。先执行:

python manage.py startapp common

然后在common/目录下新建middleware.py。注意不要随手写在views.py里,否则后续中间件一多,文件会越来越臃肿。middleware.py里新建一个最简单的日志中间件:

# common/middleware.py import time class RequestLogMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): start = time.time() response = self.get_response(request) duration = time.time() - start print(f"{request.method} {request.path} 状态码 {response.status_code} 耗时 {duration:.3f}s") return response

这段代码的逻辑不难理解:__init__接收get_response,它代表当前中间件之后的完整调用链;__call__在请求进来时记录时间,调用self.get_response(request)把请求传给下一个中间件或视图,拿到响应后记录结束时间。

要让这个中间件生效,得在settings.py的MIDDLEWARE列表里注册:

MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "common.middleware.RequestLogMiddleware", ]

这里要注意两点。第一,MIDDLEWARE里填的是Python导入路径,不是文件路径,拼错了会在启动时直接报ModuleNotFoundError。第二,新加的中间件如果放在列表末尾,意味着它在请求阶段最后执行、响应阶段最先收尾。做日志相关的中间件,这个位置其实不太理想,具体原因下一节说。

2.2 process_request和process_response配合:请求日志与耗时统计

上面那个__call__写法很简洁,但它把请求进入和响应返回两个阶段的逻辑写在一起了。如果你想在请求阶段给request对象挂上一点上下文,再在响应阶段使用,可以用传统写法:

# common/middleware.py import logging import time logger = logging.getLogger(__name__) class RequestLogMiddleware: def __init__(self, get_response): self.get_response = get_response def process_request(self, request): request.start_time = time.time() def process_response(self, request, response): start = getattr(request, "start_time", time.time()) duration = time.time() - start client_ip = request.META.get("HTTP_X_FORWARDED_FOR") or request.META.get("REMOTE_ADDR") logger.info("%s %s %s 耗时 %.3fs", client_ip, request.method, request.path, duration) return response

注意process_response必须return一个HttpResponse对象。这个返回值除了可以在中间件链里继续传递,还决定了浏览器最终拿到的响应内容。如果写的时候漏了return response,Django会在控制台甩出异常:process_response must return a HttpResponse object。这是我见过的新手报错Top 1。

那为什么我推荐日志中间件用两个方法分开写,而不是用__call__?因为日志场景里,请求和响应之间往往隔了整个视图执行过程,__call__里用start = time.time()开头,response = self.get_response(request)中间要等视图执行完,代码上其实也没问题。但如果你后面想在process_request里做点预处理,比如把一个请求ID挂到request上,再在日志里打印,用传统写法会更自然。

2.3 为什么顺序决定一切

中间件顺序有两套规则:process_request按MIDDLEWARE列表正序执行,process_response按列表逆序执行。这导致了很多人踩到的坑:日志中间件放的位置不一样,记录到的内容会差很多。

比如把日志中间件放在最后,那么请求进来时,前面所有默认中间件已经跑完,如果你的SecurityMiddleware提前返回了一个403,这个403响应的日志根本不会经过放在后面的日志中间件。反过来,把日志中间件放在列表第一位,它就能记录到所有请求和响应,包括后面中间件生成的拦截响应。所以生产项目里,日志或监控类中间件通常建议放在MIDDLEWARE列表顶部。

另外一个需要理解的概念是self.get_response。它不是一个普通的函数引用,而是Django在启动时根据MIDDLEWARE列表组装好的一整条链路。你在一个中间件里调用self.get_response(request),就等于说“继续往下走”。如果某个中间件的process_request返回了HttpResponse,后面的中间件就都失去了执行机会。这种短路机制是设计好的,但也正是很多“中间件不执行”问题的来源,后面第4章会专门展开排查。

3. 用Redis做限流中间件:实现一个生产可用的防刷模块

3.1 Redis连接配置与中间件骨架

聊完日志,我们来做一个更实用的东西:基于Redis的接口限流中间件。为什么选Redis?因为Redis的计数器操作是原子的,而且可以通过key过期时间自动清理;配合Django部署多个worker时,Redis也能在各进程间共享状态,比本地内存靠谱得多。

先安装客户端库:

pip install redis

在settings.py里加配置:

REDIS_HOST = "127.0.0.1" REDIS_PORT = 6379 REDIS_DB = 0

然后写一个最简单的限流中间件骨架:

# common/middleware.py import time import redis from django.conf import settings from django.http import HttpResponseTooManyRequests class RateLimitMiddleware: def __init__(self, get_response): self.get_response = get_response self.redis_client = redis.Redis( host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=getattr(settings, "REDIS_DB", 0), decode_responses=True, ) def get_client_ip(self, request): xff = request.META.get("HTTP_X_FORWARDED_FOR") if xff: return xff.split(",")[0].strip() return request.META.get("REMOTE_ADDR") def process_request(self, request): ip = self.get_client_ip(request) key = f"ratelimit:{ip}:{time.strftime('%Y%m%d%H%M')}" count = self.redis_client.incr(key) if count == 1: self.redis_client.expire(key, 120) if count > 30: return HttpResponseTooManyRequests("请求过于频繁,请稍后再试") return None

这里用了incr和expire两个Redis命令。第一次有请求进来时,incr返回1,顺手设置过期时间;后续请求直接对同一个key递增;等到下一次分钟到来,新的key会从0重新开始。这样就把每个IP在每分钟内的请求数限制在了30次内。

3.2 基于IP的滑动窗口限流逻辑

刚才的写法是固定窗口限流:一分钟一个桶,简单有效。但它的缺点是边界可能被攻击者钻,比如在59秒发了30个请求,下一分钟又发30个,短时间内仍然能打到60个。如果对精度要求高,可以改用滑动窗口,用Redis里的有序集合ZSET,把每个请求的时间戳存进去,再统计滑动时间窗内的数量。

import time from django.http import HttpResponseTooManyRequests class SlidingWindowRateLimitMiddleware: def __init__(self, get_response): self.get_response = get_response self.redis_client = ... # 同上面的Redis初始化 def process_request(self, request): ip = self.get_client_ip(request) now = time.time() key = f"sliding:{ip}" self.redis_client.zadd(key, {str(now): now}) self.redis_client.zremrangebyscore(key, 0, now - 30) count = self.redis_client.zcard(key) self.redis_client.expire(key, 60) if count > 30: return HttpResponseTooManyRequests("请求过于频繁") return None

这段逻辑是:把当前请求的时间戳加入一个以IP命名的ZSET,然后移除30秒之前的旧记录,剩下集合大小就是最近30秒内请求总数。ZSET和INCR相比更精确,但数据量更大。我在实际项目中,高成本接口(比如导出、删除大量对象)会用滑动窗口,普通查询接口用固定窗口就足够了。

顺带一提,热词里反复出现“django执行查询-删除对象”,如果一个接口要批量删除对象,通常是比较重的操作,我一般会单独给这类接口设置更严格的独立限流规则。最简单的做法是在中间件里按路径前缀区分阈值:

DELETE_LIMIT_MAP = { "/api/bulk-delete/": 5, "/api/export/": 3, } def process_request(self, request): ip = self.get_client_ip(request) limit = DELETE_LIMIT_MAP.get(request.path, 30) ...

这样能防止有人拿脚本对昂贵的删除接口做高频调用,带来的资源消耗比普通页面大得多。

3.3 在真实请求中验证效果

写完中间件,别急着上线,先在本地验证。可以在项目根目录跑开发服务器,然后用一个简单的循环发起请求:

for i in $(seq 1 35); do curl -s -o /dev/null -w "%{http_code}\n" -H "X-Forwarded-For: 1.2.3.4" http://127.0.0.1:8000/ done

观察到前30次返回200,后面开始出现429,说明限流生效。如果你用的是Django测试客户端,也可以这样模拟:

from django.test import Client c = Client(REMOTE_ADDR="10.0.0.1") for _ in range(31): resp = c.get("/") assert resp.status_code == 429

验证时有几个细节要注意。第一,新增了中间件后,有时候开发服务器的自动重载不会触发,最稳妥的办法是手动重启一次。第二,拿到客户端IP时,不要一看到X-Forwarded-For就直接取第一个值,这个Header是客户端可以伪造的。如果你没有控制好自己的反向代理,最安全的方式是直接用REMOTE_ADDR,或者让Nginx清洗X-Forwarded-For后再传给Django。第三,中间件里连Redis要记得加超时和异常兜底,否则Redis一抖动,整个接口就全挂了。这个后面会再强调。

4. 中间件没生效?一份从现象到根因的排查清单

4.1 最常见的坑:注册位置、返回值、进程缓存

写了中间件但不生效,是新手最容易崩溃的场景。我见过的坑基本集中在下面这几类:

  • 忘记在MIDDLEWARE里注册。光在middleware.py里写了类,但settings.py没更新,那Django根本不会加载它。
  • 注册路径写错。common.middleware.RequestLogMiddleware是全路径,少写了middleware或者类名大小写不对,启动时就报错。
  • 方法签名错误。process_request(self, request)和process_response(self, request, response)的参数数量有严格规定,多写一个参数就会报TypeError。
  • process_response没有返回HttpResponse。很多人写完中间件随手return None,Django直接报错。
  • 开发服务器没有重启。新增文件或修改settings.py时,runserver有时不会自动重新加载,导致你改了代码但线上还是旧逻辑。
  • 中间件被前面的中间件短路了。这个最有迷惑性。某个前置中间件返回了HttpResponse,后面的process_request根本没有机会运行,你会误以为是自己的代码写错了。

前几个坑看报错信息就能定位,最后一个往往让人抓破脑袋。

4.2 完整的排查链路复现

如果你的中间件始终不执行,不要猜,用最笨也最有效的办法:打点。

先在项目里写一个临时调试中间件:

# common/middleware.py class DebugOrderMiddleware: def __init__(self, get_response): self.get_response = get_response print(f"初始化 {self.__class__.__name__}") def __call__(self, request): print(f"进入 {self.__class__.__name__}") response = self.get_response(request) print(f"离开 {self.__class__.__name__}") return response

把它临时加到MIDDLEWARE列表的第一位:

MIDDLEWARE = [ "common.middleware.DebugOrderMiddleware", "django.middleware.security.SecurityMiddleware", ... ]

启动项目访问任意页面,观察控制台输出顺序。你最少能看到“进入”和“离开”各一条。如果连“进入”都没打印,那说明中间件压根没被加载,优先检查注册路径和项目进程是否重启。如果能看到“进入”但看不到“离开”,那说明响应阶段出了问题,极大可能是某个后续中间件在process_response里抛异常了。

接下来逐步把其他中间件加回去,直到复现问题。大多数情况下,你会定位到某个中间件在process_request阶段返回了HttpResponse或直接Redirect,导致你的中间件排在后面自然收不到请求。

还有一个常见场景:你想用中间件做用户登录校验,但把它放在AuthenticationMiddleware之前,这时request.user虽然可以引用,却还没有被绑定到当前登录用户上。所以依赖用户状态的中间件,一定要注意它在MIDDLEWARE列表里的位置。

4.3 装饰器和中间件怎么选

很多人会纠结:同一个需求,用装饰器还是中间件?我的判断标准很简单:看这个逻辑是不是针对所有请求,还是要能和视图参数做互动。

中间件适合做全站横切逻辑,比如统一日志、统一限流、统一加响应头、登录状态检查。它的优势是注册一次全局生效,不怕漏挂。装饰器适合做和具体视图强相关的逻辑,比如某个接口要求必须有某个URL参数,或者要根据入参做动态校验。装饰器可以直接拿到视图函数的args和kwargs,这在中间件里是做不到的,因为中间件在路由匹配之前或之后,但拿不到视图函数的参数列表。

举个具体的例子:你要给/api/bulk-delete/接口做限流,如果希望在装饰器里解析request.POST里的目标对象列表大小,再决定限流阈值,用装饰器更方便;但如果你要对所有/api/开头的请求做IP限流,则用中间件更合适。用哪种不是对错问题,而是维护成本和健壮性之间的权衡。我刚写Django的时候喜欢到处挂装饰器,后来发现新加一个接口总会忘记挂,被线上事故教育了几次之后,凡是全局统一的逻辑一律往中间件里放。

5. 进阶实战:异常处理与响应标准化,顺便聊聊AI Agent场景

5.1 用process_exception统一处理删除对象时的数据库异常

写业务接口时,最烦的就是各种数据库异常。举个例子,你执行某个对象的delete()方法,对象可能已经被其他请求删掉了,也可能因为外键关联删不掉。如果不处理外层异常,用户会直接看到Django默认的500页面,对后端排查和前端体验都很不友好。

通过process_exception中间件,我们可以把异常统一转换成JSON响应:

# common/middleware.py import logging from django.core.exceptions import ObjectDoesNotExist from django.db import IntegrityError from django.http import JsonResponse logger = logging.getLogger(__name__) class ExceptionMiddleware: def __init__(self, get_response): self.get_response = get_response def process_exception(self, request, exception): if isinstance(exception, ObjectDoesNotExist): return JsonResponse({"error": "对象不存在"}, status=404) if isinstance(exception, IntegrityError): return JsonResponse({"error": "数据完整性错误"}, status=400) logger.exception(exception) return None

注意process_exception返回None时,Django会把这个异常继续往外抛,最终还是会走默认500页面。所以这里只处理你能预期的异常;处理不了的,要么return None放行,要么在上层再做统一兜底。我当时在一个批量删除接口里配合这个中间件用,用户请求删除一个已经被清掉的用户对象时,前端不再看到一大段HTML乱码,而是拿到一个清晰的404 JSON。这个体验提升非常明显。

还有一个细节:异常处理中间件在MIDDLEWARE里的顺序影响了谁先收到异常。异常传播方向是从后往前,也就是说越靠后的中间件越先知道异常。如果你希望某个异常处理中间件能兜住所有视图异常,把它放在列表末尾即可。

5.2 用中间件给外部调用方(AI Agent)返回统一结构

最近很多人开始用AI Agent去调用Django后端接口。Agent不像前端工程师那样能接受一个不规范JSON,它对返回结构的稳定性要求很高。如果你准备把自己的Django服务作为工具开放给Agent调用,建议在中间件层做一次响应标准化。

思路很简单:在process_response里判断请求路径,只处理/api/开头的接口,然后检查响应类型。如果已经是JsonResponse,直接放行;如果不是,就包成一个固定格式的JSON。

# common/middleware.py from django.http import JsonResponse, HttpResponse class AgentResponseMiddleware: def process_response(self, request, response): if not request.path.startswith("/api/"): return response if isinstance(response, JsonResponse): return response if response.status_code == 200: content = response.content.decode("utf-8") if isinstance(response, HttpResponse) else "" return JsonResponse({"code": 0, "data": content, "msg": ""}) return JsonResponse( {"code": response.status_code, "data": None, "msg": "请求失败"}, status=response.status_code, )

这个中间件很有用,但也要注意它的局限。第一,绝对不要对文件下载、流式响应做包装,否则会把二进制内容塞进JSON里。所以我在代码里可以加一个跳过条件,比如检测到Content-Disposition头就直接return response。第二,如果你的接口返回的是HTML片段,包装之前要三思,Agent消费的到底是HTML还是JSON。最稳妥的做法是一开始就让API接口都返回JsonResponse,这个中间件只是兜底。

在用AI Agent开发的团队里,中间件还能承担一个隐藏职责:给Agent的调试请求注入跟踪ID。你可以在process_request阶段生成一个request.request_id放到请求对象上,再在process_response阶段通过响应头X-Request-ID返回给调用方。这样Agent在执行多步操作时,哪一步出了问题,通过ID就能精准定位日志。

5.3 经验:中间件别写太厚,别做重业务

我在生产项目里见过有人把限流、登录、权限、日志、缓存、数据埋点全都堆进一个中间件里,结果每次请求都要查好几张表,接口平均耗时被拖到几百毫秒。中间件的核心价值是横切,不是业务编排。做日志就只做日志,做限流就只做限流,中间件之间保持单一职责,维护起来会轻松很多。

还有个很实际的心得:中间件里能不用ORM查询就尽量不要用。因为每个请求都会经过中间件,一次不必要的数据库查询会被放大到全站所有请求上。比如想用中间件判断“这个用户是不是VIP”,你应该在请求里设置好request.user后,在视图或服务层处理,而不是在中间件里反复查表。

另外,所有外部依赖都要做容错。限流中间件依赖Redis,如果Redis突然挂了,你不想让正常用户也因为中间件异常而打不开网站。我一般的处理是:

try: count = self.redis_client.incr(key) except redis.RedisError: return None

return None表示什么都不做,把请求放行到下一个中间件。这样即使Redis故障,也只是暂时丢失限流能力,不会拖垮主业务流程。这点在正式环境里非常重要。

如果你打算在项目里实践中间件,我的建议是先从一个日志中间件开始,观察调用顺序和日志输出,再逐步加上限流、异常处理。每加一个中间件都用第4章的调试方法跑一遍,确认调用顺序符合预期,再继续下一个。中间件是Django里少有的“写起来简单、排错复杂”的组件,但只要把顺序和返回值这两件事想清楚,后面基本就能玩转了。

返回列表