01-模型层(models)

Tomy
23 分钟阅读
60 次浏览
模型层规范:如何正确使用 clean 方法、属性和自定义方法,以及如何进行模型级测试。
DjangoModels数据库设计

Django Styleguide - 模型层(Models)

模型层概述

模型应该负责数据模型,而不是其他更多的事情。

💡 核心思想

Django 的 Model 层应该:

  • ✅ 定义数据结构

  • ✅ 定义字段类型和约束

  • ✅ 简单的数据验证

  • ✅ 简单的派生属性

不应该:

  • ❌ 包含复杂的业务逻辑

  • ❌ 进行外部 API 调用

  • ❌ 发送邮件或通知

  • ❌ 处理复杂的数据转换


基础模型(Base Model)

定义一个可以继承的 BaseModel 是个好主意。

通常,像 created_atupdated_at 这样的字段是放入 BaseModel 的完美候选。

🔖 设计模式:抽象基类

使用抽象基类(Abstract Base Class)可以:

  • 避免代码重复

  • 确保所有模型的一致性

  • 便于统一修改共同字段

  • 不会创建额外的数据库表

这是一个 BaseModel 示例:

python
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

python
class SomeModel(BaseModel):
    pass

现在 SomeModel 会自动拥有 created_atupdated_at 字段。

💡 扩展建议

根据项目需求,你可以在 BaseModel 中添加:

python
class 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']  # 默认排序

验证 - cleanfull_clean

我们应该在模型层进行验证,因为模型层是数据进入数据库前的最后一道防线。

🔖 核心科普:Django 验证 vs Pydantic

很多开发者在接触了 FastAPI 或现代后端开发后,会疑惑:“既然 Pydantic 这么好用,我还需要 Django 的 full_clean 吗?”

答案是:需要,它们负责不同的“治安区域”。

特性Django full_clean()Pydantic (BaseModel)
身份数据库的守门员业务逻辑的翻译官
核心优势能处理数据库相关的约束(如:唯一性冲突、数据库约束)。极速、强类型支持、极其擅长复杂嵌套 JSON 的清洗。
局限性校验逻辑重,嵌套处理弱。无法察觉数据库里的状态(如:ID 是否真的存在)。

💡 架构演进:现代实践中的建议

如果你在项目中引入了 Pydantic 来提升数据处理体验,我们建议:

  1. Pydantic (入口校验):用于 Service 的入参。当你的 Service 接收一大堆复杂参数时,用 Pydantic 封装成一个 DTO (数据传输对象)。这能保证你的 Service 拿到的数据在“格式”和“基础类型”上是绝对正确的。

  2. Django full_clean (模型校验):在 Service 真正要 save() 前调用。它负责检查这个对象在当前数据库状态下是否“合法”(比如你的模型加了 unique_together)。

记住:Pydantic 保护你的代码不崩溃,而 Django 验证保护你的数据库不脏。


💡 进阶思考:关于“去魔法化”

我们在这里稍微提一嘴:如果你考虑使用 Pydantic 彻底取代传统的 DRF Serializer,你会发现这与后文“API 章节”中推崇的 “使用最基础 APIView 而非魔法 API” 的理念是不谋而合的。它们共同指向一个目标——让代码逻辑变得显式且可控。关于这一点,我们会在 03_API 和序列化器 章节中进行详细的论述。

为什么调用 full_clean()

让我们看一个示例模型:

python
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():这是验证总指挥。当你调用它时,它会依次执行:

    1. clean_fields():检查字段定义(长度、数字范围等)。

    2. clean():执行你写的那些自定义逻辑。

    3. 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 之前执行此操作:

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

  1. 集中验证逻辑:所有创建/更新操作都通过 service

  2. 一致性:确保无论何时创建对象都会验证

  3. 可测试性:可以单独测试 service 的验证行为

  4. 与 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 方法中添加验证,我们有几条经验法则:

  1. 如果我们基于模型的多个非关系字段进行验证。

  2. 如果验证本身足够简单。

