Django 系列 · 第一篇

title = models.CharField(...),这行代码最后去哪了?

上一篇验证了元类的机制本身。这一篇看一个真实框架怎么重度依赖它——Django 的每一个 Model 类,它的元类都是 ModelBase。本机真实拦截发现一个反直觉的结果:title = CharField(...) 这行代码执行完之后,类属性 title 已经不是那个 CharField 对象了——它被替换成了别的东西。这一篇顺着真实源码,把这个替换过程和它背后的真实用途(用一次真实多打的 SQL 查询证明)都挖了出来。

DeferredAttribute
本机真实测得:Article.__dict__['title'] 的真实类型,不是 CharField
+1 次查询
本机真实实测:访问一个被 .only() 排除的字段,真实多发一次 SQL

先确认:Model 的元类真的是自定义的

本机真实配置了一个最小化的 Django 环境(Django 6.0.7),定义一个最简单的 Model。

真实实测
from django.db import models
class Article(models.Model):
    title = models.CharField(max_length=200)
    body = models.TextField()
    class Meta:
        app_label = '__main__'

print(type(Article))
print(type(Article).__mro__)
<class 'django.db.models.base.ModelBase'> (<class 'django.db.models.base.ModelBase'>, <class 'type'>, <class 'object'>)

印证了上一篇的结论:Article 这个类,是 ModelBase 的实例,而 ModelBase 本身是 type 的子类——一个真正在生产环境重度使用的元类。

真实实验:title 字段"消失"了

按直觉,Article.title 应该就是那个 CharField(max_length=200) 对象。实测不是。

真实实测
print(Article.__dict__['title'])
print(type(Article.__dict__['title']))
print()
for f in Article._meta.fields:
    print(f'  {f.name!r}: {type(f).__name__}')
<django.db.models.query_utils.DeferredAttribute object at 0x10b715220> <class 'django.db.models.query_utils.DeferredAttribute'> 'id': BigAutoField 'title': CharField 'body': TextField

两个意外发现:第一,Article.__dict__['title'] 是一个 DeferredAttribute,真正的 CharField 对象藏到了 Article._meta.fields 里;第二,_meta.fields 里多出来一个从没声明过的 id 字段。这两件事都是 ModelBase 干的。

真实源码:ModelBase.__new__ 的真实四步

ModelBase.__new__ 拿到的 attrs,就是上一篇讲过的 __build_class__ 第三步"执行类体"之后的那个命名空间——这一刻 title 还真的是 CharField 实例。

django/db/models/base.py · django @ 6.0.7, L97 class ModelBase(type): def __new__(cls, name, bases, attrs, **kwargs): ... attr_meta = attrs.pop("Meta", None) # 内部 class Meta 被摘出去 contributable_attrs = {} for obj_name, obj in attrs.items(): if _has_contribute_to_class(obj): # Field 实例都有这个方法 contributable_attrs[obj_name] = obj else: new_attrs[obj_name] = obj new_class = super_new(cls, name, bases, new_attrs, **kwargs) # 先造一个"光身"的类 ... for obj_name, obj in contributable_attrs.items(): new_class.add_to_class(obj_name, obj) # 逐个把字段"贡献"给类

四步翻译成大白话:①拿到类体执行完的命名空间,这时候 title 还是原始的 CharField;②把"有 contribute_to_class 方法的东西"(所有 Field 实例)单独摘出来,顺手把内部 class Meta 也摘出去——这解释了为什么 hasattr(Article, 'Meta')False;③用剩下的"普通"属性先造出一个不带任何字段的空壳类;④对每个被摘出来的字段,调用 add_to_class,这一步才是真正的字段安装。

django/db/models/base.py · django @ 6.0.7, L391 def add_to_class(cls, name, value): if _has_contribute_to_class(value): value.contribute_to_class(cls, name) else: setattr(cls, name, value)

真实源码:一个字段怎么"贡献"自己

真正的替换动作,发生在 Field.contribute_to_class 里。

django/db/models/fields/__init__.py · django @ 6.0.7, L944 def contribute_to_class(self, cls, name, private_only=False): self.set_attributes_from_name(name) self.model = cls cls._meta.add_field(self, private=private_only) # 真正的 Field 对象注册进 _meta if self.column: setattr(cls, self.attname, self.descriptor_class(self)) # 类属性被替换成描述符

self.descriptor_class 默认就是 DeferredAttribute(定义在 Field 类上:descriptor_class = DeferredAttribute)。这一行 setattr 就是 Article.title 变成 DeferredAttribute 的真正发生地——原始的 CharField 对象只活在 _meta.fields 里,从没真的成为过类属性。

