🚀 ニフティ’s Notion

【オブジェクト指向2024 #10】インターフェースでメール送信を切り離す

メール送信インターフェースを作成する

Python では、ABC という特別な基底クラスを継承した親クラスを作ると、インターフェースのようなものを定義することができる。メール送信のインターフェース MailClient を定義するときはこんな感じ。

from abc import ABC, abstractmethod

# MailClient インターフェース。
#
# メールの送信ができることを規定する。
class MailClient(ABC):
    @abstractmethod
    def send_mail_to(self, address: str, message: str):
        "メール送信処理"
        pass

本番環境用の MailClient を作る

これを実装 (Python の仕組み上は継承) するクラスを追加すると、そのクラスが「メール送信機能を持つ」という意思表示ができる。

# 本番環境用
class ActualMailClient(MailClient):
    @override
    def send_mail_to(self, address: str, message: str):
        # 本当はメール送信処理、例示のため print() で代用しておく
        print("actually send mail")
        pass

そして、 subscribe() 関数がクライアントを受け取るようにすれば、本番はこのように実装できる。

def subscribe(mail_client: MailClient, mail_address: str, plan_id: int):
    "申し込む"

    # ...なんやかんや申込処理...

    # メール送信
    message = f"{subscription.monthly_price()}円で購読ありがとうございます"
    mail_client.send_mail_to(mail_address, message)


# 本番
subscribe(ActualMailClient(), "test@example.com", 12345)

テスト環境用の MailClient を作る

ではテストは? …テスト用に、何もしない MailClient を作成してあげればいい。ポリモーフィズムにより、subscribe() 関数は何も修正する必要はない。

# テスト用
class MockMailClient(MailClient):
    @override
    def send_mail_to(self, address: str, message: str):
        # テスト用なので、メール送信をしない
        pass


# テスト用
subscribe(MockMailClient(), "test@example.com", 12345)

ポイント: なるべく小さいインターフェースにする

同じ種類の操作は、バラバラにするよりなるべく同じインターフェースにまとめたほうが意味上のつながりを理解しやすくなり、可読性や変更容易性を高めることになる。だが一方で、何もかもを同じインターフェースにいれるのもまた問題を起こしてしまう。

例えば、メール送信インターフェースの中に、メール送信取り消しメソッドも含まれていたとしよう。

そんな機能普通つかわないよねというツッコミはナシでお願いします。

例えば… サブスク管理システムの中で、メール送信機能が次の2つのユースケースで使われるとしよう。

  • 社内に日次申込統計を送信する
  • お客様にありがとうメールを送る

日次申込統計の送信後、誤りが発覚した場合に取り消したいケースを考えよう。同一組織内ならメール送信取り消しができるメールサービスもあるらしいので、社内のメール送信である日次申込統計の送信では念の為キャンセルできるようにしておきたい。メール関連処理なので MailClient インターフェースに取り消しメソッドを追加すれば良いように思える。

ところが、ごく少数の例外を除けば、基本的にメール配信サービスでは取り消しはできない。だがインターフェースに含まれてしまっていると形式的には子クラスで実装しないといけないことになる。すると苦肉の策として、呼び出されたら例外を投げるだけの実装が爆誕する。

class MailClient(ABC):
    @abstractmethod
    def send_mail_to(self, address: str, message: str):
        "メール送信処理"
        pass

    @abstractmethod
    def cancel_sent_mail(self, address: str):
        "送信取り消し処理"
        pass


class WorldWideMailClient(MailClient):
    "大規模メール配信サービスを利用するクライアント"
    # ...
    @override
    def cancel_sent_mail(self, address: str):
        raise RuntimeError("組織外に送ったメールは取り消せません")


class OrgMailClient(MailClient):
    "組織内メールサービスを利用するクライアント"
    # こちらはフル機能で実装できる
    # ...
☝️
実は、これは後で説明するSOLID原則の「I: インターフェース分離の原則」に対する違反です。

具体的にどのレベルの処理までまとめてもいいのか、には答えがない。状況に応じて考えることが必要になる。

まとめ

  • インターフェースを使うと、「クラスができること」を規定することができる。
  • 直接具体的な処理内容に依存するのではなく「できること」にだけ依存することで、具体的な処理を差し替えることができるようになり、再利用性が高まる。
  • 同じ意味の処理はまとめたほうがよい反面、まとめすぎると問題を起こすこともある。

ここまでみてきたコードの全体像

気が向いたら眺めてみてください。


次: 📄 【オブジェクト指向2024 #11】SOLID 原則