第一次接触 Django REST framework 的人,十有八九会被 Renderers 渲染器绕晕。明明视图里写的是return Response({...}),结果浏览器打开是一个可交互的调试页面,用 requests 请求拿到的又是 JSON,同一个接口凭什么能自动切换格式?答案就在渲染器这套机制里。
我把 DRF 的渲染器读了又用、用了又踩坑之后,才彻底搞清楚它到底管到哪一步、怎么配、什么时候需要自己写一个。这篇文章按我自己的理解把整条链路拆开讲一遍,从内部机制到配置方式,再到自定义渲染器实战和几个容易踩的坑,适合刚接触 DRF 或者对渲染器一知半解的读者。
1. 别被名字骗了:渲染器管的是“序列化之后、传输之前”的那段
1.1 先分清三个容易混淆的组件
DRF 里有三个组件名字长得很像,职责却完全不同:序列化器 Serializer、渲染器 Renderer、解析器 Parser。很多同学把渲染器当成序列化器用,或者以为渲染器能把 ORM 对象直接变成 JSON,这些理解都会在动手改造接口时出问题。
- 序列化器 Serializer:把 User 对象、QuerySet 转换成普通的数据类型(dict、list、字符串),核心工作是字段映射、校验、规范化。它解决的是“Python 对象怎么变成 JSON 能放进去的结构”。
- 渲染器 Renderer:把序列化器输出的 data,在 HTTP 响应阶段变成真正的字节流(bytes),同时决定 Content-Type 是什么。它解决的是“这份数据以什么面孔出现在网络上”。
- 解析器 Parser:是渲染器的反向过程,把请求体里的 JSON/表单数据解析成 dict,再交给序列化器校验。
一句话区分:Serializer 做数据清洗,Parser 管请求进门,Renderer 管响应出门。渲染器并不负责把 QuerySet 变成 JSON,这件事在序列化器阶段已经做完了;渲染器只是拿着已经“洗干净”的数据,按照你要求的格式打包发出去。
1.2 渲染器的输入输出长什么样
DRF 的渲染器都继承自BaseRenderer,核心方法只有几个。你自己写一个最简渲染器时,实现下面这个render方法就够了:
from rest_framework.renderers import BaseRenderer class MyRenderer(BaseRenderer): media_type = 'text/plain' format = 'txt' def render(self, data, accepted_media_type=None, renderer_context=None): return str(data).encode('utf-8')这里有个非常关键的点:render方法收到的data参数,是视图返回的Response里携带的数据。如果视图老老实实返回了serializer.data,那data就是已经规范化过的 dict 或 list;如果视图偷懒直接返回 QuerySet 甚至模型对象,那渲染器能不能处理就得看它自己的本事了。
JSONRenderer的内部实现大致是这样的思路:用json.dumps把 data 变成字符串,再按charset编码成 bytes。它之所以能处理部分非标准类型,是因为 DRF 给它配了一个扩展的 JSONEncoder,能对付 Date、Decimal、UUID 等类型加几个常见的场景。但记住,自定义渲染器没有这个默认加成,遇到非基础类型时,最好在视图层就把数据洗干净,让渲染器专心做格式转换。
2. 渲染链路拆解:从视图 return 到 Response 字节流的完整旅程
2.1 关键选择发生在 finalize_response
先回顾一个最简单的 DRF 视图执行流程。APIView 的 dispatch 大体做三件事:先做initial完成用户认证、权限、限流检查;然后调用具体 handler(比如get)拿到一个 Response 对象;最后进入finalize_response对 Response 做最后的加工。
内容协商和渲染器选择,就发生在finalize_response这一步。DRF 在这个阶段会拿着视图上登记的 renderer_classes,结合请求信息做一次内容协商,从中选出一个最合适的渲染器,然后把它绑定到 Response 上。大致逻辑是这样的:
# DRF 源码里的关键动作,做了删减便于理解 def finalize_response(self, request, response, *args, **kwargs): # 1. 获取渲染器集合 renderers = self.get_renderers() # 2. 内容协商选择渲染器 content_negotiator = self.get_content_negotiator() accepted_renderer, accepted_media_type = content_negotiator.select_renderer( request, renderers ) # 3. 把选中的渲染器绑定到 Response 上 response.accepted_renderer = accepted_renderer response.accepted_media_type = accepted_media_type # 4. 构建渲染上下文,后续 render 会用到 response.renderer_context = self.get_renderer_context() return response注意这只针对 DRF 自己的Response,不是 Django 原生的HttpResponse。如果你在视图里返回的是HttpResponse或者JsonResponse,DRF 不干预,原样放行,内容协商和渲染器都轮不到它干活。这其实是很多人写混的一个点:原生 Django 的 JsonResponse 是自己在视图里把数据转成 JSON 的,DRF 的 Response 则把这个职责推迟到了渲染器阶段。
2.2 Response.render() 怎么把选择变成实际内容
DRF 的 Response 是 Django TemplateResponse 的子类,这意味着它会搭上 Django 响应机制里“模板渲染”这趟车。在请求处理收尾时,Django 会对 TemplateResponse 调用render(),把响应内容真正生成出来。DRF 重写了这个方法,所以当你对 Response 调用render()时,实际发生的是:
def render(self): # 如果已经有 rendering 结果,直接返回 if self._is_rendered: return self.rendered_content # 调用渲染器,把 data 变成 bytes ret = self.accepted_renderer.render( data=self.data, accepted_media_type=self.accepted_media_type, renderer_context=self.renderer_context, ) # 再交给 Django 的 Content-type 处理逻辑 self.rendered_content = ret self['Content-Type'] = self.accepted_media_type self._is_rendered = True return self所以整条链路可以这样概括:视图拿到序列化好的 data,包进 Response;finalize_response 阶段内容协商选中一个渲染器,把它绑定到 Response 上;Django 响应机制触发 Response.render();渲染器的 render 方法把 data 变成字节流;最后 Django 把这段字节流发回客户端。
打个比方:序列化器是把馅料切好装盘,渲染器是选择外带打包盒和标签纸。同一个菜,你用塑料盒装就是快餐外卖,用漂亮的礼盒装就成了伴手礼。客户端最终拿到的“包装样式”,完全由渲染器决定。
3. 换格式的三种“指挥”方式:内容协商究竟听谁的
3.1 URL 后缀和 format 参数:显式指定优先级最高
客户端想让接口返回特定格式,最常见也最明确的方式是在 URL 上直接写:/api/users.json或者/api/users?format=csv。这种形式优先级最高,因为它是显式指定,不需要服务端猜。
DRF 对 URL 后缀的支持来自format_suffix_patterns,用起来非常简单:
from django.urls import path from rest_framework.urlpatterns import format_suffix_patterns urlpatterns = format_suffix_patterns([ path('users/', views.UserList.as_view()), path('users/<int:pk>/', views.UserDetail.as_view()), ])加上这层包装后,/api/users.json会命中同一个视图,同时视图的format会被解析成'json'。内容协商时,DRF 会优先根据这个 format 值去匹配视图的 renderer_classes 里 format 属性相同的渲染器。如果没有匹配的渲染器,协商就会失败。
如果没配format_suffix_patterns,也可以改用查询参数/api/users?format=csv。DRF 的内容协商机制依旧会先从request.query_params.get('format')里读取,只要渲染器列表里有 format 属性等于csv的渲染器,它就会被选中。同一个接口同时支持多种格式时,我一般优先推荐 URL 后缀,因为语义更清晰,也方便后续做接口版本规划。
3.2 Accept 头:隐藏在请求里的默认命令
大多数客户端不会在 URL 上写格式后缀,它们靠 HTTP 的Accept请求头告诉服务端自己想要什么。内容协商的兜底逻辑就是读Accept头,逐个尝试匹配 renderer_classes 里的 media_type。
打个比方,浏览器地址栏直接访问/api/users/时,浏览器默认带上的 Accept 头里有text/html,而且排在最前面,所以 DRF 会自动选中 media_type 为text/html的渲染器——默认配置下就是 BrowsableAPIRenderer,所以你看到的是一个调试页面而不是一串 JSON。
再用 requests 访问同一个地址,requests 默认 Accept 是*/*,不会优先匹配 text/html,内容协商时通常会按 renderer_classes 顺序选择第一个能匹配的渲染器。默认配置中 JSONRenderer 排在前面,所以拿到的是 JSON。
用 curl 显式指定想要的格式也可以:
curl -H "Accept: application/json" http://127.0.0.1:8000/api/users/这就是为什么同一个视图能“自动”输出不同格式的本质。协商优先级可以汇总成一张表:
| 优先级 | 依据 | 示例 | 说明 |
|---|---|---|---|
| 1 | URL 后缀 | /api/users.json | 最明确,不会产生歧义 |
| 2 | format 查询参数 | /api/users?format=csv | 显式指定,适合不便改 URL 的场景 |
| 3 | Accept 请求头 | Accept: text/csv | 依赖客户端声明,服务端尝试匹配 |
URL 后缀和查询参数同时出现时,DRF 源码里是先看后缀再看查询参数,不过正常场景没人会同时写两个,了解优先级顺序即可。
3.3 协商失败时会发生什么
如果客户端要求的格式在 renderer_classes 里根本不存在,内容协商会抛出NotAcceptable异常,最终返回 406 Not Acceptable。举个例子,你视图只配了 JSONRenderer,客户端偏要:
curl -H "Accept: text/xml" http://127.0.0.1:8000/api/users/DRF 会返回 406,响应体是 DRF 默认的异常格式:
{ "detail": "Could not satisfy the request Accept header" }很多新手第一次看到 406 就懵了,以为自己接口写错了。其实不是接口的错,是内容协商按规则拒绝了“它给不了的东西”。这个行为也提醒我们:接口设计时要先想清楚对外承诺支持哪些格式,别让客户端的 Accept 头和服务端的 renderer_classes 长期处于“一个要北一个往南”的状态。
4. 默认渲染器与配置引路:用最稳的方式配出多格式 API
4.1 全局配置与视图级覆盖
DRF 默认的渲染器配置在DEFAULT_RENDERER_CLASSES,你可以在 settings.py 里覆盖:
REST_FRAMEWORK = { 'DEFAULT_RENDERER_CLASSES': [ 'rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.BrowsableAPIRenderer', ], }这是全局配置,影响所有视图。但实际项目中,不同接口想要的格式往往不一样:列表页可以同时支持 JSON 和 CSV,上传接口只需要 JSON,某些内部接口甚至只需要自定义格式。这时候就要在视图级别覆盖了。APIView 里用renderer_classes属性:
from rest_framework.views import APIView from rest_framework.renderers import JSONRenderer class UserList(APIView): renderer_classes = [JSONRenderer]函数视图用装饰器:
from rest_framework.decorators import api_view, renderer_classes @api_view(['GET']) @renderer_classes([JSONRenderer]) def user_list(request): pass配置粒度从小到大有三种:全局配置、类属性、装饰器。具体落在哪个粒度,取决于你对“这个接口的格式边界”的定义。我的习惯是全局只留 JSON 和开发用的 Browsable,对内接口默认就这个组合;遇到确需导出的接口再单独挂上 CSV 或者其他自定义渲染器,避免全局配置把接口格式撑得太大。
4.2 DRF 自带渲染器各自的适用场景
DRF 内置的渲染器不算少,很多人只见过 JSONRenderer 和 BrowsableAPIRenderer。下面这张表把它们一次性列清楚:
| 渲染器 | media_type | format | 典型用途 |
|---|---|---|---|
| JSONRenderer | application/json | json | 最常用,API 默认响应格式 |
| BrowsableAPIRenderer | text/html | api | 浏览器调试页,开发期利器 |
| AdminRenderer | text/html | admin | 类 Django admin 的可视化界面 |
| HTMLFormRenderer | text/html | form | 只渲染表单,适合嵌入页面 |
| MultiPartRenderer | multipart/form-data | multipart | 处理文件上传类请求 |
| TemplateHTMLRenderer | text/html | html | 用 Django 模板渲染数据页面 |
| XMLRenderer | application/xml | xml | XML 输出,需要额外 XML 库支持 |
几个容易被误解的点:AdminRenderer 不是让你用 Django admin,它只是提供一种更像 admin 的后台式可视化表单;TemplateHTMLRenderer 适合那种一个接口直接渲染整页 HTML 的场景,但它返回的是页面而不是单纯的数据;XMLRenderer 默认配置里并不启用,因为 DRF 没有内置 XML 库,要真用得自己准备 XML 序列化库并做适配。
对绝大多数项目,生产环境用 JSONRenderer 就够撑起天下了。XML、CSV、PDF 这些格式如果客户端真有需求,优先通过自定义渲染器按需实现,而不是一股脑全堆到全局配置里。
5. 自建渲染器:从一个 CSV 导出需求说起
5.1 定义媒体类型与 format 标识
DRF 渲染器的扩展点非常清爽,一个自定义渲染器通常就是一个几十行的类。以导出 CSV 这个最经典的需求为例,先建一个renderers.py:
from rest_framework.renderers import BaseRenderer class CSVRenderer(BaseRenderer): media_type = 'text/csv' format = 'csv' def render(self, data, accepted_media_type=None, renderer_context=None): if data is None: return b'' rows = data.get('results', data) if isinstance(data, dict) else data if not rows: return b'' if isinstance(rows[0], dict): headers = list(rows[0].keys()) else: headers = None ...media_type用于内容协商时匹配 Accept 头,format用于匹配 URL 后缀和 format 查询参数。这两者是内容协商识别你这个渲染器的“身份证”,一个都不能少。render方法返回的是 bytes,DRF 会直接把它放到响应体里。
5.2 在 render 方法里组装字节流
接着把 render 方法补完整。组装 CSV 字符串不复杂,但有几个细节决定导出的文件好用不好用:
def render(self, data, accepted_media_type=None, renderer_context=None): if data is None: return b'' rows = data.get('results', data) if isinstance(data, dict) else data if not rows: return b'' first = rows[0] if isinstance(first, dict): headers = list(first.keys()) lines = [headers] for row in rows: lines.append([row.get(h, '') for h in headers]) else: lines = [['value']] + [[row] for row in rows] csv_text = '\n'.join(','.join(str(cell) for cell in row) for row in lines) response = renderer_context['response'] response['Content-Disposition'] = 'attachment; filename="export.csv"' return csv_text.encode('utf-8-sig')这里有两个非常值得记住的坑。第一个,Content-Disposition一定要在 render 方法里通过renderer_context['response']设置,而不是在视图里设置。因为视图返回 Response 后,DRF 的 finalize_response 会覆盖一部分响应头;你在 render 阶段改才来得及。
第二个,CSV 编码我用的是utf-8-sig而不是utf-8。原因很朴素:绝大多数用 Excel 打开 CSV 的用户,遇到 UTF-8 无 BOM 的文件会中文乱码;utf-8-sig会在文件头部写入 BOM 标记,Excel 就能正确识别编码。这个细节不亲自给需求方传几次文件,很难意识到它的重要性。
5.3 内容协商如何匹配自定义渲染器
自定义渲染器写好后,在视图上登记就能用:
from rest_framework.renderers import JSONRenderer class UserExportView(APIView): renderer_classes = [JSONRenderer, CSVRenderer]这样/api/users/export.csv会命中 CSVRenderer,普通请求/api/users/export/则默认返回 JSON,因为 JSONRenderer 在列表前面,而普通请求的 Accept 头没指定 text/csv 时,协商结果会落到第一个可用的 JSONRenderer 上。
如果想用查询参数触发,访问/api/users/export/?format=csv一样会走 CSVRenderer。这里我建议统一走 URL 后缀,因为?format=csv容易被业务参数混在一起,排查时会多一层干扰。
另外提醒一句:给 CSV 渲染器配Content-Disposition: attachment后,浏览器直接访问该 URL 会触发文件下载而不是在内页打开,这是导出接口通常想要的效果。如果你只是想在浏览器里预览 CSV 内容,就别加这个响应头,改成默认text/csv展示就行。
6. Browsable API 是怎么“画”出来的
6.1 HTML 页面其实是渲染器的作品
BrowsableAPI 是 DRF 极具辨识度的一个功能,很多初学者还以为它是某个中间件或者额外的调试工具。其实它就是一个渲染器:media_type 是text/html,format 是api。它的内部做法是:先把同一份 data 用 JSONRenderer 渲染成 JSON 字符串,再把这个字符串连同表单、请求选项一起塞进一个 HTML 模板,最后输出成一个完整页面。
所以你在浏览器里看到的那个能填表、能发请求、能看 JSON 的调试面板,本质是“页面型渲染器”对同一份数据做了另一层包装。这也解释了为什么同一个视图既可以输出 JSON,又可以输出调试页——两者只是选择了不同的渲染器而已。
在实际调试时,如果你想确认某个响应到底走的是哪个渲染器,可以在 renderer_context 里做文章。renderer_context 会带上 view、request、response、args、kwargs 这些信息,你在自定义渲染器里打印一下就能看到完整上下文:
def render(self, data, accepted_media_type=None, renderer_context=None): print(renderer_context['view'].__class__.__name__) print(renderer_context['request'])我在排查“为什么浏览器看到的是页面而接口返回的是 JSON”这种问题时,就是靠这招定位的——无非是内容协商激活了不同的 renderer。
6.2 开发与生产环境如何取舍
BrowsableAPI 很好用,但生产环境一定不要保留。原因有三个:第一,调试页面会暴露接口字段结构、OPTIONS 信息、表单选项,对正经业务来说这些信息没必要展示给调用方;第二,渲染 HTML 页面会有额外的模板渲染开销,虽然单个请求不大,但高并发下这成本毫无意义;第三,BrowsableAPI 页面自带可交互能力,等于把一部分调试入口暴露给了网络访客,怎么想都不稳妥。
生产配置我通常收敛成极简组合:
REST_FRAMEWORK = { 'DEFAULT_RENDERER_CLASSES': [ 'rest_framework.renderers.JSONRenderer', ], }开发环境则可以放心用默认配置,保留 HTML 调试页能大幅提升调试速度。用环境变量区分配置是最简单的做法,settings 里写个判断即可。
7. 实战中绕不开的边界场景
7.1 大文件导出:流式渲染与分页干扰
自定义渲染器虽然灵活,但它有个软肋:走 DRF 的 Response 时,渲染结果是在内存里一次性生成并返回的。CSV 数据量大到十几万行时,整块 CSV 字符串在内存里占用的空间相当可观,一不小心就把接口拖垮。
大型导出我建议绕开渲染器,直接用 Django 原生的 StreamingHttpResponse 做流式输出:
import csv from django.http import StreamingHttpResponse def csv_export(request): response = StreamingHttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="users.csv"' writer = csv.writer(response) writer.writerow(['id', 'name', 'email']) for user in User.objects.iterator(): writer.writerow([user.id, user.name, user.email]) return response流式写入的要点是生产者逐行取数,客户端逐块接收,内存占用不随数据总量线性增长。多用户导出场景下,这个方案的稳定性远高于“先拼完整 CSV 再返回”。
另一个容易被忽略的坑是分页。如果导出视图继承了带分页的 ListAPIView,那么视图传给渲染器的 data 是分页外壳,也就是{count, next, previous, results}这样的结构。如果你直接拿 data 做 CSV,第一行就会变成count、next这些字段。上面自定义渲染器里我用data.get('results', data)就是为了剥离这层外壳,这也是做导出类渲染器时必要的一步。
7.2 异常响应也会走渲染器
视图抛出校验错误、权限错误时,DRF 的 exception_handler 会生成一个异常 Response。关键是这个 Response 在 finalize_response 阶段同样会做内容协商,也就是说异常响应也会被渲染器包装。
这就带来一个非常隐蔽的坑:如果你的视图只挂了 BrowsableAPIRenderer 和 CSVRenderer,客户端用 Accept: application/json 请求这个接口,正常返回没问题,但如果接口抛异常,内容协商找不到 JSONRenderer,客户端拿到的很可能是 406 而不是预期的错误 JSON。
我第一次遇到这个现象时排查了很久,最后才意识到是渲染器列表把 JSONRenderer 挤掉了。从那以后,凡是自定义渲染器多的项目,我在全局配置或视图配置上都会保留 JSONRenderer 作为兜底,确保异常响应在任意 Accept 组合下都能稳定输出 JSON 错误信息。这是渲染器相关坑里性价比最高的一条经验。
7.3 换掉 JSONRenderer 用 orjson 的注意点
追求 JSON 序列化性能的项目会考虑用 orjson 替换默认 JSONRenderer。自定义方式很简单:
import orjson from rest_framework.renderers import JSONRenderer class ORJSONRenderer(JSONRenderer): media_type = 'application/json' format = 'json' def render(self, data, accepted_media_type=None, renderer_context=None): return orjson.dumps(data)但这里有个大坑:如果视图直接返回模型对象或者包含 datetime、Decimal、UUID 的 dict,orjson 会直接抛TypeError,因为 orjson 默认不做栈外类型转换。默认 JSONRenderer 不会报错,是因为 DRF 的 JSONEncoder 兜了底。
解决方案有两个。最推荐的是规则化视图层:保持“视图返回 serializer.data”的好习惯,在序列化器输出阶段就把类型洗干净;如果实在要处理原始数据,那就给 orjson 配置一个转换函数:
import orjson from datetime import date, datetime from decimal import Decimal from uuid import UUID def default_converter(value): if isinstance(value, (datetime, date)): return value.isoformat() if isinstance(value, Decimal): return str(value) if isinstance(value, UUID): return str(value) raise TypeError class ORJSONRenderer(JSONRenderer): media_type = 'application/json' format = 'json' def render(self, data, accepted_media_type=None, renderer_context=None): return orjson.dumps(data, default=default_converter)换 orjson 前最好用接口自测脚本把常见返回结构都打一遍,确认数据边界。只测 happy path 就上生产,迟早会有某个字段在某个分支里悄悄引入非标准类型,到时候线上报错比本地复现要痛苦得多。
我自己在项目里把 JSON、CSV 几种渲染器混用之后,才真正理解 DRF 渲染器存在的意义——它把“数据怎么呈现”这件事从视图逻辑里完全拆了出来。以后业务方再提“还要加一个 Excel 导出”,我只需要新增一个 Renderer 类,视图代码一行都不用改。这个内核设计带来的维护体验,比在视图里写一堆 if format == 'xlsx' 舒服太多了。