ルーティング

シンプルなルーティング

前章では、WSGIの仕様について確認し、それを満たす小さなアプリケーションをつくりました。 ここからは実際にWebフレームワークを作っていきましょう。

先程のアプリケーションでは、URLのパス情報によらず全て「Hello World」と返しています。 実際のアプリケーションでは、沢山のページが存在するためパス情報に応じてそれぞれ違ったレスポンスを返す必要があります。 最も簡単なルーティング方法を次に示します。

def application(env, start_response):
    path = env['PATH_INFO']
    if path == '/':
        start_response('200 OK', [('Content-type', 'text/plain')])
        return [b'Hello World']
    elif path == '/foo':
        start_response('200 OK', [('Content-type', 'text/plain')])
        return [b'foo']
    else:
        start_response('404 Not Found', [('Content-type', 'text/plain')])
        return [b'404 Not Found']

WSGIのアプリケーションの第一引数には、辞書型オブジェクトが渡されています。 これは WSGI Environment と呼ばれ、クライアントから受け取ったリクエストの情報が格納されています。 リクエストのパス情報もその一つで、 PATH_INFO により取り出すことができます。 またHTTPのメソッドも同様に REQUEST_METHOD により取り出すことができます。

URL変数と正規表現によるルーティング

URL変数

もう少し複雑なケースを考えてみましょう。 こちらのBottleのアプリケーションの一部を見てください。

@route('/hello/<name>')
def greet(name='Stranger'):
    return template('Hello {{name}}, how are you?', name=name)

@route('/users/<user_id:int>')
def user_detail(user_id):
    users = ['user{id}'.format(id=i) for i in range(10)]
    return template('Hello {{user}}', user=users[user_id])

/hello/foo/hello/bar はそれぞれ別のエンドポイントですが、上のコードではどちらも greet 関数が呼ばれます。 またURLのパス情報から foobar などの変数(以下、URL変数)を取り出しています。

先程のようにif文で分岐させていくのは大変なので、別の方法を考えてみましょう。 解決策の一つとして正規表現の利用があります。

正規表現モジュール

正規表現 は普段使わない方も多いかと思います。 ここで簡単におさらいしましょう。

>>> import re
>>> url_scheme = '/users/(?P<user_id>\d+)/'
>>> re.match('/users/(?P<user_id>\d+)/', '/users/1/').groupdict()
{'user_id': '1'}

>>> pattern = re.compile(url_scheme)
>>> pattern.match('/users/1/').groupdict()
{'user_id': '1'}

このように名前付きグループでパターンを定義し、マッチするか確認してからgroupdictを呼ぶことでuser_idの部分の数字が文字列で取得出来ます。

構成図

それでは、ルーティング機能を提供するため、フレームワークの実装を始めましょう。 ここで提供するルーティングは次のようなイメージです。

ルーティングのイメージ

ルーティングのイメージ。 パス情報にあわせて別のアプリケーションを呼び出す。

それでは図のRouterを実装していきましょう。

Routerクラス

それではルーティングの機能を提供する Router クラスを用意していきます。 このクラスには2つのメソッドを用意します。

  1. add(): 各コールバック関数(WSGIアプリケーションオブジェクト)が、どのURLパス・HTTPメソッドに紐づくかを登録する。
  2. match(): 実際に受け取ったリクエストのパスとHTTPメソッドの情報を元に登録していたコールバック関数とURL変数を返す。

まずは add() メソッドから定義してみましょう。

class Router:
    def __init__(self):
        self.routes = []

    def add(self, method, path, callback):
        self.routes.append({
            'method': method,
            'path': path,
            'callback': callback
        })

HTTPメソッドとパス、それに紐づくコールバック関数を辞書型のオブジェクトにつめて、リストに追加しました。 非常に簡単です。呼び出し側はどのようになるでしょうか? match() メソッドを定義します。

import re


def http404(env, start_response):
    start_response('404 Not Found', [('Content-type', 'text/plain; charset=utf-8')])
    return [b'404 Not Found']


class Router:
    ...

    def match(self, method, path):
        for r in self.routes:
            matched = re.compile(r['path']).match(path)
            if matched and r['method'] == method:
                url_vars = matched.groupdict()
                return r['callback'], url_vars
        return http404, {}

このメソッドでは、 add() メソッドで登録したエンドポイントのパス情報とメソッド情報が一致するかをチェックします。 もし一致した場合には、URL変数を取り出し、ひも付けられていたコールバック関数を返します。 一致しない場合には、 404 Not Found を返すようになりました。 こちらも非常にシンプルなコードですが、基本的にルーティングに必要な機能を備えています。 念の為動作も確認しましょう。

>>> from app import Router
>>> router = Router()

# コールバック関数の定義
>>> def create_user():
...     return 'user is created'
...
>>> def user_detail(id):
...     return 'user{id} detail'.format(id)
...

# エンドポイントの登録
>>> router.add('POST', '^/users/$', create_user)
>>> router.add('GET', '^/users/(?P<user_id>\d+)/$', user_detail)

# 呼び出し確認
>>> callback, kwargs = router.match('POST', '/users/')
>>> callback(**kwargs)
'user is created'
>>> callback, kwargs = router.match('GET', '/users/1/')
>>> callback(**kwargs)
'user1 detail'

