Falconソースコードリーディング -その3-

Posted by rhoboro on 2016-12-25

前々回に続き、 FalconというミニマルなWebフレームワークのソースコードを読んだ内容をまとめています。

今回はRouting周り(デフォルトルーターのroutingパッケージ)のコードをメインにまとめます。 ちなみに、FalconのデフォルトルーターはCompiledRouterというクラス名になっております。 なぜCompiledなのか、という点もコードを読めばわかるはず。。。

Falconのルーティング

Falconでルーティングを追加する際のコードは、下記のようにAPIクラスのadd_routeメソッドを呼び出します。

class Resource(object):
    def on_get(self, req, resp, name):
        resp.body = "Hello {0}!".format(name)
        resp.status = falcon.HTTP_200

app = Falcon.API()
res = Resource()
# nameは変数として渡される
app.add_route("/res/{name}", res)

APIクラスのadd_routeはこちら。

def add_route(self, uri_template, resource, *args, **kwargs):

    ~ 省略(ちょっとしたバリデーション) ~

    method_map = routing.create_http_method_map(resource)
    self._router.add_route(uri_template, method_map, resource, *args, **kwargs)

各HTTPメソッドと対応するResponder(on_getなどのメソッド)の辞書を作成し、最後の行でURIのパスと共にCompiledRouterクラスのadd_routeに渡しています。
ということで、次はCompiledRouterクラスのadd_routeを見ていきます。

CompiledRouterクラス

CompiledRouter.add_route

add_routeメソッドの内容はこちら。 最後の行でクラス名にもなっている_compile()というメソッドを読んでいますね。

 def add_route(self, uri_template, method_map, resource):
     if re.search('\s', uri_template):
         raise ValueError('URI templates may not include whitespace.')

     fields = re.findall('{([^}]*)}', uri_template)
     for field in fields:
         is_identifier = re.match('[A-Za-z_][A-Za-z0-9_]+$', field)
         if not is_identifier or field in keyword.kwlist:
             raise ValueError('Field names must be valid identifiers.')

     path = uri_template.strip('/').split('/')

     def insert(nodes, path_index=0):

       ~ 省略 ~

     insert(self._roots)
     self._find = self._compile()

やっていることはこんな感じ。

  1. スペースが含まれていないか、重複している要素(/{name}/{name}など)がないか等のチェック
  2. insert関数を使って、self._rootsにパスを追加
  3. 最後に_compileメソッドを呼び出す

ちなみにここのself._rootsは、下記のようなlistにCompiledRouterNodeを格納したツリー構造となっています。

root

CompiledRouter._compile

さて、ここから_compileメソッドを見ていきましょう。
下記がコメント以外は省略していない_compileメソッドの全コードです。

def _compile(self):
    self._return_values = []
    self._expressions = []
    self._code_lines = [
        'def find(path, return_values, expressions, params):',
        TAB_STR + 'path_len = len(path)',
    ]

    self._compile_tree(self._roots)

    self._code_lines.append(
        TAB_STR + 'return None'
    )

    self._src = '\n'.join(self._code_lines)

    scope = {}
    exec(compile(self._src, '<string>', 'exec'), scope)

    return scope['find']

組み込み関数のcompileを読んでいる箇所がありました。
これがCompiledRouterという名前の所以です。

組み込み関数のcompile, exec

_compile()メソッドは、exec(compile(self._src, '<string>', 'exec'), scope)という1行にすべてが詰まっています。 が、私もあまりexeccompileは使ったことがないので、調べました。(ドキュメント => exec, compile) とてもざっくりな理解ですが、下記のような感じでしょうか。

  • compile
    • ファイルや文字列からコードオブジェクトを作成する
  • exec
    • 文字列かコードオブジェクトを与えられたスコープ内で実行する

ちょっと触って挙動を確認して見ました。

# 文字列でfindを定義してcompileするとcode objectになる
>>> src = 'def find():\n    print("called")'
>>> compiled_src = compile(src, '<string>', 'exec')
>>> compiled_src
<code object <module> at 0x106488810, file "<string>", line 1>
# execでコードオブジェクトをscope内で実行するとそのスコープでfindが定義される
>>> scope = {}
>>> exec(compiled_src, globals(), scope)
>>> scope
{'find': <function find at 0x106494510>}
# もちろんfind関数の呼び出し可能
>>> scope['find']()
called

CompiledRouter._compile に戻る

さて、compileとexecの動きがだいたい理解できたところで
exec(compile(self._src, '<string>', 'exec'), scope)
に戻ります。

execが終わったところにブレークポントを追加して、self._srcの中身を確認。

