Рубрики
Без рубрики

Избегайте GenericForeignKey от Django

Почему GenericForeignKey от Django (обычно) – плохая идея, и, что более важно, превосходные альтернативы, которые вы можете использовать.

Автор оригинала: Luke Plant.

В Django функция GenericForeignKey позволяет связать модель с любой другой моделью в системе , в отличие от ForeignKey , которая связана с конкретной моделью.

Этот пост о том, почему GenericForeignKey обычно является чем-то, от чего вам следует держаться подальше. Я не видел никаких других статей, описывающих, почему это так, или каковы альтернативы, так что это моя попытка “GenericForeignKey считается вредным”.

Прежде чем я начну, я думаю, что есть некоторые законные случаи, когда большинство проблем, о которых я расскажу ниже, не являются проблемой. В частности, на ум приходит следующее:

  • общий аудит, при котором изменения строк БД отслеживаются в отдельной таблице — в этом случае некоторые из приведенных ниже недостатков не так важны и могут даже быть преимуществами (например, возможность ссылаться на удаленные строки),
  • общие приложения для тегов,
  • другие общие приложения, в которых у вас нет реальной альтернативы, потому что вы действительно не знаете, к каким моделям или даже к скольким различным моделям вы, возможно, захотите обратиться.

Тем не менее, я думаю, что есть много ситуаций, которые не соответствуют вышесказанному, но где люди испытывают соблазн использовать GenericForeignKey :

  • У вас есть случай, когда каждый объект данной модели должен быть связан с одной и только с одной из известного набора других моделей.
  • Вы разрабатываете универсальное приложение, в котором модель предназначена для связи с одной другой моделью, но вы еще не знаете, с какой моделью.

Большая часть этого поста посвящена первой из этих ситуаций, но я также кратко остановлюсь на второй. Во-первых, чтобы было легче говорить о вещах, я представлю пример приложения.

Наш пример приложения обрабатывает “задачи”. Задачи могут “принадлежать” как отдельному лицу, так и группе, но не обоим. Возможно, у вас возникнет соблазн использовать для этого GenericForeignKey , как показано ниже:

class Person(models.Model):
    name = models.CharField()


class Group(models.Model):
    name = models.CharField()
    creator = models.ForeignKey(Person)  # for a later example


class Task(models.Model):
    description = models.CharField(max_length=200)

    # owner_id and owner_type are combined into the GenericForeignKey
    owner_id = models.PositiveIntegerField()
    owner_type = models.ForeignKey(ContentType, on_delete=models.PROTECT)

    # owner will be either a Person or a Group (or perhaps
    # another model we will add later):
    owner = GenericForeignKey('owner_type', 'owner_id')

В этом случае есть только два варианта для владельца , для простоты, но большинство из нижеследующих будет применяться так же хорошо, если их больше двух.

Пожалуйста, будьте ясны — приведенный выше шаблон-это то, что я НЕ рекомендую! И вот почему:

Почему это плохо

Проектирование базы данных

Схема базы данных, полученная в результате использования GenericForeignKey , невелика. Я слышал, как говорили: “данные созревают, как вино, код приложения созревает, как рыба”. Ваша база данных, скорее всего, переживет приложение в его текущем воплощении, поэтому было бы неплохо, если бы она имела смысл сама по себе, без необходимости кода приложения, чтобы понять, о чем идет речь.

(Если это звучит не очень убедительно, вы все равно можете прочитать этот раздел — вещи, объясненные здесь, важны для остальной части этого поста).

В общем, услужливо названные таблицы и столбцы (которые создает Django), а также ограничения внешнего ключа (которые также создает Django) делают базы данных в значительной степени понятными. GenericForeignKey нарушает это.

В приведенном выше примере это то, как выглядит ваша база данных (с использованием синтаксиса SQLite, потому что это то, что я использую для демонстрационного приложения для этого поста):

