
皆さんこんにちは!Yuyaです。
現在、私はXのクローンアプリを開発しています。
投稿一覧を出力する際、同時にその投稿が「いいね」されているかを判定する必要がありました。調べていくと annotate
+ Exists, OuterRef
の2つを使って実装できると判明したのでアウトプットしていきます。
前提となるファイルのコードです。
apps/posts/models.py
from django.db import models
class Post(models.Model):
user = models.ForeignKey(
"accounts.CustomUser",
on_delete=models.CASCADE,
related_name="posts"
)
message = models.CharField(max_length=140, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
# 他のフィールドは省略
apps/likes/models.py
class Like(models.Model):
user = models.ForeignKey(
"accounts.CustomUser",
on_delete=models.CASCADE,
related_name="likes"
)
post = models.ForeignKey(
"posts.Post",
on_delete=models.CASCADE,
related_name="likes"
)
# 他のフィールドは省略
class Meta:
constraints = [
models.UniqueConstraint(
fields=['user', 'post'],
name='unique_user_post_like'
)
]
apps/posts/services.py
from apps.posts.models import Post
from django.db.models import Count, Exists, OuterRef
from apps.likes.models import Like
class PostService:
@staticmethod
def get_post_list(user):
""" ポスト一覧を取得(いいね状態付き) """
return Post.objects.select_related('user') \
.prefetch_related('comments') \
.annotate(
total_comments=Count('comments', distinct=True),
total_likes=Count('likes', distinct=True),
is_liked=Exists(
Like.objects.filter(
post_id=OuterRef('id'),
user_id=user.id
))) \
.order_by('-created_at', '-id')
apps/accounts/views.py
from django.views.generic import TemplateView
from django.contrib.auth.mixins import LoginRequiredMixin
from apps.posts.services import PostService
from django.core.paginator import Paginator
class HomePageView(LoginRequiredMixin, TemplateView):
template_name = "home.html"
paginate_by = 5
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = self.request.user
# PostServiceからいいね状態付きの投稿一覧を取得
post_list = PostService.get_post_list(user)
# ページネーション
paginator = Paginator(post_list, self.paginate_by)
page_number = self.request.GET.get("page")
page_obj = paginator.get_page(page_number)
context.update({
'user': user,
'page_obj': page_obj,
'posts': page_obj.object_list
})
return context
今回大事なのはservices.py
にあります。
Post.objects.select_related('user') \
.prefetch_related('comments') \
.annotate(
total_comments=Count('comments', distinct=True),
total_likes=Count('likes', distinct=True),
# ---- 重要なコード -----
is_liked=Exists(
Like.objects.filter(
post_id=OuterRef('id'),
user_id=user.id
# ---- 重要なコード -----
))) \
.order_by('-created_at', '-id')
コード説明
.select_related('user')
投稿に関連するユーザー情報を同時に取得
.prefetch_related('comments')
投稿に関連するコメントを事前に一括で取得
select_relatedとは異なり、prefetch_relatedは別のクエリを実行
.annotate(total_comments=Count('comments', distinct=True),
各投稿に対するコメント数を集計し、total_comments
という名前の属性として付与。distinct=True
を指定することで、重複カウントを防止
total_likes=Count('likes', distinct=True),
同様に、各投稿の「いいね」数を集計し、total_likes
属性として付与。こちらもdistinct=True
と指定することで、重複カウントを防止
is_liked=Exists(Like.objects.filter(post_id=OuterRef('id'), user_id=user.id))
各投稿に対して、現在のユーザーがいいねしているかどうかをブール値として計算
-
Exists()
- サブクエリの結果が存在するかどうかを判定
-
OuterRef('id')
- 外側のクエリ(
Post
)のid
フィールドを参照します
- 外側のクエリ(
- この組み合わせにより、各投稿ごとにいいね状態を効率的に判定できます
.order_by('-created_at', '-id')
取得した投稿を作成日時の降順でソートし、同じ日時の場合はIDの降順でソート
詳しくは公式ドキュメントをご覧ください。
今回 Exists, OuterRef
を使用した理由は以下です。
- いいね判定はいいねを「している/していない」のどちらかであるため「True / False」で判定することが適している
- いいねは1人のユーザーが1つの投稿に対して1回だけであり、単純な存在確認だけであるため
Exists
が最適 -
OuterRef
を使用することで外部クエリとの関連をシンプルにすることができる -
annotate
と併用することで、1回のクエリで全投稿と各投稿のいいね状態を同時に取得できる
何も考えずに書くと以下のようにserviceなんて使わず、for文を使って各投稿にいいねがされているかを確認することができます。
posts = Post.objects.all()
# 各投稿に対していいね状態を判定
for post in posts:
post.is_liked = Like.objects.filter(post=post, user=request.user).exists()
結論、これでも問題なくコードは動きます。
ただ、このコードの欠点は
N+1問題が起きてしまうことです。
先ほどのfor文を使ったコードではどんなクエリが発行されているか確認しましょう。
100件投稿があり、それぞれの投稿に対していいね確認を行うようにします。
for文を使った場合
まず、投稿を全件取得します。
その後、各投稿に対して100件いいね確認のクエリが実行される
-- 投稿ID=1のいいね確認
SELECT EXISTS(SELECT 1 FROM likes WHERE post_id = 1 AND user_id = 123);
-- 投稿ID=2のいいね確認
SELECT EXISTS(SELECT 1 FROM likes WHERE post_id = 2 AND user_id = 123);
-- 以降も同じようなSQLが繰り返し続く...
-- 投稿ID=99のいいね確認
SELECT EXISTS(SELECT 1 FROM likes WHERE post_id = 99 AND user_id = 123);
-- 投稿ID=100のいいね確認
SELECT EXISTS(SELECT 1 FROM likes WHERE post_id = 100 AND user_id = 123);
このようにfor文を使って100件の投稿に対していいね確認をすると
- 投稿全権取得
- いいね確認 × 100
合計で101回のクエリが発生します。
もし、投稿が1000件, 1万件となるとその分だけクエリの回数が増えます。
annotate, Exists, OuterRef
を使った場合
一方で annotate, Exists, OuterRef
を使うと以下のクエリが発行されます。
# views.py
post_list = PostService.get_post_list(user)
# services.py
class PostService:
@staticmethod
def get_post_list(user):
""" ポスト一覧を取得(いいね状態付き) """
return Post.objects.select_related('user') \
.prefetch_related('comments') \
.annotate(
total_comments=Count('comments', distinct=True),
total_likes=Count('likes', distinct=True),
is_liked=Exists(
Like.objects.filter(
post_id=OuterRef('id'),
user_id=user.id
))) \
.order_by('-created_at', '-id')
SELECT
posts.*,
EXISTS(
SELECT 1
FROM likes
WHERE likes.post_id = posts.id AND likes.user_id = 123
) AS is_liked
FROM posts;
なんとクエリの発行はたった1回だけです。
100件でも1000件でもたった1回だけで、各投稿がいいねされているか取得できます。
今回は、Djangoにおける投稿の「いいね」状態を効率的に取得する実装方法について解説しました。
多くの開発者が陥りがちなN+1問題を回避するため、annotate
と Exists
、OuterRef
を組み合わせたアプローチを紹介しています。
一般的なfor文による実装では、投稿件数に比例してクエリが発行され(100件の投稿なら101回のクエリ)、アプリケーションのパフォーマンスが著しく低下します。特に大規模なデータセットを扱う実運用環境では致命的な問題となります。今回紹介した手法では、サブクエリを活用して単一のSQLで全ての情報を取得するため、データ量に関わらずクエリ発行回数を1回に抑えられます。
ぜひプロジェクトでの実装時に参考にしてください。
Views: 2