loading...

Django & DRF 101, partie 1

zorky profile image DUVAL Olivier Updated on ・14 min read

Sommaire

Préambule : changement de stack

J'ai changé de stack technique depuis bientôt 2 ans. Avec un background principalement .NET (C# / ASPNET Webforms - MVC - WebApi / EF - NHibernate) depuis 15 ans, j'avais envie de changer, après être passé par d'autres langages / frameworks avant ou pendant C# / .NET : C, PHP, Perl, Ruby, Java / Spring.

En voici les raisons.

.NET

La plateforme .NET est une plateforme polyvalente, je développais principalement avec pour des applications Web et plus particulièrement sur une architecture "REST" (frontend SPA / API REST) avec MVC puis WebAPI pour coder des API.

Le langage C# est très bien, devenu opensource à partir de 2018, MS arrive à enrichir le langage pour répondre aux besoins des développeurs, en revanche, j'ai souvent trouvé "complexe" (entendre beaucoup de lignes de code ou code boilerplate) pour développer des API ou du MVC (et je ne suis pas le seul, à l'image de ce tweet) :

  • avoir des compétences Entity framework qui est devenu le standard de fait pour l'accès aux données (j'utilisais NHibernate avant qu'EF devienne mûr). Comme tout ORM, il vaut mieux bien connaitre le framework pour ne pas faire de bêtises ou que cela soit performant, en connaître les subtilités pour éviter des effets de bords indésirables,
  • C# fonctionne souvent avec beaucoup d'attributs (HttpGet, permissions, attributs dans les méthodes HttpBody et j'en passe, classes sérializables, etc), ce qui alourdit le code et le rend spécialisé,
  • devoir avoir des couches (N tiers) entre la couche données et les APIs, ce qui oblige souvent à avoir des couches intermédiaires (POCO / DTO) et donc à mapper les objets, à avoir des "services" pour abstraire la logique via des interfaces notamment, ce qui, sur un projet, génère encore une fois beaucoup de code technique

On retrouvera ces points négatifs sur l'écosystème Java ou d'autres plateformes.

Cette complexité technique empêche ou du moins réduit fortement de se concentrer sur les aspects métiers d'une application, qui pour moi, est bien plus intéressant.

Le nombre de ligne de code induit également a fortiori une augmentation automatique de bugs potentiels, effets de bords ou régressions et de générer un système à termes instable pour la maintenabilité de l'application, d'autant plus si plusieurs développeurs interviennent sur cette dernière, j'aime appeler cela l’entropie d'une application ("Il caractérise le niveau de désorganisation, ou d'imprédictibilité du contenu en information d'un système").

Je bossais chez un éditeur de solution ITSM, et on m'avait parlé de Django / python, tandis qu'on développait, en équipe de 5, la solution en .NET / C# / ASPNET.

.NET Core était aussi en train de mûrir, ce qui signifie aussi quelques changements malgré tout, et donc de ré-apprendre certains éléments, autant changer je me suis dit.

J'ai eu l'occasion d'avoir une opportunité de changer de société, pour aller vers une stack Django / Django Rest Framework / python pour le backend et Angular pour la partie frontend, et, après 2 ans, j'ai pu fortement monter en compétences sur cette pile technique et j'en suis très content : plus simple, moins de code, une productivité bien plus haute, le plaisir de retrouver un langage dynamique tel que python (qui me fait penser fortement à Javascript que j'aime) : typage dynamique, closure, fonctionnel, ... et bien entendu développer objet (POO).

