NET // communication networks
LESSON 29 / 発展編

HTTP プログラミング ─ socket で素手のサーバを書く

標準編 第12回 HTTP/1.1・HTTP/2 詳細 で学んだ HTTP/1.1 の仕様を 「実際に手を動かして動かす」 発展回です。前半は telnet で生バイト列を打つ → curl → Python クライアント → socket で素手の HTTP サーバを書く という流れで、「クライアントが送るバイト列をサーバ側でどう受け止め、何を返すのか」を 同じ生 HTTP の視線 で繋ぎます。後半は 抽象を一段ずつ上げて、ワンライナー(python -m http.server)・BaseHTTPRequestHandler・Flask / FastAPI・Node.js まで、「各レイヤが何を隠してくれているか」を体感します。

学習目標

本講を終えると、以下を達成できるようになります。

本講は 標準編 HTTP/1.1・HTTP/2 詳細 の上に乗るハンズオン発展回です。HTTP のメッセージ構造・ヘッダ・ステータスコード・Cookie の仕組みは標準編ですでに学んだ前提で進めます。telnet による生 HTTP の打ち方は 標準編 telnet で覗くアプリ層プロトコル でも触れていますので、未習であればそちらを先に読むとスムーズです。HTTP/2 / HTTP/3 / TLS の実装 はそれぞれ 発展編 QUIC/HTTP3標準編 TLS/PKI に譲り、ここでは HTTP/1.1 を中心 に扱います。
本講の構成: 前半(第2〜第7節)では telnet で打って、socket で打って、socket で受ける という生バイト列の往復を Python だけで体験し(クライアントもサーバも socket)、最後にデバッグ手段を整理します。後半(第8節以降)では 「もう socket は書かない」 抽象を一段ずつ上げて、サーバ側(http.server → Flask → FastAPI)とクライアント側(http.client → requests → aiohttp)を 両側並走 で整理し、Node.js / Cookie 設計まで広げます。

このレッスンの目次

— 前半:socket レベルで HTTP を体得する —

01 なぜ自分で書くか 普段 Web を使うとき、URL バーに https://example.com と… 02 生 HTTP を打つ HTTP/1.x はテキストベースのプロトコルなので、 TCP の上に正しい順序で文… 03 curl(紹介) curl は HTTP プログラミングの定番 CLI ツールです(macOS / L… 04 socket で HTTP クライアント telnet で手打ちした処理を Python の socket だけで自動化。第5節サーバと socket の両端で対になる… 05 socket で素手のサーバ telnet で打ったバイト列を「サーバ側で」 受ける・解釈する・返す を Python の socket だけで実装する… 06 Cookie を socket で実装 Set-Cookie / Cookie ヘッダの実体を文字列レベルで組み立て、訪問回数カウントを実装。フレームワークが裏で何を… 07 デバッグ手段 HTTP のトラブルは多層に渡るため、 道具を使い分け て切り分けます。「どの層で問…

— 後半:抽象を一段ずつ上げる(応用)—

08 抽象を上げる(両側) サーバ側(http.server → Flask → FastAPI) と クライアント側(http.client → requests → Session) を両側並走で… 09 aiohttp 非同期(発展) 数百〜数千の URL に同時にアクセスしたい場合 ── 同期 requests を for で回すと遅い問題を、非同期で解く… 10 Node.js での HTTP Node.js は JavaScript ランタイムで、 サーバサイド JavaSc… 11 Cookie / Bearer の設計 socket で書いた Cookie の上で、フレームワーク実装と Bearer トークン認証、CSRF と XSS のトレードオフを… 12 まとめ 本講の重要語句を整理 13 確認問題 理解度を問題でチェック

なぜ「自分で書く」のか ─ ブラウザの魔法を分解する

普段 Web を使うとき、URL バーに https://example.com と打てばページが表示されます。これは裏で 名前解決 → TCP 接続 → TLS ハンドシェイク → HTTP リクエスト → レスポンス受信 → HTML パース → 追加リソース取得 → レンダリング という長い処理が走っているからです。ブラウザはこの一連の作業を自動でやってくれる便利な道具ですが、「中で何が起きているか」を知らないと、トラブル時に手も足も出ません。
POINT HTTP を 自分で書く 経験は、以下の場面で必ず効きます。
トラブルシュート:「ブラウザでは見えるのに API が 401 を返す」を切り分ける
API クライアント実装:外部サービスや社内サービスに対して機械的に要求を出す
ボット・スクレイパ・自動テスト:ヘッダや Cookie を制御してアクセスする
観測と監視:エンドポイントのヘルスチェック、レイテンシ測定
セキュリティ調査:ヘッダ改竄、リプレイ、不正な値での挙動確認

道具立ての全体像

HTTP を扱う5つのレイヤと代表ツール ① 生バイト列 telnet, nc(netcat) ── 自分で1文字ずつ HTTP メッセージを打つ。学習用の最強の解像度 ② CLI ツール curl, wget, HTTPie ── ヘッダや Cookie を引数で指定して打てる。スクリプトに組み込みやすい ③ 標準ライブラリ Python http.client / urllib、Node.js http / https ── 言語に最初から入っている低レベル API ④ 高レベルライブラリ requests, httpx, aiohttp, axios, fetch ── Cookie・JSON・接続再利用を自動でやってくれる ⑤ サーバフレームワーク Flask, FastAPI, Express, Django ── ルーティング・ミドルウェア・ORM をまとめた応用層

図の見方:下に行くほど抽象度が高くなり、書きやすくなる代わりに「中で何が起きているか」が見えにくくなる。本講は ① ② ③ を中心に扱い、④ で実用感を、⑤ はさわりだけ紹介します。

つながる知識: 「自分で書く」は理解だけでなく 運用 の質を上げます。例えばブラウザだと再現できないバグも、curl なら同じヘッダを送り続けてサーバの挙動を切り分けられます。プロのサポートエンジニアは ブラウザより先に curl を開く と言われるほどです。

確認: ブラウザで再現できないが curl なら再現できるバグがあるとする。最もあり得るシナリオは?

正解:B。ブラウザは多数のヘッダを自動付与し、CORS や cache 制御も裏で実施する。curl は最小限の HTTP しか送らないので、サーバが「特定のヘッダがあること」「特定のオリジンから来ること」を前提に挙動を変えていると、curl の結果は別物になる。これを使えば「サーバはどのヘッダで挙動を変えているか」を切り分け解析できる ─ プロのデバッグツールとして curl が選ばれる本質的理由。

生 HTTP を手で打つ ─ telnet / nc

HTTP/1.x はテキストベースのプロトコルなので、TCP の上に正しい順序で文字列を流せば サーバは応答してくれます。これを実演する最小の道具が telnet(または nc / netcat) です。普段はブラウザが裏でやっている処理を、自分の手でなぞってみます。
事前準備: Windows 10 / 11 では telnet クライアントが既定で無効化されており、macOS でも High Sierra(10.13)以降は標準コマンドから外されています。手元に telnet が無い場合は nc(netcat)、Windows なら PuTTY の Raw モード、HTTPS まで含めるなら openssl s_client -crlf で代用できます。
POINT 手で打つときの3つの掟:
① 行末は必ず CRLF(\r\n)。LF(\n) だけだと多くのサーバが受け付けない
② ヘッダの最後に 空行(CRLF だけの行) を必ず入れる。これが「ヘッダ終わり」の合図
③ HTTP/1.1 は Host ヘッダが必須。書かないと 400 Bad Request になる

walkthrough:telnet で example.com に GET を打つ6ステップ

STEP 1 ── サーバに TCP 接続する

$ telnet example.com 80
Trying 23.215.0.136...
Connected to example.com.
Escape character is '^]'.

telnet は 引数で指定したホスト・ポート に TCP 接続するだけのツール。ここまでで TCP の 3-way ハンドシェイクが完了しています。下層は 標準編 TCP 参照。

