Pas un seul ORM

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.


cdpv


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.


dash1
(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() # query_get_one -    partner = query_get_one(Partner.objects.filter(id=partner_id)) #    -  query = MetaOrder.objects.filter(partner=partner) result = defaultdict(Decimal) for morder in query: #  ,     #     payment_pending = morder.get_payment_pending() payment_due = morder.get_payment_due() #        # (     ) payable = morder.get_payable_in_cur() #       if payment_pending > today: result['payment_pending'] += payable # ,     if payment_pending < today and payment_due > today: result['payment_due'] += payable return result 

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 :


 # object -      GenericForeignKey. query = query.prefetch_related('object') 

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:


timing_before


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.


count_before
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']) #     ,    # | Q(car__status__in=[...]) ) 

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 :


Annoter d'abord
 #     , #      from app.helpers.numbers import ZERO, ONE 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(ONE) ), _sell=Coalesce( 'hotel__sell', Value(ZERO) ), _sell_currency_rate=Coalesce( 'hotel__sell_currency_rate', Value(ONE) ), ) 

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).


DeuxiĂšme annoter
 .annotate( #  _base     _sell_base=( F('_sell') * F('_sell_currency_rate') ), _refund_base=( F('_refund') * F('_refund_currency_rate') ), _payable_base=( F('_sell_base') - F('_refund_base') ), _reporting_currency_rate=Case( When( content_type=Hotel.get_content_type(), then=RawSQL( '(hotel.currency_data->>%s)::numeric', (partner.reporting_currency,), ), ), output_field=DecimalField(), default=Decimal('1'), ), ) 

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.


dash2


 .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Ă©.


dash3


 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.


dash4


 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( # Calculated fields _sell_base=( F('_sell') * F('_sell_currency_rate') ), _refund_base=( F('_refund') * F('_refund_currency_rate') ), _payable_base=( F('_sell_base') - F('_refund_base') ), _reporting_currency_rate=Case( # Only hotels have currency_data, therefore we need a # check and default value When( content_type=Hotel.get_content_type(), then=RawSQL( '(hotel.currency_data->>%s)::numeric', (partner.reporting_currency,), ), ), output_field=DecimalField(), default=Decimal('1'), ), ) .annotate( _payable_in_cur=( F('_payable_base') / F('_reporting_currency_rate') ) ) before_payment_pending_query = _get_data_from_query( base_query_annotated.filter(_payment_pending__gt=today) ) after_payment_pending_before_payment_due_query = _get_data_from_query( base_query_annotated.filter( Q(_payment_pending__lte=today) & Q(_payment_due__gt=today) ) ) 

Voici comment cela fonctionne maintenant:


timing_after


count_after


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!

Source: https://habr.com/ru/post/fr447706/


All Articles