编写自定义模型字段

介绍

字段参考 文档介绍了如何使用 Django 的标准字段类—— CharFieldDateField,等等。大多数情况下,这些类就是你需要的。虽然有时候,Django 的版本不能精确地匹配你的需求,或者你想使用的字段与 Django 内置的完全不同。

Django 内置的字段类型并未覆盖所有可能的数据库字段类型——只有常见的类型,例如 VARCHARINTEGER。对于更多模糊的列类型,例如地理多边形(geographic polygons),甚至是用户创建的类型,例如 PostgreSQL custom types,你可以自定义 Django 的 Field 子类。

或者,你有一个复杂的 Python 对象,它可以以某种形式序列化,适应标准的数据库列类型。这是另一个 Field 子类能帮助你配合模型使用你的对象的示例。

我们的示例对象

创建自定义字段要求注意一些细节。为了简化问题,我们在本文档中全程使用同一实例:封装一个 Python 对象,代表手上 桥牌 的细节。不要担心,你不需要知道如何玩桥牌就能学习此例子。你只需知道 52 张牌被均分给 4 个玩家,一般称他们 西。我们的类长这样:

class Hand:
    """A hand of cards (bridge style)"""

    def __init__(self, north, east, south, west):
        # Input parameters are lists of cards ('Ah', '9s', etc.)
        self.north = north
        self.east = east
        self.south = south
        self.west = west

    # ... (other possibly useful methods omitted) ...

这是一个一般的 Python 类,其中没有特殊的 Django 内容。我们期望在我们的模块中做如下操作 (我们假设模型中的 hand 属性是 Hand 的一个实例):

example = MyModel.objects.get(pk=1)
print(example.hand.north)

new_hand = Hand(north, east, south, west)
example.hand = new_hand
example.save()

对模型中的 hand 属性的赋值与取值操作与其它 Python 类一直。技巧是告诉 Django 如何保存和加载对象。

为了在模型中使用 Hand 类,我们 需要修改这个类。这很不错,因为这以为着你仅需为已存在的类编写模型支持,即便你不能修改源码。

备注

你可能只想要自定义数据库列的优点,并在模型中像使用标准 Python 那样;字符串,或浮点数,等等。这种情况与 Hand 例子类似,在进行过程中,我们将注意到差异。

背后的理论

数据库存储

让我们从模型字段开始吧。把它们分开来看,一个模型字段可以在处理数据库时,提供处理一般 Python 对象的方法(字符串,布尔值, datetime ,或像 Hand 这样更复杂的类型),并将其转换成有用的格式。(对序列化而言这种格式是很有用的,但接下来我们会看到,如果数据库端处于控制之下,序列化会更简单)

模型中的字段必须能以某种方式转换为已存在的数据库列类型。不能的数据库提供不同的可用列类型集,但规则仍相同:你只需要处理这些类型。你想存在数据库中的任何数据都必须能适配这些类型中的某一个。

一般地讲,您要么编写一个 Django 字段来匹配特定的数据库列类型,要么需要一种方法将数据转换为字符串。

For our Hand example, we could convert the card data to a string of 104 characters by concatenating all the cards together in a predetermined order -- say, all the north cards first, then the east, south and west cards. So Hand objects can be saved to text or character columns in the database.

一个字段(Field)类做了什么?

所有的 Django 字段(本页提到的 字段 均指模型字段,而不是 表单字段)都是 django.db.models.Field 的子类。对于所有字段,Django 记录的大部分信息是一样的——名字,帮助文本,是否唯一,等等。存储行为由 Field 处理。稍后,我们会深入了解 Field 能做什么;现在, 可以说万物源于 Field,并在其基础上自定义了类的关键行为。

了解 Django 字段类不保存在模型属性中很重要。模型属性包含普通的 Python 对象。你所以定义的字段类实际上在模型类创建时在 Meta 类中(这是如何实现的在这里不重要)。这是因为在仅创建和修改属性时,字段类不是必须的。相反,他们提供了属性值间转换的机制,并决定了什么被存入数据库或发送给 序列化器

在你创建自定义字段时牢记这点。你所写的 Django 的 Field 子类提供了多种在 Python 实例和数据库/序列化器之间的转换机制(比如,保存值和使用值进行查询之间是不同的)。听起来有点迷糊,但别担心——通过以下的例子会清晰起来。只要记住,在你需要一个自定义字段时,只需创建两个类:

  • 第一个类是用户需要操作的 Python 对象。它们会复制给模型属性,它们会为了显示而读取属性,就想这样。这里本例中的 Hand 类。
  • 第二类是 Field 的子类。这个类知道如何在永久存储格式和 Python 格式之间来回转换。

