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 OrderPaideventを必須の引数にすれば、書き忘れも検出できる。ただしその場合は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を空にする、などの逃げ道を用意しておく。
関連
- __set_name__で環境変数の設定クラスからキー名の二重書きをなくす:
__init_subclass__より先に呼ばれる__set_name__の使い道