Open22

Djangoでの開発Tips

shimakaze_softshimakaze_soft

Djangoで複合ユニーク制約を実装するやり方

データベースのテーブルには 「主キー」を複数のカラムの組み合わせ として、複合主キーというのが設定できます。

しかし、Djangoには複合主キーを設定する機能がありません。その代わり、複数のカラムの組み合わせに対して一意(ユニーク)な制約をつけることができます。このような制約を複合ユニーク制約といいます。

今回はDjangoでモデル作成時に複合ユニーク制約を設定する方法です。

shimakaze_softshimakaze_soft

複合主キーの例

例えば以下のようなテーブルが複合主キーとしてイメージがしやすいです。

「学年」「組」「出席番号」の組み合わせは、 同じものが存在してはいけない一意(ユニーク) なものです。

学年 出席番号 名前
1 1 1 鈴木一郎
1 1 2 佐藤次郎
1 1 3 高橋三郎
1 3 1 田中四郎

しかし、Djangoには複合主キーの機能ありません。その代わりに、複合ユニーク制約の機能はあります。

shimakaze_softshimakaze_soft

複合ユニーク制約を付与したモデルクラスを作成

上記を元にモデルクラスを作成していきます。

from django.db import models

class Student(models.Model):
    grade = models.IntegerField(verbose_name='学年',)
    group = models.IntegerField(verbose_name='クラス')
    number = models.IntegerField(verbose_name='')
    name = models.CharField(verbose_name='名前')

複合ユニーク制約の機能を加えると以下のようになります。

from django.db import models

class Student(models.Model):
    grade = models.IntegerField(verbose_name='学年',)
    group = models.IntegerField(verbose_name='クラス')
    number = models.IntegerField(verbose_name='')
    name = models.CharField(verbose_name='名前')

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=['grade', 'group', 'number'], name='unique_student')
        ]

Metaにオプションを追加しています。詳しくは以下に記述しました。

constraints

constraintsはメタデータオプションの一つで、DBの制約を定義できる。
モデルに定義したい制約をリスト形式で設定する。

UniqueConstraint

Django2.2から追加されました。
DBにユニーク制約を作成する事ができる。

キーワード引数については以下のようになっています

  • fields : 複合ユニークにしたいフィールド名のリストを設定。
  • fields : 制約の名前を設定でき、DBに制約名が反映される。常に一意の名前にする必要がある。
shimakaze_softshimakaze_soft

unique_togetherについて

メタデータオプションにunique_togetherを使って複合ユニーク制約をつける事もできますが、今後は非推奨になる可能性があるため、ここでもconstrainsUniqueConstraintを使って実装しています。

shimakaze_softshimakaze_soft

実際の動作

上記で作成したモデルクラスを実行してみると、以下のようになります。複合ユニーク制約が働いていることがわかります。

>>> from .models import Student

>>> Student.objects.create(grade=1, group=1, number=1, name="佐藤一郎")
# 正常に実行

>>> Student.objects.create(grade=1, group=1, number=1, name="田中次郎")
# 失敗してエラーになる
shimakaze_softshimakaze_soft

Djangoの高速化について

データベースの同じテーブルに対して複数のデータを追加したい場合は、以下のようにfor文などでレコードの追加処理をしてはいけない。

for d in data_list:
    # レコードの追加
    ModelObject.objects.create(field_1=d)

これだとデータベースへのアクセス回数が増え、アプリケーションの動作が落ちる。場合によっては処理途中でタイムアウトと言ったことも考えられる。

複数のデータを登録したり、更新するためのレコードデータの一括作成一括更新方法について紹介します。

bulk_createとbulk_update

Djangoのレコードデータの一括作成、一括更新のためにbulk_createbulk_updateというメソッドが用意されています。

bulk_create

試しに10万件のUserオブジェクトが入ったリストを作ってみます

from django.contrib.auth import get_user_model

User = get_user_model()

