Django Styleguide - 模型层(Models)
模型层概述
模型应该负责数据模型,而不是其他更多的事情。
💡 核心思想
Django 的 Model 层应该:
✅ 定义数据结构
✅ 定义字段类型和约束
✅ 简单的数据验证
✅ 简单的派生属性
不应该:
❌ 包含复杂的业务逻辑
❌ 进行外部 API 调用
❌ 发送邮件或通知
❌ 处理复杂的数据转换
基础模型(Base Model)
定义一个可以继承的 BaseModel 是个好主意。
通常,像 created_at 和 updated_at 这样的字段是放入 BaseModel 的完美候选。
🔖 设计模式:抽象基类
使用抽象基类(Abstract Base Class)可以:
避免代码重复
确保所有模型的一致性
便于统一修改共同字段
不会创建额外的数据库表
这是一个 BaseModel 示例:
from django.db import models
from django.utils import timezone
class BaseModel(models.Model):
created_at = models.DateTimeField(db_index=True, default=timezone.now)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
📝 代码详解
created_at:创建时间
db_index=True:在数据库中创建索引,加速查询
default=timezone.now:使用 Django 的时区感知时间(推荐)
updated_at:更新时间
auto_now=True:每次保存时自动更新为当前时间
abstract = True:标记为抽象模型,不会创建数据库表
⚠️ 常见错误:时区问题
python# ❌ 错误:使用 datetime.now from datetime import datetime created_at = models.DateTimeField(default=datetime.now) # ✅ 正确:使用 timezone.now from django.utils import timezone created_at = models.DateTimeField(default=timezone.now)使用
timezone.now可以:
支持时区感知(timezone-aware)
与 Django 的
USE_TZ设置配合避免时区相关的 bug
然后,每当你需要新模型时,只需继承 BaseModel:
class SomeModel(BaseModel):
pass
现在 SomeModel 会自动拥有 created_at 和 updated_at 字段。
💡 扩展建议
根据项目需求,你可以在
BaseModel中添加:pythonclass BaseModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) created_at = models.DateTimeField(db_index=True, default=timezone.now) updated_at = models.DateTimeField(auto_now=True) is_deleted = models.BooleanField(default=False) # 软删除 class Meta: abstract = True ordering = ['-created_at'] # 默认排序
验证 - clean 和 full_clean
我们应该在模型层进行验证,因为模型层是数据进入数据库前的最后一道防线。
🔖 核心科普:Django 验证 vs Pydantic
很多开发者在接触了 FastAPI 或现代后端开发后,会疑惑:“既然 Pydantic 这么好用,我还需要 Django 的
full_clean吗?”答案是:需要,它们负责不同的“治安区域”。
特性 Django full_clean()Pydantic (BaseModel) 身份 数据库的守门员 业务逻辑的翻译官 核心优势 能处理数据库相关的约束(如:唯一性冲突、数据库约束)。 极速、强类型支持、极其擅长复杂嵌套 JSON 的清洗。 局限性 校验逻辑重,嵌套处理弱。 无法察觉数据库里的状态(如:ID 是否真的存在)。 💡 架构演进:现代实践中的建议
如果你在项目中引入了 Pydantic 来提升数据处理体验,我们建议:
Pydantic (入口校验):用于 Service 的入参。当你的 Service 接收一大堆复杂参数时,用 Pydantic 封装成一个 DTO (数据传输对象)。这能保证你的 Service 拿到的数据在“格式”和“基础类型”上是绝对正确的。
Django
full_clean(模型校验):在 Service 真正要save()前调用。它负责检查这个对象在当前数据库状态下是否“合法”(比如你的模型加了unique_together)。记住:Pydantic 保护你的代码不崩溃,而 Django 验证保护你的数据库不脏。
💡 进阶思考:关于“去魔法化”
我们在这里稍微提一嘴:如果你考虑使用 Pydantic 彻底取代传统的 DRF Serializer,你会发现这与后文“API 章节”中推崇的 “使用最基础 APIView 而非魔法 API” 的理念是不谋而合的。它们共同指向一个目标——让代码逻辑变得显式且可控。关于这一点,我们会在 03_API 和序列化器 章节中进行详细的论述。
为什么调用 full_clean()?
让我们看一个示例模型:
class Course(BaseModel):
name = models.CharField(unique=True, max_length=255)
start_date = models.DateField()
end_date = models.DateField()
def clean(self):
if self.start_date >= self.end_date:
raise ValidationError("End date cannot be before start date")
我们定义模型的 clean 方法,因为我们希望确保数据库中获得良好的数据。
现在,为了调用 clean 方法,必须有人在保存模型实例之前调用 full_clean。
🔖 Concept 科普:clean() 与 full_clean() 的“指挥官”关系
很多开发者会直接在逻辑里写
obj.clean(),这是不完整的。
clean():这是一个验证“钩子”。Django 默认它什么都不做。由你来重写它,编写跨字段的逻辑(例如:如果type是“视频”,那么url必须以https开头)。
full_clean():这是验证总指挥。当你调用它时,它会依次执行:
clean_fields():检查字段定义(长度、数字范围等)。
clean():执行你写的那些自定义逻辑。
validate_unique():检查数据库唯一性约束(Unique together 等)。⚠️ 致命陷阱:Django 的
Model.save()默认不会调用验证系统!如果你在 Service 层只写obj.save(),所有验证都会被绕过。这就是为什么本指南强调必须显式调用full_clean()。
⚠️ 重要注意事项:
save()不会自动调用验证python# ❌ 这样不会触发验证 course = Course(name="Test", start_date=date(2024, 12, 31), end_date=date(2024, 1, 1)) course.save() # 保存成功,但数据不合法! # ✅ 这样会触发验证 course = Course(name="Test", start_date=date(2024, 12, 31), end_date=date(2024, 1, 1)) course.full_clean() # 抛出 ValidationError course.save()
我们的建议是在服务(service)中,在调用 save 之前执行此操作:
def course_create(*, name: str, start_date: date, end_date: date) -> Course:
obj = Course(name=name, start_date=start_date, end_date=end_date)
obj.full_clean()
obj.save()
return obj
💡 为什么在 Service 中调用
full_clean()
集中验证逻辑:所有创建/更新操作都通过 service
一致性:确保无论何时创建对象都会验证
可测试性:可以单独测试 service 的验证行为
与 Django Admin 兼容:Admin 表单会自动调用
full_clean()
这也与 Django admin 配合得很好,因为那里使用的表单会在实例上触发 full_clean。
📝 Django Admin 的自动验证
当你在 Django Admin 中保存对象时:
python# Django Admin 内部会做: form = ModelForm(data=request.POST, instance=obj) if form.is_valid(): # 这里会调用 full_clean() form.save()
关于何时在模型的 clean 方法中添加验证,我们有几条经验法则:
如果我们基于模型的多个非关系字段进行验证。
如果验证本身足够简单。
🔖 "非关系字段"解释
非关系字段:普通字段,如 CharField, IntegerField, DateField 等
关系字段:ForeignKey, ManyToManyField 等需要查询其他表的字段
pythonclass Course(BaseModel): name = models.CharField(max_length=255) # 非关系字段 start_date = models.DateField() # 非关系字段 instructor = models.ForeignKey(User) # 关系字段 students = models.ManyToManyField(User) # 关系字段💡 深度解析:为什么不建议在
clean()中包含关系字段?限制在非关系型字段进行验证,主要有两个核心考量:
预防 N+1 性能黑洞:这是最实际的原因。
clean()方法是在full_clean()时触发的。如果你在一个批量导入脚本或列表页循环中对 100 个对象执行校验,而clean()里包含self.instructor.name这样的属性访问,且你没有预先做select_related,那么 Django 将会为每一个对象额外发起一次数据库查询。这会导致总查询数瞬间翻倍,产生致命的性能损耗。职责颗粒度的定义:模型层的职责是确保“对象自身的原子完整性”(这个东西长得对不对)。一旦涉及关系型字段,通常意味着进入了“业务规则”的范畴(比如:只有具备某种资格的老师才能被分配到这门课)。这类跨实体、带语境的逻辑,更适合放在 Service 层(业务逻辑层)进行统筹。 students = models.ManyToManyField(User) # 关系字段
TEXT
如果出现以下情况,验证应该移至服务层:
验证逻辑更复杂。
需要跨关系查询并获取额外数据。
💡 验证位置决策树
TEXT需要验证? ├─ 简单验证(单个字段) │ └─> 使用字段的 validators 参数 │ ├─ 多个非关系字段的简单验证 │ └─> 使用 Model.clean() │ ├─ 涉及关系字段或复杂逻辑 │ └─> 使用 Service 层 │ └─ 数据库级别的约束 └─> 使用 Meta.constraints
✅ 实际示例
python# ✅ 适合放在 Model.clean() 中 class Course(BaseModel): start_date = models.DateField() end_date = models.DateField() min_students = models.IntegerField() max_students = models.IntegerField() def clean(self): if self.start_date >= self.end_date: raise ValidationError("结束日期不能早于开始日期") if self.min_students > self.max_students: raise ValidationError("最小学生数不能大于最大学生数") # ✅ 应该放在 Service 中 def course_create(...): # 检查讲师是否有资格教授该课程(需要查询关系) if not instructor.qualifications.filter(subject=course.subject).exists(): raise ValidationError("讲师没有资格教授该科目") # 检查时间冲突(复杂查询) if Course.objects.filter( instructor=instructor, start_date__lte=end_date, end_date__gte=start_date ).exists(): raise ValidationError("讲师在此时间段已有其他课程")
💡 补充说明
可以同时在
clean和service中都有验证,但如果是这种情况,我们倾向于将事情移到service中。为什么 Service 相比
clean()更有优势?
语境感知 (Context Awareness):Service 能够识别操作的“前因后果”。例如,它知道当前是用户在前台自助修改,还是管理员在后台强制覆盖,从而应用不同的校验逻辑。而
clean()只能看到对象本身,缺乏这种场景感。显性的副作用控制 (Side Effects Control):模型层的
clean()经常会被 Django 生态(如 Admin 渲染、序列化器校验)被动触发。如果在clean()中包含较重的逻辑,会产生非预期的性能波动。而 Service 是显式的入口,校验只在你明确执行业务代码时运行一次,可预测性更强。更精准的错误表达 (Exception Granularity):在模型层你几乎只能抛出通用的
ValidationError。在 Service 层,你可以抛出具有明确业务含义的自定义异常(如AccountBalanceInsufficient),这让 API 层能更优雅地返回特定的业务代码。单一维护原则 (Single Source of Truth):既然业务动作(Action)已在 Service 层,将“准入检查”也放在一起,能让开发者在同一个地方读懂“动作前检查 -> 执行动作”的完整逻辑,无需在模型和服务文件之间反复切换。
验证 - 约束(Constraints)
如此issue 中所提议的,如果你可以使用 Django 的约束 进行验证,那么你应该以此为目标。
更少的代码要写,更少的代码要维护,即使数据是从不同地方插入的,数据库也会负责数据。
🔖 数据库约束(Database Constraints)
数据库约束是在数据库级别强制执行的规则,主要优势:
✅ 性能好(数据库原生支持)
✅ 数据一致性强(无论如何插入数据都会检查)
✅ 防止脏数据
✅ 支持并发环境
Django 支持的约束类型:
CheckConstraint:检查约束
UniqueConstraint:唯一约束
ExclusionConstraint(PostgreSQL):排除约束
让我们看一个例子!
class Course(BaseModel):
name = models.CharField(unique=True, max_length=255)
start_date = models.DateField()
end_date = models.DateField()
class Meta:
constraints = [
models.CheckConstraint(
name="start_date_before_end_date",
check=Q(start_date__lt=F("end_date"))
)
]
📝 代码详解
CheckConstraint:创建一个检查约束
name:约束的名称(会在数据库中创建)
check:检查条件,使用 Django 的 Q 对象
Q(start_date__lt=F("end_date")):
__lt:less than(小于)
F("end_date"):引用同一行的 end_date 字段
⚠️ 注意:约束命名
约束名称应该:
描述性强
使用蛇形命名法(snake_case)
避免过长
在整个数据库中唯一
python# ✅ 好的命名 name="start_date_before_end_date" name="price_positive" name="email_or_phone_required" # ❌ 不好的命名 name="constraint1" name="check" name="validation"
现在,如果我们尝试通过 course.save() 或 Course.objects.create(...) 创建新对象,我们将得到一个 IntegrityError,而不是 ValidationError。
💡 深度分析:为什么“数据库报错”不如“Python 报错”好用?
尽管数据库约束(Constraints)更安全,但它抛出的
IntegrityError在开发体验上有一个明显的痛点:它太“硬”了,且时机太晚。
统一拦截的烦恼:
ValidationError是 Django 验证系统的标准产物,DRF 或各种 Form 可以自动捕获它并转化为友好的 API 响应。而IntegrityError是数据库驱动抛出的底层异常。如果你在业务代码里不加try...except拦截,整个请求就会直接崩掉(500 错误)。代码的可读性(拒绝 try 满天飞):我们追求的是“声明式验证”。如果依赖
IntegrityError,你被迫在每一个save()动作周围写大量的预防性代码。相比之下,调用一次full_clean()然后让全局异常处理器去处理ValidationError要干净得多。报错信息的“人性化”:数据库返回的消息通常类似于
duplicate key value violates unique constraint "..."。这对用户毫无意义。而 Python 级别的ValidationError可以携带具体的字段信息(Field-specific errors),告诉前端:“‘课程名称’已存在”,这种精确的错误回显是提升 DX(开发者体验)的关键。这实际上可能是这种方法的一个缺点(从 Django 4.1 开始这不再是问题。请查看下面的额外部分。),因为现在我们必须处理
IntegrityError,它的错误消息也是数据库返回的,不够友好。
⚠️ Django 版本差异
Django 4.1 之前:
pythoncourse = Course.objects.create( name="Test", start_date=date(2024, 12, 31), end_date=date(2024, 1, 1) ) # 抛出 IntegrityError(数据库级别错误)Django 4.1 及之后:
pythoncourse = Course( name="Test", start_date=date(2024, 12, 31), end_date=date(2024, 1, 1) ) course.full_clean() # 现在会检查约束! # 抛出 ValidationError(Python 级别错误)
👀 ⚠️ 👀 自 Django 4.1 起,调用
.full_clean()也会检查模型约束!这实际上消除了上面提到的缺点,因为如果模型约束检查失败,你将得到一个友好的
ValidationError(如果你通过Model.objects.create(...)创建对象,缺点仍然存在)更多信息请参考:https://docs.djangoproject.com/en/4.1/ref/models/instances/#validating-objects
示例测试用例请查看 Styleguide-Example 仓库:https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/common/tests/models/test_random_model.py#L12
💡 最佳实践:结合使用
python# 在 Service 中 def course_create(*, name: str, start_date: date, end_date: date) -> Course: course = Course(name=name, start_date=start_date, end_date=end_date) # full_clean() 会检查约束(Django 4.1+) course.full_clean() # 抛出友好的 ValidationError course.save() # 如果上面通过,这里不会失败 return course
Django 关于约束的文档相当简洁,因此你可以查看 Adam Johnson 的以下文章,了解如何使用它们的示例:
💡 约束的实际应用场景
python# 1. 确保价格为正数 class Product(BaseModel): price = models.DecimalField(max_digits=10, decimal_places=2) class Meta: constraints = [ models.CheckConstraint( name="price_positive", check=Q(price__gte=0) ) ] # 2. 确保至少提供一种联系方式 class Contact(BaseModel): email = models.EmailField(blank=True) phone = models.CharField(max_length=20, blank=True) class Meta: constraints = [ models.CheckConstraint( name="email_or_phone_required", check=Q(email__gt='') | Q(phone__gt='') ) ] # 3. 防止自我关注 class Follow(BaseModel): follower = models.ForeignKey(User, related_name='following') following = models.ForeignKey(User, related_name='followers') class Meta: constraints = [ models.CheckConstraint( name="prevent_self_follow", check=~Q(follower=F('following')) ), models.UniqueConstraint( fields=['follower', 'following'], name='unique_follow' ) ]
属性(Properties)
模型属性是从模型实例快速访问派生值的好方法。
🔖 Python 属性(Property)
@property装饰器可以将方法转换为属性:
调用时不需要括号
适合计算派生值
不应该改变对象状态
不应该执行耗时操作
例如,让我们看看 Course 模型的 has_started 和 has_finished 属性:
from django.utils import timezone
from django.core.exceptions import ValidationError
class Course(BaseModel):
name = models.CharField(unique=True, max_length=255)
start_date = models.DateField()
end_date = models.DateField()
def clean(self):
if self.start_date >= self.end_date:
raise ValidationError("End date cannot be before start date")
@property
def has_started(self) -> bool:
now = timezone.now()
return self.start_date <= now.date()
@property
def has_finished(self) -> bool:
now = timezone.now()
return self.end_date <= now.date()
📝 代码详解
@property:装饰器,使方法可以像属性一样访问
-> bool:类型提示,表示返回布尔值
timezone.now():获取当前时区感知时间
.date():将 datetime 转换为 date
这些属性很方便,因为我们现在可以在序列化器中引用它们或在模板中使用它们。
# 在视图或序列化器中使用
course = Course.objects.get(id=1)
if course.has_started:
print("课程已开始")
# 在 Django 模板中使用
{% if course.has_started %} # 不用加括号
<p>课程已开始</p>
{% endif %}
关于何时向模型添加属性,我们有几条经验法则:
如果我们需要一个简单的派生值,基于非关系模型字段,为此添加一个
@property。如果派生值的计算足够简单。
💡 "简单"的定义
简单的计算通常指:
不需要数据库查询
执行时间短(< 1ms)
不涉及外部 API 调用
逻辑清晰易懂
在以下情况下,属性应该是其他东西(service、selector、utility):
如果我们需要跨越多个关系或获取额外数据。
如果计算更复杂。
⚠️ 避免在属性中执行查询
python# ❌ 错误:属性中执行数据库查询 class Course(BaseModel): @property def student_count(self) -> int: return self.students.count() # 每次访问都会查询数据库! @property def instructor_name(self) -> str: return self.instructor.full_name # 外键,可能导致 N+1 查询 # ✅ 正确:使用 selector 或在查询时优化 def course_list_with_counts(): return Course.objects.annotate( student_count=Count('students') ).select_related('instructor')
💡 实际示例:好的属性用法
pythonclass Order(BaseModel): subtotal = models.DecimalField(max_digits=10, decimal_places=2) tax_rate = models.DecimalField(max_digits=5, decimal_places=4) discount = models.DecimalField(max_digits=10, decimal_places=2, default=0) @property def tax_amount(self) -> Decimal: """计算税额""" return self.subtotal * self.tax_rate @property def total(self) -> Decimal: """计算总额""" return self.subtotal + self.tax_amount - self.discount @property def discount_percentage(self) -> Decimal: """计算折扣百分比""" if self.subtotal == 0: return Decimal('0') return (self.discount / self.subtotal) * 100
请记住,这些规则是模糊的,因为上下文通常很重要。使用你的最佳判断!
💡 决策树:属性 vs Selector
TEXT需要派生值? ├─ 只使用当前对象的字段? │ ├─ 计算简单? │ │ └─> 使用 @property │ └─ 计算复杂? │ └─> 考虑 @property 或辅助方法 │ └─ 需要关联对象或额外查询? └─> 使用 Selector
方法(Methods)
模型方法也是非常强大的工具,可以建立在属性之上。
🔖 方法 vs 属性
属性:不接受参数(除了 self),像字段一样访问
方法:可以接受参数,需要括号调用
让我们看一个带有 is_within(self, x) 方法的示例:
from django.core.exceptions import ValidationError
from django.utils import timezone
class Course(BaseModel):
name = models.CharField(unique=True, max_length=255)
start_date = models.DateField()
end_date = models.DateField()
def clean(self):
if self.start_date >= self.end_date:
raise ValidationError("End date cannot be before start date")
@property
def has_started(self) -> bool:
now = timezone.now()
return self.start_date <= now.date()
@property
def has_finished(self) -> bool:
now = timezone.now()
return self.end_date <= now.date()
def is_within(self, x: date) -> bool:
return self.start_date <= x <= self.end_date
📝 代码说明
is_within不是属性,因为它需要一个参数。所以它是一个方法。
# 使用示例
course = Course.objects.get(id=1)
today = date.today()
if course.is_within(today):
print("课程正在进行中")
在模型中使用方法的另一个好方法是用于属性设置,当设置一个属性必须始终跟随设置另一个属性为派生值时。
💡 属性设置方法的价值
当多个字段需要同步更新时,使用方法可以:
确保一致性
避免遗忘某个字段
封装复杂逻辑
提供清晰的 API
一个例子:
from django.utils.crypto import get_random_string
from django.conf import settings
from django.utils import timezone
class Token(BaseModel):
secret = models.CharField(max_length=255, unique=True)
expiry = models.DateTimeField(blank=True, null=True)
def set_new_secret(self):
now = timezone.now()
self.secret = get_random_string(255)
self.expiry = now + settings.TOKEN_EXPIRY_TIMEDELTA
return self
📝 代码详解
get_random_string(255):生成 255 字符的随机字符串
settings.TOKEN_EXPIRY_TIMEDELTA:从配置中读取过期时间
return self:返回自身,支持链式调用
现在,我们可以安全地调用 set_new_secret,它会为 secret 和 expiry 生成正确的值。
# 使用示例
token = Token()
token.set_new_secret() # 同时设置 secret 和 expiry
token.save()
# 支持链式调用
token = Token().set_new_secret().save()
💡 实际应用场景
pythonclass User(BaseModel): email = models.EmailField() password = models.CharField(max_length=255) password_changed_at = models.DateTimeField(null=True) def set_password(self, raw_password: str): """设置密码时同时更新修改时间""" from django.contrib.auth.hashers import make_password self.password = make_password(raw_password) self.password_changed_at = timezone.now() return self class Article(BaseModel): title = models.CharField(max_length=255) slug = models.SlugField(unique=True, blank=True) def set_slug(self): """根据标题生成 slug""" from django.utils.text import slugify if not self.slug: self.slug = slugify(self.title) return self class Order(BaseModel): status = models.CharField(max_length=20) completed_at = models.DateTimeField(null=True) def mark_as_completed(self): """标记为完成时设置时间戳""" self.status = 'completed' self.completed_at = timezone.now() return self
关于何时向模型添加方法,我们有几条经验法则:
如果我们需要一个简单的派生值,需要参数,基于非关系模型字段,为此添加一个方法。
如果派生值的计算足够简单。
如果设置一个属性总是需要设置其他属性的值,使用方法。
⚠️ 方法不应该做的事情
python# ❌ 避免:方法中执行复杂业务逻辑 class Order(BaseModel): def process(self): # 发送邮件 send_confirmation_email(self.user) # 调用支付 API payment_gateway.charge(self) # 更新库存 Inventory.objects.update(...) # 这些都应该在 Service 中!
在以下情况下,方法应该是其他东西(service、selector、utility):
如果我们需要跨越多个关系或获取额外数据。
如果计算更复杂。
请记住,这些规则是模糊的,因为上下文通常很重要。使用你的最佳判断!
💡 方法的最佳实践
保持简单:方法应该快速且简单
避免副作用:不要在方法中调用外部 API 或发送邮件
返回 self:如果方法修改对象,考虑返回 self 支持链式调用
类型提示:使用类型提示提高代码可读性
文档字符串:为复杂方法添加说明
测试
只有当模型有额外内容时才需要测试模型 - 比如验证、属性或方法。
💡 测试原则
不需要测试:
❌ Django 自带的功能(字段定义、基本 CRUD)
❌ 数据库约束(数据库会处理)
需要测试:
✅ 自定义验证逻辑
✅ 属性计算
✅ 方法行为
✅ 约束在 full_clean() 中的表现(Django 4.1+)
这是一个示例:
from datetime import timedelta
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.utils import timezone
from project.some_app.models import Course
class CourseTests(TestCase):
def test_course_end_date_cannot_be_before_start_date(self):
start_date = timezone.now()
end_date = timezone.now() - timedelta(days=1)
course = Course(start_date=start_date, end_date=end_date)
with self.assertRaises(ValidationError):
course.full_clean()
📝 测试代码详解
TestCase:Django 的测试基类,提供测试数据库
timedelta(days=1):1 天的时间差
with self.assertRaises(ValidationError):断言会抛出异常
course.full_clean():触发验证
💡 小贴士:关于测试框架的选择
这里的示例为了保持全栈框架的零依赖通用性,使用了 Django 内置的
TestCase。但在实际的工程项目中,我们强烈建议你使用pytest及其插件pytest-django来编写测试。内置的unittest风格往往需要继承繁琐的大类,且断言语法较为陈旧;而pytest能让你的测试代码更简洁、更具可读性。详见 06_测试和 Celery 章节。
这里要注意几件事:
我们断言如果调用
full_clean,将会抛出验证错误。我们根本没有访问数据库,因为没有必要。这可以加速某些测试。
💡 不访问数据库的好处
python# ✅ 快速测试:不访问数据库 def test_property_calculation(self): order = Order(subtotal=100, tax_rate=0.1) self.assertEqual(order.tax_amount, Decimal('10')) # 没有 .save(),测试更快 # ⚠️ 慢速测试:访问数据库 def test_with_database(self): order = Order.objects.create(subtotal=100, tax_rate=0.1) self.assertEqual(order.tax_amount, Decimal('10')) # 涉及数据库操作,较慢
💡 更多测试示例
pythonclass CourseTests(TestCase): def test_has_started_returns_true_when_started(self): """测试课程已开始的情况""" past_date = timezone.now().date() - timedelta(days=1) future_date = timezone.now().date() + timedelta(days=30) course = Course(start_date=past_date, end_date=future_date) self.assertTrue(course.has_started) def test_has_started_returns_false_when_not_started(self): """测试课程未开始的情况""" future_start = timezone.now().date() + timedelta(days=1) future_end = timezone.now().date() + timedelta(days=30) course = Course(start_date=future_start, end_date=future_end) self.assertFalse(course.has_started) def test_is_within_returns_true_for_date_in_range(self): """测试日期在范围内""" start = date(2024, 1, 1) end = date(2024, 12, 31) test_date = date(2024, 6, 15) course = Course(start_date=start, end_date=end) self.assertTrue(course.is_within(test_date)) def test_is_within_returns_false_for_date_outside_range(self): """测试日期在范围外""" start = date(2024, 1, 1) end = date(2024, 12, 31) test_date = date(2025, 1, 1) course = Course(start_date=start, end_date=end) self.assertFalse(course.is_within(test_date))
✅ 测试最佳实践
一个测试一个场景:每个测试方法只测试一个具体情况
清晰的命名:测试名称应该描述测试内容
AAA 模式:Arrange(准备)、Act(执行)、Assert(断言)
避免数据库:如果不必要,就不要访问数据库
边界测试:测试边界情况和异常情况
pythondef test_something(self): # Arrange(准备) start_date = date(2024, 1, 1) end_date = date(2024, 12, 31) # Act(执行) course = Course(start_date=start_date, end_date=end_date) result = course.is_within(date(2024, 6, 15)) # Assert(断言) self.assertTrue(result)
📝 本章总结
本章介绍了 Django 模型层的最佳实践:
核心要点
基础模型(BaseModel)
使用抽象基类统一公共字段
推荐包含
created_at和updated_at使用
timezone.now而不是datetime.now
数据验证
简单验证:使用
Model.clean()方法复杂验证:移至 Service 层
数据库约束:优先使用
Meta.constraints始终调用
full_clean():在 Service 中保存前验证
属性和方法
属性:简单、快速的派生值
方法:需要参数或修改多个字段
避免:在属性/方法中执行数据库查询
测试策略
只测试自定义逻辑
尽量避免访问数据库
使用清晰的测试命名
决策指南
何时使用 Model.clean()?
✅ 多个非关系字段的简单验证
✅ 逻辑清晰简单
❌ 涉及关系查询
❌ 复杂业务规则
何时使用 @property?
✅ 基于当前对象字段的简单计算
✅ 不需要参数
✅ 执行快速
❌ 需要数据库查询
❌ 复杂计算逻辑
何时使用方法?
✅ 需要参数
✅ 需要同时设置多个字段
✅ 简单的派生逻辑
❌ 复杂业务流程
❌ 外部 API 调用
记住
模型层的职责是定义数据结构和简单的数据验证,而不是处理复杂的业务逻辑。
下一章预告: 在下一章中,我们将详细介绍服务层(Services)和选择器(Selectors),了解如何组织业务逻辑和数据查询。
相关章节: