単一のORMではない
みなさんこんにちは! Ostrovok.ruホテル予約サービスのパートナー開発部門を担当しています。 この記事では、1つのプロジェクトでDjango ORMをどのように使用したかについてお話したいと思います。
実際、私はだまされていました、名前は「 ない ORM single。「なぜ私がこれを書いたのか疑問に思っているなら、そして:
- スタックにDjangoがあり、
Model.objects.all()
だけでなく、ORMから最大値を絞り出したい場合 - ビジネスロジックの一部をデータベースレベルに転送したい場合、
- または、B2B.Ostrovok.ruの開発者にとって最も頻繁な言い訳が「歴史的」である理由を知りたいですか?
...猫へようこそ。
2014年、B2B.Ostrovok.ruを開始しました。これは、観光市場の専門家(旅行代理店、オペレーター、企業顧客)向けのホテル、送迎、車、その他の旅行サービス向けのオンライン予約サービスです。
B2Bでは、 MetaOrder
-meta order- MetaOrder
基づいた抽象順序モデルを設計し、非常にうまく使用しています。
メタオーダーは、ホテル( Hotel
)、追加サービス( Upsell
)、または車( Car
)に属する注文の種類に関係なく使用できる抽象的なエンティティです。 将来、他のタイプが表示される可能性があります。
これは常にそうではありませんでした。 B2Bサービスが開始されたとき、ホテルのみを予約でき、すべてのビジネスロジックはそれらに焦点を合わせていました。 たとえば、売上金額と予約払い戻し金額の為替レートを表示するために、多くのフィールドが作成されています。 時間が経つにつれて、メタオーダーが与えられると、このデータをどのように保存して再利用するのが最善かを実感しました。 しかし、コード全体を書き換えることはできず、この遺産の一部が新しいアーキテクチャに持ち込まれました。 実際、これにより、数種類の注文を使用する計算が困難になりました。 何をすべきか- 歴史的に ...
私の目標は、この例でDjango ORMの力を示すことです。
背景
費用を計画するために、B2Bのクライアントは、今/明日/後に支払う必要がある金額、注文に負債があるかどうか、そのサイズは何であるか、限度内でどれだけ多くを使うことができるかについての情報が本当に不足していました。 この情報をダッシュボードの形式で表示することにしました-わかりやすい図のようなシンプルなパネルです。
(すべての値はテストであり、特定のパートナーには適用されません)
一見、すべてが非常に簡単です-パートナーのすべての注文をフィルタリングし、要約して表示します。
ソリューションオプション
計算方法の簡単な説明。 当社は国際企業であり、さまざまな国のパートナーがさまざまな通貨で予約(購入および再販)を行っています。 さらに、選択した通貨(通常は現地通貨)で財務諸表を受け取る必要があります。 すべての通貨のレートに関するすべての可能なデータを保存するのは愚かで非実用的であるため、ルーブルなどの参照通貨を選択する必要があります。 したがって、すべての通貨のレートをルーブルにのみ保存できます。 したがって、パートナーが概要の受け取りを希望する場合、販売時に設定されたレートで金額を変換します。
「額に」
実際、これはModel.objects.all()
あり、条件ループです:
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
このクエリは、数百の予約を潜在的に含むジェネレーターを返します。 これらの予約ごとにベースへのリクエストが行われるため、サイクルは非常に長い間続きます。
prefetch_related
メソッドを追加することで、少し速度を上げることができます:
# object - GenericForeignKey. query = query.prefetch_related('object')
その後、データベースへのクエリはわずかに少なくなります( GenericForeignKey
します)が、とにかく、ループの各反復でデータベースへのクエリが行われるため、それらの数で停止します。
output
メソッドはキャッシュできますが、キャッシュする必要がありますが、最初の呼び出しは1分間の順序を満たしますが、これはまったく受け入れられません。
このアプローチの結果は次のとおりです。
平均応答時間は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']) # , # | Q(car__status__in=[...]) )
また、多くの場合、事前に準備されたリクエストを使用して、支払いできないすべての注文を除外します。 非常に多くのビジネスロジックがあり、この記事のフレームワークではあまり興味深いものではありませんが、本質的にこれらは単なる追加のフィルターです。 準備されたクエリを返すメソッドは次のようになります。
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
を使用します。 したがって、1つのフィールドで任意の数の新しいタイプの注文を抽象化できます。
annotate
ブロックの最初の部分は次のとおりです。
# , # 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
はここで強打します。 これが、金額のValue(ZERO)
Value(ONE)
と為替レートのValue(ONE)
表示方法です。 ZERO
およびONE
はDecimal('0')
およびDecimal(1)
、定数の形式のみです。 アマチュアのアプローチですが、私たちのプロジェクトでは慣習的です。
質問があるかもしれません。いくつかのフィールドをメタ順で1レベル上に上げてみませんか? たとえば、どこにでもあるpayment_pending
です。 実際、時間の経過とともに、そのようなフィールドをメタオーダーに転送しますが、コードはうまく機能するため、そのようなタスクは優先事項ではありません。
別の準備と計算
ここで、最後のannotate
ブロックで受け取った量を使用して計算する必要があります。 ここで、注文の種類に縛られる必要はもうありません(1つの例外を除く)。
.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'), ), )
このブロックの最も興味深い部分は、 _reporting_currency_rate
フィールド、または販売時の参照通貨への為替レートです。 ホテル注文の参照通貨に対するすべての通貨の為替レートのデータは、currency_dataに保存されcurrency_data
。 これは単なるJSONです。 なぜこれを保持するのですか? これは歴史的にそうです。
そして、ここでは、なぜF
を使用して契約通貨の価値を代用しないのでしょうか? つまり、これを行うことができればクールです:
F(f'currency_data__{partner.reporting_currency}')
ただし、 F
f-strings
F
ではサポートされ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
コンストラクトを大量に生成することです。 これは、SQL query- query.__str__()
アンロードすることで確認できます。
これは 、 base_query_annotated
用にDjango ORMによって生成された SQLコードの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
そしてもう1つ-これはビジネス条件による最後のフィルタリングです。たとえば、すぐに支払う必要があるすべての注文が必要です。
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( # 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) ) )
これが今の仕組みです:
結論
ロジックを書き直して最適化することで、アフィリエイトメトリックをかなり迅速に処理し、データベースへのクエリの数を大幅に削減することができました。 ソリューションは良好であることが判明し、このロジックをプロジェクトの他の部分で再利用します。 ORMは私たちのすべてです。
コメントを書いて、質問してください-私たちは答えようとします! よろしくお願いします!