# 10万件のUserオブジェクトが入ったリストを作る
users = []
for i in range(100000):
    user = User(username="steve" + str(i), email="steve" + str(i) +"@steve.com")
    users.append(user)

# 以下でも同じことができます
users = [
    User(username="steve" + str(i), email="steve" + str(i) +"@steve.com")
    for i in range(100000)
]

# users のデータをDBに一括登録
User.objects.bulk_create(users)

上記の処理では1回だけクエリが実行されるため、10万件の追加処理でも一瞬で終わります。
自分の環境では2秒以内で処理が終了しました。

bulk_update

一括更新にはbulk_update()を使用する。

ここでは、それぞれのnameの末尾に"さん"を加えて更新してみます。

from django.contrib.auth import get_user_model

User = get_user_model()

# 10万件のUserオブジェクトが入ったリスト
users = User.objects.all()
for user in users:
    user.name = user.name + "さん"

# users のデータを一括更新
User.objects.bulk_update(users, fields=["name"])

上記の処理も1回だけクエリが実行されるため、10万件の更新処理でも一瞬で終わります。

bulk_updateを用いれば、下記のようなコードを書かなくてよくなります。

from django.contrib.auth import get_user_model
User = get_user_model()

users = User.objects.all()
for user in users:
    user.name = user.name + "さん"
    user.save()
shimakaze_softshimakaze_soft

bulk_createとbulk_updateの注意点

しかし、bulk_createとbulk_updateを使う際にはいくつか注意点があります

bulk_create()の注意点

  • モデルのsave()メソッドは呼ばれず、pre_save()post_saveシグナルも送信されない

  • ManyToManyFieldのリレーションは機能しない。そのため、リレーションの中間テーブルをbulk_create()する必要がある。

  • マルチテーブル継承シナリオの子モデルでは機能しない

  • モデルの主キーがAutoFieldの場合、データベースバックエンドでサポートされていない限り、save()のように主キー属性を取得および設定しない

    • 現在はPostgreSQLがサポート。PostgreSQLであれば下記のようにIDを取得することができる。
models = ModelObject.objects.bulk_create(create_list)

for m in models:
    print(m.id)

bulk_update()の注意点

  • モデルの主キーを更新することはできない

  • モデルのsave()メソッドは呼び出されず、pre_saveおよびpost_saveシグナルは送信されない

  • 行列数が多いデータを更新する場合、生成されるSQLサイズが非常に大きくなる可能性があるため、適切なbatch_sizeを指定して、これを回避する必要がある。

  • マルチテーブルで継承元親クラスに定義されたフィールドを更新すると、親クラスごとに追加のクエリが発生する

  • objsに重複が含まれている場合、最初のもののみが更新される

shimakaze_softshimakaze_soft

bulk_createとbulk_updateのパラメータ

bulk_createbulk_updateのそれぞれに指定できるパラメータがあります

batch_size

レコード数が多いデータに対してはバッチサイズの指定が可能です。
batch_sizeパラメータは、1つのクエリで保存されるオブジェクト数を制御できます。

デフォルトではNoneです。

ignore_conflicts

ignore_conflictsパラメータをTrueに設定すると、重複するユニークな値などの制約に違反するレコードの挿入の失敗を無視するようにデータベースに指示します。

しかし、このパラメーターを有効にすると、各モデルインスタンスの主キーの設定が無効になります。

shimakaze_softshimakaze_soft

settings.pyにキャッシュの設定

settings.py
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
        'LOCATION': 'my_cache_table',
    }
}

BACKEND

キャッシュの保存先の種類。
上記のようにdb.DatabaseCasheでは、キャッシュの保存先をデータベースにしている。

LOCATION

キャッシュを保存するテーブル。
名前を付ける際は既に存在するテーブルの名前と被らないようにする必要がある。

shimakaze_softshimakaze_soft

Djangoのボトルネックとなる箇所を特定していく

Pythonにはボトルネックとなるような場所を特定するために、動的プログラム分析の一種であるプロファイリングを行うためのプロファイラが標準でついています。

