Webアプリケーションフレームワークの作り方 in Python — c-bata.link の資料が最近になってホットエントリー入りし、多くの方に読んでいただけています。実はあの資料途中で執筆に飽きちゃって雑に書き上げて放置していたのですが、最近読まれてるみたいなので気が向いたときに資料を修正しています。
そんな中でWSGIサーバーを作りながらHTTP 1.1について学べる章があってもいいかもとふと思いました。 書くとすれば内容的には id:shimizukawa さんのPyCon JP 2018の発表をもう少し詳しく説明する資料になりそうな気がします。
PyCon JP 2018: Webアプリケーションの仕組み - 清水川のScrapbox
とはいえ自分もWSGIサーバーを一度も書いたことがないので、気分転換にシンプルなWSGIサーバーを書いてみました。 4時間ぐらいかかるかなと思いつつ、 id:shimizukawa さんの上記の資料のコードをぱくりつつ書いてみたら、1時間で動くものが出来ました (WSGI Environの中身が少し足りてなかったりしますが)。
100行程度なので解説もそれほど大変ではなさそう。メモリに一気に載せてしまっていたり、他にも色々と実装が雑な点はあるけれど気が向いたらちゃんと書いてみようかなと思います。読みたいという声があればモチベーションあがるかも...
import io import socket import urllib.parse from threading import Thread class ResponseWriter: def __init__(self): self.headers = None self.status_code = None def start_response(self, status_code, headers): self.status_code = status_code self.headers = headers @property def called(self): return self.headers is not None and self.status_code is not None 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 501\r\n\r\nSorry\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") 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=128, max_read=4096): self.app = app self.host = host self.port = port self.max_accept = max_accept self.max_read = max_read def make_wsgi_environ(self, conn, client_addr): raw_request = b'' content_length = 0 while True: chunk = conn.recv(self.max_read) raw_request += chunk content_length += len(chunk) if len(chunk) < self.max_read: break header_bytes, body = raw_request.split(sep=b'\r\n\r\n', maxsplit=1) headers = header_bytes.decode('utf-8').splitlines() request_line = headers[0] headers = headers[1:] method, path, proto = request_line.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': self.host, 'SERVER_PORT': self.port, 'REMOTE_ADDR': f"{client_addr[0]}:{client_addr[1]}", 'wsgi.url_scheme': "http", 'wsgi.input': io.BytesIO(body), 'wsgi.multithread': True, 'wsgi.version': (1, 0, 1), } for l in headers: key, value = l.split(":", maxsplit=1) env_key = "HTTP_" + key.replace("-", "_").upper() if env_key in env: env[env_key] = env[env_key] + ',' + value else: env[env_key] = value 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_addr = sock.accept() env = self.make_wsgi_environ(conn, client_addr) Thread(target=worker, args=(conn, app, env), daemon=True).start() if __name__ == '__main__': from main import app serv = WSGIServer(app) serv.run_forever()
動かすと下のようにちゃんと APPEND_SLASH のリダイレクトとかも動いている。
最近かなりブログの更新頻度が落ちていたので、いつもより雑なネタですが出してみました。
2018/09/23 23:33:00 追記
この記事で紹介した実装は、socketからreadする長さが4096バイトの整数倍のときに、次の書き込みを期待する実装になっているのでブロックする。
ちゃんとハンドリングするならおそらく select
とか epoll
使うのが筋ですが、清水川さんの発表資料はトークの対象レベルが結構低めなので非同期I/Oの話を入れられなかったぽい。気が向いたらこっちのサンプルはselect使って修正します。
追記おわり