3. まずは 1 リクエストだけ処理するサーバを作る

2 章HTTP は何をやりとりしているのか)で、HTTP リクエストとレスポンスの構造、TCP ソケットの基本操作、そしてバイト列の境界管理について学びました。 知識としてはひととおり揃いました。 ここからは、その知識を使って実際に動く HTTP サーバを自分の手で組み立てていきます。

3 章まずは 1 リクエストだけ処理するサーバを作る)の方針は、小さく始めて段階的に機能を追加していくことです。 最初から完璧なサーバを作ろうとすると、考えるべきことが多すぎて手が止まってしまいます。 まずは「1つのリクエストを受け取って、固定のレスポンスを返す」だけのサーバを作り、動くことを確認しましょう。 その後の項で、ルーティング、POST ボディの処理、複数リクエストの対応と、一歩ずつ機能を積み重ねていきます。

Tip

本章の学習スタイルは「写経 → 動作確認 → 改造」です。 コードを読むだけでなく、実際に手を動かして動かしてみることで、HTTP サーバの本質が身に付きます。

3.1. 最小の socket サーバ

diagram

2-4 で動かした minimal_server.py を出発点にしますが、2 章HTTP は何をやりとりしているのか)で学んだ問題点を意識しながら、もう少し丁寧に書き直してみましょう。

# server_v1.py
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-4 の minimal_server.py と比べて、いくつかの改善を加えています。 順に確認していきましょう。

まず、ヘッダー終端の検出ループです。recv() を1回だけ呼ぶのではなく、\r\n\r\n が見つかるまで繰り返し受信しています。 2-5 で説明した「部分受信の問題」への対処です。 小さな 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-Typecharset=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-LanguageAccept-EncodingCookie など)を付けてきます。

Tip

実験: Content-Length を意図的に間違えてみる

Content-Length: 5 に変更して実行すると、curl は 5 バイト(Hello)だけを受け取って表示を終えます。 , World! の部分は読み捨てられます。 持続的接続であれば、次のレスポンスの先頭として誤って解釈されることもあります。 2-2 で説明した 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 つのパーツで構成されます。

  1. ステータスラインHTTP/1.1 200 OK\r\n

  2. ヘッダー群Content-Type: ...\r\n など

  3. 空行\r\n(ヘッダーとボディの境界)

  4. ボディ — 実際のコンテンツ

この順序を守ることが、正しいレスポンス組み立ての第一歩です。

3.3.1. ステータスライン

HTTP レスポンスの先頭行であるステータスラインは、3つの要素で構成されます。

HTTP/1.1 200 OK\r\n
diagram

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桁の整数です(200404500 など)。 理由フレーズはステータスコードに対応する人間向けの説明文で、200 なら OK404 なら Not Found500 なら 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-Typetext/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_bytesbytes 型)として受け取り、文字列部分をエンコードしたバイト列に連結しています。

この設計には理由があります。 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 バイトになります。

これをまとめて、完全なレスポンスの組み立て関数を作りましょう。

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"


def build_headers(headers):
    header_lines = ""
    for name, value in headers:
        header_lines += f"{name}: {value}\r\n"
    return header_lines


def build_response(status_code, headers, body_bytes=b""):
    response = build_status_line(status_code)
    response += build_headers(headers)
    response += "\r\n"
    return response.encode("utf-8") + body_bytes


def make_response(status_code, body_text, content_type="text/plain; charset=utf-8"):
    body_bytes = body_text.encode("utf-8")
    headers = [
        ("Content-Type", content_type),
        ("Content-Length", str(len(body_bytes))),
        ("Connection", "close"),
    ]
    return build_response(status_code, headers, body_bytes)

make_response() は、ステータスコードとボディのテキストを渡すだけで完全な HTTP レスポンスのバイト列を返すヘルパー関数です。 Content-Length の計算もエンコーディングも内部で処理してくれるので、呼び出し側のコードがすっきりします。

# 200 OK を返す
response = make_response(200, "Hello, World!")

# 404 Not Found を返す
response = make_response(404, "Page not found.")

# HTML を返す
response = make_response(
    200,
    "<html><body><h1>Welcome</h1></body></html>",
    content_type="text/html; charset=utf-8",
)

コラム: フレームワークのレスポンスオブジェクトとの関係

この小さなヘルパー関数は、実はフレームワークが提供するレスポンスオブジェクトの原始的な姿です。

  • Django の HttpResponse("Hello, World!", content_type="text/plain")

  • FastAPI の Response(content="Hello, World!", media_type="text/plain")

これらも突き詰めれば、ここで書いた処理——ステータスラインの生成、ヘッダーの組み立て、Content-Length の計算、エンコーディング、空行の付加——をカプセル化したものです。


HTTP レスポンスを構成するステータスライン、ヘッダー、空行、ボディの4要素を正しく組み立てるには、次の4点が欠かせません。

  • \r\n の一貫した使用

  • Content-Length のバイト数での計算

  • 文字列とバイト列の明確な区別

  • charset の明示

どれも小さなことですが、HTTP というプロトコルの正確な実装を支える基盤です。

次項では、この make_response() を活用して、リクエストされたパスに応じて異なるレスポンスを返すルーティングの仕組みを追加します。

3.4. リクエストを読んで分岐する

前項で、HTTP レスポンスを正しく組み立てるための関数群を作りました。 しかし、どんなリクエストが来ても同じレスポンスを返すサーバは、Web アプリケーションとは呼べません。 Web アプリケーションの本質は、リクエストの内容に応じて異なるレスポンスを返すことです。

本項では、HTTP リクエストから情報を読み取り、その情報に基づいて処理を分岐させる仕組みを作ります。 フレームワークでは当たり前のように使っている「ルーティング」の原始的な姿を、自分の手で実装してみましょう。

3.4.1. パスを見る

最も基本的な分岐は、リクエストされたパスに基づくものです。/ にアクセスしたらトップページを、/about にアクセスしたらアバウトページを返す——こうした処理をまず実装します。

diagram

リクエストラインからパスを取り出すところから始めましょう。 2-1 で学んだとおり、リクエストラインは「メソッド パス HTTPバージョン」の形式です。

def parse_request_line(header_part):
    """ヘッダー部分からリクエストラインを解析する"""
    first_line = header_part.split(b"\r\n")[0].decode("utf-8")
    parts = first_line.split(" ")
    method = parts[0]          # "GET"
    full_path = parts[1]       # "/users?name=Taro"
    return method, full_path

