Djangoでの開発Tips
Djangoで複合ユニーク制約を実装するやり方
データベースのテーブルには 「主キー」を複数のカラムの組み合わせ として、複合主キー
というのが設定できます。
しかし、Djangoには複合主キーを設定する機能がありません。その代わり、複数のカラムの組み合わせに対して一意(ユニーク)な制約をつける
ことができます。このような制約を複合ユニーク制約といいます。
今回はDjangoでモデル作成時に複合ユニーク制約を設定する方法です。
複合主キーの例
例えば以下のようなテーブルが複合主キーとしてイメージがしやすいです。
「学年」「組」「出席番号」の組み合わせは、 同じものが存在してはいけない一意(ユニーク) なものです。
学年 | 組 | 出席番号 | 名前 |
---|---|---|---|
1 | 1 | 1 | 鈴木一郎 |
1 | 1 | 2 | 佐藤次郎 |
1 | 1 | 3 | 高橋三郎 |
1 | 3 | 1 | 田中四郎 |
しかし、Djangoには複合主キーの機能ありません。その代わりに、複合ユニーク制約の機能はあります。
複合ユニーク制約を付与したモデルクラスを作成
上記を元にモデルクラスを作成していきます。
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に制約名が反映される。常に一意の名前にする必要がある。
unique_togetherについて
メタデータオプションにunique_togetherを使って複合ユニーク制約をつける事もできますが、今後は非推奨になる可能性があるため、ここでもconstrains
とUniqueConstraint
を使って実装しています。
実際の動作
上記で作成したモデルクラスを実行してみると、以下のようになります。複合ユニーク制約が働いていることがわかります。
>>> from .models import Student
>>> Student.objects.create(grade=1, group=1, number=1, name="佐藤一郎")
# 正常に実行
>>> Student.objects.create(grade=1, group=1, number=1, name="田中次郎")
# 失敗してエラーになる
Djangoの高速化について
データベースの同じテーブルに対して複数のデータを追加したい場合は、以下のようにfor文などでレコードの追加処理をしてはいけない。
for d in data_list:
# レコードの追加
ModelObject.objects.create(field_1=d)
これだとデータベースへのアクセス回数が増え、アプリケーションの動作が落ちる。場合によっては処理途中でタイムアウトと言ったことも考えられる。
複数のデータを登録したり、更新するためのレコードデータの一括作成、一括更新方法について紹介します。
bulk_createとbulk_update
Djangoのレコードデータの一括作成、一括更新のためにbulk_create
とbulk_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()
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に重複が含まれている場合、最初のもののみが更新される
bulk_createとbulk_updateのパラメータ
bulk_create
とbulk_update
のそれぞれに指定できるパラメータがあります
batch_size
レコード数が多いデータに対してはバッチサイズの指定が可能です。
batch_size
パラメータは、1つのクエリで保存されるオブジェクト数を制御できます。
デフォルトではNoneです。
ignore_conflicts
ignore_conflicts
パラメータをTrueに設定すると、重複するユニークな値などの制約に違反するレコードの挿入の失敗を無視するようにデータベースに指示します。
しかし、このパラメーターを有効にすると、各モデルインスタンスの主キーの設定が無効になります。
Djangoのキャッシュ機能
Djangoのキャッシュ機能には、memcachedやRedis、データベースに対してもキャッシュを保存することができます。
settings.pyにキャッシュの設定
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
'LOCATION': 'my_cache_table',
}
}
BACKEND
キャッシュの保存先の種類。
上記のようにdb.DatabaseCashe
では、キャッシュの保存先をデータベースにしている。
LOCATION
キャッシュを保存するテーブル。
名前を付ける際は既に存在するテーブルの名前と被らないようにする必要がある。
Djangoのボトルネックとなる箇所を特定していく
Pythonにはボトルネックとなるような場所を特定するために、動的プログラム分析の一種であるプロファイリングを行うためのプロファイラが標準でついています。
Pythonで標準でついているのは、cProfileとprofileというプロファイラです。
Djangoでボトルネックの特定によく使われるのはdjango-slik
です。django-silk
を使うと時間のかかるリクエスト、SQLを計測することができる。
$ pip install django-silk
settings.py
のMIDDLEWARE
とINSTALLED_APPS
に以下を追加する。
MIDDLEWARE = [
...
'silk.middleware.SilkyMiddleware',
...
]
INSTALLED_APPS = (
...
'silk'
)
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に以下の行を追加し、
SILKY_PYTHON_PROFILER = True
# SILKY_PYTHON_PROFILER_BINARY = True
SILKY_PYTHON_PROFILER_RESULT_PATH = os.path.join(BASE_DIR, "profiles")
SILKY_PYTHON_PROFILER_BINARY
とSILKY_PYTHON_PROFILER
を両方True
にしてはいけない。
その他の計測ツール
動的分析
Django-Doctor
プルリクエスト内でDjangoのコードの改善案を提案してくれる分析ツール。
pyinstrument
DjangoにNewRelicを組み込む
DjangoにNew Relicを実装する方法について簡単なチュートリアルを行うつもりですが、同じことは他のPythonベースのWeb フレームワークにも適用できる。
$ pip install newrelic
newrelic.ini
を同ディレクトリに置く。
import newrelic.agent
newrelic.agent.initialize(os.path.join(os.path.dirname(__file__), "newrelic.ini"))
application = newrelic.agent.WSGIApplicationWrapper(application)
DjangoでFastAPIのような開発体験を得たい。
FastAPIでは便利なpydanticや最初からswaggerが入っているなど、RESTfulなAPIを開発するのにおいてはFastAPIが一番使いやすい。
Django上で同じような体験ができるツールがある。
ログ設定
memcachedを入れてキャッシュ機能を入れる
docker-compose
にmemcached
コンテナを入れます。
version: "3"
services:
memcached:
image: memcached
ports:
- 11211:11211
事前にmemcachedを扱えるようにするためのライブラリを入れる必要がある。pylibmc
を導入する。python-memcached
というライブラリもあるが、こちらはあまりメンテナンスされていないため、pylibmcの方を導入する。
$ pip install pylibmc
djangoのconfigには以下のように設定する。
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.py
のMIDDLEWARE_CLASSES
を編集します。
MIDDLEWARE_CLASSES
の先頭にdjango.middleware.cache.UpdateCacheMiddleware
を追加し、 最後にdjango.middleware.cache.FetchFromCacheMiddleware
を追加する。
MIDDLEWARE_CLASSES = (
'django.middleware.cache.UpdateCacheMiddleware',
…
'django.middleware.common.CommonMiddleware',
…
'django.middleware.cache.FetchFromCacheMiddleware',
)
ビュー単位のキャッシュを行う
個々のビューの出力をキャッシュします。
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}
細かい設定はこちらに記載されている。
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
キャッシュバックエンドへ渡したい オプションです。 使えるオプションは、バックエンドごとに様々であり、locmem
やfilesystem
、database
といったキャッシュバックエンドは それぞれ独自の淘汰方法を持っていて、次のオプションに従う。
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
の設定です。
Redisを入れてキャッシュ機能を入れる
docker-compose
にmemcached
コンテナを入れます。
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には以下のように設定する。
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis:6379/1",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient"
}
}
}
データベース(RDB)周りの高速化のメモ
こちらにも記述したが、Django ORM周りの高速化についてもメモしていく