CREATE TABLE "gfks_task" (
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "description" varchar(200) NOT NULL,
    "owner_id" integer unsigned NOT NULL,
    "owner_type_id" integer NOT NULL REFERENCES "django_content_type" ("id")
);
CREATE INDEX "gfks_task_618598c8"
    ON "gfks_task" ("owner_type_id");

Таким образом, owner_id — это просто целое число — любое целое число-без очевидного способа определить, к чему оно относится. owner_type_id лучше — мы получаем другую таблицу для просмотра. Вот как это выглядит:

CREATE TABLE "django_content_type" (
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "app_label" varchar(100) NOT NULL,
    "model" varchar(100) NOT NULL);
)
CREATE UNIQUE INDEX "django_content_type_app_label_76bd3d3b_uniq"
    ON "django_content_type" ("app_label", "model");

Взгляните на содержимое этой таблицы для моего демонстрационного приложения:

1 вход в систему администратор
2 группа автор
3 пользователь автор
4 разрешение автор
5 тип контента типы контента
6 сессия сессий
7 группа gfks
8 человек gfks
9 задача gfks

С некоторыми хорошими догадками, кто-то в будущем, глядя на данные, может догадаться, как это работает, а именно::

  • gfks_task.owner_type_id ссылается на строку в django_content_type (это ясно из ограничений).

  • Собрав вместе app_label и model из этой строки, мы можем определить имя таблицы , добавив подчеркивания, например, если gfks_task.owner_type_id , нам нужно посмотреть на таблицу gfks_person ;

    (На самом деле это неверно. Чтобы сделать это правильно, нам на самом деле нужно посмотреть на модель, т. Е. Нам нужно импортировать gfx.models.Person , и посмотрите на его ._meta.db_table атрибут. Это довольно неприятная маленькая ошибка, которая поймает вас, если атрибут Meta.db_table был явно установлен для модели, и означает, что у нас довольно уродливая зависимость от возможности импортировать наше приложение Python, чтобы понять смысл базы данных).

  • Теперь у нас есть имя таблицы, в которой мы можем найти запись, PK которой соответствует значению owner_id .

Есть некоторые очевидные вещи, которые стоит прокомментировать:

  • Очевидно, что это намного сложнее, чем просто поиск внешнего ключа в таблице.
  • Описанный выше механизм значительно усложняет написание пользовательского SQL для запроса этих данных — условие соединения стало очень неприятным, потому что само имя таблицы стало значением, которое необходимо вычислить.

Но самая большая проблема, прежде всего, заключается в том, что схема базы данных больше не очень хорошо описывает ваши данные.

Ссылочная целостность

Еще более важной, чем выше, является проблема ссылочной целостности, а именно, у вас ее нет.

Это, пожалуй, самая большая и важная проблема. Согласованность и целостность данных в базе данных имеет первостепенное значение, и с помощью GenericForeignKey вы значительно проигрываете по сравнению с внешними ключами базы данных.

Поскольку owner_id – это просто целое число, в нем может быть мусор, что означает, что он не ссылается на какие-либо реальные данные. Это может произойти, если поле редактируется вручную, или если строка, на которую оно ссылается, удаляется, или если происходят различные другие вещи — вещи, от которых ваша база данных защитит вас, если вы используете обычный внешний ключ.

Представление

Основная проблема с GenericForeignKey – это производительность.

Чтобы получить объект с его общим связанным объектом, мы должны выполнить несколько поисков:

  1. Получите основной объект (например, a Task выше).
  2. Получите объект ContentType , на который указывает Task.owner_type (эта таблица обычно кэшируется Django).
  3. Из объекта ContentType мы можем найти модель и, следовательно, имя таблицы.
  4. Зная имя таблицы из части 3 и идентификатор объекта из части 1, мы можем получить связанный объект.

Это более сложный и дорогостоящий процесс, чем обычный внешний ключ, и он также сопротивляется оптимизации, особенно когда вы получаете пакет объектов.