Pythonで標準でついているのは、cProfileとprofileというプロファイラです。

Djangoでボトルネックの特定によく使われるのはdjango-slikです。django-silkを使うと時間のかかるリクエスト、SQLを計測することができる。

https://github.com/jazzband/django-silk

$ pip install django-silk

settings.pyMIDDLEWAREINSTALLED_APPSに以下を追加する。

settings.py
MIDDLEWARE = [
    ...
    'silk.middleware.SilkyMiddleware',
    ...
]

INSTALLED_APPS = (
    ...
    'silk'
)

urls.pyに下記行を追加する。

urls.py
urlpatterns += [path('silk/', include('silk.urls', namespace='silk'))]

データベースに計測結果を記録していくためにテーブルを追加する。

$ python manage.py makemigrations
$ python manage.py migrate
$ python manage.py collectstatic

計測結果ページにアクセスする

silk/にアクセスすると計測結果が表示される。

Summary

要約ページ

Requests

各リクエストの結果。Recent, Path, Num. Queries, Time, Time on queriesの順でソートできる。
各リクエストをクリックするとDetailsとSQLが表示される。

Details

リクエストの詳細。

  • 処理時間
  • クエリ数
  • クエリにかかった時間
  • リクエスト/レスポンスヘッダー
  • 要求/応答期間

SQL

SQLの詳細

Profiling

settings.pyに以下の行を追加し、

settings.py
SILKY_PYTHON_PROFILER = True
# SILKY_PYTHON_PROFILER_BINARY = True
SILKY_PYTHON_PROFILER_RESULT_PATH = os.path.join(BASE_DIR, "profiles")

SILKY_PYTHON_PROFILER_BINARYSILKY_PYTHON_PROFILERを両方Trueにしてはいけない。

https://wonderwall.hatenablog.com/entry/2018/04/08/001500

https://kracekumar.com/post/profiling_django/

shimakaze_softshimakaze_soft

DjangoにNewRelicを組み込む

DjangoにNew Relicを実装する方法について簡単なチュートリアルを行うつもりですが、同じことは他のPythonベースのWeb フレームワークにも適用できる。

$ pip install newrelic

https://medium.com/@muratsert1453/django-application-monitoring-with-new-relic-ea99b21cdd11

newrelic.iniを同ディレクトリに置く。

import newrelic.agent
newrelic.agent.initialize(os.path.join(os.path.dirname(__file__), "newrelic.ini"))
application = newrelic.agent.WSGIApplicationWrapper(application)

https://docs.newrelic.com/jp/docs/apm/agents/python-agent/getting-started/introduction-new-relic-python/

shimakaze_softshimakaze_soft

memcachedを入れてキャッシュ機能を入れる

docker-composememcachedコンテナを入れます。

docker-compose.yml
version: "3"
services:
  memcached:
    image: memcached
    ports:
      - 11211:11211

事前にmemcachedを扱えるようにするためのライブラリを入れる必要がある。pylibmcを導入する。python-memcachedというライブラリもあるが、こちらはあまりメンテナンスされていないため、pylibmcの方を導入する。

$ pip install pylibmc

djangoのconfigには以下のように設定する。

settings.py
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.memcached.PyLibMCCache",
        "LOCATION": "memcached:11211"
    }
}

# 各ページのキャッシュ時間を秒単位で指定
CACHE_MIDDLEWARE_SECONDS = 60 * 15
CACHE_MIDDLEWARE_KEY_PREFIX = "myapp_"
CACHE_MIDDLEWARE_ANONYMOUS_ONLY = False

CACHE_MIDDLEWARE_SECONDS

CACHE_MIDDLEWARE_SECONDSにキャッシュ時間を秒単位で設定する。省略した場合のデフォルト値は600であり、つまりは10分。

CACHE_MIDDLEWARE_KEY_PREFIX

