🎂

[Django]Fat Modelを避けるための戦略

2021/12/24に公開

はじめに

これは Django Advent Calendar 2021 20日目の記事です(空いていたので飛び入りで)。

DjangoはいわゆるMVCパターンを採用しています。Controllerに相当するところがDjangoではView、Viewに相当するところがDjangoではTemplateなので、MTVパターンとも呼ばれます。

MVCパターンはシンプルですが、そのシンプルさのために、MVCいずれかが太りがちです。Fat View、Fat Controller、Fat Modelのように。

その3つの中ではFat Modelが一番マシですが、それでも太るのは避けたいです。レイヤーを分割する手もありますが、この記事では、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モデルというのがあります。

https://docs.djangoproject.com/en/4.0/topics/db/models/#proxy-models

これはモデルを継承するときに使います。次のように。

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を使うのにちょうどいいと思います。

https://docs.djangoproject.com/en/4.0/howto/custom-model-fields/

モデルからロジックを切り出す

これ以外にもモデルクラスからロジックを切り出したいパターンはあると思います。例えばインスタンスのバリデーションが複雑なため、別ファイルに切り出したいなど。

そのときは、次のようにモデルメソッドを定義し、そこから別のモジュール内のメソッドを呼び出しています。下では @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 を実装すると、テンプレートで使いやすいです。

https://djangobrothers.com/blogs/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 Modelを避けるための手法をいろいろと記載しました。

もちろん、Fat Modelより、Fat View、Fat Controllerの方が問題です。ですが、Fat View、Fat Controllerにしろ Fat Model にしろ、行き先がはっきりしないと混乱するだけです。

そして、Djangoのようなフルスタックフレームワークを使っているなら、まずそのフレームワークに備わっている機能を最大限活かすのが近道だと思い、この記事を書きました。

Discussion