Для начала, вы не можете использовать select_related , потому что для этого потребуется знать, к какой таблице присоединиться. Для prefetch_related существует некоторая ограниченная поддержка. Например, вы можете сделать:

Task.objects.all().prefetch_related('owner')

Django пытается быть умным в этом случае и максимально сокращает количество запросов. Однако, если, например, вы хотели бы сделать:

Task.objects.all().prefetch_related('owner__creator')

тогда вы получите исключение, потому что только Group имеет атрибут creator , а не Person .

Код Джанго

Кроме того, по моему опыту, использование Gfk, как правило, делает ваш код Django хуже, а не лучше. Может быть заманчиво думать, что наличие одного атрибута Task.owner , который ведет себя полиморфно, является привлекательным вариантом, но вскоре он разрушается.

Во — первых, фильтрация с помощью Django ORM работает плохо-ORM не может создавать соединения с нужной таблицей, перекладывая на вас бремя фильтрации на уровне БД.

Например, если вы хотите получать только задачи, назначенные группам, и фильтровать их дальше самостоятельно, вы не сможете этого сделать:

Task.objects.filter(owner__creator=foo)

Вместо этого вы должны сделать:

group_ct = ContentType.objects.get_for_model(Group)
groups = Group.objects.filter(creator=foo)
tasks = Task.objects.filter(owner_type=group_ct,
                            owner_id__in=groups.values_list('id'))

Есть и другие более эффективные варианты, но вы должны быть готовы испачкать руки, создавая SQL-соединения вручную.

Во-вторых, полиморфный объект редко работает так хорошо, как кажется. По моему опыту, вам очень часто придется ветвиться по типу:

if isinstance(task.owner, Group):
    # do group things
else:
    # do person things

….либо в коде Python, либо в ваших шаблонах, и в этот момент это уже не кажется таким аккуратным. Это особенно важно, когда модели, на которые вы указываете, не находятся под вашим контролем, поэтому сложнее сделать так, чтобы все они имели один и тот же интерфейс.

Необходимое следствие их дизайна означает, что люди просто более неудобны в общении, и это также отражается на уровне поддержки, которую они получают от других функций Django:

Удаление

По умолчанию, если вы удалите Группу или Человека (целевой объект), например, в интерфейсе администратора или из кода, объект, который ссылается на него, не будет обновлен/удален. Интерфейс администратора не будет отслеживать через Genericforeignkey , который может ссылаться на этот объект. Вы просто останетесь с поврежденными данными.

Однако вы можете добавить Общее отношение к Группе и Человеку моделям, что позволит исправить ПЗУ и администратору выполнить удаление. Но обратите внимание, что это не является значением по умолчанию и пытается обеспечить на уровне приложения то, что было бы обеспечено на уровне базы данных для обычного внешнего ключа.

Интерфейс администратора

Для поля GenericForeignKey администратор покажет вам только то, что вы ожидаете для owner_id и owner_type_id — целочисленное поле и раскрывающийся список типов контента, что не очень полезно. И да, вы можете изменить целочисленное значение на что угодно, что приведет к оборванным строкам, т. Е. поврежденным данным. Есть некоторые сторонние попытки получить лучший интерфейс, например, см. http://stackoverflow.com/questions/13907211/genericforeignkey-and-admin-in-django

И, как упоминалось выше, объекты, на которые ссылается Gfk, не включаются (по умолчанию) в логику “сбор и отображение объектов для удаления” на странице удаления администратора Django.

Существуют различные другие ошибки — например, они плохо работают с фильтрами списка администратора, вам придется написать дополнительный код для их поддержки, и они плохо работают с моделями. Вам придется самостоятельно исправлять много вещей на уровне интерфейса.

Альтернативы

Надеюсь, убедив вас найти другое решение, давайте рассмотрим некоторые из доступных вариантов.

Альтернатива 1 – обнуляемые поля в исходной таблице

Это, пожалуй, самое простое решение. Мы создаем поле владелец для каждого типа возможного владельца. Для этого необходимо сделать поля обнуляемыми и выполнить проверку уровня приложения, чтобы убедиться, что на практике у нас есть один-и-только-один не нулевой.

