- 作者:Michal Jaworski,Tarek Ziade
- 発売日: 2018/02/26
- メディア: 単行本
はじめに — Webアプリケーションフレームワークの作り方 in Python の資料が最近になってホットエントリー入りし、思ったよりも多くの方に読んでいただけているようです。見返していると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 logging import socket import urllib.parse from threading import Thread logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) logger.addHandler(logging.StreamHandler()) def worker(conn, wsgi_app, env): with conn: headers = None status_code = None def start_response(s, h): nonlocal headers, status_code status_code = s headers = h wsgi_response = wsgi_app(env, start_response) if headers is None or status_code is None: conn.sendall(b'HTTP/1.1 500\r\n\r\nInternal Server Error\n') return logger.info(f"{env['REMOTE_ADDR']} - {status_code}") status_line = f"HTTP/1.1 {status_code}".encode("utf-8") headers = [f"{k}: {v}" for k, v in 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() # close() does not raise an exception if called twice. conn.sendall(status_line + b"\r\n" + header_bytes + b"\r\n\r\n" + response_body) def make_wsgi_environ(rfile, client_address, port): # 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': port, 'REMOTE_ADDR': client_address[0], '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 serve_forever(app, host="127.0.0.1", port=8000, max_accept=128, timeout=30.0, rbufsize=-1): logger.info(f"Serving HTTP on http://{host}:{port}/") with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True) sock.bind((host, port)) sock.listen(max_accept) while True: conn, client_address = sock.accept() conn.settimeout(timeout) rfile = conn.makefile('rb', rbufsize) env = make_wsgi_environ(rfile, client_address, port) Thread(target=worker, args=(conn, app, env), daemon=True).start() if __name__ == '__main__': from main import app serve_forever(app)
動かすとWSGIアプリケーションから渡されたヘッダーも正しく返しているので、下のようにリダイレクトとかも問題なく動いている。
最近かなりブログの更新頻度が落ちていたので、いつもより雑なネタですが出してみました。
2018/09/23 23:33:00 追記
パクられた!w
— Takayuki Shimizukawa (@shimizukawa) 2018年9月23日
受信byte数が4096byteだとブロックしちゃうバグがあるのだけど、解決するにはコードが複雑になっちゃう。シンプルな解決方法見つけたらおしえてー
この記事で紹介した実装は、socketからreadする長さが4096バイトの整数倍のときに、次の書き込みを期待する実装になっているのでブロックする。ちゃんとハンドリングするならおそらく select
とか epoll
使うのが筋ですが、清水川さんの発表資料はトークの対象レベルが結構低めなので非同期I/Oの話を入れられなかったぽい。気が向いたらこっちのサンプルはselect使って修正します。
wsgirefの実装読んでたら、makefileを使ったシンプルな解決策に気づいたのでこの問題修正済みです。
追記おわり
2018/09/24 04:10:00 追記
wsgirefの実装読んでいて知ったのですが、makefileメソッドを使えばファイルライクオブジェクトが返ってくるので、そこから readline() を使って行ベースで読み出せばキレイに解決することに気づきました。パース処理も楽になるし、wsgi.inputも簡単に作れて最高ですhttps://t.co/cOVpNQYd1q https://t.co/zhJxh0U8WQ
— Masashi Shibata (@c_bata_) 2018年9月23日
該当行(wsgiref)
- cpython/socketserver.py at c87d9f406bb23657c1b4cd63017bb7bd7693a1fb · python/cpython · GitHub
- cpython/server.py at c87d9f406bb23657c1b4cd63017bb7bd7693a1fb · python/cpython · GitHub
- cpython/simple_server.py at c87d9f406bb23657c1b4cd63017bb7bd7693a1fb · python/cpython · GitHub
追記おわり
2018/09/25 13:10:00 追記
昼休みにサクッとCLI用意してリポジトリ用意してみた。オートリロードの実装とかimportlib使ったアプリケーションの読み込み実装は昔実装したWSGICLIからコピペでほとんど済みました。
— Masashi SHIBATA (@c_bata_) 2018年9月25日
CLIは $ kwsgi https://t.co/ciFuSBwBAf app --port 8000 --reload みたいに使えます。https://t.co/2ohR8VfAZX
追記おわり