Accueil / Blog / Métier / 2015 / Django Rest Framework : les Viewset (partie 2)

Django Rest Framework : les Viewset (partie 2)

Par Yannick Chabbert — publié 20/12/2015, édité le 24/02/2016
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.
Django Rest Framework : les Viewset (partie 2)

Dans ce 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.

ABONNEZ-VOUS À LA NEWSLETTER !
Voir aussi
La Haute-Garonne met à l’honneur l’application Geotrek dans son magazine 08/06/2021

Le magazine Haute-Garonne du mois de mai-juin 2021 présente le site marando.haute-garonne.fr

Comment migrer vers une version récente de Django ? Comment migrer vers une version récente de Django ? 15/04/2021

Que ce soit pour avoir les dernières fonctionnalités ou les correctifs de sécurité, rester sur ...

Créer un tag d'inclusion avec paramètres dans Django 22/12/2020

La bibliothèque de tags interne permet d'enregistrer des tags avec paramètres ou des tags ...

Présentation de django-admin-watchdog Présentation de django-admin-watchdog 12/11/2020

Comment garder une trace des erreurs Django en toute simplicité.

Présentation de django-tracking-fields Présentation de django-tracking-fields 03/11/2020

Suivi de modification d'objets Django