第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.GETがQueryDictで遅延生成される仕組みと「複数値とイミュータブル」の理由request.POST/request.bodyの関係と「一度しか読めない」の根拠MultiPartParserがファイルアップロードをどう解析するかDATA_UPLOAD_MAX_MEMORY_SIZEによる本文サイズ上限(DoS 緩和)HttpResponseが文字列をバイト列へ正規化するmake_bytesJsonResponse(safe=True)がなぜ dict 以外を拒むのか
Tip
本章も Django 5.2.15 の実物のソースを引用します(出典コメント付き)。
省略は # ...(中略)... で示し、その中略こそ本章で開く対象です。
2.1 ビューを挟む2つのオブジェクト
ビューは def view(request) -> HttpResponse という形をしています。
左から request が入り、右へ response が出ます。
第1章で見たように、request は environ 辞書から作られ、response は最終的に start_response とバイト列イテラブルへ分解されてサーバへ渡りました。
本章はその両端、WSGIRequest と HttpResponse の内部を読みます。
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"] のように添字でアクセスすると、最後の値が返ります。
WSGIRequest の GET は @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.bodyとrequest.POSTのどちらか一方しか安全に使えない」場面の根拠がこれです(request.POSTも内部で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。膨大なフィールド数で辞書を膨らませる攻撃への緩和です。
図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 で改めて扱います。
ここまでの遅延生成と安全弁の関係を図にまとめます。
図2-3 WSGIRequest 各属性の遅延生成と安全弁
__init__ で即時確定するのは META/method/path などで、GET・body・POST/FILES は初回アクセス時にだけ生成されます。body は上限+1 バイトの先読みで DoS を緩和し、POST は Content-Type に応じて MultiPartParser へ分岐します。
environ の各キーと request 属性の対応を一覧にしておきます。
environ キー |
request 属性 |
生成のしかた |
|---|---|---|
|
|
|
|
|
|
|
|
初回アクセス時( |
|
|
|
|
|
初回アクセス時に一度だけ read(上限+1) |
(body を解析) |
|
初回アクセス時( |
|
|
初回アクセス時( |
|
|
|
|
|
|
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_bytes が self.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 を通ってバイト列になる流れを並べてまとめます。
図2-4 HttpResponse の継承ツリーと本文の正規化
左の継承ツリーは HttpResponseBase を頂点に HttpResponse/JsonResponse/StreamingHttpResponse/FileResponse が枝分かれする関係を示し、右は content セッターが受け取った文字列を make_bytes で self.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)