Makina Blog

Le blog Makina-corpus

Django Rest Framework : les Viewset (partie 2)


Dans un précédent article, nous avions évoqué les bonnes pratiques à appliquer avec les serializers et les exceptions. Dans cet article, nous allons nous intéresser plus particulièrement aux Viewset.

Dans un précédent article nous avions évoqués les serializers et les exceptions.

Nous allons désormais aborder le vaste sujet des ViewSet.

ViewSet ou API View ?

Le ViewSet est un concept introduit par DRF qui consiste à regrouper un ensemble de vues pour un modèle donné dans une seule classe Python. À cet ensemble de vues correspond des actions prédéfinies de type CRUD (Create, Read, Update, Delete), associées à des méthodes HTTP. Chacune de ces actions est une méthode d'instance ViewSet. Parmi ces actions par défaut, on retrouve :

  • list
  • retrieve
  • create
  • update
  • partial_update
  • destroy

Les API View, qu'on appellera aussi les vues génériques par soucis de simplicité, sont l'équivalent REST des Class Based View du core de Django.

Le choix entre l'usage d'un ViewSet ou d'une API View n'est pas évident, et relève parfois même des préférences de chacun. Mais avant toute chose, il faut savoir que vous pouvez arriver aux mêmes résultats avec l'un ou l'autre.

Quand on a un ensemble de vues liées à des opérations de type CRUD sur un modèle de données, l'usage d'un ViewSet plutôt qu'un ensemble d'API View parait évident. Mais cela devient moins évident quand il s'agit d'un ensemble de vues autour d'une même thématique (pas nécessairement un modèle), avec des points d'entrées personnalisés différents… Pour faire ce choix, il est essentiel de bien comprendre les différences entre les ViewSet et les API View.

Différences entre ViewSet et API View

Les ViewSet sont en fait une surcharge aux API View. La seule surcharge apportée par les ViewSet est un binding d'actions prédéfinies avec des méthodes HTTP. Cette surcharge est faite dans la classe mixin ViewSetMixin. Quand le ViewSet est appelé par le dispatcher de Django (matching du pattern d'url), l'action correspondante à la méthode HTTP est appelée. Ces actions prédéfinies utilisent les mêmes mixins que les vues génériques (API View). Les actions étant des méthodes d'instance, une seule classe suffit à regrouper un ensemble de vues. L'unique différence est donc le routing.

Avantages des ViewSet

Un avantage indéniable des ViewSet est la mutualisation de code (DRY). Vous définissez des propriétés communes à chacune des vues (queryset, serializer_class, permissions_classes, filter_backends, etc). Et, au besoin, vous surchargez certaines méthodes comme get_queryset() ou get_serializer_class() selon l'action requêtée (via self.action).

Un autre avantage est bien entendu sa différence: l'usage d'un routeur. Ce dernier nous évite l'élaboration des URLconf. Il existe différentes implémentations de routeurs (vous pouvez d'ailleurs facilement faire le vôtre). Mais celui par défaut couvre déjà de nombreux besoins.

Enfin, une seule classe regroupe le code. Personnellement, je trouve plus lisible et maintenable une seule classe avec quelques méthodes plutôt qu'un ensemble de classes avec peu de méthodes. De plus, vous pouvez dès lors séparer les vues Web des vues d'API avec un fichier respectif (par ex: views.py et viewsets.py). Cela rend la séparation encore plus stricte et naturelle.

Pour illustrer ces propos, voici un exemple de plusieurs points d'entrées fonctionnellement identiques. Mais avec deux implémentations différentes.

Ci-dessous une écriture avec les API View.

from django.conf.urls import url
from django.db import models

from rest_framework import serializers
from rest_framework.generics import (
    ListCreateAPIView, RetrieveUpdateDestroyAPIView,
)
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import AllowAny, IsAuthenticated


class MyModel(models.Model):
    foo = models.TextField()
    bar = models.TextField()


class MyModelSerializer(serializers.ModelSerializer):

    class Meta:
        model = MyModel
        fields = ('foo', 'bar')


