[Django]DBアクセスを減らす

最近は、外でSurfaceで開発しているので、容量もそれほどなくあまり色々入れたくないというのと、外に持ち運ぶ端末内にお客さんの個人情報を入れたくないということで、開発環境のWEBサーバ―はローカルPC内だが、DBはクラウドサーバー上にあるという変な形でやっている。

この環境だとDBアクセスのオーバーヘッドが顕著にあらわれる。ORMを何も考えないで使っていると、処理によっては、結構、待たされる。ということで、そろそろリリースに向けた見直し中。

1.モデルの使い方

Djangoのモデルを効率よく使う方法は、以下ページに書かれている。

https://docs.djangoproject.com/ja/3.1/topics/db/optimization/

複雑な参照系は、Rawクエリで一発で取ってくるようにしているが、微妙なところはモデルをそのまま使うようにしている。その部分で、余分なクエリが結構発生しているので直す。

1.1 外部キー

1)必要のない参照は消す

プログラム内で外部キーを参照するときに、モデル上のリレーションモデルからキーを参照すると、リレーションモデルの取得が発生するので、書き方を見直す。

https://docs.djangoproject.com/ja/3.1/topics/db/optimization/#use-foreign-key-values-directly

albumモデル上でartistを参照している場合に、albumからartisitのidを取得する場合には以下のようにする。

(正)album.artist_id
(誤)album.artist.id  ※artisitをDBから取得する

この延長で、リレーションのそのまたリレーションからモデルを参照するのは、中間のモデルが必要ない場合は直す。以下では、companyを参照しているが、その前にartistもDBから取得されてしまうので、クエリで取得するように直す。

(例)company = album.artist.company  ※artisitもDBから取得してしまう

2)必要な参照は同時に取る

select_relatedを使って、参照も同時に取る。

https://docs.djangoproject.com/ja/3.1/ref/models/querysets/#django.db.models.query.QuerySet.select_related

3)必要な参照はまとめて取る

prefech_relatedを使って、参照を一気に取る。select_relatedとの違いは、ベースのデータを取得時にselect_relatedは結合で同時に取るというのに対して、prefech_relatedは参照テーブルのデータを事前に一気に取って、メモリ上で結合するという感じ。

https://docs.djangoproject.com/ja/3.1/ref/models/querysets/#django.db.models.query.QuerySet.prefetch_related

1.2 キャッシュの利用

1)メソッドのキャッシュ化

モデルから外部データを取得するメソッドに、cached_propertyを付けることで、モデルインスタンス上でキャッシュされるようになる。

https://docs.djangoproject.com/ja/3.1/ref/utils/#django.utils.functional.cached_property

2)マスタのキャッシュ

DBアクセスを減らすと基本と言えば、マスタ系をキャッシュするということで、Djangoのキャッシュ機能については以下。

https://docs.djangoproject.com/ja/3.1/topics/cache/

ページ単位のキャッシュ等が出てくるが、マスタDB等のキャッシュとしては、以下の「低レベルキャッシュAPI」を使うことが考えられる。

https://docs.djangoproject.com/ja/3.1/topics/cache/#the-low-level-cache-api

バックエンドとして、色々設定できるようだが、簡単なマスタのキャッシュということで、選択肢はローカルメモリしかありえない。

キャッシュ機能を使わなくても、マスタ管理のクラスを作ってグルーバルにインスタンス定義すれば、1)のcashed_propertyで実装できるか。

1.3 一括更新

一括更新(bulk_update)については以下。

https://docs.djangoproject.com/ja/3.1/topics/db/optimization/#update-in-bulk

複合主キーのレガシーDBを使っており、以下パッケージで対応した。

django-compositepk-model

https://github.com/Arisophy/django-compositepk-model/issues/4

1.4 登録

複合主キーの登録については、注意が必要。無指定でModel.saveを使うと、最初にUPDATEをしに行くので、force_insert=Trueを指定するか、QuerySet.createを使う。

https://github.com/Arisophy/django-compositepk-model/blob/main/README.md#5-create-is-better-than-save

