Pythonで、URLルーティング機能だけがあるWSGIフレームワークを自作してみた

以前、

を自作しました。

これらの自作により、「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」の第3章以降もPythonで書く見通しが立ちました。

gihyo.jp

 
第3章以降では、URLルーティングやクッキー・セッションなどの機能が登場します。

ただ、それらの機能を

のどちらの形で実装するか悩みました。

それでも、せっかくなので、後者のWSGIフレームワークを自作してみようと考えました。

そこで今回、URLルーティング機能だけがあるWSGIフレームワークを自作してみました。

なお、学習用途で作ったため、セキュリティ面は考えていませんのであしからず。

 
目次

 

環境

  • Windows10 x64
  • Python 3.5.2 32bit

 
なお、WSGI準拠のWebサーバは自作したものを使うため、server.pyとして保存しておきます。
wsgi_webserver-sample/multi_response_wsgi_server.py at master · thinkAmi-sandbox/wsgi_webserver-sample

   

復習:単純なWSGIアプリケーション

まずは復習として、単純なWSGIアプリケーションを作ってみます。

no1_base_app.py

def application(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
return [b"Hello, world."]

 
動作確認として、

(env) >python server.py no1_base_app:application

を実行し、ブラウザでhttp://localhost:8888/へアクセスすると、ブラウザ上に 「Hello, world」 が表示されました。

URLルーティングは実装していないため、http://localhost:8888/hogeのようなURLでも、「Hello, world」が表示されます。

 

URLルーティング機能を作る(関数版)

続いて、URLルーティング機能を持ったアプリを作ります。

ただ、最初からデコレータを使ってURLルーティング機能を実装すると、自分の理解が追いつかないかもしれないと考えました。

そこで、その代わりとなる方法を調べてみたところ、以下の記事がありました。
Serving Static Files and Routing with WSGI

この記事の方法が分かりやすかったため、no2_router_separately_by_func.pyとして実装してみます。

 
まずはURLルーティングのためのタプルのリストを作ります。

routes = [
    ('/', get),
    ('/hoge', hoge),
]

その内容は

  • タプルは2つの要素を持つ
    • ひとつ目の要素は、対象のURLパス
    • ふたつ目の要素は、対象のURLパスにアクセスした時に呼ばれる関数

です。

 
続いて、WSGIサーバから呼ばれる関数を用意します。

def application(environ, start_response):
    for path, func in routes:
        if path == environ['PATH_INFO']:
            return func(environ, start_response)

    return not_found(environ, start_response)

タプルを順次読み込み、リクエストのパス(environ['PATH_INFO'])と一致した場合に、タプルで指定した関数を呼び出しています。

 
あとは、application関数から呼び出される関数を

def get(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, world."]

def hoge(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"hoge"]

def not_found(environ, start_response):
    start_response('404 Not Found', [('Content-Type', 'text/plain')])
    return [b"Not found."]

と用意して完成です。

 
動作確認として、

(env) >python server.py no2_router_separately_by_func:application 

を実行し、ブラウザでアクセスすると以下のようになります。

アクセス先のURL 表示内容
http://localhost:8888/ Hello, world
http://localhost:8888/hoge hoge
http://localhost:8888/fuga Not found.

 

URLルーティング機能を作る(クラス版)

URLルーティング機能を、関数版からクラス版へと変更してみます。

no3_router_separately_by_class.py

class MyWSGIApplication(object):
    def __init__(self):
        self.routes = [
            ('/', self.get),
            ('/hoge', self.hoge),
        ]

    def get(self, environ, start_response):
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"Hello, world by class."]

    def hoge(self, environ, start_response):
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"hoge by class"]

    def not_found(self, environ, start_response):
        start_response('404 Not Found', [('Content-Type', 'text/plain')])
        return [b"Not found by class."]


    def __call__(self, environ, start_response):
        for path, method in self.routes:
            if path == environ['PATH_INFO']:
                return method(environ, start_response)

        return self.not_found(environ, start_response)

app = MyWSGIApplication()

 
動作確認として、

(env) >python server.py no3_router_separately_by_class:app

として、ブラウザでアクセスしても、末尾に「 by class.」が付くだけで、それ以外の動作は変わっていません。

 

フレームワーク

次は

の2つに機能を分割して、それぞれを実装します。

 
WSGIフレームワークno3_router_separately_by_class.pyからURLルーティング機能を移植します。

no4_1_framework.py

class MyWSGIFramework(object):
    def __init__(self, routes):
        self.routes = routes

    def not_found(self, environ, start_response):
        start_response('404 Not Found', [('Content-Type', 'text/plain')])
        return [b"Not found by framework."]


    def __call__(self, environ, start_response):
        for path, method in self.routes:
            if environ['PATH_INFO'] == path:
                return method(environ, start_response)
        return self.not_found(environ, start_response)

 
WSGIアプリはURLルーティング用のタプルのリストと、URLルーティングされた時に呼ばれる関数だけになります。

no4_2_app.py

from no4_1_framework import MyWSGIFramework

