ليس ORM واحد
مرحبا بالجميع! أنا المسؤول عن قسم تطوير الشركاء في خدمة حجز الفنادق في Ostrovok.ru . في هذه المقالة ، أود أن أتحدث عن الطريقة التي استخدمنا بها Django ORM في مشروع واحد.
في الواقع ، كنت أخدع ، كان يجب أن يكون الاسم " ليس ORM single ". إذا كنت تتساءل لماذا كتبت هذا ، وكذلك إذا:
- لديك Django على المكدس ، وتريد ضغط الحد الأقصى من ORM ، وليس فقط
Model.objects.all()
، - تريد نقل جزء من منطق العمل إلى مستوى قاعدة البيانات ،
- أو هل ترغب في معرفة السبب الأكثر عذرًا للمطورين في B2B.Ostrovok.ru هو "تاريخياً" ،
... مرحبا بكم في القط.

في عام 2014 ، أطلقنا B2B.Ostrovok.ru - خدمة الحجز عبر الإنترنت للفنادق والنقل والتحويلات وخدمات السفر الأخرى للمحترفين في سوق السياحة (وكلاء السفر والمشغلين وعملاء الشركات).
في B2B ، قمنا بتصميم نموذج طلب مجردة واستخدامه بنجاح بناءً على GenericForeignKey
- meta - MetaOrder
.
يعد أمر التعريف كيانًا مجرَّدًا يمكن استخدامه بغض النظر عن نوع الطلب الذي ينتمي إليه: فندق ( Hotel
) أو خدمة إضافية ( Upsell
) أو سيارة ( Car
). في المستقبل ، قد تظهر أنواع أخرى.
هذا لم يكن الحال دائما. عندما تم إطلاق خدمة B2B ، يمكن حجز الفنادق فقط من خلالها ، وكان كل منطق العمل يركز عليها. تم إنشاء العديد من الحقول ، على سبيل المثال ، لعرض أسعار صرف مبلغ المبيعات ومبلغ استرداد الحجز. بمرور الوقت ، أدركنا أفضل طريقة لتخزين هذه البيانات وإعادة استخدامها ، بالنظر إلى أوامر التعريف. لكن لا يمكن إعادة كتابة الكود بالكامل ، وجزء من هذا التراث دخل في العمارة الجديدة. في الواقع ، أدى ذلك إلى صعوبات في الحسابات ، والتي تستخدم عدة أنواع من الطلبات. ما يجب القيام به - تاريخيا ...
هدفي هو إظهار قوة Django ORM في مثالنا.
قبل التاريخ
للتخطيط لنفقاتهم ، يفتقر عملاؤنا B2B إلى معلومات حول مقدار ما يحتاجون إلى دفعه الآن / غدًا / لاحقًا ، وما إذا كانت لديهم ديون مستحقة على الطلبات ومقدارها ، وأيضًا ما يمكنهم إنفاقه في حدود حدودهم. قررنا إظهار هذه المعلومات في شكل لوحة معلومات - مثل المقبس البسيط ذي الرسم التخطيطي الواضح.

(جميع القيم هي اختبار ولا تنطبق على شريك محدد)
للوهلة الأولى ، كل شيء بسيط للغاية - نقوم بتصفية جميع طلبات الشريك وتلخيصها وإظهارها.
خيارات الحل
شرح بسيط حول كيفية إجراء الحسابات. نحن شركة دولية ، وشركاؤنا من مختلف البلدان يقومون بعمليات - شراء وبيع الحجوزات - بعملات مختلفة. علاوة على ذلك ، يجب أن يتلقوا بيانات مالية بعملتهم المختارة (عادة ما تكون محلية). سيكون من الغباء وغير العملي تخزين جميع البيانات الممكنة على أسعار جميع العملات ، لذلك تحتاج إلى اختيار عملة مرجعية ، على سبيل المثال ، الروبل. وبالتالي ، يمكنك تخزين أسعار جميع العملات فقط على الروبل. وفقًا لذلك ، عندما يرغب أحد الشركاء في الحصول على ملخص ، نقوم بتحويل المبالغ بالسعر المحدد في وقت البيع.
"في الجبهة"
في الواقع ، هذا هو Model.objects.all()
وحلقة الشروط:
Model.objects.all () مع الظروف def output(partner_id): today = dt.date.today()
سيعود هذا الاستعلام مولد يحتوي على مئات الحجوزات المحتملة. سيتم تقديم طلب إلى قاعدة البيانات لكل من هذه الحجوزات ، وبالتالي ستنجح الدورة لفترة طويلة جدًا.
يمكنك تسريع الأمور قليلاً عن طريق إضافة الأسلوب prefetch_related
:
بعد ذلك ، سيكون هناك عدد أقل قليلاً من الطلبات إلى قاعدة البيانات ( GenericForeignKey
على GenericForeignKey
) ، ولكن لا يزال ، في النهاية ، سوف نتوقف عند عددها ، لأن الطلب إلى قاعدة البيانات سيظل في كل تكرار للدورة.
يمكن (ويجب) تخزينها مؤقتًا في طريقة output
، ولكن لا يزال النداء الأول يفي بترتيب دقيقة ، وهو أمر غير مقبول تمامًا.
فيما يلي نتائج هذا النهج:

