DjangoのORMについて実装を読んで理解を深めたい

2024/09/25に公開

こんにちは。Ultimate Hacking Keyboardが繋がっていなければ、もう何の仕事もやる気が起きない程UHKを愛しすぎた男、天地人エンジニアリングマネージャの高瀬(@k_tacafe)です。

私たち天地人では、天地人コンパス(Tenchijin COMPASS)という地図アプリと、天地人コンパスのアプリケーション上で展開される「天地人コンパス 宇宙水道局」(以下、宇宙水道局)という、自治体様向けの水道管漏水リスク評価ソリューションを開発しております。

つい先日の2024年9月5日、宇宙水道局では、累計契約自治体数が20を超えた実績を糧に、水道業務への理解度をより高いレベルに引き上げ、大幅に操作性を向上させるためのアップグレードを完了しました。

その天地人コンパスや宇宙水道局を支えるフロントエンドにはVueとReact、バックエンドAPIにはPython Djangoを採用しています。

N+1問題との闘い

Object-Relational Mappping(ORM) (ないしは ORMapper )を使ってデータベースの操作を伴うバックエンドAPIを開発している人なら、恐らく多くの人が N+1問題 と格闘した経験があると思います。私もつい先日、リリース直前になって、N+1問題が画面の読み込み速度を大幅に低下させていたAPIを発見し、慌てて格闘した経験がありました。

そもそも運用されるデータ量が少ないようなModelであれば、例えN+1問題が発生していたとしても無視しちゃっても良い場合があるかもしれません。

今回は、あるModelを取得する際に ManyToMany で関連したModelと、その先の別のModelをまとめて取得するような、複雑なリレーションシップを辿るユースケースがありました。N+1に配慮したつもりでも、潰しきれていなかった箇所が大きなボトルネックになり、本番環境の疑似データで動作テストして初めて気づくことができました。ただ単に私の実装力が足りないだけという単純な話

N+1問題を解決する・未然に防ぐためには、使用しているORMの特性を理解してその機能を上手く利用する必要があります。これはPython Djangoだけの話ではなく、ORMやフレームワークを利用する上では常に抑えておくべき大切なポイントですね。Djangoでは prefetch_related メソッドや Prefetch オブジェクトを駆使して、SQLの効率や発行回数を最適化することが一つの鍵となります。

前置きが長くなりましたが、今回のN+1ヒヤリハット経験を踏まえて、DjangoのORMを 完全に理解 するために、実装を確かめながら一から学びなおして行きたいな、というモチベーションが高まりました。その温度感が高いうちに筆をとらせてUltimage Hacking Keyboardに手を置かせていただきたいと思います。

尚、本記事で扱うDjangoの公式ドキュメントやソースコードのバージョンについて、本記事執筆時点での最新版である5.1系を前提としております。

ORMの基本を押さえる

ORMを利用したレコード操作について、Djangoのドキュメントでは、Retrieving Objects の項に分かりやすい解説があるので該当部分を全部引用します。

データベースからオブジェクトを取得するには、モデルクラスの Manager から QuerySet を作ります。
QuerySet はデータベース上のオブジェクトの集合を表しています。多数の フィルタ を持つことができます。フィルタは与えられたパラメータに基づいてクエリの検索結果を絞り込みます。SQL 文においては、 QuerySet は SELECT 句、フィルタは WHERE や LIMIT のような絞り込みに用いる句に対応しています。
モデルの QuerySet を取得するには、モデルの Manager を使用します。各モデルには少なくとも1つの Manager があり、デフォルトでは objects という名前がついています。モデルクラスから直接アクセスしてください

QuerySetの取得例

QuerySetの取得について、簡単な例を用いて咀嚼して行きましょう。

from django.db import models

class User(models.Model):
    name = models.CharField(max_length=255)
    email = models.EmailField(unique=True)
    is_active = models.BooleanField(default=True)

今回はこのような単純な User モデルを使ったユースケースを想定したいと思います。
データベースからレコードを取得してみましょう。

