c-bata web

@c_bata_ のメモ。python と Go が多めです。

Pythonで約100行のシンプルなWSGIサーバーを書いてみる

エキスパートPythonプログラミング改訂2版

エキスパートPythonプログラミング改訂2版

Webアプリケーションフレームワークの作り方 in Python — c-bata.link の資料が最近になってホットエントリー入りし、思ったよりも多くの方に読んでいただけているようです。見返しているとWSGIサーバーを作りながらHTTPについて学べる章があってもいいかもとふと思いました。書くとすれば内容的には id:shimizukawa さんのPyCon JP 2018の発表をもう少し詳しく説明する資料になりそうな気がします。

PyCon JP 2018: Webアプリケーションの仕組み - 清水川のScrapbox

とはいえ自分もWSGIサーバーを一度も書いたことがないので、気分転換にシンプルなWSGIサーバーを書いてみました。 4時間ぐらいかかるかなと思いながら id:shimizukawa さんの上記の資料のコードをぱくりつつ書いてみたら、1時間ちょっとで最低限動くものが出来ました。その後結局リファクタリングのためにwsgirefやgunicornの実装読んでたら5時間ぐらい使ったのですが、今は手元のDjangoアプリケーションを動かしてみてもPOSTのエンドポイントとか含めてちゃんと動いてくれました。

100行ちょっと超えた程度の実装に落ち着いたので解説もそれほど大変ではなさそう。パフォーマンスの改善やヘッダーを異常に長くしてサーバー詰まらせるようなリクエストの対策などを考えだすと課題はいくらか残っていますが解説用にはちょうどいい実装かなと思います。

ソースコードはこちら

import socket
import urllib.parse
from threading import Thread


class ResponseWriter:
    def __init__(self):
        self.headers = None
        self.status_code = None
        self.called = False

    def start_response(self, status_code, headers):
        self.status_code = status_code
        self.headers = headers
        self.called = True


def worker(conn, wsgi_app, env):
    with conn:
        response = ResponseWriter()
        wsgi_response = wsgi_app(env, response.start_response)
        print(f"{env['REMOTE_ADDR']} - {response.status_code}")
        if not response.called:
            conn.sendall(b'HTTP/1.1 500\r\n\r\nInternal Server Error\n')
            return

        status_line = f"HTTP/1.1 {response.status_code}".encode("utf-8")
        headers = [f"{k}: {v}" for k, v in response.headers]

        response_body = b""
        content_length = 0
        for b in wsgi_response:
            response_body += b
            content_length += len(b)
        headers.append(f"Content-Length: {content_length}")
        header_bytes = "\r\n".join(headers).encode("utf-8")
        env["wsgi.input"].close()  # this doesn't raise error if close twice.
        conn.sendall(status_line + b"\r\n" + header_bytes + b"\r\n\r\n" + response_body)


class WSGIServer:
    def __init__(self, app, host="127.0.0.1", port=8000,
                 max_accept=32, timeout=30.0, rbufsize=-1):
        self.app = app
        self.host = host
        self.port = port
        self.max_accept = max_accept
        self.timeout = timeout
        self.rbufsize = rbufsize

    def make_wsgi_environ(self, rfile, client_address):
        # should return '414 URI Too Long' if line is longer than 65536.
        raw_request_line = rfile.readline(65537)
        method, path, version = str(raw_request_line, 'iso-8859-1').rstrip('\r\n').split(' ', maxsplit=2)
        if '?' in path:
            path, query = path.split('?', 1)
        else:
            path, query = path, ''

        env = {
            'REQUEST_METHOD': method,
            'PATH_INFO': urllib.parse.unquote(path, 'iso-8859-1'),
            'QUERY_STRING': query,
            'SERVER_PROTOCOL': "HTTP/1.1",
            'SERVER_NAME': socket.getfqdn(),
            'SERVER_PORT': self.port,
            'REMOTE_ADDR': client_address,
            'SCRIPT_NAME': "",
            'wsgi.version': (1, 0),
            'wsgi.url_scheme': "http",
            'wsgi.multithread': True,
            'wsgi.multiprocess': False,
            'wsgi.run_once': False,
        }

        while True:
            # should return '431 Request Header Fields Too Large'
            # if line is longer than 65536 or header exceeds 100 lines.
            line = rfile.readline(65537)
            if line in (b'\r\n', b'\n', b''):
                break

            key, value = line.decode('iso-8859-1').rstrip("\r\n").split(":", maxsplit=1)
            value = value.lstrip(" ")
            if key.upper() == "CONTENT-TYPE":
                env['CONTENT_TYPE'] = value
            if key.upper() == "CONTENT-LENGTH":
                env['CONTENT_LENGTH'] = value
            env_key = "HTTP_" + key.replace("-", "_").upper()
            if env_key in env:
                env[env_key] = env[env_key] + ',' + value
            else:
                env[env_key] = value
        env['wsgi.input'] = rfile
        return env

    def run_forever(self):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True)
            sock.bind((self.host, self.port))
            sock.listen(self.max_accept)

            while True:
                conn, client_address = sock.accept()
                conn.settimeout(self.timeout)
                rfile = conn.makefile('rb', self.rbufsize)
                env = self.make_wsgi_environ(rfile, client_address)
                Thread(target=worker, args=(conn, self.app, env), daemon=True).start()


if __name__ == '__main__':
    from main import app
    serv = WSGIServer(app)
    serv.run_forever()

動かすとWSGIアプリケーションから渡されたヘッダーも正しく返しているので、下のようにリダイレクトとかも問題なく動いている。

f:id:nwpct1:20180923214828p:plain

最近かなりブログの更新頻度が落ちていたので、いつもより雑なネタですが出してみました。

2018/09/23 23:33:00 追記

この記事で紹介した実装は、socketからreadする長さが4096バイトの整数倍のときに、次の書き込みを期待する実装になっているのでブロックする。ちゃんとハンドリングするならおそらく select とか epoll 使うのが筋ですが、清水川さんの発表資料はトークの対象レベルが結構低めなので非同期I/Oの話を入れられなかったぽい。気が向いたらこっちのサンプルはselect使って修正します。

wsgirefの実装読んでたら、makefileを使ったシンプルな解決策に気づいたのでこの問題修正済みです。

追記おわり

2018/09/24 04:10:00 追記

該当行(wsgiref)

追記おわり

2018/09/25 13:10:00 追記

追記おわり