第2章 HttpRequest と HttpResponse オブジェクトの解剖

第1章では WSGIHandler.__call__environ を受け取り、self.request_class(environ)WSGIRequest を作り、self.get_response(request) でレスポンスを得るところまでを追いました。 その入口で「解剖は次章へ」と送った2つのオブジェクト(ビューが受け取る request と、ビューが返す response)を、本章で開きます。

問いはこうです。 この2オブジェクトは、生の environ/バイト列との間で何を肩代わりしているのでしょうか。 なぜ request.GET は遅延で、request.body は一度しか読めず、JsonResponse({...}) は dict 以外を拒むのでしょうか。

この章で学ぶことは次のとおりです。

  • request.GETQueryDict で遅延生成される仕組みと「複数値とイミュータブル」の理由

  • request.POSTrequest.body の関係と「一度しか読めない」の根拠

  • MultiPartParser がファイルアップロードをどう解析するか

  • DATA_UPLOAD_MAX_MEMORY_SIZE による本文サイズ上限(DoS 緩和)

  • HttpResponse が文字列をバイト列へ正規化する make_bytes

  • JsonResponse(safe=True) がなぜ dict 以外を拒むのか

Tip

本章も Django 5.2.15 の実物のソースを引用します(出典コメント付き)。 省略は # ...(中略)... で示し、その中略こそ本章で開く対象です。

2.1 ビューを挟む2つのオブジェクト

ビューは def view(request) -> HttpResponse という形をしています。 左から request が入り、右へ response が出ます。 第1章で見たように、requestenviron 辞書から作られ、response は最終的に start_response とバイト列イテラブルへ分解されてサーバへ渡りました。 本章はその両端、WSGIRequestHttpResponse の内部を読みます。

2.2 HttpRequest と WSGIRequest の分担

request の型は WSGIRequest で、HttpRequest を継承しています。

 1# django/http/request.py — Django 5.2
 2class HttpRequest:
 3    """A basic HTTP request."""
 4
 5    def __init__(self):
 6        self.GET = QueryDict(mutable=True)
 7        self.POST = QueryDict(mutable=True)
 8        self.COOKIES = {}
 9        self.META = {}
10        self.FILES = MultiValueDict()
11        # ...(中略:path / method などの初期化)...

HttpRequest は WSGI にも ASGI にも依存しない抽象的な基底です。 WSGIRequest(第1章で読みました)はそこに「environ をどう読むか」という WSGI 固有の知識を足したサブクラスでした。 self.META = environ と辞書をそのまま参照していたのが、その固有部分です。 同じ HttpRequest を ASGI 側で継承するのが ASGIRequest で、これは第16章「Django の ASGI 対応」で読みます。

2.2.1 GET:クエリ文字列の遅延解析

request.GET が返す QueryDict は、Python 標準の dict ではなく Django 独自の辞書サブクラスです。 HTTP のクエリ文字列は ?color=red&color=blue のように同じキーが複数回現れることがあり、ふつうの辞書では後勝ちで blue しか残りません。 QueryDict は内部で値をリストとして保持し、request.GET.getlist("color")["red", "blue"] を取り出せます。 request.GET["color"] のように添字でアクセスすると、最後の値が返ります。

WSGIRequestGET@cached_property です。

1# django/http/request.py — Django 5.2(WSGIRequest)
2@cached_property
3def GET(self):
4    # The WSGI spec says 'QUERY_STRING' may be absent.
5    raw_query_string = get_bytes_from_wsgi(self.environ, "QUERY_STRING", "")
6    return QueryDict(raw_query_string, encoding=self._encoding)

@cached_property なので、初回アクセス時に一度だけクエリ文字列を解析し、以後は結果をキャッシュします。 GET リクエストでも request.GET に触れなければ解析は走りません。 「触られるまで実行しない」という遅延は、第1章の起動時前払いとは逆方向の、「使う直前まで遅らせる」もう一つの省力化です。 本書では HttpResponse のレンダリング遅延(第5章)、QuerySet の評価遅延(第8章)、依存解決のキャッシュ(第14章)と、この遅延思想に何度も再会します。