from apps.user.models import User

qs = User.objects.filter(is_active=True)

基本通りに、UserモデルのManager(User.objects)を呼び出し、フィルタ条件を設定してみました(.filter(is_active=True))。

type(qs)
# <class 'django.db.models.query.QuerySet'>

Managerは QuerySet オブジェクトを返します。

print(qs.query)
# SELECT "user"."email", ... FROM "user" WHERE "user"."is_active

QuerySet オブジェクトは発行予定のSQLを.queryプロパティに保持しておりますが、オブジェクトが生成された段階ではデータベースに対する問い合わせはまだ行われません。

list(qs)
# [<User: 1 - sample@example.com>]

qs[0]
# <User: 1 - sample@example.com>

for user_obj in qs:
  print(user_obj)
# 1 - sample@example.com

このように QuerySet オブジェクトが 評価 されるとSQLがデータベースに発行され、取得したデータを元にModelオブジェクトが作られます。
Djangoドキュメントの QuerySetが評価されるタイミング に詳解されていますが、QuerySet に対してlist化する、インデックスで要素にアクセスする、 イテレーションを実行する、などの操作を行ったときにSQLが発行されることになります。

実装を見てみよう

先の例では、 User.objects.filter() などと気軽に ModelManager を呼び出していますが、この1行の裏側を探っていきます。

Modelの実装を覗いてみる

User という式が書けるのは、あらかじめ定義されているUserクラスをimportしているためですね。しかし、このimportよりもっと前、Djangoがアプリケーションの起動処理の中でmodel.pyファイルが読み込まれてメモリに展開される時に、様々な重要な出来事が起こっています。

from django.db import models

class User(models.Model):
    name = models.CharField(max_length=255)

Djangoのmodel定義は、models.Model を継承する作法のため、User クラスが読み込まれる時に、親クラスの Model クラスも読み込まれます。この Model クラスの実装は django/db/models/base.py#459 です。

class Model(AltersData, metaclass=ModelBase):
    def __init__(self, *args, **kwargs):
        # Alias some things as locals to avoid repeat global lookups
        cls = self.__class__
        ...

クラスのコンストラクタ (__init__()) の実装がここから100行くらい続くのですが、鍵となるのはクラス定義の行(1行目)にある metaclass=ModelBase の部分です。metaclassに関するPythonドキュメントはこちらにありますが、今日はメタプログラミングに関してはあまり深く触れずにサラッと流していきたいと思います。

metaclass=ModelBase ということで、ModelModelBase メタクラスによって作成されます。ModelBase の実装は、同じファイル内にある django/db/models/base.py#92 です。

class ModelBase(type):
    """Metaclass for all models."""

    def __new__(cls, name, bases, attrs, **kwargs):
        super_new = super().__new__
        ...
        # 300行くらいなんやかんやある
        ...
        new_class._prepare()

メタクラスに定義された __new__ メソッド(Pythonドキュメント)を通じて、ModelBaseModel クラスを管理し、その生成プロセスに介入して属性やメソッドを追加する役割を持ちます。

この処理の後半で、_prepare() というメソッドが呼び出されます。
_prepare() では、ModelBase.__new__() によって生成されようとしている Model クラス(new_class)オブジェクトに対してメソッド追加などを行っているのですが、

      manager = Manager()
      manager.auto_created = True
      cls.add_to_class("objects", manager)

ここで、objects という属性に、manager を設定している現場を押さえることができました!

User.objects のように、「objectsという名前でManagerにアクセスする、、、」という作法の背景が分かってきた気がします(参考: Djangoのドキュメント Model attributes)。

Managerの実装を覗いてみる

では次に、Modelobjects 属性として与えられた Manager とは何者なのか?その押さえどころを見て行きたいと思います。

Manager クラスの定義はdjango/db/models/manager.py#176 にあります。

class Manager(BaseManager.from_queryset(QuerySet)):
    pass

なるほど。Managerクラスに対する具体的な実装は無く、継承元として BaseManager.from_queryset(Queryset) が指定されているだけですね。 ModelModelBase メタクラスによって生成されるのと同じような匂いがします。

