🐍

Django REST FrameworkのView実装で迷った話

に公開

はじめに

早速導入背景の話となりますが、
社内向けのWebアプリケーションをpythonフレームワークのDjangoで作っており、
そのアプリケーション開発が進むにつれてAPIの実装が必要となりました。

API実装を進めるにあたって、
前述の通りDjangoを用いたアプリケーション開発を進めていたため、
自然にDjango REST Framework(以下DRF)の導入に行きつきました。

しかしながら実装を進めていく中で、

  • APIView
  • GenericAPIView(+Mixins)/ generics.*APIView
  • ModelViewSet(ViewSet)

と、クラスベースのViewの書き方が大まかに分けて3パターン存在し、
ドキュメントや文献を読んだ際、結局どれを使えばええんや??と迷いが生じました。

そのため、私が理解を進めるために整理したポイントをまとめ、
自分用のメモ兼これからDRFに触れる人へのヒントにもなればと考え、
記事として残すことにしました。

※※※
DRF自体の概要や詳細な実装方法の説明については、
偉大なる先人の皆様が以下のようにわかりやすくまとめてくださっているので、
本記事においては割愛させていただきます。

・Django REST Frameworkを使って爆速でAPIを実装する
https://qiita.com/kimihiro_n/items/86e0a9e619720e57ecd8

・Django REST FrameworkでAPIを作成してみた
https://zenn.dev/whitecat_22/articles/f826daf43155cd


先に結論を読みたい方へ

これからDRFの3つのViewパターン比較と各実装例、
開発規模に応じてこうすればいいんじゃないかという私見の順に記事を書いています。

それらをすっ飛ばして先に結論を読みたい方は、
画面下部「結論」をご覧ください。


3つのViewパターンの比較

本題の部分ですが、それぞれのパターンがどういうものなのか、何を作るのに向いているのか、
また実装することによるメリットやデメリットをざっくりとまとめました。

(ここ違うんじゃね?というのがあればこっそりコメントで教えてください)

1. APIView

どういうものか

DjangoのViewをAPI用に拡張した素の土台のようなイメージ。
HTTPメソッド(get/post/putなど)等も自分で実装する必要があります。

向いているもの

RESTに縛られないAPI、Webhookの受け口、特殊な入出力処理など。

メリット

  • 実装をする上での自由度が一番高い
     シリアライザーやレスポンス形式、バリデーション等を好きに構成できます。
     リクエスト/レスポンス等を細かく制御しやすいです。

  • 学習コストが低い
     後述のGenericAPIViewのようなフックを覚える必要がないです。
     言うなれば、メソッドに処理を書くだけで実装ができます。
     (それが一番自由度が高い分難しくもあると思いますが)

デメリット

  • CRUD処理を毎回手書きしがち
    CRUD処理の重複実装や実装の抜け漏れが発生するリスクがあります。
    その分テストにかかる工数も増えるかと思います。

  • 一覧/ページネーション/フィルタ等のDRFお得機能の恩恵を得れない
     具体的にどういう処理になるのかは後述の通りですが、
     自由度の高い分すべて自前で実装する必要があります。

  • コード量が増えやすい
     自前で実装する分、都度手で組みがちなためコード量が肥大化しやすいです。

2. GenericAPIView(+Mixins)/ generics.*APIView

どういうものか

APIViewに共通の便利機能を盛り込んだようなイメージです。
よく使うCRUD系の操作は、Mixinsやgenericsで完成品が用意されています。

向いているもの

基本はCRUDだけど、一部だけカスタムして挙動を変えたいようなアプリケーション開発。

メリット

  • DRYに書くことができる
    CRUDの定型はMixinsに任せるなど、共通部品を活用してコーディングができます。
     必要機能だけをガッチャンコして、いわゆる「痩せたAPI」を作れます。

  • 必要な所だけオーバーライドして拡張しやすい
     get_querysetで権限制御をするなど、フックを用いて拡張を簡単に実現できます。

デメリット

  • どの責務をどこで弄るか理解が必要
    使えるフックが多い分、どのフックをどこで変えるか慣れが必要となります。
     仕組みを理解しないとフックの場所に迷います。

  • 後述のModelViewSetほど省力化されない
     URLルーティングは自分で定義する必要があります。

3. ModelViewSet(ViewSet)

どういうものか

リソースをひとまとめにして、Routerが自動でURLを生やします。
また、ModelViewSetはModel+SerializerでフルCRUDを即実装できます。

向いているもの

とにかくCRUDをサクッと作りたい場合や、典型的なREST APIなど。

メリット

  • 爆速でCRUD一式が実装可能
     標準的なCRUDなら最小コード・最小バグで素早く実装できます。

  • @actionで簡単に追加カスタム
     @actionを用いてエンドポイントやカスタム操作を追加できます。

デメリット

  • 責務の肥大化
    なんでもできる分、責務が大きめになりがちです。
     1クラスにアクションを生やしすぎるパターンも考えられます。

  • REST以外の形にはやや不向き
     いわゆるコマンドっぽいAPIには向いていません。

  • 権限をきちんと考えないと危険
     権限管理をしっかりしないと不要なエンドポイントまで公開されてしまいます。


3パターンの実装例

共通処理

3パターンの実装例を、以下のようにBookモデルとして表現します。
まずは共通部分のmodels.pyとserializers.pyは以下のように実装したとします。

models.py
from django.db import models

class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.CharField(max_length=120)
    published_at = models.DateField(null=True, blank=True)
    is_published = models.BooleanField(default=False)

    def __str__(self):
        return self.title
serializers.py
from rest_framework import serializers
from .models import Book

class BookSerializer(serializers.ModelSerializer):
    class Meta:
        model = Book
        fields = ["id", "title", "author", "published_at", "is_published"]
        read_only_fields = ["id", "is_published"]

次に、パターンごとのビュー処理を記載します。

1. APIView

APIViewでビューを手書きすると以下のようになります。

views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.shortcuts import get_object_or_404
from .models import Book
from .serializers import BookSerializer

class BookListCreate(APIView):
    def get(self, request):
        # ページネーションや検索は自分で実装する
        # 以下は全件をidで並べ替えて一気に出す実装を例としています
        qs = Book.objects.all().order_by("-id")
        serializer = BookSerializer(qs, many=True)
        return Response(serializer.data)

    def post(self, request):
        serializer = BookSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        # 例えば作成時は未公開とする
        serializer.save(is_published=False)
        return Response(serializer.data, status=status.HTTP_201_CREATED)

class BookDetail(APIView):
    def get(self, request, pk):
        book = get_object_or_404(Book, pk=pk)
        return Response(BookSerializer(book).data)

    def put(self, request, pk):
        book = get_object_or_404(Book, pk=pk)
        ser = BookSerializer(book, data=request.data)
        ser.is_valid(raise_exception=True)
        ser.save()
        return Response(ser.data)

    def delete(self, request, pk):
        book = get_object_or_404(Book, pk=pk)
        book.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)
urls.py
from django.urls import path
from .views import BookListCreate, BookDetail

urlpatterns = [
    path("books/", BookListCreate.as_view()),
    path("books/<int:pk>/", BookDetail.as_view()),
]

他パターンと比べて一番自由に書ける分、
ページネーション・フィルタ・認可機能などは自分でフル実装する必要があります。
(実現させる方法は複数あるかと思いますが、細かくは省略しています)

2. GenericAPIView + mixins(generics.*APIView)

GenericAPIView系の場合は、
以下のように「generics.*APIView」を継承しフックを使います。

views.py
from rest_framework import generics, permissions
from .models import Book
from .serializers import BookSerializer

class BookListCreateView(generics.ListCreateAPIView):
    queryset = Book.objects.all().order_by("-id")
    serializer_class = BookSerializer
    permission_classes = [permissions.IsAuthenticated]

    # 例えば、作成処理だけ挙動をカスタム
    def perform_create(self, serializer):
        serializer.save(is_published=False)

    # 一覧の絞り込みを楽に
    def get_queryset(self):
        qs = super().get_queryset()
        author = self.request.query_params.get("author")
        if author:
            qs = qs.filter(author__icontains=author)
        return qs

class BookRetrieveUpdateDestroyView(generics.RetrieveUpdateDestroyAPIView):
    queryset = Book.objects.all()
    serializer_class = BookSerializer
    permission_classes = [permissions.IsAuthenticated]
urls.py
from django.urls import path
from .views import BookListCreateView, BookRetrieveUpdateDestroyView

urlpatterns = [
    path("books/", BookListCreateView.as_view()),
    path("books/<int:pk>/", BookRetrieveUpdateDestroyView.as_view()),
]

上記のようにperform_createやget_queryset等のフックの使いこなせれば、
自由度と生産性のバランスがいい感じに取れるかと。

3. ModelViewSet + Router(+カスタムアクション)

ModelViewSetで実装する際は、
以下のように@actionで任意のメソッドに対してURLルーティングを追加します。

views.py
from rest_framework import viewsets, permissions, decorators, response, status
from .models import Book
from .serializers import BookSerializer

class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.all().order_by("-id")
    serializer_class = BookSerializer
    permission_classes = [permissions.IsAuthenticated]

    # /books/{pk}/publish/ で公開にする
    @decorators.action(detail=True, methods=["post"])
    def publish(self, request, pk=None):
        book = self.get_object()
        book.is_published = True
        book.save()
        return response.Response({"status": "published"}, status=status.HTTP_200_OK)

    # /books/unpublished/ で未公開一覧
    @decorators.action(detail=False, methods=["get"])
    def unpublished(self, request):
        qs = self.get_queryset().filter(is_published=False)
        ser = self.get_serializer(qs, many=True)
        return response.Response(ser.data)
urls.py
from rest_framework.routers import DefaultRouter
from .views import BookViewSet

router = DefaultRouter()
router.register(r"books", BookViewSet, basename="book")
urlpatterns = router.urls

結論

上記3パターンのメリット・デメリットや実装方法を踏まえた上で、
開発規模や今後の展望を考えつつ、以下のように使い分けるのがDRFの理想とするところなんじゃないかと思いました。

試作や小規模開発

試しに動かしてみたい場合や、短期間1,2人程度でCRUD処理を実装したいような場合は、
ModelViewSet一択で問題なし。
爆速で実装し、あとは必要に応じて@actionで業務ロジックを足していけば、
試作や小規模開発程度であれば十分なものが出来上がる認識。

中規模開発

ベース部分はModelViewSetで実装し、開発中にこじれそうな箇所がありそうだったら
generics.*APIViewに逃がす選択肢を持たせて柔軟に対応する。
例えば、複雑な検索画面だけListAPIViewを独立させて責務を分ける等。

大規模・ドメイン駆動・RESTに収まらない箇所が多い場合

コア部分の設計は ModelViewSetかGenericAPIViewで作る。
RESTで表現しづらいユースケース(集約ルートや外部連携など)はAPIViewでケース毎に切り出す。
あとはURL設計ポリシーをドキュメント化(「/resources/」はModelViewSet、「/ops/」はAPIView…など)するとチーム内での迷いが減るかと思いました。


おわりに (+蛇足)

これ以降の部分は蛇足かつ、
上記で書いた理想とする結論を覆すような、筆者個人の思想の投げかけとなります。

実際の開発現場で実装を進めていく上では以下の観点から、
最初から全部APIViewに統一してゴリゴリ書いた方がいいんじゃね?と思いました。

  • REST外実装の観点
    結局のところ、RESTっぽくない要件は全部APIViewで実装しますよね、という話。
    外部サービス連携(Slack通知、PDF生成)だったりワークフローを進めるボタンAPI等が例として挙げられると思いますが、
    こいつらはRESTのCRUDにきれいに収まらないから、結局APIViewで書くことが自然に増えますよね。

  • 学習コストとリーダブルコードの観点
    GenericAPIViewやModelViewSetはすごく便利だけど、
    フックが多くて何をオーバーライドすべきか分からない事案が多々発生しそう。
    最初からAPIViewで統一しておけば、他パターンの学習コストもかからないし、
    リーダブルコードの観点からも直感的に他の人が見やすい(当然コードが肥大化しやすい問題はありますが、それでも十分読みやすいコードにまとまる)よね、という話。

  • 保守やチーム運用の観点
    最初からAPIViewで統一して開発を進めた方が、
    以降DRFで何か新しい仕組みができない限りはこの運用を固定できるし、
    開発チームのメンバー(既存/新規JOIN問わず)が迷わずに書けるので無難かと。
    前項とちょっと被りますが、
    例えばModelViewSetだと意図しないエンドポイントが自動で生えたりする可能性があるから、
    それなら最初から必要なメソッドだけ手書きで実装するAPIViewで統一させた方が、
    保守の観点や運用ポリシー上も安全・明快になってわかりやすいですよね、という話。

筆者自身は元々PHP畑の出身でpython自体の現場経験が少ないため、
実際にDRFを導入している現場をたくさん見てきたわけではないですが、
上記の観点から、最初から全部APIViewで書いた方が一番気持ち良く進められると思いました。

とはいえ現場ごとに決まった運用ポリシーや、
偉い現場エンジニアさんの信仰する宗教はあると思うので、
それに従いながら実際にDRFの実装を進めていくのがいいでしょう。

※※※
もちろん前述の理想で書いたことが本来の使い分け手法だと思いますし、
私が気づいていないだけで自身の考えが浅い部分も絶対あると思います。
何かあればコメントでツッコミを入れていただけると大変助かります。
※※※

長くなってしまった上、最後の最後で筆者の思想を投げかけて記事の締めとなり恐縮ですが、
ここまで読んでくださり誠にありがとうございました。

皆さんの現場ではどのようにルール設定をしているか、
もしよろしければ是非書ける範囲でコメントに残していってください。
私自身も今後DRFに触れる上での参考にさせていただきたいと思います。

Discussion