Bukan ORM tunggal
Halo semuanya! Saya bertanggung jawab atas departemen Pengembangan Mitra layanan pemesanan hotel Ostrovok.ru . Dalam artikel ini saya ingin berbicara tentang bagaimana kami menggunakan Django ORM pada satu proyek.
Bahkan, saya menipu, nama itu seharusnya " Tidak ORM single ". Jika Anda bertanya-tanya mengapa saya menulis ini, juga jika:
- Anda memiliki Django di tumpukan, dan Anda ingin memeras maksimal dari ORM, bukan hanya
Model.objects.all()
, - Anda ingin mentransfer bagian dari logika bisnis ke tingkat basis data,
- Atau Anda ingin mencari tahu mengapa alasan paling umum untuk pengembang di B2B.Ostrovok.ru adalah "sangat historis" ,
... selamat datang di kucing.

Pada tahun 2014, kami meluncurkan B2B.Ostrovok.ru - layanan pemesanan online untuk hotel, transfer, mobil, dan layanan perjalanan lainnya untuk para profesional di pasar pariwisata (agen perjalanan, operator, dan klien korporat).
Di B2B, kami telah merancang dan cukup berhasil menggunakan model pesanan abstrak berdasarkan GenericForeignKey
- meta order - MetaOrder
.
Sebuah meta order adalah entitas abstrak yang dapat digunakan tidak peduli apa pun jenis pesanannya: hotel ( Hotel
), layanan tambahan ( Upsell
) atau mobil ( Car
). Di masa depan, tipe lain mungkin muncul.
Ini tidak selalu terjadi. Ketika layanan B2B diluncurkan, hanya hotel yang dapat dipesan melalui itu, dan semua logika bisnis difokuskan pada mereka. Banyak bidang telah dibuat, misalnya, untuk menampilkan nilai tukar dari jumlah penjualan dan jumlah pengembalian dana reservasi. Seiring waktu, kami menyadari cara terbaik untuk menyimpan dan menggunakan kembali data ini, mengingat meta-order. Tetapi seluruh kode tidak dapat ditulis ulang, dan bagian dari warisan ini masuk ke arsitektur baru. Sebenarnya, ini menyebabkan kesulitan dalam perhitungan, yang menggunakan beberapa jenis pesanan. Apa yang harus dilakukan - secara historis ...
Tujuan saya adalah untuk menunjukkan kekuatan Django ORM dalam contoh kami.
Latar belakang
Untuk merencanakan pengeluaran mereka, klien B2B kami benar-benar tidak memiliki informasi tentang berapa yang harus mereka bayar sekarang / besok / nanti, apakah mereka memiliki hutang yang belum tertagih atas pesanan dan berapa banyak mereka, dan juga berapa banyak lagi yang dapat mereka keluarkan dalam batas mereka. Kami memutuskan untuk menampilkan informasi ini dalam bentuk dasbor - soket sederhana dengan diagram yang jelas.

(semua nilai adalah tes dan tidak berlaku untuk mitra tertentu)
Pada pandangan pertama, semuanya sangat sederhana - kami memfilter semua pesanan mitra, merangkum dan menunjukkan.
Opsi solusi
Sedikit penjelasan tentang bagaimana kita membuat perhitungan. Kami adalah perusahaan internasional, mitra kami dari berbagai negara melakukan operasi - beli dan jual kembali pemesanan - dalam mata uang yang berbeda. Selain itu, mereka harus menerima laporan keuangan dalam mata uang pilihan mereka (biasanya lokal). Akan bodoh dan tidak praktis untuk menyimpan semua data yang mungkin tentang nilai tukar semua mata uang, jadi Anda perlu memilih mata uang referensi, misalnya rubel. Dengan demikian, Anda dapat menyimpan nilai semua mata uang hanya ke rubel. Karenanya, ketika mitra ingin menerima ringkasan, kami mengonversi jumlah pada kurs yang ditetapkan pada saat penjualan.
"Di dahi"
Bahkan, ini adalah Model.objects.all()
dan loop kondisi:
Model.objects.all () dengan ketentuan def output(partner_id): today = dt.date.today()
Permintaan ini akan mengembalikan generator yang berpotensi berisi beberapa ratus pemesanan. Permintaan ke database akan dibuat untuk masing-masing pemesanan ini, dan oleh karena itu siklus akan bekerja untuk waktu yang sangat lama.
Anda dapat mempercepat sedikit dengan menambahkan metode prefetch_related
:
Kemudian akan ada lebih sedikit permintaan ke basis data ( GenericForeignKey
pada GenericForeignKey
), tetapi tetap, pada akhirnya, kami akan berhenti di nomor mereka, karena permintaan ke basis data akan tetap dibuat pada setiap iterasi siklus.
Metode output
dapat (dan harus) di-cache, tetapi masih panggilan pertama memenuhi urutan satu menit, yang sama sekali tidak dapat diterima.
Berikut adalah hasil dari pendekatan ini:

