▶️

Angularの依存性の注入(DI)が分からない

2024/11/24に公開

エンジニアとして業務を始めてからAngularを中心に開発していたのですが、近頃LaravelでAPI開発していく中で依存性の注入(以降DI)への理解が深まったので備忘録。

※自分のようにフロントエンドを中心に開発しているDI初心者に向けた記事です。間違ってたら教えて下さい!

依存性の注入ってなに

DIについて検索してみると、「対象のクラスが依存しているクラスを外から注入する」的なことが出てきます。

、、、意味分からなくないですか??

そんなの当たり前やんって思った方はこの先読んでも時間の無駄になります。
ここでearly returnしてNetflexでも見ましょう。

依存しているクラスを外から注入する

さて、「対象のクラスが依存しているクラスを外から注入する」はコードで表すとこんな感じ。

<?php

class Parent {
    public function __construct(
        ChildA childA,
        ChildB childB,
        ChildC childC
    ) {}
}

これはコンストラクタインジェクションを行っており、Parentクラスの外部でインスタンス化された3つのChildクラスをconstructorの引数に渡しています。
また、constructorの引数の3つのchildクラスがないとParentクラスは成り立ちません。この状況はParentクラスは3つのchildクラスに依存していると言えそうです。

上記の2つを合わせたのが、「対象のクラスが依存しているクラスを外から注入する」ですね。
(LaravelではメソッドでもDIできる!)

DIすると「疎結合」になるとよく言われていますが、
疎結合になるメリットをAIに聞いたらこんな返事をくれました。

(要約) クラスAがDIを行わずにクラスBをインスタンス化すると、クラスAはクラスBの実装の詳細に依存してしまうことになる。そうすると、クラスBを変更した時にクラスAも変更しなければいけなくなる。

これはちょっと言い過ぎのように感じます。
たとえクラスBがDIされていても、メソッドの引数や返り値が変わったらクラスAでも変更は必要になるはずです。Angular開発でDIを使っていても他のクラスの影響を受けていないと感じることはあまりないと思います。

個人的には、DIは主に下記のメリットが大きそうだと思っています。

  • テストしやすくなる
  • 再利用しやすい

それぞれ少し考えてみましょう。

テストしやすくなる

DIするとクラスはinterfaceなどの抽象に依存することになります。そうすると、あるクラス(コンポーネントやサービス)のテストをしたい時に、依存しているクラスを別のテスト用のクラス/値(モック)に簡単に差し替えることができるようになります。

DIしている場合
ServiceA内でServiceBをインスタンス化しておらず、ServiceAはServiceBの抽象に依存しているだけです。よって、ServiceAの知らないところでServiceBの中身がすり替わっていてもServiceAは気づけません。そのおかげでテスト時にServiceBを簡単にモックしやすくなります。

@Injectable()
class ServiceA {
    // もちろんinjectメソッド使っても同様
    constructor(
        private readonly serviceB: ServiceB;
    )
}
// ServiceAのテスト準備
const setup = () => {
    TestBed.configureTestingModule({
        // ServiceBをとりあえず空のオブジェクトでモック化する
        providers: [
            { provide: ServiceB, useValue: {} }
        ]
    })
}

なお、ここでServiceAがServiceBの実体ではなく抽象に依存するようにしていることを依存性逆転の原則(DIP)と呼び、AngularのDIコンテナが秘密裏に行っています。
https://zenn.dev/yoshinani_dev/articles/c743a3d046fa78

DIしていない場合

@Injectable()
class ServiceA {
    constructor(
        private readonly serviceB = new ServiveB();
    ) {}
}

ServiceA内でServiceBを生成しており、ServiveAはServiveBの実体に依存している状態です。DIの時のように外からServiceBを別のクラスにすりかえるのは難しくなります。テスト時はまずServiceAを生成した後にServiceBをモックと差し替えるなど工夫する必要があります。

再利用しやすくなる

DIすると依存クラスのライフサイクル(生成から破棄)を管理しなくてよいので、共有集約の関係を作りやすくなります。結果として、クラスの再利用がしやすくなります。

  • 共有集約
    あるクラスが依存しているクラスは別のクラスにも共有されうる
  • 合成集約
    あるクラスが依存しているクラスのライフサイクルが完全に所有されている

https://christina04.hatenablog.com/entry/difference-between-association-aggregation-and-composition

共有集約
DIしているため、ParentAクラスが破棄されてもChildクラスは破棄されません。

<?php

class ParentA {
    private Child $child;

    public function __construct(
        Child $child
    ) {
        $this->child = $child;
    }
}

合成集約
DIしていないので、ParentBクラスが破棄されるとChildクラスも破棄されます。

<?php

class ParentB {
    private Child $child;

    public function __construct() {
        $this->child = new Child();
    }
}

シングルトン

共有集約のメリットとして、Childクラスをシングルトンにしやすい点が重要そうです。
シングルトンとはプログラム全体でインスタンスが1つだけ存在するようにするデザインパターンで、プログラムのどこからでも同じインスタンスを参照できるようになります。

AngularではDIコンテナが各クラスがシングルトンになるように調整してくれているようで、そのおかげで私たちはどこからでも同じインスタンスにアクセスすることができています。
(意図的に複数のインスタンスを生成することもできる)

もし下記のように2つのコンポーネントからDIしていないサービスを生成した場合、同じサービスであるはずなのに別の値が返ってくる、、!といったことが起こりうります。

@Component({...})
class ComponentA {
    private service;

    constructor() {
        // ComponentBとは別のインスタンスが生成される
        this.service = new Service();
    }

    // 見ているインスタンスが違うのでComponentBとは違う値が返ってくる
    getValue() {
        return this.service.getValue();
    }

    setValue() {
        this.service.setValue(1);
    }
}

@Component({...})
class ComponentB {
    private service;

    constructor() {
        // ComponentAとは別のインスタンスが生成される
        this.service = new Service();
    }

    // 見ているインスタンスが違うのでComponentAとは違う値が返ってくる
    getValue() {
        return this.service.getValue();
    }

    setValue() {
        this.service.setValue(2);
    }
}

export class Service {
    private value: number = 0;

    getValue() {
        return this.value;
    }

    setValue(newValue) {
        this.value = newValue;
    }
}

AngularのDI

DIのありがたみが分かってきたところで、ここからはAngularのDIコンテナの仕様をざっと確認していきます。
Angularに興味がないのにここまで読んでしまった方はここでreturnしてカフェラテでも飲みましょう。

さて、今さらで怒られそうですが、AngularのDIについてはすでに素晴らしい記事があります。
https://zenn.dev/lacolaco/books/angular-after-tutorial/viewer/dependency-injection

初心者の私がつらつらと説明する必要は全くといってないのですが、自分のアウトプットのために書いているのでよかったらお付き合いください。

ライフサイクルの管理

AngularのDIコンテナでは、特定のスコープでクラスがシングルトンになるようにライフサイクルの管理を行っています。

ライフサイクルは私が超ざっくり理解したところによるとこんな感じ。

  1. クラスの依存関係を解析する
  2. 必要なクラスがすでにインスタンス化されていればそれを使う、なければインスタンスを生成する

これをDIコンテナが裏側で繰り返していることにより、シングルトンを維持できているということですね。(本当に超ざっくり)

// TODO: もっと詳しく調べたい

@Injectable

Angular CLIでserviceを生成すると、class宣言の上に@Injectable()ってのが付いていると思います。@injectable()をつけるとAngularのDIコンテナに登録が可能になります。

つまり、@Injectable()はAngularのDIコンテナがこのクラスはDI可能なのかどうかを判断する目印みたいなものです。

目印とは書きましたがこれだけではDIすることはできません。DIできる範囲も一緒に決めてあげないといけません。範囲の決め方は大きく2通りあります。

providedIn: 'root'

@Injectableの中にprovidedIn: 'root'と書いてあげると、文字通りアプリケーション全体で対象クラスがDIできるようになります。

@Injectable({
  providedIn: 'root'
})

providedIn: 'root'にしていると、Angularが自動で使用されていないクラスを削除(ツリーシェーキング)してくれるようです。
CLIでサービスを生成するとデフォルトでこの形になっているし、Angularもこれを推奨しています。

componentのproviders

componentもDIコンテナを持っていて、providersにDIしたいクラスを書くとそのコンポーネント内でのみDIが可能になります。もしHogeComponentのインスタンスが増えれば、HogeServiceもその分だけ生成されることになります。

@Component({
  selector: 'app-hoge',
  template: '',
  providers: [HogeService]
})
class HogeComponent {
    private readonly hogeService = inject(HogeService);
}

@Injectable()
export class HogeServive {}

こちらはツリーシェーキングは働かないようで、providersに書かれたクラスは使用されていなくても存在し続けてしまいます。
特別な理由がない限りAngularが推奨しているprovidedIn: 'root'を使っておけば良さそうです。

詳しくは公式ドキュメントの下記ページまで。
https://angular.jp/guide/di/dependency-injection

providers

componentでDIをする時に出てきたprovidersの話です。
Angular公式ドキュメントだと下記ページあたり。
https://angular.jp/guide/di/dependency-injection-providers

providers内ではDIするときのtokenとなる値と、その実態となるものを登録します。

一番シンプルなのは、tokenと実体が同一の場合です。

// HogeServiceクラスがほしいと言われたらそのままHogeServiceを返す
providers: [
    HogeService
]

他にも、別のクラスや値とすり替えることも可能です。
これを使ってテスト書く時にモック化しやすくなります。

// クラスプロパイダー

// HogeServiceクラスがほしいと言われたら代わりにMockHogeServiceクラスを返す
providers: [
    { provide: HogeService, useClass: MockHogeService }
]
// 値プロパイダー

// HogeServiceクラスがほしいと言われたら代わりになんらかの値を返す
providers: [
    { provide: HogeService, useValue: { getHello: () => 'hello' }}
]

providedIn: 'root'されているクラスは自動でrootレベルのprovidersに登録されているようなので、改めて明示的に書く必要はありません。

ただし、テスト書く時などに意図的にクラスのすり替えをしたい時は、明示的にprovidersに書くことでオーバーライドすることができます。これによってモックと差し替えてテストすることができるんですね。なるほど。

※JavaScriptにinterfaceはないので、Larvelのサービスプロバイダーと違ってAngularのDIのキーはinterfaceではなくあくまでもtokenです。このtokenの仕組みについてはここでは割愛しますが、先に載せた記事でかなり丁寧に解説されています。

ちなみに、Factoryプロパイダーなど他にも種類があるようです。
詳しくは公式ドキュメントをあたってみてください。

さいごに

ここまでの内容をまとめるとこんな感じでしょうか。

  • DIするとクラスが依存先クラスを生成しなくて良いのでクラスをすり替えやすい
  • DIするとシングルトンにしやすいのでクラスを再利用しやすくなる
  • @Injectable()デコレータをつけることでAngularのDIコンテナに登録が可能
  • AngularではrootレベルまたはcomponentのprovidersでDIできるクラスを管理している
  • AngularではDIコンテナがシングルトンになるように調整している

さて、私はAngularからプログラミングを始めているのですが、ずっと依存性の注入はよく分かっておらず 他のクラスを使いたい時に書くやつ 程度の理解で使っていました。
最近Laravelを実装している中でDIを意識する場面が増え、やっとAngularが裏側でやっていることへの理解が進みました。

フレームワークは便利だけど、使い方だけ覚えても応用できなさそうだし怖くもありますね。
教えてくれてありがとうAngular!

Discussion