CACHE_MIDDLEWARE_KEY_PREFIXは、Djangoのキャッシュシステムにおいて、キャッシュキーのプレフィックス(接頭辞)を設定するための設定値です。
この設定は、Djangoのキャッシュミドルウェアが使用するキャッシュキーを一意に識別するのに役立つ。

複数のDjangoアプリケーションが同じキャッシュバックエンドを共有している場合、CACHE_MIDDLEWARE_KEY_PREFIXを使用して各アプリケーションのキャッシュキーを分離できる。

また、特定のプレフィックスを使用することで、キャッシュの管理や無効化をより簡単に行えます。例えば、特定のプレフィックスを持つキャッシュのみをクリアすることが可能です。

CACHE_MIDDLEWARE_KEY_PREFIX = 'myapp_'とすることで、キャッシュミドルウェアによって生成される全てのキャッシュキーはmyapp_で始まる。これにより、キャッシュの衝突を避け、特定のアプリケーションや環境に対してキャッシュをより効果的に管理できる。

省略した場合のデフォルト値は''(空文字列)になる。

CACHE_MIDDLEWARE_ANONYMOUS_ONLY

キャッシュミドルウェアが匿名ユーザーに対してのみキャッシュを行うように設定するためのもの。
このオプションがTrueに設定されている場合、認証されたユーザーにはキャッシュされたページが提供されません。

この設定は、認証されたユーザーに対してパーソナライズされたコンテンツを提供する必要があるウェブサイトで有用。
例えば、ユーザーがログインしている場合、彼らのプロフィール情報や特定のユーザー向けのコンテンツを表示する場合など。
このような状況では、認証されたユーザーに対してキャッシュされたページを提供すると、パーソナライズされた情報が正しく表示されなくなる可能性がある。

CACHE_MIDDLEWARE_ANONYMOUS_ONLYをTrueにすることで、Djangoは匿名ユーザーのリクエストに対してのみキャッシュを適用する。認証されたユーザーのリクエストには、常に動的に生成されたページが提供される。

サイト単位のキャッシュを行う

サイト単位のキャッシュでは、GETまたはPOSTパラメータをもたない全てのページをキャッシュする。設定ファイルを編集してキャッシュ機能の有効化する。

settings.pyMIDDLEWARE_CLASSESを編集します。

MIDDLEWARE_CLASSESの先頭にdjango.middleware.cache.UpdateCacheMiddlewareを追加し、 最後にdjango.middleware.cache.FetchFromCacheMiddlewareを追加する。

settings.py
MIDDLEWARE_CLASSES = (
    'django.middleware.cache.UpdateCacheMiddleware','django.middleware.common.CommonMiddleware','django.middleware.cache.FetchFromCacheMiddleware',
)

ビュー単位のキャッシュを行う

個々のビューの出力をキャッシュします。

index.py
from django.views.decorators.cache import cache_page

@cache_page(60 * 15)
def index(request):

cache_pageの引数には、キャッシュ時間を秒単位で指定する。

好きなタイミングでキャッシュに保存

何らかの変数などを好きなタイミングでキャッシュに保存できます。
データベースの変更を契機にキャッシュを更新するなど、きめ細かいキャッシュの制御を行うことができる。

from django.core.cache import cache

# キャッシュの登録
cache.set(key, value, timeout_seconds)

# キャッシュの取得、キャッシュがなければdefaultを返す
# timeout_secondsのデフォルト値はCACHE_BACKEND設定のtimeout引数の値になる
cache.get(key, default=None, timeout_seconds=timeout)

# キャッシュを削除
cache.delete(key)

cache.add(key, value, timeout_seconds)はキャッシュ辞書上にキーが存在しない場合のみ、値を登録する。キーが存在する場合は、キャッシュを更新せず、cache.addはcache.setと同じ引数をとります。

cache.get('my_key') #=> None
cache.get('my_key', 'hey') #=> 'hey'
cache.set('my_key', 'hello, world!', 30)
cache.get('my_key') #=> 'hello, world!'
cache.delete('my_key')
cache.get('my_key') #=> None

