Jak współdzielić serializery i widoki w Django Rest Framework?

Kiedy tworzymy proste aplikacje CRUD w Django Rest Framework, najczęściej wykorzystujemy zaledwie dwa endpointy: jeden do akcji list / create, drugi do wszystkich pozostałych: retrieve / update / destroy.

Implementacja tylko jednego widoku dla wielu akcji teoretycznie oznacza stworzenie tylko jednego serializera, ale czy zawsze mniej kodu znaczy lepiej?

W wielu sytuacjach może okazać się, że API powinno zwracać dane w zupełnie innej postaci w zależności od wywoływanej akcji. Co to oznacza w praktyce?

W tym poście postaram się pokazać Wam różne podejścia i możliwości rozwiązania tego problemu.


🇺🇸 Angielską wersję tego artykułu możecie znaleźć tutaj.


Krótkie wprowadzenie

Załóżmy, że mamy pizzerię (przecież wszyscy kochają pizzę  🍕) i musimy stworzyć aplikację, dzięki której klienci będą mogli składać zamówienia, a kuchnia zacznie realizować je szybko i sprawnie.

Spójrzmy na modele, którymi operujemy:

# models.py

from django.db import models


class Ingredient(models.Model):
   name = models.TextField(max_length=20)

class Sauce(models.Model):
   name = models.TextField(max_length=20)

class Pizza(models.Model):
   name = models.TextField(max_length=20)
   price = models.DecimalField(decimal_places=2, max_digits=4)
   ingredients = models.ManyToManyField(Ingredient)
   sauce = models.ForeignKey(Sauce, null=True, blank=True, on_delete=models.SET_NULL)

