🚀 ニフティ’s Notion

🌞 【Webアプリ2024 #4】インターフェースとDependency Injection

依存

モジュールを分割したら、次は個別にテストをすることを考えます。

以下の保存処理を考えましょう。

⚠️
入力されたユーザをDBに保存する
ただし、前章の条件を満たさない場合はエラーとする
前章の条件
🤝
結婚可能年齢に達している かつ 年収500万円以上

*2022年以前の基準(男性18歳、女性16歳)

これを単純に書くとこうなります。

from dataclasses import dataclass

@dataclass
class User:
    name: str
    sex: "man" | "woman"
    age: Age
    income: Income


class Application:
    # 適当なDBクライアントを想定
    __dbClient: DBClient
  
    def __init__(self) -> void:
        __dbClient = DBClient(...)
 
    def createUser(self, user: User) -> void:
        if isAcceptable(user):
            self.__dbClient.exec('INSERT INTO user VALUES(?, ?, ?, ?)',
              (user.name, user.sex, user.age.value, user.income.value))
        else:
            raise Error("User is not acceptable for this service.")

    # 前章と同一ロジック
    def isAcceptable(user: User) -> bool:
        if user.sex == "man" and user.age.value >= 18 or user.sex == "woman" and user.age.value >= 16:
            if user.income.value >= 5:
                return true
        return false

このApplicationクラスはDBへの保存と、ユーザのチェックという2種類の役割を持っているので、分割してみましょう。

image block
# アプリケーションメインロジック
class Application:
    __repository: UserRepository

    def __self__(self):
        self.__repository = UserRepository()

    def createUser(self, user: User) -> void:
        if isAcceptable(user):
            self.__repository.save(user)
        else:
            raise Error("User is not acceptable for this service.")

    def isAcceptable(user: User) -> bool:
        if user.sex == "man" and user.age.value >= 18 or user.sex == "woman" and user.age.value >= 16:
            if user.income.value >= 5:
                return true
        return false
# ユーザデータに責務を持つ
class UserRepository:
    # 適当なDBクライアントを想定
    __dbClient: DBClient
  
    def __init__(self) -> void:
        self.__dbClient = DBClient(...)

    def save(self, user: User) -> void:
        self.__dbClient.exec('INSERT INTO user VALUES(?, ?, ?, ?)',
          (user.name, user.sex, user.age.value, user.income.value))

さて、これは分割できたと言えるでしょうか…?

UserRepositoryクラス

問題ありません。DBが必要ですが、DBがあればこのクラス単独でテストが可能です。

Applicationクラス

内部でUserRepositoryクラスが必ず呼ばれます。言い換えると、ApplicationクラスはUserRepositoryクラスに 依存 しています。

Applicationクラスの動作にはUserRepositoryクラスが必須なので、これは不完全な分割です。

インターフェース

依存をなくすためには、インターフェースを導入します。

オブジェクト指向における 依存性逆転の原則(DIP) を思い出ししてください。

参考: 📄 【オブジェクト指向2024 #9】インターフェース

image block
class Application:
    __repository: IUserRepository

    # コンストラクタで挿入
    def __self__(self, repository: IUserRepository):
        self.__repository = repository

    def createUser(self, user: User) -> void:
        if isAcceptable(user):
            self.__repository.save(user)
        else:
            raise Error("User is not acceptable for this service.")

    def isAcceptable(user: User) -> bool:
        if user.sex == "man" and user.age.value >= 18 or user.sex == "woman" and user.age.value >= 16:
            if user.income.value >= 5:
                return true
        return false

from abc import ABC, abstractmethod

# インターフェース
class IUserRepository(ABC):
    # save()メソッドがあることだけを宣言
    @abstractmethod
    def save(self, user: User):
        raise NotImplementedError()

# インターフェースを実装していることを宣言
class UserRepository(IUserRepository):
    # 適当なDBクライアントを想定
    __dbClient: DBClient
  
    def __init__(self) -> void:
        self.__dbClient = DBClient(...)

    def save(self, user: User) -> void:
        self.__dbClient.exec('INSERT INTO user VALUES(?, ?, ?, ?)',
          (user.name, user.sex, user.age.value, user.income.value))

ポイントは3つ。

  • IUserRepository インターフェースを用意して、インターフェースを実装する形でUserRepositoryクラスを作る
  • Applicationクラスは IUserRepository を使うようにする
    • Applicationクラスは「save()メソッドを持つ何か」を呼び出すことになります
  • IUserRepository の実体はコンストラクタで外から渡す

こうすることで、Applicationクラスはsave()メソッドしか呼び出せなくなります。言い換えると インターフェースにのみ依存 することになります。

IUserReposiotryインターフェースを実装しているクラスなら何でも良いので、テスト時などでは適当なモックに差し替えて動かすことが可能です。

image block
class MockUserRepository:
    # DBの代わりの辞書型
    __mockDict: dict[str, User]
  
    def save(self, user: User) -> void:
        self.__mockDict[user.name] = user

このように、直接クラスを参照せずにインターフェースを参照することで、実体クラスは 入れ替え可能 になります。

これはテストの差し替えだけでなく、

  • DBクライアントライブラリの仕様が変わった
  • DBを変えなければいけなくなった

など、実装変更が必要になった場合、一部クラスの差し替えだけで済む(=影響範囲が限定される)ことを意味します。

依存性の注入(Dependency Injection)

インターフェースを導入したことで、実体クラスインスタンスを渡す必要が出ました。

これはクラス呼び出し側で行います。

# IUserRepotitoryインターフェースを満たすクラスを用意して
repository = UserRepository()
# 依存として渡す(=注入)
application = Application(repository)

application.createUser(...)

このように、依存するインスタンス(オブジェクト)を外から与えることを 依存性の注入(Dependency Injection = DI) といい、特にコンストラクタで与えることをコンストラクタインジェクションと呼びます。

DIは依存クラスが増えてくるとだんだん辛くなってくるので、これを自動的にやってくれるライブラリに任せることがあります。

このようなライブラリを DIコンテナ と呼びます。

まとめ

  • インターフェースを挟むことで、クラスを入れ替え可能になる