2.独自クエリ

一覧画面系は、最初からさっさと独自クエリで実装した。

https://docs.djangoproject.com/ja/3.1/topics/db/sql/#executing-custom-sql-directly

一覧を取得する処理は、カスタムmanager上に実装して、namedtupleで渡すほうが扱いやすい。更新にも使う場合は、modelにつめて返すようにした。

[Django]複合主キー

0.Djangoは複合主キーをサポートしている?

https://docs.djangoproject.com/ja/3.1/faq/models/#do-django-models-support-multiple-column-primary-keys

https://code.djangoproject.com/wiki/MultipleColumnPrimaryKeys

Djangoでは複合主キーのサポートは公式にはされていない。※対応するパッケージとして以下を提供済み

「primary_key=True」が指定されているフィールドが無い場合は、自動で「id」が主キーとして追加されて動作するようなので、従来システムのDBを使っている場合、モデルを使った更新、削除が、このままでは動作しない。テーブルにidフィールドを追加するか、独自のクエリで処理する必要がある。

1.解決方法

案1:サロゲートキーを追加する

サロゲートキー(“id”)をDBに追加して、複合キーには一意制約(unique_together)を設定すれば、Djangoのモデルがそのまま使える。

(問題)DBの再構築に時間がかかり、計画停止が必要。

案2:独自クエリ―にて実装する

ORMに頼らずに、自分でクエリ―を書く実装にする。クエリの性能面等を自分でコントロールできる。

(問題)コードとテストが増える。

案3:Modelを拡張する

Djangoプロジェクトでも以前に検討されたようだが、最近はどうなっているかは、よく分からない。

https://code.djangoproject.com/ticket/373

https://code.djangoproject.com/wiki/MultipleColumnPrimaryKeys#CurrentStatus

色々、挑戦した人もいるみたいだが、結局、どれも未完か?

とりあえず、自分でモデルを拡張することで、自分の仕事で使えるように、Modelのサブクラスを作成する。とりあえずは、Djangoのすべての機能をカバーする必要はないので、あくまでも自分が必要とする単体モデルのINSERT,UPDATE,DELETEがカバーできるものならできそう。

(問題)現状のModelのコードを理解する必要がある。

案4:別のORMを作る

モデルまわりのコードを見たが、少しスパゲッティな感じもした。ざっと実装を調べていて感じたのは、Model、Manager、QuerySetでの役割がきれいに分かれてないように見受けられた。複合キー対応も含めて、簡素な自分のORM作るほうがいいような気もする。

(問題)そんな時間は無い!

案5:既にある別のORMを使う

以下は、主キーではなくて、あくまで複合外部キーのためか?

https://github.com/Arisophy/django-composite-foreignkey

(問題)適切なものが見つからない

2.結論

今のプロジェクトは、Ruby On Railsで作られたレガシーなシステムを、徐々にDjangoに移行しているところで、DBに手を加えたくない。

結論として、案2と案3で進めることにした。

INSERT,UPDATE,DELETEについては、案3でModelの拡張クラスで対応する。実際に作ってみたら、少ないコードで、いい感じで動いている。このままその拡張モデルを使いながら、プロジェクトを進めていく。

SELECTについては、そもそも、すべてORMに頼るわけにはいかないので、独自クエリ―も利用する。

拡張クラスをどう作ったかは、後日、公開します。

(2021/01/29)

以下で、パッケージを公開しました。

https://github.com/Arisophy/django-compositepk-model

(2021/06/06)

パッケージの日本語説明

(2021/03/16)

Djangoのticket373についての自分の見解は以下。

[Django]DB操作

1.モデル

https://docs.djangoproject.com/ja/3.2/topics/db/models/

1.1 オブジェクトの取得

オブジェクトを取得する

1)全件取得

MyModel.objects.all()

2)1件の取得

get() を用いて1つのオブジェクトを取得する

3)複雑な条件

以下で様々な条件が作れる。

Q オブジェクトを用いた複雑な検索

Field lookups

集計して、さらにその条件で検索するような場合は、以下を参考。

アグリゲーション

Filtering on annotations

1.2 リレーション

リレーション

関係により、以下クラスでFieldを定義する。リレーションを定義された側のモデルでは、自動で小文字のモデル名でフィールドが作成されて利用できるようになる。

反対向きのリレーション

1)多対一 (many-to-one) 関係

class ForeignKey(to, on_delete, **options)

必須ではありませんが、ForeignKey フィールド名 (上記の例では manufacturer はモデル名を小文字にしたものをおすすめします。

Djangoドキュメント:モデル-リレーション

2)多対多 (many-to-many) 関係

class ManyToManyField(to, **options)

3)一対一 (one-to-one) 関係

class OneToOneField(to, on_delete, parent_link=False, **options)

オブジェクトのhasattrは、以下でやる。

hasattr(object, 'field_name')

https://docs.djangoproject.com/ja/3.1/topics/db/examples/one_to_one/#one-to-one-relationships

4)実装例

Examples of model relationship API

5)その他

・キーは、無指定の場合、モデル名+’_id’となる。違う定義の場合は、db_columnで指定する。

Database Representation

・逆側のリレーションを作りたくない場合は、「related_name=’+’」

https://docs.djangoproject.com/ja/3.1/ref/models/fields/#django.db.models.ForeignKey.related_name

2.独自のクエリ

1)カスタムSQLの操作

https://docs.djangoproject.com/en/3.1/topics/db/sql/#executing-custom-sql-directly

2)トランザクション制御

https://docs.djangoproject.com/ja/3.1/topics/db/transactions/

明示的にトランザクションをコントロールする

3)素の SQL 文の実行

https://docs.djangoproject.com/ja/3.1/topics/db/sql/#performing-raw-sql-queries

3.複合主キーの扱い

https://docs.djangoproject.com/ja/3.1/faq/models/#do-django-models-support-multiple-column-primary-keys

https://code.djangoproject.com/wiki/MultipleColumnPrimaryKeys

複合主キーはサポートされていなくて、「primary_key=True」指定されているフィールドが無い場合は、「id」が主キーとして動作するので、従来システムのDBを使っている場合、モデルを使った更新、削除が、このままでは動作しない。テーブルにidフィールドを追加するか、独自のクエリで処理する必要がある。

3.Manager

3.1 カスタムマネージャーの作成

DB取得等のロジックをViewの中に色々書くのもいまいちなので、カスタムマネージャーを作成する。

(Django公式)マネージャーのカスタマイズ

上記の日本語版は、翻訳が微妙で間違った解釈になるところがあるので、英語版を読んだ方が良さそう。

(Django公式)Custom managers

拡張クラスにメソッドを定義、あるいはget_querysetを書き換える等をコーディングするなり、QuerySetを定義するなりする。以下に4パターンの方法が記載されている。

(Django公式ドキュメント)

①カスタムMangerにメソッド追加する方法

Adding extra manager methods

②カスタムMangerの初期クエリセットを変更する方法

Modifying a manager’s initial QuerySet

③QuerySetを拡張してメソッド追加する方法

Calling custom QuerySet methods from the manager

④QuerySetを複数拡張する場合に、Managerをクエリセットから定義できる方法

Creating a manager with QuerySet methods

上記から適切な方法で実装していく。

4.QuerySet

https://docs.djangoproject.com/ja/3.1/ref/models/querysets/

OSSへの参加

Djangoで開発している中で、汎用的なクラスを公開したほうがいいのかなと思い、今までは躊躇していたが、OSSに参加することにした。利用させてもらうだけというのも、確かに申し訳ないし。

まずは、GitHubで業務に関係のない共通的なクラスで、他の人にも役立ちそうなものを登録して、Djangoコミュニティーにも参加していこうかと思う。

1.GitHub

仕事関係は、自分でGitサーバーを立てて管理していたが、共通ライブラリーはGitHubに入れるようにする。

1.1 オープンソースについて

