如何创建 Django 自定义模型字段

简介

模型参考文档介绍了怎样使用 Django 的标准字段类,例如 CharField、DateField。对于许多用途,这些类已经足够。但有时 Django 提供的类型不能准确满足需求,或者你需要一种与内置字段完全不同的字段。

Django 内置字段没有覆盖所有可能的数据库列类型,只包括 VARCHAR、INTEGER 等常见类型。对于地理多边形等较少见的列类型,乃至 PostgreSQL 自定义类型,可以定义自己的 Django Field 子类。

也可能存在一个复杂 Python 对象,能够通过某种序列化方式存入标准数据库列。这种情况下,Field 子类也能帮助你在模型中使用它。

示例对象

创建自定义字段需要留意一些细节。为了方便理解,全文采用同一个例子:封装一个 Python 对象,用来表示桥牌的一副发牌结果。无需懂桥牌也可以理解这个例子,只要知道52张牌平均分给四名玩家,传统上称为 north、east、south、west。类大致如下:

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()

我们像使用其他 Python 类一样,给模型的 hand 属性赋值并读取它。关键在于告诉 Django 怎样保存和加载这种对象。

为了在模型中使用 Hand 类,无需对这个类做任何修改。这很理想,因为即使无法修改已有类的源码,也能为它编写模型支持。

注意:你也可能只想使用自定义数据库列类型,而在模型中仍然以字符串、浮点数等标准 Python 类型处理数据。这与 Hand 示例类似,后文会指出差异。

背景原理

数据库存储

先从模型字段谈起。模型字段提供了一种方式,将普通 Python 对象——字符串、布尔值、datetime,或 Hand 这样的复杂对象——转换为适合数据库处理的格式,并从该格式转换回来。这种格式也可用于序列化;一旦掌握数据库一侧的处理,后面的序列化会更容易。

模型字段必须通过某种转换,适配已有的数据库列类型。不同数据库提供的有效列类型不同,但规则相同:只能使用数据库支持的类型。任何要存入数据库的内容,都必须能够放入其中某种类型。

通常,你要么编写匹配特定数据库列类型的 Django 字段,要么寻找将数据转换为字符串等类型的方式。

对于 Hand 示例,可以按照固定顺序连接全部牌,例如先 north,再 east、south、west,得到104个字符的字符串。因此,Hand 对象可以保存到数据库的文本列或字符列中。

字段类做什么?

Django 的所有字段都是 django.db.models.Field 的子类。本文中的“字段”始终指模型字段,而非表单字段。Django 为字段记录的大部分信息是通用的,例如名称、帮助文本、唯一性;这些信息由 Field 保存。

后文会说明 Field 的具体能力。现在只需理解:所有字段都从 Field 派生,再定制类行为的关键部分。

必须认识到,Django 字段类并不是存放在模型属性中的值。模型属性保存的是普通 Python 对象。模型中定义的字段类,在模型类创建时实际保存在 Meta 类中;这里不必了解具体机制。因为单纯创建和修改属性时,并不需要字段类。

字段类提供的,是在属性值与数据库保存值、或发送给序列化器的值之间转换的机制。

创建自定义字段时应牢记这一点:你编写的 Django Field 子类负责在 Python 实例与数据库/序列化器使用的值之间,以不同方式进行转换。例如,保存一个值和把它用于查询,处理方式就有区别。例子会让这些关系更清楚。需要自定义字段时,通常会创建两个类:

  • 第一个是用户直接操作的 Python 对象类。用户把它赋给模型属性,读取它用于显示。这里就是 Hand。
  • 第二个是 Field 子类,它知道怎样在第一个类的 Python 形式与持久化存储形式之间双向转换。

编写字段子类

规划 Field 子类时,先考虑新字段与哪个现有字段最接近。能否通过继承现有 Django 字段省去一些工作?如果不能,就直接继承所有字段的基类 Field。

初始化新字段时,需要将自己特有的参数与通用参数分开,把通用参数传给 Field 或父类的 __init__()。

