🏗️

Flask/FastAPIライクなルーティングとパスパラメータの仕組みを、単純な実装から理解する

2022/02/21に公開

ウェブフレームワークのシンプルな実装の背景

FlaskFastAPIといったウェブフレームワークでは、以下のようにアクセスするエンドポイントのパスとその処理内容を一つの関数で実現したり、パスをパラメータ化して関数内部で利用することができます。

# FastAPIの事例 (公式ドキュメントより)
@app.get("/")
async def root():
    return {"message": "Hello World"}
    
@app.get("/items/{item_id}")
async def read_item(item_id):
    return {"item_id": item_id}    

これまでは何気なく便利だなと思いながら利用していたのですが、内部ロジックやその実装背景をきちんと理解していませんでした。よくよく考えると、どういう方法でパスパラメータはデコレーター引数の文字列の一部を関数の引数に取って内部で利用可能にしているんでしょうか。便利なものほどそれがなぜそうなっているのかを考える機会が無いというのは、現実世界でもよくあると思います。

今回Flask/FastAPIライクなウェブフレームワークを作成する以下のブログ記事を読んで、これで初めてルーティングやパスパラメータの理念を理解できました。

How to write a Python web framework. Part I.

今回はこうした実装の背後にある考え方を、私なりの言葉で説明してみたいと思います。私個人としてはステップ・バイ・ステップに一つずつ実装して複雑化/抽象化していく理解の流れが好きなので、そのような形で書いてみます。

要件:何を作るか

今回は具体事例として、下記のウェブサービスを作りたいとしましょう。

  • URLが/homeにアクセスすると、Hello from the HOME pageと表示される
  • URLが/hello/{name}にアクセスすると、Hello, {name}と表示される (nameは任意の文字列)
  • URLが上記以外の場合は、Not Foundと表示される

何も中身がないウェブサービスですが、よくある構成ですね。ユーザが特定のURLにアクセスしたときにリクエストがウェブサーバー/ウェブアプリケーションに送られて、そこで内部的な処理をし、結果を返すという部分の実装です。主目的はウェブサービスの実装ですが、話の流れ的に最終的にはFlask/FastAPIのようなウェブフレームワークとして機能するような形を目指します。

なお、今回はWSGI準拠でウェブサーバーにはgunicornを利用します。今回実装するすべてのコードは以下のレポジトリにまとめてあり、実際に動かすことが出来ます。

https://github.com/yagays/understanding_handler_webapi

ステップ1:URLごとに条件分岐

要件を元にナイーブに考えると、URLごとにif文で条件分岐させて処理結果を返せば良さそうです。愚直に実装すると以下のようになります。

class API:
    def __call__(self, environ, start_response):
        request = Request(environ)
        response = Response()

        # Routing
	# パスが/homeの場合
        if request.path == "/home":
            response.text = "Hello from the HOME page"
	# パスが/hello/から始まる場合
        elif request.path.startswith("/hello"):
            parse_result = parse("/hello/{name}", request.path)
            name = ""
            if parse_result:
                name = parse_result.named["name"]
            response.text = f"Hello, {name}"
	# パスがそれ以外の場合
        else:
            response.status_code = 404
            response.text = "Not found."

        return response(environ, start_response)

app = API()

RequestResponseWebObというWSGI準拠のHTTPオブジェクトを扱うパッケージを利用しています。そういった細かな実装の部分の説明は割愛するとして、# Routingのコメント以下からのコードで、要件に合うように条件分岐で出し分けていることがわかると思います。

さて、今回の要件の場合ではこれで良さそうですが、より多くのぺージが存在したり複雑な形でURLのパスを指定しなければいけない場合などは、この条件分岐が膨れ上がることは容易に想像できます。もう少しシンプルな形でURLのパスと処理内容を記述できると、より直感的で安全なコードが書けるでしょう。また、特定ページの実装が別のページの処理に影響を与えうる実装となっており、意図しない処理となる可能性もあります。なるべくページごとの処理は独立した形で実行できると望ましいでしょう。イメージとしては、以下のような書き方ができると良さそうです。

  • home/にアクセス → 関数home()を実行
  • hello/{name}にアクセス → 関数hello(name)を実行

では、こうした形で実装してみましょう。

ステップ2: パスと関数を対応付ける辞書を持つ

