多态性建模在关系型数据库是一个极具挑战性的任务。在这篇文章,我们呈现几种建模计数去展现多态性对象在一个关系型数据库使用Django的对象关系映射 object-relational mapping (ORM).
什么是多态性?
多态性是一种能力对于一个对象它可以有种呈现的形式。一个常见的例子对于多态性物体就是事件流,不同的类型的使用者,还有使用在电商的商品。一个多态性模型被使用当一个单一的实体要求不同的功能或者信息的时候。
在上面的例子中,所有的事件都为了未来的使用而预先登记过了,但它们包含着不同的数据。所有的使用者都需要登陆,但每个使用者可能轮廓结构是不一样的。在每个电商,一个用户想要放入不同的货品到它们的购物车中。
为什么多态性建模是一个挑战呢?
有很多种方式可以去实现我们的建模多态性。有一些方法使用了django的ORM的标准或者特殊特性。当你建模多态对象会可能遇到以下的问题:
- 如何去呈现一个单一的多态对象:
多态对象有不同的属性。django ORM 映射属性到列 在数据库中。在那种情况下,如何让Django ORM 映射到列表中的列呢?是不是应该让不同的对象存留在相同的表格中?或者是多样化的表格呢?
- 如何去关联一个多态性的实例呢
去利用数据库和django ORM 特性,你需要参照对象使用外键。你如何决定去呈现一个单一的多态对象对于你的能力去参考它来说是重要的。
一个简单的(幼稚的)实现
我们从一个书店开始。
创建模型。
from django.contrib.auth import get_user_model
from django.db import models
class Book(models.Model):
name = models.CharField(
max_length=100,
)
price = models.PositiveIntegerField(
help_text='in cents',
)
weight = models.PositiveIntegerField(
help_text='in grams',
)
def __str__(self) -> str:
return self.name
class Cart(models.Model):
user = models.OneToOneField(
get_user_model(),
primary_key=True,
on_delete=models.CASCADE,
)
books = models.ManyToManyField(Book)
创建一本新的书。
>>> from naive.models import Book
>>> book = Book.objects.create(name='Python Tricks', price=1000, weight=200)
>>> book
<Product: Python Tricks>
创建一辆购物车:
>>> from django.contrib.auth import get_user_model
>>> haki = get_user_model().create_user('haki')
>>> from naive.models import Cart
>>> cart = Cart.objects.create(user=haki)
用户也可以往我们的购物车添加东西了:
>>> cart.products.add(book)
>>> cart.products.all()
<QuerySet [<Book: Python Tricks>]>
优点:
- 很容易理解和维护
缺点:
- 仅限用于同类物品
稀疏模型
我们已经成功搭建了我们的书店了,下一步是,我们需要电子书,那怎么办呢?
首先电子书与纸质书有啥不同呢?
- 电子书没有重量。
- 电子书不需要邮寄,它需要给出一个下载链接。
为了让你现在的模型能够去销售电子书,我们做出一些改变:
from django.contrib.auth import get_user_model
from django.db import models
class Book(models.Model):
TYPE_PHYSICAL = 'physical'
TYPE_VIRTUAL = 'virtual'
TYPE_CHOICES = (
(TYPE_PHYSICAL, 'Physical'),
(TYPE_VIRTUAL, 'Virtual'),
)
type = models.CharField(
max_length=20,
choices=TYPE_CHOICES,
)
# Common attributes
name = models.CharField(
max_length=100,
)
price = models.PositiveIntegerField(
help_text='in cents',
)
# Specific attributes
weight = models.PositiveIntegerField(
help_text='in grams',
)
download_link = models.URLField(
null=True, blank=True,
)
def __str__(self) -> str:
return f'[{self.get_type_display()}] {self.name}'
class Cart(models.Model):
user = models.OneToOneField(
get_user_model(),
primary_key=True,
on_delete=models.CASCADE,
)
books = models.ManyToManyField(
Book,
)
我们添加了一行类型还有一行,下载链接,那么下面的实现结果:
# 纸质书
>>> from sparse.models import Book
>>> physical_book = Book.objects.create(
... type=Book.TYPE_PHYSICAL,
... name='Python Tricks',
... price=1000,
... weight=200,
... download_link=None,
... )
>>> physical_book
<Book: [Physical] Python Tricks>
# 电子书
>>> virtual_book = Book.objects.create(
... type=Book.TYPE_VIRTUAL,
... name='The Old Man and the Sea',
... price=1500,
... weight=0,
... download_link='https://books.com/12345',
... )
>>> virtual_book
<Book: [Virtual] The Old Man and the Sea>
# 添加到我们的购物车
>>> from sparse.models import Cart
>>> cart = Cart.objects.create(user=user)
>>> cart.books.add(physical_book, virtual_book)
>>> cart.books.all()
<QuerySet [<Book: [Physical] Python Tricks>, <Book: [Virtual] The Old Man and the Sea>]>
但是呢,可能会因为错误的输入导致错误的数据:
>>> Book.objects.create(
... type=Book.TYPE_PHYSICAL,
... name='Python Tricks',
... price=1000,
... weight=0,
... download_link='http://books.com/54321',
... )
有些东西明摆着是错误的/无意义的。这就是导致了我们数据库的完整性受到了破坏。
为了解决这个问题,我们需要添加验证函数到我们的模型中:
from django.core.exceptions import ValidationError
class Book(models.Model):
# ...
def clean(self) -> None:
if self.type == Book.TYPE_VIRTUAL:
if self.weight != 0:
raise ValidationError(
'A virtual product weight cannot exceed zero.'
)
if self.download_link is None:
raise ValidationError(
'A virtual product must have a download link.'
)
elif self.type == Book.TYPE_PHYSICAL:
if self.weight == 0:
raise ValidationError(
'A physical product weight must exceed zero.'
)
if self.download_link is not None:
raise ValidationError(
'A physical product cannot have a download link.'
)
else:
assert False, f'Unknown product type "{self.type}"'
你使用了django的内部机制去强制我们的数据遵循这个完整性规则。clean() 只会被django表单自动地调用。对于不是由Django表单创建的对象,您需要确保显式地验证该对象。
就像这样,
>>> book = Book(
... type=Book.TYPE_PHYSICAL,
... name='Python Tricks',
... price=1000,
... weight=0,
... download_link='http://books.com/54321',
... )
>>> book.full_clean()
ValidationError: {'__all__': ['A physical product weight must exceed zero.']}
>>> book = Book(
... type=Book.TYPE_VIRTUAL,
... name='Python Tricks',
... price=1000,
... weight=100,
... download_link=None,
... )
>>> book.full_clean()
ValidationError: {'__all__': ['A virtual product weight cannot exceed zero.']}
在本例中,您希望在将if保存到数据库之前验证对象。首先创建对象(Book(…)),验证它(Book .full_clean()),然后保存它(Book .save())。
反规范化:
一个稀疏模型是一个反规范化地产物。
稀疏模型是去正规化的产物。在反规范化过程中,为了获得更好的性能,可以将多个规范化模型中的属性内联到一个表中。非规范化的表通常有许多可空列。
反规范化通常用于决策支持系统,如数据仓库,其中读取性能是最重要的。与OLTP系统不同,数据仓库通常不需要强制执行数据完整性规则,这使得非规范化成为理想的选择。
简单来说:反规范化的模型用于对于数据完整性要求低,但性能要求高的地方。
- 优点
很容易理解和维护。 - 缺点
不能够利用到数据库中的not null 限制。
得实现复杂的验证逻辑 。
得创建很多空的领域。
新类型需要改变我们的模式。
使用的情境
稀疏模型是一种理想化的模型当我们想表示一些不同的对象有着很多共通的属性,当新项目没有经常被添加的时候。
半结构化模型
在稀疏模型中,你需要对每一种新类型都添加一个域。现在模型中有很多的可以空的领域,这让开发者和你的员工无从保持这种现状了。
为了解决这个问题,你决定将一些共有的属性放在一个模型中,然后你存储剩下的内容到单一的json域中。
from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import JSONField
from django.db import models
class Book(models.Model):
TYPE_PHYSICAL = 'physical'
TYPE_VIRTUAL = 'virtual'
TYPE_CHOICES = (
(TYPE_PHYSICAL, 'Physical'),
(TYPE_VIRTUAL, 'Virtual'),
)
type = models.CharField(
max_length=20,
choices=TYPE_CHOICES,
)
# Common attributes
name = models.CharField(
max_length=100,
)
price = models.PositiveIntegerField(
help_text='in cents',
)
extra = JSONField()
def __str__(self) -> str:
return f'[{self.get_type_display()}] {self.name}'
class Cart(models.Model):
user = models.OneToOneField(
get_user_model(),
primary_key=True,
on_delete=models.CASCADE,
)
books = models.ManyToManyField(
Book,
related_name='+',
)
JSONField:
在本例中,您使用PostgreSQL作为数据库后端。Django在Django .contrib. PostgreSQL .fields中为PostgreSQL提供了一个内置的JSON字段。
对于其他数据库,如SQLite和MySQL,也有提供类似功能的包。
现在你的书不是集群了。共通的属性被模型化成域。对于不是共通的属性我们存在json域中。
>>> from semi_structured.models import Book
>>> physical_book = Book(
... type=Book.TYPE_PHYSICAL,
... name='Python Tricks',
... price=1000,
... extra={'weight': 200},
... )
>>> physical_book.full_clean()
>>> physical_book.save()
<Book: [Physical] Python Tricks>
>>> virtual_book = Book(
... type=Book.TYPE_VIRTUAL,
... name='The Old Man and the Sea',
... price=1500,
... extra={'download_link': 'http://books.com/12345'},
... )
>>> virtual_book.full_clean()
>>> virtual_book.save()
<Book: [Virtual] The Old Man and the Sea>
>>> from semi_structured.models import Cart
>>> cart = Cart.objects.create(user=user)
>>> cart.books.add(physical_book, virtual_book)
>>> cart.books.all()
<QuerySet [<Book: [Physical] Python Tricks>, <Book: [Virtual] The Old Man and the Sea>]>
清理掉集群是很重要的,但它也造成了逻辑验证更大的复杂性。
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
class Book(models.Model):
# ...
def clean(self) -> None:
if self.type == Book.TYPE_VIRTUAL:
try:
weight = int(self.extra['weight'])
except ValueError:
raise ValidationError(
'Weight must be a number'
)
except KeyError:
pass
else:
if weight != 0:
raise ValidationError(
'A virtual product weight cannot exceed zero.'
)
try:
download_link = self.extra['download_link']
except KeyError:
pass
else:
# Will raise a validation error
URLValidator()(download_link)
elif self.type == Book.TYPE_PHYSICAL:
try:
weight = int(self.extra['weight'])
except ValueError:
raise ValidationError(
'Weight must be a number'
)
except KeyError:
pass
else:
if weight == 0:
raise ValidationError(
'A physical product weight must exceed zero.'
)
try:
download_link = self.extra['download_link']
except KeyError:
pass
else:
if download_link is not None:
raise ValidationError(
'A physical product cannot have a download link.'
)
else:
raise ValidationError(f'Unknown product type "{self.type}"')
使用了恰当的域的好处是它验证了类型。django和django ORM能够执行检查去确认的域中使用了正确的类型。
但是当使用了json域的时候,你需要对它们的类型和值自己写一个验证。
使用json的另一个问题是并不是所有的数据库都能支持这种查询和检索数据在json域中的能力。
还有另一个限制实施是当使用json你不能够添加数据库中的一些限制例如not null,unique,foreign,你必须通过你自己的应用程序来实现。
这种半结构化方法类似于NoSQL体系结构,有很多优点和缺点。JSON字段是一种绕过关系数据库严格模式的方法。这种混合方法为我们提供了将许多对象类型压缩到一个表中的灵活性,同时仍然保留关系型、严格类型和强类型数据库的一些优点。对于许多常见的NoSQL用例,这种方法实际上可能更适合。
优点
- 减少了集群。
- 很容易去添加一个新类型。
缺点
- 复杂而且是暂时实现的验证逻辑。
- 不能够利用我们的数据库限制语句。
- 依赖于数据库对于json的支持。
- 模式不强制实施到数据库系统。
- 没有深度的完整性在数据库系统中。
使用情境
一个半结构华的模型是理想的当你想表示一个异构对象它们没有共享很多的数据,而且新形态常常需要添加。
一种典型的使用例子对于半结构化方法是存储事件流(就像记录、分析,事件存储等等)大部分的事件有一个时间戳,类型和元数据就像设备、使用代理、使用者、等等。对于存在json中的每一个类型。对于分析和记录事件,很重要去添加一个新的类型的事件用最小的力气,那么这个类型就是理想的。
抽象基类
最近,你在用一个把它们当作同一类去处理它们,你在这么一种假设之下就是它们之间的差异是最小化的,所以去维持它们在一个相同的模型之下是有道理的。
现在我们使用一种面向对象的环境下,你可以把product当作一个基类,或者说是一个接口,然后其他的东西都是product的实现,扩展它自己的一些属性。
Django提供了一种能力去创建一个抽象基类。
from django.contrib.auth import get_user_model
from django.db import models
class Product(models.Model):
class Meta:
abstract = True
name = models.CharField(
max_length=100,
)
price = models.PositiveIntegerField(
help_text='in cents',
)
def __str__(self) -> str:
return self.name
class Book(Product):
weight = models.PositiveIntegerField(
help_text='in grams',
)
class EBook(Product):
download_link = models.URLField()
注意到我们的book和ebook都是继承于我们的product。
使用我们的派生类。
>>> from abstract_base_model.models import Book
>>> book = Book.objects.create(name='Python Tricks', price=1000, weight=200)
>>> book
<Book: Python Tricks>
>>> ebook = EBook.objects.create(
... name='The Old Man and the Sea',
... price=1500,
... download_link='http://books.com/12345',
... )
>>> ebook
<Book: The Old Man and the Sea>
你会注意到cart模型还没有:
class Cart(models.Model):
user = models.OneToOneField(
get_user_model(),
primary_key=True,
on_delete=models.CASCADE,
)
items = models.ManyToManyField(Product)
当你尝试使用 多对多的模型应用于product,这会出错。
一个外键约束只能指向一个具体的表格,product这个模型只存在于代码层面上,所以说没有具体的product表格。ORM只会给你创建book和ebook。
所以,购物车这么写:
class Cart(models.Model):
user = models.OneToOneField(
get_user_model(),
primary_key=True,
on_delete=models.CASCADE,
)
books = models.ManyToManyField(Book)
ebooks = models.ManyToManyField(EBook)
当我们添加物体,进入了我们的购物车后,我们可以计算总价:
>>> from django.db.models import Sum
>>> from django.db.models.functions import Coalesce
>>> (
... Cart.objects
... .filter(pk=cart.pk)
... .aggregate(total_price=Sum(
... Coalesce('books__price', 'ebooks__price')
... ))
... )
{'total_price': 1000}
因为你已经有了很多种书,所以使用合并(coalesce)去获取它们。
优点:
- 容易去实现特定逻辑
缺点:
- 需要多种外键
- 难以实现和维护
- 难以扩展。
使用情境 一个基类模型一个好的选择当有很少的对象需要完全不同的逻辑。
一个直观的例子就是建模一个支付过程给你的网上商店。你想要去通过我们信用卡接受支付,paypal,和存储信用。每种支付方法都经历完全不同的过程要求完全不一样的逻辑。添加一种新的支付方式不是很常见的,你最近不会想要去添加一种新的支付方法。
你创建一个支付方法过程的基类伴随着派生类对于信用卡支付过程。paypal支付过程。然后存储余额支付过程,都是非常的不同的不能够简单的共享。在这种情况下,各自特别的实现对这些支付过程的操作就很有意义了。
具体的基类模型
Django 提供了另一种方法去实现一个继承的模型,而不是使用一个抽象的基类只存在于代码之中,你可以让基类具体。具体意味着这个基类存在于数据库表格中,不像抽象类一样没有具体的表格,只存在于代码之中。
使用抽象基类模型,你不能参考多种类型的产品。你强制去创造一个多对多的关系,对于每一个产品。这让我们很难够去获取它们的共有属性的信息在购物车中就像它们的价格。
使用一个具体的基类,django会为它创建一个product的数据库表格。product模型会拥有你所有的公有域定义在你的基本模型中。派生类就像book和ebook会参考product表格使用1对1 的域,你会为基类创建一个外键。
from django.contrib.auth import get_user_model
from django.db import models
class Product(models.Model):
name = models.CharField(
max_length=100,
)
price = models.PositiveIntegerField(
help_text='in cents',
)
def __str__(self) -> str:
return self.name
class Book(Product):
weight = models.PositiveIntegerField()
class EBook(Product):
download_link = models.URLField()
它们的差别仅仅就是abstract = true 被删掉了。
在具体的基类中,我们很好奇它潜在之下的数据库中发生了什么。我们查看一个django中的数据库:
> \d concrete_base_model_product
Column | Type | Default
--------+-----------------------+---------------------------------------------------------
id | integer | nextval('concrete_base_model_product_id_seq'::regclass)
name | character varying(100) |
price | integer |
Indexes:
"concrete_base_model_product_pkey" PRIMARY KEY, btree (id)
Referenced by:
TABLE "concrete_base_model_cart_items" CONSTRAINT "..." FOREIGN KEY (product_id)
REFERENCES concrete_base_model_product(id) DEFERRABLE INITIALLY DEFERRED
TABLE "concrete_base_model_book" CONSTRAINT "..." FOREIGN KEY (product_ptr_id)
REFERENCES concrete_base_model_product(id) DEFERRABLE INITIALLY DEFERRED
TABLE "concrete_base_model_ebook" CONSTRAINT "..." FOREIGN KEY (product_ptr_id)
REFERENCES concrete_base_model_product(id) DEFERRABLE INITIALLY DEFERRED
这个产品表格有两个相似的域:name和price。
那么派生类呢:
> \d concrete_base_model_book
Column | Type
---------------+---------
product_ptr_id | integer
weight | integer
Indexes:
"concrete_base_model_book_pkey" PRIMARY KEY, btree (product_ptr_id)
Foreign-key constraints:
"..." FOREIGN KEY (product_ptr_id) REFERENCES concrete_base_model_product(id)
DEFERRABLE INITIALLY DEFERRED
它只有weight属性和product id。
在这种情境之下,我们查询会使用什么语句呢:
print(Book.objects.filter(pk=1).query):
SELECT
"concrete_base_model_product"."id",
"concrete_base_model_product"."name",
"concrete_base_model_product"."price",
"concrete_base_model_book"."product_ptr_id",
"concrete_base_model_book"."weight"
FROM
"concrete_base_model_book"
INNER JOIN "concrete_base_model_product" ON
"concrete_base_model_book"."product_ptr_id" = "concrete_base_model_product"."id"
WHERE
"concrete_base_model_book"."product_ptr_id" = 1
这时,在cart中,我们能使用product建立多对多属性了。
class Cart(models.Model):
user = models.OneToOneField(
get_user_model(),
primary_key=True,
on_delete=models.CASCADE,
)
items = models.ManyToManyField(Product)
并也能很方便的计算我们的购物车总价了:
>>> from django.db.models import Sum
>>> cart.items.aggregate(total_price=Sum('price'))
{'total_price': 2500}
在Django中迁移基类:
当创建派生模型时,Django向迁移添加一个base属性:
migrations.CreateModel (
name = ‘book’,
fields= […]
base= (“concrete_base_model.product”),
),
如果将来删除或更改基类,Django可能无法自动执行迁移。你可能会得到这样的错误:
TypeError: metaclass conflict: the metaclass of a derived class must
be a (non-strict) subclass of the metaclasses of all its bases
在Django(#23818、#23521、#26488)中,这是一个已知的问题。要解决这个问题,您必须手动编辑原始迁移并调整base属性。
优点:
- 主键一致在各种类型中,就像uuid
- 使用一个表就是找到我们的共通属性
缺点:
- 新的product需要改变模式
- 查询低效
- 无法从基类实例访问扩展数据
使用情境
具体基类模型方法对于当很多的共同域在基类中能够满足查询
举个例子,如果你通常需要去查询购物车总价, 显示购物车里面的内容。或者使用一个临时的分析查询对于购物车模型,你能够对拥有所有共同的属性在一个数据库表格中获得好处。