QueryDict はまたイミュータブル(変更不可)です。 リクエスト由来のデータを誤って書き換えないための安全装置で、書き換えたい場合は request.GET.copy() で可変なコピーを作ります。 QueryDict(raw_query_string, encoding=...)mutable を指定せずに生成しているため、既定でイミュータブルになります。

2.2.2 POST と body:フォームと生ボディ

request.POST の実体である _load_post_and_files() は、Content-Type を見て解析方法を切り替えます。

 1# django/http/request.py — Django 5.2
 2def _load_post_and_files(self):
 3    """Populate self._post and self._files if the content-type is a form type"""
 4    if self.method != "POST":
 5        self._post, self._files = (
 6            QueryDict(encoding=self._encoding),
 7            MultiValueDict(),
 8        )
 9        return
10    # ...(中略:本文を読み始めた後の整合性チェック)...
11    if self.content_type == "multipart/form-data":
12        # ...(中略:MultiPartParser によるファイルアップロードの解析)...
13        self._post, self._files = self.parse_file_upload(self.META, data)
14    elif self.content_type == "application/x-www-form-urlencoded":
15        # ...(中略)...
16        self._post = QueryDict(self.body, encoding="utf-8")
17        self._files = MultiValueDict()
18    else:
19        self._post, self._files = (
20            QueryDict(encoding=self._encoding),
21            MultiValueDict(),
22        )

読み解くと、request.POST に中身が入る条件は二つだけだと分かります。

  • Content-Type: multipart/form-data(ファイル付きフォーム)

  • Content-Type: application/x-www-form-urlencoded(通常の HTML フォーム)

それ以外の Content-Type(とりわけ application/json)は、最後の else 節で空の QueryDict になります。

注意

JSON ボディは request.POST には入りません。 Content-Type: application/json で送られたデータは、json.loads(request.body)自分でパースする必要があります。 これは Django が HTML フォーム送信を POST の主用途と想定していた時代からの仕様で、_load_post_and_files() の分岐がその事実を端的に示しています。 (FastAPI が JSON ボディを型ヒントから自動で受け取る仕組みは第14章「実行時 DI の中核」で読み、Django との設計差を対比します。)

その request.body は、生のリクエストボディをバイト列で返すプロパティです。

 1# django/http/request.py — Django 5.2
 2@property
 3def body(self):
 4    if not hasattr(self, "_body"):
 5        if self._read_started:
 6            raise RawPostDataException(
 7                "You cannot access body after reading from request's data stream"
 8            )
 9        # ...(中略:DATA_UPLOAD_MAX_MEMORY_SIZE による本文サイズの上限チェック)...
10        try:
11            if settings.DATA_UPLOAD_MAX_MEMORY_SIZE is not None and not did_check:
12                # Read one byte past the limit to detect an oversize body
13                # without loading it all into memory first.
14                self._body = self.read(settings.DATA_UPLOAD_MAX_MEMORY_SIZE + 1)
15            else:
16                self._body = self.read()
17        except OSError as e:
18            raise UnreadablePostError(*e.args) from e
19        finally:
20            self._stream.close()
21        self._stream = BytesIO(self._body)
22        # ...(中略:読み込んだサイズの再チェック)...
23    return self._body

ここに Django の設計上の工夫が三つ見えます。

  • 遅延読み取り: if not hasattr(self, "_body"): のとおり、request.body に初めて触れたときだけストリームを読みます。GET リクエストでは一度も読まれません。

  • 再読み取りへの対応: 読み終えたあと self._stream = BytesIO(self._body) でストリームを差し替えます。これで request.read() を後から呼んでも、読み込み済みのバイト列をもう一度読めます。Vol.1 で触れた「wsgi.input は一度しか読めない」問題への対処です。

  • 二度読みの禁止: 逆に、先にストリームを読み始めていた(self._read_started が真)のに後から request.body に触れると、RawPostDataException を送出して矛盾を防ぎます。「request.bodyrequest.POST のどちらか一方しか安全に使えない」場面の根拠がこれです(request.POST も内部で body を読むため)。

