🏗️

DI(Dependency Injection)は何がうれしいのか

2025/01/18に公開

DI(Dependency Injection) とはなにか

あるオブジェクトが他のオブジェクトを利用するときに、外部から依存するオブジェクトを注入する設計のことです。
これにより以下のようなメリットを得られます。

  • 密結合から疎結合にできる: 依存関係が明確になる
  • テストが容易になる: テストダブルを用いたテストが可能
  • 柔軟性が向上する: DIP(Dependency Inversion Principle)を適用することでさらに柔軟な設計が可能
class Service
{
    public function handle()
    {
        // 処理
    }
}

class A
{
    // 引数(外部)で Service を渡す
    public function call(Service $service)
    {
        $service->handle();
    }
}

密結合のよくないところ

密結合による問題点を具体例で見ていきましょう。

例: AWS の S3 を利用したファイルの保存処理

密結合の状態で書くと以下のようになります。

class Storage
{
    public function store($data)
    {
        // データを一時ファイルに保管
        file_put_contents($fileName = '/tmp/file', $data);

        // S3Client を直接利用
        $s3Client = new S3Client(['region' => 'us-west-2']);

        $s3Client->putObject([
            'Bucket' => 'filestore',
            'Key' => $fileName,
            'SourceFile' => $fileName,
        ]);

        // ローカルのファイルを削除
        unlink($fileName);
    }
}

この設計では以下の問題が出てきます。

1. 依存しているオブジェクトの変更に弱い

S3Client クラスを管理しているのは AWS であり、更新について利用者側がコントロールすることはできません。
そのため、依存しているオブジェクトに変更が加えられた場合、それを利用しているオブジェクト側も変更を余儀なくされる可能性が高まります。
仮に依存しているオブジェクトを自分(たち)で管理している場合であっても、必ずしも利用している側に影響がないように変更が入るというわけでもないため、変更がある前提で設計することはとても大事です。

2. 柔軟性がない

「S3 に保存するつもりだったが、Google Cloud Storage に変更する」といったことが起こりうるかもしれません。
そういった変更に対応するためには Storage クラスに変更を加える必要が出てきます。

また次の問題にもつながりますが、テストやローカル環境の時だけ保存先を変更したいといった要求に答えることも難しくなってしまいます。

class Storage
{
    public function store($data)
    {
        // StorageClient を使うように変更を加えなければならない
        $storageClient = new StorageClient();

        // StorageClient に合わせた実装に変更
        $bucket = $storageClient->bucket('filestore');
        $bucket->upload($data);
    }
}

3. テストが難しい

個人的にはこれがとても大きな問題と捉えています。
依存しているオブジェクトを直接利用することで、テストダブルを利用したテストが難しくなります。
結果としてテストが実施できず、品質保証やリファクタリング時の挙動担保ができなくなります。

例のコードではテスト時に AWS や GCP への接続ができる前提となっています。
ですが、開発の現場では「利用することは決まっているがまだアクセスができない状態」ということが多々あると思います。
この場合、テストができないだけでなく、開発自体が止まってしまうことになりリソースを無駄にしてしまいます。

DI と DIP(Dependency Inversion Principle) を適用した改善例

DI にすることで先に挙げた問題を解決できます。
S3ClientStorageClient をコンストラクタで受け取るようにすることで、テスト時にモックを渡せるようになります。

class Storage
{
    // StorageClient を受け取る
    public function __construct(private StorageClient $storage)
    {
    }

    public function store($data)
    {
        $bucket = $this->storageClient->bucket('filestore');

        $bucket->upload($data);
    }
}

class StorageTest
{
    #[Test]
    public function ファイル保存のテスト()
    {
        // モックの挙動を定義して
        $mockBucket = Mockery::mock(Bucket::class);
        $mockBucket->shouldReceive('upload')
          ->once();

        $mockClient = Mockery::mock(StorageClient::class);
        $mockClient->shouldReceive('bucket')
          ->with('filestore')
          ->andReturn($mockBucket)
          ->once();

        $data = '{"id":1,"name":"hoge"}';

        // 対象クラスに渡す
        new Storage($mockClient)->store($data);
    }
}

ですが、このままでは問題 1 と 2 が残ったままです。
これらの問題は DIP を適用することで解決できます。

以下のようなコードにすることで、S3ClientCloudStorage の変更が Storage に与える影響を極力抑えてくれます。
また、「ローカル環境で動かすときはローカルにファイルを保存したい」や「処理で例外が起きたとのフロントエンドの画面を開発したい」といった特定の条件下での要望に答えやすくなります。

class ClientInterface
{
    public function upload($data);
}

// S3 を利用したクラス
class AwsS3Client implements ClientInterface
{
    public function __construct(private S3Client $client)
    {
    }

    public function upload($data)
    {
        // データを一時ファイルに保管
        file_put_contents($fileName = '/tmp/file', $data);

        $this->client->putObject([
            'Bucket' => 'filestore',
            'Key' => $fileName,
            'SourceFile' => $fileName,
        ]);

        // ローカルのファイルを削除
        unlink($fileName);
    }
}

// Google Cloud Storage を利用したクラス
class CloudStorageClient implements ClientInterface
{
    public function __construct(private StorageClient $client)
    {
    }

    public function upload($data)
    {
        $bucket = $this->client->bucket('filestore');

        $bucket->upload($data);
    }
}

// ローカル環境用のクラス
class LocalStorageClient implements ClientInterface
{
    public function upload($data)
    {
        // ローカルのファイルに保存
        file_put_contents('/tmp/file', $data);
    }
}

class Storage
{
    public function __construct(private ClientInterface $client)
    {
    }

    public function handle($data)
    {
        // Storage クラスはインターフェースに定義しているメソッドを呼ぶだけ
        $this->client->upload($data);
    }
}

DI を楽に実現させるには

依存しているオブジェクトを外部から渡すことのメリットはわかりました。
ですが、いちいちそれを準備するのは面倒ですよね。
特に依存しているオブジェクトがさらに何かしらのオブジェクトに依存していて、それがまたなにかに・・・という場合は特に面倒ですね。

interface OutputInterface
{
    public function output($message);
}

class StandardOutput implements OutputInterface
{
    public function output($message)
    {
        echo $message;
    }
}

interface LoggerInterface
{
    public function error($message);
}

class Logger implements LoggerInterface
{
    public function __construct(private OutputInterface $output)
    {
    }

    public function error($message)
    {
        $this->output->output('[INFO]' . $message);
    }
}

class Service
{
    public function __construct(
        private Storage $storage,
        private LoggerInterface $logger,
    ) {
    }

    public function handle()
    {
        try {
            $data = '{"id":2,"name":"fuga"}';
            $this->storage->handle($data);
        } catch (Exception $e) {
            $this->logger->error($e->getMessage());
        }
    }
}

自分で依存関係を解決するとなるとどこかで以下のようなコードを書く必要がでてきます。

new Service(
    new Storage(new LocalStorageClient()),
    new Logger(new StandardOutput()),
);

この問題を解決するために DI コンテナというものがあります。
これはクラス間の依存関係を登録すると自動で解決してくれるというものです。

例では Laravel の DI コンテナを利用します。

// OutputInterface が要求されたら StandardOutput を返す
$container->bind(OutputInterface::class, StandardOutput::class);

// LoggerInterface が要求されたら Logger を返す
$container->bind(LoggerInterface::class, Logger::class);

// ClientInterface が要求されたら LocalStorageClient を返す
$container->bind(ClientInterface::class, LocalStorageClient::class);

このように登録しておくことで、指定したインターフェースを受け取るときの具体的なクラスを指定できます。

また、DI コンテナには Autowiring という機能があります。
これは依存関係を解決したいオブジェクトが明確な場合、事前の設定なしにインスタンスの生成まで行ってくれるというものです。

class Controller
{
    // この Service は DI コンテナに登録しなくても自動的に解決してくれる
    public function store(Service $service)
    {
        $service->handle();
    }
}

ちょっと寄り道

DI と似た概念として Service Locator パターンというものがあります。
これは DI と同様にクラス間の結合度合いを緩和するものですが、主にアプリケーションからの問い合わせに対してありとあらゆるものを取り出せるようになっているものを指します。

class Service
{
    public function handle()
    {
        $data = '{"id":3,"name":"piyo"}';

        // Service の内部で DI コンテナを利用して依存関係を解決している
        $storage = app()->make(Storage::class);

        $storage->store($data);
    }
}

Service Locator パターンは

  • 依存関係が分かりにくくなる
  • 利用側が Service Locator に依存してしまうので、本来不要であったはずの依存関係が増える
  • テストが難しくなる

といった問題点があり、アンチパターンとして紹介されることが多いです。
基本的には DI にしましょう。

例外パターン

基本的にアンチパターンとして紹介されることの多い Service Locator パターンですが、実行時に必要な情報を提供するための機能として利用される場合は問題にならないこともあります。(これは賛否ありそうです)[1]

class Factory
{
    // DI コンテナを利用する
    public function __construct(private ContainerInterface $container)
    {
    }

    public function create($type)
    {
        // 入力値に応じて関連するクラスを生成する場合など、特定の場面においては
        // Factory などが DI コンテナを保持し依存関係を解決することは許される
        return match ($type) {
            'hoge' => [$this->container->make(HogeInput::class), $this->container->make(HogeOutput::class)];
            'fuga' => [$this->container->make(FugaInput::class), $this->container->make(FugaOutput::class)];
        }
    }
}

まとめ

  • DI: 依存するオブジェクトを外部から注入する設計
    • 密結合の解消やテスト容易性が向上する
  • DIP と組み合わせることで変更に強くなる
  • DI コンテナを利用して効率的に依存関係を管理できる
脚注
  1. Service Locator: roles vs. mechanics ↩︎

GitHubで編集を提案

Discussion