Open6

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

白湯白湯

そもそも DI(Dependency Injection) とはなにか

あるオブジェクト(A)が他のオブジェクト(B)を利用するときに、外部から必要なオブジェクトを受け取るようにする設計のこと。
これにより密結合を疎結合にできる。

class A
{
    // コンストラクタインジェクション
    public funtion __construct(private B $b)
    {
    }

    // メソッドインジェクション
    public function call(B $b)
    {
        // B を使って処理をする
    }
}

class B
{
    // B が行うべき処理
}
白湯白湯

密結合の何がダメなのか

例を挙げならがら、密結合のデメリットを見ていく。

受け取ったデータを保存する処理を作るとする。
一旦 AWS の S3 に保存するコードを書いてみる。(AWS SDK のコードは適当)

class UseCase
{
    public function handle($data)
    {
        $s3 = new S3Client();

        // $data をライブラリに合わせて加工する

        $s3->putObject($data);
    }
}

この UseCase クラス内で S3Client クラスをインスタンス化すると以下の問題が出てくる。

  1. 依存しているオブジェクトに変更があった場合、利用している側も変更する可能性が高くなる
  2. 保存先を変更するコストが高くなる
  3. テストが難しくなる

依存しているオブジェクトに変更があった場合、利用している側も変更する可能性が高くなる

この S3Client クラスは OSS であり、更新について利用者側が完全にコントロールすることはできない。
そのため、オブジェクトやメソッドに変更があった場合 UseCase 側も変更が必要になってしまう。
この例において言えばこのメソッドに破壊的な変更がある可能性は低いと思うが、少なからずこちら側にコントロールする権限がない以上は変更がある前提で設計したほうが良いだろう。

また、仮に依存オブジェクトを自分(たち)で作っていたとしても変更が全くないとも限らない。
変更に備えておくことは基本的には悪いことではないので意識しておくと良いと思う。

保存先を変更するコストが高くなる

例えば S3 から Google の Cloud Storage に変更する場合もこのクラスに変更を加える必要が出てくる。

class UseCase
{
    public function handle($data)
    {
        // 保存までの処理が変わる可能性がある
        $storage = new StorageClient();

        $bucket = $storage->bucket('my_bucket');

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

また、次の問題にもつながるがテストやローカル環境のときだけファイルに保存するようにしたいといった要求に対応するのも難しくなってしまう。

テストが難しくなる

個人的にはこれが一番大きな問題と感じる。
依存オブジェクトを直接利用してしまうとをしてしまうとそのクラスのモックを作ることが難しくなってしまう。
結果としてテストを行うことが難しくなってしまう。

今回の例ではテストを行う際に必ず AWS or GCP へ接続ができることが前提になっている。
もし接続できないとなるとテストができないことになる。
結合テストを行う環境では接続できることはあるかもしれないが、開発者個人のローカル環境では必ずしも準備されているわけではない。
AWS の場合、LocalStack(ローカル上でエミュレートするツール)があるがほかのクラウドサービスも同様にこのようなツールがあるとは限らない。(なお LocalStack は有料プランにしか使えないサービスがある)

class UseCase
{
    private StorageClient $storage;

    public function __construct()
    {
        $this->storage = new StorageClient();
    }

    public function handle($data)
    {
        $bucket = $this->storage->bucket('my_bucket');

        // $data をライブラリに合わせて加工する

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

このようなコードにすればテストコード上で Reflection を使うことで StorageClient をモックすることはできなくはないがテスト対象のクラスの構造を無理やり書き換えてしまうとテスト自体が曖昧になってしまう。

白湯白湯

そこで DI の出番

DI にすることでこれらの問題を解決できる。
以下のようなコードにすることでテスト実行時やローカル環境の場合のみ、モックを渡すことで UseCase の実行をできるようになる。

class UseCase
{
    public function __construct(private StorageClient $storage)
    {
    }

    public function handle($data)
    {
        $bucket = $this->storage->bucket('my_bucket');

        // $data をライブラリに合わせて加工する

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

class UseCaseTest
{
    #[Test]
    public function testUseCase()
    {
        // モックの設定
        $mockStorageClient = Mockery::mock(StorageClient::class);

        $data = 'テストデータ';

        (new UseCase($mockStorageClient))->handle($data);

        // アサーション
    }
}

しかしこのままではライブラリの変更(アップデートや置き換え)時に UseCase を変更する問題が残ってしまう。
そのため、以下のようにインターフェースを受け取るようにする。
こうすることでライブラリの変更時に UseCase 側を変更しなければいけなくなる可能性は減る。

ちなみにこのようにインターフェースを利用することで「保存することは決まっているけど、どこに保存するかはまだ決まっていないから UseCase の開発もできない」や「例外が起きた時のフロントエンド側の画面を開発したい」といった問題や要望も解決できる。
これに関しては今回の話と少しそれるのでこれ以上は言及しない。

interface StorageInterface
{
    public function save($data);
}

class CloudStorageClient implements StorageInterface
{
    public function __construct(private StorageClient $storage)
    {
    }

    public function save($data)
    {
        // $data をライブラリに合わせて加工する
        $bucket = $this->storage->bucket($data['bucket']);

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

class UseCase
{
    public function __construct(private StorageInterface $storage)
    {
    }

    public function handle($data)
    {
        try {
            $this->storage->save($data);
        } catch (Exception $e) {
            // 必ず例外を投げる実装をすればここの処理を確認できる
        }      
    }
}
白湯白湯

DI を楽に実現させるには

依存オブジェクトを外部から渡すことで DI は可能になるが、その依存オブジェクトを都度準備するのは面倒だ。
特に依存関係が増えれば増えるほどそれを準備するのも手間になる。

interface StorageInterface
{
    public function save($data);
}

class CloudStorageClient implements StorageInterface
{
    public function __construct(private StorageClient $storage)
    {
    }

    public function save($data)
    {
        // 保存処理
    }
}

class Writer
{
    public function write($message)
    {
        // 書き込み処理
    }
}

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

class Logger interface LoggerInterface
{
    public function __construct(private Writer $writer)
    {
    }

    public function info($message)
    {
        $this->writer->write($message);
    }
}

class UseCase
{
    public function __construct(
        private StorageInterface $storage,
        private LoggerInterface $logger,
    ) {
    }

    public function handle($data)
    {
        $this->storage->save($data);

        $this->logger->info('保存しました');
    }
}
// UseCase を使うには以下の依存を解決する必要がある
new UseCase(
  new CloudStorageClient(new StorageClient()),
  new Logger(new Writer()),
);

この問題を解決するために DI コンテナという仕組みがある。
DI コンテナは簡単に言えば各インスタンスの作り方を登録しておき、必要になったら取り出せるというものだ。

今回の説明では Laravel の DI コンテナを前提に話を進める。
Laravel では Service Container と呼ぶらしいがこのスクラップでは DI コンテナで統一する。
ソースコード

// StorageInterface を要求された場合、CloudStorageClient を返す
$container->bind(StorageInterface::class, function ($app) {
    return new CloudStorageClient($app->make(StorageClient::class);
});

// LoggerInterface を要求された場合、Logger を返す
$container->bind(LoggerInterface::class, function ($app) {
    return new Logger($app->make(Writer::class));
});

事前に DI コンテナに登録しておくことで UseCase の生成が簡潔になる。

$useCase = $container->make(UseCase::class);

また DI コンテナには Autowiring という機能がある。
これは解決したいオブジェクトが明確な場合、事前の設定なしにインスタンスの生成ができるというものである。
この例では StorageClient/Writer/UseCase が当てはまる。

$container->bind(StorageInterface::class, function ($app) {
    return new CloudStorageClient($app->make(StorageClient::class)); // $app->make(StorageClient::class) が autowiring で解決される
});

$container->bind(LoggerInterface::class, function ($app) {
    return new Logger($app->make(Writer::class)); // $app->make(Writer::class) が autowiring で解決される
});

$useCase = $container->make(UseCase::class); // $container->make(UseCase::class) が autowiring で解決される

基本的にはインターフェースや抽象クラスを依存オブジェクトとして定義しない場合は DI コンテナの設定は不要になる。

Laravel ではこの DI コンテナをフレームワーク側が利用して、オブジェクトの依存関係を解決している。

白湯白湯

ちょっと寄り道

DI のように密結合を緩和させる方法として Service Locator というものがある。
これは利用側のオブジェクトが DI コンテナを所有して必要なオブジェクトをコンテナから取得するようなものである。

class UseCase
{
    public function handle($data)
    {
        $storage = ServiceLocator::resolve(Storage::class);

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

ただし、このパターンは

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

といった問題点がある。

個人的には基本的に DI を選択しても問題ないと考えている。

白湯白湯

まとめ

DI は依存オブジェクトを外部から注入することで密結合を解消する設計のこと。
テストのしやすさや柔軟性の向上といったメリットがある。
ただし依存関係を開発者がすべて準備するのも大変。
その問題を解決するために DI コンテナがある。