Это выглядит так:

class Task(models.Model):
    owner_group = models.ForeignKey(Group, null=True, blank=True,
                                    on_delete=models.CASCADE)
    owner_person = models.ForeignKey(Person, null=True, blank=True,
                                     on_delete=models.CASCADE)

Таким образом, мы восстановили правильные внешние ключи и все хорошее, что с ними связано. Вам нужно будет сделать None проверки при доступе к owner_group и owner_person , которые вы могли бы обернуть так, если бы вам нужно было какое-то полиморфное поведение:

@property
def owner(self):
    if self.owner_group_id is not None:
        return self.owner_group
    if self.owner_person_id is not None:
        return self.owner_person
    raise AssertionError("Neither 'owner_group' nor 'owner_person' is set")

Точно так же вам также нужно будет убедиться, что при сохранении задано одно и только одно из двух полей.

Это имеет тот недостаток, что на уровне схемы, если вы не добавите контрольное ограничение , существует возможность того, что Владелец указывает как на Человека , так и на Группу , что не имеет смысла. Но это намного меньше, чем проблемы, которые у вас есть с GenericForeignKey .

Альтернатива 2 – промежуточная таблица с полями, допускающими обнуление

Здесь мы перемещаем обнуляемые FK в новую таблицу, где они превращаются в поля один к одному, и создаем ненулевой FK в первой таблице. Это выглядит так:

class Owner(models.Model):
    group = models.OneToOneField(Group, null=True, blank=True,
                                 on_delete=models.CASCADE)
    person = models.OneToOneField(Person, null=True, blank=True,
                                 on_delete=models.CASCADE)


class Task(models.Model):
    owner = models.ForeignKey(Owner, on_delete=models.CASCADE)

Это имеет некоторые приятные преимущества — теперь у нас есть Владелец абстракция. Если вы хотите использовать Task.owner полиморфно, у вас есть место для размещения логики, которая понимает , как обращаться с Person и Group по-разному, без необходимости включать ее в Person или Group , что особенно полезно, если вы не владеете этими моделями или хотите, чтобы логика была разделена. У нас также есть одно место, которое документирует все вещи, которые могут быть “владельцами”.

Кроме того, если вам понадобятся другие вещи , которые используют то же определение Owner , у вас будет очень простая реализация — просто еще один FK для Owner , что намного лучше, чем для альтернативы 1.

У него все еще есть недостатки обнуляемых полей, но наличие выделенной Владельца модели для решения этой проблемы кажется намного чище.

Он также имеет несколько других недостатков по сравнению с предыдущим решением:

  • У нас есть дополнительная таблица, увеличивающая количество соединений, необходимых для получения всего, если нам нужно все сразу.
  • Нам нужно будет убедиться, что запись Owner существует для каждой группы/человека, с которым вы хотите связать. Это может означать создание одного из них, когда мы создаем группу/человека или позже. Кроме того, правильная настройка поля Task.owner потребует больше работы, чем в альтернативе 1 — это влияет как на код, так и на такие вещи, как интерфейс администратора по умолчанию.

Альтернатива 3 – промежуточная таблица с полем Onetoonefield на моделях назначения

Это начинается с альтернативы 2, но перемещает Onetoonefield в другую таблицу, т. Е. в целевые модели. Таким образом, они больше не должны быть аннулированы.

class Owner(models.Model):
    pass


class Person(models.Model):
    name = models.CharField()
    owner = models.OneToOneField(Owner, on_delete=models.CASCADE)


class Group(models.Model):
    name = models.CharField()
    owner = models.OneToOneField(Owner, on_delete=models.CASCADE)
    creator = models.ForeignKey(Person)


class Task(models.Model):
    description = models.CharField(max_length=200)
    owner = models.ForeignKey(Owner, on_delete=models.CASCADE)