full_path にはクエリ文字列が含まれている可能性があります(/users?name=Taro のような形式)。 パスとクエリ文字列を分離する必要がありますが、それは後ほど扱います。まずはパス部分だけを取り出して分岐に使いましょう。

def get_path(full_path):
    """パスからクエリ文字列を除去する"""
    if "?" in full_path:
        return full_path.split("?", 1)[0]
    return full_path

これらの関数を使って、パスに基づく分岐を実装します。

def handle_request(header_part):
    """リクエストを解析してレスポンスを返す"""
    method, full_path = parse_request_line(header_part)
    path = get_path(full_path)

    if path == "/":
        return make_response(200, "Welcome to the top page!")
    elif path == "/about":
        return make_response(200, "This is a minimal HTTP server.")
    elif path == "/hello":
        return make_response(
            200,
            "<html><body><h1>Hello!</h1></body></html>",
            content_type="text/html; charset=utf-8",
        )
    else:
        return make_response(404, f"Not Found: {path}")

これがルーティングの最も素朴な実装です。 if ... elif ... else の連鎖でパスを判定し、一致するものがなければ 404 を返します。

コラム: フレームワークのルーティングとの関係

Django の urlpatterns も FastAPI の @app.get() も、突き詰めれば「パスとハンドラーの対応表を持ち、リクエストのパスを照合する」という同じ仕事をしています。 フレームワークは正規表現や型変換などの高度な機能を追加していますが、本質はここで書いたシンプルな分岐と同じです。

この handle_request() を、3-1 のサーバに組み込んでみましょう。

# server_v2.py
import socket


REASON_PHRASES = {
    200: "OK",
    400: "Bad Request",
    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"


def build_headers(headers):
    header_lines = ""
    for name, value in headers:
        header_lines += f"{name}: {value}\r\n"
    return header_lines


def build_response(status_code, headers, body_bytes=b""):
    response = build_status_line(status_code)
    response += build_headers(headers)
    response += "\r\n"
    return response.encode("utf-8") + body_bytes


def make_response(status_code, body_text, content_type="text/plain; charset=utf-8"):
    body_bytes = body_text.encode("utf-8")
    headers = [
        ("Content-Type", content_type),
        ("Content-Length", str(len(body_bytes))),
        ("Connection", "close"),
    ]
    return build_response(status_code, headers, body_bytes)


def parse_request_line(header_part):
    first_line = header_part.split(b"\r\n")[0].decode("utf-8")
    parts = first_line.split(" ")
    method = parts[0]
    full_path = parts[1]
    return method, full_path


def get_path(full_path):
    if "?" in full_path:
        return full_path.split("?", 1)[0]
    return full_path


def handle_request(header_part):
    method, full_path = parse_request_line(header_part)
    path = get_path(full_path)

    if path == "/":
        return make_response(200, "Welcome to the top page!")
    elif path == "/about":
        return make_response(200, "This is a minimal HTTP server.")
    elif path == "/hello":
        return make_response(
            200,
            "<html><body><h1>Hello!</h1></body></html>",
            content_type="text/html; charset=utf-8",
        )
    else:
        return make_response(404, f"Not Found: {path}")


def start_server(host="127.0.0.1", port=8000):
    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}")

    client_conn, client_addr = server.accept()
    try:
        buffer = b""
        while b"\r\n\r\n" not in buffer:
            chunk = client_conn.recv(4096)
            if not chunk:
                return
            buffer += chunk

        header_part, _, _ = buffer.partition(b"\r\n\r\n")
        response = handle_request(header_part)
        client_conn.sendall(response)
    finally:
        client_conn.close()
        server.close()


if __name__ == "__main__":
    start_server()

サーバを起動し、いくつかのパスにアクセスしてみてください。

curl http://127.0.0.1:8000/
# → Welcome to the top page!

curl http://127.0.0.1:8000/about
# → This is a minimal HTTP server.

curl http://127.0.0.1:8000/nonexistent
# → Not Found: /nonexistent

パスに応じて異なるレスポンスが返ることを確認できます。 ただし、このサーバはまだ1リクエストで終了するため、確認のたびに再起動が必要です(複数リクエストへの対応は後の項で行います)。

3.4.2. メソッドを見る

パスの次に確認すべきはメソッドです。 同じパスでも GETPOST で異なる処理をしたいケースは非常に多く、むしろそれが Web アプリケーションの標準的な設計です。

diagram

ユーザー一覧の取得は GET /users、ユーザーの新規作成は POST /users というように、パスは同じでもメソッドで操作を区別します。

handle_request() にメソッドの分岐を追加しましょう。

def handle_request(header_part):
    method, full_path = parse_request_line(header_part)
    path = get_path(full_path)

    if path == "/":
        if method == "GET":
            return make_response(200, "Welcome to the top page!")
        else:
            return make_response(405, f"Method {method} not allowed.")

    elif path == "/about":
        if method == "GET":
            return make_response(200, "This is a minimal HTTP server.")
        else:
            return make_response(405, f"Method {method} not allowed.")

    elif path == "/echo":
        if method == "GET":
            return make_response(200, "Send a POST request to echo your message.")
        elif method == "POST":
            return make_response(200, "POST received (body parsing is next!).")
        else:
            return make_response(405, f"Method {method} not allowed.")

    else:
        return make_response(404, f"Not Found: {path}")

注釈

405 Method Not Allowed は「このパスは存在するが、使用したメソッドは許可されていない」ことをクライアントに伝えるステータスコードです。 404 と 405 を正しく使い分けることで、クライアントは「パスが間違っているのか、メソッドが間違っているのか」を判断できます。

curl でメソッドを指定してテストできます。

curl http://127.0.0.1:8000/echo
# → Send a POST request to echo your message.

curl -X POST http://127.0.0.1:8000/echo
# → POST received (body parsing is next!).

curl -X DELETE http://127.0.0.1:8000/echo
# → Method DELETE not allowed.

Django のビュー関数で if request.method == "GET": と書くのと、ここで if method == "GET": と書くのは、本質的に同じことをしています。 フレームワークはこの分岐をデコレータやクラスベースビューで洗練させていますが、根底にあるのは「リクエストラインの1つ目のフィールドを見て処理を分ける」というシンプルな操作です。

3.4.3. クエリ文字列を見る

パスとメソッドに加えて、クエリ文字列もリクエストから情報を読み取る重要な手段です。 /search?q=python&page=2 のような URL から、検索キーワードやページ番号を取り出す処理です。

