Django REST frameworkをカオス化させず少しマシにするためのtips

↓↓↓はじめて講座を受ける方はこちら↓↓↓
開発環境を作ろう

ウサギ
ウサギ

ウサギだ

ウサギ
ウサギ

今回はDjangoでAPIを作るときの必修項目、Django REST frameworkについて説明してみる

Django REST framework(DRF)はDjango上に構築された強力なツールキットで、RESTful APIの開発を簡素化し、効率化するために設計されている。

下記の講座で少し使ったな。

少ないコードで簡単にCRUDが作れ、面倒なValidation処理や、レスポンスのシリアライズ、認証周りのゴタゴタを簡単に実装できるのが強みだ。

ただ、ウサギが長年使ってきた感想だが、気をつけないとソースコードがカオスりやすい。

フレームワーク部分とビジネスロジックの分断が難しく、なんとなく書いていると簡単に責務の分割を間違える。

今回はDRFを使って機能を作る際に、こうしたほうが良いんじゃない? というポイントをいくつか紹介していくぞ。

※ 本記事は、クロスマートアドベントカレンダー 2024 の 20 日目の記事として執筆しています!

ViewsetよりAPIViewを使う

いまいちパターン Viewsetを使って分岐まみれにする

たとえばViewsetを使って、日記一覧、新規登録/更新、詳細表示の機能を下記のように作ったとする。

router.register(r'posts', views.PostViewSet, basename='post')
class PostViewSet(
  viewsets.GenericViewSet,
  ListModelMixin,
  CreateModelMixin,
  UpdateModelMixin,
  RetrieveModelMixin,
):

  def get_serializer_class(self):
    if self.action == "create":
      return PostCreateSerializer  # 新規登録のロジック
    if self.action == "update":
      return PostUpdateSerializer  # 更新のロジック
    return PostListSerializer  # 一覧表示、詳細表示時のシリアライズ

  def get_queryset(self):
    return Post.objects.all()

よくある構成で、新規登録のルートではPostCreateSerializerで登録ロジックを。それ以外はPostListSerializerで一覧表示時のシリアライズを指定している。

Viewset配下にさらにルートが増えれば下記のようにget_serializer_classがさらに分岐していく。

  def get_serializer_class(self):
    ...
    if self.action == "⭐️⭐️⭐️":
      return PostHogeSerializer  # ⭐️ 追加
    ...

さらに、N+1対策で詳細表示時だけ、select_relatedやprefetch_relatedを追加したい、というシーンが出てくるとする(よくある)。

するとget_queryset部分も下記のように分岐し始める。

  def get_queryset(self):
    if self.action == "retrieve":
      # ⭐️ 履歴情報表示時のN+1を解消
      return Post.objects.prefetch_related("post_histories")
    return Post.objects.all()

異なるユースケースを一つのクラスに詰め込んだわけなので、自然、条件分岐が発生せざるを得ない。

このような分岐は、論理的凝集と呼ばれ、保守性を落とす要因とされる。

マシなパターン APIViewで分岐を減らす

そもそもユースケースレベルで違うものは、ルーティングレベルで分けてしまうのが良さそうだ。

なのでViewsetを使わず、APIViewで各ルートを定義する。

# router.register(r'posts', views.PostViewSet, basename='post')

urlpatterns = [
  ...
  path("api/posts/", views.PostListCreate.as_view(), name="post-list-create"),
  path("api/posts/<str:pk>", views.PostRetrieveUpdate.as_view(), name="post-retrieve-update"),
  ...
]


from rest_framework.generics import ListCreateAPIView, RetrieveUpdateAPIView


class PostListCreate(
  ListCreateAPIView,
):
  queryset = Post.objects.all()

  def get_serializer_class(self):
    if self.request.method == 'POST':
      return PostCreateSerializer
    return PostSerializer


class PostRetrieveUpdate(
  RetrieveUpdateAPIView,
):
  queryset = Post.objects.prefetch_related("post_histories")

  def get_serializer_class(self):
    if self.request.method == 'PUT':
      return PostUpdateSerializer
    return PostSerializer

URLの都合上、一覧と新規登録、更新と詳細表示が一緒のクラスになるが、すべてがごちゃまぜになっている場合よりだいぶマシになる

Modelに記載されている以外の項目を表示させる場合 Modelのpropertyを使う

例えば一覧APIで、Modelの項目以外のものを出したい場合がある。

下記⭐️のようなイメージ

[
	{
		"id": 1,
		"title": "1つめの日記",
		"body": "1つめの日記本文1つめの日記本文1つめの日記本文1つめの日記本文1つめの日記本文",
        "description": bodyを15文字だけ出したい  # ⭐️
	},
	...
]