パスと処理を対応付ける辞書としてのself.routesを導入し、add_route()メソッドで登録できるようにしました。各パスの処理はhome_handler()hello_handler()という名前の関数を実装しています。

class API:
    def __init__(self) -> None:
        self.routes = {}

    def add_route(self, path, handler):
        self.routes[path] = handler

    def __call__(self, environ, start_response):
        request = Request(environ)
        response = Response()

        # find handler
        handler = None
        for path, path_handler in self.routes.items():
            parse_result = parse(path, request.path)
            if parse_result:
                handler = path_handler
                kwargs = parse_result.named
        if handler:
            response.text = handler(**kwargs)
        else:
            response.status_code = 404
            response.text = "Not found."

        return response(environ, start_response)

def home_handler():
    return "Hello from the HOME page"

def hello_handler(name):
    return f"Hello, {name}"

app = API()

app.add_route("/home", home_handler)
app.add_route("/hello/{name}", hello_handler)

URLのパスの処理を記述する部分がadd_route()メソッドを使って一箇所にまとめられて、一つ一つ登録していく形でシンプルに実装できるようになりました。if文が無くなったので可読性も上がったと思います(優先順位の話が出てきますが、今回は脇に置いておきます)。また、APIクラスの内部で今回のウェブサービスに関するロジックを実装する必要がなくなり、ウェブアプリケーションのフレームワークとして最低限利用できるようになりました。ちなみに、実際にFastAPIのAPIRouterクラスのadd_api_route()メソッドがこんなイメージです。

今の状態でも十分にウェブフレームワークとして最低限成り立つ形になりましたが、強いて課題を上げるとするならば、これでは関数の実装とそれを登録する部分が離れてしまっています。例えば以下のように、いろんなパスとハンドラーを登録していく際に、*_handler関数の名前を書き間違えてしまうかもしれません。

app.add_route("/home", home_handler)
app.add_route("/hello/{name}", home_handler) # hello_handlerの間違い

もう少しパスとハンドラーの設定を実装する部分を近づけて対応付けを間違えないような実装方法だと、より良くなりそうです。それでは最後にデコレーターを導入して、よりFlask/FastAPIっぽい形にしてみましょう。

ステップ3: デコレーターを利用する

self.routesなどの実装はそのままに、デコレーターとして利用するroute()メソッドの内部処理でパスとハンドラーを対応付けています。そして、先ほど作成した各handler関数の定義部分に、@routeデコレーターを付与しました。

class API:
    def __init__(self) -> None:
        self.routes = {}

    def __call__(self, environ, start_response):
        request = Request(environ)
        response = Response()

        # find handler
        handler = None
        for path, path_handler in self.routes.items():
            parse_result = parse(path, request.path)
            if parse_result:
                handler = path_handler
                kwargs = parse_result.named
        if handler:
            response.text = handler(**kwargs)
        else:
            response.status_code = 404
            response.text = "Not found."

        return response(environ, start_response)

    def route(self, path):
        def wrapper(handler):
            self.routes[path] = handler
            return handler

        return wrapper

app = API()

@app.route("/home")
def home_handler():
    return "Hello from the HOME page"

@app.route("/hello/{name}")
def hello_handler(name):
    return f"Hello, {name}"

これでパスとハンドラーを登録する部分を一つに出来ました。こうすれば、home_handler/homeのパスの際に実行されるということがひと目でわかります。

こうしたFlaskやFastAPIのような実装は、これまでのステップで見てきた課題を解決した形であると言えます。

  • URLのルーティングを条件分岐で実装しなくて良い
    • ルーティング処理はウェブフレームワーク側で行う
    • ユーザはパスとハンドラーを設定していくだけで良い
  • パスとその内部処理を一箇所にまとめて実装できる
    • 関心の分離

というメリットがあることがわかります。

まとめ

今回は、Flask/FastAPIライクなウェブフレームワークをステップバイステップで実装していく中で、どうしてデコレーターを用いたシンプルな実装方法に至ったかを考えてみました。いきなり正解を出されるとこういうものかと思ってしまいがちですが、こうしてナイーブな実装からの経過を見ていくと、より背景が理解できたと思います。こうしたエレガントな実装を自分も自然に出来るようになりたいところです。

Discussion