如何使用 JSONField 和 Pydantic 在 Django 中构建灵活的数据模型

在本文中,我将引导您了解如何使用 Django 的 JSONField(`JSON` 和 `JSONB` 包装器)来建模半结构化数据,以及如何使用 Pydantic 对该数据强制实施模式——这种方法对于 Python Web 开发人员来说应该很自然。

灵活的类型定义

让我们考虑一个处理付款的系统,例如“交易”表。它看起来像这样:

from django.db import models

class Transaction(models.Model):
    # Other relevant fields...
    payment_method = models.JSONField(default=dict, null=True, blank=True)

我们的重点是“payment_method”字段。在实际情况下,我们将有现有的处理付款的方法:

  • 信用卡
  • PayPal
  • 先买后付
  • 加密货币
  • 我们的系统必须能够适应存储每种支付方式所需的特定数据,同时保持一致且可验证的结构。

    我们将使用 Pydantic 为不同的付款方式定义精确的模式:

    from typing import Optional
    from pydantic import BaseModel
    
    class CreditCardSchema(BaseModel):
        last_four: str
        expiry_month: int
        expiry_year: int
        cvv: str
    
    
    class PayPalSchema(BaseModel):
        email: EmailStr
        account_id: str
    
    
    class CryptoSchema(BaseModel):
        wallet_address: str
        network: Optional[str] = None
    
    
    class BillingAddressSchema(BaseModel):
        street: str
        city: str
        country: str
        postal_code: str
        state: Optional[str] = None
    
    
    class PaymentMethodSchema(BaseModel):
        credit_card: Optional[CreditCardSchema] = None
        paypal: Optional[PayPalSchema] = None
        crypto: Optional[CryptoSchema] = None
        billing_address: Optional[BillingAddressSchema] = None

    这种方法有几个显著的好处:

  • 一次只能有一种付款方式可以具有非空值。
  • 无需复杂的数据库迁移即可轻松扩展或修改。
  • 确保模型级别的数据完整性。
  • 为了在我们的“payment_method”字段上强制执行模式,我们利用 Pydantic 模型来确保传递给该字段的任何数据都符合我们定义的模式。

    from typing import Optional, Mapping, Type, NoReturn
    from pydantic import ValidationError as PydanticValidationError
    from django.core.exceptions import ValidationError
    
    def payment_method_validator(value: Optional[dict]) -> Optional[Type[BaseModel] | NoReturn]:
        if value is None:
            return
    
        if not isinstance(value, Mapping):
            raise TypeError("Payment method must be a dictionary")
    
        try:
            PaymentMethodSchema(**value)
        except (TypeError, PydanticValidationError) as e:
            raise ValidationError(f"Invalid payment method: {str(e)}")

    在这里,我们执行一些检查,以确保输入验证器的数据属于正确类型,以便 Pydantic 可以对其进行验证。对于可空值,我们不执行任何操作,如果传入的值不是 Mapping 类型的子类(例如 `Dict` 或 `OrderedDict`),则会引发类型错误。

    当我们使用传递给构造函数的值创建 Pydantic 模型的实例时。如果值的结构不符合 `PaymentMethodSchema` 定义的模式,Pydantic 将引发验证错误。例如,如果我们为 `PayPalSchema` 中的 `email` 字段传递了无效的电子邮件值,Pydantic 将引发如下验证错误:

    ValidationError: 1 validation error for PaymentMethodSchema
    paypal.email
      value is not a valid email address: An email address must have an @-sign. [type=value_error, input_value='Check me out on LinkedIn: https://linkedin.com/in/daniel-c-olah', input_type=str]

    我们可以通过两种方式强制执行此验证:

  • 自定义验证方法 在保存过程中,我们调用验证函数以确保付款方式与预期模式匹配。 from django.db import models class Transaction(models.Model): # ... other fields ... payment_method = models.JSONField(null=True, blank=True) def save(self, *args, **kwargs): # Override save method to include custom validation payment_method_validator(self.payment_method) super().save(*args, **kwargs) 虽然有效,但这种方法在 Django 中会变得繁琐且不太符合习惯。我们甚至可以用执行相同操作的类方法替换该函数,以使代码更简洁。
  • 使用字段验证器 此方法利用 Django 的内置字段验证机制: from django.db import models class Transaction(models.Model): # 验证器直接附加到字段 payment_method = models.JSONField( help_text="Extensible payment method info.", validators=[payment_method_validator], null=True, blank=True ) def save(self, *args, **kwargs): # 确保触发完整验证 self.full_clean() super().save(*args, **kwargs) 此方法在灵活性和对 payment_method 字段中存储的值的控制之间取得平衡。它使我们能够适应未来的需求变化,而不会损害该字段中现有数据的完整性。例如,我们可以在 Paystack 模式中包含一个 Paystack ID 字段。这种变化是无缝的,因为我们不必处理复杂的数据库迁移。
  • 我们甚至可以在将来轻松添加“pay_later”方法。字段的类型也可以改变,而且我们不会面临数据库字段迁移限制,比如从整数主键迁移到 UUID 主键时遇到的限制。您可以在此处查看完整代码,以完全理解该概念。

    反规范化

    非规范化涉及故意在多个文档或集合中复制数据,以优化性能和可扩展性。这种方法与传统关系数据库中使用的严格规范化形成鲜明对比,NoSQL 数据库通过引入灵活的面向文档的存储范例,在推广非规范化方面发挥了重要作用。

    考虑一个电子商务场景,其中产品和订单有单独的表。当客户下订单时,必须捕获购物车中包含的产品详细信息的快照。我们不会引用当前产品记录(这些记录可能会因更新或删除而随时间而变化),而是将产品信息直接存储在订单中。这可确保订单保留其原始上下文和完整性,反映购买时产品的确切状态。非规范化在实现这种一致性方面起着至关重要的作用。

    一种可能的方法可能是在订单表中复制一些产品字段。但是,这种方法可能会带来可扩展性挑战并损害订单架构的凝聚力。更有效的解决方案是将相关产品字段序列化为 JSON 结构,从而允许订单维护产品的独立记录,而无需依赖外部查询。以下代码说明了此技术:

    from uuid import UUID
    from typing import Optional, Any
    from pydantic import constr, BaseModel, ValidationError as PydanticValidationError
    from django.db import models
    from django.core.exceptions import Validation as ValidationError
    
    class FrozenProductSchema(BaseModel):
        id: UUID
        type: Any
        name: constr(min_length=2, strict=True)
        description = ""
        pricing: dict
    
    class FrozenProductsSchema(BaseModel):
        products: list[FrozenProductSchema]
    
    def frozen_products_validator(value: Optional[list[dict]]):
        if not value:
            return
        try:
            FrozenProductsSchema({"products": value})
        except PydanticValidationError as e:
            raise ValidationError(e.errors())
    
    class Order(models.Model):
        # Other fields 
        products = models.JSONField(
                help_text="Validated snapshot of product details at time of order",
                validators=[frozen_products_validator]
                null=True,
                blank=True,
        )

    由于我们在上一节中已经介绍了大部分概念,您应该开始了解 Pydantic 在所有这些方面的作用。在上面的示例中,我们使用 Pydantic 来验证与订单相关的产品列表。通过为产品结构定义架构,Pydantic 可确保添加到订单中的每个产品都符合预期要求。如果提供的数据不符合架构,Pydantic 会引发验证错误。

    在 Django 中查询 JSONField

    我们可以像在 Django 字段中执行查找一样查询“JSONField”键。以下是根据我们的用例给出的几个示例。

    # Find transactions with credit card payments
    credit_card_transactions = Transaction.objects.filter(
        payment_method__credit_card__isnull=False
    )
    
    # Find transactions from a specific country in billing address
    us_transactions = Transaction.objects.filter(
        payment_method__billing_address__country='USA'
    )
    
    # Complex nested filtering
    complex_filter = Transaction.objects.filter(
        payment_method__credit_card__last_four__startswith='4111',
        payment_method__billing_address__city='New York'
    )
    
    # Orders where any product has id = 1
    orders = Order.objects.filter(products__contains=[{"id": 1}])

    您可以查看文档以了解有关过滤 JSON 字段的更多信息。

    结论

    在 PostgreSQL 中使用 JSON 和 JSONB 为处理关系数据库中的半结构化数据提供了极大的灵活性。Pydantic 和 Django 的“JSONField”等工具有助于强制执行数据结构规则,从而更容易保持准确性并适应变化。但是,这种灵活性需要谨慎使用。如果没有适当的规划,随着数据随时间变化,它可能会导致性能下降或不必要的复杂性。

    在 Django 中,仅当显式调用 full_clean() 时才会触发字段验证器 — 这通常在使用 Django Forms 或在 DRF 序列化器上调用 is_valid() 时发生。有关更多详细信息,您可以参考 Django 验证器文档。

    解决此问题的更高级方法是实现一个自定义 Django 字段,该字段集成了 Pydantic 来内部处理 JSON 数据的序列化和验证。虽然这需要一篇专门的文章,但目前,您可以探索为该问题提供现成解决方案的库,例如:django-pydantic-jsonfield