cache.add('my_key', 'hey')
cache.get('my_key') #=> 'hey'

cache.add('my_key', 'hello, world!')
cache.get('my_key') #=> 'hey'

# cache.get_mary()は指定したキーのものの辞書を返します。
cache.set('a', 1)
cache.set('b', 2)
cache.set('c', 3)
cache.get_many(['a', 'b', 'c']) #=> {'a': 1, 'b': 2, 'c': 3}

細かい設定はこちらに記載されている。

https://devcenter.heroku.com/ja/articles/django-memcache

shimakaze_softshimakaze_soft

CACHESのオプション

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
        'LOCATION': '/var/tmp/django_cache',
        'TIMEOUT': 60,
        'OPTIONS': {
            'MAX_ENTRIES': 1000
        }
    }
}

TIMEOUT

デフォルトのタイムアウトで、単位は秒です。 デフォルト値は5 分 (300 秒) に設定されている。

OPTIONS

キャッシュバックエンドへ渡したい オプションです。 使えるオプションは、バックエンドごとに様々であり、locmemfilesystemdatabaseといったキャッシュバックエンドは それぞれ独自の淘汰方法を持っていて、次のオプションに従う。

MAX_ENTRIES

いくつまでキャッシュエントリを保持するかの設定です。 この設定を超えると古いものから削除される。 デフォルト値は 300 です。

CULL_FREQUENCY

キャッシュエントリ数が MAX_ENTRIES に達したときにどのくらいのキャッシュエントリを削除するかを分数で指定する。
実際の割合は 1/CULL_FREQUENCY です。つまり、CULL_FREQUENCYを 2 に設定すると、MAX_ENTRIESに達した場合に半分のキャッシュを削除する。

CULL_FREQUENCYに0を指定すると、キャッシュエントリ数がMAX_ENTRIESに到達した時に全てのキャッシュエントリを廃棄する。 この設定は、キャッシュミスの増加と引き換えに、淘汰処理を劇的に高速化する。

サードパーティのライブラリを使ったキャッシュバックエンドはライブラリの オプションを背後のライブラリに直にオプションを渡す。結果として、有効なオプションのリストは使うライブラリに依存する。

KEY_PREFIX

Djangoサーバが使うキャッシュキーに自動的に付与される文字列です(デフォルトでは前につきます)。
頭につけられる文字列です。

複数のサーバ間でキャッシュインスタンスを共有している場合や本番環境と 開発環境で共有している場合には、あるサーバのキャッシュデータを他の サーバに使われてしまうことがある。
キャッシュデータのフォーマットがサーバ間でつがう場合には突き止めるのが 非常に難しい問題を引き起こしがちです。

この問題を避けるために、すべてのキャッシュキーにプリフィックスをつけられる。 個別のキャッシュキーを保存するときや取得するときに キャッシュのKEY_PREFIXに設定された値をDjangoが自動でプリフィックスをつける。

各Django インスタンスのKEY_PREFIXを確実に別のものに設定しておくことで、キャッシュが衝突することを避けられます。

VERSION

Djangoサーバが生成するキャッシュキーに使われる デフォルトのバージョン番号です。

KEY_FUNCTION

ドットで区切られた関数のパスを設定する。関数でキーの頭につけられる文字とバージョンを最終的にどのように構成するかを定義する。


ファイルシステムを使ったキャッシュを、デフォルトの タイムアウトが60秒で、 最大のキャッシュエントリ保持数が1000の設定です。

shimakaze_softshimakaze_soft

Redisを入れてキャッシュ機能を入れる

docker-composememcachedコンテナを入れます。

docker-compose.yml
version: "3"
services:
  redis:
    image: redis:7.2.3-alpine
    ports:
      - "6375:6379"
    volumes:
      - "./storage/cached:/data"

事前にredisを扱えるようにするためのライブラリを入れる必要がある。django-redisを導入する。

$ pip install django-redis

djangoのconfigには以下のように設定する。

settings.py
CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis:6379/1",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient"
        }
    }
}