Chanomic Blog

Socket通信勉強(3) - 簡易HTTPサーバー作成

(last modified:
)

categories:

1年以上前に書いた記事 で、HTTPサーバーもどき(リクエストを読まず、ただ一方的にレスポンスを返すだけのサーバ)を書いた。 今回はもう少しだけこれを進化させる。

動機

非常にどうでもいいのだが動機を記しておく。

Land of Lispの13章でソケット通信が出てきた。 Land of Lispで扱っているCommon Lispの処理系がCLISPなのに対し、今自分が利用しているのはSBCLなので、 本文中のコードが動かない。そこで色々調べて、usocketを利用しようと思いつく。 その後なんとか書き上げる。ところがChromeやcurlでは動くのに、Safari(現バージョンは14.0.2)では動かない。ページを読み込んだ後、タイムアウトしたかのような挙動を起こす。

その理由を明らかにしたくて「そもそもLisp以外では動くのか。例えばPythonのソケット通信では動くのか」「PythonのWebアプリ、例えばFlaskの開発用サーバーで動くのはなぜか」 など色々調べた。cpythonのsocketserverやhttp.serverなどのソースコードも読んだ。

調べた結果、どうやらSafariがたまに「何も送らない通信(?)」を行うことが原因だった。 何も送ってくれないと、リクエストをrecvで受け取るときに、ブロッキングが働いてサーバー側が固まってしまう。 ただし普通のリクエストも送ってくるので、マルチスレッドなどの多重化を行なっておけば 問題なくSafariでもページが読み込まれる。なのでFlaskの開発用サーバーでは大した問題にならなかった。 Safariがなぜこんな通信をするのかはよく分からない。HTTPの仕様をちゃんと読んでいないので、何か見落としがあるのだろうか。もしくはバグか何かなのか。

何はともあれ、色々ソースコードを読んでいくうちに、リクエストヘッダの取得のやり方など参考になった。 せっかくなのて得た知見を元に再びHTTPサーバを作ってみようと思い立った。

作るもの

以下の条件を満たすHTTPサーバのようなものを作る(そもそも、どこまで実装できたらHTTPサーバと呼べるのだろうか)。

前回に比べてPythonについての知見が広がったため、 コードにおいてf-stringsやtype-annotation、dataclassなどの機能を使ってみている。 また処理を細かく関数に分ける。

listen用のソケットの作成

待ち受け用のソケットを作成し、それを返す関数を作成する。 bindlistenは前回説明した通り。 動作確認を何度も行う都合上、TIME_WAIT状態のポートを待つのは面倒なので、setsockopt(...)の記述でそれを解決している。 (この辺りの詳細は"TIME_WAIT"とか"REUSEADDR"あたりのキーワードで検索すれば出てくる)

import socket


def server_socket(port: int):
    soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    soc.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True)
    soc.bind(('', port))
    soc.listen(5)
    return soc

動作確認

以下のようにrun_serverを作る。

def run_server(port: int):
    with server_socket(port) as soc:
        while True:
            conn, addr = soc.accept()
            print(f'Connected: {addr}')
            with conn:
                conn.shutdown(socket.SHUT_RDWR)

if __name__ == '__main__':
    run_server(8080)

作ったサーバーを実行し、別の端末でcurl localhost:8080とすると、サーバー側で以下のようなメッセージが出力される。 60724の部分は実行の度に異なる。

Connected: ('127.0.0.1', 60724)

ハンドラの作成とマルチスレッド化

リクエストハンドラを作成する。これは、クライアントから送られてきたリクエストの情報を元にしてレスポンスを返す関数。 リクエストを受信したりレスポンスを送信したりする必要があるため、引数にソケットをとっている。第2引数はログ用。

以下では、テストのため適当なレスポンスを返している (前回は改行文字を特に考えず送信してうまくいっていたが、HTTPの仕様では改行文字はCRLFのようだ)。

from typing import Tuple


def handle_request(conn: socket.socket, addr: Tuple[str, str]):
    with conn:
        conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello, World')
        print(addr, '200 OK')
        conn.shutdown(socket.SHUT_RDWR)

続いて、run_serverを修正する。threading.Threadで、handle_requestを別スレッドで実行することができる。

import threading


def run_server(port: int):
    with server_socket(port) as soc:
        while True:
            conn, addr = soc.accept()
            print(f'Connected: {addr}')
            t = threading.Thread(target=handle_request, args=[conn, addr])
            t.start()

作ったサーバーを実行し、別の端末でcurl localhost:8080とすると、curl側で以下のようなメッセージが出力される。

Hello, World

サーバー側では以下のようなメッセージが出力される。

Connected: ('127.0.0.1', 63066)
('127.0.0.1', 63066) 200 OK

リクエストライン、リクエストヘッダの受信とパース

handle_requestを修正する。 makefileを使うと、ソケットをファイルのように扱えるようになる。 こうしておくと、例えばreadlineメソッドで1行毎にデータを取得することができるようになるので、recvメソッドよりも使い勝手が良い。

def handle_request(conn: socket.socket, addr: Tuple[str, str]):
    with conn:
        with conn.makefile('rb') as rfile:
            request_line = get_request_line(rfile) #これから書く
            headers = get_request_headers(rfile) #これから書く
            print(addr, request_line)
            print(addr, headers)
            conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello, World')
            print(addr, '200 OK')
            conn.shutdown(socket.SHUT_RDWR)

リクエストラインの取得とパース

HTTPの仕様より、リクエストラインは メソッド、URI、HTTPのバージョンが空白区切りで並んでいることがわかる。 なので、splitを使って3つを切り出せば良い。 丁寧にRequestLineというdataclassを作ってそれを返すようにする。

from typing import Tuple, BinaryIO 


@dataclass
class RequestLine:
    method: str
    uri: str
    http_version: str


def get_request_line(rfile: BinaryIO) -> RequestLine:
    [method, uri, http_version] = rfile.readline(65535).decode('ascii').strip().split()
    return RequestLine(method, uri, http_version)

リクエストヘッダの取得とパース

1行ずつデータを取得して、:keyvalueに分ける。 \r\nのみの行が現れれば、それがリクエストヘッダの終わりだと分かる。

splitメソッドでmaxsplit=1としているのは、余計に:が分割されるのを防ぐため。例えば、host: localhost:8080['host', 'localhost', '8080']となるのを防ぐ。

lowerメソッドでkeyを小文字にしているのは、HTTPの仕様上ヘッダ名がcase-insensitiveであるため。 例えば、Content-Typecontent-typeが区別されるのを防ぐ。

from typing import Tuple, BinaryIO, Dict

def get_request_headers(rfile: BinaryIO) -> Dict[str, str]:
    headers = dict()

    while True:
        line = rfile.readline(65535)
        if line == b'\r\n':
            break
        key, value = line.decode('ascii').split(':', maxsplit=1)
        key = key.strip().lower()
        value = value.strip()
        headers[key] = value

    return headers

動作確認

作ったサーバーを起動し、別の端末でcurl localhost:8080を実行。サーバー側で以下のメッセージが出力される。

Connected: ('127.0.0.1', 50076)
('127.0.0.1', 50076) RequestLine(method='GET', uri='/', http_version='HTTP/1.1')
('127.0.0.1', 50076) {'host': 'localhost:8080', 'user-agent': 'curl/7.64.1', 'accept': '*/*'}
('127.0.0.1', 50076) 200 OK

(おまけ)HTTPクライアントもどきの作成

サーバーに適当なリクエストをして、レスポンスをヘッダごと出力するだけのプログラムを書いてみる。

recv_allはサーバーからのレスポンスを全て読み取る関数。 recv相手のソケットが閉じられた場合に0バイトのデータを受信する。よって、それを読み込み終わりの条件としている。 クライアントではこのような条件が書けるが、サーバでは書けないことに注意。 クライアントに送信するまでソケットを閉じることができないから、ブロッキングが働いてサーバーは固まってしまう。 ちなみに同様の理由で、リクエストヘッダにContent-Lengthが無ければリクエストボディを読み取れない。

