为Django模型生成非顺序ID/PK

33

我即将开始开发一个新的Web应用程序。其中一部分将为用户提供可自定义的网页,这些网页需要具有唯一的URL。

如果按照Django默认的方式,它会给模型分配一个标准的AUTOINCREMENTID。虽然这样工作得非常好,但它看起来不那么美观,而且也使网页变得非常可预测(在这种情况下是不希望的)。

与其使用1、2、3、4这样的数字编号,我想要设置固定长度的随机生成的字母数字字符串(例如h2esj4)。由36个字符组成的6位字符串可以给我超过20亿个组合,这在目前阶段应该足够了。当然,如果以后能够扩展,那就更好了。

但存在两个问题:

  1. 随机字符串有时可能拼出不良单词或其他令人不悦的短语。有没有一个不错的方法来避免这个问题呢?公平地说,我可能会接受一个数字字符串,但这会大大增加冲突的可能性。

  2. 如何让Django(或数据库)在插入时做重复检查?我不想先插入数据再计算主键(因为那不是一个好的方法)。我假设还需要注意并发问题,如果同时生成了两个新页面,并且第二个页面(出奇迹地)在第一个提交之前获得了与第一个相同的主键。

我认为这与URL缩短器生成其ID的方式没有太大区别。如果有一个不错的Django实现的URL缩短器,我可以借鉴它。


1
作为一条注释:'URL缩短器'通常会生成连续的URL :)。 - Michał Górny
7个回答

24

有内置的Django方式可以实现你想要的功能。在“自定义页面”的模型中添加一个字段,该字段具有primary_key=Truedefault=密钥生成函数的名称,例如:

class CustomPage(models.Model):
    ...
    mykey = models.CharField(max_length=6, primary_key=True, default=pkgen)
    ...

现在,对于每个模型实例pagepage.pk成为page.mykey的别名,在该实例创建时,page.mykey将自动分配为您的函数pkgen()返回的字符串。
快速&简单实现:

def pkgen():
    from base64 import b32encode
    from hashlib import sha1
    from random import random
    rude = ('lol',)
    bad_pk = True
    while bad_pk:
        pk = b32encode(sha1(str(random())).digest()).lower()[:6]
        bad_pk = False
        for rw in rude:
            if pk.find(rw) >= 0: bad_pk = True
    return pk

假设random()足够随机,两个页面获取相同的主键的概率非常低,并且没有并发问题。当然,通过从编码字符串中切割更多的字符,这种方法很容易扩展。


3
我不明白b32encode和sha1在这个概念中的意义。随机选择一个字符列表不是也可以生成同样随机的结果,而且开销(以及代码量)要少得多吗? - Oli
@Oli 你可以生成任何你想要的字符串,重点是将回调函数设置为默认值,这是将字符串分配为 PK 的方法。在我看来,这似乎是正确的解决方案 +1 Upvote - Rasiel
1
在可重用的环境中,它无法进行碰撞检查。不能有多个具有相同 slug 的 Model 实例。这是 default 参数的缺陷,它无法接受其他信息(以将类传递给生成器)。 - Oli
1
random_key = lambda: '{k:032X}'.format(k=random.getrandbits(128)) - Paulo Scardine
id = django.utils.http.int_to_base36(uuid.uuid4().int)[:length] - Nour Wolf
b32encode行生成错误:TypeError:Unicode对象必须在哈希之前进行编码 - Little Brain

10

以下是我最终采用的方法。我创建了一个抽象模型,因为我的用例需要多个模型来生成它们自己的随机 slug。

slug 的格式是 AA##AA ,也就是说有 52x52x10x10x52x52 = 731,161,600 种组合。这比我需要的数量多一千倍,如果这成为问题,我可以增加一个字母以获得 52 倍的组合数。

使用 default 参数不起作用,因为抽象模型需要在子模型上检查 slug 冲突。继承是最容易、可能是唯一的方法。

from django.db import models
from django.contrib.auth.models import User

import string, random

class SluggedModel(models.Model):
    slug = models.SlugField(primary_key=True, unique=True, editable=False, blank=True)

    def save(self, *args, **kwargs):
        while not self.slug:
            newslug = ''.join([
                random.sample(string.letters, 2),
                random.sample(string.digits, 2),
                random.sample(string.letters, 2),
            ])

            if not self.objects.filter(pk=newslug).exists():
                self.slug = newslug

        super().save(*args, **kwargs)

    class Meta:
        abstract = True