Model Pizza składa się z pól:

  • name – nazwa,
  • price – cena (wyjątkowo nieistotna w tym przypadku 😆),
  • ingredients – składniki (pole typu ManyToManyField, gdyż pizza składa się z wielu składników,
  • sauce – sos (tutaj zakładamy, że mamy do wyboru tylko jeden sos, stąd relacja ForeignKey).

Zaimplementujmy więc prosty widok PizzaAPIView dziedziczący z generycznego widoku DRF – generics.ListCreateAPIview, który obsłuży akcje list (lista wszystkich pizz) i create (tworzenie nowej):

# views.py

class PizzaAPIView(generics.ListCreateAPIView):
    serializer_class = PizzaSerializer
    queryset = Pizza.objects.all()

Dodajmy do tego klasyczny serializer modelu:

# serializers.py

class PizzaSerializer(serializers.ModelSerializer):
   class Meta:
       model = Pizza
       fields = '__all__'

Jeszcze jedna linijka kodu definiująca url:

# urls.py

urlpatterns = [
   url(r'^pizza/$', views.PizzaAPIView.as_view()),
]

I szybkie zapoznanie się z zawartością tabel Ingredients i Sauce, żeby wiedzieć, jak dobrze zaopatrzona jest nasza kuchnia.

Tabela Ingredients
Tabela Sauce

Czas stworzyć najlepszą pizzę na świecie 🤩. Json do zapytania POST powinien wyglądać następująco:

{
    "name": "Hawaiian",
    "price": 29.99,
    "sauce": 1,
    "ingredients": [1,2,5]
}

Następnie wyślijmy zapytanie typu GET, żeby pobrać listę dostępnych pizz. Powinniśmy ujrzeć na niej stworzoną przed chwilą Hawajską.

Hmm, niby wszystko działa, ale… gdzie jest mój ananas? Jako klient nie chcę widzieć na liście numerów id składników i sosów, ale ich nazwy 🤔.

Jak więc osiągnć założony efekt bez większego wysiłku i utrzymując czysty kod?

Wiele widoków – wiele serializerów

Pierwszym rozwiązaniem jest rozdzielenie naszego widoku na dwa oddzielne:

# views.py

class PizzaCreateAPIView(generics.CreateAPIView):
   serializer_class = PizzaSerializer


class PizzaListAPIView(generics.ListAPIView):
   serializer_class = PizzaListSerializer
   queryset = Pizza.objects.all()

W ten sposób możemy stworzyć też nowy serializer, który obsłuży widok PizzaListAPIView:

# serializers.py

class PizzaListSerializer(serializers.ModelSerializer):
   sauce = serializers.CharField(source="sauce.name")

   class Meta:
       model = Pizza
       fields = '__all__'
       depth = 1

Nadpisałam tutaj pole sauce dodając mu parametr source, dzięki czemu będzie ono zwracało nazwę sosu. Dodałam też właściwość depth = 1, która pozwoli na zwrócenie pełnych obiektu powiązanych z modelem.

Zostało nam jeszcze dodanie nowego endpointu:

# urls.py

urlpatterns = [
   url(r'^pizza/create$', views.PizzaCreateAPIView.as_view()),
   url(r'^pizza/list$', views.PizzaListAPIView.as_view()),
]

Teraz zapytanie GET zwróci nam następujące dane:

Pizza z ananasem🍍. To lubię!

Niestety powyższe rozwiązanie wymagało stworzenia dodatkowego widoku i endpointu. Może jest jakiś lepszy sposób?

Jeden widok – jeden serializer

A może by tak spróbować połączyć wszystko razem i zmieścić się w jednym widoku i jednym serializerze? Powróćmy do początkowego widoku PizzaAPIView i zmodyfikujmy nieco wywoływany w nim PizzaSerializer:

# serializers.py

class PizzaSerializer(serializers.ModelSerializer):
   selected_sauce = serializers.CharField(source="sauce.name", read_only=True)
   selected_ingredients = IngredientSerializer(many=True, source="ingredients", read_only=True)

   class Meta:
       model = Pizza
       fields = '__all__'
       extra_kwargs = {
           'sauce': {'write_only': True},
           'ingredients': {'write_only': True}}

Tym razem dodałam dwa dodatkowe pola: selected_sauce oraz selected_ingredients, które mają zwracać powiązane obiekty w odpowiednim formacie. Przypisałam im też parametr read_only = True, dzięki czemu będą one dostępne tylko w odpowiedzi API na zapytania.

Z kolei pola sauce i ingredients otrzymały parametr write_only = True. W ten sposób mamy pewność, że będą one wymagane podczas wysylania zapytania POST, jednak nie zostaną wyświetlone w odpowiedzi.

Takie podejście umożliwia nam obsłużenie dwóch akcji wykorzystując tylko jeden widok i jeden serializer. Jednak czy jest to najlepsze rozwiązanie? To już zależy od Ciebie.

W moim odczuciu dodawanie nadmiarowych pól o innych nazwach sprawia, że kod jest nieco zagmatwany. Dodatkowo świadomość, że muszę dokładnie sprawdzić, które pole jest do odczytu, a które do zapisu, przyprawia mnie o lekki ból głowy i mam wrażenie, że ten kod, mimo że nie zajmuje dużo miejsca, nie jest do końca czysty.

Z tego powodu zaczęłam szukać jakiejś alternatywy i udało mi się znaleźć trzecie rozwiązanie.

Jeden widok – wiele serializerów

Wróćmy jeszcze raz do wyjściowego widoku i zmodyfikujmy go. Django Rest Framework zapewnia nam dostęp do wielu ciekawych metod, które możemy łatwo nadpisać i uzyskać w ten sposób to, czego potrzebujemy.

Jedną z takich metod jest get_serializer_class(). Jest ona wywoływana w momencie, gdy wysyłamy request, a Django szuka odpowiedniego serializera. Domyślnie jest tu zwracana wartość pola serializer_class zdefiniowanego w widoku, ale jeśli sami nadpiszemy tą metodę, zadzieje się prawdziwa magia…

# views.py

class PizzaListCreateAPIView(generics.ListCreateAPIView):
   queryset = Pizza.objects.all()

   def get_serializer_class(self):
       if self.request.method == 'POST':
           return PizzaCreateSerializer
       return PizzaListSerializer

Co się tutaj stało? Jedno proste sprawdzenie, czy wysłany request jest typu POST, czy nie. W pierwszym przypadku (czyli podczas tworzenia nowego obiektu), zmuszamy Django do użycia PizzaCreateSerializer, w pozostałych – PizzaListSerializer.

Stwórzmy więc oba serializery:

# serializers.py

class PizzaListSerializer(serializers.ModelSerializer):
   sauce = serializers.CharField(source="sauce.name")

   class Meta:
       model = Pizza
       fields = '__all__'
       depth = 1


class PizzaCreateSerializer(serializers.ModelSerializer):
   class Meta:
       model = Pizza
       fields = '__all__'

I zaktualizujmy endpointy:

# urls.py

urlpatterns = [
   url(r'^pizza/$', views.PizzaListCreateAPIView.as_view()),
]

Wow, udało się! Wykorzystaliśmy właśnie dwa serializery w jednym widoku. A możemy pójść jeszcze dalej i użyć ich tyle, ile tylko chcemy. Wszystko zależy od logiki, którą zawrzemy w naszej implementacji get_serializer_class().

Teraz kod wygląda dużo czyściej i każdy serializer jest odpowiedzialny tylko za jedną akcję.

Podsumowanie

Opisany przykład pokazuje, że praktycznie w każdej sytuacji mamy wiele rozwiązań i w zależności od kontekstu każde z nich może okazać się dobre. Warto więc znać różne podejścia i używać ich zamiennie w zależności od aktualnych potrzeb.

Nie bójcie się eksperymentować ze swoim kodem – jest to najlepsza metoda do odkrycia czegoś nowego i (miejmy nadzieję) także lepszego 🔥.

P.S. Mam nadzieję, że nie znienawidzicie mnie za tą pizzę z ananasem, ale naprawdę ją uwielbiam❤️.

P.S.2 A jakie rozwiązanie jest Twoim zdaniem najlepsze? A może znasz inne podejście? Podziel się w komentarzu.

5 2 votes
Article Rating
guest
0 komentarzy
Inline Feedbacks
View all comments