第4章
HTTPサーバーとクライアントを作る
空のPythonファイルからGET・JSON応答・POST・データ保存を順に実装し、自分で書いたクライアントから通信します。
この章で作るもの
自分でHTTPサーバーとクライアントを書き、変更するたびに通信して確かめます。 最初は短い文字列を返すだけのサーバーです。そこへ機能を一つずつ足し、最後にメッセージを登録・取得するAPIを完成させます。
| 段階 | 自分で書く処理 | 動かして確認すること |
|---|---|---|
| 1 | 最小のHTTPサーバー | GETに200と文字列を返す |
| 2 | パス・クエリの分岐、JSON応答 | 名前を変えると応答が変わり、未知のパスは404になる |
| 3 | POSTの本文の読み取りと検証 | JSONが届く。形式が違うと400・415になる |
| 4 | データの保存・一覧・1件取得 | POSTで201、GETで登録内容を取得する |
| 5 | PythonのHTTPクライアント | 自分のプログラムから登録・取得する |
各段階は 書く → 保存 → サーバーを再起動 → 通信 → 結果を記録 の順で進めます。完成例は章末にあります。まず本文の変更指示に沿って、自分のファイルを育ててください。
0. 作業場所を準備する
PC、エディタ、Python 3.9以降、ブラウザ、curlを使います。追加のPythonパッケージは不要です。変数、if、関数、辞書・リストの基本を使います。クラスやHTTPサーバーの経験は必要ありません。
http-workshop フォルダーを作り、エディタで空の server.py を作成します。ターミナルを2つ開き、どちらもこのフォルダーへ移動します。Aはサーバーを動かし続ける場所、Bはcurlやクライアントを実行する場所です。
| 確認 | Windows PowerShell | macOS・Linux |
|---|---|---|
| Pythonの版 | python --version |
python3 --version |
| curlの版 | curl.exe --version |
curl --version |
| サーバーの起動 | python server.py |
python3 server.py |
以後のコマンドはWindows向けに python、curl.exe と表記します。macOS・Linuxでは python3、curl に置き換えてください。Windowsで python がない場合は py も確認します。
実習の通信先は 127.0.0.1:8000 です。自分のPC内だけで通信し、DNSの名前解決やTLSの暗号化は使いません。サーバーは学習用で、公開運用には使いません。Pythonが必要なら公式ダウンロード案内を確認します。
1. 文字列を返すサーバーを書く
書く:server.py 全体
空のファイルに次を書きます。この段階だけは、以下がファイル全体です。
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
body = "Hello, HTTP!".encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
self.close_connection = True
self.wfile.write(body)
def main():
with HTTPServer(("127.0.0.1", 8000), Handler) as server:
print("http://127.0.0.1:8000/ を開いてください。終了は Ctrl+C", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
print("サーバーを終了します")
if __name__ == "__main__":
main()
Handler はリクエストを受けたときの処理をまとめるクラスです。BaseHTTPRequestHandler がリクエスト行やヘッダーの解析を担当し、GETを受け取ると、自分で書いた do_GET が呼ばれます。self は処理中のリクエストを扱うためのオブジェクトです。
| 行・処理 | HTTPとの関係 |
|---|---|
send_response(200) |
ステータスコードを決める |
send_header(...) |
本文の形式・長さなどを伝える |
end_headers() |
ヘッダーの終わりを示す空行を送る |
wfile.write(body) |
本文のバイト列を送る |
Content-Length は文字数ではなくバイト数です。そのため、まずUTF-8へ変換してから len(body) を計算します。教材では分かりやすくするため、応答後に接続を閉じます。HTTP/1.1で毎回切断が必須という意味ではありません。PythonのHTTPサーバー資料
動かす
保存し、ターミナルAで起動します。
python server.py
ターミナルBから送信します。
curl.exe -i "http://127.0.0.1:8000/"
期待する結果は HTTP/1.1 200 OK、Content-Type: text/plain; charset=utf-8、本文の Hello, HTTP! です。ブラウザで同じURLを開いても文字列が表示されます。-i は応答のヘッダーも表示する指定です。
自分で変更する
本文を「こんにちは、HTTP!」へ変え、保存します。ターミナルAで Ctrl+C を押してから再び python server.py を実行し、同じGETを送ります。日本語の本文と Content-Length が変わることを確認してください。
この段階では、/missing へGETしても200になります。まだパスによる分岐を書いていないからです。次の段階で修正します。
2. パス・クエリを読んでJSONを返す
書く:importを追加する
server.py の先頭、既存のimportの下へ次を追加します。
import json
from urllib.parse import parse_qs, urlsplit
書く:Handler クラスを置き換える
class Handler... から main の直前までを、次のコードで置き換えます。main 関数と最後の if __name__ ... は残します。 Handler を2つ並べて定義しないでください。
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def respond(self, status, data, headers=None):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.send_header("Cache-Control", "no-store")
for name, value in (headers or {}).items():
self.send_header(name, value)
self.end_headers()
self.close_connection = True
if self.command != "HEAD":
self.wfile.write(body)
def do_HEAD(self):
self.do_GET()
def do_GET(self):
url = urlsplit(self.path)
if url.path == "/api/hello":
name = parse_qs(url.query).get("name", ["World"])[0]
self.respond(200, {
"message": f"こんにちは、{name}さん!",
"received_x_lesson": self.headers.get("X-Lesson"),
})
else:
self.respond(404, {"error": "そのパスはありません"})
respond はJSONの応答を作る共通関数です。ここへまとめると、どのパスでも本文の形式や長さを同じ方法で設定できます。headers は、後で作成先や転送先を伝えるための追加ヘッダーです。
urlsplit はパスとクエリを分け、parse_qs はクエリを辞書にします。?name=Sora は {"name": ["Sora"]} です。.get("name", ["World"])[0] は名前を取り出し、省略時には World を使います。
do_HEAD はGETと同じヘッダーを返すための処理です。respond の最後の条件によって、HEADには本文を送りません。これで curl.exe -I も試せます。
動かす:保存して再起動する
curl.exe -i "http://127.0.0.1:8000/api/hello?name=Sora"
curl.exe -i "http://127.0.0.1:8000/api/hello?name=Aoi"
curl.exe -i "http://127.0.0.1:8000/missing"
最初の2つは200で、JSON内の名前が変わります。最後は404です。ここからは / も404になります。入口を /api/hello に変更したためで、起動失敗ではありません。
curl.exe -v -H "X-Lesson: first-request" "http://127.0.0.1:8000/api/hello"
curl.exe -I "http://127.0.0.1:8000/api/hello"
-v の出力で > は送信ヘッダー、< は受信ヘッダーです。自分で付けた X-Lesson がJSONの received_x_lesson に入ることを確認します。大文字の -I はHEAD、小文字の -i は応答ヘッダーを表示する指定です。curl公式マニュアル
自分で変更する
クエリ名を name から nickname へ変更し、URL側も変更すると動くことを確かめます。その後、次の段階に向けて name に戻します。クライアントとサーバーで、送る項目の名前を合わせる必要があります。
3. POSTの本文を読み取る
書く:Handler にメソッドを追加する
do_GET の後、main の前に、以下の do_POST と save_message を追加します。どちらも Handler の中なので、def の前は4スペースです。既存の respond、do_HEAD、do_GET は残します。
def do_POST(self):
if urlsplit(self.path).path != "/api/messages":
self.respond(404, {"error": "そのパスはありません"})
return
if self.headers.get("Transfer-Encoding"):
self.respond(501, {"error": "この実習ではContent-Lengthを使います"})
return
if self.headers.get_content_type() != "application/json":
self.respond(415, {"error": "application/jsonで送ってください"})
return
length_text = self.headers.get("Content-Length", "")
if not length_text.isascii() or not length_text.isdecimal():
self.respond(400, {"error": "Content-Lengthが必要です"})
return
length = int(length_text)
if length > 16384:
self.respond(413, {"error": "本文が大きすぎます"})
return
self.connection.settimeout(5)
try:
raw = self.rfile.read(length)
except OSError:
self.respond(408, {"error": "本文を受信できませんでした"})
return
if len(raw) != length:
self.respond(400, {"error": "本文の長さが違います"})
return
try:
data = json.loads(raw.decode("utf-8-sig"))
except (UnicodeDecodeError, json.JSONDecodeError):
self.respond(400, {"error": "UTF-8のJSONを送ってください"})
return
if not isinstance(data, dict) or not isinstance(data.get("text"), str):
self.respond(400, {"error": "textは文字列にしてください"})
return
text = data["text"].strip()
if not 1 <= len(text) <= 200:
self.respond(400, {"error": "textは1〜200文字にしてください"})
return
self.save_message(text)
def save_message(self, text):
self.respond(200, {"received": text})
処理は「パスの確認 → 本文形式の確認 → バイト数の確認 → 読み取り → JSONとして解釈 → 項目の検証 → 応答」の順です。return はそこで処理を終了し、エラー後に登録処理へ進まないために書いています。
read() だけで全部を読むと、クライアントが接続を閉じるのを待ち続ける場合があります。この実習では Content-Length 分だけ読みます。大きさの上限とタイムアウトも設け、無制限には待たないようにしています。分割転送の Transfer-Encoding は扱いません。
save_message は、この段階では受け取った文字列を返すだけです。次の段階で中身を保存処理へ差し替えます。
書く:送信するファイル
同じフォルダーに message.json を作り、UTF-8で保存します。
{"text":"自分でPOSTを実装できました"}
動かす:保存して再起動する
curl.exe -i -H "Content-Type: application/json" --data-binary "@message.json" "http://127.0.0.1:8000/api/messages"
--data-binary でファイルの内容を送り、この場合はPOSTになります。期待するステータスは この段階では200、本文は次の形です。
{"received":"自分でPOSTを実装できました"}
次に、自分で書いたエラー分岐が働くか確認します。
curl.exe -i -H "Content-Type: application/json" --data-binary "not-json" "http://127.0.0.1:8000/api/messages"
curl.exe -i -H "Content-Type: text/plain" --data-binary "@message.json" "http://127.0.0.1:8000/api/messages"
順に400、415になるはずです。message.json を {"text":""} に変えた場合も400になります。確認後は元の文字列に戻します。
自分で説明する
「ヘッダーはJSONなのに本文がJSONでない」と「本文はJSONなのに必要な text がない」は、コードのどの条件で区別していますか。該当行を指して説明してください。
4. 保存してからGETで取得するAPIへ育てる
書く:保存先を追加する
すべてのimportの下、class Handler より前に、次の1行を追加します。
messages = []
リクエストごとの一時変数ではなく、同じサーバープロセスから共有するリストです。この教材の HTTPServer は要求を順に処理します。複数の処理を同時に動かすサーバーへ変える場合は、共有データの扱いも見直す必要があります。
書く:save_message だけを置き換える
段階3の2行のメソッドを、次へ置き換えます。do_POST の受信・検証部分はそのまま使います。
def save_message(self, text):
message = {"id": len(messages) + 1, "text": text}
messages.append(message)
self.respond(201, message, {"Location": f"/api/messages/{message['id']}"})
新しい対象を作るため、200から 201 Created へ変更しました。Location は作ったメッセージの取得先を伝えます。同じPOSTを2回送ると、別のIDで2件保存される仕様です。
書く:do_GET だけを置き換える
一覧・1件取得と、リダイレクトを追加します。respond、do_HEAD、do_POST、save_message、main は残してください。
def do_GET(self):
url = urlsplit(self.path)
if url.path == "/api/hello":
name = parse_qs(url.query).get("name", ["World"])[0]
self.respond(200, {
"message": f"こんにちは、{name}さん!",
"received_x_lesson": self.headers.get("X-Lesson"),
})
elif url.path == "/api/messages":
self.respond(200, {"messages": messages})
elif url.path.startswith("/api/messages/"):
identifier = url.path.removeprefix("/api/messages/")
for message in messages:
if str(message["id"]) == identifier:
self.respond(200, message)
return
self.respond(404, {"error": "メッセージがありません"})
elif url.path == "/redirect":
self.respond(302, {"message": "転送先へ移動してください"},
{"Location": "/api/hello?name=Redirect"})
else:
self.respond(404, {"error": "そのパスはありません"})
動かす:保存して再起動する
curl.exe -i "http://127.0.0.1:8000/api/messages"
curl.exe -i -H "Content-Type: application/json" --data-binary "@message.json" "http://127.0.0.1:8000/api/messages"
curl.exe -i "http://127.0.0.1:8000/api/messages/1"
curl.exe -i "http://127.0.0.1:8000/api/messages"
起動直後の一覧は {"messages": []} です。POSTで201が返り、その後のGETで同じ内容を取得できます。IDは応答の Location に合わせてください。ブラウザで一覧URLを開いても、同じJSONが見られます。
/redirect に対しても比較します。
curl.exe -i "http://127.0.0.1:8000/redirect"
curl.exe -i -L "http://127.0.0.1:8000/redirect"
1行目は302、2行目は302に続いて転送先の200が出ます。Location を返す処理はサーバー、転送先へ新たに要求する処理はクライアントが担当します。
自分で変更する
登録したデータの件数を返す /api/count を、do_GET の最後の else より前へ追加してください。応答は {"count": 2} のようなJSONにします。ヒントは len(messages) と self.respond(200, ...) です。
サーバーを再起動するとリストは空になります。件数を確かめるには、再起動後にもう一度POSTしてください。ブラウザの再読み込みだけではリストは消えません。
5. クライアント側もプログラムを書く
書く:新しい client.py
サーバーはターミナルAで動かしたままにします。同じフォルダーに client.py を新規作成して、次を書きます。server.py へ追記しないでください。
from http.client import HTTPConnection
import json
def request(method, path, data=None):
connection = HTTPConnection("127.0.0.1", 8000, timeout=5)
body = None
headers = {}
if data is not None:
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
try:
connection.request(method, path, body=body, headers=headers)
response = connection.getresponse()
result = response.read().decode("utf-8")
print(method, path, "->", response.status, response.reason)
print("Content-Type:", response.getheader("Content-Type"))
print(result)
return response.status, response.getheader("Location")
finally:
connection.close()
def main():
status, location = request("POST", "/api/messages", {"text": "Pythonから送信"})
if status == 201 and location is not None:
request("GET", location)
request("GET", "/api/messages")
request("GET", "/missing")
if __name__ == "__main__":
main()
connection.request がリクエストを送り、getresponse と read が応答を受け取ります。本文をUTF-8のバイト列にすると、http.client がその長さに合う Content-Length を付けます。サーバーで自分が書いた読み取り処理と対になっています。
動かす
ターミナルBで実行します。
python client.py
順に POST 201 → 作成した1件のGET 200 → 一覧のGET 200 → 存在しないパスのGET 404 が出ることを確認してください。取得先はPOSTの Location から読み取るので、すでにデータがあっても新しく作ったメッセージを取得できます。
自分で変更する
送信する text を空文字列へ変え、POSTが400になり、1件取得を実行しなくなることを確認してください。その後、元へ戻します。クライアント側でも、成功を前提にせずステータスを見て次の処理を決めます。
6. サーバー停止とHTTPエラーを比べる
まずサーバーが動いている状態で /missing へ送ると、404とJSONが返ります。次にターミナルAで Ctrl+C を押して止め、python client.py を実行します。
停止中は通常、接続失敗の例外になり、HTTPステータス自体が得られません。このクライアントは学習のため、接続例外をそのまま表示します。404のときは応答を受け取れていたことと比べてください。
発展課題として、client.py の main 呼び出しを try / except OSError で囲み、接続できない場合に分かりやすいメッセージを表示する処理を書いてみましょう。
書いたコードと通信を照合する
サーバーを再起動し、ブラウザで http://127.0.0.1:8000/api/hello?name=Sora を開きます。開発者ツールのNetworkタブを開いて再読み込みし、次の対応を確認します。
| Networkで見えるもの | 自分が書いた処理 |
|---|---|
| Request URL | self.path を分解したパスとクエリ |
| Request Method: GET | do_GET が呼ばれる |
| Status Code: 200 | self.respond(200, ...) |
| Content-Type | respond 内の send_header |
| ResponseのJSON | json.dumps したデータ |
Pythonの client.py が送る通信はブラウザのNetworkタブには出ません。そちらはクライアントの出力と、ターミナルAのサーバーログを照合します。この実習の / は段階2以降404で、操作ボタンのあるページは作っていません。
途中で詰まったときの確認
| 症状 | 確認する場所 |
|---|---|
起動時に IndentationError |
クラス内の def は4スペース、その本文は8スペース。タブを混ぜない |
| 追加した処理が動かない | 同じ名前のメソッドを2つ書いていないか。指定された箇所を置き換えたか |
| GETは動くのにPOSTが501 | do_POST がクラスの外になっていないか、サーバーを再起動したか |
| 編集したのに結果が変わらない | 保存し、Aで Ctrl+C → python server.py を行ったか |
/ が404になる |
段階2以降は正常。/api/hello や /api/messages を使う |
| リクエストが待ち続ける | end_headers()、本文の長さ、wfile.write を確認する |
| ポート8000が使用中 | 自分の古いサーバーを終了するか、main の8000を8001に変え、URLとクライアントの番号もそろえる |
message.json が読めない |
Bが作業フォルダーにいるか、.json.txt という名前になっていないか |
| curlが使えない | GETはブラウザで確認できる。POSTは段階5のクライアントを先に書いて request で試せる |
照合用のコード
各段階で動作を確かめたあと、自分のコードと比較するために使ってください。段階別ファイルはそれぞれ単独で動きます。複数のサーバーを同時に起動すると、ポート8000が重複します。
- 段階1:文字列を返すサーバー
- 段階2:GET・JSON・404
- 段階3:POSTの受信・検証
- 段階4:保存・取得・リダイレクト
- サーバーの完成例:http_server.py
- クライアントの完成例:http_client.py
完成例は必須の手順までを含み、/api/count や接続例外の表示など「自分で変更する」発展課題の解答は含みません。旧版の http_lab.py とはファイル名も構成も異なるので、この章では新しく作った server.py を使います。
達成を確認する
- 応答のステータス・ヘッダー・本文を設定した行を説明できる。
- GETのクエリとPOSTの本文を、それぞれコードで取り出せる。
- 正常なPOST、壊れたJSON、存在しないパスを自分で試し、分岐と結果を対応付けられる。
- 保存と取得の処理を書き、別のクライアントからも同じデータが見えることを確かめた。
- クライアントのコードからPOST・GETを送り、ステータスによって処理を変えられる。
「どのコードを変更したか」「送ったメソッド・URL・本文」「予想した結果」「実際の結果」を段階ごとに記録してください。実習後はサーバーを Ctrl+C で止めます。次章では、今回の観察にIP・DNS・経路の調査を加えます。