AberSheeran
Aber Sheeran

Django 解析非POST请求

起笔自
所属文集: Django-Simple-Api
共计 6632 个字符
落笔于

众所周知, Django的每个请求都有对应一个独立的request变量。这个变量在视图函数中使用时,有三个重要的属性GETPOSTFILES

其中POSTFILES是解析request.body得到的结果。当我们使用POST方法接收数据时,一切都很完美。然而当使用其他方法PUTPATCH时你就会发现这两个属性无论如何都是空!

为了解决这个问题,我们应该首先看看为什么会有这个问题。

源码分析

追溯到django.core.handlers.wsgi.WSGIRequest这个类中,我们可以看到如下代码

@cached_property
def GET(self):
    # The WSGI spec says 'QUERY_STRING' may be absent.
    raw_query_string = get_bytes_from_wsgi(self.environ, 'QUERY_STRING', '')
    return QueryDict(raw_query_string, encoding=self._encoding)

def _get_post(self):
    if not hasattr(self, '_post'):
        self._load_post_and_files()
    return self._post

def _set_post(self, post):
    self._post = post

@cached_property
def COOKIES(self):
    raw_cookie = get_str_from_wsgi(self.environ, 'HTTP_COOKIE', '')
    return parse_cookie(raw_cookie)

@property
def FILES(self):
    if not hasattr(self, '_files'):
        self._load_post_and_files()
    return self._files

POST = property(_get_post, _set_post)

FILESPOST原来都是要运行self._load_post_files函数之后才能取到结果。

那么接着追溯这个_load_post_files函数到django.http.request.HttpRequest能看到

def _load_post_and_files(self):
    """Populate self._post and self._files if the content-type is a form type"""
    if self.method != 'POST':
        self._post, self._files = QueryDict(encoding=self._encoding), MultiValueDict()
        return
    if self._read_started and not hasattr(self, '_body'):
        self._mark_post_parse_error()
        return

    if self.content_type == 'multipart/form-data':
        if hasattr(self, '_body'):
            # Use already read data
            data = BytesIO(self._body)
        else:
            data = self
        try:
            self._post, self._files = self.parse_file_upload(self.META, data)
        except MultiPartParserError:
            # An error occurred while parsing POST data. Since when
            # formatting the error the request handler might access
            # self.POST, set self._post and self._file to prevent
            # attempts to parse POST data again.
            # Mark that an error occurred. This allows self.__repr__ to
            # be explicit about it instead of simply representing an
            # empty POST
            self._mark_post_parse_error()
            raise
    elif self.content_type == 'application/x-www-form-urlencoded':
        self._post, self._files = QueryDict(self.body, encoding=self._encoding), MultiValueDict()
    else:
        self._post, self._files = QueryDict(encoding=self._encoding), MultiValueDict()

原来第一行就先判断了当前的请求方法是否是POST!不是POST就直接返回对象,不解析。

思考一下,我们该如何来解决这个问题。

Hack

或者直接把解析部分复制粘贴过来,我们自己解析是一个办法。但是这里面用了太多的内置函数,如果哪天Django悄无声息的修改了它们当中的任何一个,你的代码就失效了。

所以下面提供一个最少的使用内部函数的方法。采用了一个十分Hack的手段,欺骗函数帮助你解决这个问题。

from django.http import HttpResponseBadRequest
from django.utils.deprecation import MiddlewareMixin

class RequestParsingMiddleware(MiddlewareMixin):

    def process_request(self, request):
        if request.method not in ("GET", "POST"):
            if hasattr(request, '_post'):
                del request._post
                del request._files

            _shadow = request.method
            request.method = "POST"
            request._load_post_and_files()
            request.method = _shadow

使用的时候,只需要把这个中间件给加入项目当中即可。

另一个问题

Django是无法解析application/json格式的数据,往往前端话语权比较弱的小团队中,为了适应后端,不得不使用各种方法提交application/x-www-form-urlencoded格式的数据。

我们既然已经解决了非POST请求的数据解析,不如顺便把这个也解决了。

Django将请求体的原始数据放在request.body中,我们只需要使用json.loads(request.body)一条语句就可以解析好application/json格式的数据。

那么,修改一下上面的中间件

import json

from django.http import HttpResponseBadRequest
from django.utils.deprecation import MiddlewareMixin


class RequestParsingMiddleware(MiddlewareMixin):

    def process_request(self, request):
        request.JSON = None
        if request.content_type != "application/json":
            if request.method not in ("GET", "POST"):
                # if you want to know why do that,
                # read https://abersheeran.com/articles/Django-Parse-non-POST-Request/
                if hasattr(request, '_post'):
                    del request._post
                    del request._files

                _shadow = request.method
                request.method = "POST"
                request._load_post_and_files()
                request.method = _shadow
        else:
            try:
                request.JSON = json.loads(request.body)
            except ValueError as ve:
                return HttpResponseBadRequest("Unable to parse JSON data. Error: {0}".format(ve))

关于 TestClient

查看 Django 的测试部分的源码可以看到 PUTPATCHDELETE 的默认 content-type 都是 application/octet-stream,参考 MDN 可以得知此类型是一个未知的类型,在正常的请求里完全不会出现。

def put(self, path, data='', content_type='application/octet-stream',
        follow=False, secure=False, **extra):
    """Send a resource to the server using PUT."""
    response = super().put(path, data=data, content_type=content_type, secure=secure, **extra)
    if follow:
        response = self._handle_redirects(response, data=data, content_type=content_type, **extra)
    return response

def patch(self, path, data='', content_type='application/octet-stream',
            follow=False, secure=False, **extra):
    """Send a resource to the server using PATCH."""
    response = super().patch(path, data=data, content_type=content_type, secure=secure, **extra)
    if follow:
        response = self._handle_redirects(response, data=data, content_type=content_type, **extra)
    return response

def delete(self, path, data='', content_type='application/octet-stream',
            follow=False, secure=False, **extra):
    """Send a DELETE request to the server."""
    response = super().delete(path, data=data, content_type=content_type, secure=secure, **extra)
    if follow:
        response = self._handle_redirects(response, data=data, content_type=content_type, **extra)
    return response

那么,它在 Django 里唯一可能出现的情况就是测试时使用以上三种请求方法且未指定 content-type。通过增加一个判断,可以有效地防止这种情况的产生,从而提醒测试人员对测试进行修复(哪怕他完全没了解过这部分代码)。

在中间件最开始增加如下的判断代码即可。

if (
    request.content_type == "application/octet-stream"
    and int(request.META.get("CONTENT_LENGTH", "0")) > 0
):
    return HttpResponseBadRequest(
        "Get content-type: application/octet-stream,"
        + " you must change content-type."
    )
如果你觉得本文值得,不妨赏杯茶
Django ORM 模型序列化
Django 解决跨域请求