第3章 まずは 1 リクエストだけ処理するサーバを作る
第2章(HTTP は何をやりとりしているのか)で、HTTP リクエストとレスポンスの構造、TCP ソケットの基本操作、そしてバイト列の境界管理について学びました。 知識としてはひととおり揃いました。 ここからは、その知識を使って実際に動く HTTP サーバを自分の手で組み立てていきます。
第3章(まずは 1 リクエストだけ処理するサーバを作る)の方針は、小さく始めて段階的に機能を追加していくことです。 最初から完璧なサーバを作ろうとすると、考えるべきことが多すぎて手が止まってしまいます。 まずは「1つのリクエストを受け取って、固定のレスポンスを返す」だけのサーバを作り、動くことを確認しましょう。 その後の項で、ルーティング、POST ボディの処理、複数リクエストの対応と、一歩ずつ機能を積み重ねていきます。
Tip
本章の学習スタイルは「写経 → 動作確認 → 改造」です。 コードを読むだけでなく、実際に手を動かしてみることで、HTTP サーバの本質が身に付きます。
3.1 最小の socket サーバ
図3-1 server_v1.py の動作シーケンス
socket の受信から HTTP レスポンスの送信までの一連の流れを示します。
2.8.5 節(send)で動かした minimal_server.py を出発点にしますが、第2章(HTTP は何をやりとりしているのか)で学んだ問題点を意識しながら、もう少し丁寧に書き直してみましょう。
import socket
def start_server(host="127.0.0.1", port=8000):
"""1リクエストだけ処理して終了する最小のHTTPサーバ"""
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((host, port))
server.listen(5)
print(f"Serving on http://{host}:{port}")
# 接続を1つだけ受け入れる
client_conn, client_addr = server.accept()
print(f"Connection from {client_addr}")
try:
# ヘッダーの終端が見つかるまで受信を繰り返す
buffer = b""
while b"\r\n\r\n" not in buffer:
chunk = client_conn.recv(4096)
if not chunk:
print("Client disconnected before sending a complete request.")
return
buffer += chunk
# 受信したリクエストを表示する
header_part, _, _ = buffer.partition(b"\r\n\r\n")
request_line = header_part.split(b"\r\n")[0].decode("utf-8")
print(f"Request: {request_line}")
# 固定のレスポンスを返す
body = "Hello, World!"
response = (
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/plain; charset=utf-8\r\n"
f"Content-Length: {len(body.encode('utf-8'))}\r\n"
"Connection: close\r\n"
"\r\n"
f"{body}"
)
client_conn.sendall(response.encode("utf-8"))
finally:
client_conn.close()
server.close()
print("Server stopped.")
if __name__ == "__main__":
start_server()
2.8.5 節(send)の minimal_server.py と比べて、いくつかの改善を加えています。
順に確認していきましょう。
まず、ヘッダー終端の検出ループです。recv() を1回だけ呼ぶのではなく、\r\n\r\n が見つかるまで繰り返し受信しています。
2.9 節(テキストに見えるが、境界を自分で管理する必要がある)で説明した「部分受信の問題」への対処です。
小さな GET リクエストでは1回の recv() でほぼ確実にすべて届きますが、この書き方を習慣にしておくことで、リクエストが大きくなったときにも正しく動きます。
buffer = b""
while b"\r\n\r\n" not in buffer:
chunk = client_conn.recv(4096)
if not chunk:
print("Client disconnected before sending a complete request.")
return
buffer += chunk
注意
chunk が空のバイト列(b"")だった場合は、クライアントが接続を閉じたことを意味します。
このチェックがなければ、クライアントが途中で切断した場合に無限ループに陥ります。
必ず if not chunk: return の行を入れてください。
次に、Connection: close ヘッダーを含めています。
このサーバは1リクエストだけ処理して終了するため、レスポンスに Connection: close を含めてクライアントに「この接続は閉じます」と明示的に伝えます。
これがないと、クライアント(特にブラウザ)は持続的接続を期待して次のリクエストを送ろうとし、サーバが接続を閉じたときにエラーが表示される可能性があります。
Content-Type に charset=utf-8 を付けているのも意識的な判断です。
ブラウザは charset の指定がない場合、独自の推測で文字コードを判断しようとします。
明示しておけば、日本語などのマルチバイト文字を含むレスポンスでも文字化けしません。
try: ... finally: で client_conn.close() を囲んでいるのは、レスポンスの送信中に例外が発生しても、ソケットが確実に閉じられるようにするためです。
ソケットを閉じ忘れると、OS のリソース(ファイルディスクリプタ)がリークします。
3.2 固定レスポンスを返す
このサーバを実際に動かしてみましょう。 ターミナルでスクリプトを実行します。
python server_v1.py
Serving on http://127.0.0.1:8000 と表示されたら、別のターミナルから curl でリクエストを送ります。
curl -v http://127.0.0.1:8000/hello
-v(verbose)オプションを付けると、リクエストとレスポンスの詳細が表示されます。
* Connected to 127.0.0.1 (127.0.0.1) port 8000
> GET /hello HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/8.7.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=utf-8
< Content-Length: 13
< Connection: close
<
Hello, World!
> で始まる行がクライアントからサーバに送られたリクエスト、< で始まる行がサーバからクライアントに返されたレスポンスです。
自分が書いたコードで組み立てた HTTP レスポンスが、curl に正しく解釈されていることがわかります。
サーバ側のターミナルには、次のような出力が表示されています。
Serving on http://127.0.0.1:8000
Connection from ('127.0.0.1', 54321)
Request: GET /hello HTTP/1.1
Server stopped.
ブラウザでも試してみてください。http://127.0.0.1:8000/ を開くと、画面に「Hello, World!」と表示されます。
ブラウザが送ってくる HTTP リクエストも、curl が送るものと本質的には同じです。
ただし、ブラウザは curl よりも多くのヘッダー(Accept-Language、Accept-Encoding、Cookie など)を付けてきます。
Tip
実験: Content-Length を意図的に間違えてみる
Content-Length: 5 に変更して実行すると、curl は 5 バイト(Hello)だけを受け取って表示を終えます。
, World! の部分は読み捨てられます。
持続的接続であれば、次のレスポンスの先頭として誤って解釈されることもあります。
2.6 節(HTTP/1.1 の基本動作)で説明した Content-Length の重要性を、自分の目で確認できる良い実験です。
このサーバには明らかな制限があります。
どんな URL にアクセスしても同じ Hello, World! を返し、1つのリクエストを処理したら終了してしまいます。
しかし、この時点ですでに、Web サーバの核心(ソケットで接続を受け入れ、HTTP リクエストを受信し、HTTP レスポンスを送信する)を自分のコードで実現しています。
小さなコードですが、第2章(HTTP は何をやりとりしているのか)で学んだ知識(ヘッダー終端の検出、Content-Length の計算、Connection: close の意味、部分受信への対処、ソケットのクローズ)がすべて織り込まれています。
次項では、このサーバにルーティングの機能を追加します。 リクエストされたパスに応じて異なるレスポンスを返せるようにすることで、「Web アプリケーション」と呼べるものに一歩近づきます。
3.3 HTTP レスポンスを正しく組み立てる
前項では、固定の Hello, World! を返すだけのサーバを動かしました。
レスポンスの組み立て部分は文字列の連結で書きましたが、機能を追加していく前に、HTTP レスポンスの組み立てをもう少し丁寧に整理しておきましょう。
第2章(HTTP は何をやりとりしているのか)で HTTP レスポンスの構造を学びましたが、「知っている」ことと「正しく組み立てられる」ことは別物です。 レスポンスの各パーツを自分で組み立てるコードを書きながら、「正しい」とはどういうことかを確認していきましょう。 些細に見えるミスが、ブラウザの挙動を狂わせたり、セキュリティ上の問題を引き起こしたりすることを体感してください。
注釈
HTTP レスポンスは 4 つのパーツで構成されます。
ステータスライン:
HTTP/1.1 200 OK\r\nヘッダー群:
Content-Type: ...\r\nなど空行:
\r\n(ヘッダーとボディの境界)ボディ: 実際のコンテンツ
この順序を守ることが、正しいレスポンス組み立ての第一歩です。
3.3.1 ステータスライン
HTTP レスポンスの先頭行であるステータスラインは、3つの要素で構成されます。
HTTP/1.1 200 OK\r\n
図3-2 HTTP レスポンスの構造
ステータスライン、ヘッダー群、空行、ボディの4要素がこの順に並びます。
HTTP バージョン、ステータスコード、理由フレーズの3つです。 これをコードで組み立てる関数を書いてみましょう。
def build_status_line(status_code, reason_phrase):
return f"HTTP/1.1 {status_code} {reason_phrase}\r\n"
シンプルですが、いくつか注意すべき点があります。
HTTP バージョンは HTTP/1.1 と書きます(http/1.1 ではありません)。
HTTP の仕様では、バージョン文字列の HTTP 部分は大文字であることが求められています。
多くのクライアントは小文字でも受け入れてくれますが、仕様に従っておくのが安全です。
ステータスコードは3桁の整数です(200、404、500 など)。
理由フレーズはステータスコードに対応する人間向けの説明文で、200 なら OK、404 なら Not Found、500 なら Internal Server Error となります。
よく使うステータスコード一覧
コード |
理由フレーズ |
意味 |
|---|---|---|
200 |
OK |
リクエスト成功 |
201 |
Created |
リソース作成成功 |
204 |
No Content |
成功(ボディなし) |
301 |
Moved Permanently |
恒久リダイレクト |
302 |
Found |
一時リダイレクト |
400 |
Bad Request |
クライアントのリクエストが不正 |
403 |
Forbidden |
アクセス禁止 |
404 |
Not Found |
リソースが見つからない |
405 |
Method Not Allowed |
メソッドが許可されていない |
500 |
Internal Server Error |
サーバ内部エラー |
よく使うステータスコードと理由フレーズを辞書として持っておくと便利です。
REASON_PHRASES = {
200: "OK",
201: "Created",
204: "No Content",
301: "Moved Permanently",
302: "Found",
400: "Bad Request",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
500: "Internal Server Error",
}
def build_status_line(status_code):
reason = REASON_PHRASES.get(status_code, "Unknown")
return f"HTTP/1.1 {status_code} {reason}\r\n"
これで build_status_line(200) は "HTTP/1.1 200 OK\r\n" を、build_status_line(404) は "HTTP/1.1 404 Not Found\r\n" を返します。
3.3.2 ヘッダー
ステータスラインの後に続くヘッダーは、「名前: 値」のペアを \r\n で区切って並べます。
def build_headers(headers):
header_lines = ""
for name, value in headers:
header_lines += f"{name}: {value}\r\n"
return header_lines
ヘッダーをタプルのリストとして受け取り、各タプルを 名前: 値\r\n の形式に変換しています。
注釈
辞書ではなくタプルのリストを使っているのは、HTTP ヘッダーでは同じ名前のフィールドが複数回出現することがあるためです。
たとえば Set-Cookie ヘッダーは、複数のクッキーを設定するために複数行に渡ることがあります。
辞書ではキーが重複できないため、この状況を表現できません。
ヘッダーの名前と値の間にはコロンと空白が入ります(Content-Type:text/html ではなく Content-Type: text/html)。
仕様上はコロンの後の空白はオプションですが、ほぼすべての実装が空白を入れています。
レスポンスに最低限含めるべきヘッダーを考えてみましょう。
Content-Type: ボディのデータ形式をクライアントに伝えるために事実上必須です。これがなければ、ブラウザはレスポンスボディをどう解釈すべきか判断できません。HTML なのかプレーンテキストなのか JSON なのか、Content-Typeがその手がかりです。Content-Length: 持続的接続においてボディの終端をクライアントに知らせるために重要です。Connection: 接続の扱いをクライアントに伝えます。今のサーバでは常にcloseを返しています。
HTML を返す場合の Content-Type は text/html; charset=utf-8 です。
charset=utf-8 を省略するとどうなるかを試してみるのも良い実験です。
body = "<html><body><h1>こんにちは</h1></body></html>"
charset=utf-8 がなければ、ブラウザはデフォルトの文字コード(ブラウザの設定や言語によって異なる)で解釈しようとします。
運が良ければ正しく表示されますが、文字化けする環境もあります。
charset を明示すれば、どの環境でも確実に正しく表示されます。
サーバが自分で組み立てるレスポンスだからこそ、こうした細部に気を配る必要があります。
3.3.3 空行
ヘッダーの後には空行を挟み、その後にボディが続きます。
空行は \r\n だけの行、つまり最後のヘッダーの \r\n に続けてもうひとつ \r\n を付けます。
def build_response(status_code, headers, body_bytes):
response = build_status_line(status_code)
response += build_headers(headers)
response += "\r\n" # 空行
return response.encode("utf-8") + body_bytes
重要
空行はヘッダーの一部ではなく、ヘッダーとボディの境界を示す独立した要素です。
最後のヘッダー行の \r\n に続けてさらに \r\n を追加すると、レスポンスの中に \r\n\r\n という4バイトの並びが現れます。
これがクライアントがヘッダーの終端を検出するためのマーカーです。
警告
この空行を忘れると、ヘッダーとボディの境界がなくなり、クライアントはボディの内容をヘッダーの一部として解釈しようとします。
たとえば Hello, World! がヘッダーフィールドとしてパースされ、不正なヘッダーとして扱われるか、クライアントがヘッダーの終端を見つけられずにハングします。
たった2バイトの \r\n が欠けるだけで通信が破綻します。
テキストプロトコルの可読性に油断するわけにはいきません。
3.3.4 ボディ
ボディの組み立てで最も注意すべきなのは、文字列とバイト列の区別です。
前項の build_response() 関数をもう一度見てください。
ステータスラインとヘッダーは文字列(str)として組み立て、最後に encode("utf-8") でバイト列に変換しています。
一方、ボディは最初から body_bytes(bytes 型)として受け取り、文字列部分をエンコードしたバイト列に連結しています。
この設計には理由があります。 HTTP レスポンスのボディは、必ずしもテキストとは限りません。 画像ファイル、PDF、バイナリデータを返す場合もあります。 これらはもともとバイト列であり、文字列としてのエンコーディングは意味を持ちません。 ボディをバイト列として扱うことで、テキストもバイナリも統一的に処理できます。
テキストのボディを返す場合は、呼び出し側でエンコードします。
body_text = "Hello, World!"
body_bytes = body_text.encode("utf-8")
headers = [
("Content-Type", "text/plain; charset=utf-8"),
("Content-Length", str(len(body_bytes))),
("Connection", "close"),
]
response_bytes = build_response(200, headers, body_bytes)
client_conn.sendall(response_bytes)
警告
Content-Length に渡しているのは len(body_bytes) であって len(body_text) ではないことに注目してください。
Content-Length はバイト数です。body_text が ASCII 文字だけであればどちらも同じ値になりますが、日本語を含む場合は異なります。
たとえば「こんにちは」は文字数が5文字ですが、UTF-8 では 15 バイトになります。
これをまとめて、完全なレスポンスの組み立て関数を作りましょう。