Django: Abstract Base Classes vs Multiple Table Inheritance

Django: Abstract Base Classes vs Multiple Table Inheritance
Photo by Iván Tamás from Pixabay

Django là một framework rất đầy đủ của Python cho phép chúng ta có thể phát triển nhanh một trang Web với những tính năng mạnh mẽ. Django đã cho phép kế thừa các model từ cách đây khá lâu, khi QuerysetRefactor branch được merge. Điều này cho phép chúng ta tái sử dụng các code, giảm thiểu số lượng code thừa và tăng productivity. Trong bài viết này, chúng ta sẽ tìm hiểu một số cách để kế thừa model, xem chuyện gì thực sự diễn ra ở database. Từ đó, chúng ta có thể lựa chọn phương án phù hợp nhất cho công việc mình cần.

Mở đầu

Khi xây dựng những ứng dụng Web, thỉnh thoảng tôi có gặp trường hợp một số model có cấu trúc gần giống nhau. Chúng giống nhau phần lớn các thuộc tính và chỉ khác biệt một số lượng rất ít (chỉ một hoặc hai) thuộc tính. Khi làm việc với framework Ruby on Rails, tôi thấy nó có tính năng khá hay là Single Table Inheritance (STI) cho phép các model kế thừa nhau mà chỉ sử dụng một bảng duy nhất. Đây là tính năng rất cần thiết cho những tình huống như thế này.

Nhưng Django thì có vẻ lại không có tính năng đó. Sau một thời gian tìm hiểu, thì tôi cũng biết cách làm việc tương tự với Django là Multiple Table Inheritance (MTI) và Abstract Base Class (ABC).

Tuy nhiên, bài viết này sẽ không giới thiệu cách dùng những tính năng trên, bởi tài liệu của django đã rất đầy đủ. Tôi cũng không sa đà vào thảo luận những vấn đề hay hạn chế của chúng. Bạn có thể tìm hiểu tài liệu, thử trên máy của mình, và vào forums nếu bạn có bất cứ thắc mắc nào.

Mục tiêu của bài viết này là review lại những phương án có thể sử dụng để kế thừa model nhằm giảm thiểu code (để giữ code DRY), đó là Model Table Inheritance – MTI (kế thừa bảng model), Abstract Base Classes - ABC (các class trừu tượng làm cơ sở) và OneToOneField (liên kết bảng). Chúng ta sẽ xem xét định nghĩa các model, kiểm tra các truy vấn SQL và cú pháp để lấy kết quả các thuộc tính của model, và hiệu năng của những phương án này.

Trước khi bắt đầu, chúng ta sẽ sử dụng một project có sẵn trên máy, tạo một app demo để thử nghiệm các phương án của chúng ta. Những code trong bài viết này hoàn toàn là một ví dụ và nó có thể rất khác so với thực tế.

Multiple Table Inheritance (MTI)

Chúng ta sẽ tìm hiểu qua 2 model, đó là ContentItemBasePost. Model Post sẽ kế thừa từ model ContentItemBase. Model ContentItemBase sẽ là “base” cho các model khác kế thừa. Bây giờ tôi có model Post nhưng sau này tôi có thể thêm vào các model Diary, Photo, Video, v.v…

Dưới đây là định nghĩa của model ContentItemBase:

class ContentItemBase(models.Model):
    COMMENT_STATUS_CHOICES = (
        ('0','Open'),
        ('1','Closed'),
    )
    DEFAULT_COMMENT_STATUS = '0'
    CONTENT_STATUS_CHOICES = (
        ('1','Draft'),
        ('2','Public'),
    )
    DEFAULT_CONTENT_STATUS = '1'

    title = models.CharField(_('title'), max_length=100,
                             unique_for_date="publish_on")
    slug = models.SlugField(_('slug'))
    created_on = models.DateTimeField(_('created on'), auto_now_add=True,
                                      editable=False, )
    updated_on = models.DateTimeField(_('updated on'), editable=False)
    publish_on = models.DateTimeField(_('publish on'), )
    tags = TagField()
    status = models.IntegerField(
        _('status'),
        choices=CONTENT_STATUS_CHOICES,
        default=DEFAULT_CONTENT_STATUS,
        db_index=True
    )
    comment_status = models.IntegerField(
        _('comment status'),
        choices=COMMENT_STATUS_CHOICES,
        default=DEFAULT_COMMENT_STATUS,
        db_index=True
    )

    class Meta:
        pass