🔖 "非关系字段"解释

  • 非关系字段:普通字段,如 CharField, IntegerField, DateField 等

  • 关系字段:ForeignKey, ManyToManyField 等需要查询其他表的字段

python
class Course(BaseModel):
    name = models.CharField(max_length=255)      # 非关系字段
    start_date = models.DateField()              # 非关系字段
    instructor = models.ForeignKey(User)         # 关系字段
    students = models.ManyToManyField(User)      # 关系字段

💡 深度解析:为什么不建议在 clean() 中包含关系字段?

限制在非关系型字段进行验证,主要有两个核心考量:

  1. 预防 N+1 性能黑洞:这是最实际的原因。clean() 方法是在 full_clean() 时触发的。如果你在一个批量导入脚本或列表页循环中对 100 个对象执行校验,而 clean() 里包含 self.instructor.name 这样的属性访问,且你没有预先做 select_related,那么 Django 将会为每一个对象额外发起一次数据库查询。这会导致总查询数瞬间翻倍,产生致命的性能损耗。

  2. 职责颗粒度的定义:模型层的职责是确保“对象自身的原子完整性”(这个东西长得对不对)。一旦涉及关系型字段,通常意味着进入了“业务规则”的范畴(比如:只有具备某种资格的老师才能被分配到这门课)。这类跨实体、带语境的逻辑,更适合放在 Service 层(业务逻辑层)进行统筹。 students = models.ManyToManyField(User) # 关系字段

TEXT

如果出现以下情况,验证应该移至服务层:

  1. 验证逻辑更复杂。

  2. 需要跨关系查询并获取额外数据。

💡 验证位置决策树

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("讲师在此时间段已有其他课程")

💡 补充说明

可以同时在 cleanservice 中都有验证,但如果是这种情况,我们倾向于将事情移到 service 中。

为什么 Service 相比 clean() 更有优势?

  1. 语境感知 (Context Awareness):Service 能够识别操作的“前因后果”。例如,它知道当前是用户在前台自助修改,还是管理员在后台强制覆盖,从而应用不同的校验逻辑。而 clean() 只能看到对象本身,缺乏这种场景感。

  2. 显性的副作用控制 (Side Effects Control):模型层的 clean() 经常会被 Django 生态(如 Admin 渲染、序列化器校验)被动触发。如果在 clean() 中包含较重的逻辑,会产生非预期的性能波动。而 Service 是显式的入口,校验只在你明确执行业务代码时运行一次,可预测性更强。

  3. 更精准的错误表达 (Exception Granularity):在模型层你几乎只能抛出通用的 ValidationError。在 Service 层,你可以抛出具有明确业务含义的自定义异常(如 AccountBalanceInsufficient),这让 API 层能更优雅地返回特定的业务代码。

  4. 单一维护原则 (Single Source of Truth):既然业务动作(Action)已在 Service 层,将“准入检查”也放在一起,能让开发者在同一个地方读懂“动作前检查 -> 执行动作”的完整逻辑,无需在模型和服务文件之间反复切换。


验证 - 约束(Constraints)

如此issue 中所提议的,如果你可以使用 Django 的约束 进行验证,那么你应该以此为目标。

更少的代码要写,更少的代码要维护,即使数据是从不同地方插入的,数据库也会负责数据。

🔖 数据库约束(Database Constraints)

数据库约束是在数据库级别强制执行的规则,主要优势:

  • ✅ 性能好(数据库原生支持)

  • ✅ 数据一致性强(无论如何插入数据都会检查)

  • ✅ 防止脏数据

  • ✅ 支持并发环境

Django 支持的约束类型:

  • CheckConstraint:检查约束

  • UniqueConstraint:唯一约束

  • ExclusionConstraint(PostgreSQL):排除约束

让我们看一个例子!

