В 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) # пригодится в дальнейшем
class Task(models.Model):
description = models.CharField(max_length=200)
# owner_id и owner_type скомбинированы в GFK
owner_id = models.PositiveIntegerField()
owner_type = models.ForeignKey(ContentType, on_delete=models.PROTECT)
# owner будет либо Person, либо Group (возможно другую модель мы добавим позже)
owner = GenericForeignKey(‘owner_type’, ‘owner_id’)
В этом случае, для владельца задачи есть два варианта, это для простоты, большая часть дальнейшего материала не ограничена этими вариантами.
Пожалуйста, обратите внимание на то, что вышеприведённый пример является тем, что я крайне не рекомендую делать! И вот почему:
Почему это плохо
Дизайн базы данных
При использовании GenericForeignKey
схема базы данных неидеальна. Я слышал высказывания вида: «данные зреют как вино, а код приложения как рыба». Ваша БД, вероятно, переживёт приложение в своём текущем воплощении, таким образом, было бы неплохо, чтобы для понимания структуры данных не требовалось изучать исходный код приложения.
(Если это звучит не очень убедительно и вы всё ещё желаете дочитать этот раздел, вещи, объясняемые здесь, важны для понимания остальной части статьи).
В общем, правильные названия таблиц и столбцов (которые создаёт Django), внешних ключей (которые также создаёт Django) делают базу данных самодокументируемой. GenericForeignKey
ломают этот механизм.
Для вышеприведённого примера ваша БД будет выглядеть так (используем SQLite синтаксис, так как я его использую в демонстрационном приложении):
CREATE TABLE "gfks_task" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"description" archer(200) NOT NULL,
"owner_id" integer unsigned NOT NULL,
"owner_type_id" integer NOT NULL REFERENCES "django_content_type"
);
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");
Посмотрим на содержимое последней таблицы для моего демонстрационного приложения:
id app_label model
1 admin logentry
2 auth group
3 auth user
4 auth permission
5 contenttypes contenttype
6 sessions session
7 gfks group
8 gfks person
9 gfks task
Рассмотрим, как в недалёком будущем некто, смотря в БД, пытается разобраться в логике работы:
- Поле
gfks_task.owner_type_id
ссылается на запись вdjango_content_type
(это очевидно из ограничения целостности). - Объединяя вместе
app_label
иmodel
из этой записи через подчёркивание, мы можем получить имя таблицы, т.е. еслиgfks_task.owner_type_id == 8
, то требуется заглянуть в таблицуgfks_person
; (На самом деле это не так. Чтобы сделать всё правильно, мы на самом деле должны посмотреть в модель, т.е. надо импортироватьgfks.models.Person
и посмотреть в её атрибут._meta.db_table
. Это довольно противный маленький глюк, на который вы будете налетать, если этот атрибут был явно установлен для модели. Это означает, что у нас есть нехорошая зависимость от необходимости импортировать наше приложение, чтобы понять схему базы данных). - Теперь у нас есть имя таблицы, в которой мы должна поискать запись по имеющемуся первичному ключу в
owner_id
.
Есть несколько очевидных вещей, которые стоит прокомментировать:
- Ясно, что это гораздо более сложный метод, чем простой поиск по внешнему ключу.
- Показанный выше механизм усложняет написание собственных SQL запросов, условие объединения запросов становится некрасивым, потому что само имя таблицы становится значением, которое требуется вычислять.
Но самая большая проблема заключается в том, что схема базы данных больше не описывает ваши данные.
Ссылочная целостность
Более важной проблемой является ссылочная целостность, а именно, её отсутствие.
Возможно, это самая большая и наиболее важная проблема. Непротиворечивость и целостность данных в БД имеют самое важное значение, но используя GenericForeignKey
, вы многое теряете по сравнению с использованием внешних ключей.
Из-за того, что owner_id
является просто целым числом, значит может вообще не указывать на реальные данные. Такое может произойти при ручной правке поля или если поле ссылается на удалённую запись или ещё чего произошло. В общем то, от чего вас защищает сама БД, если вы используете обычные внешние ключи.
Производительность
Большой проблемой GenericForeignKey
является производительность.
Для того, чтобы получить объект мы должны сделать множество запросов:
- Получить основной объект (в нашем случае,
Task
). - Получить объект
ContentType
, на который указываетTask.owner_type
(эта таблица обычно кэшируется Django). - Зная имя таблицы и идентификатор объекта из первого пункта, мы можем получить связанный объект.
Этот процесс является более сложным и затратным по сравнению с использованием обычных внешних ключей. Он также неудобен в плане оптимизации, особенно при работе с пачкой объектов.
Для начала, вы не можете использовать select_related
, так как это потребует знания с какой таблицей следует объединяться. Использование prefetch_related
также имеет некоторые ограничения. Например, вы можете делать:
Task.objects.all().prefetch_related(‘owner’)
Django пытается быть умной в данном случае и уменьшит количество запросов к БД насколько это возможно. Однако, если вам потребуется сделать это:
Task.objects.all().prefetch_related(‘owner__creator’)
тогда вы получите исключение, потому что только Group
имеет атрибут creator
, у Person
такого атрибута нет.
Код Django
В дополнение к вышеприведённому, из моего опыта, использование GenericForeignKey
в общем случае ухудшает ваш 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 instance(task.owner, Group):
# работаем с группой
else:
# работаем с персоной
… либо в коде на Python, или в ваших шаблонах, что уже нельзя назвать приятным. Особенно это проявляется в случаях, когда требуется связываться с моделями, которые находятся вне вашего контроля, поэтому достаточно непросто организовать им единый интерфейс.
Следствием дизайна GenericForeignKey
является неудобство их использования, что также отражается на уровне поддержки, который им обеспечивается со стороны других компонентов Django:
Удаление
По умолчанию, если вы удаляете Group
или Person
(целевой объект), не имеет значения через интерфейс администратора или из кода, связанный объект не будет удалён или изменён. Интерфейс администратора не отслеживает связи через GenericForeignKey
, которые могут вести на удаляемый объект. Вас просто оставят со сломанными данными.
Однако, вы можете добавить обощённые связи к моделям Group
и Person
, которые починят ORM и интерфейс администратора в части удаления. Но следует отметить, что это не делается по умолчанию и выполняется попытка выполнить действие на уровне приложения, что на самом деле могло бы выполняться на уровне БД при использовании внешних ключей.
Интерфейс администратора
Для GenericForeignKey
поля, интерфейс администратора покажет вам только то, что вы могли бы ожидать для полей owner_id
и owner_type_id
- поле для целого числа и выпадашку для выбора типа контента, что не очень то и полезно. Естественно, вы можете свободно менять значение целого числа, имея возможность испортить данные. Существует несколько сторонних попыток получить более лучший интерфейс, см. http://stackoverflow.com/questions/13907211/genericforeignkey-and-admin-in-django
Как упоминалось ранее, объекты, связанные через GenericForeignKey
(по умолчанию) не включаются в логику «найти и показать объекты для удаления» страницы удаления интерфейса администратора Django.
Так же есть ряд других фишек, GenericForeignKey
плохо работают с фильтрами списков на интерфейсе администратора, вам потребуется писать дополнительный код для поддержки фильтрации, и они работают не очень хорошо с модельными формами. Вам потребуется патчить достаточное количество кода интерфейса администратора.
Альтернативы
Надеюсь, я был достаточно убедителен в необходимости поиска другого решения, давайте рассмотрим некоторые доступные решения.
Альтернатива 1 - NULL поля в исходной таблице
Модель выглядит так:
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»)
Аналогично вы также можете проверять, что одно и только одно из двух полей было заполнено при сохранении.
Такой подход имеет недостатки на уровне схемы, пока вы не добавите дополнительное ограничение, так как существует возможность того, что Owner
указывает на Person
и Group
одновременно, что не соответствует задаче. Но это несравнимо с проблемами GenericForeignKey
.
Альтернатива 2 - промежуточная таблица с NULL полями
В данном примере мы перемещаем пустые внешние ключи в отдельную таблицу, где они становятся O2O полями и создаём непустой внешний ключ в основной таблице. Например:
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)
Такой подход имеет ряд достоинств, мы сделали Owner
абстракцией. При необходимости полиморфного использования Task.owner
, у вас есть место для размещения логики, которая будет знать как следует различать Person
и Group
, не добавляя эту логику в указанные модели, что особенно полезно в случае когда вы не владеете этими моделями или когда вам надо хранить логику отдельно. У нас также появляется единственное место, которое документирует все сущности, которые могут стать «владельцами».
Далее, если вам потребуются другие сущности, которые будут использовать то же определение Owner
, это будет несложно реализовать, просто добавляем ещё один внешний ключ в Owner
, что гораздо выгоднее по сравнению с первым подходом.
Данный поход всё ещё имеет недостатки в виде пустых внешних ключей, но наличие модели Owner
, которая работает с ними, делает код чище.
Также существует ряд других недостатков, по сравнению с предыдущим решением:
- У нас появляется дополнительная таблица, увеличивая количество объединений, когда нам требуется получить все данные одним запросом.
- Нам требуется проверять, что запись в
Owner
существует для каждой группы/персоны, с которыми требуется выполнить связь. Это требует создания такой записи при создании группы/персоны, или позже. Также, поиск правильного значения для поляTask.owner
требует больше действий по сравнению с предыдущим решением, на уровне кода и таких сущностей как интерфейс администратора.
Альтернатива 3 - промежуточная таблица с O2O полями на целевые модели
Берём предыдущее решение и выносим O2O поля в отдельную таблицу, т.е.в целевые модели. Такой подход позволяет избавиться от пустых полей.
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.OnerToOneField(Owner, on_delete=models.CADCADE)
creator = models.ForeignKey(Person)
class Task(models.Model):
description = models.CharField(max_length=200)
owner = models.ForeignKey(Owner, on_delete=models.CASCADE)
Отметим различия с предыдущим подходом:
- У нас больше нет внешних ключей с
NULL
значениями. - Однако, нам по прежнему требуется создавать записи в
Owner
при созданииGroup
илиPerson
. В дополнение, эти записи могут быть никогда не использованы, т.е. группа может никогда не использоваться в как владелец. - Данный подход требует внесения изменений в модели
Person
иGroup
. - Для некоторых случаев использования приходится делать больше запросов (например, при запуске задачи и необходимости узнать тип её владельца, потребуется выполнить больше запросов по сравнению с предыдущим подходом).
Альтернатива 4 - наследование нескольких таблиц
Если вы знаете о наследовании таблиц, вы можете увидеть, что предыдущий подход мог быть создан с использованием меньшего объёма кода. Вместо явного назначения O2O на Owner
мы могли унаследовать Person
и Group
от модели Owner
.
Такое решение приведёт к созданию схемы БД подобной предыдущему варианту - Django добавить O2O связи самостоятельно. Несмотря на различие в именах полей, следует отметить то, что поле owner также будет использовано в качестве первичного ключа (что можно было сделать вручную в предыдущем подходе, при необходимости).
На уровне кода, всё очень похоже на предыдущий подход, хотя и проще, так как вам не требуется вручную создавать записи в Owner
. В дополнение, теперь вы получаете полиморфизм бесплатно, так как Person
унаследован от Owner
, он также наследует и его поведение.
Лично я не рекомендую использовать наследование таблиц. Одной причиной этого является моё беспокойство насчёт сложности механизма наследования, используемого в Django, другой причиной являются проблемы с производительностью - наличие O2O связей явно указывает на необходимость объединений, что влияет на производительность (имхо, автор бредит, прим. переводчика). И, наконец, Django не поддерживает множественное наследование, в то время, как предыдущий подход можно использовать столько раз, сколько нужно. Тем не менее, для полноты картины, я добавил этот подход в статью:
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 = True
в модели `Owner.
Заменяемые модели
Наконец, иногда требуется реализовать связь с единственной, но пока неизвестной моделью (например, в общем стороннем приложении), для чего GenericForeignKey
кажется предпочтительным выбором.
В этом случае можно использовать два подхода:
- Сделать свою модель абстрактной и требовать пользователей, чтобы они наследовали своим модели от неё, добавляя поле внешнего ключа. Для некоторых случаев это может быть полезным решением, но в других может стать очень неповоротливым.
- Использовать заменяемые модели. Django обеспечивает соответствующую поддержку для этого, но на момент написания оригинальной статьи, она была рекомендована только для внутреннего использования (т.е. для замены модели
django.auth.contrib.User
). Однако, приложение Swapper является неофициальной попыткой создания публичного API для этого функционала, которая выглядит вполне поддерживаемой. Для меня это выглядит лучше, чем использованиеGenericForeignKey
.