未読(初期状態)_stream = wsgi.inputまだ本文を読んでいないbody 読み込み済み_body 保持・_stream=BytesIO に差し替えbody / POST を安全に再読み可ストリーム直読み開始済み_read_started = Truerequest.read() を先に呼んだRawPostDataExceptionread した後に body は取得不可 request.body 初回アクセスrequest.read() を先に呼ぶ 後から request.body に触れる

図2-1 request.body のストリーム状態遷移

2.2.3 MultiPartParser:ファイルアップロードを開く

_load_post_and_files()multipart/form-data のときだけ呼ぶ MultiPartParser(さきほどの中略の中身)を開きましょう。 これはファイルアップロードの本体です。

 1# django/http/multipartparser.py — Django 5.2(MultiPartParser._parse 抜粋)
 2def _parse(self):
 3    # ...(中略:Content-Length が 0 のときの早期 return・ハンドラ委譲)...
 4    self._post = QueryDict(mutable=True)
 5    self._files = MultiValueDict()
 6    stream = LazyStream(ChunkIter(self._input_data, self._chunk_size))
 7
 8    old_field_name = None
 9    num_post_keys = 0
10    try:
11        for item_type, meta_data, field_stream in Parser(stream, self._boundary):
12            # ...(中略:直前のファイルの完了処理)...
13            if (
14                item_type in FIELD_TYPES
15                and settings.DATA_UPLOAD_MAX_NUMBER_FIELDS is not None
16            ):
17                # Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FIELDS.
18                num_post_keys += 1
19                if settings.DATA_UPLOAD_MAX_NUMBER_FIELDS + 2 < num_post_keys:
20                    raise TooManyFieldsSent(
21                        "The number of GET/POST parameters exceeded "
22                        "settings.DATA_UPLOAD_MAX_NUMBER_FIELDS."
23                    )
24            try:
25                disposition = meta_data["content-disposition"][1]
26                field_name = disposition["name"].strip()
27            except (KeyError, IndexError, AttributeError):
28                continue
29            # ...(中略:item_type が FIELD なら値、FILE ならアップロードハンドラへ)...

ポイントは三つです。

  • boundary でパートに割る: Parser(stream, self._boundary)multipart/form-data の境界文字列(Content-Type: multipart/form-data; boundary=...)で本文をパートに分割し、各パートを (item_type, meta_data, field_stream) として吐き出します。

  • FIELD と FILE の振り分け: 各パートの Content-Disposition から name を読み、filename があるかどうかで通常フィールド(item_type == FIELD)かファイル(FILE)かを振り分けます。前者は self._post、後者は self._files へ入ります。

  • フィールド数の上限: num_post_keys を数え、DATA_UPLOAD_MAX_NUMBER_FIELDS を超えると TooManyFieldsSent膨大なフィールド数で辞書を膨らませる攻撃への緩和です。

multipart/form-data 本文boundary で区切られた連続バイト列Parser(stream, boundary)boundary でパートに分割各パートの Content-Disposition を読むパート: name=titlefilename なし= 通常フィールド (FIELD)パート: name=avatar; filename=a.pngfilename あり= アップロードファイル (FILE)self._postQueryDict(複数値対応)self._filesMultiValueDict 本文全体パート 1..nパート 1..nfilename なし → FIELDfilename あり → FILE

図2-2 multipart 本文の boundary 分割と FIELD/FILE 振り分け

注釈

ファイルがメモリに載るか一時ファイルへ退避されるかは、FILE_UPLOAD_MAX_MEMORY_SIZE を閾値とするアップロードハンドラが決めます。 小さなファイルはメモリ(MemoryFileUploadHandler)、大きなファイルはディスク(TemporaryFileUploadHandler)へ。 「大きなアップロードでメモリを使い果たさない」ための切り替えです。

2.2.4 本文サイズの上限:DoS 緩和

request.body の中略にあった DATA_UPLOAD_MAX_MEMORY_SIZE のチェックを開きます。 ここは巨大な POST による DoS(メモリ枯渇)への緩和です。

実装の妙は、self.read(settings.DATA_UPLOAD_MAX_MEMORY_SIZE + 1)上限より1 バイト多く読むところにあります。 全部をメモリに載せてからサイズを測るのではなく、「上限+1 バイトだけ読んで、もし読めてしまったら上限超過」と判定するのです。 上限を超えていれば(中略の再チェックで)RequestDataTooBig を送出します。 巨大な本文を最後まで読まずに弾けるので、DoS 緩和そのものが省メモリに行われます。