# 定義されていないエンドポイントでは次のように http404 関数が callback として返ってきます。
>>> callback, kwargs = r.match('POST', '/foobar')
>>> callback
<function http404 at 0x103696840>
>>> dummy_start_response = lambda x, y: print(x, y)
>>> callback({}, dummy_start_response, **kwargs)
404 Not Found [('Content-type', 'text/plain; charset=utf-8')]
[b'404 Not Found']

うまく動作していることが確認できます。 ここではついでに2つ改善を加えておきましょう。

  1. パスは定義されているもののメソッドだけが一致しない場合には 405 Method Not Allowed を返す。
  2. re.compile()Router.add() メソッドの呼び出し時に行っておく。

修正を加えると全体像は次のようになります。

import re


def http404(env, start_response):
    start_response('404 Not Found', [('Content-type', 'text/plain; charset=utf-8')])
    return [b'404 Not Found']


def http405(env, start_response):
    start_response('405 Method Not Allowed', [('Content-type', 'text/plain; charset=utf-8')])
    return [b'405 Method Not Allowed']


class Router:
    def __init__(self):
        self.routes = []

    def add(self, method, path, callback):
        self.routes.append({
            'method': method,
            'path': path,
            'path_compiled': re.compile(path),
            'callback': callback
        })

    def match(self, method, path):
        error_callback = http404
        for r in self.routes:
            matched = r['path_compiled'].match(path)
            if not matched:
                continue

            error_callback = http405
            url_vars = matched.groupdict()
            if method == r['method']:
                return r['callback'], url_vars
        return error_callback, {}

他にもいろいろと考えることはありますが、今はこれくらいにしておきます。 それでは Router クラスを実際にアプリケーションに組み込んでいきます。

Routerを組み込んだWSGIのアプリケーション用クラスを作る

Router クラスを組み込んで行きたいのですが、関数のままだと少し扱いづらいです。 ここではWSGIアプリケーションをクラスで定義してみましょう。

class App:
    def __call__(self, env, start_response):
        start_response('200 OK', [('Content-type', 'text/plain')])
        return [b'Hello World']

ここで注目してほしいのは __call__() メソッドが定義されている点です。 __call__ メソッドを定義すると、このクラスのオブジェクト自身が呼び出し可能(callable)になります。 つまり app = App() は、関数 def app(env, start_response): pass 同じように振る舞ってくれます。 これでだいぶ拡張がしやすくなりました。 Router クラスを組み込んでみましょう。

class App:
    def __init__(self):
        self.router = Router()

    def route(self, path=None, method='GET', callback=None):
        def decorator(callback_func):
            self.router.add(method, path, callback_func)
            return callback_func
        return decorator(callback) if callback else decorator

    def __call__(self, env, start_response):
        method = env['REQUEST_METHOD'].upper()
        path = env['PATH_INFO'] or '/'
        callback, kwargs = self.router.match(method, path)
        return callback(env, start_response, **kwargs)

route デコレーターを使ってコールバック関数にHTTPメソッドと正規表現で書かれたパスを割り当てできるようにしました。 このフレームワークの利用者からは、次のような形で利用出来ます。

from app import App
from wsgiref.simple_server import make_server


app = App()


@app.route('^/$', 'GET')
def hello(request, start_response):
    start_response('200 OK', [('Content-type', 'text/plain; charset=utf-8')])
    return [b'Hello World']


@app.route('^/user/$', 'POST')
def create_user(request, start_response):
    start_response('201 Created', [('Content-type', 'text/plain; charset=utf-8')])
    return [b'User Created']


@app.route('^/user/(?P<name>\w+)/$', 'GET')
def user_detail(request, start_response, name):
    start_response('200 OK', [('Content-type', 'text/plain; charset=utf-8')])
    body = 'Hello {name}'.format(name=name)
    return [body.encode('utf-8')]


@app.route('^/user/(?P<name>\w+)/follow/$', 'POST')
def create_user(request, start_response, name):
    start_response('201 Created', [('Content-type', 'text/plain; charset=utf-8')])
    body = 'Followed {name}'.format(name=name)
    return [body.encode('utf-8')]


if __name__ == '__main__':
    httpd = make_server('', 8000, app)
    httpd.serve_forever()

実際にアプリケーションを実行して動作確認してみましょう。 定義されたエンドポイントにアクセスすると、次のように正しくレスポンスを受け取ることができます。

$ curl http://127.0.0.1:8000/
Hello World
$ curl -X POST http://127.0.0.1:8000/user/
User Created
$ curl http://127.0.0.1:8000/user/shibata/
Hello shibata
$ curl -X POST http://127.0.0.1:8000/user/shibata/follow/
Followed shibata

一方で定義されていないパスや、メソッドが定義されていないエンドポイントでは、404 や 405が返ってきます。

$ curl http://127.0.0.1:8000/foobar
404 Not Found
$ curl http://127.0.0.1:8000/user/shibata/follow/
405 Method Not Allowed

まとめ

ここまでルーティングの役割について考え、実装しました。 用意した Router クラスは、正規表現で記述されたパスにマッチするかどうかを判定し、 マッチした場合には登録しておいたコールバック関数とURL変数を返してくれます。 デコレーターでルーティングの情報を指定することで、ユーザーは複数のエンドポイントを見通しよく定義できるようになりました。