本例将字段命名为 HandField。建议将 Field 子类命名为 <Something>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 模型字段接受一些实际上不使用的选项。例如,给 DateField 同时传入 editable 和 auto_now 时,它会忽略 editable,因为启用 auto_now 隐含 editable=False。这种情况不会报错。

这种行为简化了字段类,使它不必检查不需要的选项;它将所有选项传给父类,后续不再使用其中一些。你可以决定自己的字段采用更严格的选项检查,还是沿用现有字段较宽松的行为。

Field.__init__() 接受以下参数:

没有额外解释的选项,与普通 Django 字段中的含义相同。示例和详情见字段文档。

字段解构

与 __init__() 对应的是 deconstruct()。模型迁移使用它,将新字段实例转换为可序列化形式,尤其是确定重建实例时应该给 __init__() 传哪些参数。

如果没有在继承的字段之上增加额外选项,就不必重写 deconstruct()。但如果修改了传入 __init__() 的参数,就像 HandField 所做的,就需要补充对返回值的处理。

deconstruct() 返回四项元组:字段属性名、字段类的完整导入路径、位置参数列表、关键字参数字典。这不同于自定义类的 deconstruct(),后者返回三项元组。

自定义字段作者不必处理前两项,Field 基类已经能够推导属性名和导入路径。但必须关心位置参数和关键字参数,因为这些通常是自己修改的部分。

例如,HandField 总是在 __init__() 中强制设置 max_length。基类的 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。当某个值不影响重建字段状态,例如使用默认值时,也应省略:

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.non_db_attrs,定制不影响列定义的属性。模型迁移用它识别无需执行操作的 AlterField。

例如:

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,就必须保留它。

为自定义字段编写文档

应为字段类型编写文档,使用户了解其用途。除了为开发者提供文档字符串,还可以通过 django.contrib.admindocs 应用,让后台用户看到字段类型的简短说明。为此,在自定义字段中提供包含说明文字的 description 类属性。

上面的 HandField 在 admindocs 中显示的说明是 A hand of cards (bridge style)。

django.contrib.admindocs 会使用 field.__dict__ 对字段说明进行插值,使说明能够包含字段参数。例如,CharField 的说明是:

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

有用的方法

创建 Field 子类后,可根据字段行为重写一些标准方法。以下列表大致按重要程度递减排列,可以从前面的开始。

自定义数据库类型

假设创建了名为 mytype 的 PostgreSQL 自定义类型。可以继承 Field 并实现 db_type():

from django.db import models


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

有了 MytypeField,就能像其他字段一样在任何模型中使用:

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"

Django 在为应用构造 CREATE TABLE 语句,即首次创建表时,会调用 db_type() 和 rel_db_type()。构造包含模型字段的 WHERE 子句时,也会调用这些方法,例如使用 get()、filter()、exclude() 等 QuerySet 方法,并把模型字段作为参数。

某些数据库列类型接受参数,如 CHAR(25) 中的25表示最大列长度。与在 db_type() 中写死相比,在模型中指定参数更灵活。例如,定义 CharMaxlength25Field 意义不大:

# This is a silly example of hardcoded 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 不介入的方式。

ForeignKey、OneToOneField 等指向其他字段的字段,会调用 rel_db_type() 确定自身的数据库列类型。例如,若有 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 对象

如果自定义字段处理的数据结构比字符串、日期、整数、浮点数更复杂,可能需要重写 from_db_value() 和 to_python()。

如果字段子类定义了 from_db_value(),从数据库加载数据的所有情况都会调用它,包括聚合和 values() 调用。

反序列化,以及表单使用的 clean() 方法中,会调用 to_python()。

通常,to_python() 应妥善处理以下参数:

  • 正确类型的实例,例如 Hand。
  • 字符串。
  • None,如果字段允许 null=True。

HandField 在数据库中以 VARCHAR 保存数据,因此 from_db_value() 需要处理字符串和 None。to_python() 还必须处理 Hand 实例:

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 的 CHAR、VARCHAR 或 TEXT,必须保证 get_prep_value() 始终返回字符串。当以整数查询这些列类型时,MySQL 会执行宽松且可能出乎意料的匹配,导致查询结果包含意外对象。始终返回字符串可以避免这一问题。

