wsgiref.simple_server で web アプリケーションを作ってみる

  • 8
    Like
  • 0
    Comment

はじめに

Python には、web サーバーと web アプリケーションフレームワークを統一的に接続するための WSGI というインタフェース定義があります。
大切なことは全て PEP 3333 とか readthedocs とかに書いてあるので、適宜そちらを参照しましょう。
ここでは細かい仕様などには一切触れずに、雰囲気で何をすればよいか掴んでいきます。

把握してみる

最も単純な Web アプリケーション

これが Hello World を出力するためのコードです。どのパスに対しても Hello World を出力します。
公式ドキュメントからの引用です。

app.py
from wsgiref.simple_server import make_server

# Every WSGI application must have an application object - a callable
# object that accepts two arguments. For that purpose, we're going to
# use a function (note that you're not limited to a function, you can
# use a class for example). The first argument passed to the function
# is a dictionary containing CGI-style environment variables and the
# second variable is the callable object (see PEP 333).
def hello_world_app(environ, start_response):
    status = '200 OK'  # HTTP Status
    headers = [('Content-type', 'text/plain; charset=utf-8')]  # HTTP Headers
    start_response(status, headers)

    # The returned object is going to be printed
    return [b"Hello World"]

httpd = make_server('', 8000, hello_world_app)
print("Serving on port 8000...")

# Serve until process is killed
httpd.serve_forever()

色々なものが見えてきます。見ていきましょう。

def hello_world_app(environ, start_response):
    status = '200 OK'  # HTTP Status
    headers = [('Content-type', 'text/plain; charset=utf-8')]  # HTTP Headers
    start_response(status, headers)

    # The returned object is going to be printed
    return [b"Hello World"]

これは web アプリケーションの核となる関数です。この関数が、全てのリクエストを受け取り、レスポンスを返します。
第一引数に、リクエストを受け取った際の環境変数の dict が、第二引数にレスポンスを返すための callable らしきものが渡される、というインタフェースになっています。

次のような手順を踏めば、HTTP レスポンスを返すことができるらしいことが分かります。

  1. ステータスコードとレスポンスヘッダのリスト(ヘッダ名と内容のタプルのリスト)を start_response() に渡す
  2. bytes にエンコードされたレスポンス本文を1行ごとに格納したリストを return する
httpd = make_server('', 8000, hello_world_app)
print("Serving on port 8000...")

# Serve until process is killed
httpd.serve_forever()

wsgiref.simple_server.make_server() 関数に、ホスト名とポートとリクエストを処理する関数を渡すと、サーバーが完成します。
httpd.serve_forever() 関数を呼び出すことによって、サーバーを稼動させることができます。
こうして、最初の web アプリケーションが完成します。

ルーティングをする

さて、たいていの場合は、どのようなパスに対しても全く同じレスポンスを返すということはしないでしょうから、ルーティングをやっていく必要があります。うまくやっていきましょう。

いきなりですが、実装例です。

app.py
import sys
from wsgiref.simple_server import make_server

def not_found(env):
    request_path = env['PATH_INFO']

    status = '404 Not Found'
    headers = [('Content-Type', 'text/plain; charset=utf-8')]
    body = 'Not Found: {}'.format(request_path)

    return status, headers, body

def bad_request(env):
    request_method = env['REQUEST_METHOD']
    request_path = env['PATH_INFO']

    status = '400 Bad Request'
    headers = [('Content-Type', 'text/plain; charset=utf-8')]
    body = 'Bad Request: {} {}'.format(request_method, request_path)

    return status, headers, body

def index(env):
    status = '200 OK'
    headers = [('Content-Type', 'text/plain; charset=utf-8')]
    body = 'Hello, world!'

    return status, headers, body

def cat(env):
    status = '200 OK'
    headers = [('Content-Type', 'text/plain; charset=utf-8')]
    body = 'にゃーん'

    return status, headers, body

def routing(env):
    request_method = env['REQUEST_METHOD']
    request_path = env['PATH_INFO']

    allowed_request_method = {'GET', 'POST'}

    router = {
        'GET': {
            '/': index,
            '/cat': cat,
        },
        'POST': {
        }
    }

    if request_method not in allowed_request_method:
        return bad_request(env)

    return router[request_method].get(request_path, not_found)(env)

def app(env, start_response):
    status, headers, body_raw = routing(env)
    body = [bytes(line, encoding='utf-8') for line in body_raw.splitlines()]

    start_response(status, headers)
    return body

def main(argv):
    httpd = make_server('localhost', 8080, app)
    httpd.serve_forever()

    return 0

if __name__ == '__main__':
    sys.exit(main(sys.argv))

これをペッと貼って起動すれば、8080番ポートを listen する HTTP サーバーが立ち上がるはずです。

app() のやること

さて、make_server() に渡せる関数の中であらゆることをやろうとすると、いずれ破綻します。
したがって、やることを分散させていくのが世の常です。

app() には、次のことだけをやってもらうようにしました。

  • 環境変数と start_response を引数として取る
  • routing() 関数に環境変数を渡し、ステータスコード、レスポンスヘッダ、レスポンスボディの三つ組を受け取る
  • レスポンスボディを1行ごとに bytes のリストに変換する
  • それらを踏まえて start_response を呼び、レスポンスボディを返す

ルーティングが routing() という関数に分担されるようになったとも呼べます。見ていきましょう。

