Django の Signal って使う頻度が少ないので、使うときにだいたい忘れてませんか? 奇遇ですね、自分もです。
今回は そんな Signal についてまとめてみました。
シグナルとは何らかのイベントが発火したタイミングで登録した処理(以下ハンドラという)を呼び出す機能です。
- info
- 執筆時での最新バージョンを使います。
- Python 3.6
- Django: 2.0
- Sqlite3
- 執筆時での最新バージョンを使います。
Events
ハンドラを定義していませんが、あえて先に説明します。
シグナルは用途によって配置されているモジュールが異なるので、モジュールパスも一応書いておきます。 (バージョンによって変わる可能性はあります)
- 名前
- 説明
- モジュールパス
- pre_save
- (対象)モデルに対するレコードの
追加
or変更
をする 直前 django.db.models.signals.pre_save
- (対象)モデルに対するレコードの
- post_save
- (対象)モデルに対するレコードの
追加
or変更
をした 直後 django.db.models.signals.post_save
- (対象)モデルに対するレコードの
- pre_delete
- (対象)モデルから レコードを
削除
する 直前 django.db.models.signals.pre_delete
- (対象)モデルから レコードを
- post_delete
- (対象)モデルから レコードを
削除
した 直後 django.db.models.signals.post_delete
- (対象)モデルから レコードを
- m2m_changed
- 中間テーブルの変更検知 前後 (2回呼び出される)
django.db.models.signals.m2m_changed
- request_started
- ビュー関数(メソッド) が 実行される 直前 。リクエストのハンドラなんで
紐づくユーザを抽出することで、アクセスユーザの行動を記録したりできそうな気がするのですが、残念ながら
request
引数を 受け取らないため、 自力で HTTP レベルの情報(environ 引数)を解析して情報を取得する必要があります。 まぁそんなことをするくらいならデコレータでも書いたほうが楽です。 django.core.signals.request_started
- ビュー関数(メソッド) が 実行される 直前 。リクエストのハンドラなんで
紐づくユーザを抽出することで、アクセスユーザの行動を記録したりできそうな気がするのですが、残念ながら
- request_finished
- ビュー関数(メソッド) が 実行された 直後 。
request_started
と同様にrequest
引数は受けとりません。 また、environ
も受けとりません。 django.core.signals.request_finished
- ビュー関数(メソッド) が 実行された 直後 。
- got_request_exception
- ビュー関数(メソッド) が 例外を送出した 直後 。これはなぜか
request
引数を受けとります。 django.core.signals.got_request_exception
- ビュー関数(メソッド) が 例外を送出した 直後 。これはなぜか
- user_logged_in
- ユーザのログインが成功したとき。
django.contrib.auth.signals.user_logged_in
上記以外にもあるので、必要になったら調べてみてください。 あとサードパーティライブラリによって使えるようになるものもあります。
- info
- ちなみに実コードはこのあたりです。
Linking
ハンドラとイベントのヒモづけは、デコレータ(receiver
) か
connect
(Signal.connect)
メソッド を使います。
デコレータは
signal
だけを引数として受取るに対し、connect
はweak
,dispatch_uid
といった引数も受け取れます。weak
とはハンドラを弱参照として登録するか否かを指定するためのフラグです。 通常はTrue
で関数内などで作成したガベージコレクションの対象となりうるハンドラの登録は このフラグをFalse
にするとよいらしいです。- たとえば、ファクトリで作成したシグナル関数は
weak=False
を指定しないと発火しません。
- たとえば、ファクトリで作成したシグナル関数は
dispatch_uid
とは シグナルを一意に識別するためのID(文字列) です。 この ID を指定しない場合、同じハンドラは一度しか登録されません。 connect した分だけ発火させたい場合には一意なIDを指定しましょう。 (Preventing duplicate signals)
ハンドラで束ねる(複数のタイミングで特定(1つ)の処理を実行したい)場合はデコレータが適していますがどちらでも登録はできます。
デコレータは
signal
をリスト形式で複数受け取れるため、一つのハンドラを複数のタイミングで発火させたい場合に適しているといえるでしょう。 別に一つで使ってもかまわないと思いますが。
ハンドラは呼び出し可能オブジェクト(大抵は関数)として定義します。 決まりではないのですが、当記事では以下の慣例に従います。
- ハンドラの名前は
xxx_handler
にする - シグナルはアプリケーションディレクトリ直下の
signals.py
に配置する
ここからは 実際にコードを書いてみましょう。当記事のコードを試すにあたって準備が必要です。 興味のない方は次のセクションまで飛ばしてください。
myapp
アプリ を作ります。
(venv) $ ./manage.py startapp myapp
空のシグナルモジュールを作ります。
(venv) $ touch myapp/signals.py
myapp/models.py
を以下のように記述します。
from django.db import models from django.contrib.auth.models import AbstractUser class User(AbstractUser): accessed = models.DateTimeField(null=True) num_followers = models.IntegerField(default=0) followers = models.ManyToManyField('self') class ChangeHistory(models.Model): user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) field = models.CharField(max_length=255) before = models.TextField() after = models.TextField() changed = models.DateTimeField(auto_now_add=True)
settings.py
に以下を追記します。
INSTALL_APPS = ( # : # : someapp 'myapp', ) AUTH_USER_MODEL = 'myapp.User' EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
マイグレーションして、モデルに対応するテーブルを作ります。
(venv) $ ./manage.py makemigrations Migrations for 'myapp': myapp/migrations/0001_initial.py - Create model User - Create model ChangeHistory (venv) $ ./manage.py migrate Operations to perform: Apply all migrations: admin, auth, contenttypes, myapp, sessions Running migrations: Applying contenttypes.0001_initial... OK Applying contenttypes.0002_remove_content_type_name... OK Applying auth.0001_initial... OK Applying auth.0002_alter_permission_name_max_length... OK Applying auth.0003_alter_user_email_max_length... OK Applying auth.0004_alter_user_username_opts... OK Applying auth.0005_alter_user_last_login_null... OK Applying auth.0006_require_contenttypes_0002... OK Applying auth.0007_alter_validators_add_error_messages... OK Applying auth.0008_alter_user_username_max_length... OK Applying auth.0009_alter_user_last_name_max_length... OK Applying myapp.0001_initial... OK Applying admin.0001_initial... OK Applying admin.0002_logentry_remove_auto_add... OK Applying sessions.0001_initial... OK
認証用のユーザを作ります。
(venv) $ ./manage.py createsuperuser
認証情報は test/test1234
にしました。
Imports signals
シグナルのコードを書きたいのですが、実はこのままでは Django が読み込んでくれず、 せっかく定義してもシグナルは実行されません。
ここで利用するのが、 apps.py
です。 ready()
メソッドで
signals
をインポートするだけでOKです。
さらに apps.py
を読み込ませるために __init__.py
に以下を記述し、
app_config を登録してあげて完了です。
概ね、以下のようになります。難しい処理はありません。
apps.py
__init__.py
from django.apps import AppConfig class MyappConfig(AppConfig): name = 'myapp' def ready(self): from . import signals
default_app_config = 'myapp.apps.MyappConfig'
- info
- インポートするだけで登録されるのは、初回インポート時にモジュールのコードが実行されるためです。 (参考:Pythonのモジュールについてまとめてみたよ)
少しめんどくさいですが、これは Django のお作法なのでおとなしく従います。
もしくは 以下のように settings.INSTALL_APPS
にコンフィグを指定してもOKです。
INSTALL_APPS = ( # : # : someapp 'myapp.apps.MyappConfig', )
ここからは実際にありそうな例を踏まえ、シグナルを試していきましょう!
累計を記録する
ユーザのフォロワー関係を記録する中間テーブルがあり、ユーザごとにフォロワー数を表示するという仕様があるとします。
単純に中間テーブルで自分に紐付いたレコード数をカウントすればいいんですが、
パフォーマンス向上の為、ユーザのフィールド (num_followers
)
に計算した数値を入れておきたいそうです(他人事)
中間テーブルの変更なので、 m2m_changed
を使い、合計数を記録します。
from django.db.models.signals import m2m_changed from django.dispatch import receiver from .models import User @receiver(m2m_changed, sender=User.followers.through) def count_handler(sender, instance, action, **kwargs): print('action:', action) if action.startswith('post_'): instance.num_followers = instance.followers.count() instance.save(update_fields=['num_followers'])
m2m_changed
は中間テーブルにどのような変更が加わったのかを表すaction
引数を受けとります。sender
にはUser
ではなく中間テーブルのモデルを指定します。- 変更前、変更後、両方でハンドラがコールされてしまうので、ここでは
post_
だけに制限しています。
>>> from myapp.models import User >>> u = User.objects.get(username='test') >>> u.num_followers 0 >>> u2 = User.objects.get(username='test2') >>> u.followers.add(u2) action: pre_add action: post_add >>> u.num_followers 1 >>> u.followers.clear() action: pre_clear action: post_clear >>> u.num_followers 0
u
変数 が ハンドラの instance 引数にそのまま渡るので、
ハンドラ内で instance の属性 (num_followers) を 編集することで 実行元
(今回はインタラクティブシェル中) で参照できます。
post_add
, pre_remove
など細かく制御できるので、用途に応じて使いこなしましょう。
(m2m_changed)
- info
- 今回は利用しませんでしたが
pk_set
という引数(キーワード引数) は 追加・削除された ID の setオブジェクトを受けとります。 clear()
メソッドによって削除された場合はNone
が返ります。
- 今回は利用しませんでしたが
- warning
中間テーブルモデルに対して直接操作を行った場合、
m2m_changed
は検知できないようです。検証はしていないのですが、おそらく m2m フィールドのメソッド経由じゃないと発火しない気がします。
>>> R = User.followers.through >>> R.objects.all() <QuerySet []> >>> r = R(from_user=u, to_user=u2) >>> r.save() >>> R.objects.all() <QuerySet [<User_followers: User_followers object (16)>]> >>> u.followers.all() <QuerySet [<User: test2>]> >>> u.num_followers # もちろん u2.num_followers も 0 0
登録完了時にメールを送信する
おなじみのあれです。
登録直後にメールを送信したいので、 post_save
を使います。
from django.db.models.signals import post_save from django.dispatch import receiver from django.core.mail import send_mail from .models import User @receiver(post_save, sender=User) def send_registered_mail_handler(sender, instance, created, **kwargs): if created and instance.email: send_mail('[タイトル] 登録完了', 'しました(本文)', 'no-reply@crohaco.net', [instance.email])
新規作成された場合には created
引数が True
になるのでそれによってメール送信をするかどうか決めています。
>>> u2 = User.objects.create(username='test2', email='test2@example.com') Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit Subject: =?utf-8?b?W+OCv+OCpOODiOODq10g55m76Yyy5a6M5LqG?= From: no-reply@crohaco.net To: test2@example.com Date: Tue, 03 Apr 2018 00:00:00 -0000 Message-ID: <152277303383.71471.4148164239602794163@macbook.local> しました(本文) -------------------------------------------------------------------------------
ユーザ情報の変更を検知する
セキュリティの観点からユーザ情報の変更を記録・通知するというシナリオを考えてみます。
- 登録しているメールアドレスにメールを送る
- メールアドレスが変更された場合は変更前のメールアドレスにも送る
- 履歴テーブル(ChangeHistory) に 変更された列 を記録する。
- データの活用はしないので値はすべて文字列型でいい。
メール送信っていうのが前の例とかぶっていますが気にしないでください。
変更前の値を知りたいので pre_save
を使います。
from django.db.models.signals import pre_save from django.dispatch import receiver from django.core.mail import send_mail from .models import ChangeHistory WATCHING_FIELDS = {'email', 'username'} # 監視対象の列 def write_changes(sender, instance, **kwargs): user = sender.objects.filter(pk=instance.pk).first() changes = {} if user: for field_name in WATCHING_FIELDS: before, after = getattr(user, field_name), getattr(instance, field_name) if before != after: changes[field_name] = (before, after) if changes: histories = [] message = '' for field, (before, after) in changes.items(): histories.append(ChangeHistory(user=user, field=field, before=str(before), after=str(after))) message += f'{field}: {before} -> {after}\n' ChangeHistory.objects.bulk_create(histories) send_mail('アカウント情報変更', message, 'no-reply@crohaco.net', {user.email, instance.email}) pre_save.connect(write_changes, User)
今回は理由はありませんが、 connect メソッドで登録しています。
>>> from myapp.models import User, ChangeHistory >>> u = User.objects.get(username='test') >>> u.email = 'test+1@example.com' >>> u.first_name = 'a' >>> u.last_name = 'b' >>> u.save() # 以下はメールの文面 (WATCHING_FIELDS の列が対象) Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit Subject: =?utf-8?b?44Ki44Kr44Km44Oz44OI5oOF5aCx5aSJ5pu0?= From: no-reply@crohaco.net To: test@example.com, test+1@example.com Date: Tue, 03 Apr 2018 00:00:00 -0000 Message-ID: <152277281012.71471.11863327050333504034@macbook.local> email: test@example.com -> test+1@example.com ------------------------------------------------------------------------------- >>> ChangeHistory.objects.values() # 以下は登録レコード <QuerySet [{'id': 1, 'user_id': 1, 'field': 'email', 'before': 'test@example.com', 'after': 'test+1@example.com', 'changed': datetime.datetime(2018, 4, 3, 0, 0, 0, 0, tzinfo=<UTC>)}]>
- warning
- bulk_create
によって 作成された場合、シグナルでキャッチすることはできません。
- 当然ながら、Django 以外の方法で作成・編集された場合も同様にシグナルは検知できません
- pre_save には created が渡ってこないので、このフラグによる新規作成判定はできません
- bulk_create
によって 作成された場合、シグナルでキャッチすることはできません。
disconnect
処理によってはシグナルを無効にしたいことがあります。
しかし、一つの特殊なケースをハンドラに反映させる(if文を増やす)ということもしたくありません。
こういうときは disconnect
を使うと特定のシグナルを解除できます。
解除したシグナルを有効にしたい場合は再登録をしてあげる必要があります。
例えば、以下のような コンテキストマネージャ を定義することで with ステートメント内のみ 特定の ハンドラを無効にできます。
from contextlib import contextmanager @contextmanager def disable_signal(signal, receiver, sender=None, weak=True, dispatch_uid=None): signal.disconnect(receiver, sender, dispatch_uid=dispatch_uid) yield signal.connect(receiver, sender, weak=weak, dispatch_uid=dispatch_uid)
実際に使ってみます。 with
のブロックで囲むだけです。
先程作った 新規ユーザ作成メール送信のハンドラを試してみます。
>>> from django.db.models import signals >>> from myapp.signals import send_registered_mail_handler >>> from myapp.models import User >>> # メールとんだ >>> User.objects.create(username='test3', email='test3@example.com') Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit Subject: =?utf-8?b?W+OCv+OCpOODiOODq10g55m76Yyy5a6M5LqG?= From: no-reply@crohaco.net To: test3@example.com Date: Tue, 03 Apr 2018 00:00:00 -0000 Message-ID: <152302790049.80517.4458651697059021571@macbook.local> しました(本文) ------------------------------------------------------------------------------- <User: test3> >>> # メールとばなかった >>> with disable_signal(signals.post_save, send_registered_mail_handler, User): ... User.objects.create(username='test4', email='test4@example.com') ... <User: test4> >>> # メールとんだ >>> User.objects.create(username='test5', email='test5@example.com') Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit Subject: =?utf-8?b?W+OCv+OCpOODiOODq10g55m76Yyy5a6M5LqG?= From: no-reply@crohaco.net To: test5@example.com Date: Tue, 03 Apr 2018 00:00:00 -0000 Message-ID: <152302790052.80517.8834743268777731895@macbook.local> しました(本文) ------------------------------------------------------------------------------- <User: test5>
期待通り、 with で囲んだときはメールが飛びませんでしたね!
シグナルがなくても実装はできると思いますが、コードの分離ができるので積極的に採用していきたいところですね。
参考
DjangoThe web framework for perfectionists with deadlines.https://docs.djangoproject.com/en/2.0/topics/signals/ Django 1.7で追加されるAppConfigの紹介 - 偏った言語信者の垂れ流しこの記事はDjango 1.7 alpha2の段階で書いています。 Django 1.7では、アプリケーションのロードや管理の仕組みが変更されます。 ドキュメントも追加されています。 Applications | Django documentation | Django 大雑把な説明 大雑把に説明すると、 アプリケーションのロード時にフックする仕組みが増えた(以前はmodels.pyやurls.pyなどにコードを書かないといけなかった) アプリケーションにデフォルト設定を持たせて、差し替える仕組みが増えた(以前は各アプリケーションでsettingsを読めなければデフォルト値を使うような記述を…https://tokibito.hatenablog.com/entry/20140301/1393660554 Pythonでweakref(弱参照)モジュールを使いこなす - Qiitaweakrefモジュールは実用的で、非常にパワフルなモジュールである。おそらく、このモジュールほど実用性と知名度のバランスで不遇な扱いを受けているモジュールはないだろう。この記事ではweakre…https://qiita.com/pashango2/items/fb1e5e79589279c5a861 How to Create Django Signalshttps://simpleisbetterthancomplex.com/tutorial/2016/07/28/how-to-create-django-signals.html Disconnect signals for models and reconnect in djangoI need make a save with a model but i need disconnect some receivers of the signals before save it. I mean, I have a model: class MyModel(models.Model): ... def pre_save_model(sender, inst...https://stackoverflow.com/questions/2209159/disconnect-signals-for-models-and-reconnect-in-django