Webhookのイベント名と処理の対応表を手で管理していると、ハンドラーを足したのに表に登録し忘れたり、同じイベントを2つのクラスで処理していたりする。クラスを定義しただけで対応表に載り、二重登録はその場でエラーにしたい。

class Foo(Base, event="order.paid")のように、クラス文の基底クラスの後ろにはキーワード引数を書ける。この引数は、基底クラスの__init_subclass__に渡される(3.6から)。メタクラスもデコレーターも使わずに、サブクラスの定義に割り込める。

作り方

class Handler:
    registry: dict[str, type["Handler"]] = {}
 
    def __init_subclass__(cls, *, event: str | None = None, **kwargs) -> None:
        super().__init_subclass__(**kwargs)
        if event is None:      # 共通処理だけの中間クラスは登録しない
            return
        if (prev := Handler.registry.get(event)) is not None:
            raise TypeError(f"{event!r} already handled by {prev.__qualname__}")
        Handler.registry[event] = cls
 
    def handle(self, payload: dict) -> str:
        raise NotImplementedError
 
 
class OrderHandler(Handler):
    def order_id(self, payload: dict) -> int:
        return payload["id"]
 
 
class OrderCreated(OrderHandler, event="order.created"):
    def handle(self, payload: dict) -> str:
        return f"created {self.order_id(payload)}"
 
 
class OrderPaid(OrderHandler, event="order.paid"):
    def handle(self, payload: dict) -> str:
        return f"paid {self.order_id(payload)}"
 
 
def dispatch(event: str, payload: dict) -> str:
    return Handler.registry[event]().handle(payload)
sorted(Handler.registry)            -> ['order.created', 'order.paid']
dispatch("order.paid", {"id": 1})   -> 'paid 1'

__init_subclass__は、@classmethodを付けなくても暗黙にクラスメソッドとして扱われる。clsには今作られたサブクラスが入る。

二重登録は、クラスを定義した時点でTypeErrorになる。

class Dup(OrderHandler, event="order.paid"): ...
# TypeError: 'order.paid' already handled by OrderPaid

eventを必須の引数にすれば、書き忘れも検出できる。ただしその場合はOrderHandlerのような中間クラスも書けなくなるので、上の例では省略可能にして「Noneなら登録しない」としている。

呼ばれる順番

class文が実行されると、type.__new__がクラスを作ったあと、属性の__set_name__を呼び、そのあとで親クラスの__init_subclass__を呼ぶ。__init_subclass__の中では、クラスの属性はもう全部そろっていて、デスクリプタの名前も決まっている。handleが定義されているかをここで確かめることもできる。

渡したキーワード引数は__init_subclass__に渡るだけで、クラスの属性にはならない(OrderPaid.eventは存在しない)。あとから参照したいなら、__init_subclass__の中でcls.event = eventのように自分で保存する。

super().__init_subclass__(**kwargs)は省略しない方がよい。多重継承で別の基底クラスも__init_subclass__を持っている場合、ここで呼ばないとそちらが実行されない。

ハマりどころ

登録はクラスの定義が実行されたときに起きるので、ハンドラーを書いたモジュールがimportされていないと登録されない。ハンドラーを置いたパッケージを起動時に全部importしておく必要がある。lazy importで遅延させると、使うまで読み込まれないので登録されないままになる(lazy importでCLIの起動を速くするの「importするだけで登録するプラグイン」と同じ問題)。

モジュールを再読み込みすると、同じクラスがもう一度定義されて二重登録のエラーになる。

importlib.reload(handlers_refund)
# TypeError: 'order.refunded' already handled by Refund

開発サーバーの自動リロードや、テストで同じモジュールを読み直す場合に起きる。同じ__qualname__と__module__からの再登録は上書きを許す、テストの前にregistryを空にする、などの逃げ道を用意しておく。

関連

References