Django 别用 Generic Foreign Key 了! 原因 GenericForeignKey 替代方案

最近学会了GenericForeignKey这个字段,总是喜欢用它,但是用了2天后发觉,反向查询不很方便,也开始担心反向查询的效率问题。查阅资料后发现了这篇博客,写的很好,想翻译一下。

在Django中,GenericForeignKey用于关联系统中任何其它的model,不像ForeignKey那样只可以关联同一个model。

这篇文章是关于为什么你要避免使用GenericForeignKey。我没看到其它的文章解释为什么要避免使用或者提供替换方案,所以我在尝试说明“GenericForeignKey是有害的”。

在开始之前,我认为这里确实有一些合法的情况,在这些情况下我将要强调的内容可能多此一举:
1、generic auditing,在单独的表中追踪数据库的改动。这种情况下,下面的缺点就不重要了,而且可能会转变为优点(比如可以引用被删除的行)。(译者按:例如Django中的LogEntry)
2、generic tagging apps,这种情况下,你没有选择,你连model是什么都不知道,你甚至不知道你可能引用的有多少个不同的model。

但是我认为还是有很多情况不符合上面的情况,但是人们滥用GenericForeignKey:
1、你有一个model,需要用外键关联仅仅一个别的model,而这个model属于固定的几个model之一。
2、你在开发一个通用的app,一个model被指定关联到另一个model,但是你还不知道是哪个model。

本博客重点关注第一个情况,但是我也会简要强调以下第二个。首先,举个例子。

这个例子关于Task,Task 可以被person或group “拥有”(不能同时拥有)。你可能会用 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')

这个例子里,为了简单起见,owner只有两个选项,但是如果有多个选项,那么上面这种写法也适用。

注意!上面的模式是我不推荐的!这是原因:

1、数据库设计
GenericForeignKey导致的数据库架构模式不好。俗话说:“数据成熟如酒,应用程序代码成熟如鱼”。你的数据库会在你的app中存活很久,如果它的结构能解释清楚它的含义是最好的,不需要阅读app的业务代码来理解它的含义。

(如果这听起来不是很有说服力,你可能还是想读这一节——这里解释的东西对博客剩余的部分很重要)

通常,有帮助性的命名数据库表名和列名(Django会产生)、和外键限制(Django也会产生),让数据库变得可以自我解释,但是GenericForeignKey打破了它。

对于上面的例子,这是你数据库的样子(用了SQLite的语法,因为这是我给博客中demo用的数据库)

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");

看看在demo app中,这个表的内容:

idapp_labelmodel
1adminlogentry
2authgroup
3authuser
4authpermission
5contenttypescontenttype
6sessionssession
7gfksgroup
8gfksperson
9gfkstask

将来看到这些数据的人,可能会来猜这是怎么工作的:

gfks_task.owner_type_id指向django_content_type中的一行 (这个限制是清晰的)。

app_labelmodel放在一起,通过添加下划线,我们可以得到表的名字(例如gfks_task.owner_type_id==8,我们需要看gfks_person这张表)

事实上,这不对。为了正确实现它,我们需要看看model。我们需要import gfks.models.Person,然后看它的._meta.db_table的属性。如果显示设定了一个model的Meta.db_table属性,这会很糟糕,意味着为了理解数据库,我们依赖于导入的python代码。(译者按,就是直接看数据库看不懂,想看懂必须去看django代码)

我们有了表的名字,现在我们可以去查询谁的pk匹配owner_id了。

有些显而易见的事情:

1、显然这比ForeignKey的查询更复杂。
上面的机制使得直接写SQL语句来查询更困难——组合的条件变得很糟糕,因为我们必须去用值来计算表的名字。
但是最重要的问题是,数据库结构没有很好的描述你的数据。

2、引用完整性
更重要的问题是引用完整性——显然,你没有。
这也许是最大的也是最重要的问题了。数据库中,数据的一致性和完整性是最重要的,但是用GenericForeignKey你失去了很多,相比于foreign keys。
因为owner_id只是个整数,它可能在那里没有指代任何东西,就是个辣鸡。如果这个地方是人工编辑的,它是可能发生的;或者它之前指代的行被删除了;或者别的各种事情发生了。但是如果你使用foreign key,你的数据库会保护你不受这些事情的影响。

3、性能
GenericForeignKey带来的一个主要问题是性能。
为了得到一个GenericForeignKey引用的对象,我们不得不查询多次:

  1. 先获得主要的对象(例如Task)
  2. 再获得ContentType对象,这是被Task.owner_type指代的(这张表通常被Django缓存)
  3. 从ContentType找到模型,从而得到表的名字
  4. 从3知道表名,从1知道object id,我们能得到最终的对象