重要

DATA_UPLOAD_MAX_MEMORY_SIZE(本文全体のメモリ上限)と FILE_UPLOAD_MAX_MEMORY_SIZE(ファイル1個をメモリに載せる閾値)、DATA_UPLOAD_MAX_NUMBER_FIELDS(フィールド数の上限)は、いずれもリクエスト由来データの暴走を抑える安全弁です。 「外から来るデータは信用しない」という Vol.1 の原則が、Django ではこれら設定値として実装されています。 これらの上限とセキュリティ上の意味は Vol.3 で改めて扱います。

ここまでの遅延生成と安全弁の関係を図にまとめます。

environ 辞書(WSGI サーバが用意)WSGIRequest__init__ で即時確定META / method / path / _streamGET@cached_propertyQUERY_STRING → QueryDictbody@propertywsgi.input を一度だけ read上限+1 で DoS 緩和POST / FILES_load_post_and_files()Content-Type で分岐MultiPartParserboundary 分割FIELD / FILE 振り分け コンストラクタ初回アクセス時初回アクセス時form なら body を解析multipart のとき

図2-3 WSGIRequest 各属性の遅延生成と安全弁

__init__ で即時確定するのは METAmethodpath などで、GETbodyPOSTFILES は初回アクセス時にだけ生成されます。body は上限+1 バイトの先読みで DoS を緩和し、POST は Content-Type に応じて MultiPartParser へ分岐します。

environ の各キーと request 属性の対応を一覧にしておきます。

表2-1 environ キーと request 属性の対応

environ キー

request 属性

生成のしかた

REQUEST_METHOD

request.method

__init__ で即時

PATH_INFO

request.path_info

__init__ で即時

QUERY_STRING

request.GET