Associé à Django / DRF, on est bien plus productif et surtout, on se concentre sur le métier et non plus sur du code technique (même s'il y en a bien entendu ;-) )

Django et DRF

Après s'être intéressé au pourquoi avoir basculé de stack technique, intéressons-nous aux frameworks utilisés pour en faire une introduction, avec comme 1ère partie, un focus sur l'ORM de Django.

Django

Django est un framework Web "MVC" (MVT pour être exact), opensource (c'est une fondation derrière), écrit en python.

En tant que framework, il englobe tout un tas de fonctionnalités, avec, notamment :

  • l'aspect Web (urls / routes, les requêtes Web, sessions, authentification, utilisateur, scaffolding "admin" pour le développement, ...)
  • gestion multi sites (voir ça comme une plateforme applicative avec dessus des applications),
  • possibilité de middlewares pour étendre le framework,
  • un moteur de template,
  • un mode "admin" pour les entités pour du scaffolding rapide pour des besoins en mode développement,
  • une gestion de configuration, de logs, etc
  • un ORM (connexions, requêtage, sécurité des requêtes, multi-bases, etc)

...et c'est très bien documenté.

Dans le cadre d'API, on lui adjoint DRF que nous verrons dans un futur article. Avec DRF, je n'utilise pas le modèle MVC / MVT et son moteur de template de Django mais uniquement le reste.

ORM

Venant d'Entity framework / Nhibernate, mon passage à à l'ORM de Django a été le plus perturbant pour le requêtage, mais on y vient sans trop de difficulté et quelques principes restent les mêmes (exécution des requêtes différée à la "IQueryable" avec les querysets, lazy / eager loading, migrations), définition des entités, même si LINQ était très appréciable. Un excellent article sur les ORM ou plus particulièrement l'ORM de Django.

L'ORM suit le modèle "code / model first" : on définit ses entités, la génération en base vient ensuite grâce aux migrations (que cela soit le schéma de données ou les données), tout est donc porté par le code : versionning des entités que l'on pourra partager dans Git par exemple.

L'ORM propose ce que l'on peut retrouver dans tout ORM : gestion des connexions, typage des champs, relations (1 -- 1, 1 -- *, * -- *), contraintes, requêtage, etc

Pour une configuration d'une station de travail pour Django, voir l'article dédié : https://dev.to/zorky/django-drf-101-initialisation-1e39

Entités

Par exemple, on veut représenter le schéma suivant sous forme UML :

Task * ---- 1 TodoList : pouvoir créer des listes de tâches à faire.

J'utilise une classe utilitaire "abstraite" TimeStampedModel pour ajouter automatiquement 2 champs aux entités : dates de création ou de modification d'un enregistrement dans la table, les entités hériteront de cette classe.

class TimeStampedModel(models.Model):
    """
    Classe utilitaire, champs dt_created / dt_updated
    """
    dt_created = models.DateTimeField(auto_now_add=True)
    dt_updated = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True


class TodoList(TimeStampedModel):
   """
   Une liste de trucs à faire 
   """
   label = models.CharField(max_length=100, help_text='liste de tâches')


class Task(TimeStampedModel):
    """
    Une tâche à faire
    """
    label = models.CharField(max_length=300, db_index=True, 
                             null=False, blank=False,
                             help_text='libellé de la tâche')
    done = models.BooleanField(default=False, help_text='fait ou non ?')
    list = models.ForeignKey(TodoList, null=False, 
                             related_name='tasks', 
                             on_delete=models.CASCADE, 
                             help_text='liste associée, list.tasks pour avoir la liste des tâches pour cette liste')

Il est nul besoin de préciser l'id (clé primaire), par défaut, Django en ajoute un en auto-incrément. Il est à noter qu'il y a une restriction quant aux clés primaires : on ne peut avoir de clé composée de plusieurs champs de la table.

db_index crée un index sur label, contraintes sur NULL ou vide avec null=False et blank=False
le related_name représente la relation inverse (le many du one to many), côté TodoList, pour avoir les tâches associée à une liste, il suffira alors de faire : list.tasks

Migrations

Nos entités sont créées, maintenant, générons ce qu'il faut pour la prise en compte en base en utilisant la commande django manage.py et à la commande makemigrations (le script python pour générer ce qu'il faudra en base). Django tient à jour les migrations et les versions des différentes migrations créées (dans une table django_migration qu'il ne faut absolument pas toucher), avantage : on pourra revenir en arrière (grâce à la commande migrate ) si on le souhaite.

$ python3 manage.py makemigrations

Cela génère un fichier dans le répertoire migrations de la forme numero_nom.py (ici, c'est la 1ère, on aura alors 001_initial.py)

On pourrait aussi nommer la migration pour plus de clarté avec l'option --name

$ python3 manage.py makemigrations --name 'todolist_task'

cela va créer un fichier de migration de type numero_todolist_task

Lorsque tout est bon, il reste à passer les fichiers de migrations avec la commande migrate, cela va créer les tables en base. Par défaut, la convention de nommage des tables est projet_entite, ici, si notre projet s'appelle todolist, on aura todolist_task et todolist_todolist

$ python3 manage.py migrate

Requêtage

Pour écrire des requêtes, nous utilisons les Queryset. Une queryset n'est évaluée (on dira qu'elle est lazy) uniquement lorsqu'elle est explicitement appelée (par un print ou une itération par exemple), ce qui permettra de construire sa requête dynamiquement sans l'exécuter, comme nous le verrons plus tard.

Maintenant que nous avons nos 2 entités, on va pouvoir les manipuler. Django propose un utilitaire en ligne de commande : shell qui ouvre une session python pour taper des commandes, pour ma part, je lui préfère shell_plus qui a le mérite de charger toutes les entités ou autre besoins pour le requêtage, inclut dans le module django-extensions

$ python3 manage.py shell_plus
Création

Créons une liste de tâches et au moins une tâche dans cette liste :

>>> todolist = TodoList(label='urgente')
>>> todolist.save()

>>> task1 = Task(label='chercher le pain', list=todolist)
>>> task1.save()
>>> task2 = Task(label='prendre les enfants', list=todolist)
>>> task2.save()
Interrogations

Interrogeons en base nos 2 entités :

>>> Task.objects.all()
<QuerySet [<Task: Task object (1)>, <Task: Task object (2)>]>
>>> TodoList.objects.all()
<QuerySet [<TodoList: TodoList object (1)>]>

objects est le manager par défaut des entités, il contient l'API pour requêter les objets, ici, all() qui renvoie l'ensemble des lignes de chaque entité.

On retrouvera dans objects pelle-mêle les fonctions suivantes : filter(), get(), order_by(), count(), exists(), exclude(), first(), last(), annotate() et bien d'autres.

Par défaut la liste affichée est la liste des objets avec leur id, si on veut que cela soit plus parlant, il suffit de surcharge pour chaque entité la fonction str pour préciser quel valeur de champ à afficher, par exemple :

class TodoList(TimeStampedModel):
   """
   Une liste de trucs à faire 
   """
   label = models.CharField(max_length=100, help_text='liste de tâches')

   def __str__(self):
      return self.label

class Task(TimeStampedModel):
    """
    Une tâche à faire
    """
    label = models.CharField(max_length=300, db_index=True, 
                             null=False, blank=False,
                             help_text='libellé de la tâche')
    done = models.BooleanField(default=False, help_text='fait ou non ?')
    list = models.ForeignKey(TodoList, null=False, 
                             related_name='tasks', 
                             on_delete=models.CASCADE, 
                             help_text='liste associée, list.tasks pour avoir la liste des tâches pour cette liste')

    def __str__(self):
       return self.label

en exécutant la même requête (Task.objects.all()), on aura alors le label et non plus l'id d'affiché :

>>> Task.objects.all()
<QuerySet [<Task: chercher le pain>, <Task: prendre les enfants>]>

Maintenant, je souhaite recherche la tâche identifiée d'id 2

>>> task = Task.objects.get(id=2)
>>> task
<Task: prendre les enfants>

on aurait aussi pu utiliser le mot clé pk (primary key) que je préfère, cela abstrait la dénomination de la clé primaire, par défaut c'est id mais cela pourrait être autre chose si on l'avait défini autrement (uid, object_id, etc) :

>>> task = Task.objects.get(pk=2)
>>> task
<Task: prendre les enfants>

ou par son nom :

>>> task = Task.objects.get(label='chercher le pain')
>>> task
<Task: chercher le pain>

Attention si la fonction get() peut lever des exceptions dans au moins 2 cas :

>>> task = Task.objects.get(label='chercher le pai')
Traceback (most recent call last):
  File "/usr/local/lib/python3.7/code.py", line 90, in runcode
    exec(code, self.locals)
  File "<console>", line 1, in <module>
  File "/usr/local/lib/python3.7/site-packages/django/db/models/manager.py", line 82, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
  File "/usr/local/lib/python3.7/site-packages/django/db/models/query.py", line 408, in get
    self.model._meta.object_name
api_projets.models.DoesNotExist: Task matching query does not exist.
Filtrage avec des condition(s)

Des filtres un peu plus évolués avec la fonction filter() : représente le WHERE d'une requête SQL, renvoie les lignes correspondent au critère ou [] si rien n'est trouvé

Les tâches qui contient "pain" dans leur label

>>> tasks = Task.objects.filter(label__contains='pain')
>>> tasks
<QuerySet [<Task: chercher le pain>]>

Affichons la requête qui est générée par l'ORM

>>> tasks = Task.objects.filter(label__contains='pain')
>>> print(tasks.query)
SELECT "api_projets_task"."id", "api_projets_task"."dt_created", "api_projets_task"."dt_updated", "api_projets_task"."label", "api_projets_task"."done", "api_projets_task"."list_id" FROM "api_projets_task" WHERE "api_projets_task"."label"::text LIKE %pain%

il est toujours intéressant de voir la requête générée pour des requêtes complexes ou voir si on a bien ce qu'il faudrait.

Quelques mots clés sur les champs textes :

  • startswith : correspond à champ LIKE '%valeur'
  • contains ou icontains : correspond à champ LIKE '%valeur%', le i signifie de ne pas prendre en compte la casse ('Pain' et 'pain' renverront la même chose)
  • champ='valeur' ou exact ou iexact : correspond à champ='valeur', le i permet de ne pas prendre en compte la casse
  • on peut rajouter unaccent pour que la recherche ne soit pas sensible aux accents, par exemple :
>>> tasks = Task.objects.filter(label__unaccent__icontains='elec')
>>> tasks
<QuerySet [<Task: élection du pain>]>

la requête générée :

>>> print(tasks.query)
SELECT "api_projets_task"."id", "api_projets_task"."dt_created", "api_projets_task"."dt_updated", "api_projets_task"."label", "api_projets_task"."done", "api_projets_task"."list_id" FROM "api_projets_task" WHERE UPPER(UNACCENT("api_projets_task"."label"
)::text) LIKE '%' || UPPER(REPLACE(REPLACE(REPLACE((UNACCENT(elec)), E'\\', E'\\\\'), E'%', E'\\%'), E'_', E'\\_')) || '%'

On peut cumuler les filtres, par défaut, cela correspondra à un ET, par exemple les tâches commençant par 'elec' et ayant l'id = 1, on affiche la requête SQL générée

>>> tasks = Task.objects.filter(label__unaccent__istartswith='elec', pk=1)
>>> print(tasks.query)
SELECT "api_projets_task"."id", "api_projets_task"."dt_created", "api_projets_task"."dt_updated", "api_projets_task"."label", "api_projets_task"."do
ne", "api_projets_task"."list_id" FROM "api_projets_task" WHERE (UPPER(UNACCENT("api_projets_task"."label")::text) LIKE UPPER(REPLACE(REPLACE(REPLAC
E((UNACCENT(elec)), E'\\', E'\\\\'), E'%', E'\\%'), E'_', E'\\_')) || '%' AND "api_projets_task"."id" = 1)

