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

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

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

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

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

画面遷移は以下です。

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

最後の4番の部分でクエリ最適化をできないかと試行錯誤したということです。

# 前提条件
:::note warn
今回の目的は最適なクエリについてなので
フォルダ構成、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
```python
# 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`で使っていないカラムが多い為<br>`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`を使った場合と使わない場合のクエリをそれぞれ確認していきましょう。

:::note warn
`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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/3d77e27d-1353-4549-b8e3-5ab0f2a2c50a.png)

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

![スクリーンショット 2025-04-06 16.14.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/ec936fc0-e491-4fff-80a7-7d5589c06969.png)

これをみた際に思ったことは
- ユーザーのパスワード、Webサイトって`LikeService`から呼び出すもんじゃないし、必要ないね。結果めっちゃクエリ最適化！！
- この経験を活かして`values`多用確定

# 疑問が浮かぶ
最適化を確信した後にふとこのような考えが浮かびました。

確かに`values`で最適化できたけど
例えば、ポストの投稿時刻とポストしたユーザーの生年月日にアクセスしたくなった時は以下のようにわざわざ追加するってことか...
```python
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`のようなフィールドにアクセスできないようにできますが、カスタムマネージャーを定義するのがベスト
```python
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`使うべきだったという実装に出会ったら
その時アウトプットしようと思います。

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