class MyModelDetailSerializer(MyModelSerializer):
    baz = serializers.CharField()

    class Meta(MyModelSerializer.Meta):
        fields = MyModelSerializer.Meta.fields + ('baz',)


class MyModelListCreateAPIView(ListCreateAPIView):
    permission_classes = (AllowAny,)
    pagination_class = LimitOffsetPagination

    def get_queryset(self):
        if self.request.method == 'GET':
            return MyModel.objects.filter(is_active=True)
        else:
            return MyModel.objects.all()

    def get_serializer_class(self):
        if self.request.method == 'GET':
            return MyModelDetailSerializer
        else:
            return MyModelSerializer


class MyModelRetrieveUpdateDestroyAPIView(RetrieveUpdateDestroyAPIView):
    permission_classes = (IsAuthenticated,)
    queryset = MyModel.objects.all()
    serializer_class = MyModelSerializer


class MyModelCustomActionAPIView(GenericAPIView):
    permission_classes = (IsAuthenticated,)
    queryset = MyModel.objects.all()
    serializer_class = MyModelSerializer

    def post(self, request, *args, **kwargs):
        pass  # Do some wonderful stuff

urlpatterns = [
    url(
        r'^mymodels/$',
        MyModelListCreateAPIView.as_view(),
        name='mymodel-list',
    ),
    url(
        r'^mymodels/(?P<pk>[^/.]+)/$',
        MyModelRetrieveUpdateDestroyAPIView.as_view(),
        name='mymodel-detail',
    ),
    url(
        r'^mymodels/custom_action/$',
        MyModelCustomActionAPIView.as_view(),
        name='mymodel-custom-action',
    ),
]

Remarquez que nous avons utilisé des vues génériques qui combinent déjà plusieurs mixins par type de route list ou detail (nous reviendrons sur ces types de routes dans un prochain article). En l'occurrence la classe ListCreateAPIView et RetrieveUpdateDestroyAPIView. Outre l'élégance de cette implémentation, c'est surtout que … nous n'avons pas le choix !

"Ah bon ? Et puis justement, tu n'as pas dit plus haut que les ViewSet et API View utilisent les même mixins ? Pourquoi alors ne pas faire carrément sa propre cuisine et combiner toutes les mixins ensemble pour n'avoir qu'une seule classe !?"

Pour les plus attentifs, vous l'avez déjà deviné. La réponse est la seule différence entre les deux : le routing. Dans cet exemple, les 3 URL actuelles (les 3 éléments de la liste urlpatterns) imposent naturellement 3 vues. De même, si on utilisait des vues plus spécifiques comme ListAPIView et CreateAPIView au lieu de ListCreateAPIView, on aurait deux vues. Donc deux patterns d'url à définir au lieu d'un. À moins peut-être de commencer à faire du sale… Pensez à vos collègues, vous n'êtes pas seul ! ;-)

Bon et maintenant, la même chose avec un ViewSet :

from django.db import models

from rest_framework import serializers
from rest_framework.decorators import list_route
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.routers import DefaultRouter
from rest_framework.viewsets import ModelViewSet


class MyModel(models.Model):
    foo = models.TextField()
    bar = models.TextField()


class MyModelSerializer(serializers.ModelSerializer):

    class Meta:
        model = MyModel
        fields = ('foo', 'bar')


class MyModelDetailSerializer(MyModelSerializer):
    baz = serializers.CharField()

    class Meta(MyModelSerializer.Meta):
        fields = MyModelSerializer.Meta.fields + ('baz',)


class MyModelViewset(ModelViewSet):
    permission_classes = (IsAuthenticatedOrReadOnly,)
    pagination_class = LimitOffsetPagination

    def get_queryset(self):
        if self.action == 'retrieve':
            return MyModel.objects.filter(is_active=True)
        else:
            return MyModel.objects.all()

    def get_serializer_class(self):
        if self.action == 'retrieve':
            return MyModelDetailSerializer
        else:
            return MyModelSerializer

    @list_route(methods=['post'])
    def custom_action(self, request, *args, **kwargs):
        pass  # Do some stuff in bulk with serializer