编写一个 field 子类

计划编写第一个 Field 子类时,需要先想想新字段和哪个已有的 Field 最相似。你会继承 Django 字段节约你的时间吗?如果不会,你需要继承 Field 类,从它继承了一切。

初始化新字段有点麻烦,因为要从公共参数中分离你需要的参数,并将剩下的传给父类 Field__init__() 方法(或你的父类)。

在本例中,我们会调用 HandField。(调用你的 Field 子类这个主意也很不错,所以认证为一个 Field 很简单。)它并不表现的像任何已存在的字段,所以我们将直接继承自 Field:

from django.db import models


class HandField(models.Field):
    description = "A hand of cards (bridge style)"

    def __init__(self, *args, **kwargs):
        kwargs["max_length"] = 104
        super().__init__(*args, **kwargs)

我们的 HandField 接收大多数标准字段选项(参考下面的列表),但是我们确定参数是定长的,因为它只需要保存 52 个卡片和它们的值;总计 104 个字符。

备注

许多 Django 模型字段可以接受并没有什么用的可选参数。比如,你可以同时将 auto_now 和:attr:~django.db.models.DateField.auto_now 传递给 django.db.models.DateField ,它将无视参数 editableauto_now 被设置了就意味着 editable=False)。这种情况下不会抛出错误。

这种行为简化了字段类,因为它不需要检查那些没必要出现的可选参数。它们传递所有可选参数到父类,之后就不再使用它们了。您可以更严格地设置字段可选参数,或者对当前字段设置更放任的行为,一切都有您来决定。

Field.__init__() 方法接收以下参数:

上述列表中所有无解释的选项与在普通 Django 字段中的作用一样。参见 字段文档 获取例子和细节信息。

字段解析

The counterpoint to writing your __init__() method is writing the deconstruct() method. It's used during model migrations to tell Django how to take an instance of your new field and reduce it to a serialized form - in particular, what arguments to pass to __init__() to recreate it.

如果你未在继承的字段之前添加任何选项,就不需要编写新的 deconstruct() 方法。然而,如果你正在修改传递给 __init__() 的参数(像 HandField 中的一样),你需要增补被传递的值。

deconstruct() 返回包含 4 个项目的元组:字段的属性名,字段类的完整导入路径,位置参数(以列表的形式),和关键字参数(以字典的形式)。注意,这与 为自定义类deconstruct() 方法不同,它返回包含 3个项目的元组。

作为自定义字段的作者,你不需要担心前两个值;基类 Field 已包含处理字段属性名和导入路径的代码。然后,你仍必须关注位置参数和关键字参数,这些是你最有可能改的东西。

例如,在 HandField 类中,我们总是强制设置 __init__() 的长度。基类 Field 中的 deconstruct() 方法会看到这个值,并尝试在关键字参数中返回它;因此,我们能为了可读性从关键字参数中剔除它:

from django.db import models


class HandField(models.Field):
    def __init__(self, *args, **kwargs):
        kwargs["max_length"] = 104
        super().__init__(*args, **kwargs)

    def deconstruct(self):
        name, path, args, kwargs = super().deconstruct()
        del kwargs["max_length"]
        return name, path, args, kwargs

若你添加了一个新的关键字参数,你需要在 deconstruct 中新增代码,将其值传入 kwargs。如果不需要字段的重构状态,比如使用默认值的情况,还应该忽略 kwargs 中的值。

from django.db import models


class CommaSepField(models.Field):
    "Implements comma-separated storage of lists"

    def __init__(self, separator=",", *args, **kwargs):
        self.separator = separator
        super().__init__(*args, **kwargs)

    def deconstruct(self):
        name, path, args, kwargs = super().deconstruct()
        # Only include kwarg if it's not the default
        if self.separator != ",":
            kwargs["separator"] = self.separator
        return name, path, args, kwargs

更多的复杂例子超出本文的范围,但是请牢记——对于你的字段实例的任意配置,deconstruct() 必须返回能传递给 __init__ 的参数重构状态。

如果你在父类 Field 中设置了新的默认值需要额外注意;说明你希望总是包含它们,而不是在它们采用旧有值时消失。