si l'on souhaite faire des OU ou des not (n'est pas égal) , on utilisera les Q expressions, par exemple, les tâches commençant par "elec" ou d'id 2, il suffira d'utiliser l'opérateur | dans la fonction filter()

>>> commencepar = Q(label__unaccent__istartswith='label')
>>> id = Q(pk=2)
>>> tasks = Task.objects.filter(commencepar | id)
>>> print(tasks.query)
SELECT "api_projets_task"."id", "api_projets_task"."dt_created", "api_projets_task"."dt_updated", "api_projets_task"."label", "api_projets_task"."do
ne", "api_projets_task"."list_id" FROM "api_projets_task" WHERE (UPPER(UNACCENT("api_projets_task"."label")::text) LIKE UPPER(REPLACE(REPLACE(REPLAC
E((UNACCENT(label)), E'\\', E'\\\\'), E'%', E'\\%'), E'_', E'\\_')) || '%' OR "api_projets_task"."id" = 2)

la même requête mais avec n'ayant pas (le ~ est alors utilisé) l'id 2

>>> tasks = Task.objects.filter(commencepar | ~Q(id=2))
>>> print(tasks.query)
SELECT "api_projets_task"."id", "api_projets_task"."dt_created", "api_projets_task"."dt_updated", "api_projets_task"."label", "api_projets_task"."do
ne", "api_projets_task"."list_id" FROM "api_projets_task" WHERE (UPPER(UNACCENT("api_projets_task"."label")::text) LIKE UPPER(REPLACE(REPLACE(REPLAC
E((UNACCENT(label)), E'\\', E'\\\\'), E'%', E'\\%'), E'_', E'\\_')) || '%' OR NOT ("api_projets_task"."id" = 2))

