Pas un seul ORM
Bonjour à tous! Je suis en charge du département Développement Partenaires du service de réservation d'hÎtel Ostrovok.ru . Dans cet article, je voudrais parler de la façon dont nous avons utilisé Django ORM sur un projet.
En fait, je trompais, le nom aurait dĂ» ĂȘtre " Pas ORM single ". Si vous vous demandez pourquoi j'ai Ă©crit ceci, ainsi que si:
- Vous avez Django sur la pile, et vous voulez extraire le maximum d'ORM, pas seulement
Model.objects.all()
, - Vous souhaitez transférer une partie de la logique métier au niveau de la base de données,
- Ou voulez-vous savoir pourquoi l'excuse la plus fréquente pour les développeurs de B2B.Ostrovok.ru est "si historiquement" ,
... bienvenue au chat.

En 2014, nous avons lancé B2B.Ostrovok.ru - un service de réservation en ligne d'hÎtels, de transferts, de voitures et d'autres services de voyage pour les professionnels du marché du tourisme (agents de voyages, opérateurs et entreprises).
En B2B, nous avons conçu et utilisé avec succÚs un modÚle d'ordre abstrait basé sur GenericForeignKey
- meta order - MetaOrder
.
Une mĂ©ta-commande est une entitĂ© abstraite qui peut ĂȘtre utilisĂ©e quel que soit le type de commande auquel elle appartient: un hĂŽtel ( Hotel
), un service supplémentaire ( Upsell
) ou une voiture ( Car
). Ă l'avenir, d'autres types peuvent apparaĂźtre.
Cela n'a pas toujours Ă©tĂ© le cas. Lorsque le service B2B a Ă©tĂ© lancĂ©, seuls les hĂŽtels pouvaient ĂȘtre rĂ©servĂ©s via celui-ci et toute la logique commerciale Ă©tait concentrĂ©e sur eux. De nombreux champs ont Ă©tĂ© créés, par exemple, pour afficher les taux de change du montant des ventes et du montant du remboursement de la rĂ©servation. Au fil du temps, nous avons rĂ©alisĂ© la meilleure façon de stocker et de rĂ©utiliser ces donnĂ©es, compte tenu des mĂ©ta-commandes. Mais le code entier n'a pas pu ĂȘtre réécrit, et une partie de cet hĂ©ritage est entrĂ©e dans la nouvelle architecture. En fait, cela a conduit Ă des difficultĂ©s dans les calculs, qui utilisent plusieurs types de commandes. Que faire - donc historiquement ...
Mon objectif est de montrer la puissance de Django ORM dans notre exemple.
Contexte
Pour planifier leurs dépenses, nos clients B2B manquaient vraiment d'informations sur le montant qu'ils doivent payer maintenant / demain / plus tard, s'ils ont des dettes sur les commandes et quelle est sa taille, ainsi que combien ils peuvent dépenser davantage dans leurs limites. Nous avons décidé d'afficher ces informations sous la forme d'un tableau de bord - une telle prise simple avec un diagramme clair.