router = DefaultRouter()
router.register(r'mymodels', MyModelViewset, base_name='mymodel')
urlpatterns = router.urls

Au départ j'en conviens, c'est un peu déroutant quand on est habitué au Class Based View. Mais c'est un cap qui se franchit rapidement. Et très vite, on apprécie et on oublie les vues génériques !

Quand utiliser un ViewSet ?

Vous devriez plutôt utilisez des ViewSet quand :

  • vous manipulez un modèle avec une action définie. Même si c'est une action personnalisée. Même si il n'y a qu'une seule action. Même si vous n'avez pas envie. Bon sang faites-le et puis c'est tout !
  • vous souhaitez rester dans le même namespace/pattern d'URI (préfixe). Croyez-moi, faire cohabiter des API View avec un ViewSet autour d'un même préfixe d'URL est source de conflit à coup sûr… Donc pour faire simple : c'est l'un ou l'autre.

En plus de ces conseils théoriques, un moyen pratique est de commencer par poser vos URL à plats et de coder l'URLconf avant même de développer vos vues. Vous vous rendrez vite compte si vous avez besoin d'un routeur. Par contre, essayez aussi de vous projeter un peu pour anticiper de futures URL possibles.

Quand utiliser une API View ?

Elles seront parfaites pour des besoins simples. En règle générale, il y a peu de ressources manipulées et peu de méthodes HTTP associées. Par exemple, un point d'entrée en GET qui renvoie des informations de configurations, la connexion/déconnexion en POST, etc.

Donc au contraire, utilisez plutôt des API View quand :

  • vous ne manipulez pas de resources (modèles)
  • vous devez/devrez pouvoir changer intégralement l'URI (changement de namespace par exemple).
  • pour des raisons diverses, vous devez isoler la vue des autres (si la surcharge au sein d'un ViewSet devient trop complexe). Je n'ai encore jamais rencontré de cas d'utilisation. Mais j'imagine qu'il puisse malgré tout y en avoir. Néanmoins, c'est probablement une des rares fois où vous utiliserez les vues génériques pour une bonne raison !

Retour d'expérience : pourquoi faut-il utiliser des ViewSet

Pour être honnête, j'ai moi-même fait l'amère expérience d'avoir écrit initialement des vues génériques (API View) plutôt que des ViewSet en me disant : "baaaah, c'est overkill ! J'ai juste une opération non standard sur un modèle. J'ai pas besoin de tout ça pour le moment…". Pour le moment. Et puis plus tard (rapidement quoi), le projet évolue. De nouveaux points d'entrées doivent être implémentés dans le même namespace d'URI avec de nouvelles méthodes. Certaines de ces méthodes sont même des méthodes classiques incluses dans les actions du ViewSet ! On peut continuer dans le déni et faire de nouvelles classes d'API View. On peut aussi reconnaître son erreur car finalement, on a besoin d'un ViewSet et que le routeur, bah c'est pas si mal ! Même si on s'y était préparé, il faut bien reconnaître que recoder les vues génériques en ViewSet et revoir les tests, c'est fastidieux et une belle perte de temps!

Alors qu'au contraire, si on avait utilisé dès le départ un ViewSet, on aurait simplement ajouté une méthode dans le ViewSet, quelques méthodes de tests et le tour était joué…

La souplesse des ViewSet

Gardez à l'esprit que l'usage des ViewSet reste souple. Vous pouvez sans problème :

  • utiliser uniquement les mixins qui vous intéressent. Vous n'êtes pas obligé de sortir l'artillerie lourde avec un CRUD complet comme la classe ModelViewSet
  • ajouter de nouvelles actions avec des chemins et méthodes HTTP données. Par contre, le préfixe de chemin reste le même pour toutes les vues (typiquement, le nom au pluriel de la ressource)
  • surcharger pour un point d'entrée personnalisé les attributs de classes tel que permissions_classes, filter_backends, pagination_class, permission_classes, serializer_class, etc
  • personnaliser un routeur si le DefaultRouter ne répond pas à tous vos besoins

Voici un exemple qui montre comment surcharger les attributs de classes pour une action personnalisée :

from django.db import models

from rest_framework import serializers
from rest_framework.decorators import list_route
from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin
from rest_framework.pagination import (
    LimitOffsetPagination, PageNumberPagination,
)
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.routers import DefaultRouter
from rest_framework.viewsets import GenericViewSet


class MyObject(models.Model):
    foo = models.CharField(max_length=128)
    bar = models.CharField(max_length=128)


class MyDefaultSerializer(serializers.ModelSerializer):
    class Meta:
        model = MyObject


class CustomPostSerializer(serializers.Serializer):
    pass


class MyViewSet(CreateModelMixin, RetrieveModelMixin, GenericViewSet):
    queryset = MyObject.objects.all()
    permission_classes = (AllowAny,)
    serializer_class = MyDefaultSerializer
    pagination_class = PageNumberPagination

    @list_route(
        methods=['post'],
        permission_classes=(IsAuthenticated,),
        serializer_class=CustomPostSerializer,
        pagination_class=LimitOffsetPagination,
        url_path='a-better-sexy-name',
    )
    def my_custom_post(self, request, *args, **kwargs):
        pass


router = DefaultRouter()
router.register(r'mymodels', MyViewSet)

urlpatterns = []
urlpatterns += router.urls

Dans cet exemple, l'action personnalisée (my_custom_post):

  • requiert l'authentification
  • utilise un autre serializer
  • utilise une autre pagination ("Eh! Pour des points d'entrées type create/retrieve, c'est débile non ?" Exact ! C'est bien, vous êtes attentif! Rangez votre fusil, c'est juste un exemple hein ;-)).
  • surcharge le chemin généré automatiquement à partir du nom de la méthode avec l'attribut spécifique au routeur url_path. Donc l'url devient : POST /mymodels/a-better-sexy-name/ au lieu de POST /mymodels/my_custom_post/. Carrément plus classe.

