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 :
- gestion multi sites (voir ça comme une plateforme applicative avec dessus des applications) : Django peut avoir un mode "site" (projet) dans lequel l'on peut créer des applications, ce qui a l'avantage de mutualiser tout ce qui relatif à la configuration, modules, logs, etc
- l'aspect Web (urls / routes, les requêtes Web, sessions, authentification, utilisateur, scaffolding "admin" pour le développement, ...)
- 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 :
- si le get() ne trouve pas la ligne (exception Entite.DoesNotExist)
- si le get() renvoie plus d'une ligne (exception Entite.MultipleObjectsReturned
>>> 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 :
- select_related : pour les relations à 1 (ForeignKey)
- prefetch-related : pour les relations * (définies dans le related_name)
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.
Top comments (3)
Wow bravo pour la qualité de cette série!
Merci, même si je pense que j'aurais dû faire plus simple :)
Merci pour la qualité de cette série. J'ai vraiment appris beaucoup.