[Django]Fat ActiveRecordを避けるための戦略
はじめに
これは Django Advent Calendar 2021 20日目の記事です(空いていたので飛び入りで)。
DjangoはいわゆるMVCパターンを採用しています。Controllerに相当するところがDjangoではView、Viewに相当するところがDjangoではTemplateなので、MTVパターンとも呼ばれます。
MVCパターンはシンプルですが、そのシンプルさのために、MVCいずれかが太りがちです。Fat View、Fat Controller、Fat ActiveRecord(Model)のように。
その3つの中ではFat ActiveRecordが一番マシですが、それでも太るのは避けたいです。レイヤーを分割する手もありますが、この記事では、Djangoの機能を生かして分割する方法を記載します。
ファイルを分割する
兎にも角にも必要なのは、ファイル分割です。1ファイルに多くのものを詰め込むことで、見通しが悪くなります。とは言え、何でもかんでも分割すればいいものではありません。自分はまず、アプリケーションの内部でのモデルクラスの分割を推奨します。
アプリケーションを作った場合、次のようなディレクトリ構成になっています。
- [アプリケーション名]/
-
migrations/
__init.py__
admin.py
apps.py
models.py
tests.py
views.py
-
これを次のようにします。
- [アプリケーション名]/
- [子アプリケーション名]/
models.py
views.py
tests.py
-
migrations/
__init.py__
admin.py
apps.py
models.py
urls.py
- [子アプリケーション名]/
「子アプリケーション名」は自分が勝手につけた単語ですが、アプリケーションを分割した単位です。そしてしばしば、モデルクラス1つに対応することが多いです。その場合、 [子アプリケーション名]/models.py
にはモデルクラスを1つ配置します。
そして、 [アプリケーション名]/models.py
は次のように記載します。ShoppingStatus は IntegerChoices を継承したクラスで、その他は全てモデルクラスです。
from apps.life.book.models import Book
from apps.life.diary.models import Diary
from apps.life.event.models import Event
from apps.life.fortune.models import Fortune
from apps.life.shopping.models import ShoppingItem, ShoppingStatus
ALL_MODELS = (
Book,
Diary,
Event,
Fortune,
ShoppingItem,
)
__all__ = [
*ALL_MODELS,
ShoppingStatus,
]
__all__
と ALL_MODELS
を分けているのは、 ALL_MODELS
はDjango管理サイトなど、モデルクラスのみ取得できる方法があったら便利だからです。
そして、 urls.py
を次のように定義します。もちろん views.py
も分割されます。このように分割することで見通しが良くなります。
from django.urls import include, path
from apps.life import views
app_name = "life"
urlpatterns = [
path("book/", include("apps.life.book.urls")),
path("diary/", include("apps.life.diary.urls")),
path("event/", include("apps.life.event.urls")),
path("fortune/", include("apps.life.fortune.urls")),
path("shopping/", include("apps.life.shopping.urls")),
]
カスタムManager、カスタムQuerySetを作る
次に必要なのは、カスタム Manager 、カスタム QuerySet です。
Manager は例えば Device.objects.get(pk=1)
と書いたときの objects
です。 Django では自動的に Manager が作られるようになっています。これに対して .get()
や .filter()
は QuerySet のメソッドです。正確にはほとんどの QuerySet のメソッドは Manager から呼べるようになっていますが。
そして Manager と QuerySet は独自で定義することも可能です。後から定義するのは面倒なので、むしろ、モデル作成時に定義しておくといいでしょう。具体的には次のように記載します。
QuerySet では低レベルなAPIを提供し、Manager は参照だけでなく生成を含めたより高レベルなAPIを提供するといいでしょう。
from django.db import models
from django.db.models import CharField, ForeignKey
class DeviceQuerySet(models.QuerySet):
def __init__(self, model=None, query=None, using=None, hints=None):
super().__init__(model, query, using, hints)
class DeviceManager(models.Manager):
def get_queryset(self):
return DeviceQuerySet(self.model, using=self._db)
class Device(models.Model):
name = CharField(max_length=100, verbose_name="デバイス名")
os = ForeignKey(OperatingSystem, on_delete=models.PROTECT, verbose_name="OS", related_name="devices")
version = CharField(max_length=100, verbose_name="バージョン")
objects = DeviceManager()
class Meta:
verbose_name = verbose_name_plural = "デバイス"
Proxyモデル
DjangoにはProxyモデルというのがあります。
これはモデルを継承するときに使います。次のように。
class AppleDevice(Device):
class Meta:
proxy = True
この proxy = True
をつけることで、テーブルを増やさずに継承をすることができます。
これが便利なのは、「単一テーブル継承」を使用する場合です。特定のフィールドの値に応じて処理を変えたい場合、そのフィールドに応じた Proxy モデルを使うと便利です。
proxy = True
をつけないと、「クラステーブル継承」になります。「クラステーブル継承」の方がテーブルの設計としては綺麗ですが、データ移行が必要になったときに面倒なデメリットもあるので悩ましいところです。
カスタムField
Djangoのモデルには CharField, IntegerField などの Field が用意されています。これを独自定義することができます。例えば自分は次のように備考欄に使うための NoteField
を定義しています。
class NoteField(CharField):
def __init__(self, *args, **kwargs):
kwargs.setdefault("max_length", 200)
kwargs.setdefault("verbose_name", "備考")
kwargs.setdefault("blank", True)
kwargs.setdefault("null", False)
kwargs.setdefault("default", "")
super().__init__(**kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
return name, path, args, kwargs
def formfield(self, **kwargs):
# 指定がないときのデフォルトはテキストエリア
if "widget" not in kwargs:
kwargs["widget"] = forms.Textarea
return super().formfield(**kwargs)
カスタムFieldは from_db_value
および to_python
を定義することで、任意のPythonオブジェクトを返すことができます。Value Objectを使うのにちょうどいいと思います。
モデルからロジックを切り出す
これ以外にもモデルクラスからロジックを切り出したいパターンはあると思います。例えばインスタンスのバリデーションが複雑なため、別ファイルに切り出したいなど。
そのときは、次のようにモデルメソッドを定義し、そこから別のモジュール内のメソッドを呼び出しています。下では @staticmethod
を定義していますが、もちろんインスタンスメソッドで構いません。また、メソッドの内部で import を呼び出していますが、多くの場合相互参照になってしまうのが理由です。
class Device(models.Model):
@staticmethod
def get_inconsistencies() -> List[Inconsistency]:
from apps.core.device.inconsistencies import devic_inconsistencies
return devic_inconsistencies()
モデルクラスに持たせることのメリットは、Djangoのテンプレートから呼び出しやすくするためです。Djangoのテンプレートでは任意のメソッド呼び出しができませんが、 device.get_inconsistencies
のように、インスタンスメソッドを呼び出すことはできます。そのため、テンプレートから呼び出したい場合は、モデルクラスに持たせておくといいでしょう。
モデルに持たせた方がいいメソッド
このようにいろいろな手法を活用すると、モデルクラスが肥大化することは避けられます。逆に、次のようなメソッドはモデルクラスに持たせた方がシンプルになります。
URLを取得するメソッド
例えば get_absolute_url
を実装すると、テンプレートで使いやすいです。
自分は get_absolute_url()
の他に、次のメソッドを定義しています。いわゆるCRUDLに対応するメソッドですが、他にも get_copy_url()
でインスタンスを複製する画面を提供するなど、いろいろ応用が可能です。
get_list_url()
get_add_url()
get_edit_url()
get_delete_url()
__str__
や、自分のフィールドのみ参照する短いメソッド
自分のフィールドのみ参照する短いメソッドもモデルクラスの中に入れるので十分です。もし長くなるなら「モデルからロジックを切り出す」のようにしてください。
ただ、ForeignKeyの先の内容を参照すると、N+1問題がおきてパフォーマンスに影響が出る可能性があることに注意してください。
おわりに
DjangoでFat ActiveRecordを避けるための手法をいろいろと記載しました。
もちろん、Fat ActiveRecordより、Fat View、Fat Controllerの方が問題です。ですが、Fat View、Fat Controllerにしろ Fat ActiveRecord にしろ、行き先がはっきりしないと混乱するだけです。
そして、Djangoのようなフルスタックフレームワークを使っているなら、まずそのフレームワークに備わっている機能を最大限活かすのが近道だと思い、この記事を書きました。
Discussion