STEP 2 ── リクエスト行を打つ

GET / HTTP/1.1

「メソッド SP URI SP HTTP-バージョン」。Enter キーを押すと CRLF が送信されます(端末によっては LF だけの場合があるので、後述の nc -C のような工夫が必要)。

STEP 3 ── ヘッダ行を1行打つ

Host: example.com

HTTP/1.1 で必須の Host ヘッダ。同じ IP に複数のサイトが同居している前提なので、これを書かないと「どのサイトの / を要求しているのか」サーバに伝わりません。

STEP 4 ── 空行を打つ(超重要)

(ここで Enter だけを押す = CRLF だけの行)

これが「ヘッダ終わり、本文に入ります」という合図です。GET には本文がないので、空行の直後にサーバが処理を開始します。

STEP 5 ── レスポンスを受け取る

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 1256
Date: Sun, 26 Apr 2026 03:21:08 GMT

<!doctype html>
<html>
<head>…</head>
…

サーバから ステータス行 → ヘッダ → 空行 → 本文 が流れてきます。Content-Length の数だけ本文を読めばよい、というのがクライアントの基本動作です。

STEP 6 ── コネクションを切る

Connection closed by foreign host.

HTTP/1.1 はパーシステント接続がデフォルトですが、Connection: close を付けるか、サーバのアイドルタイムアウトを過ぎるとサーバ側から切ってきます。telnet は ^](Ctrl+]) → quit でも切れます。

送信メッセージの可視化

telnet で打つ生バイト列(改行は CRLF = \r\n) GET / HTTP/1.1\r\n ← リクエスト行 Host: example.com\r\n ← ヘッダ行(必須) User-Agent: handcraft/1.0\r\n ← ヘッダ行(任意) \r\n ← 空行(これがヘッダ終わりの合図!) (本文なし。GET なので空)

図の見方:HTTP メッセージは「行」がすべて CRLF で終わるテキストの並び。空行(\r\n だけの行) が「ここでヘッダが終わる」を示すデリミタになっている。空行を忘れるとサーバはずっと「もっとヘッダが来るかも」と待ち続け、応答が返ってこない。

Q. もし行末を LF(\n) だけ で送ったらどうなるでしょう? なぜ CRLF(\r\n) が必須なのか?

実環境では多くのサイトが HTTPS のみ(80 番は 301 で 443 にリダイレクト) です。telnet は平文しか喋れないので、TLS 越しの HTTPS には接続できません。HTTPS で生 HTTP を打ちたいときは openssl s_client -connect example.com:443 を使うと、TLS ハンドシェイク後の平文セッションを開けます(詳細は TLS/PKI 標準編)。

curl ─ telnet の一段上、Python の一段下(紹介)

curl は HTTP プログラミングの定番 CLI ツールです(macOS / Linux / Windows 10 以降に概ね標準搭載)。telnet が「全部手打ち」だったのに対し、curl は 面倒な部分(TCP 接続、TLS、Host ヘッダ、Content-Length 計算など) を自動でやった上で、必要な部分だけ引数で上書き できます。本講では「あるツール」として前提し、各節で必要に応じて使っていきます。
導入: macOS / Linux は標準で同梱、Windows 10 以降も標準コマンドとして付属しています。curl --version で確認できます。主要オプション -v(verbose) -H(ヘッダ追加) -X(メソッド) -d(本文) -c -b(Cookie) -o -i(出力)を組み合わせれば GET / POST / Cookie / JSON / multipart など実用ケースの大半をカバーできます。HTTPie のような「人にやさしい curl」 も同じ思想の派生ツールです。

触りだけ:1行で example.com を取る

# 本文だけ標準出力
$ curl https://example.com/

# -v(verbose) で送信ヘッダ・受信ヘッダ・TLS の様子を全部見る
$ curl -v https://example.com/

telnet で十数行手打ちしていた処理が、curl では 1行-v を付ければ「自分が何を送ったか(>)」「サーバが何を返したか(<)」が両方見えるので、telnet 手打ちの代わりに HTTP の挙動を観察 する用途にも使えます。

4ツールで「同じ GET を打つ」比較

初学者がよく混乱する 「同じことをやるのに書き方がツールごとに違う」 問題。example.com に GET を打つ という同じ動作を、4つのツールで並べてみます。

telnet(生バイト)

$ telnet example.com 80
GET / HTTP/1.1
Host: example.com

(空行 ← ここで Enter)

最も低レベル。CRLF・空行・Host ヘッダを 全部自分で 用意する必要がある。

curl(CLI)

$ curl http://example.com/

# verbose 表示で何が起きているかを見る
$ curl -v http://example.com/

1行で済む。Host ヘッダ・User-Agent・Accept は curl が自動で付ける。シェルスクリプトに組み込みやすい

Python (requests)

import requests

r = requests.get("http://example.com/")
print(r.status_code)        # 200
print(r.headers["Content-Type"])
print(r.text[:200])

Python 側からアクセスして 結果をプログラムで処理 したいときの定番。レスポンスがオブジェクトとして扱える。

Node (fetch)

// Node.js 18+ の組み込み fetch
const res = await fetch("http://example.com/");
console.log(res.status);              // 200
console.log(res.headers.get("content-type"));
console.log((await res.text()).slice(0, 200));

Node.js 18 以降は fetch がブラウザ互換 API として組み込まれた。サーバサイドでもブラウザと同じ書き方で使える。

図の見方:同じ HTTP リクエストでも、ツールが違えば書き方が大きく変わる。低レベル(telnet) ほど自由度が高く面倒、高レベル(fetch / requests) ほど書きやすいが内部が見えにくい。場面に応じて使い分ける のが上級者の道具立て。

つながる知識: 上の例の2つ目(curl)で出てくる -v 1個だけでも、HTTP の挙動観察にはかなり強力です。-H(ヘッダ追加)や -d(本文)を組み合わせて POST / Cookie / multipart まで扱えるようになると、ブラウザ操作の大半は再現できます。HTTPie のような「人にやさしい curl」も同じ思想の派生ツールとして使われます。

確認: curl でブラウザの動きを完全に再現したい。サーバ側のログを見ながら curl の挙動をブラウザに近づけるためのオプションとして、最も効果が大きいのはどれか?

正解:C。サーバの挙動はリクエストヘッダの組み合わせで決まることが多く、特に Cookie / User-Agent / Origin / Referer の影響が大きい。Chrome / Firefox の DevTools には 「Copy as cURL」 が標準搭載されており、ブラウザの実際のリクエストをすべて再現する curl コマンドが一発で取れる。本物のブラウザバグ調査でこれが最も使われる手段。-k はデバッグ目的の悪手(証明書問題を隠す)。

Python の socket で HTTP クライアントを書く

第2節では telnet で生 HTTP リクエストを手打ちしました。本節では同じ手順を Python の socket モジュールだけ で書いて自動化します。フレームワークも http.client も使わない、「TCP ソケットに HTTP メッセージ文字列を書いて、戻りの bytes を読むだけ」 の最小クライアントです。次の 第5節 socket サーバ対になる パートで、クライアントとサーバの両側が socket に揃います。
POINT HTTP クライアントの本質は次の4行に集約できます。
TCP で接続する(socket()connect())
リクエスト行 + ヘッダ + 空行 を bytes として書き込む(sendall())
戻りの bytes を全部読む(recv() を繰り返す / または recv しきるまで)
ステータス行・ヘッダ・本文を空行で区切ってパース
── 第2節で telnet の指が打っていた処理を、Python のコードが代わりに打つだけ。

simple_http_client.py ── 全文(30 行ちょい)

example.com に GET を打ってレスポンスを表示する 最小クライアントです。HTTP/1.0 を使う(Connection: close で1往復したらサーバ側から切ってもらう)ことで、Keep-Alive のフレーミング処理を省いてシンプルに保ちます。

import socket

HOST = "example.com"
PORT = 80