另外,应当避免用位置参数来返回值,尽可能用关键字参数来返回值以保证在未来能拥有最大的兼容性。如果您在构造方法参数列表中修改参数名称比修改其位置更频繁,您可能会更喜欢位置参数,但请记住,在相当长的一段时间内(也许几年),人们将从序列化版本中重新构建您的字段,这取决于您迁移持续的时间。

您可以在包含字段的迁移文件里查看解析的结果,此外您可以在单元测试中通过解构和重构字段来测试。

name, path, args, kwargs = my_field_instance.deconstruct()
new_instance = MyField(*args, **kwargs)
self.assertEqual(my_field_instance.some_attribute, new_instance.some_attribute)

Field attributes not affecting database column definition

New in Django 4.1.

You can override Field.non_db_attrs to customize attributes of a field that don't affect a column definition. It's used during model migrations to detect no-op AlterField operations.

例子:

class CommaSepField(models.Field):
    @property
    def non_db_attrs(self):
        return super().non_db_attrs + ("separator",)

修改自定义字段的基类

你可能修改自定义字段的基类,因为 Django 无法检测到修改,并为其实施迁移。例如,如果你先这样:

class CustomCharField(models.CharField):
    ...

随后决定继承自 TextField,你不能像这样修改子类:

class CustomCharField(models.TextField):
    ...

替代方法是,你必须新建一个自定义字段类,并将你的模型指向此类:

class CustomCharField(models.CharField):
    ...


class CustomTextField(models.TextField):
    ...

就像文档 移除字段 中讨论的一样,你必须保留原 CustomCharField 类只要你还有迁移指向它。

为自定义字段编写文档

像之前一样,你需要为自定义字段类型编写文档,这样用户就会知道这他喵到底是啥。除了为其提供对开发者很有用的 docstring 外,你也需要让后台管理员应用程序的用户通过应用程序 django.contrib.admindocs 1 看到一个关于字段类型的简单介绍。只需在自定义字段的 description 属性提供描述性文本。在上述例子中,由 admindocs 应用为 HandField 字段提供的描述是 'A hand of cards (bridge style)'。

django.contrib.admindocs 展示的内容中,字段描述在 field.__dict__ 中差值,它允许描述包含字段参数。例如, CharField 的说明是:

description = _("String (up to %(max_length)s)")

实用方法

一旦你已创建了 Field 的子类,你可能会考虑重写一些标准方法,这取决于你的字段行为。以下列表中的方法大致按重要性降序排列,即从上至下。

自定义数据库类型

假设你已创建了一个 PostgreSQL 自定义字段,名叫 mytype。你可以继承 Field 并实现 db_type() 方法,像这样:

from django.db import models


class MytypeField(models.Field):
    def db_type(self, connection):
        return "mytype"

只要已建立 MytypeField,你就能像使用其它 Field 类型一样在模型中使用它:

class Person(models.Model):
    name = models.CharField(max_length=80)
    something_else = MytypeField()

如果您的目标是构建一个与数据库无关的应用程序,您应该考虑数据库账户列类型的差异。 例如,PostgreSQL 中的日期/时间列类型称为 timestamp ,而 MySQL 中的同一列称为 datetime 。您可以通过 db_type() 方法中处理此问题检查连接 connection.vendor 属性。 当前的内置供应商名称是:sqlite, postgresql, mysql, 和 oracle

例子:

class MyDateField(models.Field):
    def db_type(self, connection):
        if connection.vendor == "mysql":
            return "datetime"
        else:
            return "timestamp"

db_type()rel_db_type() 方法由 Django 框架在为应用构建 CREATE TABLE 语句时调用——即你第一次创建数据表的时候。这些方法也在构建一个包含此模型字段的 WHERE 字句时调用——即你在利用 QuerySet 方法(get(), filter(), 和 exclude())检出数据时或将此模型字段作为参数时。它们在其它时间不会被调用,故它们能承担执行有点小复杂的代码,例如上述的 connection.settings_dict 例子。

某些数据库列类型接受参数,例如 CHAR(25),参数 25 表示列的最大长度。类似用例中,该参数若在模型中指定比硬编码在 db_type() 方法中更灵活。举个例子,构建 CharMaxlength25Field 没多大意义,如下所示:

# This is a silly example of hard-coded parameters.
class CharMaxlength25Field(models.Field):
    def db_type(self, connection):
        return "char(25)"