初回アクセス時(@cached_property

CONTENT_TYPE

request.content_type

_set_content_type_params で即時

wsgi.input

request.body

初回アクセス時に一度だけ read(上限+1)

(body を解析)

request.POST / request.FILES

初回アクセス時(_load_post_and_files

HTTP_COOKIE

request.COOKIES

初回アクセス時(@cached_property

HTTP_* 全般

request.headers["..."]

HttpHeadersMETA を逆変換

environ 全体

request.META

__init__ で参照を保持

2.3 ビューが返す HttpResponse の構造

ここからは出口側、ビューが返す HttpResponse を読みます。 第1章で __call__ が参照していた

status = "%d %s" % (response.status_code, response.reason_phrase)
response_headers = [*response.items(), ...]

が、どの属性を参照していたのかの答え合わせから始めます。

2.3.1 HttpResponseBase:ステータスとヘッダー

レスポンスの土台は HttpResponseBase です。 本文を持たず、ステータスとヘッダーだけを扱います。 さきほどまで中略にしていた「Content-Type の既定値」と「status の範囲チェック」を、今回は開いて引用します。

 1# django/http/response.py — Django 5.2
 2def __init__(
 3    self, content_type=None, status=None, reason=None, charset=None, headers=None
 4):
 5    self.headers = ResponseHeaders(headers)
 6    self._charset = charset
 7    if "Content-Type" not in self.headers:
 8        if content_type is None:
 9            content_type = f"text/html; charset={self.charset}"
10        self.headers["Content-Type"] = content_type
11    # ...(中略:headers と content_type の二重指定チェック)...
12    self.cookies = SimpleCookie()
13    self.closed = False
14    if status is not None:
15        try:
16            self.status_code = int(status)
17        except (ValueError, TypeError):
18            raise TypeError("HTTP status code must be an integer.")
19        if not 100 <= self.status_code <= 599:
20            raise ValueError("HTTP status code must be an integer from 100 to 599.")
21    self._reason_phrase = reason

二つの既定と検証が読み取れます。

  • Content-Type の既定: 明示がなければ text/html; charset=... を入れます。HttpResponse("...") がブラウザに HTML として表示されるのはこの一行のためです。

  • status の範囲チェック: 100 <= status <= 599 以外は ValueError。HTTP のステータスコードの範囲を、レスポンス生成の時点で守らせています。

reason_phrase は明示がなければ Python 標準の語句辞書から "OK" などを引きます(HttpResponse(status=200) だけで "200 OK" が組み上がる根拠)。 items() はヘッダーの (名前, 値) ペアを返すだけです。第1章の [*response.items(), ...] はこの戻り値を展開していました。

2.3.2 HttpResponse:本文をバイト列へ正規化する

普段使う HttpResponse は、HttpResponseBase本文を足したサブクラスです。 ここで開きたい中略は、self.content = "..." という代入が裏で何をしているか、すなわち content セッターと make_bytes です。

 1# django/http/response.py — Django 5.2
 2@content.setter
 3def content(self, value):
 4    # Consume iterators upon assignment to allow repeated iteration.
 5    if hasattr(value, "__iter__") and not isinstance(
 6        value, (bytes, memoryview, str)
 7    ):
 8        content = b"".join(self.make_bytes(chunk) for chunk in value)
 9        # ...(中略:イテレータの close)...
10    else:
11        content = self.make_bytes(value)
12    # Create a list of properly encoded bytestrings to support write().
13    self._container = [content]
14    self.__dict__.pop("text", None)
15
16def make_bytes(self, value):
17    """Turn a value into a bytestring encoded in the output charset."""
18    if isinstance(value, (bytes, memoryview)):
19        return bytes(value)
20    if isinstance(value, str):
21        return bytes(value.encode(self.charset))
22    # Handle non-string types.
23    return str(value).encode(self.charset)

HttpResponse("こんにちは") と文字列を渡すと、content セッターが起動し、make_bytesself.charset(既定 UTF-8)でバイト列へ正規化します。 PEP 3333 が要求するとおり、WSGI レスポンスの本文はバイト列でなければならないからです(docstring とコメントが PEP 3333 を明記)。 イテラブルを渡したときは各チャンクを make_bytes で正規化して連結し、self._containerリストとして保持します(後から response.write() で追記できるように)。

つまり「文字列を渡すだけでバイト列のレスポンスになる」のは、この make_bytes がエンコーディングを肩代わりしているからです。 Vol.1 で自作した app[b"..."] とバイト列を手で用意していましたが、Django ではそれが make_bytes に集約されています。

図2-4に、HttpResponse を頂点とする継承ツリーと、ビューが渡す文字列が make_bytes を通ってバイト列になる流れを並べてまとめます。

継承ツリー (django/http/response.py)content セッターの正規化HttpResponseBaseステータス / ヘッダーのみ本文を持たないHttpResponseHttpResponseBase + 本文JsonResponseHttpResponse の薄い派生safe=True 既定StreamingHttpResponseHttpResponseBase を直接継承本文を逐次ストリームFileResponseStreamingHttpResponse の派生file_to_stream を持つcontent (str)ビューが渡す文字列make_bytes(value)self.charset でエンコードbytes_container に保持WSGI 本文 (PEP 3333) 継承継承継承継承content セッターencode(charset)

図2-4 HttpResponse の継承ツリーと本文の正規化

左の継承ツリーは HttpResponseBase を頂点に HttpResponseJsonResponseStreamingHttpResponseFileResponse が枝分かれする関係を示し、右は content セッターが受け取った文字列を make_bytesself.charset のバイト列へ正規化し、PEP 3333 が求める WSGI 本文に整える流れを示します。

2.3.3 JsonResponse:safe 既定のセキュリティ

REST API でおなじみの JsonResponse も、実体は HttpResponse の薄いサブクラスです。

 1# django/http/response.py — Django 5.2
 2class JsonResponse(HttpResponse):
 3    def __init__(
 4        self, data, encoder=DjangoJSONEncoder, safe=True, json_dumps_params=None, **kwargs
 5    ):
 6        if safe and not isinstance(data, dict):
 7            raise TypeError(
 8                "In order to allow non-dict objects to be serialized set the "
 9                "safe parameter to False."
10            )
11        if json_dumps_params is None:
12            json_dumps_params = {}
13        kwargs.setdefault("content_type", "application/json")
14        data = json.dumps(data, cls=encoder, **json_dumps_params)
15        super().__init__(content=data, **kwargs)