Bây giờ, chúng ta cần định nghĩa model Post, model này có khá nhiều thuộc tính chia sẻ với những model khác, chính là những thuộc tính của model ContentItemBase ở trên. Làm thế nào chúng ta có thể dùng lại những thuộc tính đó cho các model khác nhau? Tôi nghĩ chúng ta đoán cũng được, đó là kế thừa lại model ContentItemBase. Chúng ta sẽ xem qua một định nghĩa của model Post như dưới đây:

class Post(ContentItemBase):
    DEFAULT_INPUT_FORMAT = 'X'
    INPUT_FORMAT_CHOICES = (
        ('X','XHTML'),
        ('M','Markdown'),
        ('R','Resructured Text'),
    )

    teaser = models.TextField(_('teaser'), blank=True, null=False)

Không có gì đặc biệt ở đây? Ngoại trừ việc model Post không kế thừa từ models.Model như bình thường mà kế thừa từ một model khác là ContentItemBase. Điều đó có nghĩa là gì? Đơn giản chỉ là model Post có thể truy cập đến toàn bộ thuộc tính của model mà nó kế thừa – ContentItemBase. Vì vậy, chúng ta không cần khai báo lại những thuộc tính như title, v.v trong model Post mà nó vẫn có những thuộc tính này. Bởi vì Post là con của ContentItemBase và nó sẽ kế thừa những thuộc tính từ model cha. Để biết rõ hơn về cấu trúc cơ sở dữ liệu với những model này, chúng ta sẽ xem schema được sinh ra bởi 2 model trên.

CREATE TABLE "demo_contentitembase" (
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "title" varchar(100) NOT NULL,
    "slug" varchar(50) NOT NULL,
    "created_on" datetime NOT NULL,
    "updated_on" datetime NOT NULL,
    "publish_on" datetime NOT NULL,
    "tags" varchar(255) NOT NULL,
    "status" integer NOT NULL,
    "comment_status" integer NOT NULL
);

CREATE TABLE "demo_post" (
    "contentitembase_ptr_id" integer NOT NULL PRIMARY KEY REFERENCES "demo_contentitembase" ("id"),
    "teaser" text NOT NULL
);

Có gì hot? Ở tầng cơ sở dữ liệu, Django tạo ra 2 bảng – một bảng cho model ContentItemBase và một bảng cho model Post. Hai bảng này quan hệ 1:1 với nhau. Bảng demo_posts có một trường là contentitembase_ptr_id, trường này sẽ lưu ID của bảng demo_contentitembase.

Vì có hai bảng, nên khi chúng ta query, chúng ta sẽ phải join hai bảng này với nhau. Đây không phải là điều gì xấu cả. Tuy nhiên, trong trường hợp cơ sở dữ liệu của bạn lớn, có thể bạn cần để ý xem có bao nhiêu bảng được join với nhau và chúng được thực hiện thường xuyên hay không. Bởi những việc này có ảnh hướng không nhỏ tới hiệu năng của hệ thống.

Quay trở lại với ví dụ của chúng ta, bây giờ, nếu chúng ta cần lấy ra thuộc tính title của Post. Chúng ta sẽ thực hiện như thế nào?

Trước hết, lấy ra bản ghi có ID = 1 từ cơ sở dữ liệu:

>>> p = Post.objects.get(id=1)
>>> p.title
'foobar'

Bây giờ, chúng ta sẽ xem xét câu lệnh query được gọi. Liệu câu lệnh SQL nào đã được thực thi để lấy ra instance Post từ cơ sở dữ liệu. Có thể bạn sẽ để ý rằng, trong ví dụ trên, khi lấy thuộc tính title, tôi không hề tham chiếu đến model ContentItemBase hay bảng của nó trên database. Đây là một tính năng tự động Django đã làm cho chúng ta.

Nếu bạn sử dụng khóa ngoài hay OneToOneField thì ở đây, bạn sẽ phải tham chiếu đến title thông qua ContentItemBase, kiểu như post.contentitembase.title bởi vì đây là hai bảng riêng biệt và câu lệnh trên sẽ tham chiếu đến bảng được liên kết.

Nhưng trong ví dụ của chúng ta, không một lệnh tham chiếu nào được gọi. Như những gì chúng ta thấy, title dường như là một thuộc tính của Post chứ không phải thuộc tính của bảng tham chiếu nữa. Và chúng ta có thể lấy giá trị của thuộc tính này rất dễ dàng. Điều gì đã xảy ra, hãy xem câu lệnh SQL cho truy vấn trong trường hợp này.