这是复杂的、昂贵的流程,相比于一个普通的foreign key,而且它不能优化,尤其是当你准备获得很多的对象的时候。

首先,你不能使用select_related,因为这需要知道什么table被join了。对于prefetch_related,有一些被限制的支持。例如:

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

Django在这个案例中会尝试尽力减少查询次数。但是,如果你想要这么做:

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

然后你会得到异常,因为只有Group才有creator,而Person没有。

4、Django代码
另外,在我的经历中,GFKs(即GenericForeignKeys)的使用将使你的Django代码变得更糟糕,而非更好。可能有人会认为有个单一的Task.owner属性是个很有吸引力的选择。但是它很快会崩溃。

首先,Django中的filter表现会很糟糕。ORM不会创建join给正确的table,把db层面的filter的负担加给了你。

例如,如果你想要得到owner是一个group(它的creator是foo)的tasks,你不能这样做:

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语句,手动join。

第二,一个多态的object很少像他听起来那样工作起来很优秀。在我的经验中,你将经常处理在类型上的分支:

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

这些或许在你的python代码里,或者在你的templates模板里,它看起来一点都不整洁。尤其是当你所指代的模型不在你的控制范围内时,所以很难使他们有相同的接口。

他们设计的一个必然的结果是:GFKs会更难处理,这也反映在他们能得到的Django特性支持上:

删除
默认,如果你删除Group或Person,你代码中所指代的对象不会被更新/删除。admin接口不会通过GFKs追踪,只会留下坏的数据。(译者按:就是指向不存在数据的数据)
然而,你可以加一个GenericRelation给Group或Person,这将修复ORM和admin,从而可以delete。但是注意,这不是默认行为,它在app level(译者按:而非db level)来确保某个数据有正常的引用。

Admin接口
对于GenericForeignKey field,admin将仅仅展示给你一个owner_id和owner_type_id这两个输入框,一个整数<input>,一个<select>下拉框,一点用都没有。当然你能随便输入一个整数,甚至一个坏数据。有一些第三方插件,尝试给出更好的接口,例如:http://stackoverflow.com/questions/13907211/genericforeignkey-and-admin-in-django

正如上面提到的,被通过GFKs引用的对象,默认不会被包含在Django admin删除页面的“collect and display objects for deletion”逻辑。

还有很多别的错误。例如admin的list filter会错误的工作,你不得不写额外的代码来支持他们,而且他们跟ModelForms配合的不好。你将不得不自己写很多东西。

替代方案

希望已经顺服你来寻找另一个解决方案了,让我们来看看一些选项呗。

选项1:在表上设置nullable fields

这也许是最简单的解决方案。 我们为每种可能的所有者类型创建一个所有者字段。 这要求使这些字段可为空,并进行应用程序级别检查,以确保在实践中只有一个不为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)

因此,我们已经恢复了正确的外键以及它们附带的所有优点。 当您访问owner_group和owner_person时,您将需要做None检查,如果您想要某些多态行为,可以像下面这样包装:

@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:用包含nullable fields的中间表

在这里,我们将可为空的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)

这具有一些不错的优点-我们现在有了Owner抽象。 如果要多态使用Task.owner,则可以放置一个逻辑以了解如何区别对待Person和Group,而不必将其放在Person或Group上,如果您不拥有这些模型,这将特别有用 ,或者希望逻辑分开。 我们还有一个地方记录所有可能是“所有者”的东西。

此外,如果您需要使用所有者定义相同的其他东西,您将有一个非常简单的实现-所有者的另一个FK,这比替代方法1更好。

它仍然具有可为空字段的缺点,但是使用专用的Owner模型来处理该问题感觉要干净得多。

与以前的解决方案相比,它还具有其他一些缺点:

我们有一个额外的表,如果需要一次全部获取,则增加了获取全部所需的联接数。
我们将需要确保您要链接到的每个组/个人都有一个所有者记录。 这可能意味着我们在创建小组/人员时或在以后创建一个。 另外,正确设置Task.owner字段将比替代方法1花费更多的工作-这会影响代码和默认管理界面等内容。

选项3:在目标模型上用OneToOneFields指向中间表

备选方案3-目标表上具有OneToOneFields的中间表
这从替代方法2开始,但是将OneToOneFields移动到另一个表,即目标模型。 这样,它们不再需要为空。

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相比,有些注意事项:

我们不再需要担心任何NULL外键。
但是,在创建Person或Group对象时,需要在Owner中创建行。 此外,这些行可能永远都不会使用,例如 组可能永远不会用作所有者。
此模式需要修改人员和组。
对于某些访问模式,这需要更多的查询(例如,如果您从“任务”开始并且想知道您拥有的所有者类型,那么与替代方法2相比,这将需要更多的查询)。