# 第2節で telnet に打ったのと「全く同じ文字列」
REQUEST = (
    f"GET / HTTP/1.0\r\n"
    f"Host: {HOST}\r\n"
    f"User-Agent: handcraft/1.0\r\n"
    f"Connection: close\r\n"
    f"\r\n"
)


def main():
    # ① TCP で接続
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(10)
    sock.connect((HOST, PORT))

    # ② リクエストを bytes として書き込む
    sock.sendall(REQUEST.encode("utf-8"))

    # ③ サーバが close するまで読み続ける
    chunks = []
    while True:
        data = sock.recv(4096)
        if not data:
            break
        chunks.append(data)
    sock.close()
    response = b"".join(chunks).decode("utf-8", errors="replace")

    # ④ 空行でヘッダと本文を分離
    header_part, _, body = response.partition("\r\n\r\n")
    print("---- Response Headers ----")
    print(header_part)
    print("---- Body (先頭 300 文字) ----")
    print(body[:300])


if __name__ == "__main__":
    main()

walkthrough:4ステップで読む

STEP 1 ── TCP で接続する

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect((HOST, PORT))

AF_INET = IPv4、SOCK_STREAM = TCP。connect() はサーバの IP に対して 3-way ハンドシェイク を実行します。settimeout を付けないと応答が無いサーバで永久に止まります。telnet で言う「$ telnet example.com 80」と書いた瞬間と等価。

STEP 2 ── リクエスト行+ヘッダ+空行 を書き込む

REQUEST = (
    f"GET / HTTP/1.0\r\n"
    f"Host: {HOST}\r\n"
    f"User-Agent: handcraft/1.0\r\n"
    f"Connection: close\r\n"
    f"\r\n"
)
sock.sendall(REQUEST.encode("utf-8"))

文字列リテラルが 第2節で telnet に手打ちした内容そのものCRLF 必須・空行でヘッダ終わり・Host 必須 ── 第2節で繰り返した3つの掟がそのまま現れます。.encode("utf-8") で bytes に変換するのが Python での「キーボードを打つ」相当の動作。

注意: HTTP/1.1 を使うなら Keep-Alive がデフォルトなので、サーバ側がコネクションを保持し続け、クライアントが「いつ読み終わるか」を Content-LengthTransfer-Encoding: chunked から自力で判断する必要があります。学習用に HTTP/1.0 + Connection: close を使うのは、サーバが close した時点 = 受信終了 と単純化するため。

STEP 3 ── サーバが close するまで recv ループ

chunks = []
while True:
    data = sock.recv(4096)
    if not data:        # サーバが close すると空 bytes が返る
        break
    chunks.append(data)
sock.close()
response = b"".join(chunks).decode("utf-8", errors="replace")

recv(4096)最大 4096 バイト読む ですが、レスポンス全部を一発で読める保証はありません。サーバが close するまで繰り返す のがコツ。空 bytes が返ったらそれが「もう来ない」の合図。── サーバ側 (第5節) の client_sock.close() がこの空 bytes を生み出しています。

STEP 4 ── 空行でヘッダと本文を分離する

header_part, _, body = response.partition("\r\n\r\n")
print("---- Response Headers ----")
print(header_part)
print("---- Body (先頭 300 文字) ----")
print(body[:300])

HTTP レスポンスも「ステータス行 → ヘッダ → 空行 → 本文」というテキスト構造。"\r\n\r\n"str.partition すれば、ヘッダ部と本文を一発で分けられます。本文の長さを正確に取りたい なら、さらに header_part の中から Content-Length: 行を見つけて int に直す処理が要りますが、今回は close 終端なので「最後まで読んだものが本文」で済みます。

クライアント側とサーバ側が socket で繋がる

第4節(クライアント)と第5節(サーバ)で socket が両端で対応する 第4節:simple_http_client.py sock.connect((HOST, PORT)) sock.sendall(REQUEST.encode()) ↓ (HTTP メッセージを書く) data = sock.recv(4096) ↑ (close まで読む) sock.close() GET / HTTP/1.0... HTTP/1.0 200 OK... 第5節:simple_http_server.py server_sock.accept() request = client_sock.recv(...) ↑ (左の sendall が届く) client_sock.sendall(RESPONSE.encode()) ↓ (左の recv で読まれる) client_sock.close()

図の見方:左が本節のクライアント、右が次の第5節のサーバ。クライアントの sendall が、サーバの recv の戻り値そのもの。サーバの sendall が、クライアントの recv の戻り値そのもの。同じ HTTP メッセージを socket の両端から見ているだけ。

このクライアントも学習専用。 Content-Length に基づく正確な読み取りなし、Keep-Alive なし、HTTPS 不可、リダイレクト追跡なし、Cookie 自動管理なし、JSON 自動パースなし。実用ではこれらを 全部自分で書かないために 標準ライブラリ http.client やサードパーティ requests を使います ── それらの話は 後半 第8節 抽象を上げる に整理しました。

つながる知識: 本節と第5節で HTTP クライアントとサーバの「最小骨格」 が両方揃いました。これ以降のレッスンは 「同じ動きを抽象を上げた書き方で書く」 旅です ── http.clientrequestsaiohttp と上がる「クライアント側の階段」、http.server → Flask → FastAPI と上がる「サーバ側の階段」、両方が後半に出てきます。本節のコードはその階段の最下段 です。

小問:HTTP リクエストで Content-Length ヘッダの値を 本文の実際のバイト数より小さく 設定して送ったら、サーバ側ではどう解釈されるか?
正解:A。サーバは Content-Length の値を そのまま信じて その分だけ本文として読みます。実本文の方が長ければ、残りは 次のリクエストの先頭 として解釈され、メッセージ境界が壊れます。これは HTTP リクエストスマグリング(Request Smuggling) という有名な攻撃の入口にもなる挙動で、proxy / load balancer 跨ぎで深刻な問題を起こします。逆に Content-Length の方が大きければ、サーバは本文の続きを永久に待ってタイムアウトします。本文の長さは絶対に正確に ── これが HTTP/1.1 の鉄則です。本節のクライアントは GET なので本文を送らず、この問題に直接ぶつかりませんが、POST を書くなら Content-Length を1バイトのズレも無く計算する責任を負います。

socket で素手の HTTP サーバを書く

第2節では telnet で クライアント側のバイト列 を手で打ちました。本節では サーバ側 を Python の socket モジュールだけで書きます。フレームワークはおろか http.server すら使わない、「ソケットで bytes を読んで、ソケットに bytes を書き返すだけ」 の最小サーバです。これを通すと、後半 第8節 で扱う Web フレームワークが 裏で何を肩代わりしてくれているか がはっきり見えるようになります。
POINT HTTP サーバの本質は次の3行に集約できます。
TCP ポートを listen する(bindlisten)
クライアントから来たバイト列を読む(acceptrecv)
正しい形式のバイト列を書き返してから切る(sendallclose)
── 第2節で打った「GET / HTTP/1.1 ... 空行」は、サーバ側ではこの ② で文字列として取れます。

simple_http_server.py ── 全文(40 行ちょい)

まずは 常に同じ "Hello, World!" を返すだけ の最小サーバです。127.0.0.1:8080 で待ち受け、何が来ても 200 OK を返します。

import socket

HOST = "127.0.0.1"
PORT = 8080

BODY = "<html><body><h1>Hello, World!</h1></body></html>"

RESPONSE = (
    "HTTP/1.0 200 OK\r\n"
    "Content-Type: text/html; charset=utf-8\r\n"
    f"Content-Length: {len(BODY.encode('utf-8'))}\r\n"
    "Connection: close\r\n"
    "\r\n"
    f"{BODY}"
)