クエリ文字列のパースは、? 以降の文字列を & で分割し、各パーツを = でキーと値に分けるという手順です。 Python の標準ライブラリにはこれを行う urllib.parse モジュールがあります。

from urllib.parse import parse_qs, urlparse


def parse_full_path(full_path):
    """パスとクエリパラメータを解析する"""
    parsed = urlparse(full_path)
    path = parsed.path
    query_params = parse_qs(parsed.query)
    return path, query_params

parse_qs() は、クエリ文字列をキーと値のリストの辞書に変換します。 値がリストなのは、同じキーが複数回出現する場合(?tag=python&tag=web のような場合)に対応するためです。

# /search?q=python&page=2 の場合
path, query_params = parse_full_path("/search?q=python&page=2")
# path → "/search"
# query_params → {"q": ["python"], "page": ["2"]}

これを handle_request() に組み込んでみましょう。

from urllib.parse import parse_qs, urlparse


def parse_request_line(header_part):
    first_line = header_part.split(b"\r\n")[0].decode("utf-8")
    parts = first_line.split(" ")
    method = parts[0]
    full_path = parts[1]
    return method, full_path


def parse_full_path(full_path):
    parsed = urlparse(full_path)
    path = parsed.path
    query_params = parse_qs(parsed.query)
    return path, query_params


def handle_request(header_part):
    method, full_path = parse_request_line(header_part)
    path, query_params = parse_full_path(full_path)

    if path == "/":
        if method == "GET":
            return make_response(200, "Welcome to the top page!")
        else:
            return make_response(405, f"Method {method} not allowed.")

    elif path == "/greet":
        if method == "GET":
            name = query_params.get("name", ["World"])[0]
            return make_response(200, f"Hello, {name}!")
        else:
            return make_response(405, f"Method {method} not allowed.")

    elif path == "/search":
        if method == "GET":
            q = query_params.get("q", [""])[0]
            page = query_params.get("page", ["1"])[0]
            return make_response(
                200,
                f"Searching for '{q}' (page {page})",
            )
        else:
            return make_response(405, f"Method {method} not allowed.")

    else:
        return make_response(404, f"Not Found: {path}")

curl で試してみましょう。

curl "http://127.0.0.1:8000/greet?name=Taro"
# → Hello, Taro!

curl "http://127.0.0.1:8000/greet"
# → Hello, World!

curl "http://127.0.0.1:8000/search?q=python&page=3"
# → Searching for 'python' (page 3)

query_params.get("name", ["World"])[0] という書き方は少し冗長に感じるかもしれません。 parse_qs() が値をリストで返すため、最初の要素を [0] で取り出す必要があります。

コラム: フレームワークが吸収してくれる冗長さ

Django の request.GET.get("name", "World") が簡潔に書けるのは、フレームワークがこの不便さを吸収してくれているからです。 FastAPI に至っては、関数の引数に name: str = "World" と書くだけで、クエリパラメータの取得、型変換、デフォルト値の適用がすべて自動で行われます。

ここで自分の手で書いた冗長なコードを経験しておくことで、フレームワークが提供する簡潔さの裏側にある処理が見えるようになります。


if ... elif ... else による素朴なルーティングは洗練されたものではありませんが、HTTP リクエストからパス、メソッド、クエリ文字列を読み取り、それに応じて処理を分岐させるというフレームワークのルーティング機能の本質を体現しています。

次項では、POST リクエストのボディを読み取る処理を追加します。 クエリ文字列が URL に情報を埋め込むのに対し、POST ボディはリクエストの本文としてデータを送信する方法です。 ここで初めて、2-5 で議論した Content-Length に基づくボディの受信を実装することになります。

3.5. POST ボディを読む

前項までで、パス、メソッド、クエリ文字列に基づいてリクエストを分岐するサーバができました。 しかし、GET リクエストとクエリ文字列だけでは、クライアントからサーバに送れる情報は限られています。 URL には長さの実質的な制限がありますし、パスワードのような機密情報をクエリ文字列に含めるのはセキュリティ上好ましくありません。

POST リクエストのボディは、クライアントからサーバにまとまったデータを送るための本来の手段です。 フォームの入力内容、JSON データ、ファイルのアップロード——これらはすべてリクエストボディとして送信されます。

本項では、2-5 で議論した Content-Length に基づくボディの受信を実際にコードとして実装し、JSON とフォームデータの2つの形式を扱います。

3.5.1. Content-Length の利用

POST リクエストのボディを読み取るには、まずヘッダーから Content-Length の値を取り出し、その長さだけボディを受信する必要があります。

diagram

2-5 で説明した手順を、きちんとした関数として実装しましょう。

def parse_headers(header_part):
    """ヘッダー部分を辞書に変換する"""
    headers = {}
    lines = header_part.split(b"\r\n")
    # 最初の行はリクエストラインなのでスキップ
    for line in lines[1:]:
        if b":" not in line:
            continue
        name, value = line.split(b":", 1)
        name = name.decode("utf-8").strip().lower()
        value = value.decode("utf-8").strip()
        headers[name] = value
    return headers

注釈

ヘッダーのフィールド名を .lower() で小文字に統一している点に注目してください。 HTTP の仕様では、ヘッダーのフィールド名は大文字・小文字を区別しません(ケースインセンシティブ)。 Content-Lengthcontent-lengthCONTENT-LENGTH も同じ意味です。 小文字に統一しておくことで、後から headers.get("content-length") と書くだけで確実に値を取得できます。

次に、ヘッダーとボディを合わせてリクエスト全体を受信する関数を書きます。

def receive_request(client_conn):
    """HTTPリクエストを受信し、ヘッダー部とボディを返す"""
    # ヘッダーの終端まで受信
    buffer = b""
    while b"\r\n\r\n" not in buffer:
        chunk = client_conn.recv(4096)
        if not chunk:
            return None, None
        buffer += chunk

    header_part, _, rest = buffer.partition(b"\r\n\r\n")

    # Content-Length があればボディを受信
    headers = parse_headers(header_part)
    content_length = int(headers.get("content-length", "0"))

    body = rest  # ヘッダー受信時にボディの一部も読んでいる可能性がある
    while len(body) < content_length:
        chunk = client_conn.recv(4096)
        if not chunk:
            break
        body += chunk

    return header_part, body[:content_length]

