DjangoのORMについて実装を読んで理解を深めたい
こんにちは。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()
などと気軽に Model
や Manager
を呼び出していますが、この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
ということで、Model
は ModelBase
メタクラスによって作成されます。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ドキュメント)を通じて、ModelBase
は Model
クラスを管理し、その生成プロセスに介入して属性やメソッドを追加する役割を持ちます。
この処理の後半で、_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の実装を覗いてみる
では次に、Model
に objects
属性として与えられた Manager
とは何者なのか?その押さえどころを見て行きたいと思います。
Manager
クラスの定義はdjango/db/models/manager.py#176 にあります。
class Manager(BaseManager.from_queryset(QuerySet)):
pass
なるほど。Managerクラスに対する具体的な実装は無く、継承元として BaseManager.from_queryset(Queryset)
が指定されているだけですね。 Model
が ModelBase
メタクラスによって生成されるのと同じような匂いがします。
この継承関係をかみ砕いてみると、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__
って結局何になるんだっけ?と流れを見失いがちになりますね。この cls
は Manager
が割り当てられることになった 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()
内にあるこの処理でしたね。Model
と Manager
が繋がりました。
User
というモデルが定義され、ロードされる時に、このようなクラス生成が背後で行われた結果、
-
User
というクラスはModel
の機能を持ち -
Model
はobjects
という属性にManager
クラスが設定され -
Manager
クラスはBaseManager
メタクラスによって、QuerySet
の機能が付与されている
という状況が作り出されていることが分かりました。
User.objects.filter()
の式を機能させるために、Djangoが水面下で行っている仕事が理解できた気がします。
本日はDjangoのORMに関する基本的な使い方、その根底を支える Model
と Manager
に焦点を当てて実装を見てみました。次回は QuerySet
の内部実装やN+1対策となる prefetch
の仕組みへと、引き続きDjangoのORMへの理解を深めて行きたいと思います。
おわりに
日常的に使っている(お世話になっている)フレームワークやミドルウェアのソースコードが公開されている場合、その内部実装をこまめに確認しておく習慣をつけておく方が良いと思います。
単にツールに関する知識が増えるだけでなく、世界中の優れたエンジニアたちの叡智の結晶となっているコードベースに触れることで、プログラミング言語仕様の理解や、プログラム設計、コーディングスキル(Read & Write)の向上など、開発業務を行う上であらゆる方面で学べることが多いからです。
さらに昨今では、ChatGPTのようなLLMとの対話を通じ、よりスピーディに学習を補助してもらうことも可能です。学習の初動として「~というフレームワークのxxxという機能について、概要を理解できるように解説し、参照すべき実装箇所を教えて下さい」というようなやり取りからスタートすると、膨大な量のソースコードが記述されているフレームワークであっても、効率的に要点を押さえた学習を進めることができるのではないでしょうか。
本日は、お読みいただき誠にありがとうございました。
株式会社天地人では、人工衛星などの宇宙ビッグデータを活用し、地球規模の課題に取り組むためのオンラインGISプラットフォーム天地人コンパス(Tenchijin COMPASS)を開発しています。
私たちと一緒に天地人コンパスを開発してくれる仲間を募集しております。ご興味のある方は以下のページよりエンジニアリングの募集の求人にてご確認下さい。
Discussion