HttpRequestを使いこなす
HttpRequestの踏み込んだ解説に入る前に、簡単にHTTPの基本をおさらいします。curlを用いて、Djangoのスタートページにリクエストを飛ばしてみます。
$ curl -v http://127.0.0.1:8000/ * Trying 127.0.0.1... * TCP_NODELAY set * Connected to 127.0.0.1 (127.0.0.1) port 8000 (#0) > GET / HTTP/1.1 > Host: 127.0.0.1:8000 > User-Agent: curl/7.64.1 > Accept: */* > < HTTP/1.1 200 OK < Date: Tue, 16 Mar 2021 19:38:40 GMT < Server: WSGIServer/0.2 CPython/3.9.0 < Content-Type: text/html < X-Frame-Options: DENY < Content-Length: 10697 < X-Content-Type-Options: nosniff < Referrer-Policy: same-origin < <!doctype html> <html lang="en-us" dir="ltr"> (中略) </html> * Connection #0 to host 127.0.0.1 left intact * Closing connection 0
このように-vオプションをつけることで、リクエストやレスポンスの詳細が確認できます。curlが送信したHTTPのリクエストは、次のようになっています。
GET / HTTP/1.1 Host: 127.0.0.1:8000 User-Agent: curl/7.64.1 Accept: */* <EOF>
HTTPリクエストの1行目は、リクエストライン(Request-Line)と呼ばれます。リクエストラインは「メソッド」「リクエストURI」「HTTPバージョン」からなります。今回の場合、メソッドは「GET」、リクエストURIは「/」、プロトコルバージョンはHTTPの1.1です。リクエストラインの次にHTTPヘッダーの値が続き、空行を1行おいてメッセージボディー(Message body)があります。今回はメッセージボディーは空です。次は、Djangoのアプリケーションが返したHTTPレスポンスも確認しましょう。
HTTP/1.1 200 OK Date: Tue, 16 Mar 2021 19:39:14 GMT Server: WSGIServer/0.2 CPython/3.9.0 Content-Type: text/html X-Frame-Options: DENY Content-Length: 10697 X-Content-Type-Options: nosniff Referrer-Policy: same-origin <!doctype html> <html> ... </html>
HTTPレスポンスの1行目は、ステータスライン(Status-Line)と呼ばれます。ステータスラインは「HTTPバージョン」「ステータスコード」「リーズンフレーズ(Reason-Phrase)」からなります。ステータスコードが200番台であることから、正常にレスポンスが返ってきていることが確認できます。続いてHTTPヘッダーの値が続き、空行を1行おいてメッセージボディーがあります。メッセージボディーにはHTMLが記述されています。今回はcurlでHTTPリクエストを送信しましたが、Webブラウザでアクセスした際も基本的には変わりません。流れを図にすると図1のようになります。
ここまでの流れを頭に入れておけば、HttpRequestの理解はそれほど難しくありません。WSGIサーバーやDjangoアプリケーションによって、HTTPのリクエストは図2に示すようにHttpRequestオブジェクトに変換され、HttpResponseオブジェクトに書き込んだ値は図3に示すようにHTTPのレスポンスとしてクライアントに返されます。
x-www-form-urlencoded形式のデータの読み込み
HTTPリクエストのメッセージボディーを取得する方法は複数用意されています。
- request.body:リクエストボディー(例:b'{"message": "Hello World"')
- request.readメソッド:file-likeオブジェクト
- request.POST:x-www-form-urlencoded形式のリクエストボディー(例:QueryDict({'email': ['hello@example.com']}))
- request.FILES:multipart/form-data形式のリクエストボディー(例:MultiValueDict({'user': ['shibata']}))
メッセージボディーは、request.bodyからのバイト文字列で取得できます。しかしメッセージボディーのサイズが大きく一度にメモリに載せたくないケースでは、request.readメソッドによるfile-likeオブジェクトを取得します。またそれ以外にも特定の形式で記述されたデータをパースして取り出すことができます。例えば次のようなHTMLフォームからPOSTのリクエストを送るケースを考えてみます。
<form method="post" action="/accounts/"> {% csrf_token %} <input type="text" name="username"> <input type="text" name="email"> <button type="submit">投稿</button> </form>
このHTMLフォームはusernameやemailの文字列を/accountsに送信します。実際にHTTPのリクエストは次のようになっています。
POST /accounts/ HTTP/1.1 Host: example.com Content-Type: application/x-www-form-urlencoded Content-Length: 44 username=shibata&email=shibata%40example.com
usernameとemailの情報がメッセージボディー内に記述されています。その形式はGETリクエスト時にURLに含めるクエリ文字列の形式と同じで、パラメーターは&によって区切られ、keyとvalueは、=によって区切られます。また、英数字以外はパーセントエンコードをされるため、@は%40に変わっています。このような形式をx-www-form-urlencoded形式と呼びます。HttpRequestオブジェクトのPOST属性は、このx-www-form-urlencoded形式のデータをパースし、QueryDictという辞書ライクなオブジェクトで提供してくれます。form-urlencodedの形式は、keyの重複を許容するため1つのkeyに対して複数の値をもちます。
>>> from django.http import QueryDict >>> QueryDict("user=shibata&user=masashi&email=shibata%40example.com") <QueryDict: {'user': ['shibata', 'masashi'], 'email': ['shibata@example.com']}>
multipart/form-data形式のデータの読み込み
<form>タグのenctype属性または<button>や<input>のformenctype属性を指定するとメッセージボディーの形式が変わります。先程紹介したx-www-form-urlencoded以外にも、multipart/form-dataやtext/plainが指定できます。ここではmultipart/form-dataを指定してみましょう。
<form method="post" action="/accounts/" enctype="multipart/form-data"> {% csrf_token %} <input type="text" name="username"> <input type="image" name="icon_url"> <button type="submit">{% trans "Post" %}</button> </form>
このフォームのicon_urlフィールドでは、画像ファイルを選択します。
multipart/form-dataは、このようにHTMLフォームからファイルをアップロードする際に必要になります。メッセージボディーがどのようになっているか確認してみましょう。
POST /accounts/ HTTP/1.1 Host: example.com Content-Type: multipart/form-data;boundary="boundary" --boundary Content-Disposition: form-data; name="username" shibata --boundary Content-Disposition: form-data; name="icon_url"; filename="profile.png" Content-Transfer-Encoding: binary contents of profile.png... --boundary--
Content-Typeヘッダーで指定される区切り文字列によってメッセージボディーが分割されています。その中にさらにヘッダーが存在し、空行を空けて画像データの中身が記述されています。
multipart/form-data形式のリクエストには、HttpRequestオブジェクトのFILES属性よりアクセスできます。ファイルアップロードなどを実装する際はこの内容を押さえておくといいでしょう。
conditionデコレーターを使ったETagやLast-Modifiedヘッダーの制御
HTTPには、クライアントにレスポンス内容をキャッシュさせる仕組みがいくつかあります。もしクライアントが最後にアクセスしてから、まだサーバーのレスポンス内容に変化がないことがわかっていれば、レスポンスのメッセージボディーは空でも問題ないはずです。そこでクライアントが既に最新のコンテンツをキャッシュしていることがわかっている場合には、サーバーは304 Not Modifiedを返します。
さて、それではどのようにしてクライアントが既に最新のコンテンツをキャッシュしているかを判断するのでしょうか。1つの方法は次のようなLast-Modifiedヘッダーによる制御です。
Last-Modified: Mon, 22 Mar 2021 01:08:18 GMT
Last-Modifiedヘッダーの値には、HTTP-dateタイムスタンプ形式で最後にコンテンツが更新された日時を指定します。クライアント側は、コンテンツにアクセスした際のLast-Modifiedヘッダーの値を記憶し、次回以降のアクセスの際に次のようなIf-Modified-Sinceヘッダーをリクエストヘッダーに含めます。
If-Modified-Since: Mon, 22 Mar 2021 01:08:18 GMT
するとサーバーは、現在のコンテンツの最終更新日時とIf-Modified-Sinceヘッダーの日時を比較します。もしコンテンツの最終更新日時の方が新しければ200 OKを返し、古ければ304 Not Modifiedを返します。304 Not Modifiedを返す際には、HTTPレスポンスのメッセージボディーには何も入っていません。Djangoではconditionデコレーターを用いて次のように記述できます。
from django.views.decorators.http import condition def last_modified_func(request, snippet_id): try: snippet = Snippet.objects.get(id=snippet_id) except Snippet.DoesNotExist: return None # コンテンツが存在しなければNoneを返す。 return snippet.updated_at @condition(last_modified_func=last_modified_func) def my_view(request, snippet_id): ...
last_modified_funcは、ビュー関数と同じ引数を受け取って、最終更新日時を示すdatetimeオブジェクトを返す関数です。もしコンテンツがそもそも存在しなければ、Noneを返します。
Last-Modifiedヘッダーは日時によりキャッシュを制御しますが、日時の比較ではキャッシュの有効性を表現できないこともしばしばあります。そのようなケースではリクエスト内容やコンテンツの情報から、何らかのハッシュ値を計算し、そのハッシュ値が一致するかどうかを見る方法もあります。
それを実現するのがETagヘッダーです。大まかな流れはLast-Modifiedヘッダーのときと変わりません。サーバーは、コンテンツを返す際にHTTPのレスポンスヘッダーにETag:
またdjango.views.decorators.httpモジュールは、etagデコレーターやlast_modifiedデコレーターも提供します。これらは次のようなただのショートカット関数です。そのためconditionデコレーターが理解できていれば十分です。
def etag(etag_func): return condition(etag_func=etag_func) def last_modified(last_modified_func): return condition(last_modified_func=last_modified_func)