[DRF]結合テーブルの取得【N+1問題】


Django-rest-frameworkでAPIを作成します

前回までの基本編の記事はこちらです。

記事
[DRF]apiの作成(patch)
[DRF]apiの作成(post)
[DRF]apiの作成(get)part2
[DRF]apiの作成(get)part1

この記事からは応用編ということで、外部キーなどで結合されたテーブルの操作をまとめて行こうかと思います。

model

今回からのmodelは以下のとおりです。

models.py
from django.db import models

DISTRICT_CATEGORIES = [
    ("1", "地区1"),
    ("2", "地区2"),
    ("3", "地区3"),
    ("4", "地区4"),
]


class Student(models.Model):
    """生徒情報"""

    # 生徒ID
    student_id = models.CharField(max_length=4, primary_key=True)

    # クラス
    class_no = models.CharField(max_length=1)

    # 出席番号
    attendance_no = models.IntegerField()

    # 名前
    name = models.CharField(max_length=20)

    # 地区番号
    district_no = models.CharField(max_length=1, choices=DISTRICT_CATEGORIES)

    # フリーコメント
    comment = models.CharField(max_length=200,blank=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["class_no", "attendance_no"],
                name="class_attendance_unique"
            ),
        ]


class Exam(models.Model):
    """試験情報"""

    # 国語
    japanese_score = models.IntegerField(null=True, blank=True)

    # 数学
    math_score = models.IntegerField(null=True, blank=True)

    # 英語
    english_score = models.IntegerField(null=True, blank=True)

    # 生徒ID
    student = models.OneToOneField(Student, on_delete=models.CASCADE)

Studentに加えて、そのstudentごとの試験結果を格納するテーブルを作成し、OneToOneFieldで接続させています。生徒と試験結果が1対1で対応していると考えて下さい。

model同士の関係性の定義はいろいろあります。
このようにstudentとexamが1対1で接続しているときはOneToOneFieldを用いますし、1対多であれば外部キーということでForeignKey、多対多の場合はManyToManyFieldを用います。この場合、migration時にdjangoが中間テーブルを自動的に作成してくれます。
今回はOneToOneFieldを用いていますね。この場合、ExamオブジェクトからはOneToOneFieldとして定義したstudent_idをつけて、student_idでstudentの主キーにアクセスすることができます。
また、studentからはexamで、examからはstudentで、それぞれのオブジェクトが参照できるようです。

参照

今回はこのmodelに対して特定の生徒の情報と試験の情報の両方を抜き出して参照できるAPIを作成します。

serializer

複数テーブルを返すserializerは以下のようになります。
exam, student両方のserializerを準備して、studentの方でexamのフィールドを定義してあげます。このようにするとstudentの中にexamをネストしてserializerに格納することが可能です。

serializers.py
from rest_framework import serializers
from ..models import Student, Exam


class ExamSerializer(serializers.ModelSerializer):
    """Examクラスのシリアライザ"""

    class Meta:
        model = Exam
        fields=[
            'japanese_score',
            'math_score',
            'english_score'
        ]


class StudentSerializer(serializers.ModelSerializer):
    """Studentクラスのシリアライザ"""

    exam = ExamSerializer()

    class Meta:
        model = Student
        fields = [
            'student_id',
            'class_no',
            'attendance_no',
            'name',
            'district_no',
            'comment',
            'exam'
        ]

view

serializerに対する、getのviewは以下のとおりです。今回はpkで取得するviewとします。
特に基本編と変わらないviewを作成しています。

views.py
from rest_framework.views import APIView
from rest_framework.response import Response

from ..models import Student
from ..serializers import StudentSerializer


class StudentExamRetriveAPIView(APIView):
    """生徒情報、試験情報を取得"""

    def get(self, request, pk, *args, **kwargs):

        # pkからレコード取得
        instance = Student.objects.get(pk=pk)

        # serializer作成
        serializer = StudentSerializer(instance)

        # Response
        return Response(serializer.data)

実行

URLや実行ファイルは割愛します。
実行結果は以下のとおりです。

{"student_id":"0001","class_no":"1","attendance_no":1,"name":"山田太郎","district_no":"4","comment":"にゃ〜","exam":{"japanese_score":100,"math_score":100,"english_score":90}}

studentの中にexamがネストされて出力されているのがわかりますね。
重要なのはStudent.objects.get(pk=pk)でstudentのobjectだけとってるけど、onetooneで接続されているexamのレコードも一緒にとってきてくれるってところですね。

汎用APIView

汎用APIVewでもやります。ほぼ同じですけど。

views

importは省略してます。

views.py
class StudentExamRetriveAPIView(generics.RetrieveAPIView):
    queryset = Student.objects.all()
    serializer_class = StudentSerializer

{"student_id":"0001","class_no":"1","attendance_no":1,"name":"山田太郎","district_no":"4","comment":"にゃ〜","exam":{"japanese_score":100,"math_score":100,"english_score":90}}

終わりです・・・コード少ない。

N+1問題

DjangoやRailsでORMを使用していると良く見る問題です。

OneToOneフィールドやForeignKeyフィールドを用いたORMで注意が必要です。
例えば1対多のForeignKeyを使用している場合、最初にForeignKeyを定義しているmodelのレコードがN件取得され、その1件1件に対してForeignキーで外部キー接続しているテーブルの情報を読み出すので総じてSQLがN+1回流れてしまうという問題です。

今回の例ではOneToOneフィールドを用いていますが、仮にこれがStudent・・・1に対してexam・・・多のForeignKeyフィールドだとしましょう(試験は1回でなく中間テスト、期末テストなどあるはずです)。
そして例えば全件取得の処理を書いたとして、以下のようなイメージになるはずです。

まずはExamの全レコードを取得。そしてその後Examの各レコードのStudentから、一つ一つSQLを流して生徒情報を特定。

イメージはこんな感じです。

赤文字それぞれに関してSQLが一つひとつ流れてしまう感じです。
あまりよろしくない状況です。

仮の話を戻しますが、実際に現在OneToOneで定義しているときのgetのSQLは以下のように流れています。

(0.003) SELECT "students_student"."student_id", "students_student"."class_no", "students_student"."attendance_no", "students_student"."name", "students_student"."district_no", "students_student"."comment" FROM "students_student" WHERE "students_student"."student_id" = '0001' LIMIT 21; args=('0001',)
(0.007) SELECT "students_exam"."id", "students_exam"."japanese_score", "students_exam"."math_score", "students_exam"."english_score", "students_exam"."student_id" FROM "students_exam" WHERE "students_exam"."student_id" = '0001' LIMIT 21; args=('0001',)

しっかり2回流れていますね。

select_related()

対策として、select_relatedを紹介しておきます。
これをつけることで、SQL内でJoinをしてくれるようになります。

これを

# pkからレコード取得
instance = Student.objects.get(pk=pk)

このように書き換えました。

# pkからレコード取得
instance = Student.objects.select_related('exam').get(pk=pk)

流れるSQL

(0.002) SELECT "students_student"."student_id", "students_student"."class_no", "students_student"."attendance_no", "students_student"."name", "students_student"."district_no", "students_student"."comment", "students_exam"."id", "students_exam"."japanese_score", "students_exam"."math_score", "students_exam"."english_score", "students_exam"."student_id" FROM "students_student" LEFT OUTER JOIN "students_exam" ON ("students_student"."student_id" = "students_exam"."student_id") WHERE "students_student"."student_id" = '0001' LIMIT 21; args=('0001',)

JOINしてくれていますね。1つのSQLで済んでいそうです。

この辺の参考記事はいくつかあったので紹介しておきます。
podhmo's diaryさん
akiyokoさん

OneToOneFieldでもForeignKeyFieldでも使えれば使っていって良さそうだと思いました。ORMについてはしっかり理解したほうが良さそうですね。

では、今回はここまでとします。