>>> from django.db import connection
>>> connection.queries[-1]
{'sql': 'SELECT "demo_contentitembase"."id", "demo_contentitembase"."title", "demo_contentitembase"."slug", "demo_contentitembase"."created_on", "demo_contentitembase"."updated_on", "demo_contentitembase"."publish_on", "demo_contentitembase"."tags", "demo_contentitembase"."status", "demo_contentitembase"."comment_status", "demo_post"."contentitembase_ptr_id", "demo_post"."teaser" FROM "demo_post" INNER JOIN "demo_contentitembase" ON ("demo_post"."contentitembase_ptr_id" = "demo_contentitembase"."id") WHERE "demo_post"."contentitembase_ptr_id" = 1', 'time': '0.001'}

Tôi nghĩ mình đã hiểu chuyện gì đã xảy ra. Có một lệnh JOIN đã được thực hiện ở đây:

INNER JOIN "demo_contentitembase" ON ("demo_post"."contentitembase_ptr_id" = "demo_contentitembase"."id")

Vậy, MTI có lợi ích như thế nào? Chúng ta có thể truy cập đến các thuộc tính rất trực quan, và cơ sở dữ liệu được tổ chức khá rõ ràng với các bảng cha và con liên kết với nhau. Điều này là tốt hay xấu? Nó phụ thuộc vào bài toán thực tế mà bạn cần giải quyết. Tuy nhiên, trước khi đưa ra nhận xét về cách làm này, chúng ta sẽ xem xét thêm một cách làm khác: Abstract Base Classes.

Abstract Base Classes (ABC)

Vẫn lấy ví dụ giống như phần trước, chúng ta sẽ sử dụng lại hai model là ContentItemBasePost với một chút biến đổi. Trong phần này chúng ta sẽ xem xét sự khác biệt của ABC và MTI. Sự khác biệt rất dễ dàng nhận ra. Hãy xem định nghĩa của ContentItemBase sau đây:

class ContentItemBase(models.Model):
    COMMENT_STATUS_CHOICES = (
        ('0','Open'),
        ('1','Closed'),
    )
    DEFAULT_COMMENT_STATUS = '0'
    CONTENT_STATUS_CHOICES = (
        ('1','Draft'),
        ('2','Public'),
    )
    DEFAULT_CONTENT_STATUS = '1'

    title = models.CharField(_('title'), max_length=100,
                             unique_for_date="publish_on")
    slug = models.SlugField(_('slug'))
    created_on = models.DateTimeField(_('created on'), auto_now_add=True,
                                      editable=False, )
    updated_on = models.DateTimeField(_('updated on'), editable=False)
    publish_on = models.DateTimeField(_('publish on'), )
    tags = TagField()
    status = models.IntegerField(
        _('status'),
        choices=CONTENT_STATUS_CHOICES,
        default=DEFAULT_CONTENT_STATUS,
        db_index=True
    )
    comment_status = models.IntegerField(
        _('comment status'),
        choices=COMMENT_STATUS_CHOICES,
        default=DEFAULT_COMMENT_STATUS,
        db_index=True
    )

    class Meta:
        abstract = True

Không khác biệt gì nhiều? Nếu nhìn code thì đúng là như vậy. Chỉ có một thứ duy nhất thay đổi, đó là subclass Meta bây giờ được cài đặt là abstract = True. Đây cũng là một thay đổi rất quan trọng. Ở ví dụ trước, chúng ta chỉ pass cho subclass Meta mà thôi, nghĩa là chúng ta không cài đặt bất cứ thứ gì cho nó.

Trong ví dụ này, abstract = True sẽ chỉ thị cho Django về model này – đó là lý do tạo sao chúng ta cần subclass Meta. Nội dung của Meta trong trường hợp này chỉ đơn giản là khai báo với Django rằng, đây là một class trừu tượng, và Django sẽ thao tác với class này như những class trừu tượng khác. Chỉ một dòng code thay đổi, nhưng sự thay đổi bên trong mới thực sự tạo nên sự khác biệt.

Hãy xem định nghĩa của model Post như dưới đây:

class Post(ContentItemBase):
    DEFAULT_INPUT_FORMAT = 'X'
    INPUT_FORMAT_CHOICES = (
        ('X','XHTML'),
        ('M','Markdown'),
        ('R','Resructured Text'),
    )

    teaser = models.TextField(_('teaser'), blank=True, null=False)

