Nem um único ORM

Nem um único ORM


Olá pessoal! Sou responsável pelo departamento de Desenvolvimento de parceiros do serviço de reservas de hotéis Ostrovok.ru . Neste artigo, gostaria de falar sobre como usamos o Django ORM em um projeto.


Na verdade, eu estava enganando, o nome deveria ter sido " Não ORM single ". Se você está se perguntando por que escrevi isso, bem como se:


  • Você tem Django na pilha e deseja Model.objects.all() o máximo do ORM, não apenas Model.objects.all() ,
  • Você deseja transferir parte da lógica de negócios para o nível do banco de dados,
  • Ou você deseja descobrir por que a desculpa mais frequente para desenvolvedores no B2B.Ostrovok.ru é "tão historicamente" ,

... bem vindo ao gato.


cdpv


Em 2014, lançamos o B2B.Ostrovok.ru - um serviço de reservas on-line de hotéis, transfers, carros e outros serviços de viagens para profissionais do mercado de turismo (agentes de viagens, operadoras e clientes corporativos).


No B2B, projetamos e usamos com sucesso um modelo de pedido abstrato com base na meta-ordem MetaOrder - MetaOrder .


Uma meta-ordem é uma entidade abstrata que pode ser usada independentemente do tipo de ordem a que pertence: um hotel ( Hotel ), um serviço adicional ( Upsell ) ou um carro ( Car ). No futuro, outros tipos podem aparecer.


Isso nem sempre foi assim. Quando o serviço B2B foi lançado, somente hotéis podiam ser reservados por ele e toda a lógica de negócios estava focada neles. Muitos campos foram criados, por exemplo, para exibir as taxas de câmbio do valor da venda e do valor do reembolso da reserva. Com o tempo, percebemos a melhor forma de armazenar e reutilizar esses dados, dadas as meta-ordens. Mas o código inteiro não pôde ser reescrito e parte dessa herança entrou na nova arquitetura. Na verdade, isso levou a dificuldades nos cálculos, que utilizam vários tipos de pedidos. O que fazer - tão historicamente ...


Meu objetivo é mostrar o poder do Django ORM em nosso exemplo.


Antecedentes


Para planejar suas despesas, nossos clientes B2B realmente não tinham informações sobre quanto eles precisam pagar agora / amanhã / tarde, se eles têm dívidas pendentes em pedidos e qual é o seu tamanho, além de quanto mais eles podem gastar dentro de seus limites. Decidimos mostrar essas informações na forma de um painel - um soquete tão simples com um diagrama claro.


dash1
(todos os valores são testados e não se aplicam a um parceiro específico)


À primeira vista, tudo é bem simples - filtramos todos os pedidos do parceiro, resumimos e mostramos.


Opções de solução


Uma pequena explicação sobre como fazemos cálculos. Somos uma empresa internacional, nossos parceiros de diferentes países realizam operações - compram e revendem reservas - em diferentes moedas. Além disso, eles devem receber demonstrações financeiras na moeda escolhida (geralmente local). Seria tolice e impraticável armazenar todos os dados possíveis sobre as taxas de todas as moedas; portanto, você precisa escolher uma moeda de referência, por exemplo, o rublo. Assim, você pode armazenar as taxas de todas as moedas apenas no rublo. Assim, quando um parceiro deseja receber um resumo, convertemos os valores à taxa definida no momento da venda.


"Na testa"


De fato, este é Model.objects.all() e o loop de condições:


Model.objects.all () com condições
 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 

Esta consulta retornará um gerador que potencialmente contém várias centenas de reservas. Uma solicitação ao banco de dados será feita para cada uma dessas reservas e, portanto, o ciclo funcionará por um período muito longo.


Você pode acelerar um pouco as coisas adicionando o método prefetch_related :


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

Em seguida, haverá um pouco menos de consultas ao banco de dados ( GenericForeignKey na GenericForeignKey ), mas no final pararemos com seu número, porque a consulta ao banco de dados ainda será feita a cada iteração do loop.


O método de output pode (e deve) ser armazenado em cache, mas a primeira chamada ainda cumpre a ordem de um minuto, o que é completamente inaceitável.


Aqui estão os resultados dessa abordagem:


timing_before


O tempo médio de resposta é de 4 segundos e há picos chegando a 21 segundos. Muito tempo.


Não lançamos o painel para todos os parceiros e, portanto, não tínhamos muitos pedidos, mas ainda é suficiente entender que essa abordagem não é eficaz.


count_before
Os números do canto inferior direito são o número de consultas: mínimo, máximo, média, total.


Sabiamente


O protótipo da testa foi bom para entender a complexidade da tarefa, mas não ideal para uso. Decidimos que seria muito mais rápido e com menos recursos fazer várias consultas complexas no banco de dados do que muitas simples.


Solicitar plano


Os traços largos do plano de consulta podem ser descritos assim:


  • coletar pedidos de acordo com as condições iniciais,
  • preparar campos para o cálculo através da annotate ,
  • calcular valores de campo
  • aggregate pela quantidade e quantidade

Condições iniciais


Os parceiros que visitam o site podem ver informações apenas em seus contratos.


 partner = query_get_one(Partner.objects.filter(id=partner_id)) 

