前段时间把一个老项目的API文档从手写Markdown切到drf-yasg2自动生成,前后端联调确实清爽了不少。但等我把Swagger页面打开给前端同事看的时候,对面抛来一句:“这接口名咋这么长?api_v1_users_list到底是啥意思?我该跟后端代码里哪个函数对应?”
确实,drf-yasg2生成的Swagger文档,默认展示给用户看的“接口名称”是系统自动拼出来的英文ID,比如api_v1_users_list、api_v1_users_partial_update这种。说是准确吧,它确实唯一;但要说友好,真的谈不上。后来我花了点时间把这个默认生成逻辑改掉了,让接口名称直接显示视图方法里的注释,前端看着中文注释就能找到对接的函数,整个联调效率高了不少。
这篇文章就围绕这个改造展开,先搞清楚drf-yasg2的接口名是从哪里来的,再给出一套可以直接抄的改造方案,最后说说我在这个过程中踩过的坑。适合正在用drf-yasg2做API文档、对Swagger默认命名不满意、想自己定制文档展示的后端开发参考。
1. 烦人的默认接口名:Swagger页面上那串英文是从哪来的
1.1 Swagger页面上“接口名称”到底是什么字段
先明确一个容易混淆的点:Swagger UI页面里,每个接口最上方、最显眼的那一行标题,对应的其实是OpenAPI规范里的operationId字段,不是summary也不是description。
drf-yasg2在生成OpenAPI schema的时候,会为每个接口生成一个operationId,这个ID默认是自动拼接出来的英文,一般长这样:
json复制{
"/api/v1/users/": {
"get": {
"operationId": "api_v1_users_list",
"summary": "获取用户列表",
"description": ""
}
}
}
这里有个特别容易误导人的地方:drf-yasg2其实已经会自动把视图方法的docstring提取出来,填到summary字段里。所以很多人在Swagger UI上看到接口名还是api_v1_users_list而不是“获取用户列表”,就以为是drf-yasg2没生效,其实是因为Swagger UI默认把operationId放在最显眼的标题位置,而summary只显示在标题下方的小字区域。
明白了这个机制,改造方向就很清楚了:要么改operationId生成逻辑,要么换一种方式让summary顶上去。我选的是前者,因为operationId除了显示之外,还是OpenAPI文档里接口的全局唯一标识,前端的代码生成工具、Mock工具都会用到它,把它改成可读的中文注释,价值更大。
1.2 drf-yasg2默认命名规则拆解
drf-yasg2的operationId默认命名逻辑,直接继承了Django REST Framework的SchemaGenerator,大致的规则可以拆成两层看。
第一层是“基础名”的生成。DRF框架会根据请求的URL路径解析出路径参数和视图类信息,再拼上当前要执行的具体操作(action),形成一个类似['api', 'v1', 'users', 'list']的列表。比如:
- 请求
GET /api/v1/users/,落在UserViewSet上,最终执行的是list方法,基础名就包含list - 请求
POST /api/v1/users/,最终执行的是create方法,基础名就包含create - 请求
GET /api/v1/users/{id}/,最终执行的是retrieve方法,基础名就包含retrieve
第二层是“最终ID”的格式化。drf-yasg2会把上面这个列表用下划线连接起来,得到像api_v1_users_list这样的完整operationId。
值得注意的是,如果你用的是普通的APIView而不是ViewSet,命名风格会不一样。APIView通常拿不到basename,所以生成的operationId往往基于视图类名和HTTP方法名,比如UserListGetView_get这种。这也是很多人觉得默认命名“不可控”的原因:同一个项目里,ViewSet和APIView两套命名风格并存,Swagger页面上看起来非常不统一。
1.3 为什么说“靠配置项”解决不了
drf-yasg2虽然提供了很多配置项(比如SWAGGER_SETTINGS里可以配DEFAULT_FIELD_INSPECTORS、DEFAULT_FILTER_INSPECTORS等),但唯独没有提供一个现成的“自定义接口名称”配置项。你没法通过一个settings参数直接把operationId替换成方法注释。
想达到这个效果,只能自己定义一个SchemaGenerator子类,覆盖里面的命名方法。这是drf-yasg2留给开发者的扩展口子,也是整篇文章的核心。不过在那之前,得先把生成链条里的关键位置摸清楚,不然很容易改错地方。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 定位生成链条:drf-yasg2在哪里产出接口名
2.1 SchemaGenerator是整个文档的“总装车间”
drf-yasg2生成文档的入口是OpenAPISchemaGenerator类,它继承了DRF自带的SchemaGenerator。整体工作流程大致是:
get_schema方法接收一个request对象,负责把整个OpenAPI文档的骨架搭出来,包括paths(路径)、components(数据模型)、tags(标签)等顶级节点。get_paths方法负责遍历URL配置,拿到所有的路由端点(endpoints),依次生成每个接口的Operation对象。get_operation方法负责把单个接口的请求参数、响应结构、分页逻辑、过滤逻辑等组装成完整的Operation对象。
这个“总装车间”里,get_paths是核心中的核心。它在遍历endpoints的时候,会调用_get_view方法把视图类实例化为视图对象,并设置好action属性,然后再进入单个接口的生成环节。
所以,get_paths这个方法就是理想的切入位置:它既有完整的路由信息(path、method),又拿到了视图对象,还有最终生成好的Operation对象,几乎可以在任何环节做手脚。
2.2 get_operation_id_base是命名的唯一入口
在DRF的SchemaGenerator里,特意留了一个专门用来生成“基础接口名”的方法,叫get_operation_id_base。它的签名是:
python复制def get_operation_id_base(self, path, method, view):
这个方法接收三个参数:
path:当前接口的URL路径,比如/api/v1/users/method:HTTP请求方法,比如get、postview:当前的视图对象,注意这里已经是实例化后的view,不是视图类
DRF默认实现会调用get_operation_keys,从路径里拆出一堆key,拼成基础名。drf-yasg2在这个基础上做了一点加工,最终生成我们看到的operationId。
为什么说get_operation_id_base是“唯一入口”?因为不管你怎么改其他配置,最终落到operationId上的名字,一定是从这个方法返回的字符串变来的。只要覆盖这个方法,就能从根本上控制接口名称的来源。这也意味着,我们不需要去修改get_paths这种大方法,只需要精准替换掉命名逻辑即可,改动面很小,回归风险也低。
另外,get_operation_id_base在DRF源码里本身就是一个设计给开发者覆写的扩展点。它的注释就写着“Compute the base name for an operation ID”,明确告诉你这是用来算基础名的,所以在这个方法上做定制,是既优雅又不会跟drf-yasg2内部逻辑打架的做法。
2.3 为什么在这里能拿到方法docstring
改造的核心诉求是“接口名称 = 方法注释”,所以关键在于:在这个方法里,我们能不能拿到当前视图方法(比如list、create、retrieve)以及它的docstring?
答案是可以的。正如前面说的,get_operation_id_base收到的view参数是实例化后的视图对象。对于ViewSet来说,drf-yasg2内部在调用到这里之前,已经通过DRF的action_map机制把action设置到了视图实例上。也就是说,在get_operation_id_base内部,你可以通过view.action拿到当前要执行的操作名。
拿到操作名之后,再用getattr(view, view.action)就能拿到对应的处理方法对象。比如view.action是list,那getattr(view, 'list')就是list方法。对普通APIView来说更简单,view.action可能不存在,那就直接用HTTP方法名小写来取,比如getattr(view, 'get')。
方法对象有了,docstring自然随手可得。用inspect.getdoc()来处理的话,它会自动帮你去掉缩进和多余空白,比直接访问__doc__更干净。
这个机制设计得比较巧:drf-yasg2内部本来就通过action来区分当前操作,我们只是把这层关系再利用一次,从“知道操作名”推进到“拿到操作方法”,再推进到“拿到方法注释”,每一步都有现成的接口支撑,不需要黑魔法。
3. 动手改造:让接口名直接显示方法注释
3.1 写一个自定义的SchemaGenerator
理清了上面的链条之后,实现就非常直白了。新建一个文件,比如custom_schema.py,放在某个app下,然后写一个继承OpenAPISchemaGenerator的类:
python复制import inspect
from drf_yasg.generators import OpenAPISchemaGenerator
class DocstringOperationIdSchemaGenerator(OpenAPISchemaGenerator):
"""把接口名称(operationId)改为视图方法的 docstring 第一行"""
def get_operation_id_base(self, path, method, view):
# 对于 ViewSet,drf-yasg 内部已经把 action 设置到 view 实例上
# 对于普通 APIView,action 不存在,直接用 HTTP 方法名小写
handler_name = getattr(view, 'action', None) or method.lower()
# 拿到实际执行的处理方法
handler = getattr(view, handler_name, None)
# 提取方法注释,inspect.getdoc 会自动清理缩进和多余空行
doc = inspect.getdoc(handler)
if doc:
# 取第一行,避免多行注释把文档里的标题撑得很难看
first_line = doc.strip().splitlines()[0]
return first_line
# 没有写注释的时候,退回默认的命名逻辑
return super().get_operation_id_base(path, method, view)
这段代码逻辑不复杂,核心就三步:
- 确定当前要执行的处理方法名。优先用
view.action(ViewSet场景),拿不到就退回HTTP方法名(APIView场景)。 - 用
getattr从视图实例上拿到真正的处理方法,再用inspect.getdoc提取注释。 - 如果注释存在,取第一行作为接口名;如果没写注释,就用drf-yasg2默认的命名逻辑兜底。
这里我特意用inspect.getdoc而不是直接访问__doc__,是因为inspect.getdoc会帮你去掉docstring里的统一缩进,还能清理掉首尾的空行。很多项目里方法注释写得比较随意,直接取__doc__可能会带出一堆缩进空格,在Swagger UI上看起来会很难受。
3.2 接入Django的两种方式
写好了自定义生成器,接下来要让它生效。有两种接入方式,看你当前项目的实际情况。
方式一:在settings里配置全局生效
如果你希望整个项目的所有接口都继承这个命名规则,就在settings.py的SWAGGER_SETTINGS里加上DEFAULT_GENERATOR_CLASS:
python复制SWAGGER_SETTINGS = {
'DEFAULT_GENERATOR_CLASS': 'myapp.custom_schema.DocstringOperationIdSchemaGenerator',
# 其他配置...
}
这里填的路径是“模块路径.类名”的字符串形式,Django会在启动时自动导入。这种方式的优点是全局统一,新增的接口也自动生效,不用每个地方都改。
方式二:在get_schema_view里指定
如果你只想部分接口用这个逻辑,或者你的项目里已经通过get_schema_view创建了schema视图,可以直接在调用时传入generator_class:
python复制from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from myapp.custom_schema import DocstringOperationIdSchemaGenerator
schema_view = get_schema_view(
openapi.Info(
title="My API",
default_version='v1',
),
public=True,
generator_class=DocstringOperationIdSchemaGenerator,
)
两种方式本质上是同一个东西,只是作用范围不同。如果你刚开始改造,我建议先用方式二在本地验证效果,确认没问题之后,再通过方式一全局铺开,这样排查问题会比较快。
3.3 实测效果
改完配置后,重启Django服务,刷新Swagger页面,效果立刻就不一样了。
这是我改造前看到的接口名称:
code复制api_v1_users_list
api_v1_users_create
api_v1_users_retrieve
api_v1_users_update
改造后变成了:
code复制获取用户列表
创建用户
获取用户详情
更新用户
前端同事再也不用在十几个api_v1_xxx里猜接口含义了,直接看Swagger页面上的中文标题就知道该调哪个。
对应的openapi.json里,operationId字段也从原来的英文串变成了中文注释:
json复制{
"/api/v1/users/": {
"get": {
"operationId": "获取用户列表"
}
}
}
这里要特别提醒一下:虽然Swagger UI能正常显示中文operationId,但OpenAPI规范本身并没有限制operationId必须用英文字符,所以生成出来的文档在结构上仍然是合法的。不过,如果你后续有对接代码生成工具(比如OpenAPI Generator、TypeScript的openapi-typescript等),最好先在目标工具上验证一下中文ID会不会导致生成异常。我在项目里实测过,Swagger UI展示完全没问题,但某些严格模式的代码生成器会对非标识符字符比较敏感,这种情况就需要结合自己的工具链来评估了。
3.4 没有注释时的兜底处理
上面的改造方案有一个隐含前提:视图方法得写了docstring。但实际项目里,总有漏网之鱼,尤其是一些临时的action,或者历史遗留的接口,很可能一个注释都没写。
如果doc为空,我的代码会走super().get_operation_id_base(),也就是drf-yasg2默认的命名逻辑。这个兜底很重要,至少保证接口名不会出现None或者空白字符串。OpenAPI规范里operationId是必填字段,如果返回None,drf-yasg2在生成文档时可能会报错或者生成非法JSON。
还有一种情况:docstring存在,但是第一行内容非常长。比如有人在方法注释里写了一大段描述,第一行就三四十个字。这种情况下,operationId会变得特别长,Swagger UI上的展示效果会不太美观。如果你在意这个,可以在返回前对第一行做一次长度截断:
python复制first_line = doc.strip().splitlines()[0]
if len(first_line) > 30:
first_line = first_line[:27] + '...'
return first_line
不过说实话,我更推荐的做法是规范团队的方法注释风格,把“一句话概述”写在第一行,详细描述放在换行之后。这样不仅改造后的operationId干净,drf-yasg2默认提取的summary也会更规范。截断逻辑说到底只是补救,不是正途。
4. 备选方案、去重策略与踩坑记录
4.1 备选方案:生成后统一修改Operation对象
get_operation_id_base方案虽然简洁,但有它的局限性:这个方法只负责生成基础名,drf-yasg2后续还可能对它做进一步格式化。如果你想要的不是“用docstring替换默认名”,而是“生成完所有接口后,再根据路由信息二次加工”,那就得换一个切入层。
这个备选方案的思路是:重写get_paths,在drf-yasg2生成完所有路径的Operation对象之后,再遍历一遍,把operation_id统一改成方法注释。
python复制class DocstringOperationIdSchemaGenerator(OpenAPISchemaGenerator):
def get_paths(self, endpoints, components, request, public, for_schema_generator=False):
paths = super().get_paths(endpoints, components, request, public, for_schema_generator)
doc_map = self._build_doc_map(endpoints)
for path, operations in paths.items():
for method, operation in operations.items():
doc = doc_map.get((path, method))
if doc:
operation.operation_id = doc
return paths
def _build_doc_map(self, endpoints):
"""提前遍历 endpoints,构建 path+method -> docstring 的映射"""
doc_map = {}
for path, method, view in endpoints:
if isinstance(view, type):
# 注意:这里的 view 是类,不是实例,需要手动实例化
view = view()
handler_name = getattr(view, 'action', None) or method.lower()
handler = getattr(view, handler_name, None)
doc = inspect.getdoc(handler)
if doc:
doc_map[(path, method.lower())] = doc.strip().splitlines()[0]
return doc_map
这个方案的问题在于:get_paths接收的endpoints列表里,view是视图类而不是实例,所以没法直接用view.action拿到操作名。上面代码里我只能先手动实例化。但实例化之后,action属性也不会自动设置,因为drf-yasg2内部的_get_view方法负责这个事。
一个可行办法是参考drf-yasg2内部的逻辑,自己模拟action的赋值过程:
python复制def _build_doc_map(self, endpoints):
doc_map = {}
for path, method, view in endpoints:
if isinstance(view, type):
view = view()
# 根据 HTTP method 推导 action
if hasattr(view, 'actions'):
action = view.actions.get(method.lower())
else:
action = method.lower()
handler_name = action or method.lower()
handler = getattr(view, handler_name, None)
doc = inspect.getdoc(handler)
if doc:
doc_map[(path, method.lower())] = doc.strip().splitlines()[0]
return doc_map
看到这套“推导action”的流程,你应该能感觉到:备选方案虽然在“拿到最终Operation对象后”这层更灵活,但代价是要自己处理视图实例化和action映射,逻辑明显更重。
4.2 两种方案怎么选
对比一下这两种方案:
| 对比维度 | 重写get_operation_id_base | 重写get_paths统一替换 |
|---|---|---|
| 改动范围 | 只影响接口名生成环节 | 影响整个路径生成流程,改动面大 |
| 实现复杂度 | 低,直接在方法里返回字符串即可 | 高,需要自己模拟action推导 |
| 是否能做全局去重 | 不行,看不到其他接口的情况 | 可以,所有接口都在paths里 |
| 是否能做二次加工 | 受限于drf-yasg2后续处理 | 可以,Operation对象在手,想改什么都行 |
| 适合场景 | 大部分项目,追求最小改动 | 需要复杂后处理的定制需求 |
从我的实际经验看,绝大部分项目选get_operation_id_base方案就够了。它改动量小、逻辑直白、回归风险低。只有当你有“多个接口注释撞车,需要自动加后缀去重”这类全局性需求时,才需要考虑get_paths方案,因为get_paths能看到所有接口,能判断重复并加工。
4.3 我最想提醒的几个坑
第一个坑:改了不生效。这个问题出现的概率非常高。因为你改了SchemaGenerator,但Django进程如果带着旧的schema缓存,刷新页面还是老样子。drf-yasg2本身没有把生成的文档写入数据库或文件缓存,但如果你用了django-cache-pages或者浏览器强缓存,情况就不一样了。排查方式:换个浏览器无痕窗口,或者直接确认DEFAULT_GENERATOR_CLASS路径字符串有没有写错。路径写错是最常见的低级错误,Django启动时可能会报错,也可能因为懒加载而没有立刻暴露。
第二个坑:docstring为空返回None。如果方法没写注释,而你的代码又忘了兜底,operationId就会变成None。之后drf-yasg2在序列化这个Operation对象时,通常会直接报错,而且错误信息可能不会直接指向operationId,而是报一些奇怪的KeyError。排查起来会比较痛苦。所以无论怎么改,一定要保留super()兜底。
第三个坑:中文operationId在某些swagger-ui版本下显示没问题,但导出OpenAPI JSON给其他工具时可能会有编码问题。我项目里用的是常见的swagger-ui版本,显示中文完全OK。但如果你把openapi.json交给其他后端工具解析,最好确认一下对方能否正确处理非ASCII字符的operationId。
第四个坑:ViewSet自定义action的命名。如果你在ViewSet里用@action装饰器扩展了自定义接口,比如:
python复制@action(detail=False, methods=['post'])
def batch_create(self, request):
"""批量创建用户"""
在默认命名规则下,这个接口的operationId会变成api_v1_users_batch_create。改造后,它会变成“批量创建用户”。这个逻辑对自定义action一样有效,因为view.action同样会被设置为batch_create,getattr(view, 'batch_create')也能取到方法对象。所以不用担心自定义接口会漏掉。
第五个坑:两个不同视图方法写了相同的第一行注释,导致operationId重复。比如UserViewSet.list和GroupViewSet.list都写了“获取列表”,那Swagger UI上就会出现两个operationId相同的接口。OpenAPI规范要求operationId全局唯一,虽然Swagger UI不一定立刻报错,但复制接口链接、定位锚点、代码生成时都可能出问题。我的建议是:改造时尽量保证第一行注释的措辞能区分接口,或者团队约定里强制要求注释包含对象名(比如“获取用户列表”“获取分组列表”)。如果你觉得人为约定不可靠,那就得在get_paths方案里做一次去重处理,给重复项追加action名或短hash。
5. 搞懂这一个点,文档定制能玩出更多花样
5.1 不妨顺手改一下summary和description
这次改造的入口是get_operation_id_base,但当你开始深入了解SchemaGenerator之后,会发现它其实给你留了一整排可定制的方法。
drf-yasg2在生成每个Operation时,summary字段默认取自方法docstring的第一行,description默认取自整个docstring。如果你觉得这个行为也不符合预期,同样可以覆写相关方法。
举个例子,有些团队的docstring第一行喜欢写获取用户列表 - 分页版这种带版本说明的句子,这种句子当operationId太啰嗦,但当summary却很合适。这时候可以在自定义生成器里加一个get_summary方法:
python复制def get_summary(self, path, method, view):
handler = getattr(view, getattr(view, 'action', None) or method.lower(), None)
doc = inspect.getdoc(handler)
if doc:
return doc.strip().splitlines()[0]
return super().get_summary(path, method, view)
这样summary和operationId虽然都来自docstring,但你可以分别控制它们的取值逻辑。甚至可以在operationId里加短横线、在summary里保留完整标题,让文档的每个展示位都刚好合适。
5.2 标签分组:让接口类别更清晰
Swagger UI左侧的接口分组,靠的是tags字段。drf-yasg2默认会按视图类名或URL前缀自动生成tags,但有时候不太符合业务划分。
如果你想按业务模块重新分组,可以在视图方法上用@swagger_auto_schema装饰器指定tags,但那样要逐个方法写,很啰嗦。更高效的做法是和这篇文章的思路一致:在自定义SchemaGenerator里重写tags的生成逻辑,统一给某类接口打上指定标签。
比如可以按ViewSet的basename来分组,让每个模块的接口天然聚在一起。这种改法和改operationId是同一个套路,都是在SchemaGenerator层做定制,一次改动全局生效。
5.3 一个通用方法论:围绕生成器做定制
这几件事放到一起看,你会发现一个通用方法论:drf-yasg2的OpenAPISchemaGenerator本质上是整个文档的“装修团队”,它把每个接口的URL、方法、视图、参数、响应、命名、分组全部组装成OpenAPI结构。你不需要去改drf-yasg2的源码,只需要继承它,然后覆写你想要的那几个方法。
这套方法论的核心是:先搞清楚你要改的东西,在哪个方法、哪个阶段、哪个对象上产生。就像这次改接口名,很多人第一反应是去翻drf-yasg2的配置项,翻半天发现没有;但一旦定位到get_operation_id_base,改动量其实很小。
我个人的体会是:对于有源码扩展点的开源库,与其堆配置,不如直接继承覆写。配置项只能覆盖框架作者预先设想的场景,而继承覆写能覆盖所有场景。drf-yasg2在这方面的设计还是比较优秀的,默认把SchemaGenerator里的方法拆得足够细,像get_operation_id_base、get_summary、get_tags这样的小方法都是可以单独覆写的,改造起来很顺手。
顺着这个思路延伸,你还可以继续做很多定制:比如把接口按@action装饰器上的自定义字段分组,或者在description里附带请求示例,甚至在文档里直接展示当前接口的缓存策略。只要理解了生成器的工作流程,这些都不难实现。
回到这次改造本身,说实话改动量并不大,一个类、一个方法、几行代码,但带来的体验提升是实实在在的。前端不再对着英文ID猜接口含义,后端也不用再费口舌解释api_v1_xxx对应哪个函数,文档本身的可用性上了一个台阶。如果你也在用drf-yasg2并且对默认接口名不满意,不妨按这篇文章的思路试一试,改完之后记得清一下缓存再刷新页面,剩下的就交给代码了。
