以前、「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」を参考に、PythonでWebサーバを書きました。
「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」が良かったのでPythonで書いてみた - メモ的な思考的な
この時は3章以降は読むだけにしましたが、やはり3章以降もPythonで実装してみたいと考えました。
3章以降はJava + Tomcatを使っていたため、Pythonの場合はWSGIを使って作るのが良さそうです。
java - What is the Python equivalent of Tomcat? - Stack Overflow
まずはWSGIの仕様を知ろうと、PEP3333の日本語訳を中心に読んでみました。
- PEP 3333: Python Web Server Gateway Interface v1.0.1 — knzm.readthedocs.org 2012-12-31 documentation
- PEP 3333 -- Python Web Server Gateway Interface v1.0.1 | Python.org
次に、WSGI準拠Webサーバをイチから作成する方法を調べてみたところ、以下の記事を見つけました(以下、「参考記事」と表記)。
Let’s Build A Web Server. Part 2. - Ruslan's Blog
図などで詳しく説明されていたため、だいたいの流れは理解できました。ありがとうございました。
上記を元にWSGI準拠のWebサーバを実装してみたため、その時の内容をメモしておきます*1。
なお、誤りなどがあれば、ご指摘いただけるとありがたいです。
目次
環境
- Windows10 x64
- Python 3.5.2 32bit
- bottle 0.12.9
今回は、WSGI準拠の自作Webサーバ上で、WSGIアプリケーションフレームワークのBottleを使ったアプリを動かします。
Bottle: Python Web Framework — Bottle 0.13-dev documentation
Bottleアプリの内容は、
bottle_app.py
@route('/') def index(): # Bottleで設定するレスポンスヘッダを確認 print("----Bottle's response_header----") print(bottle.response.headerlist) return template('index.html') @route('/static/css/<filename>') def static_css(filename): return static_file(filename, root=STATIC_DIR + '/css/') @route('/static/images/<filename>') def static_css(filename): return static_file(filename, root=STATIC_DIR + '/images/') app = bottle.default_app()
views/index.html
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>テスト</title> <link rel="stylesheet" href="/static/css/default.css"> </head> <body> <h1>テストタイトル</h1> <p>テストページです</p> <p>今日のシナノゴールド</p> <img src="/static/images/shinanogold.png"> </body> </html>
h1 { color: red; }
です。
1つのレスポンスを返すWSGI Webサーバの作成
まずは、単純に1つのレスポンスを返すWSGI Webサーバを作成します。
前回作成した以下のWebサーバをベースにします。
syakyo_create_web_server/modoki01_1_5_3.py at master · thinkAmi-sandbox/syakyo_create_web_server
WSGI準拠とするため、
の3点を追加します。
WSGI環境変数の作成
PEP3333では、このあたりに書いてあります。
今回は、
env = {} env['wsgi.version'] = (1, 0) # WSGIのバージョン:決め打ち env['wsgi.url_scheme'] = 'http' # urlのスキーム(http/https) # HTTP リクエスト本体のバイト列を読み出すことができる入力ストリーム env['wsgi.input'] = io.BytesIO(byte_request) env['wsgi.errors'] = sys.stderr env['wsgi.multithread'] = False env['wsgi.multiprocess'] = False env['wsgi.run_once'] = False env['REQUEST_METHOD'] = request_method # GET env['PATH_INFO'] = path # / env['SERVER_NAME'] = server_name # FQDN env['SERVER_PORT'] = str(port) # 8888
としました。
WSGIアプリケーションから呼び出されるコールバック関数の作成
PEP3333では、このあたりに書いてあります。
コールバック関数で必要な引数は、
status
- 文字列で、
200 OK
などが入ってくる
- 文字列で、
response_headers
- 文字列で、
[('Content-Length', '398'), ('Content-Type', 'text/html; charset=UTF-8')]
などが入ってくる
- 文字列で、
exc_info=None
- エラーがなければ、基本は
None
- エラーがなければ、基本は
の3つです。引数名は任意ですが、今回はPEP3333に合わせます。
コールバック関数の中では、ステータスコードやレスポンスヘッダがWSGIアプリケーションから渡されます。
ただ、
- コールバック関数の戻り値として、それらをWebサーバへ渡すことができない
- コールバック関数の中以外では、それらを取得できない
という制約があります。
そのため、参考記事ではインスタンス変数を使ってWebサーバへそれらを渡していました。
ただ、今回は関数による実装のため、
def start_response(status, response_headers, exc_info=None): # 任意の内容の、WSGIサーバで追加するレスポンスヘッダ server_headers = [ ('Date', 'Sat, 16 Jul 2016 00:00:00 JST'), ('Server', 'HenaWSGIServer 0.1'), ] global headers_set headers_set = [status, response_headers + server_headers]
と、グローバル変数へと保存しました。
WSGI環境変数とコールバック関数を引数とした、WSGIアプリケーションの呼び出し
上記で作成した環境変数とコールバック関数をWSGIアプリケーションに渡します。
byte_response_body = application(env, start_response)
ブラウザへのレスポンスについて
以前作ったWebサーバでは、ステータスライン・レスポンスヘッダ・レスポンスボディを1行ずつ返していました。
今回も同じように1行ずつ返したところ、
Content-Length: 398 Content-Type: text/html; charset=UTF-8 Date: Sat, 16 Jul 2016 00:00:00 JST Server: HenaWSGIServer 0.1 <!DOCTYPE html> <html lang="ja"> ...
のようなテキストがブラウザに表示されてしまい、正しいレスポンスとは認識されませんでした。
そこで
# ステータスラインからレスポンスボディまで、一括で送信する # ステータスライン str_response = 'HTTP/1.1 {status}\r\n'.format(status=status) # レスポンスヘッダ for header in response_headers: str_response += '{0}: {1}\r\n'.format(*header) # レスポンスヘッダとレスポンスボディを分ける、改行コード str_response += '\r\n' # レスポンスボディ for byte_body in byte_response_body: # WSGIアプリからもらったレスポンスボディはバイト列 # レスポンスヘッダなどと結合するため、一度文字列へとデコードする str_response += byte_body.decode('utf-8') # クライアントへ送信 # バイト列で送信する必要があるため、エンコードしてから送信 connection.sendall(str_response.encode('utf-8'))
のように、ステータスラインやレスポンスヘッダ、レスポンスボディを連結して、一括して返すようにしました。
動作確認
この時点のソースコードは以下の通りです。
wsgi_webserver-sample/single_response_wsgi_server.py at master · thinkAmi-sandbox/wsgi_webserver-sample
動作確認として、コマンドプロンプトより
>python single_response_wsgi_server.py bottle_app:app
と実行し、ブラウザで確認したところ
と表示されました。
1つのレスポンスしか処理できないため、CSSや画像の部分は対応できていません。
普通にWebページを表示するWebサーバの作成
今度はCSSや画像も含めたWebページを表示できる、WSGI準拠のWebサーバを作ります。
前回作成した以下のWebサーバのように、threading.Thread
にてマルチスレッド化することで、複数のリクエストとレスポンスを扱えるようにします。
syakyo_create_web_server/modoki02_main_2_6.py at master · thinkAmi-sandbox/syakyo_create_web_server
また、せっかくなので、クラス形式でWebサーバを作ってみます。
コールバック関数のstatusやresponse_headersの扱いについて
1つのレスポンスを処理するバージョンでは、グローバル変数を使ってstatus
やresponse_headers
を保存していました。
ただ、今回はマルチスレッド化するためグローバル変数が使えません。また、参考記事のようなクラス構成でインスタンス変数を使った場合、スレッド間でインスタンス変数を上書きしてしまいます。
スレッド固有のデータを保持するthreading.local()
を使うことも考えましたが、うまい使い方が思い浮かびませんでした。
Python の threadling.local (スレッドローカル) を試してみる | CUBE SUGAR STORAGE
そんな中、WSGIのリファレンス実装であるwsgiref
モジュールの構成を見たところ、serverとhandlerのクラスが分かれていました。これならマルチスレッド化できそうです。
cpython/Lib/wsgiref at master · python/cpython
そこで、
- メインスレッドのサーバクラス(
MyWSGIServer
)でsocket.accept()
- データを受信したら、別スレッドのハンドラクラス(
MyWSGIHandler
)でリクエストとレスポンスを処理
の形としました。
念のため、MyWSGIHandlerクラスのオブジェクトについて、
while True: client_connection, client_address = self.listen_socket.accept() handler = MyWSGIHandler(client_connection, client_address, self.application, self.server_name, self.server_port) # 念のため、インスタンスの識別値を確認 print("handler object id: {}".format(id(handler))) # スレッドで画像ファイルとかも受け取れるようにする thread = threading.Thread(target=handler.handle_one_request) thread.start()
のように、オブジェクト識別値を確認してみました。
結果は
handler object id: 29123088 handler object id: 30610064 handler object id: 59447632 handler object id: 59448240
となり、リクエストごとにオブジェクトが生成されていました。
WSGI環境変数について
wsgi.multithread
wsgi.multiprocess
wsgi.run_once
の値をどうするか悩みましたが、今回は
env['wsgi.multithread'] = True # マルチスレッドだからTrueでいいのかな env['wsgi.multiprocess'] = False # マルチプロセスではないのでFalse env['wsgi.run_once'] = True # 複数回呼ばれそうなので、True
としました。
正しくないような気もしますので、ご存じの方がいればご指摘ください。
画像のレスポンスについて
上記の内容でWSGI準拠サーバを作成し、ブラウザで確認したところ、
となりました。画像のレスポンスがうまくいっていないようです。
コンソールを確認したところ、
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x89 in position 0: invalid start byte
というエラーが出ていました。
レスポンスボディを結合する際の
str_response += byte_body.decode('utf-8')
で発生したようです。
画像なのでバイナリデータなのかなと思い、Bottleからのレスポンスボディを見たところ、
<bottle.WSGIFileWrapper object at 0x03305B30>
のようなBottleオブジェクトでした。
ファイルオブジェクトをsocketで送信する方法を探そうと公式ドキュメントを見たところ、Python3.5より、socket.sendfile()
が追加されていました。
18.1. socket — 低水準ネットワークインターフェース — Python 3.5.1 ドキュメント
Bottleでの使用例も探してみたところ、
yield connection.sendall(response.render_headers())
res = yield connection.sendfile(self.filelike, 0, self.blocksize)
https://github.com/j4cbo/chiral/blob/master/chiral/web/httpd.py#L146
のような形で、レスポンスヘッダの送信後に画像データを送れば良さそうでした。
動作確認
コマンドプロンプトより、
>python single_response_wsgi_server.py bottle_app:app
と実行し、ブラウザで確認したところ、
画像も表示されていました。
ソースコード
GitHubに上げました。
thinkAmi-sandbox/wsgi_webserver-sample
こちらにも残しておきます。
import socket import io import sys import threading class MyWSGIHandler(object): def __init__(self, client_connection, client_address, application, server_name, server_port): self.connection = client_connection self.address = client_address self.application = application self.server_name = server_name self.server_port = server_port # コールバック関数で取得できるステータスコード・レスポンスヘッダの保存先 self.headers_set = [] def handle_one_request(self): request_data = self.connection.recv(1024) env = self.get_environ(request_data) byte_response_body = self.application(env, self.start_response) self.finish_response(byte_response_body) def get_environ(self, byte_request): byte_request_line = byte_request.splitlines()[0] str_request_line = byte_request_line.decode('utf-8') str_request_line_without_crlf = str_request_line.rstrip('\r\n') request_method, path, request_version = str_request_line_without_crlf.split() env = {} env['wsgi.version'] = (1, 0) env['wsgi.url_scheme'] = 'http' env['wsgi.input'] = io.BytesIO(byte_request) env['wsgi.errors'] = sys.stderr env['wsgi.multithread'] = True # マルチスレッドだからTrueでいいのかな env['wsgi.multiprocess'] = False # マルチプロセスではないのでFalse env['wsgi.run_once'] = True # 複数回呼ばれそうなので、True env['REQUEST_METHOD'] = request_method # GET env['PATH_INFO'] = path # / env['SERVER_NAME'] = self.server_name # FQDN env['SERVER_PORT'] = str(self.server_port) # 8888 return env def start_response(self, status, response_headers, exc_info=None): server_headers = [ ('Date', 'Sat, 16 Jul 2016 00:00:00 JST'), ('Server', 'MyWSGIServer 0.2'), ] self.headers_set = [status, response_headers + server_headers] def finish_response(self, byte_response_body): try: status, response_headers = self.headers_set # ステータスライン str_response = 'HTTP/1.1 {status}\r\n'.format(status=status) # レスポンスヘッダ for header in response_headers: str_response += '{0}: {1}\r\n'.format(*header) # レスポンスヘッダとレスポンスボディを分ける、改行コード str_response += '\r\n' # レスポンスボディ print(byte_response_body) # 画像データのレスポンスがあった場合のオブジェクト # => <bottle.WSGIFileWrapper object at 0x03305B30> # これをそのままdecodeするとエラーになる # => UnicodeDecodeError: 'utf-8' codec can't decode byte 0x89 in position 0: invalid start byte if 'image' in str_response: # 画像データの場合 # レスポンスヘッダを送信した後、sendfile()で画像データを送信する self.connection.sendall(str_response.encode('utf-8')) self.connection.sendfile(byte_response_body) else: # 画像データ以外の場合 for byte_body in byte_response_body: # WSGIアプリからもらったレスポンスボディはバイト列 # レスポンスヘッダなどと結合するため、一度文字列へとデコードする str_response += byte_body.decode('utf-8') # クライアントへ送信 # バイト列で送信する必要があるため、エンコードしてから送信 self.connection.sendall(str_response.encode('utf-8')) finally: self.connection.close() class MyWSGIServer(object): def __init__(self, ip_address, port, wsgi_app): self.listen_socket = socket.socket( socket.AF_INET, socket.SOCK_STREAM ) self.listen_socket.bind((ip_address, port)) self.listen_socket.listen(1) self.application = wsgi_app self.server_name = socket.getfqdn(ip_address) self.server_port = port def serve_forever(self): while True: client_connection, client_address = self.listen_socket.accept() handler = MyWSGIHandler(client_connection, client_address, self.application, self.server_name, self.server_port) # 念のため、オブジェクト識別値を確認 print("handler object id: {}".format(id(handler))) # スレッドで画像ファイルとかも受け取れるようにする thread = threading.Thread(target=handler.handle_one_request) thread.start() def make_server(ip_address, port, wsgi_app): server = MyWSGIServer(ip_address, port, wsgi_app) return server if __name__ == '__main__': if len(sys.argv) < 2: sys.exit('"module:callable"の形でWSGIアプリケーションを指定してください') app_path = sys.argv[1] module, application = app_path.split(':') module = __import__(module) wsgi_app = getattr(module, application) # WSGIサーバの起動 httpd = make_server('', 8888, wsgi_app) print('MyWSGIServer: ホスト{address}、ポート{port}にて起動しました\n'.format( address=httpd.server_name, port=httpd.server_port)) httpd.serve_forever()