def get(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, world by framework."]

def hoge(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"hoge by framework"]


app = MyWSGIFramework([
    ('/', get),
    ('/hoge', hoge),
])

 
動作確認として、

(env) >python server.py no4_2_app:app

として、ブラウザでアクセスしても、末尾に「by framework」が付くだけで、それ以外の動作は変わっていません。

 

HTMLテンプレートやCSS、画像の配信

最後に、Jinja2を使ってHTMLテンプレートやCSS・画像を表示できるようにします。

CSSや画像の出力については定型的な処理のため、今回はフレームワーク側で処理します。

画像ファイルについては、自作WSGIサーバではsocket.sendfile()を使って送信しています。そのため、画像ファイルをファイルライクオブジェクトの形にして自作WSGIサーバに渡す必要があります。

今回は、バイナリモードでファイルを読み込み、io.BytesIOにてバイナリストリームとすることで、ファイルライクオブジェクトの形にしています*1
16.2. io — ストリームを扱うコアツール — Python 3.5.1 ドキュメント

fullpath = './{}'.format(environ['PATH_INFO'])
with open(fullpath, "rb") as f:
    r = f.read()

binary_stream = io.BytesIO(r)

# 変数content_typeには、ファイルの種類により'text/css'か'image/png'が設定済
start_response('200 OK', content_type)

return binary_stream

 
それを受けて、自作WSGIサーバ(server.py)では、

if 'image' in str_response:
    # 画像データの場合
    self.connection.sendall(str_response.encode('utf-8'))
    self.connection.sendfile(byte_response_body)

else:
    # 画像データ以外の場合
    for byte_body in byte_response_body:
        str_response += byte_body.decode('utf-8')
    self.connection.sendall(str_response.encode('utf-8'))

のような形で、クライアントへデータを送信しています。

なお、PEP3333にはファイルの扱いに関しての記述もありましたが、今回は置いておきます。
PEP 3333: Python Web Server Gateway Interface v1.0.1 — knzm.readthedocs.org 2012-12-31 documentation

 
ソースコード全体ですが、WSGIフレームワーク

no5_1_framework.py

import re
import io

class MyWSGIFramework(object):
    def __init__(self, routes, css_dir=None, img_dir=None):
        self.routes = routes

        # 静的ファイル向けのディレクトリ設定
        self.static_dir = '/static'
        css_dir = css_dir if css_dir else "/css/"
        self.css_dir = '{static}{css}'.format(static=self.static_dir, css=css_dir)
        img_dir = img_dir if img_dir else "/images/"
        self.img_dir = '{static}{img}'.format(static=self.static_dir, img=img_dir)

    def static(self, environ, start_response):
        content_type = []
        if re.match(self.css_dir, environ['PATH_INFO']):
            content_type.append(('Content-Type', 'text/css'))
        elif re.match(self.img_dir, environ['PATH_INFO']):
            content_type.append(('Content-Type', 'image/png'))
        else:
            return self.not_found(environ, start_response)

        try:
            fullpath = './{}'.format(environ['PATH_INFO'])
            with open(fullpath, "rb") as f:
                r = f.read()
            binary_stream = io.BytesIO(r)
        except:
            start_response('500 Internal Server Error', [('Content-Type', 'text/plain')])
            return [b"Internal server error"]

        start_response('200 OK', content_type)
        return binary_stream

    def not_found(self, environ, start_response):
        start_response('404 Not Found', [('Content-Type', 'text/plain')])
        return [b"Not found."]


    def __call__(self, environ, start_response):
        if re.match(self.static_dir, environ['PATH_INFO']):
            return self.static(environ, start_response) 

        for path, method in self.routes:
            if environ['PATH_INFO'] == path:
                return method(environ, start_response)
        return self.not_found(environ, start_response)

となりました。

また、WSGIアプリは、

no5_2_app.py

# python server.py no5_2_app:app
from no5_1_framework import MyWSGIFramework
from jinja2 import Environment, FileSystemLoader

def get(environ, start_response):
# (省略)

def hoge(environ, start_response):
# (省略)

def index(environ, start_response):
    jinja2_env = Environment(loader=FileSystemLoader('./templates', encoding='utf8'))
    template = jinja2_env.get_template('index.html')
    html = template.render({'messages': ['hoge', 'fuga', 'piyo']})

    start_response('200 OK', [('Content-Type', 'text/html')])
    return [html.encode('utf-8')]


app = MyWSGIFramework([
    ('/', get),
    ('/hoge', hoge),
    ('/index', index),
])

となりました。

 
動作確認として、

(env) >python server.py no5_2_app:app

として、ブラウザでhttp://localhost:8888/indexへとアクセスすると、HTMLテンプレートやCSS、画像が表示されました。

 

ソースコード

GitHubに上げました。routing_onlyディレクトリの下が、今回のサンプルです。
wsgi_framework-sample/routing_only at master · thinkAmi-sandbox/wsgi_framework-sample

 

参考

 
もし、FlaskやBottleのようにデコレータを使ったURLルーティング機能を実装する場合には、以下のBottleのソースコード解説も参考になるかもしれません。
Python Bottleのソースを読む ルータ編 - TAISA BLOG

 
2016/9/28 追記 ここから

PyConJP2016にてWSGIフレームワークの作り方に関する発表がありました。
#pyconjp でWeb(WSGI)フレームワークの作り方について話してきました - c-bata web

以前kobinのソースコードを読んだとき、ルーティング・リクエスト・レスポンスについて「なぜこのような形で実装したのか」をつみきれないところがありました。

上記の資料ではそれに対する回答が記載されていたため、スッキリするとともにとても参考になりました。ありがとうございました。

2016/9/28 追記 ここまで

*1:この辺りの説明が正しいかどうか分からないため、誤りがあればご指摘いただけるとありがたいです