Le routage dans Django à partir de la deuxième version du framework a reçu un merveilleux outil - les convertisseurs. Avec l'ajout de cet outil, il est devenu possible non seulement de configurer de manière flexible les paramètres dans les itinéraires, mais également de séparer les zones de responsabilité des composants.
Je m'appelle Alexander Ivanov, je suis mentor chez Yandex.Practicum à la faculté de développement back- end et développeur principal au laboratoire de modélisation informatique. Dans cet article, je vais vous présenter les convertisseurs de route de Django et vous montrer les avantages de leur utilisation. La première chose à faire est les limites d'applicabilité:
- Django version 2.0+;
- l'enregistrement des itinéraires doit être effectué avec
django.urls.path
.
Ainsi, lorsqu'une requête arrive sur le serveur Django, elle passe d'abord par la chaîne middleware, puis l'URLResolver ( algorithme ) est activé . La tâche de ce dernier est de trouver une route appropriée dans la liste des routes enregistrées.
Pour une analyse de fond, je propose de considérer la situation suivante: il y a plusieurs endpoints qui devraient générer différents rapports pour une certaine date. Supposons que les points de terminaison ressemblent à ceci:
users/21/reports/2021-01-31/
teams/4/reports/2021-01-31/
Quels seraient les itinéraires
urls.py
? Par exemple, comme ceci:
path('users/<id>/reports/<date>/', user_report, name='user_report'),
path('teams/<id>/reports/<date>/', team_report, name='team_report'),
Chaque élément de
< >
est un paramètre de requête et sera transmis au gestionnaire.
Important: le nom du paramètre lors de l'enregistrement de la route et le nom du paramètre dans le gestionnaire doivent correspondre.
Ensuite, chaque gestionnaire aurait quelque chose comme ceci (faites attention aux annotations de type):
def user_report(request, id: str, date: str):
try:
id = int(id)
date = datetime.strptime(date, '%Y-%m-%d')
except ValueError:
raise Http404()
# ...
Mais ce n'est pas une affaire royale - copier-coller un tel bloc de code pour chaque gestionnaire. Il est raisonnable de déplacer ce code dans une fonction auxiliaire:
def validate_params(id: str, date: str) -> (int, datetime):
try:
id = int(id)
date = datetime.strptime(date, '%Y-%m-%d')
except ValueError:
raise Http404('Not found')
return id, date
Et dans chaque gestionnaire, il y aura un simple appel à cette fonction d'assistance:
def user_report(request, id: str, date: str):
id, date = validate_params(id, date)
# ...
En général, c'est déjà digeste. La fonction d'assistance renvoie les paramètres corrects des types requis ou abandonne le gestionnaire. Tout semble aller bien.
Mais en fait, voici ce que j'ai fait: j'ai transféré une partie de la responsabilité de décider si ce gestionnaire doit fonctionner pour cette route ou non, de l'URLResolver au gestionnaire lui-même. Il s'avère qu'URLResolver a mal fait son travail, et mes gestionnaires doivent non seulement faire un travail utile, mais aussi décider s'ils doivent le faire. Il s'agit d'une violation flagrante du principe SOLID de la responsabilité exclusive . Cela ne fonctionnera pas. Nous devons nous améliorer.
Convertisseurs standard
Django fournit des convertisseurs de route standard . C'est un mécanisme pour déterminer si une partie de la route est appropriée ou non par l'URLResolver lui-même. Un joli bonus: le convertisseur peut changer le type du paramètre, ce qui signifie que le type dont nous avons besoin peut venir immédiatement au gestionnaire, et non à la chaîne.
Les convertisseurs sont spécifiés avant le nom du paramètre dans la route, séparés par deux points. En fait, tous les paramètres ont un convertisseur, s'il n'est pas spécifié explicitement, alors le convertisseur est utilisé par défaut
str
.
Méfiez-vous: certains convertisseurs ressemblent à des types en Python, il peut donc sembler que ce sont des casts normaux, mais ils ne le sont pas - par exemple, il n'y a pas de convertisseurs standardfloat
oubool
. Plus tard, je vous montrerai ce qu'est un convertisseur.
Après avoir examiné les convertisseurs standard, il devient évident pour quoi
id
utiliser le convertisseur
int
:
path('users/<int:id>/reports/<date>/', user_report, name='user_report'),
path('teams/<int:id>/reports/<date>/', team_report, name='team_report'),
Mais qu'en est-il de la date? Il n'y a pas de convertisseur standard pour cela.
Vous pouvez, bien sûr, esquiver et faire ceci:
'users/<int:id>/reports/<int:year>-<int:month>-<int:day>/'
En effet, certains des problèmes ont été éliminés, car il est désormais garanti que la date sera affichée en trois nombres séparés par des tirets. Cependant, vous devez toujours gérer les cas de problème dans le gestionnaire si le client envoie une date incorrecte, par exemple 2021-02-29 ou 100-100-100 en général. Cela signifie que cette option ne convient pas.
Nous créons notre propre convertisseur
Django, en plus des convertisseurs standard, offre la possibilité de créer votre propre convertisseur et de décrire les règles de conversion comme vous le souhaitez.
Pour ce faire, vous devez suivre deux étapes:
- Décrivez la classe du convertisseur.
- Enregistrez le convertisseur.
Une classe de convertisseur est une classe avec un certain ensemble d'attributs et de méthodes décrits dans la documentation (à mon avis, il est quelque peu étrange que les développeurs n'aient pas créé de classe abstraite de base). Les exigences elles-mêmes:
- Il doit y avoir un attribut
regex
décrivant l'expression régulière pour trouver rapidement la sous-séquence requise. Je vous montrerai comment il est utilisé plus tard. - Implémentez une méthode
def to_python(self, value: str)
de conversion d'une chaîne (après tout, la route transmise est toujours une chaîne) en un objet python, qui sera éventuellement passé au gestionnaire. - Implémentez une méthode
def to_url(self, value) -> str
pour reconvertir d'un objet python en une chaîne (utilisée lors de l'appeldjango.urls.reverse
ou du balisageurl
).
La classe de conversion de la date ressemblera à ceci:
class DateConverter:
regex = r'[0-9]{4}-[0-9]{2}-[0-9]{2}'
def to_python(self, value: str) -> datetime:
return datetime.strptime(value, '%Y-%m-%d')
def to_url(self, value: datetime) -> str:
return value.strftime('%Y-%m-%d')
Je suis contre la duplication, donc je vais mettre le format de date dans un attribut - il est plus facile de maintenir le convertisseur si je veux (ou ai besoin) soudainement de changer le format de date:
class DateConverter:
regex = r'[0-9]{4}-[0-9]{2}-[0-9]{2}'
format = '%Y-%m-%d'
def to_python(self, value: str) -> datetime:
return datetime.strptime(value, self.format)
def to_url(self, value: datetime) -> str:
return value.strftime(self.format)
La classe est décrite, il est donc temps de l'enregistrer en tant que convertisseur. Cela se fait très simplement: dans la fonction,
register_converter
vous devez spécifier la classe décrite et le nom du convertisseur afin de l'utiliser dans les routes:
from django.urls import register_converter
register_converter(DateConverter, 'date')
Vous pouvez maintenant décrire les routes dans
urls.py
(j'ai délibérément changé le nom du paramètre pour
dt
ne pas confondre l'entrée
date:date
):
path('users/<int:id>/reports/<date:dt>/', user_report, name='user_report'),
path('teams/<int:id>/reports/<date:dt>/', team_report, name='team_report'),
Maintenant, il est garanti que les gestionnaires ne seront appelés que si le convertisseur fonctionne correctement, ce qui signifie que les paramètres du type requis viendront au gestionnaire:
def user_report(request, id: int, dt: datetime):
#
#
Ça a l'air génial! Et c'est ainsi, vous pouvez vérifier.
Sous la capuche
Si vous regardez de plus près, une question intéressante se pose: nulle part il n'y a de vérification que la date est correcte. Oui, il y a une saison régulière, mais une date incorrecte lui convient également, par exemple 2021-01-77, ce qui signifie qu'il
to_python
doit y avoir une erreur. Pourquoi ça marche?
À ce sujet, je dis: "Jouez selon les règles du framework, et il jouera pour vous." Les cadres prennent en charge un certain nombre de tâches courantes. Si le framework ne peut pas faire quelque chose, alors un bon framework offre la possibilité d'étendre ses fonctionnalités. Par conséquent, vous ne devez pas vous engager dans la construction de vélos, il est préférable de voir comment le cadre propose d'améliorer ses propres capacités.
Django a un sous-système de routage avec la possibilité d'ajouter des convertisseurs qui s'occupent de l'appel de méthode
to_python
et attraper les erreurs
ValueError
.
Voici le code du sous-système de routage Django sans modifications (version 3.1, fichier
django/urls/resolvers.py
, classe
RoutePattern
, méthode
match
):
match = self.regex.search(path)
if match:
# RoutePattern doesn't allow non-named groups so args are ignored.
kwargs = match.groupdict()
for key, value in kwargs.items():
converter = self.converters[key]
try:
kwargs[key] = converter.to_python(value)
except ValueError:
return None
return path[match.end():], (), kwargs
return None
La première étape consiste à rechercher des correspondances dans l'itinéraire transmis par le client à l'aide d'une expression régulière. Celui
regex
qui est défini dans la classe de convertisseur participe à la formation
self.regex
, c'est-à-dire qu'il est remplacé à la place de l'expression entre crochets
<>
dans l'itinéraire.
Par exemple,
changer enusers/<int:id>/reports/<date:dt>/
^users/(?P<id>[0-9]+)/reports/(?P<dt>[0-9]{4}-[0-9]{2}-[0-9]{2})/$
En fin de compte, juste le même régulier de
DateConverter
.
Il s'agit d'une recherche rapide, superficielle. Si aucune correspondance n'est trouvée, la route n'est certainement pas appropriée, mais si elle est trouvée, alors c'est une route potentiellement appropriée. Cela signifie que vous devez commencer la prochaine étape de vérification.
Chaque paramètre a son propre convertisseur, qui est utilisé pour appeler la méthode
to_python
. Et voici la chose la plus intéressante: l'appel est
to_python
encapsulé
try/except
et les erreurs de type sont détectées
ValueError
. C'est pourquoi le convertisseur fonctionne même dans le cas d'une date incorrecte: une erreur tombe
ValueError
, et cela est pris en compte pour que l'itinéraire ne rentre pas.
Donc, dans le cas de
DateConverter
, on peut dire, chanceux: en cas de date incorrecte, une erreur du type requis tombe. S'il y a une erreur d'un autre type, Django renverra une réponse 500.
Ne t'arrête pas
Il semble que tout va bien, les convertisseurs fonctionnent, les types nécessaires arrivent immédiatement aux manutentionnaires ... Ou pas tout de suite?
path('users/<int:id>/reports/<date:dt>/', user_report, name='user_report'),
Dans le gestionnaire de génération d'un rapport, vous en avez probablement besoin
User
, et non pas
id
(bien que cela puisse être le cas). Dans ma situation hypothétique, il suffit d'un objet pour créer un rapport
User
. Que se passe-t-il alors, encore vingt-cinq?
def user_report(request, id: int, dt: datetime):
user = get_object_or_404(User, id=id)
# ...
Transférer à nouveau les responsabilités au gestionnaire.
Mais maintenant, ce qu'il faut en faire est clair: écrivez votre propre convertisseur! Il s'assurera que l'objet existe
User
et le passera au gestionnaire.
class UserConverter:
regex = r'[0-9]+'
def to_python(self, value: str) -> User:
try:
return User.objects.get(id=value)
except User.DoesNotExist:
raise ValueError('not exists') # ValueError
def to_url(self, value: User) -> str:
return str(value.id)
Après avoir décrit la classe, je l'enregistre:
register_converter(UserConverter, 'user')
Enfin, je décris l'itinéraire:
path('users/<user:u>/reports/<date:dt>/', user_report, name='user_report'),
C'est mieux:
def user_report(request, u: User, dt: datetime):
# ...
Les convertisseurs pour modèles peuvent être utilisés souvent, il est donc pratique de créer la classe de base d'un tel convertisseur (en même temps, j'ai ajouté une vérification de l'existence de tous les attributs):
class ModelConverter:
regex: str = None
queryset: QuerySet = None
model_field: str = None
def __init__(self):
if None in (self.regex, self.queryset, self.model_field):
raise AttributeError('ModelConverter attributes are not set')
def to_python(self, value: str) -> models.Model:
try:
return self.queryset.get(**{self.model_field: value})
except ObjectDoesNotExist:
raise ValueError('not exists')
def to_url(self, value) -> str:
return str(getattr(value, self.model_field))
Ensuite, la description du nouveau convertisseur vers le modèle sera réduite à une description déclarative:
class UserConverter(ModelConverter):
regex = r'[0-9]+'
queryset = User.objects.all()
model_field = 'id'
Résultat
Les convertisseurs de route sont un mécanisme puissant qui vous aide à rendre votre code plus propre. Mais ce mécanisme n'est apparu que dans la deuxième version de Django - avant cela, nous devions nous en passer. C'est de là que
get_object_or_404
viennent les fonctions auxiliaires de ce type ; sans ce mécanisme, des bibliothèques sympas comme DRF sont créées.
Mais cela ne signifie pas que les convertisseurs ne doivent pas du tout être utilisés. Cela signifie que (encore) il ne sera pas possible de les utiliser partout. Mais dans la mesure du possible, je vous exhorte à ne pas les négliger.
Je vais laisser une mise en garde: ici, il est important de ne pas en faire trop et de ne pas faire glisser la couverture dans l'autre sens - vous n'avez pas besoin de prendre la logique métier dans le convertisseur. Il est nécessaire de répondre à la question: si un tel itinéraire est en principe impossible, alors c'est le domaine de responsabilité du convertisseur; si une telle route est possible, mais dans certaines circonstances elle n'est pas traitée, alors c'est déjà la responsabilité du gestionnaire, du sérialiseur ou de quelqu'un d'autre, mais certainement pas du convertisseur.
PS En pratique, je n'ai fait et utilisé qu'un convertisseur de dates, juste celui montré dans l'article, puisque j'utilise presque toujours DRF ou GraphQL. Veuillez nous indiquer si vous utilisez des convertisseurs d'itinéraire et, si oui, lesquels?