この関数には、2-5 で議論したポイントがすべて反映されています。

  • \r\n\r\n が見つかるまで recv() を繰り返すことで、ヘッダーの部分受信に対処しています。

  • buffer.partition(b"\r\n\r\n") で分割した後の rest を捨てずにボディの先頭として扱っています。ヘッダーを受信する過程でボディの一部まで読んでしまう可能性があるためです。

  • rest の長さが content_length に満たなければ追加で recv() を呼び、指定されたバイト数がすべて揃うまでループしています。

最後の body[:content_length] は、rest にボディ以降のデータ(持続的接続の場合、次のリクエストの先頭など)が含まれている可能性に対する防御です。 Content-Length で指定された分だけを正確に切り出します。

3.5.2. JSON を受け取る

ボディの受信ができるようになったので、JSON データを受け取るエンドポイントを作ってみましょう。 REST API では最も一般的なデータ形式です。

import json


def handle_request(header_part, body):
    method, full_path = parse_request_line(header_part)
    path, query_params = parse_full_path(full_path)
    headers = parse_headers(header_part)

    if path == "/":
        if method == "GET":
            return make_response(200, "Welcome to the top page!")
        else:
            return make_response(405, f"Method {method} not allowed.")

    elif path == "/users":
        if method == "GET":
            return make_response(200, "User list would go here.")
        elif method == "POST":
            content_type = headers.get("content-type", "")
            if "application/json" not in content_type:
                return make_response(
                    400,
                    "Content-Type must be application/json.",
                )
            try:
                data = json.loads(body.decode("utf-8"))
            except (json.JSONDecodeError, UnicodeDecodeError) as e:
                return make_response(400, f"Invalid JSON: {e}")

            name = data.get("name", "unknown")
            email = data.get("email", "unknown")
            return make_response(
                201,
                f"User created: name={name}, email={email}",
            )
        else:
            return make_response(405, f"Method {method} not allowed.")

    else:
        return make_response(404, f"Not Found: {path}")

POST /users のハンドラーで行っている処理を順に見ていきましょう。

  • 最初に Content-Type ヘッダーを確認しています。JSON を期待しているのに Content-Typeapplication/json でなければ、400 Bad Request を返します。

  • 次に json.loads() でボディをパースしています。ボディは bytes 型で届くので、まず decode("utf-8") で文字列に変換します。

  • JSON としてパースできなければ json.JSONDecodeError が発生するため、try ... except でキャッチして 400 を返します。

  • パースに成功したら、辞書から必要なフィールドを取り出して処理します。

注釈

2-6 で「Content-Type を付け忘れるとフレームワークがボディを正しく解釈しない」という話をしましたが、ここではその検証ロジックを自分で書いています。 フレームワークが自動的にやってくれていることの中身が見えてきます。

curl で JSON を送ってテストしてみましょう。

curl -X POST http://127.0.0.1:8000/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Taro", "email": "[email protected]"}'
# → User created: name=Taro, [email protected]

-H オプションでヘッダーを追加し、-d オプションでボディを指定しています。 Content-Type を省略した場合もテストしてみてください。

curl -X POST http://127.0.0.1:8000/users \
  -d '{"name": "Taro"}'
# → Content-Type must be application/json.

Tip

curl-d オプションはデフォルトで Content-Type: application/x-www-form-urlencoded を設定します。 そのため、この検証に引っかかります。 この挙動の違いに気づけること自体が、HTTP の仕組みを理解している証です。

3.5.3. フォームを受け取る

JSON に加えて、HTML フォームから送信されるデータも扱えるようにしましょう。 ブラウザのフォームが <form method="POST"> で送信するデフォルトの形式は application/x-www-form-urlencoded です。

このデータ形式は、クエリ文字列と同じ key=value&key=value のフォーマットです。 違いは、クエリ文字列が URL の中に含まれるのに対し、フォームデータはリクエストのボディに含まれるという点です。 パースの方法は同じなので、urllib.parse.parse_qs() がそのまま使えます。

from urllib.parse import parse_qs


def handle_form_post(body):
    """application/x-www-form-urlencoded のボディをパースする"""
    form_data = parse_qs(body.decode("utf-8"))
    return form_data

/contact エンドポイントを追加して、フォームの送信を受け取れるようにします。 GET でフォームの HTML を返し、POST で送信されたデータを処理するという、Web アプリケーションの古典的なパターンです。

elif path == "/contact":
    if method == "GET":
        html = (
            "<html><body>"
            "<h1>Contact</h1>"
            '<form method="POST" action="/contact">'
            '<label>Name: <input name="name"></label><br>'
            '<label>Message: <input name="message"></label><br>'
            '<button type="submit">Send</button>'
            "</form>"
            "</body></html>"
        )
        return make_response(
            200, html, content_type="text/html; charset=utf-8"
        )
    elif method == "POST":
        content_type = headers.get("content-type", "")
        if "application/x-www-form-urlencoded" in content_type:
            form_data = parse_qs(body.decode("utf-8"))
            name = form_data.get("name", ["anonymous"])[0]
            message = form_data.get("message", ["(empty)"])[0]
            return make_response(
                200,
                f"Thank you, {name}! Your message: {message}",
            )
        elif "application/json" in content_type:
            try:
                data = json.loads(body.decode("utf-8"))
            except (json.JSONDecodeError, UnicodeDecodeError) as e:
                return make_response(400, f"Invalid JSON: {e}")
            name = data.get("name", "anonymous")
            message = data.get("message", "(empty)")
            return make_response(
                200,
                f"Thank you, {name}! Your message: {message}",
            )
        else:
            return make_response(
                400,
                "Unsupported Content-Type.",
            )
    else:
        return make_response(405, f"Method {method} not allowed.")

ブラウザで http://127.0.0.1:8000/contact を開くと、フォームが表示されます。 名前とメッセージを入力して「Send」を押すと、POST リクエストが送信され、サーバが受け取ったデータを含むレスポンスが表示されます。

curl でも同じことができます。

curl -X POST http://127.0.0.1:8000/contact \
  -d "name=Taro&message=Hello"
# → Thank you, Taro! Your message: Hello

curl-d オプションはデフォルトで Content-Type: application/x-www-form-urlencoded を設定してくれるため、フォーム形式として正しく処理されます。

注釈

同じエンドポイントが Content-Type に応じて JSON とフォームデータの両方を受け取れるようにしている点に注目してください。 「ボディのバイト列をどう解釈するかは Content-Type が決める」 という HTTP の原則を、コードで表現しています。

  • Django の request.POST が HTML フォームのデータだけを扱い、JSON を受け取るには request.body を自分でパースする必要があるのは、この Content-Type の違いに基づいています。

  • FastAPI が Content-Type に応じて自動的にパーサーを切り替えてくれるのは、ここで自分の手で書いた分岐をフレームワークが引き受けてくれているということです。