python
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 在开发体验上有一个明显的痛点:它太“硬”了,且时机太晚。

  1. 统一拦截的烦恼ValidationError 是 Django 验证系统的标准产物,DRF 或各种 Form 可以自动捕获它并转化为友好的 API 响应。而 IntegrityError 是数据库驱动抛出的底层异常。如果你在业务代码里不加 try...except 拦截,整个请求就会直接崩掉(500 错误)。

  2. 代码的可读性(拒绝 try 满天飞):我们追求的是“声明式验证”。如果依赖 IntegrityError,你被迫在每一个 save() 动作周围写大量的预防性代码。相比之下,调用一次 full_clean() 然后让全局异常处理器去处理 ValidationError 要干净得多。

  3. 报错信息的“人性化”:数据库返回的消息通常类似于 duplicate key value violates unique constraint "..."。这对用户毫无意义。而 Python 级别的 ValidationError 可以携带具体的字段信息(Field-specific errors),告诉前端:“‘课程名称’已存在”,这种精确的错误回显是提升 DX(开发者体验)的关键。

这实际上可能是这种方法的一个缺点(从 Django 4.1 开始这不再是问题。请查看下面的额外部分。),因为现在我们必须处理 IntegrityError,它的错误消息也是数据库返回的,不够友好。

⚠️ Django 版本差异

Django 4.1 之前:

python
course = Course.objects.create(
    name="Test",
    start_date=date(2024, 12, 31),
    end_date=date(2024, 1, 1)
)
# 抛出 IntegrityError(数据库级别错误)

Django 4.1 及之后:

python
course = 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 的以下文章,了解如何使用它们的示例:

  1. 使用 Django 检查约束确保只设置一个字段

  2. Django 的字段选择不约束你的数据

  3. 使用 Django 检查约束防止自我关注

💡 约束的实际应用场景

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_startedhas_finished 属性:

python
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

这些属性很方便,因为我们现在可以在序列化器中引用它们或在模板中使用它们。

python
# 在视图或序列化器中使用
course = Course.objects.get(id=1)
if course.has_started:
    print("课程已开始")

# 在 Django 模板中使用
{% if course.has_started %} # 不用加括号
    <p>课程已开始</p>
{% endif %}

关于何时向模型添加属性,我们有几条经验法则:

  1. 如果我们需要一个简单的派生值,基于非关系模型字段,为此添加一个 @property

  2. 如果派生值的计算足够简单。

💡 "简单"的定义

简单的计算通常指:

  • 不需要数据库查询

  • 执行时间短(< 1ms)

  • 不涉及外部 API 调用

  • 逻辑清晰易懂

在以下情况下,属性应该是其他东西(service、selector、utility):

  1. 如果我们需要跨越多个关系或获取额外数据。

  2. 如果计算更复杂。

⚠️ 避免在属性中执行查询

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

💡 实际示例:好的属性用法

python
class 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) 方法的示例:

python
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 不是属性,因为它需要一个参数。所以它是一个方法。

python
# 使用示例
course = Course.objects.get(id=1)
today = date.today()
if course.is_within(today):
    print("课程正在进行中")

在模型中使用方法的另一个好方法是用于属性设置,当设置一个属性必须始终跟随设置另一个属性为派生值时。

💡 属性设置方法的价值

当多个字段需要同步更新时,使用方法可以:

  • 确保一致性

  • 避免遗忘某个字段

  • 封装复杂逻辑

  • 提供清晰的 API

一个例子:

python
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,它会为 secretexpiry 生成正确的值。

python
# 使用示例
token = Token()
token.set_new_secret()  # 同时设置 secret 和 expiry
token.save()

# 支持链式调用
token = Token().set_new_secret().save()

💡 实际应用场景

python
class 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

关于何时向模型添加方法,我们有几条经验法则:

  1. 如果我们需要一个简单的派生值,需要参数,基于非关系模型字段,为此添加一个方法。

  2. 如果派生值的计算足够简单。

  3. 如果设置一个属性总是需要设置其他属性的值,使用方法。