この継承関係をかみ砕いてみると、BaseManager のクラスメソッドである from_queryset() の引数に QuerySet クラスを渡し、その実行結果が継承元として動的に指定される仕組みのようです。

Manager と同じファイルに BaseManager も定義されていますので、from_queryset()の実装もその流れで見てみましょう。

    @classmethod
    def from_queryset(cls, queryset_class, class_name=None):
        if class_name is None:
            class_name = "%sFrom%s" % (cls.__name__, queryset_class.__name__)
        return type(
            class_name,
            (cls,),
            {
                "_queryset_class": queryset_class,
                **cls._get_queryset_methods(queryset_class),
            },
        )

Pythonのビルトインメソッド type を呼び出して、動的にクラスを生成するメタプログラミングが行われています。class_name が与えられていない場合、 "%sFrom%s" % (cls.__name__, queryset_class.__name__) の処理によって、 UserFromQuerySetClass といった名前のクラスが生成されます。

cls.__name__ って結局何になるんだっけ?と流れを見失いがちになりますね。この clsManager が割り当てられることになった Model クラス、すなわち本記事の例を使うと models.Model を継承して定義された User クラスを参照することになります。

ここで作られる UserFromQuerySetClass には、BaseManager.from_queryset() に引数として渡された QuerySet クラスが持つメソッドも割り当てられていますね(**cls._get_queryset_methods(queryset_class) の部分)。

そして、最終的には UserFromQuerySetClass を継承した Manager クラスが初期化され、それは objects という名前で Model クラスに割り当てられることになります。

      manager = Manager()
      manager.auto_created = True
      cls.add_to_class("objects", manager)

ModelBase クラスの _prepare() 内にあるこの処理でしたね。ModelManager が繋がりました。


User というモデルが定義され、ロードされる時に、このようなクラス生成が背後で行われた結果、

  • User というクラスは Model の機能を持ち
  • Modelobjects という属性に Manager クラスが設定され
  • Manager クラスは BaseManager メタクラスによって、QuerySet の機能が付与されている

という状況が作り出されていることが分かりました。

User.objects.filter() の式を機能させるために、Djangoが水面下で行っている仕事が理解できた気がします。

本日はDjangoのORMに関する基本的な使い方、その根底を支える ModelManager に焦点を当てて実装を見てみました。次回は QuerySet の内部実装やN+1対策となる prefetch の仕組みへと、引き続きDjangoのORMへの理解を深めて行きたいと思います。

おわりに

日常的に使っている(お世話になっている)フレームワークやミドルウェアのソースコードが公開されている場合、その内部実装をこまめに確認しておく習慣をつけておく方が良いと思います。

単にツールに関する知識が増えるだけでなく、世界中の優れたエンジニアたちの叡智の結晶となっているコードベースに触れることで、プログラミング言語仕様の理解や、プログラム設計、コーディングスキル(Read & Write)の向上など、開発業務を行う上であらゆる方面で学べることが多いからです。

さらに昨今では、ChatGPTのようなLLMとの対話を通じ、よりスピーディに学習を補助してもらうことも可能です。学習の初動として「~というフレームワークのxxxという機能について、概要を理解できるように解説し、参照すべき実装箇所を教えて下さい」というようなやり取りからスタートすると、膨大な量のソースコードが記述されているフレームワークであっても、効率的に要点を押さえた学習を進めることができるのではないでしょうか。

本日は、お読みいただき誠にありがとうございました。


株式会社天地人では、人工衛星などの宇宙ビッグデータを活用し、地球規模の課題に取り組むためのオンラインGISプラットフォーム天地人コンパス(Tenchijin COMPASS)を開発しています。

私たちと一緒に天地人コンパスを開発してくれる仲間を募集しております。ご興味のある方は以下のページよりエンジニアリングの募集の求人にてご確認下さい。

https://www.wantedly.com/companies/company_5025838/projects

Discussion