ウサギだ
今回は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ライフを送ろう!
コメント