【DRF】Django REST Frameworkでランダムなページネーションを実装する方法


今回のテーマは、DRFで全件取得をする時、ページネーションをしつつもランダムなリストを取得することです。もちろん、違うページごとには要素は一個も被らないようにします。

また、今回はViewsetではなく、Generic Viewを使います。

デフォルトのページネーションをsettings.pyに設定してある前提で進みます。

どうやって実装するか

今回はいきなり方法を説明するのではなく、色々な方法を議論しながら進めていくスタイルでいこうと思います。

querysetにorder_by('?')を使う

真っ先に思いつくのはこの方法かもしれません。例えば、

views.py
class ListView(generics.ListAPIView):
    queryset = Post.objects.order_by('?')
    serializer = PostSerializer
    filter_backends = [...]
    ...

という感じです。

これを試した方はわかると思いますが、これではページをリロードする、または次のページに行くたびに全体のオブジェクトの順番が入れ替わるため、1ページ目にあったオブジェクトが次のページにも出現してしまうということが起こります。

これは、DjangoのQueryset、DRFのlistメソッドの仕組みからわかります。

そもそも、Querysetというのはオブジェクトの配列ではなく、SQL文の集まりです。そのため、任意のページにアクセスするたびにQuerysetが評価され、新しいランダムなオブジェクトの配列が生成されてしまうのです。

ListAPIViewのlistメソッドとqueryset

次に、listメソッドの中身を見てみましょう。

listメソッドは、ListAPIViewに対してGETした時に呼ばれるメソッドです。

mixins.py
class ListModelMixin:
    """
    List a queryset.
    """
    def list(self, request, *args, **kwargs):
        queryset = self.filter_queryset(self.get_queryset())

        page = self.paginate_queryset(queryset)
        if page is not None:
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)

        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)

github django-rest-frameworkより引用

ここで注意したいのが、このlistメソッド内で、querysetという変数の型はなんなのかということです。

querysetって名前なんだからQueryset型だろ!って思いませんか?僕は思いました。

しかし、実はこのqueryset、オブジェクトのリストなんです。

page = self.paginate_queryset(queryset)

の部分に注目してみましょう。この文を呼ぶことで、最終的にはsettings.pyで設定したページネーションクラス、僕の場合にはPageNumberPaginationの、paginate_querysetというメソッドが呼ばれます。

pagination.py
class PageNumberPagination(BasePagination):
    ...

    page_size = api_settings.PAGE_SIZE

    django_paginator_class = DjangoPaginator
    ...

    def paginate_queryset(self, queryset, request, view=None):
        """
        Paginate a queryset if required, either returning a
        page object, or `None` if pagination is not configured for this view.
        """
        page_size = self.get_page_size(request)
        if not page_size:
            return None

        paginator = self.django_paginator_class(queryset, page_size)  # ここの行でDjangoのPaginatorがイニシャライズされる。

github django-rest-frameworkより引用

最後の行で、querysetという変数が第一引数として呼ばれ、Paginatorが初期化されます。実際の__init__メソッドを見てみると、、、

paginator.py
class Paginator:

    def __init__(self, object_list, per_page, orphans=0,
                 allow_empty_first_page=True):
        self.object_list = object_list

Django docより引用

querysetやなくてオブジェクトリストやないかい!

まとめると、listメソッドの中でquerysetはオブジェクトの配列になった後でページネーションが行われていたのです。

実際の方法

やりたいことをおさらいします。
今回の目的は、ユーザーごとにランダムなオブジェクトのリストを取得できること、かつ、ページを移動してもオブジェクトが被らないことです。

今までの議論から、最終的にどんなことをしたらいいのかをまとめるとこうなります。

1.
固定されたランダムなオブジェクトの配列を
page = self.paginate_queryset(queryset)
のquerysetの部分に渡す。

2.
固定されたランダムな配列を複数用意する。

3.
ユーザーごとにランダムな配列を割り当てる。

4.
1ページ目をリロードした時は、別のランダムな配列を取得する。

今回この4つの条件を満たすために、ユーザーセッション、キャッシュを使って実装します。まずはコードをお見せします。

views.py
class RandomListView(generics.ListCreateAPIView):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    # filter_backends = None  # *1

    def list(self, request, *args, **kwargs):
        """
        ランダムでページネーションされたリスト取得
        クエリパラメータに応じた挙動:
        reload: trueのとき、セッションのrandom_expを+1することで、擬似的にランダムなリストが更新される(0-24のパターンを循環する)
        page: 2ページ目移行の時はreloadはされない
        """
        # *2
        RANDOM_EXPERIENCES = 24
        if not request.session.get('random_exp'):
            request.session['random_exp'] = random.randrange(0, RANDOM_EXPERIENCES)

        # *3
        flag = None
        if 'reload' in request.GET:
            param = request.GET['reload']
            flag = bool(param)
        if 'page' in request.GET:
            flag = False

        if flag:
            request.session['random_exp'] += 1

        # *4
        object_list = cache.get('random_exp_%d' % request.session['random_exp'])

        if not object_list:
            object_list = list(Post.objects.order_by('?').all())
            cache.set('random_exp_%d' % request.session['random_exp'], object_list, 60*15)  # *5
            object_list = cache.get('random_exp_%d' % request.session['random_exp'])

        page = self.paginate_queryset(object_list)
        if page is not None:
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)

        serializer = self.get_serializer(object_list, many=True)
        return Response(serializer.data)

*1

ランダムに取得するということなので、フィルターはしません(Noneをセットしています)。

*2

まず、セッションを使って、ユーザーにrandom_expというキーで0から24の番号をランダムに割り当てます。24という数字は加減してください。

こうすることで、ユーザーは、25個のランダムなオブジェクトの配列からランダムで一つ配列を取得します。(ランダムという言葉が多いですがゆっくり読み進めてください。)

*3

次に、URLのクエリパラメータの中でreloadという値がtrueになっている時、random_expの値を+1することでユーザーに異なる配列を取得させています。

ここで注意点として、この例の場合、25回目のリロードで一番初めに持っていた配列に戻ってきます。循環させることで擬似的なランダムを実現しています。完全なランダムに近づけるには、RANDOM_EXPERIENCEの数を増やしてください。

また、URLのクエリパラメータの中でpageという値が存在する場合、つまり、2ページ目以降のページにアクセスしている時、reloadは強制でFalseになります。2ページ目以降でもリロードできてしまうと、1ページ目と2ページ目では違う配列を使っていることになるので、オブジェクトが被ってしまう可能性があるからです。

*4

その後、計25個あるランダムなオブジェクトの配列の中から、ユーザーセッションのrandom_expの番号の配列を取得して(cacheに'random_exp_0から24の数字'というキーでgetすると取得できる)、
page = self.paginate_queryset(object_list)
のobject_listの部分に渡しています。

最終的に、

api/posts/ -> ランダムなオブジェクトのリストを取得
api/posts/?reload=true -> 以前と異なるランダムなオブジェクトのリストを取得
api/posts/?page=2 -> 1ページ目の時と同じ配列で、2ページ目を表示
api/posts/?page=2?reload=true -> api/posts/?page=2と同じ

というURLが実現できました。

*5

また、キャッシュの時間を60*5のように秒数で設定することで、全ての配列を新しくするまでの時間を設定できます。試しながら調整してみてください。
ちなみに短くしすぎると1ページ目と2ページ目で同じオブジェクトが取得できてしまう可能性が高くなるということは注意してください。

終わりに

今回初めて長い説明をしたので、わかりにくいところがあるかもしれません。いつでも質問を受け付けているので、気軽にコメントしていただけるとありがたいです。

参考