ヘッダー受信時にボディの一部が含まれる問題への対処、Content-Type に基づくパーサーの切り替え、不正な入力に対するエラーレスポンス——これらはすべて、フレームワークが日常的に行っている処理の原始的な姿です。

次項では、ここまで積み上げた自作サーバが本番環境で使えない理由を正面から見ます。限界を知ることが、この後 WSGI という仕様に出会ったとき「なぜこれが必要なのか」を腹落ちさせる準備になります。

3.6. この実装の限界

3 章まずは 1 リクエストだけ処理するサーバを作る)を通じて、ソケットだけで動く HTTP サーバを段階的に組み立ててきました。 パスに基づくルーティング、メソッドの分岐、クエリ文字列の解析、POST ボディの受信——ここまでの実装で、Web アプリケーションの骨格と呼べるものができあがっています。

しかし、この自作サーバを本番環境に投入できるかと言えば、答えは明確に「できません」。 本項では、なぜできないのかを具体的に見ていきます。 自作サーバの限界を理解することは、この後の4 章WSGI が生まれた背景)で WSGI という仕様に出会ったとき、「なぜこの仕様が必要なのか」を腹落ちさせるための重要な準備です。

3.6.1. 同時接続できない

現在のサーバの最も根本的な制約は、一度にひとつのリクエストしか処理できないことです。

diagram

ループを使って複数のリクエストを順番に処理することはできます。 しかし、あるリクエストの処理中に別のクライアントが接続してきた場合、そのクライアントは最初のリクエストの処理が完了するまで待たされます。 accept() がブロックし、recv() がブロックし、sendall() がブロックする——シングルスレッド・シングルプロセスのサーバでは、すべての操作が直列に実行されるため、並行処理ができません。

具体的にどう問題になるか

たとえば、あるリクエストの処理でデータベースに問い合わせており、応答に 3 秒かかるとします。 この 3 秒間、サーバは完全に停止しています。 その間に他の 10 人のユーザーがアクセスしてきても、全員が待たされます。 最後のユーザーは最大 30 秒待つことになります。

本番環境のサーバがこの問題をどう解決しているかは、Vol.3「なぜ Web 開発で並行処理が重要なのか」で詳しく解説します。 大きく分けて、以下の3つのアプローチがあります。

  • マルチプロセス(Gunicorn のプリフォークモデル)

  • マルチスレッド

  • 非同期 I/O(asyncio ベースの Uvicorn)

3.6.2. エラー処理が弱い

現在のサーバは、「正常なリクエストが届く」ことを暗黙的に前提としています。 しかし現実には、あらゆる段階で異常が発生しえます。

  • リクエストラインの解析で parts = first_line.split(" ") としていますが、もしリクエストラインに空白が含まれていなければ、parts[1] でインデックスエラーが発生します。

  • Content-Length の値を int() で変換していますが、値が数字でなければ ValueError が発生します。

  • json.loads() でパースが失敗するケースには対処しましたが、parse_headers() の中で不正な形式のヘッダー行が届いた場合の対処はしていません。

危険

これらの例外がビュー関数内で発生した場合、try ... except で囲んでいなければプロセス全体がクラッシュします。 ひとつの不正なリクエストでサーバが停止してしまいます。

本番品質のサーバやフレームワークは、リクエスト処理のあらゆる段階を try ... except で防御し、未処理の例外が発生しても 500 Internal Server Error を返してサーバ自体は動き続けるように設計されています。 Django の黄色いエラーページ(DEBUG = True 時)や、FastAPI の自動的な 422 レスポンスは、こうした防御の仕組みの表れです。

自作サーバに同等の防御を実装しようとすると、リクエストの受信、パース、ハンドラーの実行、レスポンスの送信——それぞれの段階にエラーハンドリングのコードが必要になり、本来のロジックよりも防御コードのほうが多くなるということが起こります。

3.6.3. セキュリティが弱い

2-7 で触れた巨大リクエストの問題について、現在のサーバには何の防御もありません。

ヘッダーの受信ループは、\r\n\r\n が見つかるまで際限なくデータを読み続けます。 クライアントが終わりのないヘッダーを送り続ければ、サーバのメモリは際限なく消費されます。 Content-Length に巨大な値が指定された場合も、その分だけメモリにボディを蓄積しようとします。

# 現在のコード — 上限がない
buffer = b""
while b"\r\n\r\n" not in buffer:
    chunk = client_conn.recv(4096)
    if not chunk:
        return None, None
    buffer += chunk

最低限の防御として、バッファサイズの上限を設ける必要があります。

MAX_HEADER_SIZE = 8192  # 8KB

buffer = b""
while b"\r\n\r\n" not in buffer:
    if len(buffer) > MAX_HEADER_SIZE:
        # ヘッダーが大きすぎる → 400 を返して切断
        ...
    chunk = client_conn.recv(4096)
    if not chunk:
        return None, None
    buffer += chunk

同様に、Content-Length の値にも上限を設けなければなりません。 ボディの受信に対してもタイムアウトを設定し、Slowloris 攻撃のような「ゆっくりデータを送りつける」手法への対策も必要です。

警告

リクエストの内容をそのままレスポンスに含めている箇所にも注意が必要です。 たとえば f"Not Found: {path}"f"User created: name={name}" という記述は、クライアントが送ってきた値をエスケープせずにレスポンスに含めています。

Content-Typetext/plain であれば直接的な問題にはなりにくいですが、HTML として返す場合は クロスサイトスクリプティング(XSS) の脆弱性になります。 クライアントが name<script>alert('XSS')</script> を含めて送信すれば、そのスクリプトがブラウザで実行されてしまいます。

Django のテンプレートエンジンがデフォルトで HTML エスケープを行う理由は、この種の脆弱性を構造的に防ぐためです。

3.6.4. keep-alive に未対応

現在のサーバは、すべてのレスポンスに Connection: close を含めており、1リクエストごとに TCP コネクションを閉じています。 2-2 で解説したとおり、HTTP/1.1 では持続的接続がデフォルトです。 Connection: close を送るのは仕様上は正しいですが、パフォーマンスの面では大きな損失です。

ブラウザが HTML ページを取得し、その中に CSS、JavaScript、画像への参照が複数あった場合、持続的接続であればすべてを1本のコネクション上で取得できます。 しかし、Connection: close で毎回閉じてしまうと、ブラウザはリソースごとに TCP の3ウェイハンドシェイク(HTTPS ならさらに TLS ハンドシェイク)をやり直さなければなりません。