Некоторые примечания, по сравнению с альтернативой 2:

  • У нас больше нет никаких пустых внешних ключей, о которых нужно беспокоиться.
  • Однако мы обязаны создавать строки в Owner при создании Person или Group объектов. Кроме того, эти строки могут никогда не использоваться, например, группа никогда не может использоваться в качестве Владельца .
  • Этот шаблон требует изменения Person и Group .
  • Для некоторых шаблонов доступа это требует большего количества запросов (например, если вы начинаете с Задачи и хотите знать, какой тип Собственности у вас есть, для этого потребуется больше запросов, чем вариант 2).

Альтернатива 4 – наследование нескольких таблиц

Если вы знаете о многотабличном наследовании Django , вы можете признать, что альтернатива 3 выше может быть создана в Django с меньшим количеством кода. Вместо явного Onetoonefield для Владельца мы можем сделать Person и Group наследуемыми от Владельца .

Это фактически создаст очень похожую схему базы данных, как описано выше – Django добавляет для вас ссылки OneToOneField . Помимо различий в именах столбцов, одно дополнительное отличие схемы заключается в том, что столбец owner также будет использоваться в качестве первичного ключа (что также можно сделать вручную для альтернативы 3, если вы хотите, хотя я бы не рекомендовал это делать).

На уровне кода он также очень похож на альтернативу 3 и фактически значительно упрощает некоторые вещи, например, вам не нужно вручную создавать объекты Owner . Кроме того, теперь вы получаете полиморфизм бесплатно (ish) — поскольку Person is-a Owner , он наследует свое поведение.

Лично я избегаю использования наследования нескольких таблиц. Одна из причин этого заключается в том, что я беспокоюсь о сложности механизма наследования, используемого Django. Во — вторых, есть проблемы с производительностью-наличие OneToOneFields явно облегчает мне понимание соединений и проблем с производительностью. В-третьих, Django не поддерживает множественное наследование, поэтому вы можете использовать его только один раз. Другими словами, вы берете одно отношение “есть” или “имеет” (группа является Владельцем, а Человек-Владельцем) и придаете ему особый статус и реализацию (наследование конкретной модели), в то время как все другие подобные отношения должны рассматриваться как другие механизмы. Напротив, альтернативы 2 и 3 можно использовать столько раз, сколько вы захотите. Мой опыт работы с ООП, бизнес-объектами реального мира и постоянно меняющейся реальностью постоянно меняющихся требований заключается в том, что вам лучше “понизить” все отношения и реализовать их все с использованием композиции, а не наследования.

Однако для полноты картины я добавил эту альтернативу с кодом, описанным ниже:

class Owner(models.Model):
    pass


class Person(Owner):
    name = models.CharField()


class Group(Owner):
    name = models.CharField()
    creator = models.ForeignKey(Person)


class Task(models.Model):
    description = models.CharField(max_length=200)
    owner = models.ForeignKey(Owner, on_delete=models.CASCADE)

Обратите внимание, что это конкретное наследование модели — вы не можете использовать abstract для таблицы Owner (спасибо Airith ).

Альтернатива 5 – несколько связанных моделей

Это решение также очень простое и будет применяться, если вам на самом деле не нужна “связанная” модель ( Задача в нашем примере), чтобы быть одной моделью/таблицей. Для некоторых случаев использования может быть вполне приемлемо (или даже желательно) иметь Person со связанной Person задачей моделью и Group со связанной GroupTask моделью.

Теперь есть несколько проблем, которые могут возникнуть, если ваши модели и таблицы теперь полностью различны без объединяющей таблицы.

Во-первых, могут быть некоторые случаи, когда вам нужно показать список, содержащий экземпляры из разных моделей, возможно, включая сортировку, фильтрацию и разбиение на страницы. Может показаться, что для этого вам потребуется один стол. Однако SQL имеет запросы UNION , а Django поддерживает их через QuerySet.union . Кроме того, у Саймона Уиллисона есть хорошая статья, показывающая , как вы можете использовать это для получения списков объектов из разных таблиц, при этом имея возможность выполнять сортировку в базе данных , с относительно небольшим снижением производительности по сравнению с их размещением в одной таблице.