选项4:多表继承

如果您了解Django的多表继承,则可能会认识到可以用更少的代码在Django中创建上述替代3。我们可以使Person和Group从Owner继承,而不是向Owner显式使用OneToOneFields。

实际上,这将创建一个与上面非常相似的数据库架构-Django为您添加了OneToOneField链接。除了列名的差异外,另一个模式差异是owner列也将用作主键(如果需要,也可以对替代项3手动完成,尽管我不建议这样做)。

在代码级别上,它也与替代方案3非常相似,并且实际上大大简化了某些事情,例如您无需手动创建Owner对象。此外,您现在可以免费获得(ish)多态性-由于Person是所有者,因此它继承了其行为。

我个人避免使用多表继承。原因之一是因为我担心Django使用继承机制的复杂性。其次,还有性能方面的问题-明确指定OneToOneFields使我更容易意识到连接和性能问题。第三,Django不支持多重继承,因此您只能使用一次。换句话说,您正在采用一种“是-一种”或“具有-一种”关系(一个组是一个所有者,一个人是一个所有者),并赋予它特殊的地位和实现(具体的模型继承),其他类似的关系也必须通过其他机制来处理。相反,选择2和3可以根据需要多次使用。我对OOP,现实世界中的业务对象以及不断变化的需求的不断体验,使我更好地“降低”所有关系并使用组合而不是继承来实现它们。

为了完整起见,我添加了以下方法,并附有以下代码:

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)

请注意,这是具体的模型继承-您不能对Owner表使用abstract = True

选项5:多个链接模型(multiple linked models)

此解决方案也非常简单,如果您实际上不需要将“链接”模型(在本示例中为Task)作为单个模型/表,则可以应用该解决方案。对于某些用例,使Person具有相关的PersonTask模型和Group具有相关的GroupTask模型可能是完全可以接受(甚至是理想的)。

现在,如果您的模型和表现在完全不同且没有联接表,则可能会出现一些问题。

首先,在某些实例中,您需要显示一个列表,其中包含来自不同模型的合并实例,可能包括排序,过滤和分页。这似乎需要您有一个表。但是,SQL具有UNION查询,而Django通过QuerySet.union支持它们。此外,Simon Willison的精彩文章展示了如何使用它从不同的表中获取对象列表,同时能够在数据库中进行排序,与将它们放在一个表中相比,性能影响相对较小。

其次,在PersonTask和GroupTask之间可能有很多重复的功能。在Django中,这很容易处理。首先,只需将常见内容放入抽象Task模型中:

# 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。 在架构级别,您的两种类型的Task现在是完全分开的,继承仅存在于Python级别。

您可能还有其他代码(实用程序,视图,模板等)需要同时操纵PersonTask和GroupTask实例。 由于鸭子输入的原因,在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模型的子类来进一步增强此模式。然后,对于需要处理PersonTask和GroupTask实例的owner字段的任何通用代码,您都有一个参考点-仅需小心使用仅在Owner上定义的内容。

可交换模型
最后,有时需要链接到GenericForeignKey是诱人的解决方案的单个但未知的模型(例如,在通用的第三方应用程序中)。

对于这种情况,我知道两种方法:

使模型抽象,并要求用户从其继承,自己添加ForeignKey字段。由于其他原因,这可能是有用的模式,但在某些情况下也可能有点笨拙。
使用可交换模型。 Django实际上对此提供了支持,但是在撰写本文时,它仅正式供内部使用(即换出django.auth.contrib.User模型)。但是,Swapper是为它创建一个公共API的非官方尝试,该API似乎维护得很好。在我看来,这似乎比GFK更好。
范例程式码
对于以上所有示例,我创建了一个代码仓库:
https://bitbucket.org/spookylukey/djangoadmintips/src/default/generic_foreign_key_tests/

笔记:

所有示例都是同一项目中的不同应用程序。
它是裸露的骨头–仅出于说明目的。 并非上面提到的所有事情都已实现。
在每种情况下,Task的管理员更改列表都说明了典型的N + 1(或更糟)情况。 在每种情况下,我都实现了ModelAdmin.get_queryset并尽可能地使用了select_related和prefetch_related。 使用Django调试工具栏,您可以看到它有多成功-对于GFK情况,不是很成功。
您还将注意到,管理界面在不同的选择之间有所不同。 有一些方法可以使所有这些都变得更好,但是它们说明了您无需进行大量工作即可获得的收益。
更正或补充
如果还有其他策略或更正,请告知我-我打算保持此页面为最新。

  • 4
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值