在Django裡處理One-To-Many Relation

2018/03/18 posted in  Django comments

建立Relational Database的時候常常會用到One-To-Many Relation,一般都是以Foreign Key的形式儲存。

Django功能十分齊全,但它的model inheritance設計令所有功能都非常"implicit",一些功能如果不知道要到哪裡define的話根本無從入手。
所以這篇就來紀錄一下如何用Django的ORM處理One-To-Many的Model吧

(如果你不知道甚麼是ORM:What is ORM)

I. 建立One-To-Many Model

假設有以下model:

from django.db import models

class Restaurant(models.Model):
    name = CharField(max_length=200)

class Food(models.Model):
    restaurant = ForeignKey(Restaurant, on_delete=models.CASCADE)
    name = CharField(max_length=200)

(不知道如何define model的可先看Django Tutorial)

每個Food都有個RestaurantForeignKey,代表它屬於某個Restaurant,每個Restaurant可以有多於一個Food

II. 取得一個Parent的所有Child

方法一:Query

如果想取得Restaurant的所有Food,以官方教學中有提及的方法:

restaurant = Restaurant.objects.get(id=1)  # 取得一間restaurant
Food.objects.filter(restaurant__id=restaurant.id)
# <QuerySet [<Food: 叉燒飯>, <Food: 燒鴨飯>]>

其實就是用restaurant的資料query一次food。

方法二:Child Set

然後我發現原來官方在某個角落寫著,可以用restaurant.food_set來達到相同效果:

restaurant = Restaurant.objects.get(id=1)  # 取得一間restaurant
restaurant.food_set.all()
# <QuerySet [<Food: 叉燒飯>, <Food: 燒鴨飯>]>

food_set這名稱來自Food這個class,Django會把它變lowercase然後加上_set,神奇吧。

這就是為甚麼我不喜歡Django,明明一直都存在於model之中,但如果我沒找到這一小段文檔,可能一世也不會知道它的存在

關於Manager

  • Restaurant.objects是一個Manager,用來管理對restaurant的query,詳情可參照Manager Reference
  • 同時restaurant.food_set也是一個Manager,但它管理對「屬於這間restaurant的food」的query

自訂field name

如果想用restaurant.foods instead of restaurant.food_set,可以在ForeignKey field裡加入related_name

class Food(models.Model):
    restaurant = ForeignKey(Restaurant, on_delete=models.CASCADE, related_name='foods')  # 這裡
    name = CharField(max_length=200)
# ...
restaurant.foods.all()

III. 取得所有Parent連帶它們的Child

(等同SQL裡的Join table)

目標

[
    {
        "name": "restaurant_A",
        "foods": [
            {
                "name": "叉燒飯"
            }, {
                "name": "燒鴨飯"
            },
            ...
        ]
    },
    ...
]

這裡示範如何取得所有Restaurant連帶它們的Food:

方法一:For-loop

先取得Restaurants,再找出屬於每個Restaurant的Food:

restaurants = Restaurant.objects.all().values()  # values()把QuerySet裡的所有Restaurant objects變成dict
for restaurant in restaurants:
    food_queryset = Food.objects.filter(restaurant__id=restaurant['id']).values()
    restaurant['food'] = list(food_queryset)  # list()把QuerySet變成list
restaurants = list(restaurants)  # list()把QuerySet變成list

方法二:REST Framework Serializer

Django REST Framework是Django一個非常熱門的工具,它可以幫你處理這些常見process,讓你不用自己寫for-loop。

(只是我覺得又再abstract了一層後,已經不知道自己的code怎麼運作了。)

class FoodSerializer(serializers.ModelSerializer):
    class Meta:
        model = Food
        fields = '__all__'


class RestaurantSerializer(serializers.ModelSerializer):
    foods = FoodSerializer(many=True, source='food_set')  # 加入這行後,它會自動幫你找到所有related childs

    class Meta:
        model = Restaurant
        fields = '__all__'

queryset = Restaurant.objects.all()
serializer = RestaurantSerializer(queryset, many=True)
serializer.data  # list of restaurant_dicts with their food_dicts

Performance Issue (N+1 Problem)

在處理Database relations時,常常會遇上performance的瓶頸,而問題九成都是來自著名的N+1 Problem

N+1意思就是touch了database N+1次,例如以上兩個做法,首先是找出所有N間restaurants(touch 1次),然後逐間找一次相關的Food(touch N次),加起來就是N+1了。

在現今電腦架構中,動用實體硬碟(尤其是HDD)是非常昂貴(expensive)的,無論從速度還是機器折舊角度,都應盡量減少這種operation。

解決方法

Django的Manager採用Lazy loading,所以未動用到的data是不會主動拿出來的,就像是這裡每間restaurant的foods。

我們可以使用prefetch_related()來逼django在找restaurants的時候也找出所有相關的foods,這樣動用實體硬碟的次數就只需一次了。

例如以上serializer的做法改良版:

queryset = Restaurant.objects.prefetch_related('food_set').all()  # 使用queryset前先prefetch所有相關food
serializer = RestaurantSerializer(queryset, many=True)

相關Reference