Giống hệt như ví dụ của MTI? Chính xác, không có gì thay đổi với class con Post kế thừa từ ContentItemBase. Cả hai ví dụ, nó đều kế thừa từ class cha, nên chúng ta không cần thay đổi gì ở đây. Tuy nhiên, sự thay đổi ở database lại rất lớn:

CREATE TABLE "demo_post" (
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "title" varchar(100) NOT NULL,
    "slug" varchar(50) NOT NULL,
    "created_on" datetime NOT NULL,
    "updated_on" datetime NOT NULL,
    "publish_on" datetime NOT NULL,
    "tags" varchar(255) NOT NULL,
    "status" integer NOT NULL,
    "comment_status" integer NOT NULL,
    "teaser" text NOT NULL
);

Rất dễ để nhận ra, không hề có bảng demo_contentitembase? Đúng vậy, khi chúng ta sử dụng ABC, Django sẽ làm “phẳng” các mối liên kết giữa các model cha và con và gộp chúng vào thành một bảng trong cơ sở dữ liệu. Điều đó có ý nghĩa gì? Một lợi ích từ việc này chính là, bạn sẽ không cần phải join các bảng với nhau khi phải truy vấn đến cơ sở dữ liệu nữa. Chúng ta xem xét kỹ hơn.

Trước hết, lấy một instance Post từ cơ sở dữ liệu:

>>> p = Post.objects.get(id=1)
>>> p.title
'foobar'

Chúng ta nhận được kết quả giống hệt như ví dụ trước, với cú pháp không khác gì nhau? Sự khác biệt ở đây là thuộc tính title bây giờ là một trường của bảng demo_posts và chúng ta dễ dàng lấy nó ra mà không cần join hay thông qua bất cứ một liên kết nào. Hãy xem câu SQL của truy vấn này:

>>> from django.db import connection
>>> connection.queries[-1]
{'sql': 'SELECT "demo_post"."id", "demo_post"."title", "demo_post"."slug", "demo_post"."created_on", "demo_post"."updated_on", "demo_post"."publish_on", "demo_post"."tags", "demo_post"."status", "demo_post"."comment_status", "demo_post"."teaser" FROM "demo_post" WHERE "demo_post"."id" = 1', 'time': '0.001'}

Chính xác, không có một câu JOIN nào, có lẽ không phải nói gì nhiều, lợi ích của nó đã quá rõ ràng, đây là một phương án tốt để kế thừa các model.

Cách truyền thống: OneToOneField

Nội dung bài này chủ yếu nói về hai cách kế thừa model ở trên. Tuy nhiên, tôi sẽ bỏ thêm chút thời gian nữa, để nhìn lại cách làm truyền thống nếu chúng ta không sử dụng hai cách vừa rồi.

Với cách “truyền thống”, chúng ta có hai phương án để lựa chọn: copy những thuộc tính giống nhau của các model (không DRY cho lắm) và tạo một bảng chứa những thuộc tính chung để tái sử dụng chúng.

Ở đây, chúng ta chỉ xem xét phương án thứ 2. Chúng ta sẽ tạo một bảng ContentItemBase và một bảng Post và sẽ liên kết 1:1 giữa hai bảng này. Nghe có vẻ giống MTI nhỉ? Thực ra nghe có vẻ giống nhau nhưng chúng khác nhau hoàn toàn về bản chất. Bởi vì tư tưởng thiết kế này khá giống MTI nên chắc tôi không cần giải thích nhiều, chúng ta đều hiểu cả rồi.

Chúng ta sẽ xem xét model ContentItemBase, nó giống hệt như định nghĩa trong ví dụ MTI.

class ContentItemBase(models.Model):
    COMMENT_STATUS_CHOICES = (
        ('0','Open'),
        ('1','Closed'),
    )
    DEFAULT_COMMENT_STATUS = '0'
    CONTENT_STATUS_CHOICES = (
        ('1','Draft'),
        ('2','Public'),
    )
    DEFAULT_CONTENT_STATUS = '1'

    title = models.CharField(_('title'), max_length=100,
                             unique_for_date="publish_on")
    slug = models.SlugField(_('slug'))
    created_on = models.DateTimeField(_('created on'), auto_now_add=True,
                                      editable=False, )
    updated_on = models.DateTimeField(_('updated on'), editable=False)
    publish_on = models.DateTimeField(_('publish on'), )
    tags = TagField()
    status = models.IntegerField(
        _('status'),
        choices=CONTENT_STATUS_CHOICES,
        default=DEFAULT_CONTENT_STATUS,
        db_index=True
    )
    comment_status = models.IntegerField(
        _('comment status'),
        choices=COMMENT_STATUS_CHOICES,
        default=DEFAULT_COMMENT_STATUS,
        db_index=True
    )

    class Meta:
        pass

