[Django] Signal の使い方まとめ
2018-06-10

目次

Django の Signal って使う頻度が少ないので、使うときにだいたい忘れてませんか? 奇遇ですね、自分もです。

今回は そんな Signal についてまとめてみました。

シグナルとは 何らかのタイミングで登録した処理(以下ハンドラという)を呼び出す機能です。

備考

この時点での最新バージョンを使います。

  • Python 3.6
  • Django: 2.0
  • Sqlite3
目次

Timing

ハンドラを定義していませんが、あえて先に説明します。

シグナルは用途によって配置されているモジュールが異なるので、モジュールパスも一応書いておきます。 (バージョンによって変わる可能性はあります)

名前 説明 モジュールパス
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

上記以外にもあるので、必要になったら調べてみてください。

備考

ちなみに実コードはこのあたりです。

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 に配置する

ここからは 実際にコードを書いてみましょう。当記事のコードを試すにあたって準備が必要です。

Detail

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 にしました。

Import 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'

備考

インポートするだけで登録されるのは、初回インポート時にモジュールのコードが実行されるためです。 (参考: 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)

備考

今回は利用しませんでしたが pk_set という引数 (キーワード引数) は 追加・削除された ID の set オブジェクトを受けとります。

clear() メソッドによって削除された場合は None が返ります。

警告

中間テーブルモデルに対して直接操作を行った場合、 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>)}]>

警告

  • 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 リファレンス)

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

参考