PythonでWSGI準拠のWebサーバを自作し、その上でBottleを動かしてみた

以前、「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」を参考に、PythonでWebサーバを書きました。
「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」が良かったのでPythonで書いてみた - メモ的な思考的な

gihyo.jp

 
この時は3章以降は読むだけにしましたが、やはり3章以降もPythonで実装してみたいと考えました。

3章以降はJava + Tomcatを使っていたため、Pythonの場合はWSGIを使って作るのが良さそうです。
java - What is the Python equivalent of Tomcat? - Stack Overflow

 
まずはWSGIの仕様を知ろうと、PEP3333の日本語訳を中心に読んでみました。

 
次に、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>

 
static/css/default.css

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準拠とするため、

  • WSGI環境変数の作成
  • WSGIアプリケーションから呼び出されるコールバック関数の作成
  • WSGI環境変数とコールバック関数を引数とした、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

と実行し、ブラウザで確認したところ

f:id:thinkAmi:20160718170207p:plain

と表示されました。

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つのレスポンスを処理するバージョンでは、グローバル変数を使ってstatusresponse_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環境変数のうち、

  • wsgi.multithread
  • wsgi.multiprocess
  • wsgi.run_once

の値をどうするか悩みましたが、今回は

env['wsgi.multithread']  = True  # マルチスレッドだからTrueでいいのかな 
env['wsgi.multiprocess'] = False # マルチプロセスではないのでFalse 
env['wsgi.run_once']     = True  # 複数回呼ばれそうなので、True

としました。

正しくないような気もしますので、ご存じの方がいればご指摘ください。

 

画像のレスポンスについて

上記の内容でWSGI準拠サーバを作成し、ブラウザで確認したところ、

f:id:thinkAmi:20160718171706p:plain

となりました。画像のレスポンスがうまくいっていないようです。

コンソールを確認したところ、

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

と実行し、ブラウザで確認したところ、

f:id:thinkAmi:20160718172248p:plain

画像も表示されていました。

 

ソースコード

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()

 

その他参考

*1:もっとも、WSGI準拠とはいいつつ、BottleでGETが動く最低限のものしか実装していませんが...