Ni un solo ORM

Ni un solo ORM


Hola a todos! Estoy a cargo del departamento de Desarrollo de Socios del servicio de reserva de hoteles Ostrovok.ru . En este artículo, me gustaría hablar sobre cómo usamos Django ORM en un proyecto.


De hecho, estaba engañando, el nombre debería haber sido " No ORM single ". Si se pregunta por qué escribí esto, así como si:


  • Tiene Django en la pila y desea exprimir el máximo de ORM, no solo Model.objects.all() ,
  • Desea transferir parte de la lógica empresarial al nivel de la base de datos,
  • ¿O desea averiguar por qué la excusa más frecuente para los desarrolladores en B2B.Ostrovok.ru es "tan históricamente" ,

... bienvenido a cat.


cdpv


En 2014, lanzamos B2B.Ostrovok.ru, un servicio de reserva en línea para hoteles, traslados, automóviles y otros servicios de viaje para profesionales del mercado turístico (agentes de viajes, operadores y clientes corporativos).


En B2B, hemos diseñado y utilizado con bastante éxito un modelo de orden abstracto basado en GenericForeignKey - meta order - MetaOrder .


Un metaorden es una entidad abstracta que se puede usar sin importar a qué tipo de orden pertenece: hotel ( Hotel ), servicio adicional ( Upsell ) o automóvil ( Car ). En el futuro, pueden aparecer otros tipos.


Este no siempre ha sido el caso. Cuando se lanzó el servicio B2B, solo se podían reservar hoteles a través de él, y toda la lógica empresarial se centró en ellos. Se han creado muchos campos, por ejemplo, para mostrar los tipos de cambio del importe de venta y el importe del reembolso de la reserva. Con el tiempo, nos dimos cuenta de la mejor manera de almacenar y reutilizar estos datos, dados los metaordenes. Pero no se pudo reescribir todo el código, y parte de este patrimonio entró en la nueva arquitectura. En realidad, esto condujo a dificultades en los cálculos, que utilizan varios tipos de órdenes. Qué hacer, así que históricamente ...


Mi objetivo es mostrar el poder de Django ORM en nuestro ejemplo.


Antecedentes


Para planificar sus gastos, nuestros clientes B2B realmente carecían de información sobre cuánto deben pagar ahora / mañana / más tarde, si tienen deudas pendientes en los pedidos y cuánto son, y también cuánto más pueden gastar dentro de sus límites. Decidimos mostrar esta información en forma de tablero, un zócalo tan simple con un diagrama claro.


dash1
(todos los valores son de prueba y no se aplican a un socio específico)


A primera vista, todo es bastante simple: filtramos todos los pedidos del socio, lo resumimos y lo mostramos.


Opciones de solucion


Una pequeña explicación sobre cómo hacemos los cálculos. Somos una empresa internacional, nuestros socios de diferentes países realizan operaciones, compran y revenden reservas, en diferentes monedas. Además, deben recibir estados financieros en la moneda elegida (generalmente local). Sería tonto y poco práctico almacenar todos los datos posibles sobre las tasas de todas las monedas, por lo que debe elegir una moneda de referencia, por ejemplo, el rublo. Por lo tanto, puede almacenar las tasas de todas las monedas solo en el rublo. En consecuencia, cuando un socio desea recibir un resumen, convertimos los montos a la tasa establecida al momento de la venta.


"En la frente"


De hecho, este es Model.objects.all() y el bucle de condiciones:


Model.objects.all () con condiciones
 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 devolverá un generador que potencialmente contiene varios cientos de reservas. Se realizará una solicitud a la base de datos para cada una de estas reservas y, por lo tanto, el ciclo funcionará durante mucho tiempo.


Puede acelerar un poco las cosas agregando el método prefetch_related :


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

Luego habrá un poco menos de solicitudes a la base de datos ( GenericForeignKey en GenericForeignKey ), pero aún así, al final, nos detendremos en su número, porque la solicitud a la base de datos aún se realizará en cada iteración del ciclo.


El método de output puede (y debe) almacenarse en caché, pero aún así la primera llamada cumple el orden de un minuto, lo cual es completamente inaceptable.


Aquí están los resultados de este enfoque:


timing_before


El tiempo de respuesta promedio es de 4 segundos y hay picos que alcanzan los 21 segundos. Bastante largo tiempo


No implementamos el panel de control para todos los socios y, por lo tanto, no teníamos muchas solicitudes, pero aún lo suficiente como para comprender que ese enfoque no es efectivo.


cuenta_antes
Los números de la parte inferior derecha son el número de consultas: mínimo, máximo, promedio, total.


Sabiamente


El prototipo de la frente era bueno para comprender la complejidad de la tarea, pero no era óptimo para su uso. Decidimos que sería mucho más rápido y menos intensivo en recursos hacer varias consultas complejas en la base de datos que muchas simples.