import socket


def connect(host: str, port: int):
    soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    soc.connect((host, port))
    return soc


def recv_all(conn) -> bytes:
    chunks = []
    while True:
        chunk = conn.recv(4096)
        if chunk == b'':
            break
        chunks.append(chunk)
    return b''.join(chunks)


def send_request(conn: socket.socket):
    conn.send(b'GET / HTTP/1.1\r\n\r\n')


if __name__ == '__main__':
    with connect('localhost', 8080) as conn:
        send_request(conn)
        response = recv_all(conn)
        print(response.decode('ascii'))

サーバーを起動後、このクライアントを実行すると、クライアント側で以下のメッセージが出力される。

HTTP/1.1 200 OK
Content-Type: text/plain

Hello, World

サーバーのエラー処理

上のようなクライアントもどきを作っておくことで、以下、サーバー側で問題が発生するメッセージが送れる。

HTTPリクエストの書式に従っていないメッセージ

send_requestを以下のようにすると、サーバーはエラーを吐く。 第1行目はリクエストラインが期待されるが、まったく異なる文字列を送っている。

def send_request(conn: socket.socket):
    conn.send(b'this is not a http request.\r\nthis is not a header')

クライアントを実行すると、サーバー側で以下の例外が発生している。

Connected: ('127.0.0.1', 58254)
Exception in thread Thread-1:
Traceback (most recent call last):
  ...
  File "server.py", line 23, in get_request_line
    [method, uri, http_version] = rfile.readline(65535).decode('ascii').strip().split()
ValueError: too many values to unpack (expected 3)

何のリクエストも送らない

send_requestを以下のようにすると、サーバ側で固まったスレッドが発生してしまう。またクライアントも固まる。 どちらかがCtrl+Cで強制終了しない限り、お互い身動きができない。

def send_request(conn: socket.socket):
    pass

エラー処理

上2つのエラーを処理するのは単純で、以下のようにtry文を用いれば良い。

timeoutの時間を決めるために、conn.settimeout(30)を呼び出す。これにより、30秒ブロッキングが行われていた場合は socket.timeout例外が送出される。

また不正な書式が送信されてきた場合はValueError例外が送出されるため、その時は404 BadRequestを送るようにする。

しかしそれだけだと、例えば相手がソケットを最初に閉じてしまった場合などに対応できないため、そのための例外をOSErrorで捕捉する。

エラー処理を入れるとコードが結構複雑になることが分かる。

def handle_request(conn: socket.socket, addr: Tuple[str, str]):
    with conn:
        try:
            conn.settimeout(30)
            with conn.makefile('rb') as rfile:
                try:
                    request_line = get_request_line(rfile)
                    headers = get_request_headers(rfile)
                    print(addr, request_line)
                    print(addr, headers)
                    conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello, World')
                    print(addr, '200 OK')
                    conn.shutdown(socket.SHUT_RDWR)
                except socket.timeout:
                    print(addr, 'timeout')
                except ValueError:
                    conn.send(b'HTTP/1.1 400 BadRequest\r\nContent-Type: text/plain\r\n\r\n')
                    conn.shutdown(socket.SHUT_RDWR)
        except OSError as e:
            print(addr, f"OSError: {e}")

(おまけ)簡易的なルーティング

試しにルーティングをやってみる。次のような処理を行う。

レスポンスを返すためにconn.send(...)というを何度も書いているとコードが読みづらくなるため、 send_responseという関数を定義する。ステータスコードやヘッダ、ボディを実際に組み合わせる処理はmake_responseに任せる。

from typing import Tuple, BinaryIO, Dict, List

def make_response(status: str, headers: List[Tuple[str, str]], body: str) -> bytes:
    headers_string = '\r\n'.join([f'{h[0]}: {h[1]}' for h in headers])
    response = '\r\n'.join([f'HTTP/1.1 {status}', headers_string, '', body])
    return response.encode('ascii')


def send_response(conn: socket.socket, addr: Tuple[str, str], status: str, headers: List[Tuple[str, str]], body: str):
    conn.send(make_response(status, headers, body))
    print(addr, status)

これを元にhandle_requestを修正する。

def handle_request(conn: socket.socket, addr: Tuple[str, str]):
    with conn:
        try:
            conn.settimeout(30)
            with conn.makefile('rb') as rfile:
                try:
                    request_line = get_request_line(rfile)
                    headers = get_request_headers(rfile)
                    route(conn, addr, request_line, headers)
                    conn.shutdown(socket.SHUT_RDWR)
                except socket.timeout:
                    print(addr, 'timeout')
                except ValueError:
                    send_response(conn, addr, '400 BadRequest', [('Content-Type', 'text/plain')], 'BadRequest')
                    conn.shutdown(socket.SHUT_RDWR)
        except OSError as e:
            print(addr, f"OSError: {e}")

URIに応じて適切なレスポンスを送る関数routeを作成する。/greeting/<name>nameの部分を取り出すために、ここでは正規表現を使っている。

import re


def route(conn: socket.socket, addr: Tuple[str, str], request_line: RequestLine, headers: Dict[str, str]):
    m = re.match(r'/greeting/(\w+)', request_line.uri)

    if request_line.uri == "/":
        send_response(conn, addr, '200 OK', [('Content-Type', 'text/plain')], 'Hello, World')
    elif m is not None:
        name = m.group(1)
        send_response(conn, addr, '200 OK', [('Content-Type', 'text/plain')], f'Hello, {name}!')
    else:
        send_response(conn, addr, '404 NotFound', [('Content-Type', 'text/plain')], '404 NotFound')

作ったサーバーを起動し、別の端末でcurl localhost:8080を実行すると、curl側で以下のメッセージが出力される。

Hello, World

curl localhost:8080/greeting/Taroを実行すると、curl側で以下のメッセージが出力される。

Hello, Taro!

curl localhost:8080/fooを実行すると、curl側で以下のメッセージが出力される。

404 NotFound

ちなみに日本語には対応していない。localhost:8080/greeting/太郎としても、URIのエンコード処理をencode('ascii')で行なっているため失敗する。 そもそもURIの仕様上、日本語を直接埋め込むことはできない。日本語を間接的に埋め込む方法としてパーセントエンコーディングというものがあり、これはurllib.parse.urlencodeでできそうだが、 詳しくは未調査。

最終的なコード

100行に満たないコードで、HTTPサーバーっぽいものが実現できることが分かる。

import socket
import threading
from typing import List, BinaryIO, Tuple, Dict
from dataclasses import dataclass
import re


def server_socket(port: int):
    soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    soc.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True)
    soc.bind(('', port))
    soc.listen(5)
    return soc


@dataclass
class RequestLine:
    method: str
    uri: str
    http_version: str


def get_request_line(rfile: BinaryIO) -> RequestLine:
    [method, uri, http_version] = rfile.readline(65535).decode('ascii').strip().split()
    return RequestLine(method, uri, http_version)


def get_request_headers(rfile: BinaryIO) -> Dict[str, str]:
    headers = dict()

    while True:
        line = rfile.readline(65535)
        if line == b'\r\n':
            break
        key, value = line.decode('ascii').split(':', maxsplit=1)
        key = key.strip().lower()
        value = value.strip()
        headers[key] = value

    return headers


def make_response(status: str, headers: List[Tuple[str, str]], body: str) -> bytes:
    headers_string = '\r\n'.join([f'{h[0]}: {h[1]}' for h in headers])
    response = '\r\n'.join([f'HTTP/1.1 {status}', headers_string, '', body])
    return response.encode('ascii')


def send_response(conn: socket.socket, addr: Tuple[str, str], status: str, headers: List[Tuple[str, str]], body: str):
    conn.send(make_response(status, headers, body))
    print(addr, status)