(toutes les valeurs sont testées et ne s'appliquent pas à un partenaire spécifique)
à premiÚre vue, tout est assez simple - nous filtrons toutes les commandes du partenaire, les résumons et les affichons.
Options de solution
Une petite explication sur la façon dont nous faisons les calculs. Nous sommes une entreprise internationale, nos partenaires de différents pays effectuent des opérations - achat et revente de réservations - dans différentes devises. De plus, ils doivent recevoir les états financiers dans la devise choisie (généralement locale). Il serait stupide et peu pratique de stocker toutes les données possibles sur les taux de toutes les devises, vous devez donc choisir une devise de référence, par exemple le rouble. Ainsi, vous pouvez enregistrer les taux de toutes les devises uniquement sur le rouble. Ainsi, lorsqu'un partenaire souhaite recevoir un récapitulatif, nous convertissons les montants au taux fixé au moment de la vente.
"Dans le front"
En fait, c'est Model.objects.all()
et la boucle de conditions:
Model.objects.all () avec conditions def output(partner_id): today = dt.date.today()
Cette requĂȘte renvoie un gĂ©nĂ©rateur qui contient potentiellement plusieurs centaines de rĂ©servations. Une demande Ă la base de donnĂ©es sera faite pour chacune de ces rĂ©servations, et donc le cycle se dĂ©roulera trĂšs longtemps.
Vous pouvez accélérer un peu les choses en ajoutant la méthode prefetch_related
:
Ensuite, il y aura un peu moins de requĂȘtes vers la base de donnĂ©es ( GenericForeignKey
sur GenericForeignKey
), mais Ă la fin nous nous arrĂȘterons Ă leur nombre, car la requĂȘte vers la base de donnĂ©es sera toujours effectuĂ©e Ă chaque itĂ©ration de la boucle.
La méthode de output
peut (et devrait) ĂȘtre mise en cache, mais le premier appel remplit toujours l'ordre d'une minute, ce qui est totalement inacceptable.
Voici les résultats de cette approche:

Le temps de réponse moyen est de 4 secondes et les pics atteignent 21 secondes. Assez longtemps.
Nous n'avons pas dĂ©ployĂ© le tableau de bord pour tous les partenaires, et donc nous n'avons pas eu beaucoup de demandes pour cela, mais il suffit quand mĂȘme de comprendre que cette approche n'est pas efficace.

Les nombres en bas Ă droite sont le nombre de requĂȘtes: minimum, maximum, moyenne, total.
Sagement
Le prototype du front Ă©tait bon pour comprendre la complexitĂ© de la tĂąche, mais pas optimal pour une utilisation. Nous avons dĂ©cidĂ© qu'il serait beaucoup plus rapide et moins gourmand en ressources de faire plusieurs requĂȘtes complexes dans la base de donnĂ©es que de nombreuses requĂȘtes simples.
Plan de demande
Les traits larges du plan de requĂȘte peuvent ĂȘtre dĂ©crits comme suit:
- collecter les commandes selon les conditions initiales,
- préparer les champs pour le calcul par
annotate
, - calculer les valeurs des champs
- faire des
aggregate
par le montant et la quantité
Conditions initiales
Les partenaires qui visitent le site ne peuvent voir des informations que sur leur contrat.
partner = query_get_one(Partner.objects.filter(id=partner_id))
Dans le cas oĂč nous ne voulons pas afficher de nouveaux types de commandes / rĂ©servations, nous avons seulement besoin de filtrer celles prises en charge:
query = MetaOrder.objects.filter( partner=partner, content_type__in=[ Hotel.get_content_type(), Car.get_content_type(), Upsell.get_content_type(), ] )
Le statut de la commande est important (en savoir plus sur Q
):
query = query.filter( Q(hotel__status__in=['completed', 'cancelled'])
Nous utilisons Ă©galement souvent des demandes prĂ©dĂ©finies, par exemple, pour exclure toutes les commandes qui ne peuvent pas ĂȘtre payĂ©es. Il y a beaucoup de logique mĂ©tier, ce qui n'est pas trĂšs intĂ©ressant pour nous dans le cadre de cet article, mais en substance ce ne sont que des filtres supplĂ©mentaires. Une mĂ©thode qui renvoie une requĂȘte prĂ©parĂ©e pourrait ressembler Ă ceci:
query = MetaOrder.exclude_non_payable_metaorders(query)
Comme vous pouvez le voir, il s'agit d'une méthode de classe qui retournera également un QuerySet
.
Nous préparerons également quelques variables pour les constructions conditionnelles et pour stocker les résultats des calculs:
import datetime as dt from typing.decimal import Decimal today = dt.date.today() result = defaultdict(Decimal)
Préparation du terrain ( annotate
)
Ătant donnĂ© que nous devons nous rĂ©fĂ©rer aux champs en fonction du type de commande, nous utiliserons Coalesce
. Ainsi, nous pouvons résumer n'importe quel nombre de nouveaux types de commandes dans un seul champ.
Voici la premiĂšre partie du bloc d' annotate
:
Coalesce
travaille ici avec fracas, car les commandes d'hÎtel ont plusieurs propriétés spéciales, et dans tous les autres cas (services supplémentaires et voitures), ces propriétés ne sont pas importantes pour nous. C'est ainsi que la Value(ZERO)
pour les montants et la Value(ONE)
pour les taux de change apparaissent. ZERO
et ONE
sont Decimal('0')
et Decimal(1)
, uniquement sous forme de constantes. Une approche amateur, mais dans notre projet c'est accepté comme ça.
Vous pourriez avoir une question, pourquoi ne pas mettre certains champs d'un niveau dans une méta-commande? Par exemple, payment_pending
, qui est partout. En effet, au fil du temps, nous transférons ces champs dans une méta-commande, mais maintenant le code fonctionne bien, de telles tùches ne sont pas notre priorité.
Une autre préparation et calculs
Maintenant, nous devons faire quelques calculs avec les montants que nous avons reçus dans le dernier bloc d' annotate
. Notez qu'ici, vous n'avez plus besoin d'ĂȘtre liĂ© au type de commande (sauf une exception).
La partie la plus intéressante de ce bloc est le champ _reporting_currency_rate
, ou le taux de change vers la devise de référence au moment de la vente. Les données sur les taux de change de toutes les devises vers la devise de référence pour une commande d'hÎtel sont stockées dans currency_data
. C'est juste JSON. Pourquoi gardons-nous cela? C'est historiquement le cas .
Et ici, il semblerait, pourquoi ne pas utiliser F
et remplacer la valeur de la devise du contrat? Autrement dit, ce serait cool si vous pouviez faire ceci:
F(f'currency_data__{partner.reporting_currency}')
Mais les f-strings
ne f-strings
pas prises en charge en F
Bien que le fait que Django ORM ait déjà la capacité d'accéder aux champs json imbriqués est trÚs agréable - F('currency_data__USD')
.
Et le dernier bloc annotate
est le calcul _payable_in_cur
, qui sera rĂ©sumĂ© pour toutes les commandes. Cette valeur doit ĂȘtre dans la devise du contrat.

.annotate( _payable_in_cur=( F('_payable_base') / F('_reporting_currency_rate') ) )
La particularité de la méthode annotate
est qu'elle génÚre beaucoup de constructions SELECT something AS something_else
qui ne sont pas directement impliquĂ©es dans la requĂȘte. Cela peut ĂȘtre vu en dĂ©chargeant la requĂȘte SQL - query.__str__()
.
Voici à quoi ressemble le code SQL généré par Django ORM pour base_query_annotated
. Vous devez le lire assez souvent pour comprendre oĂč vous pouvez optimiser votre requĂȘte.
Calculs finaux
Il y aura un petit wrapper pour l' aggregate
, de sorte qu'Ă l'avenir, si le partenaire a besoin d'une autre mĂ©trique, il puisse ĂȘtre facilement ajoutĂ©.

def _get_data_from_query(query: QuerySet) -> Decimal: result = query.aggregate( _sum_payable=Sum(F('_payable_in_cur')), ) return result['_sum_payable'] or ZERO
Et encore une chose - c'est le dernier filtrage par condition commerciale, par exemple, nous avons besoin de toutes les commandes qui devront ĂȘtre payĂ©es bientĂŽt.

before_payment_pending_query = _get_data_from_query( base_query_annotated.filter(_payment_pending__gt=today) )
Débogage et vérification
Un moyen trÚs pratique de vérifier l'exactitude de la demande créée est de la comparer avec une version plus lisible des calculs.
for morder in query: payable = morder.get_payable_in_cur() payment_pending = morder.get_payment_pending() if payment_pending > today: result['payment_pending'] += payable
Connaissez-vous la méthode du "front"?
Code final
En conséquence, nous avons obtenu quelque chose comme ceci:
Code final def _get_data_from_query(query: QuerySet) -> tuple: result = query.aggregate( _sum_payable=Sum(F('_payable_in_cur')), ) return result['_sum_payable'] or ZERO def output(partner_id: int): today = dt.date.today() partner = query_get_one(Partner.objects.filter(id=partner_id)) query = MetaOrder.objects.filter(partner=partner, content_type__in=[ Hotel.get_content_type(), Car.get_content_type(), Upsell.get_content_type(), ]) result = defaultdict(Decimal) query_annoted = query.annotate( _payment_pending=Coalesce( 'hotel__payment_pending', 'car__payment_pending', 'upsell__payment_pending', ), _payment_due=Coalesce( 'hotel__payment_due', 'car__payment_due', 'upsell__payment_due', ), _refund=Coalesce( 'hotel__refund', Value(ZERO) ), _refund_currency_rate=Coalesce( 'hotel__refund_currency_rate', Value(Decimal('1')) ), _sell=Coalesce( 'hotel__sell', Value(ZERO) ), _sell_currency_rate=Coalesce( 'hotel__sell_currency_rate', Value(Decimal('1')) ), ).annotate(
Voici comment cela fonctionne maintenant:


Conclusions
AprĂšs avoir réécrit et optimisĂ© la logique, nous avons rĂ©ussi Ă gĂ©rer assez rapidement les mesures d'affiliation et Ă rĂ©duire considĂ©rablement le nombre de requĂȘtes dans la base de donnĂ©es. La solution s'est avĂ©rĂ©e bonne et nous rĂ©utiliserons cette logique dans d'autres parties du projet. ORM est notre tout.
Ăcrivez des commentaires, posez des questions - nous essaierons de rĂ©pondre! Je vous remercie!