⚠️ 方法不应该做的事情

python
# ❌ 避免:方法中执行复杂业务逻辑
class Order(BaseModel):
    def process(self):
        # 发送邮件
        send_confirmation_email(self.user)
        # 调用支付 API
        payment_gateway.charge(self)
        # 更新库存
        Inventory.objects.update(...)
        # 这些都应该在 Service 中!

在以下情况下,方法应该是其他东西(service、selector、utility):

  1. 如果我们需要跨越多个关系或获取额外数据。

  2. 如果计算更复杂。

请记住,这些规则是模糊的,因为上下文通常很重要。使用你的最佳判断!

💡 方法的最佳实践

  1. 保持简单:方法应该快速且简单

  2. 避免副作用:不要在方法中调用外部 API 或发送邮件

  3. 返回 self:如果方法修改对象,考虑返回 self 支持链式调用

  4. 类型提示:使用类型提示提高代码可读性

  5. 文档字符串:为复杂方法添加说明


测试

只有当模型有额外内容时才需要测试模型 - 比如验证、属性或方法。

💡 测试原则

不需要测试:

  • ❌ Django 自带的功能(字段定义、基本 CRUD)

  • ❌ 数据库约束(数据库会处理)

需要测试:

  • ✅ 自定义验证逻辑

  • ✅ 属性计算

  • ✅ 方法行为

  • ✅ 约束在 full_clean() 中的表现(Django 4.1+)

这是一个示例:

python
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 章节。

这里要注意几件事:

  1. 我们断言如果调用 full_clean,将会抛出验证错误。

  2. 我们根本没有访问数据库,因为没有必要。这可以加速某些测试。

💡 不访问数据库的好处

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'))
    # 涉及数据库操作,较慢

💡 更多测试示例

python
class 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))

测试最佳实践

  1. 一个测试一个场景:每个测试方法只测试一个具体情况

  2. 清晰的命名:测试名称应该描述测试内容

  3. AAA 模式:Arrange(准备)、Act(执行)、Assert(断言)

  4. 避免数据库:如果不必要,就不要访问数据库

  5. 边界测试:测试边界情况和异常情况

python
def 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 模型层的最佳实践:

核心要点

  1. 基础模型(BaseModel)

    • 使用抽象基类统一公共字段

    • 推荐包含 created_atupdated_at

    • 使用 timezone.now 而不是 datetime.now

  2. 数据验证

    • 简单验证:使用 Model.clean() 方法

    • 复杂验证:移至 Service 层

    • 数据库约束:优先使用 Meta.constraints

    • 始终调用 full_clean():在 Service 中保存前验证

  3. 属性和方法

    • 属性:简单、快速的派生值

    • 方法:需要参数或修改多个字段

    • 避免:在属性/方法中执行数据库查询

  4. 测试策略

    • 只测试自定义逻辑

    • 尽量避免访问数据库

    • 使用清晰的测试命名

决策指南

何时使用 Model.clean()?

  • ✅ 多个非关系字段的简单验证

  • ✅ 逻辑清晰简单

  • ❌ 涉及关系查询

  • ❌ 复杂业务规则

何时使用 @property?

  • ✅ 基于当前对象字段的简单计算

  • ✅ 不需要参数

  • ✅ 执行快速

  • ❌ 需要数据库查询

  • ❌ 复杂计算逻辑

何时使用方法?

  • ✅ 需要参数

  • ✅ 需要同时设置多个字段

  • ✅ 简单的派生逻辑

  • ❌ 复杂业务流程

  • ❌ 外部 API 调用

记住

模型层的职责是定义数据结构和简单的数据验证,而不是处理复杂的业务逻辑。


下一章预告: 在下一章中,我们将详细介绍服务层(Services)和选择器(Selectors),了解如何组织业务逻辑和数据查询。


相关章节: