DRFの仕組みを追ってみる ①ルーティング


今回やる事

Djangoの流れはなんとなく掴めたが、よく使うDRFは具体的にどんなところを拡張しているフレームワークなのかが気になり始めたので、ひたすら実装を追いつつまとめていきたいと思います。

サーバーが立ち上がるところはDjangoと変わらないと思うので、まずはルーティングあたりから追ってみます。

環境

djangorestframework 3.12.2
python 3.7.9

DRFとは?

まず、DRF(django rest framework)とはDjangoでRESTfulなAPIを簡単に作れるようにしてくれるフレームワークです。
RESTとは、REpresentational State Transferの略で、Webサービスの設計モデルの事です。

RESTについてはこの記事がとても参考になります。
REST入門 基礎知識

記事から引用しますと、

●アドレス指定可能なURIで公開されていること
●インターフェース(HTTPメソッドの利用)の統一がされていること
●ステートレスであること
●処理結果がHTTPステータスコードで通知されること

これらに沿ったWebサービスの事をRESTfulなサービスと言うみたいです。

そして、PythonでRESTfulなAPIを作ろうと思った時、有名どころで言うと以下の様になります。

  • DRF(django rest framework)
  • Flask
  • FastAPI

この中でもDRFは、認証系の機能やページネーション、スロットリングなどの機能がデフォルトで実装されています。しかもDjangoに慣れているユーザーからしたら、学習コストも低いため総合的に見てDjangoユーザーはほぼDRF一択だと思います。

Router

DRFではRouterというクラスによってルーティングを実装します。
デフォで用意されているのは、以下の2種類のクラスです。

SimpleRouter
 名前の通りただのシンプルなルーター。
DefaultRouter
 SimpleRouterを拡張したクラスでAPIのルートのviewと、URLにformat suffix patternを追加する機能が実装されている。

これらを参考に独自のルーターも作れたりするみたいですね。
それでは、SimpleRouterを例に実装を追っていきます。

SimpleRouter

実装方法

実際の実装方法は、以下の様になります。

  1. urlpatternsにルートとなるURLとそのViewsetを記載。
  2. 1で定めたURL以下は、1で指定したViewsetのメソッドにデコレータを付ける事によってURLを指定
urls.py
from rest_framework.routers import SimpleRouter
from . import viewsets
from django.urls import path, include

router = SimpleRouter()
router.register('users', viewsets.UserViewSet)

urlpatterns = [
    path('', include(router.urls)),
]
viewsets.py
class UserViewSet(ModelViewSet):

    @action(methods=['GET'], detail=False)
    def get_deleted_users(methods=['GET'], detail=False):
        ...

これにより、以下の様に設定される。
・「localhost/users/」にアクセスした時に、HTTPメソッドによりUserViewSetの指定メソッドが呼ばれる。
・「localhost/users/get_deleted_users/」にGETメソッドでアクセスした時に、UserViewSetのget_deleted_usersメソッドが呼ばれる。

URL探索の仕組み

ここはDjangoの処理そのままなため、思い出しながらまとめてみる。

リクエスト処理用のミドルウェア類を通る

URLResolverにより、指定URLから該当Viewの取得

といった流れでResolverクラスによって、自分で指定したURLに対して、リクエストが来たURLが存在するか検索をかける。
ここではincludeでurlpatternsに含まれるようにしているので、SimpleRouterで指定したURLも検索対象になる事になる。

※ちなみに、Djangoのincludeメソッドは、この場合はタプルで、router.urls、app_name、namespaceを返してくれるみたいです。

django.urls.conf.py
def include(arg, namespace=None):
    if isinstance(arg, tuple):
        ...
    else:
        urlconf_module = arg
    ...
    return (urlconf_module, app_name, namespace)

Resolverの検索の流れ

URLResolverは、BaseHandlerの_get_responseメソッド内でインスタンス化されました。
URLResolverのresolveメソッドで、最初にリクエストのpath_infoをnewpathとargs, kwargsに分割します。
次にurlpatternsをループし、個々のpathを見ていき、分割したnewpathと照らし合わせていきます。

個々のpathとはurlpatternsに指定したこれです。

urlpatterns = [
    path('', include(router.urls)),
]

このpathの正体は、URLResolverか、URLPatternクラスになっています。

django.urls.conf.py
path = partial(_path, Pattern=RoutePattern)

def _path(route, view, kwargs=None, name=None, Pattern=None):
    if isinstance(view, (list, tuple)):
        # includeで指定した場合、こっち
        pattern = Pattern(route, is_endpoint=False)
        urlconf_module, app_name, namespace = view
        return URLResolver(
            pattern,
            urlconf_module,
            kwargs,
            app_name=app_name,
            namespace=namespace,
        )
    elif callable(view):
        pattern = Pattern(route, name=name, is_endpoint=True)
        return URLPattern(pattern, view, kwargs, name)
    else:
        raise TypeError('view must be a callable or a list/tuple in the case of include().')

今回はincludeを指定しており、includeは↑で見た通り、タプルで、router.urls、app_name、namespaceを返すので、この場合はURLResolverが返されます。

そしてこのURLResolverのresolveメソッドにより、ResolverMatchクラスが返されます。Djangoでは、ResolverMatchクラスが返される事により、該当Viewなどが返されました。

SimpleRouterの場合はこのURLResolverがどう構築されるか見てみます。

①. includeで指定した場合は、以下の様にタプルになり、

urlpatterns = [
    path('', (router.urls, app_name, namespace)),
]

②. ↑の引数を_path関数に渡します。
partialを使う事で、PatternをRoutePatternに固定し、_path関数を新たな呼び出し可能なオブジェクトとして定義する事が出来ます。

path = partial(_path, Pattern=RoutePattern)

③. 第一引数routeには指定したURLが、第二引数viewには(router.urls, app_name, namespace)が入る。

def _path(route, view, kwargs=None, name=None, Pattern=None):
    if isinstance(view, (list, tuple)):
        pattern = Pattern(route, is_endpoint=False)
        urlconf_module, app_name, namespace = view
        return URLResolver(
            pattern,
            urlconf_module,
            kwargs,
            app_name=app_name,
            namespace=namespace,
        )

④. そして最終的にこのような中身で、インスタンス化されたURLResolverが返る。

return URLResolver(
    RoutePattern,
    router.urls,
    kwargs,
    app_name=app_name,
    namespace=namespace)

↑で返されたURLResolverが、URLResolverのresolveメソッド内のループで呼ばれる事で、router.urlsの中を探索してくれます。
URLResolverのループは下記のようになっています。

django.urls.resolvers.py
class URLResolver:
    ...
    def resolve(self, path):
        path = str(path)
        tried = []
        match = self.pattern.match(path)
        if match:
            new_path, args, kwargs = match
            # 最初はurls.pyで指定したurlpatternsがループされる。
            # includeでrouterを指定した場合は、このメソッドが再度呼ばれる事でself.url_patternsはrouter.urlsとなる。
            # そしてrouter.registerで登録したものも検索対象となる。
            for pattern in self.url_patterns:
                try:
                    sub_match = pattern.resolve(new_path)
                except Resolver404 as e:
                    self._extend_tried(tried, pattern, e.args[0].get('tried'))
                else:
                    if sub_match:

最初の探索では、url_patternsは単純にsettings.pyで指定しているURL_CONFのurlpatternsがループされ、includeでRouterを含めた場合はpatternがURLResolverになるため、sub_match = URLResolver.resolve(new_path)という風になり、更にresolveメソッドが呼ばれる事になる。その時のpatternは上記で記した通り、

return URLResolver(
    RoutePattern,
    router.urls,
    kwargs,
    app_name=app_name,
    namespace=namespace)

といった感じのURLResolverとなっています。そしてこのクラスのresolveメソッドが呼ばれると、この場合のself.url_patternsはurlconf_nameとして渡したrouter.urlsが返される事になります。

django.urls.resolvers.py
class URLResolver:
    # 引数省略
    def __init__(self, patter, urlconf_name ...):
        self.pattern = pattern
        # これがrouter.urlsになる。
        self.urlconf_name = urlconf_name
        ...

    @cached_property
    def urlconf_module(self):
        if isinstance(self.urlconf_name, str):
            return import_module(self.urlconf_name)
        else:
            # router.urlsはこっち
            return self.urlconf_name

    @cached_property
    def url_patterns(self):
        # router.urlsはurlpatternsを持たないので、router.urlsがイテレータとして返される。
        patterns = getattr(self.urlconf_module, 'urlpatterns', self.urlconf_module)
        try:
            iter(patterns)
        ...
        return patterns

router.urlsの中身

DRFのSimpleRouterの実装を見てみます。
self.url_patternsがループされurlsが呼ばれると、get_urlsメソッドが呼ばれるようになっており、router.registerで登録したregistryをループし、パスとviewを詰めたリストを返しています。
つまり、このリストがURLResolverのresolveメソッドでループされて検索されるみたいですね。

rest_framework.routers.py
class BaseRouter:
    ...
    @property
    def urls(self):
        if not hasattr(self, '_urls'):
            self._urls = self.get_urls()
        return self._urls

class SimpleRouter(BaseRouter):
    def get_urls(self):
        ret = []
        for prefix, viewset, basename in self.registry:
            ...
            # routesは、HTTPメソッド毎やdetailがTrueかFalseかによってのメソッド名などを定義したリスト
            routes = self.get_routes(viewset)
            for route in routes:
                mapping = self.get_method_map(viewset, route.mapping)
                if not mapping:
                    continue
                regex = route.url.format(
                    prefix=prefix,
                    lookup=lookup,
                    trailing_slash=self.trailing_slash
                )
                if not prefix and regex[:2] == '^/':
                    regex = '^' + regex[2:]
                initkwargs = route.initkwargs.copy()
                initkwargs.update({
                    'basename': basename,
                    'detail': route.detail,
                })
                view = viewset.as_view(mapping, **initkwargs)
                name = route.name.format(basename=basename)
                ret.append(re_path(regex, view, name=name))
        # このretを検索していく。
        return ret

ここでのroutesというのは、get_routesメソッドによりSimpleRouterで指定しているroutesと、指定したviewsetなどをリストにまとめたものみたいですね。
SimpleRouterで指定しているroutesはこんな感じ。

rest_framework.routers.py
class SimpleRouter(BaseRouter):
    routes = [
        Route(
            url=r'^{prefix}{trailing_slash}$',
            mapping={
                'get': 'list',
                'post': 'create'
            },
            name='{basename}-list',
            detail=False,
            initkwargs={'suffix': 'List'}
        ),
        DynamicRoute(
            url=r'^{prefix}/{url_path}{trailing_slash}$',
            name='{basename}-{url_name}',
            detail=False,
            initkwargs={}
        ),
        Route(
            url=r'^{prefix}/{lookup}{trailing_slash}$',
            mapping={
                'get': 'retrieve',
                'put': 'update',
                'patch': 'partial_update',
                'delete': 'destroy'
            },
            name='{basename}-detail',
            detail=True,
            initkwargs={'suffix': 'Instance'}
        ),
        DynamicRoute(
            url=r'^{prefix}/{lookup}/{url_path}{trailing_slash}$',
            name='{basename}-{url_name}',
            detail=True,
            initkwargs={}
        ),
    ]
    ...

このようにHTTPのメソッド毎にメソッドの名前を指定しているみたいですね。HTTPメソッドがgetで来た場合でもdetail=Falseだとlistメソッドが呼ばれdetail=Trueだとretrieveが呼ばれるように指定されています。

つまり、router.registerで登録した分だけループされてその要素毎にHTTPメソッドに合わせたメソッド名などが指定されたオブジェクトをリストで持っていて、そのリストを検索するようになっている様子です。

そして、Djangoと同じく最終的にResolverMatchクラスが返されてViewなどの情報が返されるんですね。

まとめ

DRFがどのようにDjangoのルーティングを拡張しているのかが分かりました。
拡張しているDRFもすごいですが、Djangoがこのように拡張出来るように作られている事に感動しました。

DRFのルーティングの流れが全体的になんとなくつかめたところで、次は違うところを見てみようと思います。