標準編 第12回 HTTP/1.1・HTTP/2 詳細 で学んだ HTTP/1.1 の仕様を 「実際に手を動かして動かす」 発展回です。前半は telnet で生バイト列を打つ → curl → Python クライアント → socket で素手の HTTP サーバを書く という流れで、「クライアントが送るバイト列をサーバ側でどう受け止め、何を返すのか」を 同じ生 HTTP の視線 で繋ぎます。後半は 抽象を一段ずつ上げて、ワンライナー(python -m http.server)・BaseHTTPRequestHandler・Flask / FastAPI・Node.js まで、「各レイヤが何を隠してくれているか」を体感します。
本講を終えると、以下を達成できるようになります。
— 前半:socket レベルで HTTP を体得する —
— 後半:抽象を一段ずつ上げる(応用)—
図の見方:下に行くほど抽象度が高くなり、書きやすくなる代わりに「中で何が起きているか」が見えにくくなる。本講は ① ② ③ を中心に扱い、④ で実用感を、⑤ はさわりだけ紹介します。
つながる知識: 「自分で書く」は理解だけでなく 運用 の質を上げます。例えばブラウザだと再現できないバグも、curl なら同じヘッダを送り続けてサーバの挙動を切り分けられます。プロのサポートエンジニアは ブラウザより先に curl を開く と言われるほどです。
確認: ブラウザで再現できないが curl なら再現できるバグがあるとする。最もあり得るシナリオは?
正解:B。ブラウザは多数のヘッダを自動付与し、CORS や cache 制御も裏で実施する。curl は最小限の HTTP しか送らないので、サーバが「特定のヘッダがあること」「特定のオリジンから来ること」を前提に挙動を変えていると、curl の結果は別物になる。これを使えば「サーバはどのヘッダで挙動を変えているか」を切り分け解析できる ─ プロのデバッグツールとして curl が選ばれる本質的理由。
$ telnet example.com 80
Trying 23.215.0.136...
Connected to example.com.
Escape character is '^]'.
telnet は 引数で指定したホスト・ポート に TCP 接続するだけのツール。ここまでで TCP の 3-way ハンドシェイクが完了しています。下層は 標準編 TCP 参照。
GET / HTTP/1.1
「メソッド SP URI SP HTTP-バージョン」。Enter キーを押すと CRLF が送信されます(端末によっては LF だけの場合があるので、後述の nc -C のような工夫が必要)。
Host: example.com
HTTP/1.1 で必須の Host ヘッダ。同じ IP に複数のサイトが同居している前提なので、これを書かないと「どのサイトの / を要求しているのか」サーバに伝わりません。
(ここで Enter だけを押す = CRLF だけの行)
これが「ヘッダ終わり、本文に入ります」という合図です。GET には本文がないので、空行の直後にサーバが処理を開始します。
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 の数だけ本文を読めばよい、というのがクライアントの基本動作です。
Connection closed by foreign host.
HTTP/1.1 はパーシステント接続がデフォルトですが、Connection: close を付けるか、サーバのアイドルタイムアウトを過ぎるとサーバ側から切ってきます。telnet は ^](Ctrl+]) → quit でも切れます。
図の見方:HTTP メッセージは「行」がすべて CRLF で終わるテキストの並び。空行(\r\n だけの行) が「ここでヘッダが終わる」を示すデリミタになっている。空行を忘れるとサーバはずっと「もっとヘッダが来るかも」と待ち続け、応答が返ってこない。
Q. もし行末を LF(\n) だけ で送ったらどうなるでしょう? なぜ CRLF(\r\n) が必須なのか?
HTTP/1.1 の仕様(RFC 9112)では、行末は CRLF と明確に定められています。歴史的には ARPANET 時代から続くインターネットテキストプロトコルの慣習で、SMTP や FTP も同様です。
そのため、telnet で手打ちするときの 「Enter だけだと LF になる端末」 では応答が返ってこないことがあります。nc -C(BSD nc の CRLF オプション) や、Python から socket で b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n" をそのまま送る方が確実です。改行コードのトラブルは HTTP プログラミングで最も頻出する地雷の1つです。
# 本文だけ標準出力
$ curl https://example.com/
# -v(verbose) で送信ヘッダ・受信ヘッダ・TLS の様子を全部見る
$ curl -v https://example.com/
telnet で十数行手打ちしていた処理が、curl では 1行。-v を付ければ「自分が何を送ったか(>)」「サーバが何を返したか(<)」が両方見えるので、telnet 手打ちの代わりに HTTP の挙動を観察 する用途にも使えます。
初学者がよく混乱する 「同じことをやるのに書き方がツールごとに違う」 問題。example.com に GET を打つ という同じ動作を、4つのツールで並べてみます。
$ telnet example.com 80
GET / HTTP/1.1
Host: example.com
(空行 ← ここで Enter)
最も低レベル。CRLF・空行・Host ヘッダを 全部自分で 用意する必要がある。
$ curl http://example.com/
# verbose 表示で何が起きているかを見る
$ curl -v http://example.com/
1行で済む。Host ヘッダ・User-Agent・Accept は curl が自動で付ける。シェルスクリプトに組み込みやすい。
import requests
r = requests.get("http://example.com/")
print(r.status_code) # 200
print(r.headers["Content-Type"])
print(r.text[:200])
Python 側からアクセスして 結果をプログラムで処理 したいときの定番。レスポンスがオブジェクトとして扱える。
// 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 はデバッグ目的の悪手(証明書問題を隠す)。
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()
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」と書いた瞬間と等価。
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-Length や Transfer-Encoding: chunked から自力で判断する必要があります。学習用に HTTP/1.0 + Connection: close を使うのは、サーバが close した時点 = 受信終了 と単純化するため。
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 を生み出しています。
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 終端なので「最後まで読んだものが本文」で済みます。
図の見方:左が本節のクライアント、右が次の第5節のサーバ。クライアントの sendall が、サーバの recv の戻り値そのもの。サーバの sendall が、クライアントの recv の戻り値そのもの。同じ HTTP メッセージを socket の両端から見ているだけ。
つながる知識: 本節と第5節で HTTP クライアントとサーバの「最小骨格」 が両方揃いました。これ以降のレッスンは 「同じ動きを抽象を上げた書き方で書く」 旅です ── http.client → requests → aiohttp と上がる「クライアント側の階段」、http.server → Flask → FastAPI と上がる「サーバ側の階段」、両方が後半に出てきます。本節のコードはその階段の最下段 です。
まずは 常に同じ "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()
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 を扱う必要があります。
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 対策)。
while True:
client_sock, client_addr = server_sock.accept()
print(f"Connected from {client_addr}")
accept() は クライアントが TCP で繋いでくるまで止まる(ブロック)。繋いでくると クライアント専用の新しいソケット client_sock を返してくれます。元の server_sock は次の接続を待つために残ります。
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 まで繰り返し読む 必要がありますが、学習用には十分です。
client_sock.sendall(RESPONSE.encode("utf-8"))
client_sock.close()
受け取ったリクエストの中身に 関係なく、STEP 1 で作った同じ文字列を返します。sendall は「全部送るまで頑張る」版の送信(send は途中までしか送らないこともある)。送り終わったらコネクションを close。
# 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 メッセージを両端から見ています。
図の見方:左の telnet で打った あの文字列(GET / HTTP/1.0 + 空行)が、右の Python サーバの recv 戻り値そのものになっている。逆向きも同じ ── サーバが sendall したバイト列が、telnet 画面に映る HTTP レスポンス。HTTP は両端で同じバイト列の構文を共有しているだけ、というのが本節で得る最大の納得。
Q. このサーバは パス(URL の / 部分) や メソッド(GET / POST) を 一切見ていない。どんなパスにアクセスしても、どんなメソッドで叩いても、必ず同じ "Hello, World!" を返す。なぜそれでも HTTP として機能するのか?
HTTP の サーバ側の責務 は仕様上「リクエストを受け取り、何らかの 有効なレスポンスを返す」だけです。クライアントが GET /foo と打っても、サーバが「常に Hello を返す」と決めていれば、それは 正しい HTTP/1.0 のやり取り として成立します(ステータスコード・Content-Length・空行が揃っていれば、クライアントは満足する)。
つまり「リクエストの内容に応じて結果を変える」のは サーバ実装のロジック であって、HTTP の仕様ではありません。次の第6節では Cookie ヘッダを 読む ようにロジックを足し、レスポンスを動的に変えてみます。「リクエストをパースして結果を出し分ける」のが Web フレームワーク(後半 第8節 の Flask / FastAPI 等)の主役の仕事 ── 本節のサーバは その最小骨格 です。
つながる知識: Python の http.client や Node.js の http.request(後半 第10節) もこれと 本質的に同じこと をしています ── socket に対して「リクエスト行 + ヘッダ + 空行 + 本文」を組み立てて書き込む。本節のサーバはその 逆向き(受けて返す)です。クライアントとサーバの両方を socket で書くと、HTTP は 「TCP の上に置かれた文字列フォーマットの規約」 でしかないことが体感できます。
訪問するたびに「○ 回目の訪問です」と表示するサーバです。回数は クライアントが持っている Cookie の値 から計算し、毎回 +1 して Set-Cookie で書き戻し ます。サーバ側は完全にステートレス(メモリに何も持たない) ── 状態は Cookie に乗っている。
import socket
HOST = "127.0.0.1"
PORT = 8080
def parse_request(request_text):
"""HTTP リクエスト文字列をパースし、(method, path, headers) を返す。"""
lines = request_text.split("\r\n")
if not lines or not lines[0]:
return None, None, {}
parts = lines[0].split(" ")
if len(parts) < 2:
return None, None, {}
method, path = parts[0], parts[1]
headers = {}
for line in lines[1:]:
if line == "":
break
if ":" in line:
key, value = line.split(":", 1)
headers[key.strip().lower()] = value.strip()
return method, path, headers
def parse_cookies(cookie_header):
"""Cookie ヘッダ ("a=1; b=2") を dict にして返す。"""
cookies = {}
if not cookie_header:
return cookies
for item in cookie_header.split(";"):
item = item.strip()
if "=" in item:
key, value = item.split("=", 1)
cookies[key.strip()] = value.strip()
return cookies
def build_response(body, set_cookie=None):
"""HTTP/1.0 レスポンス文字列を組み立てる。"""
body_bytes = body.encode("utf-8")
headers = [
"HTTP/1.0 200 OK",
"Content-Type: text/html; charset=utf-8",
f"Content-Length: {len(body_bytes)}",
"Connection: close",
]
if set_cookie:
headers.append(f"Set-Cookie: {set_cookie}")
return "\r\n".join(headers) + "\r\n\r\n" + 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("-----------------")
method, path, headers = parse_request(request)
cookies = parse_cookies(headers.get("cookie"))
# favicon.ico などは 404 を返し、カウントしない
if path != "/":
not_found = "HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
client_sock.sendall(not_found.encode("utf-8"))
client_sock.close()
continue
# 訪問回数を取得 (なければ 0)
try:
count = int(cookies.get("visit_count", "0"))
except ValueError:
count = 0
count += 1
# レスポンス本文
if count == 1:
message = "はじめまして! 初めての訪問です。"
else:
message = f"おかえりなさい! あなたは {count} 回目の訪問です。"
body = (
"<html><head><meta charset='utf-8'><title>Cookie Demo</title></head>"
"<body>"
f"<h1>{message}</h1>"
f"<p>受信した Cookie: {cookies}</p>"
"<p><a href='/'>もう一度アクセス</a></p>"
"</body></html>"
)
# Max-Age=3600 → 1時間有効、Path=/ → 全パスで送信される
set_cookie = f"visit_count={count}; Max-Age=3600; Path=/"
response = build_response(body, set_cookie=set_cookie)
client_sock.sendall(response.encode("utf-8"))
client_sock.close()
except KeyboardInterrupt:
print("\nShutting down.")
finally:
server_sock.close()
if __name__ == "__main__":
main()
def parse_request(request_text):
lines = request_text.split("\r\n")
parts = lines[0].split(" ")
method, path = parts[0], parts[1]
headers = {}
for line in lines[1:]:
if line == "":
break # ← 空行が来たらヘッダ終わり
if ":" in line:
key, value = line.split(":", 1)
headers[key.strip().lower()] = value.strip()
return method, path, headers
第5節では 無視していた リクエストを、今度は パースして使います。1行目が リクエスト行(GET / HTTP/1.1)、2行目以降が ヘッダ行(名前: 値)、空行が来たら終わり。── 第2節で telnet を打ったときの構造そのものを、ここで 逆向きに分解 しています。後半 第8節 で扱う Web フレームワークが request オブジェクトとしてプログラマに渡してくれる値は、まさにこの処理の結果です。
def parse_cookies(cookie_header):
cookies = {}
if not cookie_header:
return cookies
for item in cookie_header.split(";"):
item = item.strip()
if "=" in item:
key, value = item.split("=", 1)
cookies[key.strip()] = value.strip()
return cookies
クライアントは Cookie: visit_count=3; lang=ja のように セミコロン区切り で複数の Cookie をまとめて送ります。これを {"visit_count": "3", "lang": "ja"} に開くだけ。文字列処理だけで 後半 第8節 で扱うフレームワークの「Cookie 辞書」相当 が書けてしまうのが見どころ。
try:
count = int(cookies.get("visit_count", "0"))
except ValueError:
count = 0
count += 1
サーバは 何も覚えていない(辞書もファイルもセッションストアも使わない)。状態は クライアント側の Cookie に保管 されており、サーバはそれを 受け取って +1 して書き戻すだけ。これが「HTTP がステートレスでも Cookie で状態を持てる」という 第12回 の原理の 動く証明 です。
セキュリティ注意: 本物のセッション ID は クライアントに改竄されてはならない ので、secrets.token_urlsafe(32) のような長乱数を生成してサーバ側で照合します。学習用のカウンタなら改竄されても困らないので、そのまま値を入れています。
set_cookie = f"visit_count={count}; Max-Age=3600; Path=/"
response = build_response(body, set_cookie=set_cookie)
レスポンスヘッダに 1行追加するだけ。Max-Age=3600 で 1 時間後に消える、Path=/ で全パスで送られる。本物の認証 Cookie ならさらに HttpOnly(JS から触れない)・Secure(HTTPS のみ)・SameSite=Lax(CSRF 対策) を付けます。属性も全部単なる文字列。Flask の set_cookie(httponly=True, ...) が裏でやっているのはこれです。
$ python3 cookie_http_server.py
Listening on http://127.0.0.1:8080/
# ブラウザで http://127.0.0.1:8080/ を開く → リロードを繰り返す
# 1 回目:はじめまして! 初めての訪問です。
# 2 回目:おかえりなさい! あなたは 2 回目の訪問です。
# 3 回目:おかえりなさい! あなたは 3 回目の訪問です。
# DevTools の Application タブ → Cookies → 127.0.0.1
# 名前: visit_count、値: 3、Max-Age: 3600、Path: /
# サーバの標準出力には、毎回のリクエストヘッダがそのまま流れる
# GET / HTTP/1.1
# Host: 127.0.0.1:8080
# Cookie: visit_count=2 ← ブラウザが自動で送ってくる
# ...
DevTools で 「Cookie はブラウザのストレージにある」「ブラウザが毎リクエストで自動付与している」「サーバの応答に Set-Cookie ヘッダが乗っている」 を全部 目視 できます。シークレットウィンドウ で開き直すと Cookie が消えるので、また「初めての訪問」に戻る。
図の見方:Cookie は サーバ ↔ ブラウザ間で文字列を1行やり取りしているだけ。サーバ側はメモリに何も持たず、毎リクエストで 受け取った値 + 1 を書き戻す。「状態を保つ」という機能はクライアント側のストレージに寄生して実現されている。
確認: 上の cookie_http_server.py をそのまま動かしたとして、ブラウザのアドレスバーから document.cookie = "visit_count=999" と JS コンソールに打って、改めてアクセスすると?
正解:B。本サーバは Cookie の値を int() に通して +1 するだけで、改竄検出をしていない。これが「Cookie の値そのものを認証情報として送るのは危険」と言われる本質的理由 ── 本物のセッション認証では Cookie には 長乱数のセッション ID を入れ、サーバ側のセッションストアと突き合わせる(改竄しても無意味な ID になる)か、JWT のように サーバ秘密鍵で署名 して改竄を検出可能にする。「Cookie の値はクライアントが好きに書ける」 という前提を忘れない、これがセキュリティの第一歩です。
つながる知識: 本節と第5節を読むと、後半 第8節 で扱う Web フレームワークの「Cookie 1行作る」API が何をしているか はもう想像できます ── 結局、文字列を1行組み立ててレスポンスヘッダに足しているだけ。フレームワークの本当の価値は「ルーティング」「ミドルウェア」「バリデーション」「並行処理」「テスト容易性」 側にあって、Cookie の文字列生成自体は 本節のコードと等価 です。Bearer トークンや CSRF 対策など 設計判断 の話は後半 第11節 Cookie / Bearer の設計 で扱います。
| 道具 | 見える層 | 得意なシーン |
|---|---|---|
| curl -v | HTTP/1.1 のリクエスト・レスポンス全体 | ヘッダの値・ステータスコードを正確に確認したい時。最初の道具 |
| Chrome DevTools の Network タブ | ブラウザが投げた HTTP すべて | ブラウザ操作で再現する不具合の調査。タイミング・キャッシュ・Cookie が見やすい |
| HTTPie / Postman | HTTP/1.1 のメッセージ | API の対話的探索、ドキュメント例の試行 |
| Wireshark | TCP/IP のパケット全部 | 「そもそも TCP がつながってない」「TLS でコケてる」を切り分け |
| tcpdump | 同上(CLI 版) | サーバ側でリモートにキャプチャ。後で Wireshark で開く |
| mitmproxy | HTTPS 含む HTTP すべて | クライアント↔サーバ間に挟まり、TLS を解いて生 HTTP を見る・改変する |
| サーバアクセスログ | サーバが受けたリクエスト | 「リクエストが届いていない」のか「届いているが応答がおかしい」かの一次切り分け |
図の見方:アプリ層(curl)で問題が見えるか確認 → 見えなければサーバログ → 見えなければ TCP/IP 層 → 見えなければ中継機器、と 層を下から確認していく のが原則。やみくもに上の層で再現を試すより、下に降りた方が問題に近づくことが多い。
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 越しの中身を解読 してリクエストとレスポンスを表示します。デバッグには強力ですが、他人の通信に対して使うのは違法・倫理違反 です。必ず自分の権限がある通信だけに使ってください。
ここまでの前半で、HTTP サーバの本質は socket への bytes 入出力 であることを体感しました。後半は 抽象を一段ずつ上げて、ワンライナーで動くもの → ハンドラ → フレームワーク → Node.js までを並べ、「各レイヤが前のレイヤから何を隠してくれているか」 を整理します。ここから先は 実務でこう書く という応用パートです。
# カレントディレクトリを 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 を計算する、すべて勝手にやってくれます。
第5節の socket サーバを 少しだけ便利にした 標準ライブラリ。do_GET・do_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.path や self.headers は、第6節の parse_request が返していた値の 用意済み版。
業務での Web アプリ・API は フレームワーク を使います。標準ライブラリ版より圧倒的に書きやすく、ルーティング・バリデーション・テストも整っています。
# 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 != "/": という手書き分岐の 進化版。
# 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 ドキュメント生成。非同期サポート。新規プロジェクトでの人気が高い。
2つとも「/hello に GET したら JSON を返す」だけのコードですが、設計思想に違いがあります。
選ぶ基準は 性能要件・開発規模・チームの慣れ。プロトタイプは Flask、本番 API は FastAPI、というのが2026年時点の典型的な棲み分けです。最新版番号や微細な API 差は各公式ドキュメント参照。
サーバ側を「socket → http.server → Flask / FastAPI」と上ってきましたが、クライアント側 にもまったく同じ構造の階段があります。前半第4節 の socket クライアントを起点に、ここで一気に階段を上ります。
第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.sendall → recv ループ → partition("\r\n\r\n") を conn.request(...) + conn.getresponse() 2行に集約しただけ。Cookie 管理・JSON 変換・接続再利用は まだ自分で書く必要 があります。
インストール: 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())
同じサイトに複数回アクセスするなら 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}")
裏側はけっこう複雑なことをやっています。だからこそ「Session を使わずにグローバル requests.get をループで回す」と、毎回 TCP/TLS 確立で大幅に遅くなるという初学者の罠が生まれます。
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=... 引数で渡している、と見ると地続きです。
図の見方:下に行くほど隠してくれることが増える代わりに、「中で何が起きているか」が見えにくくなる。前半の socket サーバ(第5節)は L1 そのもの。「中はこうなっている」が見えていれば、上のレイヤを安心して使える。
図の見方:サーバ側と 完全に対応した 4 段階の階段。前半第4節(socket クライアント) は L1 そのもの、第9節 aiohttp が L4。サーバ側を Flask + クライアント側を requests で書くのが現代の典型構成 ── 両方とも L3 の抽象度。
つながる知識: サーバ側もクライアント側も、同じ「抽象を上げる」発想で道具立てが整理されているのが Web プログラミングの全体像です。L1 を一度経験している(本講前半第4節・第5節)ことで、L2 以上を 「裏でこれをやってくれてるんだな」 と腑に落とせます ── これが本講の最終到達点です。
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))
つながる知識: 前節 第8節 でクライアント側階段 socket(第4節) → http.client → requests / Session を上ってきました。aiohttp はその 最上段(L4)。サーバ側の FastAPI と 非同期(asyncio) で噛み合い、両方を非同期で書けば 1プロセスで 万単位の同時接続 も現実的に扱えます。
// 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 とほぼ同じ抽象度。
// 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)。
// 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 相当) に当たる位置。
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 は怖くなくなります。
第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 属性が付いている。── 文字列で出す形は同じだが セキュリティ属性の付け方 が違う。
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" },
});
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}
ブラウザがドメイン単位で自動管理。HttpOnly で XSS による盗難を防げる(JS から触れない)。サーバ側にセッションストアが必要(状態を持つ)。同一サイト内の Web アプリに向く。
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 より大きいので一長一短。設計判断はトレードオフの中で行う。
| 用語 | 1行説明 |
|---|---|
| telnet / nc | TCP に直接接続して生テキストを送る最小ツール。HTTP の学習用に最適 |
| CRLF | 行末コード \r\n。HTTP/1.x の行区切りで必須 |
| curl | HTTP プログラミングの定番 CLI。Cookie / 認証ヘッダ / multipart まで対応 |
| http.client | Python 標準ライブラリの低レベル HTTP クライアント |
| requests | Python のデファクト HTTP クライアント。直感的 API |
| Session | requests のセッションオブジェクト。TCP 再利用 + Cookie 自動管理 |
| aiohttp / httpx | 非同期 HTTP クライアント。高並列に向く |
| socket | Python 標準のソケット API。HTTP サーバ・クライアントの土台 |
| connect | TCP クライアントが指定先と 3-way ハンドシェイクを実行する基本動作 |
| bind / listen / accept | TCP サーバの3つの基本動作。ポートを取り、待ち、クライアントを取り出す |
| recv / sendall | ソケットからバイト列を読む / 全部書き切るまで送る |
| partition("\r\n\r\n") | HTTP レスポンスを「ヘッダ部 / 空行 / 本文」に分離する Python の文字列メソッド |
| Set-Cookie / Cookie | サーバが発行 → ブラウザが保管 → 次のリクエストで自動付与する文字列ヘッダ |
| HttpOnly / Secure / SameSite | Cookie 属性。XSS 対策 / HTTPS 限定 / CSRF 対策 |
| python -m http.server | 1コマンドで静的ファイル配信サーバを起動 |
| BaseHTTPRequestHandler | Python 標準の動的応答サーバ基底クラス |
| Flask / FastAPI | Python の主要 Web フレームワーク(同期/非同期) |
| http.createServer | Node.js の最小 HTTP サーバ API |
| fetch | ブラウザ・Node.js 18+ の標準 HTTP クライアント API |
| Bearer トークン | Authorization ヘッダで運ぶ認証情報。SPA / API で頻出 |
| mitmproxy | HTTPS を解いて中身を見る・改変する中間プロキシ |