Conclusion

Les ViewSet se distinguent des vues génériques uniquement par le routing. Mais il en découle un certain nombre d'avantages : mutualisation du code au sein d'une seule classe, abstraction des règles de routing et amélioration de la lisibilité.

Contrairement aux idées reçues, utiliser d'emblée un ViewSet plutôt que des vues génériques n'est pas de l'overengineering si c'est fait correctement. Ça ne demande pas plus d'effort et on peut potentiellement gagner du temps à l'avenir.

Donc en règle générale, quand une vue d'API manipule une ressource (modèle), utilisez des ViewSet !

À venir

Dans un prochain article, nous serons amenés à parler des routeurs.

Formations associées

Formations Django

Formation Django initiation

Nantes Du 12 au 14 mars 2024

Voir la formation

Formations Django

Formation Django Rest Framework

À distance (FOAD) Du 10 au 14 juin 2024

Voir la formation

Formations Django

Formation Django avancé

À distance (FOAD) Du 9 au 13 décembre 2024

Voir la formation

Actualités en lien

Image
Django_Rest_Framework_routeurs2
06/01/2016

Django Rest Framework : intégration des routeurs (partie 4)

Comprendre comment les routeurs fonctionnent est une chose. Savoir s'en servir correctement, c'est mieux ! On va voir que ça n'est pas si simple et que c'est probablement une des parties de DRF la plus étonnante.

Voir l'article
Image
Django_Rest_Framework_routeurs3
06/01/2016

Django Rest Framework : fonctionnement des routeurs (partie 3)

Chose promise, chose due : après avoir mis en avant les bienfaits des Viewset, on va plonger dans le côté obscur de la force de DRF : les routeurs.

Voir l'article
15/12/2015

Django Rest Framework : les Serializer et les exceptions (partie 1)

Django Rest Framework est une extension à Django pour développer rapidement des API REST robustes au goût du jour. Reprenant la philosophie Django, la prise en main est rapide et efficace. Néanmoins, certaines notions nécessitent une attention plus particulière que nous allons mettre en avant dans cette suite d'articles.

Voir l'article

Inscription à la newsletter

Nous vous avons convaincus