Optimisations

Comme tout ORM, on retrouve sur l'ORM de Django, le problème du SELECT N+1 qu'il faut se soucier pour ne pas se trouver avec des centaines de requêtes inutilement exécutées.

Le SELECT N+1 c'est quoi ? par défaut, les queryset sont différées et leurs attributs vers les entités rattachées aussi : autrement dit, dans notre exemple, si nous avons une liste de Task et que nous souhaitons afficher la TodoList à laquelle elle est rattachée, sur une itération d'une liste de Task, le fait d'afficher task.list.label , cela déclenchera une requête à chaque fois pour interroger la liste associée.

Par exemple, le code suivant déclenche au print un "SELECT" pour joindre Task et TodoList et obtenir l'objet, ce qui peut vite devenir une catastrophe sur un schéma de données bien plus complexe.

>>> tasks = Task.objects.all()
>>> for task in tasks:
>>>     print(tasks.list.label)

en activant l'affichage des requêtes de shell_plus (python3 manage.py shell_plus --print-sql), on aura le résultat suivant qui montre qu'une requête est exécutée vers TodoList pour chaque Task itérée :

SELECT "api_projets_task"."id",
       "api_projets_task"."dt_created",
       "api_projets_task"."dt_updated",
       "api_projets_task"."label",
       "api_projets_task"."done",
       "api_projets_task"."list_id"
  FROM "api_projets_task"

