はじめに
みなさん、こんにちはYuyaです!
今現在DjangoでXのクローンアプリを開発しています。
プロフィールページを実装中に「クエリを最適化しよう!」と思いました。
調べていくとselect_related
でN+1問題を解決することはもちろん、
Django では values
メソッドで
特定のカラムのみを取得できると知りました。
果たしてパフォーマンス、セキュリティ的にこの選択は吉なのでしょうか?
今回の問題
今回は、ログインユーザーのプロフィールページに移動した際、そのユーザーがいいねした投稿一覧を表示する実装のクエリを最適化しようと考えました。
画面遷移は以下です。
- サイドバーのプロフィールボタンをクリック
- ログインユーザーのプロフィールページへ移動
- 「いいね」タブをクリック
- ユーザーがいいねした投稿一覧を表示
最後の4番の部分でクエリ最適化をできないかと試行錯誤したということです。
前提条件
今回の目的は最適なクエリについてなので
フォルダ構成、Model、URL、Viewは省略する部分があります。
ご了承ください。
フォルダ構成
manage.py
config/
__init__.py
settings.py
urls.py
apps/
accounts/
__init__.py
models.py
services.py
views.py
posts/
__init__.py
models.py
services.py
views.py
likes/
__init__.py
models.py
services.py
profiles/
__init__.py
views.py
urls.py
Model
以下に3つのモデルを記載します。
- accounts/models.py
- posts/models.py
- likes/models.py
import datetime
from django.db import models
from django.contrib.auth.models import AbstractUser
from cloudinary.models import CloudinaryField
# デフォルトを定義
DEFAULT_ICON = 'https://res.cloudinary.com/dyfjcfcfm/image/upload/v1742972473/default_icon_uienbj.jpg'
DEFAULT_HEADER = 'https://res.cloudinary.com/dyfjcfcfm/image/upload/v1742977104/header_image_q4wgax.jpg'
DEFAULT_SELF_INTRODUCTION = 'NO_SELF_INTRODUCTION'
DEFAULT_PLACE = 'NO_PLACE'
DEFAULT_WEBSITE = 'NO_WEBSITE'
DEFAULT_BIRTHDATE = datetime.date(1000, 1, 1)
class CustomUser(AbstractUser):
icon_image = CloudinaryField('icon_image', default=DEFAULT_ICON)
header_image = CloudinaryField('header_image', default=DEFAULT_HEADER)
self_introduction = models.CharField(max_length=160, blank=True, default=DEFAULT_SELF_INTRODUCTION)
place = models.CharField(max_length=30, blank=True, default=DEFAULT_PLACE)
website = models.CharField(max_length=100, blank=True, default=DEFAULT_WEBSITE)
phone_number = models.CharField(
verbose_name="電話番号",
max_length=10,
default="NO_PHONE",
blank=True
)
birthdate = models.DateField(
verbose_name="生年月日",
default=DEFAULT_BIRTHDATE,
blank=True
)
class Meta:
db_table = "users"
def __str__(self):
return self.username
from django.db import models
from cloudinary.models import CloudinaryField
from django.core.exceptions import ValidationError
class Post(models.Model):
user = models.ForeignKey(
"accounts.CustomUser",
verbose_name="ユーザーID",
on_delete=models.CASCADE,
related_name="posts"
)
message = models.TextField(
verbose_name="ポスト文",
null=True,
blank=True,
max_length=140,
)
image = CloudinaryField(
'image',
null=True,
blank=True,
)
created_at = models.DateTimeField(
verbose_name="作成日時",
auto_now_add=True
)
updated_at = models.DateTimeField(
verbose_name="更新日時",
auto_now=True
)
class Meta:
ordering = ["-id"]
db_table = "posts"
def clean(self):
if not self.message and not self.image:
raise ValidationError("ポストには文章か画像がどちらかが必須です")
def __str__(self):
user = self.user.username if self.user else "Unknown"
return f"{self.message} | {user}"
from django.db import models
from django.core.exceptions import ValidationError
class Like(models.Model):
user = models.ForeignKey(
"accounts.CustomUser",
verbose_name="ユーザーID",
on_delete=models.CASCADE,
related_name="likes"
)
post = models.ForeignKey(
"posts.Post",
verbose_name="ポストID",
on_delete=models.CASCADE,
related_name="likes"
)
created_at = models.DateTimeField(
verbose_name="作成日時",
auto_now_add=True
)
class Meta:
ordering = ["-id"]
db_table = "likes"
constraints = [
# DBレベルで二重いいねを防ぐ
models.UniqueConstraint(
fields=['user', 'post'],
name='unique_user_post_like'
)
]
# アプリレベルで二重いいねを防ぐ
def clean(self):
if Like.objects.filter(self.user, self.post).exists():
raise ValidationError("このユーザーは既にいいねをしています")
def __str__(self):
return f"like: {self.user} -> {self.post}"
URL
# config/urls.py
from django.urls import include, path
urlpatterns = [
path('profile/', include('apps.profiles.urls')),
]
# profiles/urls.py
from django.urls import path
from .views import LikeView
app_name = 'profile'
urlpatterns = [
path('likes/', LikeView.as_view(), name="likes"),
]
View
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView
from django.shortcuts import get_object_or_404
from apps.accounts.models import CustomUser
from .services import ProfileService
from apps.relationships.services import RelationshipService
from apps.likes.services import LikeService
class BaseProfileView(LoginRequiredMixin ,TemplateView):
template_name = "profile.html"
def get_user_activities(self, user):
return []
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = get_object_or_404(CustomUser, id=self.request.user.id)
context['user_relatinships'] = RelationshipService.count_following_followers(user)
context['activities'] = self.get_user_activities(user)
return context
class LikeView(BaseProfileView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['active_tab'] = 'likes'
return context
def get_user_activities(self, user):
return LikeService.get_user_like_posts(user)
試したコード
初めに私は以下の2点を考えコードを記載しました。
- N+1問題を考慮するために
select_related
は必須 -
CustomUser
で使っていないカラムが多い為values
で必要最低限のカラムのみ取得- 投稿元のユーザー名
- 投稿元のユーザーアイコン
- 投稿文章
- 投稿画像
class LikeService:
@staticmethod
def get_user_like_posts(user):
return user.likes.select_related("post").values(
"post__user__username",
"post__user__icon_image",
"post__message",
"post__image",
)
どんなSQLが発行されているか
django-debug-toolbar
を使って
values
を使った場合と使わない場合のクエリをそれぞれ確認していきましょう。
django-debug-toolbar
のインストール方法は割愛します。
詳しくは以下のURLをご参考ください。
https://django-debug-toolbar.readthedocs.io/en/latest/installation.html
https://qiita.com/shun198/items/59c388f3b7f731d28985
https://djangobrothers.com/blogs/django_debug_toolbar/
↓こちらがvalues
メソッドを利用したクエリです↓
↓こちらがvalues
を使わない場合のクエリです↓
これをみた際に思ったことは
- ユーザーのパスワード、Webサイトって
LikeService
から呼び出すもんじゃないし、必要ないね。結果めっちゃクエリ最適化!! - この経験を活かして
values
多用確定
疑問が浮かぶ
最適化を確信した後にふとこのような考えが浮かびました。
確かにvalues
で最適化できたけど
例えば、ポストの投稿時刻とポストしたユーザーの生年月日にアクセスしたくなった時は以下のようにわざわざ追加するってことか...
class LikeService:
@staticmethod
def get_user_like_posts(user):
return user.likes.select_related("post").values(
"post__user__username",
"post__user__icon_image",
"post__user__birthdate", # 生年月日を追加
"post__message",
"post__image",
"post__created_at", # 投稿時刻を追加
)
必要があれば追記して、いらなくなったら削除するんか。
もしかするとvalues
ってあんまりなんじゃないかな?
よし、CTOに質問しよ
CTOの回答結果
-
values
を使ってカラムの絞り込むのはパフォーマンスとセキュリティ的に良い - パフォーマンスに関しては、微々たるもの
- セキュリティ面に関しては、一例として
view
にてpassword
のようなフィールドにアクセスできないようにできますが、カスタムマネージャーを定義するのがベスト
class UserManager(models.Manager):
def get_queryset(self):
# passwordフィールド以外の取得
return super().get_queryset().defer('password')
class User(models.Model):
objects = models.Manager()
safe_objects = UserManager() # カスタムマネージャ呼び出し
- 上記を考慮した上でvalueを使う際の以下2点の欠点も考えると
values
は使わなくても良い- モデルインスタンスのメソッドや他のフィールドにアクセスできなくなくなる
- コードが冗長になる
最終的なコード
CTOからの回答を参考にさせていただき
結論、今回のコードは以下のようにselect_related
で留めておきました。
class LikeService:
@staticmethod
def get_user_like_posts(user):
return user.likes.select_related("post__user")
かなりスッキリしました!
最後に
改めて、今回の場合は values
を利用しない方が吉という結論になりました。
まだ、駆け出し前エンジニアということで実務に入っていません。
今後、やっぱりvalues
使うべきだったという実装に出会ったら
その時アウトプットしようと思います。
最後までご覧いただきありがとうございました。