记DjangoRestFramework中的一个坑

在编写单元测试时,发现请求 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:
The query_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,没影响。

那请求参数替换一下。最小案例换成业务的 formatclear——炸了!

formatclear?难道这两个还有保留?于是一个一个试,最终确定,问题出在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仓库:

https://github.com/encode/django-rest-framework/compare/master…JourneyBean:django-rest-framework:query_format_error_message

发表评论