# In the model:
class MyModel(models.Model):
    # ...
    my_field = CharMaxlength25Field()

更好的方式是在运行时指定参数值——即类实例化的时候。需要像这样实现 Field.__init__() 即可:

# This is a much more flexible example.
class BetterCharField(models.Field):
    def __init__(self, max_length, *args, **kwargs):
        self.max_length = max_length
        super().__init__(*args, **kwargs)

    def db_type(self, connection):
        return "char(%s)" % self.max_length


# In the model:
class MyModel(models.Model):
    # ...
    my_field = BetterCharField(25)

最后,如果你的列真的要求配置复杂的 SQL,从 db_type() 返回 None。这会让 Django 创建 SQL 的代码跳过该字段。随后你需要负责为该字段在正确的表中以某种方式创建列,这种方式允许你告诉 Django 不处理此事。

rel_db_type() 方法由字段调用,例如 ForeignKeyOneToOneField ,这些通过指向另一个字段来决定数据库列类型的字段。举个例子,如果你有个 UnsignedAutoField,你也需要指向该字段的外键使用相同的数据类型:

# MySQL unsigned integer (range 0 to 4294967295).
class UnsignedAutoField(models.AutoField):
    def db_type(self, connection):
        return "integer UNSIGNED AUTO_INCREMENT"

    def rel_db_type(self, connection):
        return "integer UNSIGNED"

将值转为 Python 对象

若自定义 Field 处理的数据结构比字符串,日期,整型,或浮点型更复杂,你可能需要重写 from_db_value()to_python()

若要展示字段的子类, from_db_value() 将会在从数据库中载入的生命周期中调用,包括聚集和 values() 调用。

to_python() 在反序列化时和为表单应用 clean() 时调用。