1
有趣。我最近决定采用UUID生成方法来生成一些主键,但我也可能考虑这种方法。你的片段无论哪种方式都可以工作。只需将你生成'ret'的4行替换为类似于'''ret = uuid.uuid1()'''的内容即可。 - Van Gale
我正在尝试使用您的方法,但是我遇到了“Manager不可通过ClassName实例访问”的错误。您是如何克服这个问题的? - zsquare
1
这是一个旧的帖子,但对于任何偶然发现并使用MySQL的人来说,需要注意的一件事情是MySQL在字符串匹配上默认不区分大小写,因此"AB12AB"和"ab12ab"这样的ID都会被找到,除非你明确告诉MySQL使用区分大小写的匹配方式:http://dev.mysql.com/doc/refman/5.0/en/case-sensitivity.html - umbrae
@Oli:尽管这些组合对您来说已经足够了,但使用减少的字符集(消除元音以防止不良词汇),并在所有6个位置上使用26+26+10-5(小写字母+大写字母+数字-元音)= 57个字符,从而提供34296447249种组合,约为50倍。 - user
感谢你的代码,@Oli。在 if self.objects.filter(pk=newslug).count() 中,你可能想要用 if not。此外,应该使用 type(self).objects 而不是 self.objects,因为对象无法访问管理器(正如@zsquare指出的错误)。另外,提醒其他人注意 - 在Django的后续版本中,对于具有ManyToManyMany字段的模型使用自定义主键效果不佳(请参见#25012),如果尝试回滚,则会变得更加复杂(#24030#22997)。 - Anupam
显示剩余4条评论

9
Django现在包括一个UUIDField类型,因此您不需要任何自定义代码或Srikanth Chundi建议的外部包。该实现使用带有破折号的HEX字符串,因此文本非常安全,除了像abad1d3a这样的1337表达式 :)。
您可以像这样使用它将pk别名为uuid字段作为主键:
import uuid
from django.db import models

class MyModel(models.Model):
    uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    # other fields

请注意,当您在 urls.py 中路由到此视图时,需要使用不同的正则表达式作为 此处提到,例如:
urlpatterns = [
    url(r'mymodel/(?P<pk>[^/]+)/$', MyModelDetailView.as_view(),
        name='mymodel'),
]

我的评论也适用于那个答案。UUID非常适合生成唯一的、近乎无限的ID,但它们对用户来说并不友好。考虑到在Django和Web的上下文中,这是某些东西将会被展示,并且可能需要手动转录,而随机字符串最终可能会拼出脏话。 - Oli
在十六进制数字系统中,你能用哪些脏话来拼写?请注意,仅可使用字母 a、b、c、d、e、f。但我同意,随机的长字符串可能并不适用于每种情况。 - metakermit
2
你这样问:B00B5.. 但是没错,十六进制肯定更好。庞大的36个字符长度才是真正的问题所在。 - Oli

4

你可能需要查看Python UUID,它可以生成随机的长字符。但是你可以对其进行切片,并使用你想要的字符数,并进行一些检查以确保即使在切片后它仍然是唯一的。

如果您不想自己生成UUID,UUIDField代码段可以帮助您。

还可以查看这篇博客文章


这并没有真正绕过我在问题中强调的两个问题。尽管UUIDField有助于将一些代码从我的模型中抽象出来,但它仍然在数据库之外(我真的很想在数据库内)并且仍然非常有能力拼写出粗鲁的话语。 - Oli

3

Oli: 如果你担心拼写粗鄙的单词,你可以使用Django亵渎过滤器比较/搜索UUIDField,跳过可能引起不适的任何UUID。


同情那些居住在斯肯索普的人...谩骂过滤器是棘手的东西! - Little Brain

1

看了上面的答案,这是我现在正在使用的。

import uuid

from django.db import models
from django.utils.http import int_to_base36


ID_LENGTH = 9


def id_gen() -> str:
    """Generates random string whose length is `ID_LENGTH`"""
    return int_to_base36(uuid.uuid4().int)[:ID_LENGTH]


class BaseModel(models.Model):
    """Django abstract model whose primary key is a random string"""
    id = models.CharField(max_length=ID_LENGTH, primary_key=True, default=id_gen, editable=False)

    class Meta:
        abstract = True


class CustomPage(BaseModel):
    ...

1

这就是我最终使用 UUID 的原因。

import uuid 

from django.db import models
from django.contrib.auth.models import User


class SluggedModel(models.Model):
    slug = models.SlugField(primary_key=True, unique=True, editable=False, blank=True)

    def save(self, *args, **kwargs):
        if not self.slug:
            uuid.uuid4().hex[:16]    # can vary up to 32 chars in length
        super(SluggedModel, self).save(*args, **kwargs)

    class Meta:
        abstract = True

请注意,这16个字节在技术上只包含15个字节的随机性,因为它们包括uuid的版本号。 - Ketzu

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接