持続的接続に対応するには、レスポンスを返した後にコネクションを閉じずに次のリクエストを待ち受けるループが必要です。 一見簡単そうですが、いくつかの厄介な問題が伴います。

  • 各リクエストのボディを正確に読み切らなければなりません。ボディを1バイトでも読み残すと、その残りが次のリクエストの先頭として解釈され、以降のすべての通信が破綻します。

  • keep-alive タイムアウトの管理が必要です。クライアントが次のリクエストを送ってこない場合、サーバはいつまでコネクションを維持するべきか、適切なタイムアウト設定が欠かせません。

  • そして、同時接続の問題が再び顔を出します。持続的接続でコネクションが長時間維持されると、シングルスレッドのサーバでは他のクライアントが接続できない時間がさらに長くなります。

3.6.5. HTTP 仕様への追従が難しい

ここまで挙げてきた問題は、自作サーバに機能を追加していけば、ひとつずつ解決できるものです。 しかし、すべてに対処しようとすると、コードの複雑さは急速に膨れ上がります。

HTTP/1.1 の仕様(RFC 9110〜9112)は、本項で触れた内容以外にも多くの要件を定めています。

  • Transfer-Encoding: chunked のパースとデチャンク処理

  • Expect: 100-continue ヘッダーへの対応(クライアントがボディを送る前にサーバの許可を求めるプロトコル)

  • Host ヘッダーの検証とバーチャルホストの振り分け

  • 条件付きリクエスト(If-Modified-SinceIf-None-Match)への対応

  • Range ヘッダーによる部分コンテンツの配信

  • 文字エンコーディングの適切なネゴシエーション

これらすべてを自分で実装し、テストし、維持し続けるのは現実的ではありません。 また、その必要もありません。 ここまでの自作サーバの目的は、HTTP サーバの仕組みを理解することであって、本番品質の HTTP サーバを作ることではないからです。


本項で見てきた限界は、裏を返せば「本番品質のサーバやフレームワークが解決してくれている問題の一覧」です。 同時接続の処理、堅牢なエラーハンドリング、セキュリティ対策、持続的接続、HTTP 仕様への準拠——Gunicorn や Uvicorn、Django や FastAPI が存在する理由が、自作サーバの限界を通じて体感できたのではないでしょうか。

しかし、ここでもうひとつ大きな問題があります。 仮に完璧な HTTP サーバを自作できたとしても、そのサーバの上で動くアプリケーションは、そのサーバ専用のコードになってしまいます。 別のサーバに乗り換えたければ、アプリケーションを書き直さなければなりません。 サーバとアプリケーションが密結合しているのです。

次の4 章WSGI が生まれた背景)では、この問題を解決するために生まれた WSGI という仕様に出会います。 「サーバとアプリケーションの間に共通のインタフェースを定める」という発想が、Python の Web エコシステムをどのように変えたのかを見ていきましょう。

3.7. 「Web フレームワーク以前」にある責務

3 章まずは 1 リクエストだけ処理するサーバを作る)を通じて、ソケットだけで HTTP サーバを組み立ててきました。 前項ではその限界を確認し、本番品質のサーバがいかに多くの問題を解決しているかを見ました。

本項では、これまでに書いたコードを振り返りながら、サーバがリクエストを受け取ってからレスポンスを返すまでに必要な責務を整理します。 ここで「責務」と呼んでいるのは、どんな Web アプリケーションでも必ず誰かが引き受けなければならない仕事のことです。 自作サーバではすべてを自分で書きましたが、実際の開発ではこれらの責務がサーバ、フレームワーク、アプリケーションコードに分担されています。

その分担の境界線を引くのが、次の4 章WSGI が生まれた背景)で学ぶ WSGI です。 本項は、WSGI の必然性を理解するための最後の準備です。

3.7.1. パース

最初の責務は、TCP ソケットから届いたバイト列を、意味のある構造に変換することです。

diagram

自作サーバでは、この処理を複数の関数に分けて書きました。

  • receive_request() でヘッダーの終端を検出してボディを受信

  • parse_request_line() でメソッドとパスを取り出し

  • parse_headers() でヘッダーフィールドを辞書に変換

  • parse_full_path() でパスとクエリ文字列を分離

これらは1行1行が「HTTP の仕様に従ってバイト列を解釈する」コードです。 \r\n で行を分割し、最初の行を空白で3つに分け、ヘッダー行をコロンで2つに分け、クエリ文字列を &= で分解します。 手続きとしては単純ですが、2-5 で見たように、部分受信やエンコーディングの問題が絡むと途端に複雑になります。

注釈

パースの責務は、実際の開発では**サーバ(Gunicorn や Uvicorn)**が主に担当します。 サーバがバイト列をパースして構造化されたデータに変換し、それをフレームワークに渡します。 WSGI の場合、パース結果は environ という辞書に格納されます。 フレームワークは生のバイト列を直接扱う必要がなく、environ 辞書からメソッド、パス、ヘッダーなどを取り出すだけで済みます。

自作サーバでパースのコードを書く大変さを経験した今、「サーバがパースを引き受けてくれる」ことの価値が実感できるのではないでしょうか。

3.7.2. ルーティング

パースによってリクエストの情報が取り出せたら、次はその情報に基づいて、どの処理を実行するかを決定する責務です。

自作サーバでは、handle_request() の中に if ... elif ... else の連鎖を書きました。

if path == "/" and method == "GET":
    ...
elif path == "/users" and method == "POST":
    ...
elif path == "/contact" and method == "GET":
    ...

エンドポイントが5つや10個であれば、この書き方でもなんとかなります。 しかし、実際のアプリケーションでは数十、あるいは数百のエンドポイントが存在します。 さらに、/users/42/ のようにパスの一部が動的なパラメータであるケースも一般的です。

フレームワークのルーティングが解決すること

  • Django の urlpatterns は、正規表現やパスコンバーター(<int:user_id>)を使ったパターンマッチングで、動的なパスを扱えます。

  • FastAPI の @app.get("/users/{user_id}") も、パスパラメータの抽出と型変換を自動で行います。

ルーティングの責務はフレームワークが担当します。 サーバはリクエストのパスやメソッドをパースしてフレームワークに渡しますが、「どのハンドラーを呼ぶか」を決めるのはフレームワークの仕事です。 自作サーバではパースとルーティングが handle_request() の中に混在していましたが、この2つが別の責務であることを意識しておくと、WSGI の設計思想がすんなり理解できます。

