はじめに
Angular技術要素解説シリーズ、第4弾です。
今回は、Angularの設計思想の根幹をなすDI(Dependency Injection、依存性注入)の仕組みを、以前解説したSingletonパターンとも絡めながら解説します。
「なぜAngularではサービスクラスをコンストラクタに書くだけで使えるのか」が分かるようになります。
この記事で扱う技術要素
- DI(Dependency Injection)の基本的な考え方
- Service(サービス)
-
@Injectableデコレータ providedIn-
inject()関数
なぜDI(依存性注入)が必要なのか
まず、DIを使わない場合のコードを見てみます。
// DIを使わない場合の例
class UserService {
getUserName(): string {
return 'はなこ';
}
}
class GreetingComponent {
private userService: UserService;
constructor() {
this.userService = new UserService(); // 自分でnewして生成している
}
greet() {
return `こんにちは、${this.userService.getUserName()}さん`;
}
}
一見問題なさそうですが、この書き方には以下のような課題があります。
-
GreetingComponentはUserServiceという具体的なクラスに強く依存している - テスト時に「本物のUserServiceの代わりに、テスト用のダミーを使いたい」と思っても、
new UserService()の部分を書き換えないと差し替えられない -
UserServiceを複数の場所で使う場合、それぞれが個別にインスタンスを生成してしまい、状態を共有できない(共有したければ、自前でシングルトンの仕組みを実装する必要がある)
DI(依存性注入)は、「自分が必要とするオブジェクトを、自分で生成するのではなく、外部から受け取る」という設計手法です。
Angularはこの仕組みをフレームワークレベルで強力にサポートしています。
Service(サービス)を定義する
Angularでは、UIロジックとは別に、データ取得やビジネスロジックを担うクラスをサービスとして切り出すのが基本です。
// user.service.ts
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root', // アプリ全体でこのサービスを1つのインスタンス(シングルトン)として提供する
})
export class UserService {
private userName = 'はなこ';
getUserName(): string {
return this.userName;
}
updateUserName(newName: string): void {
this.userName = newName;
}
}
@Injectableデコレータを付けたクラスは、AngularのDIコンテナに登録され、他のクラスから注入できるようになります。
providedIn: 'root'は、「アプリケーション全体でこのサービスを1つだけ生成し、使い回す(シングルトンとして扱う)」という意味です。
以前解説したSingletonパターンの記事で、privateコンストラクタやgetInstance()を自前で書く実装を紹介しましたが、Angularのサービスではそうした実装を一切書かずに、providedIn: 'root'と書くだけで同等のことが実現できます。
コンストラクタでサービスを受け取る(従来方式)
import { Component } from '@angular/core';
import { UserService } from './user.service';
@Component({
selector: 'app-greeting',
standalone: true,
template: `<p>{{ greeting }}</p>`,
})
export class GreetingComponent {
greeting: string;
// コンストラクタの引数に型を書くだけで、AngularのDIコンテナが自動的にインスタンスを渡してくれる
constructor(private userService: UserService) {
this.greeting = `こんにちは、${this.userService.getUserName()}さん`;
}
}
GreetingComponentは、UserServiceをnewしていません。
「UserService型のオブジェクトが欲しい」とコンストラクタの引数に書くだけで、Angular側が適切なインスタンスを見つけて渡してくれます。
これにより、GreetingComponentはUserServiceの具体的な生成方法を一切知らずに済み、テスト時にはテスト用のダミー実装に差し替えることも容易になります。
inject()関数を使う書き方(現在推奨)
近年のAngularでは、コンストラクタ引数の代わりにinject()関数を使う書き方が広まっています。
import { Component, inject } from '@angular/core';
import { UserService } from './user.service';
@Component({
selector: 'app-greeting',
standalone: true,
template: `<p>{{ greeting }}</p>`,
})
export class GreetingComponent {
// inject()関数でその場でサービスを取得する
private userService = inject(UserService);
greeting = `こんにちは、${this.userService.getUserName()}さん`;
}
inject()はコンストラクタの外でも呼び出せるため、継承関係が複雑な場合や、複数の基底クラスをまたいでサービスを使い回したい場合に、コンストラクタ方式より柔軟に書けるという利点があります。
新規プロジェクトでは、こちらの書き方が推奨されることが増えています。
サービス同士を連携させる例
サービスは、他のサービスに依存することもできます。
// logger.service.ts
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class LoggerService {
log(message: string): void {
console.log(`[LOG ${new Date().toISOString()}] ${message}`);
}
}
// user.service.ts
import { Injectable, inject } from '@angular/core';
import { LoggerService } from './logger.service';
@Injectable({ providedIn: 'root' })
export class UserService {
private logger = inject(LoggerService); // サービスの中で、別のサービスを注入する
private userName = 'はなこ';
updateUserName(newName: string): void {
this.logger.log(`ユーザー名を "${this.userName}" から "${newName}" に変更`);
this.userName = newName;
}
}
このように、DIコンテナはサービス同士の依存関係も自動的に解決してくれます。
UserServiceを使う側は、その裏でLoggerServiceが使われていることすら意識する必要がありません。
providedInのスコープ
providedInには'root'以外の指定方法もあり、サービスのインスタンスをどの範囲で共有するかを制御できます。
| 指定方法 | 意味 |
|---|---|
providedIn: 'root' |
アプリ全体で1つのインスタンスを共有する(最も一般的) |
providedIn: 'platform' |
複数のAngularアプリ間でインスタンスを共有する(特殊なケース向け) |
コンポーネントのproviders配列で指定 |
そのコンポーネント(とその子コンポーネント)の中だけで有効な、専用のインスタンスを作る |
コンポーネント単位で提供する例も見てみます。
@Component({
selector: 'app-shopping-cart',
standalone: true,
providers: [CartService], // このコンポーネント配下でだけ有効な、専用のCartServiceインスタンス
template: `...`,
})
export class ShoppingCartComponent {
private cartService = inject(CartService);
}
例えば「複数のタブでそれぞれ独立したフォームの入力状態を管理したい」場合など、アプリ全体で共有したくない、コンポーネント固有の状態を持つサービスは、providedIn: 'root'ではなくコンポーネントのprovidersで提供するのが適切です。
テストのしやすさという利点
DIの最大の利点の1つが、テストのしやすさです。
// user.service.spec.ts (テストコードの例)
import { TestBed } from '@angular/core/testing';
import { GreetingComponent } from './greeting.component';
import { UserService } from './user.service';
describe('GreetingComponent', () => {
it('テスト用のダミーUserServiceを注入できる', () => {
const fakeUserService = {
getUserName: () => 'テストユーザー', // 本物の代わりに、固定値を返す簡易実装
};
TestBed.configureTestingModule({
imports: [GreetingComponent],
providers: [
{ provide: UserService, useValue: fakeUserService }, // 本物の代わりにダミーを注入する
],
});
const fixture = TestBed.createComponent(GreetingComponent);
const component = fixture.componentInstance;
expect(component.greeting).toContain('テストユーザー');
});
});
GreetingComponentのコード自体は一切変更せず、テスト時だけUserServiceをダミー実装に差し替えられています。
これは、GreetingComponentがUserServiceというインターフェース(型)にだけ依存し、具体的な実装の詳細を知らないように設計されているために可能になっています。
テストの詳しい書き方は、後日のテスト編で改めて解説します。
まとめ
| 用語 | 役割 |
|---|---|
| DI(依存性注入) | 必要なオブジェクトを、自分で生成せず外部から受け取る設計手法 |
| Service | UIロジックと切り離した、データ取得・ビジネスロジックを担うクラス |
@Injectable |
クラスをDIコンテナに登録可能にするデコレータ |
providedIn: 'root' |
サービスをアプリ全体のシングルトンとして提供する設定 |
inject() |
コンストラクタ引数の代わりに、その場でサービスを取得する関数(現在推奨) |
次回は、複数の画面を切り替える仕組みであるルーティングについて解説します。
学習に使った書籍
本記事の内容整理にあたっても、以下の書籍を参考にしました。
- Angular 超入門 第2版 [ 掌田津耶乃 ]
- Angularアプリケーションプログラミング 開発の勘所と手順をしっかりマスター [ 山田祥寛 ]
- Angular Webアプリ開発スタートブック [ 大澤文孝 ]
※上記リンクはアフィリエイトリンクを含みます。