まずは、オープンソースについて知識を入れる。

オープンソースガイドライン

1.2 マークダウン言語

https://guides.github.com/features/mastering-markdown/

https://gist.github.com/mignonstyle/083c9e1651d7734f84c99b8cf49d57fa

1.3 レポジトリーを公開

最初の公開レポジトリーを作成した。

https://github.com/Arisophy/django-searchview

2.Django

(オープンソースとしてのDjango)

https://docs.djangoproject.com/ja/3.1/#the-django-open-source-project

(DjangoのGitHub)

https://github.com/django/django

2.1 Djangoメーリングリストへの参加

まずは、開発者メーリングリストに登録した。

https://docs.djangoproject.com/ja/3.1/internals/mailing-lists/#django-developers

3.パッケージを作る

パッケージを作って、以下に登録した。

https://pypi.org/

最初のパッケージは、以下を作った。

https://pypi.org/project/django-searchview-lib/

3.1 パッケージ作成手順

https://packaging.python.org/tutorials/packaging-projects/

1)以下コマンドでパッケージ作る

python setup.py sdist bdist_wheel

2)パッケージをアップロード

(テスト環境)
python -m twine upload --repository testpypi dist/*

(リリース)
python -m twine upload dist/*

[Django]SearchViewクラス

Djangoのクラスベースのビューを使って検索画面を作成する場合、FromViewとListViewを使うのが選択肢となる。しかし、同じページで処理する場合には、二つのクラスの機能を合わせたViewが必要となってくる。調べたところ、いいものが見つからず、自分でクラスを作成した。

https://github.com/Arisophy/django-searchview

レポジトリーには、サンプルコード、データも入っています。

1.SearchViewとSearchFormのコード

追加で使うクラスは、SearchViewとSearchFormクラスです。GitHubを参照して下さい。

[GitHub]

パッケージをインストールするか、これらの2つのファイルをプロジェクトにインポートし、SearchFormクラスとSearchViewクラスを使用することで、検索ページを簡単に実装できる。

pip install django-serachview-lib

2.使い方

基本的に、FormViewListViewの使い方と同じ。SearchViewの特徴として、特別な部分は以下。

1)first_display_all_list

Trueの場合、最初のページのobject_listは、SearchFormの初期値から検索される。 Falseの場合、最初のページのobject_listは空となる。

2)form_class

SearchView.form_classに設定するのは、SearchFormクラスである。

3)検索条件の指定方法

  • SearchView.get_form_conditionsでは、SearchFormでのField変数名を、Field Lookup検索条件のLHSとなる。
  • 変数名を使わない場合や、複雑な検索条件は、SearchView.get_form_conditionsをオーバーライドする。
  • annotate“が必要な場合は、get_annotated_querysetをオーバーライドして追加する。

4)ページネーション

POSTで遷移すること。GETで要求した場合は、404エラーとなる。

5)success_url

FormとListが同じページとなったので、FormView.success_urlは使われない。

3.検索画面の実装例

1)SearchForm,SearchViewを取り込む

pip install django-searchview-lib

あるいは、views.pyとforms.pyを、プロジェクトの適当なライブラリ用のフォルダに配置する。

2)Formのサンプル

SearchFormクラスを使用して、検索フィールドを定義する必要がある。フィールドの名前は検索条件になる。以下はサンプルで、自分のDBモデルに合わせて作成する。

[forms.py]
from django.forms import Form
 :
# Test For SearchView
from searchview.forms import SearchForm

class MusicianSearchForm(SearchForm):
    """Search for Musicians."""

    first_name = forms.CharField(
        label='first_name =',
        required = False,
        max_length=50,
    )
    last_name__istartswith = forms.CharField(
        label="last_name like 'val%'",
        required = False,
        max_length=50,
    )
    instrument__contains = forms.CharField(
        label="instrument like '%val%'",
        required = False,
        max_length=32,
    )
    album_count__gte = forms.IntegerField(
        label='album_count >=',
        required = False,
        initial=1,
        min_value=0,
        max_value=999,
    )
    album_count__lt = forms.IntegerField(
        label='album_count <',
        required = False,
        min_value=0,
        max_value=999,
    )

class AlbumSearchForm(SearchForm):
    """Search for Albums."""

   :
    artist__first_name__contains = forms.CharField(
        label="Musician.first_name like '%val%'",
        required = False,
        max_length=50,
    )

3)Viewのサンプル

SearchViewクラスから、検索画面のビューを定義する。

[views.py]
 :
# Test For SearchView
from django.db.models import Count
from searchview.views import SearchView
from .forms import MusicianSearchForm,AlbumSearchForm
from .models import Musician,Album

class AlbumSearchListView(SearchView):
    template_name = 'app/album.html'
    form_class = AlbumSearchForm
    model = Album
    paginate_by = 5
    first_display_all_list = False
    ordering='-release_date'

class MusicianSearchListView(SearchView):
    template_name = 'app/musician.html'
    form_class = MusicianSearchForm
    model = Musician
    paginate_by = 5
    first_display_all_list = True
    ordering='first_name'

    def get_annotated_queryset(self, queryset, cleaned_data):
        # 'album_count'
        queryset = queryset.annotate(
            album_count=Count('album'))

        return queryset

4)Urlpatternsのサンプル

 :
from sample import views
 :
urlpatterns = [
   :
    # Test
    path('', views.AlbumSearchListView.as_view(), name='album'),
    path('musician/', views.MusicianSearchListView.as_view(), name='musician'),
   :
]
 :

5)テンプレートのサンプル

テンプレートについては、FormViewで作っていたテンプレートと、ListViewで作っていたテンプレートを、混ぜて一つのテンプレートに入れることができる。ただ、1点だけ、ページネーションを使う場合は、POSTで要求するようにすること。

 :
{% block content %}
 :
<div class="row">
   <div class="col-md-6">
        <h2>Search Condition</h2>
        <div class="table-responsive">
          <form method="post" id="search_form">
            {% csrf_token %}
            <table class="table table-striped table-sm">
              <tbody>
              {{ form.as_table }}
              </tbody>
            </table>
            <p class="text-center"><input class="btn btn-primary" type="submit" value="Search"></p>
            <input id="pagenation_page" type="hidden" name="page" />
          </form>
        </div>
    </div>
    <div class="col-md-6">
        <h2>Search Results(Album)</h2>
        <div class="table-responsive">
          <table class="table table-striped table-sm">
            <thead>
              <tr>
                <th>#</th>
                <th>name</th>
                <th>release_date</th>
                <th>num_stars</th>
                <th>artist</th>
              </tr>
            </thead>
            <tbody>
            {% for album in object_list %}
              <tr>
                <td>{{ album.id }}</td>
                <td>{{ album.name }}</td>
                <td>{{ album.release_date }}</td>
                <td>{{ album.num_stars }}</td>
                <td>{{ album.artist.first_name }}</td>
              </tr>
            {% endfor %}
            </tbody>
          </table>
        </div>
        <div class="pagination">
          <span class="step-links">
              {% if page_obj.has_previous %}
                  <button type="submit" class="page-btn" value="1">&laquo; first</button>
                  <button type="submit" class="page-btn" value="{{ page_obj.previous_page_number }}">previous</button>
              {% endif %}

              <span class="current">
                  Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
              </span>

              {% if page_obj.has_next %}
                  <button type="submit" class="page-btn" value="{{ page_obj.next_page_number }}">next</button>
                  <button type="submit" class="page-btn" value="{{ page_obj.paginator.num_pages }}">last &raquo;</button>
              {% endif %}
          </span>
        </div>
    </div>
</div>

{% endblock %}

{% block scripts %}
<script>
  $(function(){
    $('.page-btn').click(function(){
      var form1 = document.forms['search_form'];
      var pageno = $(this).val();
      $('#pagenation_page').val(pageno);
      form1.submit();
      return false;
    });
  })
 </script>
{% endblock %}