Execution time: 0.000945s [Database: default]

SELECT "api_projets_todolist"."id",
       "api_projets_todolist"."dt_created",
       "api_projets_todolist"."dt_updated",
       "api_projets_todolist"."label"
  FROM "api_projets_todolist"
 WHERE "api_projets_todolist"."id" = 1

Execution time: 0.000498s [Database: default]

Liste 1
SELECT "api_projets_todolist"."id",
       "api_projets_todolist"."dt_created",
       "api_projets_todolist"."dt_updated",
       "api_projets_todolist"."label"
  FROM "api_projets_todolist"
 WHERE "api_projets_todolist"."id" = 2

Execution time: 0.000527s [Database: default]

Liste 2
SELECT "api_projets_todolist"."id",
       "api_projets_todolist"."dt_created",
       "api_projets_todolist"."dt_updated",
       "api_projets_todolist"."label"
  FROM "api_projets_todolist"
 WHERE "api_projets_todolist"."id" = 1

Execution time: 0.000489s [Database: default]

Liste 1
SELECT "api_projets_todolist"."id",
       "api_projets_todolist"."dt_created",
       "api_projets_todolist"."dt_updated",
       "api_projets_todolist"."label"
  FROM "api_projets_todolist"
 WHERE "api_projets_todolist"."id" = 1

Execution time: 0.000445s [Database: default]

Liste 1
SELECT "api_projets_todolist"."id",
       "api_projets_todolist"."dt_created",
       "api_projets_todolist"."dt_updated",
       "api_projets_todolist"."label"
  FROM "api_projets_todolist"
 WHERE "api_projets_todolist"."id" = 2

Execution time: 0.000495s [Database: default]

Liste 2

Pour "précharger" ou du moins avoir une requête qui nous retournera directement les TodoList associées, Django propose 2 fonctions suivant la relation :