def main():
    server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server_sock.bind((HOST, PORT))
    server_sock.listen(5)
    print(f"Listening on http://{HOST}:{PORT}/")

    try:
        while True:
            client_sock, client_addr = server_sock.accept()
            print(f"Connected from {client_addr}")

            request = client_sock.recv(4096).decode("utf-8", errors="replace")
            print("---- Request ----")
            print(request)
            print("-----------------")

            client_sock.sendall(RESPONSE.encode("utf-8"))
            client_sock.close()
    except KeyboardInterrupt:
        print("\nShutting down.")
    finally:
        server_sock.close()


if __name__ == "__main__":
    main()

walkthrough:7行ずつ分解して読む

STEP 1 ── レスポンス文字列を組み立てる

BODY = "<html><body><h1>Hello, World!</h1></body></html>"

RESPONSE = (
    "HTTP/1.0 200 OK\r\n"
    "Content-Type: text/html; charset=utf-8\r\n"
    f"Content-Length: {len(BODY.encode('utf-8'))}\r\n"
    "Connection: close\r\n"
    "\r\n"
    f"{BODY}"
)

HTTP レスポンスは ステータス行 → ヘッダ群 → 空行(\r\n) → 本文 という構造のテキスト。第2節で telnet で 受信 したのと 全く同じ形 を、今度は文字列として組み立てています。行末は CRLF(\r\n)ヘッダ終わりの空行Content-Length は本文のバイト数 ── 第2節で繰り返した「掟」がそのまま現れています。

HTTP/1.0 を使っているのは学習用に 1リクエストごとに切る のが単純で済むため(Connection: close も明示)。本物のサーバは HTTP/1.1 の Keep-Alive を扱う必要があります。

STEP 2 ── ソケットを作って bind / listen

server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind((HOST, PORT))
server_sock.listen(5)

AF_INET = IPv4、SOCK_STREAM = TCP の意味。bind で「自分はこの IP:ポートで受け付ける」と OS に宣言し、listen(5) で「accept 前に最大 5 本まで TCP コネクションを保留できる」と指定します。SO_REUSEADDR停止直後に再起動できる ようにするおまじない(第28回 ネットワークプログラミング で扱った TIME_WAIT 対策)。

STEP 3 ── accept で1コネクション取り出す

while True:
    client_sock, client_addr = server_sock.accept()
    print(f"Connected from {client_addr}")

accept()クライアントが TCP で繋いでくるまで止まる(ブロック)。繋いでくると クライアント専用の新しいソケット client_sock を返してくれます。元の server_sock は次の接続を待つために残ります。

STEP 4 ── recv で生 HTTP リクエストを読む

request = client_sock.recv(4096).decode("utf-8", errors="replace")
print("---- Request ----")
print(request)
print("-----------------")

ここで読めるのは、第2節で telnet 越しに打った あの文字列そのもの です:

GET / HTTP/1.1\r\n
Host: 127.0.0.1:8080\r\n
User-Agent: curl/8.x\r\n
Accept: */*\r\n
\r\n

サーバが「リクエスト行+ヘッダ+空行」を そのまま受信する側 である、という事実をプリント文で目視できます。これが 「telnet 側と socket 側が同じバイト列の両端」 という最大の学びどころ。

注意: recv(4096) は最大 4096 バイトしか読みません。本物のサーバは Content-Length まで繰り返し読む 必要がありますが、学習用には十分です。

STEP 5 ── sendall で組み立てたレスポンスを返す

client_sock.sendall(RESPONSE.encode("utf-8"))
client_sock.close()

受け取ったリクエストの中身に 関係なく、STEP 1 で作った同じ文字列を返します。sendall は「全部送るまで頑張る」版の送信(send は途中までしか送らないこともある)。送り終わったらコネクションを close

STEP 6 ── 動かす・確かめる

# 1つ目の端末でサーバ起動
$ python3 simple_http_server.py
Listening on http://127.0.0.1:8080/

# 2つ目の端末で telnet を叩く(第2節と同じ手順!)
$ telnet 127.0.0.1 8080
GET / HTTP/1.0

(空行)

# あるいは curl で
$ curl -v http://127.0.0.1:8080/

# ブラウザで http://127.0.0.1:8080/ を開いてもよい

サーバ側の標準出力に クライアントが送ったバイト列 がそのまま流れ、ブラウザ・curl・telnet いずれにも同じ "Hello, World!" が表示されることを確かめます。第2節と第5節の真の対比:あちらは telnet が送る側、こちらは Python が受ける側。同じ HTTP メッセージを両端から見ています。

送受信の対応図

第2節(クライアント)と第5節(サーバ)は同じ HTTP メッセージの両端 第2節:telnet(クライアント) $ telnet 127.0.0.1 8080 GET / HTTP/1.0\r\n \r\n ← 空行で送信開始 ↓ TCP コネクションに書き込む HTTP/1.0 200 OK\r\n Content-Type: text/html...\r\n Content-Length: 48\r\n \r\n<html>... request response 第5節:socket(サーバ) request = client_sock.recv(4096) # ↑ 左で打ったバイト列が # ここで request 文字列に ↓ レスポンスを組み立てて返す client_sock.sendall( RESPONSE.encode("utf-8")) # ← 左に届くバイト列を # ここで自前で組み立て

図の見方:左の telnet で打った あの文字列(GET / HTTP/1.0 + 空行)が、右の Python サーバの recv 戻り値そのものになっている。逆向きも同じ ── サーバが sendall したバイト列が、telnet 画面に映る HTTP レスポンス。HTTP は両端で同じバイト列の構文を共有しているだけ、というのが本節で得る最大の納得。

Q. このサーバは パス(URL の / 部分)メソッド(GET / POST)一切見ていない。どんなパスにアクセスしても、どんなメソッドで叩いても、必ず同じ "Hello, World!" を返す。なぜそれでも HTTP として機能するのか?

このサーバは学習専用。 同時接続は1本ずつ、HTTPS なし、エラー処理ほぼなし、4096 バイトでリクエストを打ち切り、HTTP/1.1 Keep-Alive 非対応。本番では絶対に使わないでください。本番の話は第8節以降(抽象を上げる)で。

つながる知識: Python の http.client や Node.js の http.request(後半 第10節) もこれと 本質的に同じこと をしています ── socket に対して「リクエスト行 + ヘッダ + 空行 + 本文」を組み立てて書き込む。本節のサーバはその 逆向き(受けて返す)です。クライアントとサーバの両方を socket で書くと、HTTP は 「TCP の上に置かれた文字列フォーマットの規約」 でしかないことが体感できます。

デバッグ手段の使い分け

HTTP のトラブルは多層に渡るため、道具を使い分け て切り分けます。「どの層で問題が起きているか」によって最適なツールが違います。
道具見える層得意なシーン
curl -vHTTP/1.1 のリクエスト・レスポンス全体ヘッダの値・ステータスコードを正確に確認したい時。最初の道具
Chrome DevTools の Network タブブラウザが投げた HTTP すべてブラウザ操作で再現する不具合の調査。タイミング・キャッシュ・Cookie が見やすい
HTTPie / PostmanHTTP/1.1 のメッセージAPI の対話的探索、ドキュメント例の試行
WiresharkTCP/IP のパケット全部「そもそも TCP がつながってない」「TLS でコケてる」を切り分け
tcpdump同上(CLI 版)サーバ側でリモートにキャプチャ。後で Wireshark で開く
mitmproxyHTTPS 含む HTTP すべてクライアント↔サーバ間に挟まり、TLS を解いて生 HTTP を見る・改変する
サーバアクセスログサーバが受けたリクエスト「リクエストが届いていない」のか「届いているが応答がおかしい」かの一次切り分け

典型的な切り分けフロー

問題発生 「API が動かない」 curl -v で同じ要求 → 同じエラー? ブラウザだけ? サーバアクセスログ確認 → そもそも届いている? 届いていなければ tcpdump → TCP / TLS / DNS で詰まってる? 中継機器を mitmproxy で見る → 改変・喪失してないか 原因特定 → 修正 クライアント / サーバ / 経路 層を1つずつ下に降りて切り分け、原因が見えたら戻って修正する

図の見方:アプリ層(curl)で問題が見えるか確認 → 見えなければサーバログ → 見えなければ TCP/IP 層 → 見えなければ中継機器、と 層を下から確認していく のが原則。やみくもに上の層で再現を試すより、下に降りた方が問題に近づくことが多い。

もっと詳しく:mitmproxy で生 HTTP を覗く

mitmproxy は HTTPS を含む HTTP トラフィックを 中間プロキシ として捕まえ、TLS を一旦解いて生メッセージを表示・改変できるツールです。

# インストール: pip install mitmproxy
$ mitmproxy             # TUI モード
$ mitmweb               # ブラウザ UI モード
$ mitmdump              # 標準出力にダンプ

# クライアント側で proxy を mitmproxy のアドレスに向ける
$ curl -x http://127.0.0.1:8080 -k https://api.example.com/items

mitmproxy の証明書をクライアントに信頼させた上で HTTPS を通すと、TLS 越しの中身を解読 してリクエストとレスポンスを表示します。デバッグには強力ですが、他人の通信に対して使うのは違法・倫理違反 です。必ず自分の権限がある通信だけに使ってください。

─── 後半:もう socket は書かない ───

ここまでの前半で、HTTP サーバの本質は socket への bytes 入出力 であることを体感しました。後半は 抽象を一段ずつ上げて、ワンライナーで動くもの → ハンドラ → フレームワーク → Node.js までを並べ、「各レイヤが前のレイヤから何を隠してくれているか」 を整理します。ここから先は 実務でこう書く という応用パートです。

POINT 各レイヤが 隠してくれている こと:
python -m http.server ── サーバプロセスそのものを書かなくてよい
BaseHTTPRequestHandler ── リクエストのパースとレスポンス組み立てを書かなくてよい
③ Flask / FastAPI ── ルーティング・JSON 変換・バリデーションを書かなくてよい
④ Node.js Express ── 同じことを JavaScript で書ける、フロントと言語を統一できる

抽象を上げる ─ サーバ階段とクライアント階段

Python だけで、同じ「動く HTTP サーバ」同じ「GET を打つクライアント」 をそれぞれ3〜4段階の抽象度で書いてみます。各レイヤは前のレイヤの 面倒な部分を隠す ことで成り立っています。前半の socket 版が その隠された中身 ── 第4節がクライアント階段の最下段、第5節がサーバ階段の最下段でした。本節は両側を 並走 で上ります。

サーバ側の階段:python -m http.server → BaseHTTPRequestHandler → Flask / FastAPI

(a) 静的ファイル配信:1コマンドで動く

# カレントディレクトリを 8000 番ポートで配信
$ python -m http.server 8000

# 別端末から確認
$ curl http://localhost:8000/
$ curl http://localhost:8000/index.html

HTML / JS / 画像をローカルで動作確認したい時の定番。ディレクトリリスティング も自動で出してくれます(本番環境ではセキュリティ的に NG)。第5節の socket サーバを実体としていきなり丸ごと隠した もの ── 自分でファイルを開く、Content-Type を MIME から決める、Content-Length を計算する、すべて勝手にやってくれます。

(b) BaseHTTPRequestHandler:動的応答

第5節の socket サーバを 少しだけ便利にした 標準ライブラリ。do_GETdo_POST をメソッドで定義すれば、メソッドごとの処理が自動でマッピング されます。

from http.server import BaseHTTPRequestHandler, HTTPServer
import json

class MyHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/hello":
            body = json.dumps({"message": "Hello!"}).encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()                       # ← 空行を送信
            self.wfile.write(body)
        else:
            self.send_error(404, "Not Found")

    def do_POST(self):
        n = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(n)                    # ← Content-Length の通り読む
        # … body をパースして処理 …
        self.send_response(201)
        self.end_headers()

if __name__ == "__main__":
    HTTPServer(("0.0.0.0", 8000), MyHandler).serve_forever()

end_headers()空行(CRLF) を送る点に注目 ── 第5節で "\r\n\r\n" を自分で書いた処理と 完全に同じ役割self.pathself.headers は、第6節の parse_request が返していた値の 用意済み版

(c) Flask / FastAPI ─ 実用的なフレームワーク

業務での Web アプリ・API は フレームワーク を使います。標準ライブラリ版より圧倒的に書きやすく、ルーティング・バリデーション・テストも整っています。

Flask(同期)

# pip install flask
from flask import Flask, jsonify, request
app = Flask(__name__)

@app.get("/hello")
def hello():
    return jsonify(message="Hello!")

@app.post("/users")
def create_user():
    body = request.get_json()
    return jsonify(id=1, **body), 201

if __name__ == "__main__":
    app.run(port=8000)

シンプル・歴史も長い。学習・小規模アプリの定番。パス文字列 → 関数 のマッピング(@app.get("/hello")) をデコレータで宣言する点が、第6節の if path != "/": という手書き分岐の 進化版

FastAPI(非同期、型ヒント)

# pip install fastapi uvicorn
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class User(BaseModel):
    name: str
    age: int

@app.get("/hello")
async def hello():
    return {"message": "Hello!"}

@app.post("/users", status_code=201)
async def create_user(user: User):
    return {"id": 1, **user.model_dump()}

# 起動: uvicorn main:app --port 8000

型ヒントから自動でバリデーションと OpenAPI ドキュメント生成。非同期サポート。新規プロジェクトでの人気が高い。

もっと詳しく:Flask vs FastAPI 同じ Hello World の違い

2つとも「/hello に GET したら JSON を返す」だけのコードですが、設計思想に違いがあります。

選ぶ基準は 性能要件・開発規模・チームの慣れ。プロトタイプは Flask、本番 API は FastAPI、というのが2026年時点の典型的な棲み分けです。最新版番号や微細な API 差は各公式ドキュメント参照。

クライアント側の階段:http.client → requests → Session

サーバ側を「socket → http.server → Flask / FastAPI」と上ってきましたが、クライアント側 にもまったく同じ構造の階段があります。前半第4節 の socket クライアントを起点に、ここで一気に階段を上ります。

(d) http.client ─ 標準ライブラリ

第4節で socket に直接書いていた処理を 1つのオブジェクト でまとめた、標準ライブラリの低レベル API。HTTPS にも対応。

import http.client

# HTTPS なら HTTPSConnection、ポートも明示できる
conn = http.client.HTTPSConnection("example.com", 443, timeout=10)

conn.request(
    method="GET",
    url="/",
    headers={"User-Agent": "myapp/1.0", "Accept": "text/html"},
)

res = conn.getresponse()
print(res.status, res.reason)              # 200 OK
print(res.getheader("Content-Type"))       # text/html; charset=UTF-8
body = res.read().decode("utf-8")
print(body[:200])

conn.close()

第4節の socket.sendallrecv ループpartition("\r\n\r\n")conn.request(...) + conn.getresponse() 2行に集約しただけ。Cookie 管理・JSON 変換・接続再利用は まだ自分で書く必要 があります。

(e) requests ─ 圧倒的に使われるサードパーティ

インストール: pip install requests。コードが直感的で、Cookie・JSON・リダイレクト・タイムアウトの扱いが洗練されています。クライアント階段の L3 相当(サーバ側で言う Flask の位置)。

import requests

# GET + クエリパラメータ
r = requests.get(
    "https://api.example.com/items",
    params={"q": "router", "limit": 10},
    headers={"Accept": "application/json"},
    timeout=10,
)
r.raise_for_status()           # 4xx/5xx なら例外
data = r.json()                # 自動で JSON パース
print(len(data))

# POST(JSON 本体)
r = requests.post(
    "https://api.example.com/users",
    json={"name": "alice", "age": 20},   # json= は自動で Content-Type と直列化
    headers={"Authorization": "Bearer xxx"},
    timeout=10,
)
print(r.status_code, r.json())

(f) Session でコネクション再利用 + Cookie 自動管理

同じサイトに複数回アクセスするなら requests.Session を使います。これは HTTP/1.1 のパーシステント接続(標準編 第12回) を裏で活用し、TCP/TLS の確立コストを節約。さらに Cookie もまたいで自動的に運んでくれます。

import requests

with requests.Session() as s:
    # 共通ヘッダはここで一括設定
    s.headers.update({"User-Agent": "myapp/1.0"})

    # ① ログイン:Set-Cookie で session ID をもらう
    s.post("https://example.com/login",
           data={"user": "alice", "pw": "secret"})

    # ② 以降のリクエストは Cookie が自動で付く
    r = s.get("https://example.com/mypage")
    print(r.status_code)

    # ③ 同じセッション内では TCP 接続も再利用される(高速)
    for i in range(100):
        s.get(f"https://example.com/items/{i}")
もっと詳しく:requests.Session の中で何が起きているのか

裏側はけっこう複雑なことをやっています。だからこそ「Session を使わずにグローバル requests.get をループで回す」と、毎回 TCP/TLS 確立で大幅に遅くなるという初学者の罠が生まれます。

(g) エラー処理の定石

import requests
from requests.exceptions import (
    Timeout, ConnectionError, HTTPError, RequestException,
)

try:
    r = requests.get("https://api.example.com/items", timeout=(5, 30))
    r.raise_for_status()
    data = r.json()
except Timeout:
    print("タイムアウト(接続 5s / 受信 30s)")
except ConnectionError as e:
    print(f"接続失敗: {e}")
except HTTPError as e:
    print(f"HTTP エラー: {e.response.status_code}")
except RequestException as e:
    print(f"その他: {e}")

timeout=(connect, read) のようにタプルで分けて指定できます。タイムアウト未指定だと無限に待つ ので本番コードでは必ず付けましょう。第4節の socket クライアントで sock.settimeout(10) と書いた処理を、ここでは timeout=... 引数で渡している、と見ると地続きです。

抽象階段の総まとめ(サーバ側)

同じ「Hello を返すサーバ」を 4 段階で書く L1 socket 第5節:bind / listen / accept / recv / sendall まで自分で 隠さないもの:すべて。CRLF、Content-Length、空行、メソッド分岐、Cookie パース L2 標準ライブラリ BaseHTTPRequestHandler:do_GET / do_POST / self.path / self.headers 隠してくれる:リクエストのパース、空行の送信、メソッド分岐 L3 Flask @app.get("/hello") / request.json / jsonify(...) でルーティング + JSON 隠してくれる:ルーティング、JSON 変換、Cookie 属性、テンプレート L4 FastAPI 型ヒント User(BaseModel) で自動バリデーション・OpenAPI 生成・非同期 隠してくれる:入力バリデーション、ドキュメント、非同期(ASGI)、CORS L0 ワンライナー python -m http.server 8000:カレントディレクトリの静的配信 隠してくれる:プロセスそのもの。ただし静的配信限定で動的応答は書けない

図の見方:下に行くほど隠してくれることが増える代わりに、「中で何が起きているか」が見えにくくなる。前半の socket サーバ(第5節)は L1 そのもの。「中はこうなっている」が見えていれば、上のレイヤを安心して使える

抽象階段の総まとめ(クライアント側)

同じ「GET を打って結果を取る」を 4 段階で書く L1 socket 第4節:socket() / connect() / sendall() / recv ループ / partition で本文分離 隠さないもの:すべて。CRLF、Host、空行、close 検知、Content-Length 計算 L2 http.client HTTPSConnection / conn.request(...) / conn.getresponse() で 1 オブジェクト化 隠してくれる:接続管理、ステータス行/ヘッダのパース、TLS L3 requests / Session requests.get(...) / Session ─ Cookie 自動管理・コネクションプール・JSON 隠してくれる:Cookie、接続再利用、JSON 変換、リダイレクト追跡、認証 L4 aiohttp / httpx 第9節:非同期(asyncio) で 1 スレッド数千リクエスト並行 + HTTP/2 対応 隠してくれる:並行制御、イベントループ、ストリーミング、HTTP/2 フレーミング

図の見方:サーバ側と 完全に対応した 4 段階の階段。前半第4節(socket クライアント) は L1 そのもの、第9節 aiohttp が L4。サーバ側を Flask + クライアント側を requests で書くのが現代の典型構成 ── 両方とも L3 の抽象度。

つながる知識: サーバ側もクライアント側も、同じ「抽象を上げる」発想で道具立てが整理されているのが Web プログラミングの全体像です。L1 を一度経験している(本講前半第4節・第5節)ことで、L2 以上を 「裏でこれをやってくれてるんだな」 と腑に落とせます ── これが本講の最終到達点です。

発展:aiohttp で並列リクエスト

前節 第8節 (e) 〜 (g) で扱った requests同期 ライブラリです。1リクエストごとに RTT 分待つので、数百〜数千の URL に同時アクセスしたい場合(クローラ・ヘルスチェッカ・スクレイパなど) は非効率になります。本節ではクライアント側の 抽象階段の上限(L4) として、Python の非同期 HTTP クライアント aiohttp を紹介します。1スレッド内で何千本ものリクエストを並行 させられます。
import asyncio
import aiohttp

async def fetch(session, url):
    async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as r:
        return url, r.status, len(await r.read())

async def main(urls):
    async with aiohttp.ClientSession() as session:
        # 全リクエストを「同時に」開始してまとめて待つ
        results = await asyncio.gather(
            *(fetch(session, u) for u in urls),
            return_exceptions=True,
        )
        for r in results:
            print(r)

urls = [f"https://example.com/{i}" for i in range(100)]
asyncio.run(main(urls))
POINT 同期版 requests(第8節 e〜g): URL × 各 RTT(逐次)
非同期版 aiohttp(本節): max(各 RTT) ≒ 1 RTT(並行)
100 URL × 200ms RTT なら、20 秒 → 0.5 秒 程度に短縮。ただし対象サーバへの負荷を考えて 同時実行数の上限(セマフォ) を入れるのがマナー。
同期版で書きやすい requests 互換 API + 非同期両対応の httpx も近年人気です(pip install httpx)。HTTP/2 にも対応しており、新規プロジェクトでは選択肢に入れる価値があります。機能比較の細部は公式ドキュメント参照。

つながる知識: 前節 第8節 でクライアント側階段 socket(第4節) → http.client → requests / Session を上ってきました。aiohttp はその 最上段(L4)。サーバ側の FastAPI と 非同期(asyncio) で噛み合い、両方を非同期で書けば 1プロセスで 万単位の同時接続 も現実的に扱えます。

Node.js で書く ─ http モジュールと fetch

Node.js は JavaScript ランタイムで、サーバサイド JavaScript の中心。HTTP 関連は標準モジュール http / https と、Node.js 18 以降で組み込まれた fetch が主役です。前節の Python 側の抽象階段(L2 BaseHTTPRequestHandler 〜 L3 Flask) に 大体相当する位置 にあります。

(a) http.createServer ─ 最小サーバ

// node server.js で起動。Node.js 18+ 推奨
const http = require("http");

const server = http.createServer((req, res) => {
  console.log(`${req.method} ${req.url}`);

  if (req.url === "/hello") {
    const body = JSON.stringify({ message: "Hello!" });
    res.writeHead(200, {
      "Content-Type": "application/json",
      "Content-Length": Buffer.byteLength(body),
    });
    res.end(body);
  } else {
    res.writeHead(404).end("Not Found");
  }
});

server.listen(8000, () => console.log("listening on :8000"));

コールバックの (req, res) がリクエスト・レスポンスのオブジェクト。req.method / req.url / req.headers で受信内容を読み、res.writeHead + res.end で応答を送ります。Python の BaseHTTPRequestHandler とほぼ同じ抽象度。

(b) Node.js 圏のフレームワーク:Express

// npm install express
const express = require("express");
const app = express();
app.use(express.json());

app.get("/hello", (req, res) => {
  res.json({ message: "Hello!" });
});

app.post("/users", (req, res) => {
  res.status(201).json({ id: 1, ...req.body });
});

app.listen(8000);

Node.js 圏のデファクト。ミドルウェア合成型の設計で、巨大なエコシステムを持つ。Python で言えば Flask の位置(L3)。

(c) クライアント:組み込み fetch を使う(Node.js 18+)

// GET
const r = await fetch("https://api.example.com/items?q=router");
console.log(r.status);
const data = await r.json();

// POST JSON
const r2 = await fetch("https://api.example.com/users", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer xxx",
  },
  body: JSON.stringify({ name: "alice", age: 20 }),
  // タイムアウトは AbortController で
  signal: AbortSignal.timeout(10_000),
});
console.log(r2.status, await r2.json());

ブラウザの fetch同じ書き方 で動きます。Node.js 17 以前を扱うなら node-fetch パッケージを使う必要がありました。Python の requests(L3 相当) に当たる位置。

(d) より低レベル:http.request

const https = require("https");

const req = https.request({
  hostname: "api.example.com",
  port: 443,
  path: "/items",
  method: "GET",
  headers: { "Accept": "application/json" },
}, (res) => {
  let body = "";
  res.on("data", (chunk) => body += chunk);
  res.on("end", () => {
    console.log(res.statusCode, JSON.parse(body));
  });
});

req.on("error", (e) => console.error(e));
req.end();

ストリーム指向の API。大きなレスポンスを 逐次処理 したい時はこちらが向きます(全文をメモリに乗せない)。

つながる知識: Python の http.client と Node.js の http.request は、ともに 「TCP の上に直接 HTTP を喋る」 ための低レベル API です。書き方は違っても、やっていることは 「リクエスト行 + ヘッダ + 空行 + 本文を組み立てて socket に書き込む」 ── 第2節で telnet で手打ちした手順、第5節で socket で受け取った手順、その クライアント版。これが見えてくると、HTTP は怖くなくなります。

Cookie / Bearer の設計判断 ─ どちらをいつ使うか

第6節で Cookie を socket レベルで実装 してみました。本節では 実装より一段上の「設計判断」 を扱います。Cookie + サーバ側セッション、Bearer トークン、JWT ── どれを選ぶかは HTTP の仕様ではなく、アプリの形態とセキュリティ要件で決まります

(a) Flask での Cookie + セッションストア

第6節の socket 版を 本物のセッション認証 に書き直すと、Flask ではこう書きます。Cookie には セッション ID(長乱数) だけを入れ、ユーザ情報は サーバ側のストア(辞書、Redis 等)に置きます。

# Flask の例
from flask import Flask, make_response, request
import secrets

app = Flask(__name__)
SESSIONS = {}                    # 本番は Redis 等の外部ストア

@app.post("/login")
def login():
    user = request.form["user"]
    # … 認証処理 …
    sid = secrets.token_urlsafe(32)
    SESSIONS[sid] = {"user": user}
    resp = make_response({"ok": True})
    resp.set_cookie(
        "session", sid,
        httponly=True,           # JS から触れない(XSS 対策)
        secure=True,             # HTTPS のみ送信
        samesite="Lax",          # CSRF 対策
        max_age=3600,
    )
    return resp

@app.get("/mypage")
def mypage():
    sid = request.cookies.get("session")
    if sid not in SESSIONS:
        return {"error": "unauthorized"}, 401
    return {"user": SESSIONS[sid]["user"]}

第6節の socket 版との差分:① Cookie 値が予測不能な長乱数になっている ② サーバ側にセッションストアがあり、Cookie 値が そこに存在するか でユーザを判定 ③ HttpOnly / Secure / SameSite 属性が付いている。── 文字列で出す形は同じだが セキュリティ属性の付け方 が違う。

(b) クライアントが Cookie を送る

requests / fetch とも、同じ Session / 同じドメインなら 自動で運んでくれる。手動で送りたいなら以下。

# Python 手動指定
requests.get("https://example.com/mypage",
             cookies={"session": "abc123"})

# Node fetch 手動指定
await fetch("https://example.com/mypage", {
  headers: { "Cookie": "session=abc123" },
});

(c) Bearer トークン認証(API でデファクト)

SPA や REST API、特に複数オリジン跨ぎでは、Cookie より Authorization ヘッダで Bearer トークン を送る形式がよく使われます。

# クライアント側(requests)
TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6Ik..."   # JWT 等
r = requests.get(
    "https://api.example.com/me",
    headers={"Authorization": f"Bearer {TOKEN}"},
)

# サーバ側(Flask)
from flask import request

@app.get("/me")
def me():
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        return {"error": "missing token"}, 401
    token = auth[7:]
    user = verify_token(token)        # JWT 検証など
    if not user:
        return {"error": "invalid token"}, 401
    return {"user": user}

Cookie + セッション

ブラウザがドメイン単位で自動管理。HttpOnly で XSS による盗難を防げる(JS から触れない)。サーバ側にセッションストアが必要(状態を持つ)。同一サイト内の Web アプリに向く

Bearer トークン

JS から自由に扱えるため、SPA・モバイル・他ドメイン API 連携に向く。JWT ならサーバ側状態を持たずに署名検証だけで認証できる(スケールしやすい)。半面、ストレージに置けば XSS で盗まれやすく、長期トークンの取扱いに注意が必要。

つながる知識: Cookie か Bearer かは「設計判断」であって HTTP の仕様としてはどちらも単なるヘッダ です。Set-Cookie / Cookie ヘッダは古くからの慣習で特別扱いされているだけで、自前の認証ヘッダ(X-API-Key など) を使う API も多数あります。詳細は TLS/PKI 標準編 を参照(OAuth 等の本格的な認証フローは別途専門書を参照)。

確認: 同じ Web アプリでも、ブラウザ向けの認証は Cookie、API 向けの認証は Bearer トークンが選ばれることが多い。なぜ?

正解:C。Cookie の自動送信(同一オリジンならどんなリクエストにも勝手に乗る)は Web の利便性を支えるが、攻撃者が用意した別ドメインから誘発される CSRF の温床にもなる。SameSite 属性で軽減可能だが完全ではない。Bearer はクライアントが 明示的にヘッダにセットする 必要があり、自動送信されないため CSRF 経路を断てる ─ ただし XSS で盗まれるリスクは Cookie より大きいので一長一短。設計判断はトレードオフの中で行う。

まとめと用語チェック

SUMMARY(前半:socket で生 HTTP を体得) 1. HTTP/1.x はテキストプロトコル。telnet / nc で 手で打って 動きを観察できる
2. CRLF と空行と Host ヘッダ ── これらが揃わないと応答が返らない
3. curl は telnet の一段上。CLI でメソッド・ヘッダ・Cookie を制御する定番
4. socket でクライアントを書く:connect → sendall(リクエスト) → recv ループ → 空行で分離。telnet 手打ちを Python が代行
5. socket でサーバを書く:bind → listen → accept → recv → sendall。クライアントが送ったものを recv で受け取れる ─ 第4節と第5節は socket の両端で対になる
6. Cookie の実体は文字列1行。サーバは何も覚えず、状態は Cookie 側に乗せて 受け取って +1 して書き戻す
7. デバッグは 層を下に降りて 切り分け:curl -v → サーバログ → tcpdump → mitmproxy
SUMMARY(後半:抽象階段を両側上る) 8. サーバ階段:L0 python -m http.server → L2 BaseHTTPRequestHandler → L3 Flask → L4 FastAPI
9. クライアント階段:L1 socket(第4節) → L2 http.client → L3 requests / Session → L4 aiohttp / httpx
10. requests.Session:同一ホストへの TCP/TLS 接続を再利用 + Cookie jar で Cookie 自動管理
11. aiohttp:asyncio で 1 スレッド数千リクエスト並行(発展)。サーバ側 FastAPI と asyncio で噛み合う
12. Node.js は http.createServer ≒ L2、Express ≒ L3、組み込み fetch はブラウザ互換
13. Cookie + セッション と Bearer トークン は HTTP 上の設計判断。CSRF と XSS のトレードオフで決める

用語チェック

用語1行説明
telnet / ncTCP に直接接続して生テキストを送る最小ツール。HTTP の学習用に最適
CRLF行末コード \r\n。HTTP/1.x の行区切りで必須
curlHTTP プログラミングの定番 CLI。Cookie / 認証ヘッダ / multipart まで対応
http.clientPython 標準ライブラリの低レベル HTTP クライアント
requestsPython のデファクト HTTP クライアント。直感的 API
Sessionrequests のセッションオブジェクト。TCP 再利用 + Cookie 自動管理
aiohttp / httpx非同期 HTTP クライアント。高並列に向く
socketPython 標準のソケット API。HTTP サーバ・クライアントの土台
connectTCP クライアントが指定先と 3-way ハンドシェイクを実行する基本動作
bind / listen / acceptTCP サーバの3つの基本動作。ポートを取り、待ち、クライアントを取り出す
recv / sendallソケットからバイト列を読む / 全部書き切るまで送る
partition("\r\n\r\n")HTTP レスポンスを「ヘッダ部 / 空行 / 本文」に分離する Python の文字列メソッド
Set-Cookie / Cookieサーバが発行 → ブラウザが保管 → 次のリクエストで自動付与する文字列ヘッダ
HttpOnly / Secure / SameSiteCookie 属性。XSS 対策 / HTTPS 限定 / CSRF 対策
python -m http.server1コマンドで静的ファイル配信サーバを起動
BaseHTTPRequestHandlerPython 標準の動的応答サーバ基底クラス
Flask / FastAPIPython の主要 Web フレームワーク(同期/非同期)
http.createServerNode.js の最小 HTTP サーバ API
fetchブラウザ・Node.js 18+ の標準 HTTP クライアント API
Bearer トークンAuthorization ヘッダで運ぶ認証情報。SPA / API で頻出
mitmproxyHTTPS を解いて中身を見る・改変する中間プロキシ
関連回: プロトコル層の HTTP/3 / QUIC の実装は 第33回 QUIC と HTTP/3 をどうぞ。本講と 第28回 ネットワークプログラミング第30回 Wireshark をセットで読むと、「HTTP を喋る側」「プロセスとしてのサーバ」「ワイヤを流れるパケット」 が一本に繋がります。

確認問題

問1. telnet で生 HTTP リクエストを打つとき、必ず必要なものとして最も適切なものを1つ選べ。

次の選択肢から最も適切なものを選択してください。
A. リクエスト行のあとに Content-Length ヘッダを必ず1個入れる
B. リクエスト行と本文の間に必ずバイナリのフレームヘッダを入れる
C. ヘッダの最後に空行(CRLF だけの行) を入れて「ヘッダ終わり」を示す
D. 必ず最初に SETTINGS フレームを送る
正解:C
HTTP/1.1 では ヘッダの最後に CRLF だけの空行 を入れることでヘッダ終わりを示す。これがないとサーバはヘッダがまだ続くと思って待ち続ける。A は GET など本文がないリクエストでは不要、B・D は HTTP/2 の話で 1.1 とは関係ない。

問2. telnet と curl の使い分けに関する記述として最も適切なものを選べ。

次の選択肢から最も適切なものを選択してください。
A. curl は HTTP/1.1 専用で、HTTP/2 や HTTPS には対応していない
B. telnet は TLS ハンドシェイクを自動でやってくれるので HTTPS のサーバにそのまま使える
C. telnet は CRLF・空行・Host ヘッダを 全部自分で 用意する必要があるが、curl は TCP 接続・TLS・必須ヘッダを 自動で 整えてくれる
D. curl は GUI ツールなのでスクリプトに組み込みにくい
正解:C
curl は「面倒な前処理を自動化しつつ、必要な部分だけ引数で上書き」できる中間レベルのツール。HTTP/2 / HTTP/3 / HTTPS にも対応(A 誤り)、telnet は 平文 TCP しか喋れない ので HTTPS には使えない(B 誤り)、curl は CLI なのでシェルスクリプトに組み込みやすい(D 誤り)。

問3. simple_http_server.py(第5節) で「クライアントから来たリクエストを取り出す」役割を担う2つの呼び出しの組み合わせとして最も適切なものを選べ。

次の選択肢から最も適切なものを選択してください。
A. bind()listen()
B. accept()recv() ── accept で待ち受けて接続済みの専用ソケットを取り出し、recv でその上に届いたバイト列を読む
C. connect()send()
D. socket()close()
正解:B
bind は IP:ポートを自分の物と宣言、listen は受け入れ準備で、いずれもクライアントが来る前に1度だけ行う。クライアントが来るたびに accept() でブロック解除され専用ソケットが返り、その上で recv() がリクエスト文字列を返す。connect / send はクライアント側、socket / close は接続管理。

問4. requests.Session を使う主な利点として最も適切なものを選べ。

次の選択肢から最も適切なものを選択してください。
A. 同じホストへの TCP/TLS 接続を再利用し、Cookie を自動的にまたいで運ぶ
B. リクエストを HTTP/3 に自動アップグレードする
C. レスポンスを自動的にディスクに保存する
D. 認証情報を絶対に外部に出さないように暗号化する
正解:A
Session は内部にコネクションプール(urllib3) と Cookie jar を持ち、同じホストへの2回目以降のリクエストで TCP/TLS 確立をスキップ + Cookie を自動付与する。これにより HTTP/1.1 のパーシステント接続の恩恵を受けやすい。B(HTTP/3 化はしない)、C(自動保存はしない)、D(暗号化は TLS 側の話) はいずれも誤り。

問5. Content-Length ヘッダに関する記述として最も適切なものを選べ。

次の選択肢から最も適切なものを選択してください。
A. 値はクライアントが省略しても、サーバが自動で正しく計算してくれる
B. 実際の本文より大きな値を指定すると、サーバが自動で切り詰める
C. 実際の本文より小さな値を指定しても、メッセージ境界には影響しない
D. 値を間違えると、メッセージ境界が壊れて「次のリクエスト」が誤読される等の問題が起きる
正解:D
Content-Length はサーバが「本文をどこまで読むか」を決める基準。値が 本文より小さい と残りが次リクエストの先頭として誤解釈され(HTTP リクエストスマグリングの素地)、本文より大きい とサーバは続きを永久に待ってタイムアウトする。クライアントが正確に付与する責任を持つ(高レベルライブラリは自動でやってくれる)。

問6. Cookie ベース認証と Bearer トークン認証の違いに関する記述として最も適切なものを選べ。

次の選択肢から最も適切なものを選択してください。
A. Cookie はブラウザが自動で送るが、Bearer トークンはサーバが自動でセットする
B. Cookie はブラウザがドメイン単位で自動管理し、Bearer トークンはクライアントが Authorization ヘッダで毎回明示的に付与する
C. Bearer トークンは HTTP/1.1 では使えず、HTTP/2 専用の機能である
D. Cookie と Bearer はどちらも TLS 上でしか使えない仕様になっている
正解:B
Cookie はブラウザがドメイン・パス・属性に従って自動で送るのが特徴で、Web アプリのログイン維持に向く。Bearer トークンはクライアントが Authorization: Bearer ... を明示的に付ける形で、SPA / モバイル / 他オリジン API に向く。A・C・D はいずれも誤り(C: バージョン非依存、D: 仕様上は HTTP でも使えるがセキュリティ上 HTTPS が前提)。
← PREV
第28回 ネットワークプログラミング
NEXT →
第30回 Wireshark でパケットを観る