Waktu respons rata-rata adalah 4 detik, dan ada puncak mencapai 21 detik. Cukup lama.
Kami tidak meluncurkan dasbor untuk semua mitra, dan karena itu kami tidak memiliki banyak permintaan untuk itu, tetapi masih cukup untuk memahami bahwa pendekatan ini tidak efektif.

Angka-angka dari kanan bawah adalah jumlah kueri: minimum, maksimum, rata-rata, total.
Dengan bijak
Prototipe dahi baik untuk memahami kompleksitas tugas, tetapi tidak optimal untuk digunakan. Kami memutuskan bahwa akan jauh lebih cepat dan kurang intensif sumber daya untuk membuat beberapa pertanyaan kompleks ke dalam basis data daripada banyak pertanyaan sederhana.
Rencana permintaan
Sapuan lebar dari rencana kueri dapat dijelaskan seperti ini:
- mengumpulkan pesanan sesuai dengan kondisi awal,
- menyiapkan bidang untuk perhitungan melalui
annotate
, - menghitung nilai bidang
- membuat
aggregate
dengan jumlah dan kuantitas
Kondisi awal
Mitra yang mengunjungi situs hanya dapat melihat informasi tentang kontrak mereka.
partner = query_get_one(Partner.objects.filter(id=partner_id))
Jika kami tidak ingin menampilkan jenis pesanan / pemesanan baru, kami hanya perlu memfilter yang didukung:
query = MetaOrder.objects.filter( partner=partner, content_type__in=[ Hotel.get_content_type(), Car.get_content_type(), Upsell.get_content_type(), ] )
Status pesanan penting (lebih lanjut tentang Q
):
query = query.filter( Q(hotel__status__in=['completed', 'cancelled'])
Kami juga sering menggunakan permintaan yang disiapkan sebelumnya, misalnya, untuk mengecualikan semua pesanan yang tidak dapat dibayar. Ada cukup banyak logika bisnis, yang tidak terlalu menarik bagi kita dalam kerangka artikel ini, tetapi pada dasarnya ini hanyalah filter tambahan. Metode yang mengembalikan kueri yang disiapkan mungkin terlihat seperti ini:
query = MetaOrder.exclude_non_payable_metaorders(query)
Seperti yang Anda lihat, ini adalah metode kelas yang juga akan mengembalikan QuerySet
.
Kami juga akan menyiapkan beberapa variabel untuk konstruksi bersyarat dan untuk menyimpan hasil perhitungan:
import datetime as dt from typing.decimal import Decimal today = dt.date.today() result = defaultdict(Decimal)
Persiapan Lapangan ( annotate
)
Karena kenyataan bahwa kami harus merujuk ke bidang tergantung pada jenis pesanan, kami akan menggunakan Coalesce
. Dengan demikian, kami dapat mengabstraksi sejumlah jenis pesanan baru ke dalam satu bidang.
Inilah bagian pertama dari blok annotate
:
Coalesce
bekerja di sini dengan keras, karena pesanan hotel memiliki beberapa properti khusus, dan dalam semua kasus lainnya (layanan dan mobil tambahan), properti ini tidak penting bagi kami. Ini adalah bagaimana Value(ZERO)
untuk jumlah dan Value(ONE)
untuk nilai tukar muncul. ZERO
dan ONE
adalah Decimal('0')
dan Decimal(1)
, hanya dalam bentuk konstanta. Pendekatan amatir, tetapi dalam proyek kami diterima seperti ini.
Anda mungkin memiliki pertanyaan, mengapa tidak menempatkan beberapa bidang di satu tingkat dalam urutan meta? Misalnya, payment_pending
, yang ada di mana-mana. Memang, seiring waktu, kami mentransfer bidang-bidang tersebut ke meta-order, tetapi sekarang kodenya berfungsi dengan baik, jadi tugas seperti itu bukan prioritas kami.
Persiapan dan perhitungan lain
Sekarang kita perlu membuat beberapa perhitungan dengan jumlah yang kita terima di blok annotate
terakhir. Perhatikan bahwa di sini Anda tidak perlu lagi terikat dengan jenis pesanan (kecuali untuk satu pengecualian).
Bagian paling menarik dari blok ini adalah bidang _reporting_currency_rate
, atau nilai tukar ke mata uang referensi pada saat penjualan. Data tentang nilai tukar semua mata uang dengan mata uang referensi untuk pesanan hotel disimpan dalam currency_data
. Ini hanya JSON. Mengapa kita menyimpan ini? Ini adalah kasusnya secara historis .
Dan di sini, tampaknya, mengapa tidak menggunakan F
dan mengganti nilai mata uang kontrak? Artinya, akan keren jika Anda bisa melakukan ini:
F(f'currency_data__{partner.reporting_currency}')
Tetapi f-strings
tidak didukung dalam F
Meskipun fakta bahwa Django ORM sudah memiliki kemampuan untuk mengakses bidang json bersarang sangat menyenangkan - F('currency_data__USD')
.
Dan blok annotate
terakhir adalah perhitungan _payable_in_cur
, yang akan diringkas untuk semua pesanan. Nilai ini harus dalam mata uang kontrak.

.annotate( _payable_in_cur=( F('_payable_base') / F('_reporting_currency_rate') ) )
Keunikan metode annotate
adalah bahwa ia menghasilkan banyak SELECT something AS something_else
konstruk SELECT something AS something_else
yang tidak terlibat langsung dalam permintaan. Ini dapat dilihat dengan membongkar kueri SQL - query.__str__()
.
Ini adalah seperti apa kode SQL yang dihasilkan oleh Django ORM untuk base_query_annotated
. Anda harus sering membacanya untuk memahami di mana Anda dapat mengoptimalkan kueri Anda.
Perhitungan akhir
Akan ada pembungkus kecil untuk aggregate
, sehingga di masa depan, jika mitra membutuhkan beberapa metrik lainnya, dapat dengan mudah ditambahkan.

def _get_data_from_query(query: QuerySet) -> Decimal: result = query.aggregate( _sum_payable=Sum(F('_payable_in_cur')), ) return result['_sum_payable'] or ZERO
Dan satu hal lagi - ini adalah pemfilteran terakhir berdasarkan kondisi bisnis, misalnya, kita membutuhkan semua pesanan yang harus dibayar segera.

before_payment_pending_query = _get_data_from_query( base_query_annotated.filter(_payment_pending__gt=today) )
Debugging dan Verifikasi
Cara yang sangat mudah untuk memverifikasi kebenaran permintaan yang dibuat adalah membandingkannya dengan versi perhitungan yang lebih mudah dibaca.
for morder in query: payable = morder.get_payable_in_cur() payment_pending = morder.get_payment_pending() if payment_pending > today: result['payment_pending'] += payable
Apakah Anda tahu metode "dahi"?
Kode akhir
Akibatnya, kami mendapat sesuatu seperti berikut:
Kode akhir 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(
Beginilah cara kerjanya sekarang:


Kesimpulan
Setelah menulis ulang dan mengoptimalkan logikanya, kami berhasil membuat penanganan yang cukup cepat untuk metrik afiliasi dan sangat mengurangi jumlah kueri ke basis data. Solusinya ternyata bagus, dan kami akan menggunakan kembali logika ini di bagian lain dari proyek. ORM adalah segalanya bagi kami.
Tulis komentar, ajukan pertanyaan - kami akan mencoba menjawab! Terima kasih