Bây giờ đến model Post, nó sẽ khác biệt tương đối nhiều với ví dụ trước:

class Post(models.Model):
    DEFAULT_INPUT_FORMAT = 'X'
    INPUT_FORMAT_CHOICES = (
        ('X','XHTML'),
        ('M','Markdown'),
        ('R','Resructured Text'),
    )

    contentitembase = models.OneToOneField(ContentItemBase, primary_key=True)
    teaser = models.TextField(_('teaser'), blank=True, null=False)

Điều gì xảy ra ở đây? Chúng ta thêm một thuộc tính là contentitembase và định nghĩa nó là OneToOneField liên kết với model ContentItemBase. Vậy điều gì xảy ra ở tầng cơ sở dữ liệu:

CREATE TABLE "demo_contentitembase" (
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "title" varchar(100) NOT NULL,
    "slug" varchar(50) NOT NULL,
    "created_on" datetime NOT NULL,
    "updated_on" datetime NOT NULL,
    "publish_on" datetime NOT NULL,
    "tags" varchar(255) NOT NULL,
    "status" integer NOT NULL,
    "comment_status" integer NOT NULL
);

CREATE TABLE "demo_post" (
    "contentitembase_id" integer NOT NULL PRIMARY KEY REFERENCES "demo_contentitembase" ("id"),
    "teaser" text NOT NULL
);

Trông rất giống schema trong ví dụ MTI. Bây giờ, những gì chúng ta có là hai bảng của hai model và liên kết 1:1 giữa chúng thông qua contentitem_id của bảng demo_posts. Và đây mới là sự khác biệt của MTI và cách làm truyền thống này. Hãy xem truy vấn sau:

>>> from django.db import connection
>>> post = Post.objects.get(pk=1)
>>> connection.queries[-1]
{'sql': 'SELECT "demo_post"."contentitembase_id", "demo_post"."teaser" FROM "demo_post" WHERE "demo_post"."contentitembase_id" = 1', 'time': '0.001'}
>>> post.contentitembase.title
'foobar'
>>> connection.queries[-1]
{'sql': 'SELECT "demo_contentitembase"."id", "demo_contentitembase"."title", "demo_contentitembase"."slug", "demo_contentitembase"."created_on", "demo_contentitembase"."updated_on", "demo_contentitembase"."publish_on", "demo_contentitembase"."tags", "demo_contentitembase"."status", "demo_contentitembase"."comment_status" FROM "demo_contentitembase" WHERE "demo_contentitembase"."id" = 1', 'time': '0.000'}

Đây chính là sự khác biệt. Dòng đầu tiên, chúng ta lấy ra một instance của Post, sử dụng khóa chính (pk). Và trong trường hợp này khóa chính là ID của ContentItemBase chứ không phải ID của Post. Chúng ta đã định nghĩa thuộc tính contentitembase của PostOneToOneField liên kết với ContentItemBase. Chúng ta cũng định nghĩa trường này là duy nhất, cho phép nó chỉ có thể liên kết 1:1 mà thôi.

Một khác biết rõ ràng khác, đó là sử dụng cú pháp như trên, chúng ta lấy được một instance của Post thông qua pk nhưng chúng ta cần thêm một query nữa để lấy các thuộc tính được chia sẻ (như title chẳng hạn). Điều này cần được quan tâm bởi nó ảnh hưởng không nhỏ đến hiệu năng của hệ thống.

Kết luận

Bài viết này đã xem xét qua các cách làm để kế thừa các model đó là Multiple Table Inheritance (composition) và Abstract Base Classes (inheritance). Tôi nghĩ rằng, Django đã xây dựng cho chúng ta những công cụ rất tuyệt vời. Phương án nào tốt hơn phương án nào? Tôi sẽ dành việc trả lời câu hỏi này cho các bạn, khi các bạn cần phải giải quyết những bài toán của riêng mình.

Tôi xin lỗi nếu bài viết có bất kỳ typo nào. Nếu bạn nhận thấy điều gì bất thường, xin hãy cho tôi biết.

Nếu có bất điều gì muốn nói, bạn có thể liên hệ với tôi qua các mạng xã hội, tạo discussion hoặc report issue trên Github.