def route(conn: socket.socket, addr: Tuple[str, str], request_line: RequestLine, headers: Dict[str, str]):
    m = re.match(r'/greeting/(\w+)', request_line.uri)

    if request_line.uri == "/":
        send_response(conn, addr, '200 OK', [('Content-Type', 'text/plain')], 'Hello, World')
    elif m is not None:
        name = m.group(1)
        send_response(conn, addr, '200 OK', [('Content-Type', 'text/plain')], f'Hello, {name}!')
    else:
        send_response(conn, addr, '404 NotFound', [('Content-Type', 'text/plain')], '404 NotFound')


def handle_request(conn: socket.socket, addr: Tuple[str, str]):
    with conn:
        try:
            conn.settimeout(30)
            with conn.makefile('rb') as rfile:
                try:
                    request_line = get_request_line(rfile)
                    headers = get_request_headers(rfile)
                    route(conn, addr, request_line, headers)
                    conn.shutdown(socket.SHUT_RDWR)
                except socket.timeout:
                    print(addr, 'timeout')
                except ValueError:
                    send_response(conn, addr, '400 BadRequest', [('Content-Type', 'text/plain')], 'BadRequest')
                    conn.shutdown(socket.SHUT_RDWR)
        except OSError as e:
            print(addr, f"OSError: {e}")


def run_server(port: int):
    with server_socket(port) as soc:
        while True:
            conn, addr = soc.accept()
            print(f'Connected: {addr}')
            t = threading.Thread(target=handle_request, args=[conn, addr])
            t.start()


if __name__ == '__main__':
    run_server(8080)

補足: WSGIサーバー

もちろん今まで作ってきたHTTPサーバーは車輪の再発明である。HTTPサーバーはPythonのモジュールhttp.serverとして用意されている。 さらに一歩進んで、WebアプリとHTTPサーバをつなぐ仕様にWSGIというものがある。WSGIの仕様に則ったWebアプリを書けば、WSGIの仕様に則ったどんなサーバでもそれを動かすことができる。 WSGIサーバーの実装はwsgirefで用意されているので、試しに使ってみる。

以下は、前節でのルーティングをWSGIの仕様に則って書いたもの。

import re
from wsgiref.simple_server import make_server


def app(env, start_response):
    uri = env['PATH_INFO']
    m = re.match(r'/greeting/(\w+)', uri)

    if uri == "/":
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b'Hello, World']
    elif m is not None:
        name = m.group(1)
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [f'Hello, {name}!'.encode('ascii')]
    else:
        start_response('404 NotFound', [('Content-Type', 'text/plain')])
        return ['404 NotFound']


if __name__ == '__main__':
    with make_server('', 8080, app) as httpd:
        httpd.serve_forever()

WSGIの仕様により、Webアプリを表す関数(上のコードではapp、もしクラスなら__call__メソッド)の引数は2つと定められている。そして、第1引数envはリクエストに関するあらゆる情報を持っており、 第2引数start_responseはステータスコードとレスポンスヘッダを引数にとる関数である。 関数appの返り値はレスポンスボディである。このようなルールに従って関数appを定義したことにより、make_server関数でサーバーを動かすことができる。 WSGIはWebアプリとWebサーバー間の仕様であるため当前と言えば当前なのだが、ソケット通信のことは一切考えることなくWebアプリを書けるようになる。

とはいえappを直接書くと、 「ルーティングを正規表現で行なったが、正規表現より読みやすい方法が欲しい」 「URIが増えるとその度にif文が増えるため、コードが読みづらくなる」 「ステータスコードやヘッダをいちいち指定しなければいけないのが面倒」 など色々不満が出てくる。ライブラリやフレームワークは、それらの不満を解決するだけでなく、便利な機能(フレームワークによるが、例えばCookieの取得、データベース連携、アカウント認証など)を簡単に利用できる。 WSGI対応のWebアプリを作るためのライブラリとしてwerkzeugがある。 WebアプリのフレームワークとしてFlaskDjangoがある。

wsgirefはリファレンス実装のため、おそらく機能が簡素であったり、性能面に問題がある(詳しくは調べていないので確かなことは言えないが)。 なのでWebアプリを実際にデプロイしようとなったときは、wsgirefではなく外部のWSGI対応サーバを利用することになる。 WSGI対応のサーバーとしては、uWSGIGunicornがある。