将查询值转换为数据库值

某些数据类型,例如日期,必须转换为特定格式才能由数据库后端使用。这类转换应放在 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 的 DateTimeField 用它在启用 auto_now 或 auto_now_add 时正确设置属性。

重写此方法时,必须在结束时返回属性值。如果修改了值,也应更新模型属性,让持有该模型引用的代码始终看到正确的值。

指定模型字段对应的表单字段

要定制 ModelForm 使用的表单字段,可以重写 formfield()。

通过 form_class 和 choices_form_class 参数指定表单字段类:字段配置了 choices 时使用后者,否则使用前者。若没有提供这些参数,则使用 CharField 或 TypedChoiceField。

整个 kwargs 字典直接传给表单字段的 __init__()。通常,只需为 form_class,以及可能需要的 choices_form_class,设置合适的默认值,再让父类完成后续处理。这可能要求编写自定义表单字段,甚至表单控件,详情见表单文档。

如果希望将字段排除在 ModelForm 之外,可以让 formfield() 返回 None。

继续本例,方法可写为:

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

    def formfield(self, **kwargs):
        # Exclude the field from the ModelForm when some condition is met.
        some_condition = kwargs.get("some_condition", False)
        if some_condition:
            return None

        # 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_from_object() 获取字段值最合适。例如,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. 参考 Django 现有字段,源码在 django/db/models/fields/__init__.py。尽量找到接近需求的字段并做少量扩展,而不是从头创建。
  2. 为封装为字段的对象类提供 __str__()。字段代码的默认行为在很多地方会对值调用 str()。本例中的 value 是 Hand 实例,而不是 HandField。若 __str__() 能自动把 Python 对象转换为字符串形式,就能省去不少工作。

编写 FileField 子类

除了上述方法,处理文件的字段还有一些特殊要求。FileField 提供的大部分机制,例如控制数据库存储和读取,可以保留,由子类负责支持某种特定文件类型。

Django 提供 File 类,作为文件内容和操作的代理。可以继承它,定制文件访问方式及可用的方法。它位于 django.db.models.fields.files,默认行为见文件文档。

创建 File 子类后,必须让新的 FileField 子类使用它。为此,把新的 File 子类赋给 FileField 子类的特殊属性 attr_class。

几条建议

除了上述细节,以下准则可以明显提高字段代码的效率和可读性:

  1. Django 自带的 ImageField 是通过继承 FileField 支持特定文件类型的优秀示例,包含了前面介绍的技术。源码见 django/db/models/fields/files.py。
  2. 尽可能缓存文件属性。文件可能位于远程存储中,读取它会带来不必要的时间开销,甚至费用。为了获取文件内容信息而读取文件后,应尽量缓存这些数据,减少后续查询同一信息时再次读取文件的次数。

原文:How to create custom model fields。来源:Django 官方文档;作者:Django Software Foundation 与各位贡献者。本文为中文翻译,文中 API 对应 Django 6.1。部分示例包含省略号或依赖外部定义,并非完整独立程序。

© 2005-2026 Django Software Foundation and individual contributors。Django 源码及随仓库分发的文档采用 BSD 3-Clause 许可。许可允许在遵守版权、条件和免责声明的情况下以源码或二进制形式重新分发及使用,包括修改版本;不得在未获书面许可时使用 Django Software Foundation 或贡献者的名称为衍生产品背书或推广。作品按原样提供,不附带明示或默示保证;权利人及贡献者不对因使用作品产生的直接、间接、偶发、特殊、惩罚性或后果性损失负责。完整许可与免责声明保留于文末。

BSD 3-Clause 原许可

Copyright (c) Django Software Foundation and individual contributors.
All rights reserved.

Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

    1. Redistributions of source code must retain the above copyright notice,
       this list of conditions and the following disclaimer.
    2. Redistributions in binary form must reproduce the above copyright
       notice, this list of conditions and the following disclaimer in the
       documentation and/or other materials provided with the distribution.

    3. Neither the name of Django nor the names of its contributors may be used
       to endorse or promote products derived from this software without
       specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容