routing() のやること

さて、ルーティングを routing() にやってもらうことにしました。ところで、肝心のリクエストメソッドやパスはどこに格納されているのでしょうか?
ご安心を。それぞれ REQUEST_METHOD 環境変数と PATH_INFO 環境変数に格納されています。

def routing(env):
    request_method = env.get('REQUEST_METHOD', '')
    request_path = env('PATH_INFO', '')

    allowed_request_method = {'GET', 'POST'}

    router = {
        'GET': {
            '/': index,
            '/cat': cat,
        },
        'POST': {
        }
    }

    if request_method not in allowed_request_method:
        return bad_request(env)

    return router[request_method].get(request_path, not_found)(env)

routing() の実装を抜き出しました。ここで雑なルーティングが行われています。
今回は次のようなルーティングをするようにしました。

  • GET /
  • GET /cat
  • それ以外の GET と POST リクエストに対しては 404 を返す
  • それ以外のリクエストメソッドに対しては 400 を返す1

どのようにルーティングをするかを router で決めています。
リクエストメソッドごとに、どのパスならこの関数を呼ぶ、存在しないなら not_found を呼ぶ、などの処理を行っています。

ルーターに指定されている関数は、環境変数を取り、先述した status, header, body の三つ組を返す関数で、こちらが実際のリクエストを処理する関数となります。
たとえば、GET / に対しては index() 関数が呼び出され、

def index(env):
    status = '200 OK'
    headers = [('Content-Type', 'text/plain; charset=utf-8')]
    body = 'Hello, world!'

    return status, headers, body

200 を返し、Hello, world! を出力する、という処理がなされていることが分かります。
こうして、簡単に WSGI に準拠した2 web アプリケーションを作ることができました。

実際になにか作る

これは、拙作の butimi.li のクローンを simple_server に移植したものです。おおよそ互換性があるかと思われます。
使い方は、ブログの記事を参照してください。知らない人にブチミるのはやめましょう。

app.py
import re
import sys
import urllib.parse
from wsgiref.simple_server import make_server
from wsgiref.validate import validator

def not_found(env):
    request_path = env['PATH_INFO']

    status = '404 Not Found'
    headers = [('Content-Type', 'text/plain; charset=utf-8')]
    body = 'Not Found: {}'.format(request_path)

    return status, headers, body

def bad_request(env):
    request_method = env['REQUEST_METHOD']
    request_path = env['PATH_INFO']

    status = '400 Bad Request'
    headers = [('Content-Type', 'text/plain; charset=utf-8')]
    body = 'Bad Request: {} {}'.format(request_method, request_path)

    return status, headers, body

def redirect_to_github(env):
    status = '303 See Other'
    headers = [('Content-Type', 'text/html; charset=utf-8'), ('Location', 'https://github.com/utgwkk/butimili-clone')]
    body = ''

    return status, headers, body

def build_targets(path):
    path = path.replace('/tweet/', '')
    targets = [target.replace('@', '') for target in re.split(r'[/\+\s]+', path)]
    return targets

def butimili(env):
    request_path = env['PATH_INFO']

    targets = build_targets(request_path)

    replies = '@' + ' @'.join(targets)
    text = '{} うおおおおおおあああああああああああああああああああ!!!!!!!!!!! (ブリブリブリブリュリュリュリュリュリュ!!!!!!ブツチチブブブチチチチブリリイリブブブブゥゥゥゥッッッ!!!!!!!)'.format(replies)[:140]
    butimili_url = 'https://twitter.com/intent/tweet?text={}'.format(urllib.parse.quote(text))
    status = '303 See Other'
    headers = [('Content-Type', 'text/html; charset=utf-8'), ('Location', butimili_url)]
    body = ''

    return status, headers, body

def routing(env):
    request_method = env.get('REQUEST_METHOD')
    request_path = env.get('PATH_INFO')

    allowed_request_method = {'GET'}

    router = {
        'GET': {
            '': redirect_to_github,
            'tweet': butimili,
        },
    }

    if request_method not in allowed_request_method:
        return bad_request(env)

    try:
        path_start = request_path.split('/')[1]
    except IndexError:
        path_start = ''

    return router[request_method].get(path_start, not_found)(env)

@validator
def app(env, start_response):
    status, headers, body_raw = routing(env)
    body = [bytes(line, encoding='utf-8') for line in body_raw.splitlines()]

    start_response(status, headers)
    return body

def main(argv):
    httpd = make_server('localhost', 8080, app)
    httpd.serve_forever()

    return 0

if __name__ == '__main__':
    sys.exit(main(sys.argv))

まとめ

さて、wsgiref.simple_server でシンルな web アプリケーションを作る方法について述べましたが、もしあなたが、本当に、シンプルに web アプリケーションを作りたいと思っているのであれば Flask を使うべきだと思います3
Flask は最高!!!!!
Bottle.py もかなり良いのでどんどん使いましょう。


  1. 不正な HTTP バージョンのリクエスト等に対しては、505が返されるなどします。 

  2. 実際に準拠しているかどうかは、wsgiref.validate.validator 関数で app 関数をデコレートすれば確認できます(準拠していないと AssertionError で落ちる) 

  3. 実際、先述した butimi.li クローンのコードは約 1/3 になっているし、気にすることもだいぶ減っています。 

This post is the No.8 article of Python Advent Calendar 2016