Reprenons notre exemple avec la liste des Task et la TodoList associée (en ForeignKey de Task) en utilisant le select_related()

>>> tasks = Task.objects.select_related('list').all()
>>> for task in tasks:
      print(task.list.label)

LA requête générée lors de l'accès aux entités pour les afficher :

SELECT "api_projets_task"."id",
       "api_projets_task"."dt_created",
       "api_projets_task"."dt_updated",
       "api_projets_task"."label",
       "api_projets_task"."done",
       "api_projets_task"."list_id",
       "api_projets_todolist"."id",
       "api_projets_todolist"."dt_created",
       "api_projets_todolist"."dt_updated",
       "api_projets_todolist"."label"
  FROM "api_projets_task"
 INNER JOIN "api_projets_todolist"
    ON ("api_projets_task"."list_id" = "api_projets_todolist"."id")

Execution time: 0.001652s [Database: default]

Liste 1
Liste 1
Liste 1
Liste 2
Liste 2

C'est déjà mieux. Django a rajouté un INNER JOIN vers TodoList et les attributs de cette dernière dans le SELECT.

Maintenant, passons par les TodoList : liste des todolist et pour chacune, obtenir la liste des tâches associées, on aura alors ce type de requêtes :

>>> todolists = TodoList.objects.all()
>>> for list in todolists:
      for task in list.tasks.all():
         print(task.label)

qui va créer comme requêtes, une pour obtenir les TodoList et ensuite une par TodoList trouvée pour obtenir ses Tasks :

SELECT "api_projets_todolist"."id",
       "api_projets_todolist"."dt_created",
       "api_projets_todolist"."dt_updated",
       "api_projets_todolist"."label"
  FROM "api_projets_todolist"

Execution time: 0.000305s [Database: default]

SELECT "api_projets_task"."id",
       "api_projets_task"."dt_created",
       "api_projets_task"."dt_updated",
       "api_projets_task"."label",
       "api_projets_task"."done",
       "api_projets_task"."list_id"
  FROM "api_projets_task"
 WHERE "api_projets_task"."list_id" = 1

Execution time: 0.000573s [Database: default]

Tache 4
Tache 3
Tache 1
SELECT "api_projets_task"."id",
       "api_projets_task"."dt_created",
       "api_projets_task"."dt_updated",
       "api_projets_task"."label",
       "api_projets_task"."done",
       "api_projets_task"."list_id"
  FROM "api_projets_task"
 WHERE "api_projets_task"."list_id" = 2

Execution time: 0.000660s [Database: default]

Tache 5
Tache 2

Maintenant avec le prefetch_related() :

>>> todolists = TodoList.objects.prefetch_related('tasks').all()
>>> for list in todolists:
      for task in list.tasks.all():
         print(task.label)

qui génère comme requêtes, une pour la liste des TodoList et une pour la liste tâches pour les TodoList (le in) :

SELECT "api_projets_todolist"."id",
       "api_projets_todolist"."dt_created",
       "api_projets_todolist"."dt_updated",
       "api_projets_todolist"."label"
  FROM "api_projets_todolist"

Execution time: 0.000574s [Database: default]

SELECT "api_projets_task"."id",
       "api_projets_task"."dt_created",
       "api_projets_task"."dt_updated",
       "api_projets_task"."label",
       "api_projets_task"."done",
       "api_projets_task"."list_id"
  FROM "api_projets_task"
 WHERE "api_projets_task"."list_id" IN (1, 2)

Execution time: 0.000646s [Database: default]

Tache 1
Tache 3
Tache 4
Tache 2
Tache 5

ce qui est encore une fois bien bien mieux : moins de requêtes, moins de temps d'exécution.

Dans une prochaine partie, nous nous intéresserons à des aspects un peu plus avancés pour le requêtage (managers, agrégations, fonctions F(), Prefetch et des fonctions utiles) ainsi qu'une introduction à DRF.

Posted on by:

zorky profile

DUVAL Olivier

@zorky

CP technique / Scrummaster, développeur sénior ancien blog : https://medium.com/zorky

Discussion

pic
Editor guide