第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が重複します。

完成例は必須の手順までを含み、/api/count や接続例外の表示など「自分で変更する」発展課題の解答は含みません。旧版の http_lab.py とはファイル名も構成も異なるので、この章では新しく作った server.py を使います。

達成を確認する

  1. 応答のステータス・ヘッダー・本文を設定した行を説明できる。
  2. GETのクエリとPOSTの本文を、それぞれコードで取り出せる。
  3. 正常なPOST、壊れたJSON、存在しないパスを自分で試し、分岐と結果を対応付けられる。
  4. 保存と取得の処理を書き、別のクライアントからも同じデータが見えることを確かめた。
  5. クライアントのコードからPOST・GETを送り、ステータスによって処理を変えられる。

「どのコードを変更したか」「送ったメソッド・URL・本文」「予想した結果」「実際の結果」を段階ごとに記録してください。実習後はサーバーを Ctrl+C で止めます。次章では、今回の観察にIP・DNS・経路の調査を加えます。