Во-вторых, у вас может быть много дублированных функций между Личной задачей и Групповой задачей . В Джанго с этим легко справиться. Для начала просто вытащите общие вещи в абстрактную |/задачу модель:

# Person and Group as in our initial code

class Task(models.Model):
    description = models.CharField(max_length=200)

    class Meta:
        abstract = True


class PersonTask(Task):
    owner = models.ForeignKey(Person)


class GroupTask(Task):
    owner = models.ForeignKey(Group)

Теперь вы можете поместить любые общие поля и функции в Task . На уровне схемы ваши два типа Задачи теперь полностью разделены, наследование существует только на уровне Python.

У вас может быть другой код (утилиты, представления, шаблоны и т. Д.), Который должен манипулировать как экземплярами Personal , так и экземплярами Group Task . Из-за утиной типизации в Python это не должно быть проблемой, если эти процедуры действительно универсальны и используют только то, что верно для всех экземпляров Task . Вы всегда можете сделать isinstance проверки, чтобы узнать, какой у вас есть, если это необходимо.

Помните также, что Python имеет классы первого класса, поэтому вы можете определить функции, которые принимают классы в качестве аргументов, где класс может быть моделью. Например:

def get_happy_tasks(model):
    return model.objects.filter(description__contains="☺")

happy_person_tasks = get_happy_tasks(PersonTask)

Подобные шаблоны можно использовать для уменьшения дублирования, которое в противном случае могло бы вас напугать, учитывая, что у вас больше моделей с этой техникой.

Вы можете еще больше улучшить этот шаблон, создав Person и Group подклассы абстрактной Owner модели. Затем у вас есть точка отсчета для любого общего кода, который должен обрабатывать поле owner обоих экземпляров PersonTask и GroupTask — ему просто нужно быть осторожным, чтобы использовать только вещи, определенные в Owner .

Сменные модели

Наконец, есть случай необходимости ссылки на единственную, но неизвестную модель (например, в универсальном стороннем приложении), для которой GenericForeignKey является заманчивым решением.

Для этого случая я знаю два подхода:

  1. Сделайте свою модель абстрактной и потребуйте, чтобы пользователи наследовали от нее, добавив поле ForeignKey сами. Это может быть полезным шаблоном по другим причинам, но в некоторых случаях может быть немного громоздким.
  2. Используйте сменные модели. На самом деле Django поддерживает это, но на момент написания статьи он официально предназначен только для внутреннего использования (т. Е. Для замены django.auth.contrib.Пользователь модель). Однако Swapper – это неофициальная попытка создать для него общедоступный API, который, похоже, хорошо поддерживается. По-моему, это лучший вариант, чем GFK.

Пример кода

Для всех приведенных выше примеров я создал репо: https://bitbucket.org/spookylukey/djangoadmintips/src/default/generic_foreign_key_tests/

Записи:

  • Все примеры – это разные приложения в рамках одного проекта.
  • Это голые кости — просто для иллюстрации. Не все вещи, упомянутые выше, реализованы.
  • В каждом случае список изменений администратора для Задачи иллюстрирует типичную ситуацию N+1 (или хуже). В каждом случае я реализовал ModelAdmin.get_queryset и использовал select_related и prefetch_related как можно лучше. Используя панель инструментов отладки Django, вы можете увидеть, насколько это успешно — для случая GFK, не очень.
  • Вы также заметите, что интерфейсы администратора различаются между различными альтернативами. Будут способы сделать их все лучше, но они иллюстрируют то, что вы получите без особого труда.

Исправления или дополнения

Если есть другие стратегии или исправления, пожалуйста, дайте мне знать — я намерен поддерживать эту страницу в актуальном состоянии в качестве ссылки.

Обновления

2018-10-19 – Добавлена альтернатива 5