作为通用规则, to_python 应该平滑地处理以下参数:

  • 一个正确的类型(本业持续介绍的例子 Hand )。
  • 一个字符串
  • None (若字段允许 null=True

In our HandField class, we're storing the data as a VARCHAR field in the database, so we need to be able to process strings and None in the from_db_value(). In to_python(), we need to also handle Hand instances:

import re

from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _


def parse_hand(hand_string):
    """Takes a string of cards and splits into a full hand."""
    p1 = re.compile(".{26}")
    p2 = re.compile("..")
    args = [p2.findall(x) for x in p1.findall(hand_string)]
    if len(args) != 4:
        raise ValidationError(_("Invalid input for a Hand instance"))
    return Hand(*args)


class HandField(models.Field):
    # ...

    def from_db_value(self, value, expression, connection):
        if value is None:
            return value
        return parse_hand(value)

    def to_python(self, value):
        if isinstance(value, Hand):
            return value

        if value is None:
            return value

        return parse_hand(value)

注意,我们总是为这些方法返回一个 Hand 实例。这就是我们要保存在模型属性中的 Python 对象类型。

对于 to_python() 来说,如果在值转换过程中出现任何问题,你应该抛出一个 ValidationError 异常。

将 Python 转为查询值

使用数据库需要双向转换,如果你重写了 from_db_value() 方法,你也必须重写 get_prep_value() 将 Python 对象转回查询值。

例子:

class HandField(models.Field):
    # ...

    def get_prep_value(self, value):
        return "".join(
            ["".join(l) for l in (value.north, value.east, value.south, value.west)]
        )

警告

如果你使用了 MySQL 的 CHARVARCHARTEXT 类型,你必须确保 get_prep_value() 总是返回一个字符串。在 MySQL 中对这些类型操作时非常灵活,甚至有时超出预期,在传入值为正数时,检出结果可能包含非期望的结果。这个问题不会在你总为 get_prep_value() 返回字符串类型的时候出现。

将查询值转为数据库值

某些数据类型(比如 dates)在数据库后端处理前要转为某种特定格式。 get_db_prep_value() 实现了这种转换。查询所以使用的连接由 connection 参数指定。这允许你在需要时指定后台要求的转换逻辑。

例如,Django 为其 BinaryField 利用以下方法:

def get_db_prep_value(self, value, connection, prepared=False):
    value = super().get_db_prep_value(value, connection, prepared)
    if value is not None:
        return connection.Database.Binary(value)
    return value

万一自定义字段需要与普通查询参数使用的转换不同的转换规则,你可以重写 get_db_prep_save()

在保存前预处理数值

如果你要在保存前预处理值,你可以调用 pre_save()。举个例子,Django 的 DateTimeFieldauto_nowauto_now_add 中利用此方法正确设置属性。

如果你重写了此方法,你必须在最后返回该属性的值。如果修改了值,那么你也需要更新模型属性,这样持有该引用的模型总会看到正确的值。

为模型字段指定表单字段

为了自定义 ModelForm 使用的表单属性,你必须重写 formfield()

表单字段类能通过 form_classchoices_form_class 参数指定;如果字段指定了选项,则使用后者,反之前者。若未提供这些参数,将会使用 CharFieldTypedChoiceField

完整的 kwargs 被直接传递给表单字段的 __init__() 方法。一般的,你要做的全部工作就是为 form_class 参数配置一个合适的默认值,并在随后委托父类处理。这可能要求你编写一个自定义表单字段(甚至表单视图)。查看 表单文件材料 获取相关信息。

承接上面的例子,我们能这样编写 formfield() 方法:

class HandField(models.Field):
    # ...

    def formfield(self, **kwargs):
        # This is a fairly standard way to set up some defaults
        # while letting the caller override them.
        defaults = {"form_class": MyFormField}
        defaults.update(kwargs)
        return super().formfield(**defaults)

这假定我们已导入 MyFormField 字段类(它有默认视图)。本页文档未覆盖编写自定义表单字段的细节。

仿造内置字段类型

若你已创建了 db_type() 方法,你无需担心 get_internal_type() 方法——它并不常用。虽然很多时候,数据库存储行为和其他字段类似,所以你能直接用其它字段的逻辑创建正确的列。

例子:

class HandField(models.Field):
    # ...

    def get_internal_type(self):
        return "CharField"

无论我们使用了哪个数据库后端, migrate 或其它 SQL 命令总会在保存字符串时为其创建正确的列类型。

get_internal_type() 返回了当前数据库后端(即 django.db.backends.<db_name>.base.DatabaseWrapper.data_types 中未出现的后端)无法理解的字符串——该字符串仍会被序列化器使用的,但是默认的 db_type() 方法会返回 None。查阅文档 db_type() 了解为啥有用。如果您打算在 Django 之外的其他地方使用序列化器输出,那么将描述性字符串作为序列化器的字段类型是一个有用的想法。

为序列化转换字段数据

自定义序列化器序列化值的流程,你要重写 value_to_string()。使用 value_to_string() 是在序列化之前获取字段值的最佳方法。举个例子,由于 HandField 使用字符串存储数据,我们能复用一些已有代码:

class HandField(models.Field):
    # ...

    def value_to_string(self, obj):
        value = self.value_from_object(obj)
        return self.get_prep_value(value)

一些通用建议

编写自定义字段是个棘手的,尤其是在 Python 类,数据库,序列化格式之间进行复杂转换的时候。下面有几个让事情更顺利的建议:

  1. Look at the existing Django fields (in django/db/models/fields/__init__.py) for inspiration. Try to find a field that's similar to what you want and extend it a little bit, instead of creating an entirely new field from scratch.
  2. 为字段类添加一个 __str__() 方法。在很多地方,字段代码的默认行为是对值调用 str()。(本页文档中, value 会是一个 Hand 实例,而不是 HandField)。所以 __str()__ 方法会自动将 Python 对象转为字符串格式,帮你剩下不少时间。

编写一个 FileField 子类

除了上述方法外,处理文件的字段还有一些必须考虑到的特殊要求。 FileField 提供的大部分机制(像是操作数据库存储和检索)能保持不变,让子类面对支持特殊文件的挑战。

Django 提供一个 File 类,作为文件内容和文件操作的代理。可以继承该类自定义访问文件的方式,哪些方法是可用的。它位于 django.db.models.fields.files,它的默认行为在 file 文档 中介绍。

一旦创建了文件 File 子类,必须说明要使用新子类 FileField。为此,需要为 FileField 的子类的 attr_class 属性指定新的文件 File 子类。

一些建议

除了上述细节,下面还有一些准则,有助于极大地提高字段代码的效率和可读性。

  1. The source for Django's own ImageField (in django/db/models/fields/files.py) is a great example of how to subclass FileField to support a particular type of file, as it incorporates all of the techniques described above.
  2. 尽可能的缓存文件属性。因为文件可能保存在远端存储系统中,检出它们会消耗额外的时间,甚至是钱,且不总是必要的。一旦检出某个文件,获取其内容,尽可能缓存所有数据,以减少后续调用再次检索文件的次数。