3.7.3. 例外処理

Web アプリケーションでは、あらゆる段階で例外が発生する可能性があります。

  • パースの段階: 不正なリクエスト形式による例外

  • ルーティングの段階: パスに一致するハンドラーが存在しない状況

  • ハンドラーの実行中: データベースの接続エラーやビジネスロジックのバグによる例外

これらの例外を適切にキャッチし、クライアントに意味のあるエラーレスポンスを返すことが、例外処理の責務です。

自作サーバでは、JSON パースの失敗に対して try ... except400 Bad Request を返す処理を書きました。 しかし、それ以外の多くの箇所では例外処理を省略していました。

フレームワークは、この責務を体系的に引き受けます。

  • Django はビュー関数で発生した未処理の例外をキャッチし、DEBUG = True であれば詳細なエラーページを、False であれば汎用的な 500 エラーページを返します。

  • Http404 例外を投げれば 404 レスポンスに、PermissionDenied 例外を投げれば 403 レスポンスに自動的に変換されます。

  • FastAPI も同様に、HTTPException を投げればステータスコードとメッセージを含む JSON レスポンスが自動生成されます。

重要

重要なのは、例外処理の責務が複数の層に分散しているという点です。 パースの段階で発生する例外はサーバが処理すべきものであり、ビジネスロジックの例外はフレームワークやアプリケーションコードが処理すべきものです。 WSGI でサーバとフレームワークが分離されると、この責務の分担が明確になります。

3.7.4. レスポンス生成

最後の責務は、処理結果を HTTP レスポンスの形式に変換してクライアントに送り返すことです。

自作サーバでは、build_status_line()build_headers()build_response()make_response() という関数群を作ってこの責務を担いました。 ステータスラインの組み立て、ヘッダーのフォーマット、Content-Length の計算、文字列からバイト列へのエンコーディング、空行の挿入——これらすべてが、レスポンス生成の責務に含まれます。

フレームワークが提供するレスポンスオブジェクトは、この責務をカプセル化したものです。

  • Django の HttpResponse("Hello") は、文字列をボディとして持ち、Content-Type のデフォルトを設定し、ステータスコードを保持するオブジェクトです。

  • JsonResponse({"key": "value"}) は、さらに辞書を JSON 文字列に変換し、Content-Typeapplication/json に設定してくれます。

注釈

レスポンス生成の責務もまた、サーバとフレームワークに分担されています。 フレームワークは「何を返すか」(ステータスコード、ヘッダー、ボディ)を決定し、サーバは「どう返すか」(バイト列への変換、TCP ソケットへの書き込み)を担当します。 WSGI では、この分担が start_response() と iterable という仕組みで明確に定義されています。


重要

ここまでで、Web アプリケーションの処理に必要な4つの責務を整理しました。

  1. パース — バイト列を構造化データに変換する

  2. ルーティング — リクエストに対応するハンドラーを決定する

  3. 例外処理 — 異常を適切なエラーレスポンスに変換する

  4. レスポンス生成 — 処理結果を HTTP レスポンスとしてクライアントに返す

自作サーバでは、これらの責務がすべてひとつのコードベースの中に混在していました。 パースの直後にルーティングがあり、ルーティングの中でレスポンスを生成し、例外処理はところどころに散在している。 コードが小さいうちはこれでも管理できますが、アプリケーションが成長するにつれて、責務の混在は保守性を著しく損ないます。

そしてもうひとつ、より根本的な問題があります。 自作サーバのコードでは、サーバの仕事(ソケット操作、パース)とアプリケーションの仕事(ルーティング、ビジネスロジック)が分離されていません。 サーバを変えたければアプリケーションを書き直す必要があり、アプリケーションを別のサーバで動かすこともできません。

4 章WSGI が生まれた背景)で学ぶ WSGI は、まさにこの問題を解決するために生まれた仕様です。 「サーバはここまで、アプリケーションはここから」という境界を定め、両者を自由に組み合わせられるようにする。 3 章まずは 1 リクエストだけ処理するサーバを作る)で自作サーバの責務を自分の手で書き、その大変さと限界を体験した今、WSGI の設計思想が単なる技術仕様ではなく、実際の問題への解答であることが見えるはずです。

3.8. トラブルシューティングの観点

3 章まずは 1 リクエストだけ処理するサーバを作る)では、ソケットから HTTP サーバを自作し、その限界と責務の分担を確認してきました。 本項では少し角度を変えて、ここまでに得た知識が実際のトラブルシューティングでどう役立つかを考えます。

フレームワークを使った開発に戻ったとき、ソケットや生の HTTP を直接触ることはほとんどありません。 しかし、トラブルが起きたとき——特に「なぜかうまくいかない」としか言いようのない状況に陥ったとき——フレームワークの抽象を一段剥がして、生の HTTP を見る力が問題解決の突破口になります。

3.8.1. 生 HTTP を見る価値

フレームワークが提供する request オブジェクトや response オブジェクトは、HTTP メッセージの便利な抽象です。 しかし、抽象を通して見ていると、問題の原因が抽象の「向こう側」にある場合に気づけません。

たとえば、あるAPI エンドポイントが「リクエストボディが空です」というエラーを返すとします。 クライアント側は確実に JSON を送っているはずなのに、サーバ側の request.body が空になっている。 フレームワークのコードをいくら読んでも、原因がわからない。

こうしたとき、フレームワークに届く前の段階で、実際に何が送られてきているかを確認することが第一歩です。 自作サーバの recv_demo.py(2-4 で書いたコード)のように、ソケットから直接バイト列を読み取って表示すれば、リクエストの生の姿が見えます。

# debug_server.py — 届いたリクエストをそのまま表示する
import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 9999))
server.listen(5)
print("Debug server listening on 127.0.0.1:9999")

while True:
    client_conn, client_addr = server.accept()
    data = client_conn.recv(8192)
    print(f"--- From {client_addr} ---")
    print(data.decode("utf-8", errors="replace"))
    print("--- End ---")
    client_conn.sendall(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nOK")
    client_conn.close()

この簡易デバッグサーバにリクエストを向ければ、HTTP メッセージの生のテキストがそのまま表示されます。 ヘッダーが正しく付いているか、ボディが含まれているか、改行コードは \r\n か——フレームワークの抽象を通さずに確認できます。

先ほどの「ボディが空になる」問題も、このサーバで確認すれば原因が見つかるかもしれません。

  • 実は Content-Length ヘッダーが付いていなかった

  • Transfer-Encoding: chunked で送られていてサーバが対応できていなかった

  • リバースプロキシがボディを削除していた

2 章HTTP は何をやりとりしているのか)と3 章まずは 1 リクエストだけ処理するサーバを作る)で HTTP メッセージの構造を自分の手で扱った経験があれば、生のテキストを見て異常を発見する目が養われています。

