多彩编程 多彩编程MZPH · CODE BLOG
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_responseDjango中间件之所以灵活是因为它定义了一套钩子。传统写法里一个中间件类可以实现以下这些方法每个方法负责一个阶段钩子调用时机返回值影响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 responseDjango会在控制台甩出异常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( hostsettings.REDIS_HOST, portsettings.REDIS_PORT, dbgetattr(settings, REDIS_DB, 0), decode_responsesTrue, ) 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 fratelimit:{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 fsliding:{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_ADDR10.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 NoneDjango直接报错。开发服务器没有重启。新增文件或修改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: 对象不存在}, status404) if isinstance(exception, IntegrityError): return JsonResponse({error: 数据完整性错误}, status400) 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: 请求失败}, statusresponse.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 Nonereturn None表示什么都不做把请求放行到下一个中间件。这样即使Redis故障也只是暂时丢失限流能力不会拖垮主业务流程。这点在正式环境里非常重要。如果你打算在项目里实践中间件我的建议是先从一个日志中间件开始观察调用顺序和日志输出再逐步加上限流、异常处理。每加一个中间件都用第4章的调试方法跑一遍确认调用顺序符合预期再继续下一个。中间件是Django里少有的“写起来简单、排错复杂”的组件但只要把顺序和返回值这两件事想清楚后面基本就能玩转了。
返回列表