django/db/models/query_utils.py · django @ 6.0.7, L243 class DeferredAttribute: """A wrapper for a deferred-loading field. When the value is read from this object the first time, the query is executed.""" def __get__(self, instance, cls=None): if instance is None: return self data = instance.__dict__ field_name = self.field.attname if field_name not in data: ... instance.refresh_from_db(fields=[field_name]) # 真的会发一次新查询 return data[field_name]

这是一个真正的描述符(实现了 __get__)——类访问(Article.title)拿到的是描述符本身,实例访问(article.title)才会走 __get__ 逻辑,返回实例 __dict__ 里真正的值。名字里的"Deferred"不是随便起的:如果这个字段的值还没被加载(比如用了 .only()/.defer() 排除了它),第一次访问会触发 refresh_from_db,真的再发一次数据库查询。

真实实验:DeferredAttribute 不是纸上谈兵,真的会多打一次 SQL

本机真实建表、插入一行数据,用 .only('title') 故意排除 body 字段,监控真实发出的 SQL。

真实实测 $ python3 exp_deferred.py === fetching with .only('title') -- body is deliberately excluded === queries so far: 1 accessing a.title (was loaded): hello queries so far: 1 accessing a.body (was DEFERRED, not loaded)... queries so far: 2 the extra query that just fired: SELECT "__main___article"."id", "__main___article"."body" FROM "__main___article" WHERE "__main___article"."id" = 1 LIMIT 21

a.title 访问不产生任何新查询(数据已经在 .only() 那次查询里加载了);a.body 访问真实触发了第二次 SQL,专门去数据库补这一个字段。调用方完全看不出这中间发生了什么——a.body 用起来和普通属性一模一样,这正是描述符协议存在的意义:把"要不要查数据库"这种复杂逻辑,藏在一次看起来平平无奇的属性访问背后。

真实源码:那个自动多出来的 id 字段

没有声明主键的话,谁来决定加一个 id?

django/db/models/options.py · django @ 6.0.7, L309 if self.pk is None: if self.parents: ... else: pk_class = self._get_default_pk_class() auto = pk_class(verbose_name="ID", primary_key=True, auto_created=True) model.add_to_class("id", auto) # 走的是和普通字段完全一样的 add_to_class

_meta._prepare() 阶段,如果扫描完所有字段发现没有主键,就用 settings.DEFAULT_AUTO_FIELD(本机默认配的 BigAutoField)造一个,再调用同一个 add_to_class 把它装上去——自动字段和手写字段走的是完全相同的机制,没有任何特殊通道。这也是为什么 _meta.fieldsid 排在最前面:它是最先被 add_to_class 的。

演示:一次 ModelBase.__new__ 完整走一遍

复现 Article(title, body) 从类体执行完毕到最终类对象定型的完整过程。

ModelBase 字段安装流程演示
Article._meta.fields
Article 类的 __dict__(只看字段相关的键)

数据来自独立验证过的 Python 模拟,复现了真实的四步流程和自动主键注入顺序;最终 _meta.fields 的顺序(id 在最前)和类 __dict__ 里每个字段对应的描述符,都用独立回放算法核对过。

参考与说明

  • 本文全部真实实验(type(Article) 确认、Article.__dict__['title'] 类型检查、.only() 触发额外 SQL 查询)均在本机真实运行的 Django 6.0.7(配合内存 SQLite 数据库)上完成,数据未做删改。
  • 源码引用(django/db/models/base.pyModelBase.__new__/add_to_classfields/__init__.pyField.contribute_to_classquery_utils.pyDeferredAttributeoptions.py 的自动主键注入逻辑)取自本机通过 pip 安装的 Django 6.0.7 包本身,并且逐字节比对确认与 django/django 仓库 6.0.7 标签完全一致。
  • 演示动画的字段安装流程模型(接收命名空间、拆分、造空壳类、逐字段贡献)由 Python 独立实现并交叉验证过——用真实实测过的 Article(title, body) 场景做参数,最终 _meta.fields 顺序和类 __dict__ 内容都用独立回放算法核对过,不是照抄 Django 源码,而是复现了它对外可观察的行为规则。
  • 没有涉及:多表继承(parent_link)场景下字段安装的额外逻辑、ManyToManyField 这类需要额外中间表的字段类型、抽象基类(abstract = True)如何影响这套流程、Options(_meta 对象本身)的完整初始化过程。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电