2018-06-10

[Django] Signal の使い方まとめ

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 だけを引数として受取るに対し、 connectweak, 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

少しめんどくさいですが、これは 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 が渡ってこないので、このフラグによる新規作成判定はできません

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 で囲んだときはメールが飛びませんでしたね!

(disconnect リファレンス)

シグナルがなくても実装はできると思いますが、コードの分離ができるので積極的に採用していきたいところですね。

参考

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