3.8.2. curl で再現する

トラブルシューティングの鉄則のひとつは、問題を最小限の手順で再現することです。 ブラウザや複雑なクライアントアプリケーションを介してしか問題が再現できないとき、原因の特定は困難です。変数が多すぎるからです。

curl は、この再現作業において最も頼りになるツールです。 HTTP リクエストのあらゆる要素——メソッド、パス、ヘッダー、ボディ——を明示的に指定でき、余計なものが一切入らないからです。

ブラウザから送った POST リクエストがうまく動かないとき、まず curl で同じリクエストを再現してみます。

curl -v -X POST http://localhost:8000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Taro", "email": "[email protected]"}'

-v オプションは、送信するリクエストと受信するレスポンスの詳細をすべて表示します。 リクエストラインの > 行、レスポンスのステータスラインとヘッダーの < 行を確認することで、通信の全貌が見えます。

Tip

curl による切り分けの有効な使い方:

  • curl で再現できれば、問題はサーバ側にあります。

  • curl では正常に動くがブラウザからは動かないなら、問題はブラウザ側(CORS、Cookie、リダイレクトなど)にあります。

この切り分けだけで、調査範囲は半分になります。

curl には他にも有用なオプションがあります。

オプション

用途

-H

ヘッダーの追加や上書き

-d

ボディの指定

-X

メソッドの指定

-v

送受信の詳細表示

特定のヘッダーを外して送ってみる、Content-Type を変えてみる、ボディを空にしてみる——こうした「ひとつだけ変えて試す」実験が、curl なら簡単にできます。

# Content-Type を省略してみる
curl -v -X POST http://localhost:8000/api/users \
  -d '{"name": "Taro"}'

# Content-Type を間違えてみる
curl -v -X POST http://localhost:8000/api/users \
  -H "Content-Type: text/plain" \
  -d '{"name": "Taro"}'

# ボディを空にしてみる
curl -v -X POST http://localhost:8000/api/users \
  -H "Content-Type: application/json"

それぞれの結果を見比べることで、「サーバがどの要素を見て判断を下しているか」が浮かび上がります。 3-4 で Content-Type に基づいてパーサーを切り替えるコードを自分で書いた経験があれば、この実験の意味がより深く理解できるはずです。

注釈

Python の requests ライブラリや httpx もクライアントとして使えますが、デバッグの初動には curl のほうが向いています。 ライブラリは内部で自動的にヘッダーを付加したり、リダイレクトを追従したりするため、「実際に何が送られているか」を正確に把握しにくいことがあります。 curl は指定したものだけを送り、指定しなかったものは送りません。この明示性が、トラブルシューティングでは重要です。

3.8.3. ログに何を出すべきか

問題が発生したとき、その場で curl やデバッグサーバを使って調査できるとは限りません。 本番環境で断続的に発生する問題は、ログを頼りに後から追跡するしかない場合がほとんどです。 そのとき、何がログに記録されているかが調査の成否を左右します。

3 章まずは 1 リクエストだけ処理するサーバを作る)で HTTP サーバを自作した経験から、ログに記録すべき情報が具体的に見えてきます。

ログに記録すべき情報

リクエストの基本情報(最低限)

  • メソッド、パス、クエリ文字列

  • クライアントの IP アドレス

レスポンスの基本情報

  • ステータスコード(結果の概要)

  • 処理時間(パフォーマンス問題の検出に必須)

コンテキスト情報(診断に役立つ)

  • リクエストの Content-TypeContent-Length

  • 認証済みユーザーの ID

  • セッション ID

「普段は 50ms で返している API が、特定の時間帯だけ 5 秒かかっている」といった情報は、処理時間のログがなければ見つけられません。

自作サーバに簡単なログ出力を追加するなら、次のようになります。

import datetime


def log_request(client_addr, method, path, status_code, elapsed_ms):
    timestamp = datetime.datetime.now().isoformat()
    print(
        f"{timestamp} {client_addr[0]} "
        f'"{method} {path}" {status_code} {elapsed_ms:.1f}ms'
    )
2025-01-15T10:30:45.123456 127.0.0.1 "GET /users" 200 12.3ms
2025-01-15T10:30:45.234567 127.0.0.1 "POST /users" 201 45.6ms
2025-01-15T10:30:45.345678 127.0.0.1 "GET /unknown" 404 1.2ms

この形式は、Nginx や Gunicorn のアクセスログとよく似ています。 実際、本番環境のサーバが出力するログの形式を知っていることは、それ自体がトラブルシューティングの能力です。 ログの各フィールドが何を意味するかを理解し、異常な値を素早く見つけ出す——この力は、自分でログ出力のコードを書いた経験から自然に身につきます。

Django の settings.pyLOGGING 設定が複雑に見えるのは、Python の logging モジュールの仕組みを知らないからかもしれません。 しかし、その設定が最終的にやっていることは「どのレベルのログを、どの形式で、どこに出力するか」を決めているだけです。 そして、出力されるログの中身は、ここで議論した「メソッド、パス、ステータスコード、処理時間」という基本的な情報です。


2 章HTTP は何をやりとりしているのか)と3 章まずは 1 リクエストだけ処理するサーバを作る)で学んだ低レイヤーの知識は、日常の開発で具体的な武器になります。

  • 生の HTTP を見る力は、フレームワークの抽象が問題を隠してしまう場面で効果を発揮します。

  • curl でリクエストを最小限に再現する技術は、変数を減らして原因を特定する基本的な調査手法になります。

  • ログに何を記録すべきかの判断は、リクエストからレスポンスまでの流れを理解していてこそ適切に行えます。

3 章まずは 1 リクエストだけ処理するサーバを作る)はここで終わりです。 TCP ソケットの上に HTTP サーバを自作するという旅を通じて、Web アプリケーションの最も根源的な動作を体験しました。 次の4 章WSGI が生まれた背景)では、自作サーバの限界を超えるために生まれた WSGI という仕様に出会います。 サーバとアプリケーションを分離するこの仕様を理解することで、Django や Flask がなぜあのように動くのかが、根底から見えるようになります。