1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

パフォーマンス、セキュリティ視点から見るvaluesメソッド

Last updated at Posted at 2025-04-07

はじめに

みなさん、こんにちはYuyaです!
今現在DjangoでXのクローンアプリを開発しています。
プロフィールページを実装中に「クエリを最適化しよう!」と思いました。

調べていくとselect_relatedでN+1問題を解決することはもちろん、
Django では values メソッドで
特定のカラムのみを取得できると知りました。

果たしてパフォーマンス、セキュリティ的にこの選択は吉なのでしょうか?

今回の問題

今回は、ログインユーザーのプロフィールページに移動した際、そのユーザーがいいねした投稿一覧を表示する実装のクエリを最適化しようと考えました。

画面遷移は以下です。

  1. サイドバーのプロフィールボタンをクリック
  2. ログインユーザーのプロフィールページへ移動
  3. 「いいね」タブをクリック
  4. ユーザーがいいねした投稿一覧を表示

最後の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
accounts/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

posts/models.py
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}"

likes/models.py
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

profiles/views.py
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で必要最低限のカラムのみ取得
    • 投稿元のユーザー名
    • 投稿元のユーザーアイコン
    • 投稿文章
    • 投稿画像
likes/services.py
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メソッドを利用したクエリです↓

スクリーンショット 2025-04-06 16.19.38.png

↓こちらがvaluesを使わない場合のクエリです↓

スクリーンショット 2025-04-06 16.14.08.png

これをみた際に思ったことは

  • ユーザーのパスワード、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で留めておきました。

likes/services.py
class LikeService:
    @staticmethod
    def get_user_like_posts(user):
        return user.likes.select_related("post__user")

かなりスッキリしました!

最後に

改めて、今回の場合は values を利用しない方が吉という結論になりました。

まだ、駆け出し前エンジニアということで実務に入っていません。
今後、やっぱりvalues使うべきだったという実装に出会ったら
その時アウトプットしようと思います。

最後までご覧いただきありがとうございました。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?