Chez DoorDash, la majeure partie de notre backend est actuellement basée sur Django et Python. La base de code Django de nos débuts a évolué et mûri au fil des ans, au fur et à mesure que notre entreprise s'est développée. Pour continuer à évoluer, nous avons également commencé à migrer notre application monolithique vers une architecture microservices. Nous avons beaucoup appris sur ce qui fonctionne bien et ce qui ne fonctionne pas avec Django, et nous espérons pouvoir partager quelques conseils utiles sur la façon de travailler avec ce framework web populaire.
Attention aux "applications"
Django a ce concept d'" applications ", qui sont vaguement décrites dans la documentation comme " un paquetage Python qui fournit un ensemble de fonctionnalités ". Bien qu'elles aient un sens en tant que bibliothèques réutilisables qui peuvent être insérées dans différents projets, leur utilité en ce qui concerne l'organisation du code de votre application principale est moins évidente.
La façon dont vous définissez vos " applications " a quelques implications dont vous devez être conscient. La plus importante est que Django suit les migrations de modèles séparément pour chaque application. Si vous avez des clés étrangères qui lient des modèles dans différentes applications, le système de migration de Django essaiera de déduire un graphe de dépendance pour que les migrations soient exécutées dans le bon ordre. Malheureusement, ce calcul n'est pas parfait et peut conduire à des erreurs ou même à des dépendances circulaires complexes, en particulier si vous avez beaucoup d'applications.
À l'origine, nous avions organisé notre code en plusieurs "applications" distinctes pour organiser les différentes fonctionnalités, mais nous avions beaucoup de ForeignKey inter-applications. Les migrations que nous avions vérifiées se retrouvaient parfois dans un état où elles fonctionnaient correctement en production, mais pas en développement. Dans le pire des cas, elles ne fonctionnaient même pas sur une base de données vierge. Chaque système peut avoir une permutation différente d'états de migration pour différentes applications, et l'exécution de manage.py migrate
peut ne pas fonctionner avec toutes ces applications. En fin de compte, nous avons constaté que l'existence de toutes ces applications distinctes entraînait une complexité et des maux de tête inutiles.
Nous avons rapidement découvert que si ces ForeignKey traversaient différentes applications, il ne s'agissait peut-être pas vraiment d'applications distinctes au départ. En fait, nous n'avions qu'une seule "application" qui pouvait être organisée en différents paquets. Pour mieux refléter cela, nous avons mis à la poubelle nos migrations et avons tout migré vers une seule "application". Ce processus n'a pas été la tâche la plus facile à accomplir (Django utilise également les noms d'applications pour remplir les ContentType et pour nommer les tables de la base de données - nous y reviendrons plus tard), mais nous sommes heureux de l'avoir fait. Cela signifiait également que toutes nos migrations devaient être linéarisées, et bien que cela comporte des inconvénients, nous avons constaté qu'ils étaient compensés par l'avantage d'avoir un système de migration prévisible et stable.
En résumé, voici nos suggestions pour les développeurs qui démarrent un projet Django :
- Si vous ne comprenez pas vraiment l'intérêt des applications, ignorez-les et contentez-vous d'une seule application pour votre backend. Vous pouvez toujours organiser une base de code croissante sans utiliser d'applications séparées.
- Si vous souhaitez créer des applications distinctes, vous devrez être très attentif à la manière dont vous les définissez. Soyez très explicite et minimisez les dépendances entre les différentes applications. (Si vous prévoyez de migrer vers des microservices à terme, je peux imaginer que les "apps" pourraient être une construction utile pour définir les précurseurs d'un futur microservice).
Organisez vos applications dans un paquet
Puisque nous parlons d'applications, parlons un peu de l'organisation des paquets. Si vous suivez le tutoriel "Getting started" de Django, l'élément manage.py startapp
créera une "application" au niveau supérieur du répertoire du projet. Par exemple, une application appelée foo sera accessible en tant que import foo.models…
. Nous vous conseillons vivement de placer vos applications (et tout votre code Python) dans un paquetage Python, à savoir le paquetage qui est créé avec django-admin startproject
.
Dans l'exemple du tutoriel de Django, au lieu de :
mysite/
mysite/
__init__.py
polls/
__init__.py
Nous suggérons :
mysite/
mysite/
__init__.py
polls/
__init__.py
Il s'agit d'un petit changement subtil, mais il permet d'éviter les conflits d'espace de noms entre votre application et les bibliothèques Python tierces. En Python, les modules de premier niveau sont placés dans un espace de noms global et doivent être nommés de manière unique. À titre d'exemple, la bibliothèque Python d'un fournisseur que nous avons utilisé, Segment, s'appelle en fait analytics
. Si nous avions un analytics
défini comme un module de premier niveau, il n'y aurait aucun moyen de faire la distinction entre les deux paquets dans votre code.
Nommez explicitement les tables de votre base de données
Votre base de données est plus importante, plus durable et plus difficile à modifier après coup que votre application. Sachant cela, il est logique que vous soyez très attentif à la manière dont vous concevez le schéma de votre base de données, plutôt que de laisser un cadre web prendre ces décisions à votre place.
Bien que vous contrôliez en grande partie le schéma de la base de données dans Django, il y a quelques éléments qu'il gère par défaut et que vous devriez connaître. Par exemple, Django génère automatiquement un nom de table pour vos modèles, selon le modèle suivant <app_name>_<model_name_lowercased>
. Au lieu de vous fier à ces noms générés automatiquement, vous devriez envisager de définir votre propre convention de nommage et de nommer toutes vos tables manuellement, à l'aide de la fonction Meta.db_table
.
class Foo(Model):
class Meta:
db_table = 'foo'
L'autre point à surveiller est ManyToManyFields
. Django permet de générer facilement des relations entre plusieurs personnes à l'aide de ce champ et créera la table de jointure avec des noms de table et de colonne générés automatiquement. Au lieu de cela, nous vous conseillons vivement de toujours créer et nommer les tables de jointure manuellement (à l'aide de l'option through
). Il sera ainsi beaucoup plus facile d'accéder directement au tableau, et franchement, nous avons trouvé qu'il était tout simplement ennuyeux d'avoir des tableaux cachés.
Cela peut sembler être des détails mineurs, mais découpler le nommage de votre base de données des détails de l'implémentation de Django est une bonne idée car il y aura d'autres choses qui toucheront vos données en plus de l'ORM de Django, comme les entrepôts de données. Cela vous permet également de renommer vos classes de modèle plus tard si vous changez d'avis. Enfin, cela simplifie les choses comme la séparation des tables en services distincts ou la transition vers un framework web différent.
Éviter GenericForeignKey
Si vous le pouvez, évitez d'utiliser les GenericForeignKey's. Vous perdez des fonctionnalités d'interrogation de base de données telles que les jointures (select_related
) et des fonctions d'intégrité des données telles que les contraintes de clés étrangères et les suppressions en cascade. L'utilisation de tables séparées est généralement une meilleure solution, et vous pouvez exploiter des modèles de base abstraits si vous cherchez à réutiliser le code.
Cela dit, dans certaines situations, il peut être utile d'avoir une table qui peut pointer vers d'autres tables. Si c'est le cas, vous feriez mieux de faire votre propre implémentation et ce n'est pas si difficile (vous avez juste besoin de deux colonnes, l'une pour l'ID de l'objet, l'autre pour définir le type). Une chose que nous n'aimons pas à propos de GenericForeignKey est qu'ils dépendent du framework ContentTypes de Django, qui stocke les identifiants des tables dans une table de correspondance nommée django_contenttypes
.
Cette table n'est pas très amusante à gérer. Pour commencer, il utilise le nom de votre application (app_label
) et la classe de modèle Python (model
) comme colonnes pour faire correspondre un modèle Django à un identifiant entier, qui est ensuite stocké dans la table avec le GFK. Si vous déplacez des modèles d'une application à l'autre ou si vous renommez vos applications, vous devrez effectuer des modifications manuelles sur cette table. Plus important encore, le fait qu'une table commune contienne ces correspondances GFK compliquera grandement les choses si vous souhaitez déplacer vos tables dans des services et des bases de données distincts. Comme dans la section précédente sur le nommage explicite de vos tables, vous devez également posséder et définir vos propres identifiants de table. Que vous souhaitiez utiliser un entier, une chaîne de caractères ou autre chose, tous ces éléments sont préférables à un identifiant arbitraire défini dans une table aléatoire.
Assurer la sécurité des migrations
Si vous utilisez Django 1.7 ou une version ultérieure et une base de données relationnelle, vous utilisez probablement le système de migration de Django pour gérer et migrer votre schéma. Lorsque vous commencez à fonctionner à grande échelle, il y a quelques nuances importantes à prendre en compte dans l'utilisation des migrations de Django.
Tout d'abord, vous devez vous assurer que vos migrations sont sûres, c'est-à-dire qu'elles n'entraîneront pas de temps d'arrêt lorsqu'elles seront appliquées. Supposons que votre processus de déploiement consiste à appeler manage.py migrate
automatiquement avant de déployer le dernier code sur vos serveurs d'application. Une opération telle que l'ajout d'une nouvelle colonne ne présente aucun danger. Mais il ne devrait pas être trop surprenant que la suppression d'une colonne casse les choses, car le code existant ferait toujours référence à la colonne inexistante. Même si aucune ligne de code ne fait référence au champ supprimé, lorsque Django récupère un objet (par ex. Model.objects.get(..)
sous le capot, il effectue une SELECT
sur chaque colonne définie dans le modèle. Par conséquent, pratiquement tous les accès ORM de Django à cette table soulèveront une exception.
Vous pouvez éviter ce problème en veillant à exécuter les migrations après le déploiement du code, mais cela signifie que les déploiements doivent être un peu plus manuels. Cela peut s'avérer délicat si les développeurs ont effectué plusieurs migrations avant un déploiement. Une autre solution consiste à transformer ces migrations et d'autres migrations dangereuses en migrations "no-op", en faisant des migrations des opérations purement "d'état". Vous devrez alors exécuter l'opération DROP
après le déploiement.
class Migration(migrations.Migration):
state_operations = [ORIGINAL_MIGRATIONS]
operations = migrations.SeparateDatabaseAndState(
state_operations=state_operations
)
Bien entendu, l'abandon de colonnes et de tables n'est pas la seule opération à laquelle vous devez faire attention. Si vous disposez d'une base de données de production importante, de nombreuses opérations dangereuses peuvent bloquer votre base de données ou vos tables et entraîner des temps d'arrêt. Les types d'opérations spécifiques dépendent de la variante de SQL que vous utilisez. Par exemple, avec PostgreSQL, il peut être dangereux d'ajouter des colonnes avec un index ou qui ne sont pas annulables à une grande table. Voici un très bon article de BrainTree qui résume certaines des migrations dangereuses sur PostgreSQL.
Réduisez vos migrations
Au fur et à mesure que votre projet évolue et accumule de plus en plus de migrations, celles-ci prendront de plus en plus de temps à s'exécuter. De par sa conception, Django doit lire de manière incrémentale chaque migration en commençant par la première afin de construire son état interne du schéma de la base de données. Non seulement cela ralentira les déploiements de production, mais les développeurs devront également attendre lorsqu'ils configureront initialement leur base de données de développement locale. Si vous avez plusieurs bases de données, ce processus sera encore plus long, car Django jouera toutes les migrations sur chaque base de données, que la migration affecte ou non cette base de données.
À défaut d'éviter complètement les migrations de Django, la meilleure solution que nous ayons trouvée est de faire un nettoyage de printemps périodique et de "écraser" vos migrations. Une option est d'essayer la fonction d'écrasement intégrée à Django. Une autre option, qui a bien fonctionné pour nous, est de le faire manuellement. Déposez tout dans le fichier django_migrations
supprimer les fichiers de migration existants et exécuter manage.py makemigrations
pour créer de nouvelles migrations consolidées.
Réduire les frictions liées à la migration
Si plusieurs dizaines de développeurs travaillent sur la même base de code Django, vous pouvez fréquemment rencontrer des conditions de course avec la fusion dans les migrations de base de données. Par exemple, considérons un scénario où l'historique des migrations sur master ressemble à ceci :
0001_a
0002_b
Supposons maintenant que l'ingénieur A génère une migration 0003_c
sur sa branche locale, mais avant qu'il ne puisse la fusionner, l'ingénieur B arrive en premier et vérifie la migration 0003_d
. Si l'ingénieur A fusionne maintenant sa branche, toute personne essayant d'exécuter des migrations après avoir récupéré le dernier code rencontrera l'erreur "Conflicting migrations detected ; multiple leaf nodes in the migration graph : (0003_c, 0003_d)."
Au minimum, cela oblige à linéariser manuellement les migrations ou à créer une migration de fusion, ce qui provoque des frictions dans le processus de développement de l'équipe. L'équipe d'ingénieurs de Zenefits aborde ce problème plus en détail dans un billet de blog, dont nous nous sommes inspirés pour l'améliorer.
En moins d'une douzaine de lignes de code, nous avons pu résoudre une forme plus générale de ce problème dans le cas où nous avons plusieurs serveurs Django applications. Pour ce faire, nous avons remplacé la fonction handle()
de notre méthode makemigrations
pour générer un manifeste de migration multi-applications :
Si l'on applique ce principe à l'exemple ci-dessus, le fichier manifeste contiendrait une entrée doordash: 0002_b
pour notre application. Si nous générons un nouveau fichier de migration 0003_c
hors HEAD, le diff sur le fichier manifeste s'appliquera proprement et pourra être fusionné tel quel :
- doordash: 0002_b
+ doordash: 0003_c
Toutefois, si les migrations sont obsolètes, par exemple si un ingénieur ne dispose que de 0001_a
localement et génère une nouvelle migration 0002_d
le diff du fichier manifeste ne s'appliquera pas proprement et Github déclarera qu'il y a des conflits de fusion :
- doordash: 0001_a
+ doordash: 0002_d
L'ingénieur serait alors responsable de la résolution du conflit avant que Github n'autorise la fusion de la demande d'extraction. Si vous avez des tests d'intégration sur lesquels les fusions de code sont gated (ce que toute entreprise de cette taille devrait faire), c'est aussi une autre motivation pour garder la suite de tests rapide !
Éviter les mannequins obèses
Django promeut le modèle "fat model" qui consiste à placer l'essentiel de la logique métier dans les méthodes du modèle. Bien que ce soit ce que nous avons utilisé au départ, et que cela puisse même être assez pratique, nous avons réalisé que cela ne s'adaptait pas très bien à l'échelle. Au fil du temps, les classes de modèle se gonflent de méthodes et deviennent extrêmement longues et difficiles à lire. Les mixins sont un moyen d'atténuer un peu la complexité, mais ne sont pas une solution idéale.
Ce modèle peut être un peu gênant si vous avez une logique qui n'a pas vraiment besoin d'opérer sur une instance de modèle complète extraite de la base de données, mais qui n'a besoin que de la clé primaire ou d'une représentation simplifiée stockée dans le cache. De plus, si vous souhaitez un jour abandonner l'ORM de Django, le fait de coupler votre logique à des modèles va compliquer cet effort.
Cela dit, l'intention réelle derrière ce modèle est de garder l'API/la vue/le contrôleur légers et exempts de logique excessive, ce que nous préconisons fortement. Avoir de la logique dans les méthodes du modèle est un moindre mal, mais vous pouvez envisager de garder les modèles légers et de vous concentrer sur la couche de données. Pour que cela fonctionne, vous devrez trouver un nouveau modèle et placer votre logique d'entreprise dans une couche intermédiaire entre la couche de données et la couche API/présentationnelle.
Attention aux signaux
Le cadre de signaux de Django peut être utile pour découpler les événements des actions, mais un cas d'utilisation qui peut s'avérer gênant est le suivant pre/post_save
les signaux. Ils peuvent être utiles pour de petites choses (par exemple, vérifier quand invalider un cache), mais mettre trop de logique dans les signaux peut rendre le flux du programme difficile à tracer et à lire. Il n'est pas vraiment possible de passer des arguments ou des informations personnalisés par l'intermédiaire d'un signal. Il est également très difficile, sans avoir recours à des astuces, d'empêcher un signal de se déclencher dans certaines conditions (par exemple, si vous souhaitez mettre à jour certains modèles en masse sans déclencher de signaux coûteux).
Nous vous conseillons de limiter l'utilisation de ces signaux et, si vous les utilisez, d'éviter d'y intégrer une logique autre que simple et peu coûteuse. Vous devriez également organiser ces signaux à un endroit prévisible et cohérent (par exemple, à proximité de l'endroit où les modèles sont définis), afin de faciliter la lecture de votre code.
Évitez d'utiliser l'ORM comme interface principale avec vos données.
Si vous créez et mettez à jour directement des objets de base de données à partir de plusieurs parties de votre base de code avec des appels à l'interface ORM de Django (Model.objects.create()
ou Model.save()
), vous voudrez peut-être revoir cette approche. Nous avons constaté que l'utilisation de l'ORM comme interface principale pour modifier les données présente certains inconvénients.
Le principal problème est qu'il n'existe pas de méthode propre pour effectuer les actions courantes lors de la création ou de la mise à jour d'un modèle. Supposons qu'à chaque fois que le modèleA est créé, vous souhaitiez également créer une instance du modèleB. Ou vous voulez détecter quand un certain champ a changé par rapport à sa valeur précédente. En dehors des signaux, votre seule solution est de surcharger une grande partie de la logique en Model.save()
ce qui peut s'avérer très lourd et gênant.
Une solution consiste à établir un modèle dans lequel toutes les opérations importantes de la base de données (création/mise à jour/suppression) passent par une sorte d'interface simple qui enveloppe la couche ORM. Cela vous donne des points d'entrée propres pour ajouter une logique supplémentaire avant ou après les événements de la base de données. De plus, le fait de découpler un peu votre code d'application de l'interface du modèle vous donnera la flexibilité de vous passer de l'ORM de Django à l'avenir.
Ne pas mettre en cache les modèles de Django
Si vous travaillez à la mise à l'échelle de votre application, vous utilisez probablement une solution de mise en cache comme Memcached ou Redis pour réduire les requêtes de base de données. Bien qu'il soit tentant de mettre en cache des instances de modèles Django, ou même les résultats de jeux de requêtes entiers, il y a quelques mises en garde dont vous devez être conscient.
Si vous migrez votre schéma (ajoutez/modifiez/supprimez des champs de votre modèle), Django ne gère pas cela de manière très élégante lorsqu'il s'agit d'instances mises en cache. Si Django essaie de lire une instance de modèle qui a été écrite dans le cache à partir d'une version antérieure du schéma, il mourra. Sous le capot, il est en train de désérialiser un objet récupéré du backend du cache, mais cet objet sera incompatible avec le code le plus récent. Il s'agit plus d'un détail malheureux de l'implémentation de Django que d'autre chose.
Vous pourriez simplement accepter que vous aurez quelques exceptions après un déploiement avec une migration de modèle, et limiter les dégâts en définissant des TTL de cache raisonnablement courts. Mieux encore, évitez de mettre en cache les modèles en règle générale. Au lieu de cela, ne mettez en cache que les clés primaires et recherchez les objets dans la base de données. (Généralement, les recherches de clés primaires sont assez bon marché. Ce sont les requêtes SELECT pour trouver ces identifiants qui sont coûteuses).
Si l'on va plus loin et que l'on veut éviter complètement les accès à la base de données, il est possible de mettre en cache les modèles Django en toute sécurité si l'on ne maintient qu'une seule copie en cache d'une instance de modèle. Il est alors assez trivial d'invalider ce cache en cas de modification du schéma du modèle. Notre solution a consisté à créer un hachage unique des champs connus et à l'ajouter à notre clé de cache (par ex. Foo:96f8148eb2b7:123
). Chaque fois qu'un champ est ajouté, renommé ou supprimé, les changements de hachage invalident effectivement le cache.
Conclusion
Django est sans aucun doute un framework puissant et riche en fonctionnalités pour démarrer votre service backend, mais il y a des subtilités auxquelles il faut faire attention et qui peuvent vous éviter des maux de tête par la suite. Définir soigneusement les applications Django et mettre en place une bonne organisation du code dès le départ vous permettra d'éviter un travail de refonte inutile par la suite. Entre-temps, en prenant le contrôle total de votre schéma de base de données et en étant conscient de la manière dont vous utilisez les fonctionnalités de Django telles que GenericForeignKey's et l'ORM, vous pouvez vous assurer que vous n'êtes pas trop lié au framework et migrer vers d'autres technologies ou architectures à l'avenir.
En pensant à ces choses, vous pouvez conserver la flexibilité nécessaire pour faire évoluer votre backend à l'avenir. Nous espérons que certaines des choses que nous avons apprises sur l'utilisation de Django vous aideront à créer vos propres applications !