متوسط وقت الاستجابة هو 4 ثوان ، وهناك قمم تصل إلى 21 ثانية. وقت طويل جدا.
لم نطرح لوحة القيادة لجميع الشركاء ، وبالتالي لم يكن لدينا الكثير من الطلبات لذلك ، ولكن لا يزال يكفي أن نفهم أن مثل هذا النهج غير فعال.

الأرقام من أسفل اليمين هي عدد الاستعلامات: الحد الأدنى ، الحد الأقصى ، المتوسط ، الإجمالي.
مع العقل
كان النموذج الأولي للجبهة جيدًا لفهم مدى تعقيد المهمة ، ولكنه ليس مثاليًا للاستخدام. قررنا أنه سيكون أسرع بكثير وأقل كثافة في استخدام الموارد لجعل العديد من الاستعلامات المعقدة في قاعدة البيانات من العديد من الاستعلامات البسيطة.
طلب خطة
يمكن وصف الحدود الواسعة لخطة الاستعلام بالشكل التالي:
- جمع الطلبات وفقًا للشروط الأولية ،
- إعداد الحقول للحساب من خلال
annotate
، - حساب قيم الحقول
- جعل
aggregate
حسب الكمية والكمية
الظروف الأولية
يمكن للشركاء الذين يزورون الموقع الاطلاع على المعلومات المتعلقة بعقودهم فقط.
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(), ] )
حالة الطلب مهمة (المزيد حول Q
):
query = query.filter( Q(hotel__status__in=['completed', 'cancelled'])
غالبًا ما نستخدم الطلبات المعدة مسبقًا ، على سبيل المثال ، لاستبعاد جميع الطلبات التي لا يمكن دفعها. يوجد الكثير من منطق العمل ، وهو أمر غير مثير للاهتمام بالنسبة لنا في إطار هذه المقالة ، ولكن في جوهرها هذه مجرد عوامل تصفية إضافية. قد تبدو الطريقة التي تُرجع استعلامًا جاهزًا كما يلي:
query = MetaOrder.exclude_non_payable_metaorders(query)
كما ترون ، هذه طريقة صفية ستُرجع أيضًا QuerySet
.
سنقوم أيضًا بإعداد متغيرين للإنشاءات الشرطية ولتخزين نتائج الحساب:
import datetime as dt from typing.decimal import Decimal today = dt.date.today() result = defaultdict(Decimal)
إعداد المجال ( annotate
)
نظرًا لحقيقة أننا يجب أن نشير إلى الحقول وفقًا لنوع الطلب ، سنستخدم Coalesce
. وبالتالي ، يمكننا تجريد أي عدد من أنواع الطلبات الجديدة في حقل واحد.
هنا هو الجزء الأول من كتلة annotate
:
تعمل Coalesce
هنا مع اثارة ضجة ، لأن طلبات الفنادق لها العديد من الخصائص الخاصة ، وفي جميع الحالات الأخرى (الخدمات والسيارات الإضافية) هذه الخصائص ليست مهمة بالنسبة لنا. هذه هي الطريقة التي تظهر بها Value(ZERO)
للمبالغ Value(ONE)
لأسعار الصرف. ZERO
و ONE
Decimal('0')
Decimal(1)
، فقط في شكل ثوابت. نهج الهواة ، ولكن في مشروعنا هو مقبول من هذا القبيل.
قد يكون لديك سؤال ، لماذا لا تضع بعض الحقول في مستوى واحد بترتيب التعريف؟ على سبيل المثال ، payment_pending
، وهو في كل مكان. في الواقع ، مع مرور الوقت ، نقوم بنقل هذه الحقول إلى ترتيب ميتا ، ولكن الشفرة تعمل الآن بشكل جيد ، وبالتالي فإن هذه المهام ليست من أولوياتنا.
إعداد آخر والحسابات
نحتاج الآن إلى إجراء بعض العمليات الحسابية بالمبالغ التي تلقيناها في آخر annotate
. لاحظ أنك لم تعد بحاجة إلى الارتباط بنوع الطلب (باستثناء استثناء واحد).
الجزء الأكثر إثارة من هذا الحظر هو الحقل _reporting_currency_rate
، أو سعر الصرف للعملة المرجعية في وقت البيع. يتم تخزين البيانات على أسعار صرف جميع العملات إلى العملة المرجعية لطلب فندق في currency_data
. هذا مجرد جسون. لماذا نحافظ على هذا؟ هذا هو الحال تاريخيا .
وهنا ، على ما يبدو ، لماذا لا تستخدم F
واستبدل قيمة عملة العقد؟ بمعنى أنه سيكون رائعًا إذا أمكنك القيام بذلك:
F(f'currency_data__{partner.reporting_currency}')
لكن f-strings
غير مدعومة في F
على الرغم من حقيقة أن Django ORM لديه بالفعل القدرة على الوصول إلى حقول json المتداخلة ، إلا أنه أمر رائع للغاية - F('currency_data__USD')
.
وآخر كتلة annotate
هي حساب _payable_in_cur
، والذي سيتم تلخيصه لجميع الطلبات. يجب أن تكون هذه القيمة في عملة العقد.

.annotate( _payable_in_cur=( F('_payable_base') / F('_reporting_currency_rate') ) )
إن خصوصية طريقة annotate
هي أنه يولد الكثير من SELECT something AS something_else
إنشاء SELECT something AS something_else
غير مباشر بشكل مباشر في الطلب. يمكن ملاحظة ذلك عن طريق إلغاء تحميل استعلام SQL - query.__str__()
.
هذا هو ما يبدو عليه كود SQL الذي تم إنشاؤه بواسطة Django ORM لـ base_query_annotated
. عليك أن تقرأها كثيرًا حتى تفهم أين يمكنك تحسين استفسارك.
الحسابات النهائية
سيكون هناك غلاف صغير aggregate
، بحيث في المستقبل ، إذا كان الشريك يحتاج إلى بعض المقاييس الأخرى ، فيمكن إضافته بسهولة.

def _get_data_from_query(query: QuerySet) -> Decimal: result = query.aggregate( _sum_payable=Sum(F('_payable_in_cur')), ) return result['_sum_payable'] or ZERO
وشيء آخر - هذا هو التصفية الأخيرة حسب حالة العمل ، على سبيل المثال ، نحتاج إلى جميع الطلبات التي يتعين دفعها قريبًا.

before_payment_pending_query = _get_data_from_query( base_query_annotated.filter(_payment_pending__gt=today) )
التصحيح والتحقق
هناك طريقة ملائمة للغاية للتحقق من صحة الطلب الذي تم إنشاؤه تتمثل في مقارنته بإصدار أكثر قابلية للقراءة للحسابات.
for morder in query: payable = morder.get_payable_in_cur() payment_pending = morder.get_payment_pending() if payment_pending > today: result['payment_pending'] += payable
هل تعرف طريقة "الجبين"؟
الرمز النهائي
نتيجة لذلك ، حصلنا على شيء مثل التالي:
الرمز النهائي 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(
هذه هي الطريقة التي تعمل الآن:


النتائج
بعد إعادة كتابة المنطق وتحسينه ، تمكنا من إجراء معالجة سريعة إلى حد ما للمقاييس التابعة وتقليل عدد الاستعلامات إلى قاعدة البيانات بشكل كبير. تبين أن الحل جيد ، وسوف نعيد استخدام هذا المنطق في أجزاء أخرى من المشروع. ORM هو كل شيء لدينا.
اكتب تعليقات ، اطرح أسئلة - سنحاول الإجابة! شكرا لك