Caso não desejemos mostrar novos tipos de pedidos / reservas, precisamos apenas filtrar os suportados:


 query = MetaOrder.objects.filter( partner=partner, content_type__in=[ Hotel.get_content_type(), Car.get_content_type(), Upsell.get_content_type(), ] ) 

O status do pedido é importante (mais sobre Q ):


 query = query.filter( Q(hotel__status__in=['completed', 'cancelled']) #     ,    # | Q(car__status__in=[...]) ) 

Também costumamos usar solicitações pré-preparadas, por exemplo, para excluir todos os pedidos que não podem ser pagos. Há muita lógica de negócios, o que não é muito interessante para nós na estrutura deste artigo, mas, em essência, esses são apenas filtros adicionais. Um método que retorna uma consulta preparada pode ser assim:


 query = MetaOrder.exclude_non_payable_metaorders(query) 

Como você pode ver, este é um método de classe que também retornará um QuerySet .


Também prepararemos algumas variáveis ​​para construções condicionais e para armazenar resultados de cálculos:


 import datetime as dt from typing.decimal import Decimal today = dt.date.today() result = defaultdict(Decimal) 

Preparação de Campo ( annotate )


Devido ao fato de termos que nos referir aos campos, dependendo do tipo de pedido, usaremos Coalesce . Assim, podemos abstrair qualquer número de novos tipos de pedidos em um único campo.


Aqui está a primeira parte do bloco de annotate :


Primeira anotação
 #     , #      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 trabalha aqui com um estrondo, porque os pedidos de hotéis têm várias propriedades especiais e, em todos os outros casos (serviços adicionais e carros), essas propriedades não são importantes para nós. É assim que o Value(ZERO) para valores e o Value(ONE) para taxas de câmbio aparecem. ZERO e ONE são Decimal('0') e Decimal(1) , apenas na forma de constantes. Uma abordagem amadora, mas em nosso projeto é aceita assim.


Você pode ter uma pergunta: por que não colocar alguns campos em um nível em uma meta ordem? Por exemplo, payment_pending , que está em toda parte. De fato, com o tempo, transferimos esses campos para uma meta-ordem, mas agora o código funciona bem, portanto essas tarefas não são nossa prioridade.


Outra preparação e cálculos


Agora, precisamos fazer alguns cálculos com os valores que recebemos no último bloco de annotate . Observe que aqui você não precisa mais estar vinculado ao tipo de pedido (exceto uma exceção).


Segunda anotação
 .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'), ), ) 

A parte mais interessante desse bloco é o campo _reporting_currency_rate ou a taxa de câmbio da moeda de referência no momento da venda. Os dados sobre as taxas de câmbio de todas as moedas para a moeda de referência para um pedido de hotel são armazenados em currency_data . Este é apenas JSON. Por que mantemos isso? Este é historicamente o caso .


E aqui, ao que parece, por que não usar F e substituir o valor da moeda do contrato? Ou seja, seria legal se você pudesse fazer isso:


 F(f'currency_data__{partner.reporting_currency}') 

Mas f-strings não f-strings suportadas em F Embora o fato do Django ORM já tenha a capacidade de acessar campos json aninhados seja muito agradável - F('currency_data__USD') .


E o último bloco de annotate é o cálculo _payable_in_cur , que será resumido para todos os pedidos. Este valor deve estar na moeda do contrato.


dash2


 .annotate( _payable_in_cur=( F('_payable_base') / F('_reporting_currency_rate') ) ) 

A peculiaridade do método annotate é que ele gera uma grande quantidade de construções SELECT something AS something_else que não estão diretamente envolvidas na solicitação. Isso pode ser visto descarregando a consulta SQL - query.__str__() .


É assim que o código SQL gerado pelo Django ORM para base_query_annotated . Você precisa lê-lo com frequência para entender onde pode otimizar sua consulta.


Cálculos finais


Haverá um pequeno invólucro para aggregate , para que, no futuro, se o parceiro precisar de alguma outra métrica, ele possa ser facilmente adicionado.


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 

E mais uma coisa: esta é a última filtragem por condição comercial, por exemplo, precisamos de todos os pedidos que precisarão ser pagos em breve.


dash4


 before_payment_pending_query = _get_data_from_query( base_query_annotated.filter(_payment_pending__gt=today) ) 

Depuração e verificação


Uma maneira muito conveniente de verificar a exatidão da solicitação criada é compará-la com uma versão mais legível dos cálculos.


 for morder in query: payable = morder.get_payable_in_cur() payment_pending = morder.get_payment_pending() if payment_pending > today: result['payment_pending'] += payable 

Você conhece o método "testa"?


Código final


Como resultado, obtivemos algo como o seguinte:


Código 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) ) ) 

É assim que funciona agora:


timing_after


count_after


Conclusões


Depois de reescrever e otimizar a lógica, conseguimos fazer um tratamento bastante rápido das métricas de afiliados e reduzir bastante o número de consultas no banco de dados. A solução acabou sendo boa e reutilizaremos essa lógica em outras partes do projeto. ORM é o nosso tudo.


Escreva comentários, faça perguntas - tentaremos responder! Obrigada

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


All Articles