Plan de solicitud


Los trazos amplios del plan de consulta se pueden describir así:


  • recoger pedidos de acuerdo con las condiciones iniciales,
  • preparar campos para el cálculo mediante annotate ,
  • calcular valores de campo
  • hacer aggregate por la cantidad y cantidad

Condiciones iniciales


Los socios que visitan el sitio solo pueden ver información sobre su contrato.


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

En caso de que no queramos mostrar nuevos tipos de pedidos / reservas, solo necesitamos filtrar los admitidos:


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

El estado del pedido es importante (más sobre Q ):


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

También usamos solicitudes preparadas, por ejemplo, para excluir todos los pedidos que no se pueden pagar. Hay bastante lógica de negocios, lo que no es muy interesante para nosotros en el marco de este artículo, pero en esencia estos son solo filtros adicionales. Un método que devuelve una consulta preparada podría tener este aspecto:


 query = MetaOrder.exclude_non_payable_metaorders(query) 

Como puede ver, este es un método de clase que también devolverá un QuerySet .


También prepararemos un par de variables para construcciones condicionales y para almacenar resultados de cálculo:


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

Preparación de campo ( annotate )


Debido al hecho de que tenemos que referirnos a los campos según el tipo de orden, utilizaremos Coalesce . Por lo tanto, podemos abstraer cualquier número de nuevos tipos de órdenes en un solo campo.


Aquí está la primera parte del bloque de annotate :


Primero anotar
 #     , #      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 funciona aquí con una explosión, porque los pedidos de hoteles tienen varias propiedades especiales, y en todos los demás casos (servicios adicionales y automóviles) estas propiedades no son importantes para nosotros. Así es como aparece el Value(ZERO) para cantidades y el Value(ONE) para tasas de cambio. ZERO y ONE son Decimal('0') y Decimal(1) , solo en forma de constantes. Un enfoque aficionado, pero en nuestro proyecto se acepta así.


Es posible que tenga una pregunta, ¿por qué no colocar algunos campos en un nivel en un metaorden? Por ejemplo, payment_pending , que está en todas partes. De hecho, con el tiempo, transferimos dichos campos a un metaorden, pero ahora el código funciona bien, por lo que tales tareas no son nuestra prioridad.


Otra preparación y cálculos.


Ahora necesitamos hacer algunos cálculos con las cantidades que recibimos en el último bloque de annotate . Tenga en cuenta que aquí ya no necesita estar vinculado al tipo de orden (excepto por una excepción).


Segunda anotación
 .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 parte más interesante de este bloque es el campo _reporting_currency_rate , o el tipo de cambio de la moneda de referencia en el momento de la venta. Los datos sobre los tipos de cambio de todas las monedas a la moneda de referencia para un pedido de hotel se almacenan en currency_data . Esto es solo JSON. ¿Por qué guardamos esto? Este es históricamente el caso .


Y aquí, parece, ¿por qué no usar F y sustituir el valor de la moneda del contrato? Es decir, sería genial si pudieras hacer esto:


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

Pero f-strings no f-strings compatibles con F Aunque el hecho de que Django ORM ya tenga la capacidad de acceder a campos json anidados es muy agradable - F('currency_data__USD') .


Y el último bloque de annotate es el cálculo _payable_in_cur , que se _payable_in_cur para todos los pedidos. Este valor debe estar en la moneda del contrato.


dash2


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

La peculiaridad del método de annotate es que genera una gran cantidad de construcciones SELECT something AS something_else que no están directamente involucradas en la solicitud. Esto se puede ver descargando la consulta SQL - query.__str__() .


Así es como se ve el código SQL generado por Django ORM para base_query_annotated . Debe leerlo con bastante frecuencia para comprender dónde puede optimizar su consulta.


Cálculos finales


Habrá un pequeño contenedor para el aggregate , de modo que en el futuro, si el socio necesita alguna otra métrica, se puede agregar fácilmente.


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 

Y una cosa más: este es el último filtrado por condición comercial, por ejemplo, necesitamos todos los pedidos que deberán pagarse pronto.


dash4


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

Depuración y Verificación


Una forma muy conveniente de verificar la exactitud de la solicitud creada es compararla con una versión más legible de los 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 

¿Conoces el método de "frente"?


Código final


Como resultado, obtuvimos algo como lo siguiente:


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

Así es como funciona ahora:


timing_after


cuenta_después


Conclusiones


Después de reescribir y optimizar la lógica, logramos hacer un manejo bastante rápido de las métricas de afiliados y reducir en gran medida el número de consultas a la base de datos. La solución resultó ser buena y reutilizaremos esta lógica en otras partes del proyecto. ORM es nuestro todo.


Escriba comentarios, haga preguntas, ¡trataremos de responder! Gracias

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


All Articles