いまいちパターン Serializerにロジックを書いちゃう

この場合、下記のようにSerializer側でbodyの値を加工して書くこともできる。

class PostListSerializer(serializers.ModelSerializer):

  class Meta:
    model = Post
    fields = ('id', 'title', 'body', 'description',)

  description = serializers.SerializerMethodField()

  def get_description(self, obj):
    # bodyを15文字に切り取って返す
    return obj.body[:15] + '...'

ただ、このdescriptionという概念を、一時的な仕様ではなく、プロジェクト全体での共通知識としたい場合。

Serializer内に書いていると、共通仕様だということが認識しづらくなる。

すると、ここのエンドポイントのdescriptionは15文字だが、こっちではなぜか30文字になってるぞ?

みたいなことが起こる。

マシなパターン Model側にロジックを書く

DDDでいう値オブジェクト的な、共通のドメイン知識をSerializer内に書くのはよろしくない。

ということで、Modelのpropertyに書くことで、共通の仕様だということを宣言する。

class Post(models.Model):
  ...

  @property
  def description(self):
    return f'{self.body[:15]}...'


class PostSerializer(serializers.ModelSerializer):

  class Meta:
    model = Post
    fields = ('id', 'title', 'body', 'description',)

すると、descriptionという日記に関する仕様が、Postモデルに集約されることになり、仕様があちこちに散らかる、ということがなくなる。

表示項目はSerializerでなく、Model側のpropertyに書く、は徹底したいところだ。

ロジックの中身はSerializer側に書かない

例えば日記保存時に、保存履歴を残す、みたいな仕様が追加されたとしよう。

いまいちパターン Serializerの中に直接ロジックを書いてしまう

特に何も考えない場合、

class PostCreateSerializer(serializers.ModelSerializer):
  class Meta:
    model = Post
    fields = ('title', 'body', 'status', 'categories')

  def create(self, validated_data):
    post = Post.objects.create(
      user=self.context['request'].user,
      title=validated_data['title'],
      body=validated_data['body'],
    )
    PostHistory.objects.create(post=object)
    return post

↑のように、直接ORMを書いて、Serializerにどんどんロジックを追加していく。

もちろん、一定これでよいのだが、Serializerレベルでは抽象化されたメソッドを叩くにとどめたいところだ。

Serializer側に重要なPostの作成ロジックが書かれてしまうと、仕様があちこちに散在し、保守がしにくくなるからだ。

マシなパターン Serviceクラスを呼び出し、ロジックはそこに集約する

言いたいことは一つで、「SerializerやViewにロジックを書くな」という一点に尽きる。

下記のように高凝集なクラスにロジックを移行すると良い。

class PostCreateSerializer(serializers.ModelSerializer):
  class Meta:
    model = Post
    fields = ('title', 'body', 'status', 'categories')

  def create(self, validated_data):
    return PostCreateService(
      user=self.context['request'].user,
      title=validated_data['title'],
      body=validated_data['body'],
    ).execute()


class PostCreateService:
  def __init__(self, user, title, body):
    self.user = user
    self.title = title
    self.body = body

  def execute(self):
    post = Post.objects.create(
      user=self.user,
      title=self.title,
      body=self.body,
    )
    PostHistory.objects.create(post=post)
    return post

一件ややこしいのだが、こうすることで、

  • Serializerは受け取った値を整形し、処理を呼び出すだけ
  • ロジックだけが独立し、テストしやすくなる

というメリットが出てくる。

この先CSVによる日記登録、などの仕様追加が出てきた場合、PostCreateServiceは活躍しそうである。

逆に、Serializer側にロジックを書いてしまった場合、Serializer側のロジックと密結合する場面が出てきて、非常に保守しにくくなってくる。

2年ぐらいすると、FatなSerializerが爆誕し、誰も保守したくなくなるのが関の山だ。

ウサギ
ウサギ

ただし、過度な共通化は厳禁だ↓
あくまで「日記を登録する」という目的の場合のみPostCreateServiceを使いまわす、ということを徹底しよう

まとめ

ウサギ
ウサギ

いろいろ言ってきたが、大事な点は下記だけだ

  • View側、Serializer側にロジックを書くな
  • ViewはリクエストメソッドとpathをもとにSerializerを決定するのみ
  • Serializerはロジックを呼び出すのみ
  • ロジックはModelか付随するService層で書く

上記を守って、マシなDRFライフを送ろう!

コラム
わいへいをフォローする

小久保洋平。
Djangoのサーバーサイドエンジニア。
自動テスト周りが得意。
やってみたレベルではなく
実際に現場で使うDjangoテクニックを発信します。

わいへいをフォローする
ウサでも分かる中級Django講座

コメント

タイトルとURLをコピーしました