src

さすがに画像だけだと見づらいので_srcに格納されている文字列を整形(笑)

def find(path, return_values, expressions, params):
    path_len = len(path)
    if path_len > 0:
        if path[0] == "res":
            if path_len > 1:
                params["name"] = path[1]
                if path_len == 2:
                    return return_values[0]
                return None
            return None
        return None
    return None

app.add_route("/res/{name}", res)で渡したres, nameがあることがわかります。 ちなみに、この_srcに格納された文字列は少し上のself._compile_tree(self._roots)で作られています。

これをexecで実行することで、find関数がscopeという名前空間に定義されます。
execの第2引数scopeが__builtins__を含んでいなかったので、組み込みモジュールbuiltinsへの参照も追加されています。 これはドキュメントに記載されている通りですね。

scope

いろいろ処理しているように見えますが、まとめるとadd_routerはfind関数を定義して、Falcon.APIのrouter._findとして保持しているだけです。

リクエストがきたら

さてさて、add_routerの動きは理解できました。 ということで、次は先ほど定義したfindが実際にどう使われるかを見ていきます。

前回紹介しましたが、FalconアプリケーションではリクエストがくるとFalcon.API.__call__が呼ばれ、その中で_get_responderが呼ばれます。 その_get_responderのコードがこちら。

def _get_responder(self, req):
    path = req.path
    method = req.method
    uri_template = None

    route = self._router.find(path)

    if route is not None:
        try:
            resource, method_map, params, uri_template = route
        except ValueError:
            resource, method_map, params = route
    else:
        resource = None

    if resource is not None:
        try:
            responder = method_map[method]
        except KeyError:
            responder = falcon.responders.bad_request
    else:

        ~ 省略 ~

    return (responder, params, resource, uri_template)

このなかで、本質的に大事なのは下記の3行。

  • route = self._router.find(path)
  • resource, method_map, params, uri_template = route
  • responder = method_map[method]

まず、最初のfindメソッドのコードはこちらです。pathをリストに変換し、先ほどの_find関数を呼び出しています。

def find(self, uri):

    path = uri.lstrip('/').split('/')
    params = {}
    node = self._find(path, self._return_values, self._expressions, params)

    if node is not None:
        return node.resource, node.method_map, params, node.uri_template
    else:
        return None

_findとして格納されたfind関数も再掲。

def find(path, return_values, expressions, params):
    path_len = len(path)
    if path_len > 0:
        if path[0] == "res":
            if path_len > 1:
                params["name"] = path[1]
                if path_len == 2:
                    return return_values[0]
                return None
            return None
        return None
    return None

node変数に格納される値は、_srcのコード文字列を動的に作成した際に、ひっそりと作られていたCompiledRouterNodeです。

return_value

このnodeから、node.method_mapを含むタプルがrouteに返されます。 そして、routeからこの値を取りdし、HTTPメソッドと合わせることでresponderを特定しています。

つまり、リクエストがきた際の挙動をまとめると下記のようになっています。

  1. WSGIサーバーからfalcon.API.__call__が呼び出される。
  2. falcon.API.__call__が_get_responderを呼び出す。
  3. _get_responder内でfind関数によりリクエストのパスに対応するCompiledRouterNodeが特定。
  4. _get_responderには、CompiledRouterNodeからHTTPメソッドに対応するresponderを格納したmethod_map(を含むタプル)が返される。
  5. _get_responderのresponder = method_map[method]というコードでresponderを特定、返却する。
  6. このresponderがfalcon.API.__call__でresponder(req, resp, **params)という形で実行される。

まとめ

動的にソースコードを生成しているところが面白いですね。 add_routeが呼ばれるたびにコードを生成、実行しているのでアプリケーションが大きくなればなるほど起動にかかる時間が増えそうな気がします。 (コードの生成箇所は再帰な処理もしていますし)

長くて見づらくなってしまいました(笑) まぁ、個人用のメモなので多めに見ていただければと思います。

ちなみにexecは文字列かコードオブジェクトを第1引数にとります。 したがって、

exec(compile(self._src, '<string>', 'exec'), scope)

はcompileしなくても動くはずです。 わざわざcompileしている理由は下記あたりでしょうか。

  • syntax errorの検出し、不完全なコードの実行を制御
    • セキュリティが少し向上する?

パフォーマンスの向上もあるのかなとも考えましたが、インタプリタ上で試したところcompileの有無ではパフォーマンスに差異は見つからず。 Pythonistaな方々、本当の理由を教えてくださいm(_ _)m

tags: Github, Falcon, Python