在编写单元测试时,发现请求 POST /api/ruleset/{id}/import/
就可以,但带上参数 ?format=excel&clear=true
就返回404,这是怎么回事呢?
问题
在单位写代码,编写了一个API,想使用单元测试验证一下功能。技术栈是 Django+DjangoRestFramework,其他的单元测试都通过了,但是到最后一步,将这些代码组合在一起,对整个API进行测试时,就出问题了!
这个API主要的功能是,接受用户上传的一个Excel文件,然后执行导入操作。format
参数来表明上传文件的格式,提供多种格式的导入;clear
表示是否清除已有数据。
一切准备就绪,开始写单元测试。用的django自带的TestCase
来测试。问题就在此时产生了。
测试方案是,我先准备10000条数据,通过pandas.DataFrame
导出成Excel文件,然后再通过API上传这个文件来导入,检查导入是否成功,不同规则套是否隔离,性能是否良好,等等。
但意外总是来得这么快。还没开始大展拳脚呢,导入的步骤就出错了:
from rest_framework.test import APIClient
self.client = APIClient()
...
response = self.client.post(
api_url("/ruleset", pk=1, action="import"),
query_params={"format": "excel", "clear": "true"}
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
debug发现,实际返回的是404,这是怎么回事呢?
...
AssertionError: 404 != 200
通过 pdb
在 assert 前进入断点,查看 response
的属性,输出具体的返回内容:
> response.json()
{"detail":"Not found."}
好家伙,这啥玩意儿啊,这么模糊的错误,连个说明都没有,这咋排查啊???心中一万匹草泥马跑过,要是给我揪出来这个始作俑者,看我怎么把锅拍到他脸上!
排查过程
1. 是测试数据的问题吗?
难道是因为id=1的数据不存在,导致返回404?
要排查这一点很简单,在前面再加一个请求,获取对应的数据就可以了:
response = self.client.get(api_url("/ruleset", pk=1))
self.assertEqual(response.status_code, status.HTTP_200_OK)
跑一下,发现没问题,这条数据是存在的。
而且就算不存在,DRF框架会直接告诉你是哪个数据找不到:
{"detail": "No Ruleset matches the given query."}
这个信息可比一个”Not Found。”要详细多了。排除。
2. 是框架的问题吗?
这个排查就比较麻烦了。
2.1 换个请求方法可以吗?
我先尝试一下,允许通过GET方法请求这个API,试一下能不能复现问题:
- 通过GET方式请求,还是404
- 通过GET方式,硬编码URL参数请求,还是404
- 通过GET方式,去掉URL参数请求,返回200!
- 通过POST方式,去掉URL参数来请求,返回200!
这就基本可以确定是参数问题了。为什么加个参数就会导致404呢?是因为路由没有匹配上吗?
2.2 是URL配置有问题吗?
由于这个工程最近进行了一次解耦,将各个模块拆开了,继而将URL路由也分割成了一块一块的。通过include的方式来包含各个子模块的路由。因此也不排除有这方面的问题。
举个例子,之前的方式是:
# project/urls.py
from rest_framework.routers import DefaultRouter
import module_a.views as module_a_views
import module_b.views as module_b_views
router = DefaultRouter()
router.register('module-a/endpoint-1', module_a_views.Endpoint1ViewSet)
router.register('module-a/endpoint-2', module_a_views.Endpoint2ViewSet)
router.register('module-b/endpoint-1', module_b_views.Endpoint1ViewSet)
router.register('module-b/endpoint-2', module_b_views.Endpoint2ViewSet)
urlpatterns = router.urls
现在的方式是:
# project/urls.py
import module_a.urls as module_a_urls
import module_b.urls as module_b_urls
urlpatterns = [
path('module-a', include(module_a_urls)),
path('module-b', include(module_b_urls)),
]
# module_a/urls.py
from .views import Endpoint1ViewSet, Endpoint2ViewSet
router = DefaultRouter()
router.register('endpoint-1', Endpoint1ViewSet)
router.register('endpoint-2', Endpoint2ViewSet)
urlpatterns = router.urls
# module_b/urls.py 类似...
难道是DRF框架的问题,不能识别被include
的router,导致 URL dispatch 失败?怎么排查呢?
(1)进业务逻辑了吗?
在业务逻辑中提前返回一个致命错误,结果发现错误并没有触发。说明没进业务逻辑。
(2)框架输出什么日志了吗?
由于测试框架下运行时不会输出日志的,需要在settings
中添加以下内容,确保日志的开启:
LOGGING = {
'version': 1,
'handlers': {
'console':{
'level':'DEBUG',
'class':'logging.StreamHandler',
},
},
'loggers': {
'django.request': {
'handlers':['console'],
'propagate': True,
'level':'DEBUG',
}
},
}
可是,令人绝望的是这里打印的日志依然非常模糊:
Not Found: /ruleset/1/import/
(3)浅挖源码,请求是怎么过去的?
沿着调用栈层层挖掘,发现最终这个请求走到了WSGIRequest类中,通过看源码+debug发现,这个请求通过一层包装,直接发往了这个类,处理完毕后,同步返回的处理结果,并不走网络栈。
同时查看了WSGI相关的入参,发现URL、请求参数等数据确实正常抵达了WSGI处理器中。说明测试框架是没问题的。
通过对这部分源码的阅读,我还得出一个结论:由于不走网络栈,函数是以同步的方式调用,那么直接在业务代码内设置断点,测试时发送请求,是可以直接触发的。为了印证上述是参数导致问题发生的结论,我给业务代码内设置了断点,发现确实,如果不带参数就会触发断点,如果带参数,就不会触发,也印证了根本就没进业务代码的结论。
(4)是python环境问题吗?
事情发展到现在已经有点玄学了。本着从易到难的排查思路,先排查下python环境是否有问题吧。
既然现在在py3.12跑有问题,于是激活了py3.11的虚拟环境。跑一下,没有差异。
(5)最小代码案例能复现吗?
此时已经想去GitHub去提问了,于是开了一个新的django工程,编写了一个最小案例,也是报着试一试的心态,看看有没有问题。
现象有差异了!现在无论怎样都能命中业务代码!但是也有问题,业务代码中,我尝试将接收的URL参数打印出来,结果发现:
- 将URL参数硬编码到URL中去请求,一切正常
- 使用
query_params
带URL参数,有问题,所有的URL参数都无法获取到!
我不得不又开始怀疑测试框架是否有问题,可是之前已经排查过了啊,传到WSGI的请求都是正确的啊。
于是我开始找官方文档,看到了一行小字:
Changed in Django 5.1:
Thequery_params
parameter was added.
看下环境,哦,原来是py3.11版本,看下django版本,哦,原来是5.0.x,怪不得了。
于是切换到了py3.12版本的环境,django版本5.1.x。跑一下,啊,居然一切正常!
(6)从最小代码案例出发尝试复现问题
有了最小能正常运行的案例,就能轻松对比出和项目代码的区别。差异点主要有以下几个:
- RESTful URL的action名称不一样。项目代码用的 import,最小案例用的myaction
- 请求参数不一样,项目代码用的 format 和 clear,最小案例简单粗暴用的 q
- 具体业务代码不一样
那就逐个排查呗。
首先排查,难道是action用的import
是python关键字,DRF框架没处理好?——最小案例换成import
,没影响。
那请求参数替换一下。最小案例换成业务的 format
和 clear
。——炸了!
format
、clear
?难道这两个还有保留?于是一个一个试,最终确定,问题出在format
参数上。
所以,是URL问题、而且是URL参数的问题。
真相大白
通过查找DRF文档,原来format
是一个保留的参数:
Formats
By default, the API will return the format specified by the headers, which in the case of the browser is HTML. The format can be specified using?format=
in the request, so you can look at the raw JSON response in a browser by adding?format=json
to the URL. There are helpful extensions for viewing JSON in Firefox and Chrome.文档地址:https://www.django-rest-framework.org/topics/browsable-api/#formats
这不刚好和我们业务需要的参数冲突了么,猜测这就是根因所在了……但是我没有证据。
于是在经历艰苦的debug后,我逐渐摸清了DRF框架中各个组件的关系,并将目标锁定在了 negotiation.py
文件上。(源码链接)
具体的调用顺序如下:
views.py::APIView.initial
初始化这个视图,并开始perform_content_negotiation
- 默认为
DEFAULT_RENDERER_CLASSES
,包含json、api渲染器,一个渲染纯json数据,一个渲染html形式的api文档。 select_renderer
,选择正确的渲染器filter_renderers
,找到正确的渲染器
- 默认为
就是这个函数抛出了异常,内容如下:
def filter_renderers(self, renderers, format):
"""
If there is a '.json' style format suffix, filter the renderers
so that we only negotiation against those that accept that format.
"""
renderers = [renderer for renderer in renderers
if renderer.format == format]
if not renderers:
raise Http404
return renderers
可以看到,逻辑非常简单,就是暴力排查注册的所有renderers。但如果找不到,则直接返回404。
结合我们业务代码,我们format
参数给的是excel
,而默认只有 json
, api
可选。那自然就找不到对应的renderer了,自然就报404了。
解决方式
我们查看一下上面部分的代码,发现其实提供了一个覆盖功能(源码链接),只需在工程的 settings.py
中添加以下内容即可:
REST_FRAMEWORK = {
'URL_FORMAT_OVERRIDE': 'view-format',
}
这样就可以将默认的 ?format=json
改成 ?view-format=json
了,无需修改业务的设计文档了。
一些思考
对AI辅助代码开发的思考
在遇到这个问题时,我首先去找GPT问了下,但是GPT的回答牛头不对驴嘴。我尝试让它给出一些排查的方向,就开始列清单了,什么排查URL是否正确,排查测试代码是否编写正确云云。作为经验丰富的开发者,一眼就能看出这些都不是真正的根因。AI在这方面的能力还是弱了些。
最近很火的Cursor不知道能不能解决这个问题。这是直接把所有代码都embed了,就是不知道会不会将库代码也embed进去。如果是的话,说不定还真能解答这个问题。但是,还是要考虑到一个局限性,AI能接触到的工程信息非常局限,它虽然有大量的知识,但它只能根据我们提供的数据来进行生成。就算把DRF框架的所有源码都embed了,如果这个问题是由于cpython底层导致的呢?那AI也只能干瞪眼了。
所以,未来AI要在辅助代码开发上面做文章,还是得走AI+Agent的路线,让AI自己调试代码,自己发现并解决问题。而这在目前也比较困难。首先,需要解决AI与PC的交互问题。这个现在一些头部厂商已经开始做了,AI开始能操作PC了。但第二个问题才是关键。AI需要学习如何思考,思考的流程是怎样的。其实这也就是人们解决问题的方法的语料。AI通过人们提供的方法论,通过 Chain of Thought + Multi Player,说不定才能达到比较好的效果。
对框架设计的思考
我认为通过format这个URL参数来控制DRF行为、并且默认启用,不是一个明智的选择。因为format这个参数,与实际业务发生碰撞的概率实在太大了,也就是说,format在业务中还是比较常用的一个关键字。作为框架,应该要尽量避免这种设计。
我在公司的工作也是测试框架的设计和开发。在开发过程中我也遇到过一次类似的设计问题,现在想起来确实有点蠢哈哈,框架的一部分和另一部分打架了。
具体是这样的:在我的设计中,Service
是一个基本容器(受Laravel和SpringBoot启发),提供了Service间通讯、子任务管理等基本功能。然后在这些基本容器上,再进一步构建出TestTask
, TestCase
这种测试相关的任务功能。但是,冲突产生了。我一方面想通过 service_instance.id 管理和登记基本容器,通过 ServiceManager.get(id)
方式获取这些容器,另一方面想通过 testcase.id
管理测试用例。结果可想而知,testcase.id
在继承和组合时覆盖了 service_instance.id
,导致了一些问题。
所以,我想,在框架设计中,对于一些底层的参数,还是需要特别对待一下,不然通过继承或组合,组件之间、或者框架与业务,可能就会产生冲突。框架要尽量避开业务常用的命名。
对代码可维护性的思考
我认为DRF在format未找到不存在时直接抛出404,并不加以说明的行为也是不妥当的。我认为框架抛出的异常信息应该越详细越好,否则开发者在使用过程中,很难区分到底是自己的业务代码问题,还是其他什么问题。
无论在公司内部还是外部,普遍提倡编写高质量代码思想。比如说可读性、可靠性、可维护性、可测试性。这种影响问题定位的就是属于可维护性的问题。
对协议分层的思考
按理来说,这种要求服务器返回哪种格式的内容,应该放在HTTP请求头中。比如我要求以json形式返回,那就应该添加个 Accept: application/json
的请求头,而不是放在query参数中。
当然了,关于这一点,DRF文档中提到了,是为了方便大家在浏览器中直接阅读原始json数据,才做了这一功能。好吧,关于这一点我也不想评价什么,那作者说是就是吧,哈哈。
对文档阅读和编写的思考
其实关于这篇文章提到的问题,作者在文档中是有说明,但是全部放在Settings那一章说明的。但说实话,哪个开发的能通篇熟读文档呢?都是边看边写的,因此文档的组织也很重要。
就拿这个例子来说,我定位到是format
参数的问题,也在文档中搜索到了这个章节,但是,这个章节却只是提了一嘴,知会有这个特性的存在。而我还是阅读源码才找到这个format
是可配置的。没想到Settings里面居然会说明。
所以,文档的组织也挺重要的,我认为可以将Settings对应内容链接到Format章节,将相关的内容提供给查阅者参考。同时,这样组织文档,还能方便RAG检索。毕竟现在大模型上下文联系还是比较有限的,将相似的内容从源头上就给聚一下,在大模型微调上也比较有帮助。
后续
这种问题自然是想推动作者去修改的,按照作者在“如何贡献”一栏的描述,作者认为DRF框架已经比较完善,如果有新需求,需要先发讨论,大家认为是个问题,再发issue或提供PR。那就发一个呗:
https://github.com/encode/django-rest-framework/discussions/9591
甚至我的PR也准备好了,方案就是改成 NotAcceptable
错误。fork仓库: