サービス
Tour of Heroesアプリを進化させていくにつれ、ヒーローのデータをアクセスするコンポーネントを増やすことになります。
同じコードを何度もコピペするのではなく、再利用可能なデータサービスを作り、それを、必要としているコンポーネントに注入していくことにします。独立したサービスを使うことで各コンポーネントを簡素にでき、ビューの構築に専念することができます。またモックサービスを使ってコンポーネントの単体テストしやすくなります。
データサービスは常に非同期であるため、最終的にはPromiseベースのデータサービスを構築します。
ヒーローサービスの作成にあたって
ヒーローサービスを利用する側は、ヒーロを様々な場所で、様々な方法で表示します。 ユーザは現在、リストからヒーローを選ぶことができます。次は、デキるヒーローを抜粋したダッシュボードを追加し、 また、ヒーローの詳細を編集するためのビューを作成します。3つのビューはそれぞれ、ヒーローのデータを必要とします。
現時点では、AppComponent
は表示に必要なヒーロのモックデータを定義しています。しかし、ヒーローの定義はコンポーネントの本来の仕事ではありません。またヒーローのデータを他のコンポーネントやビューと簡単に共有することができないという欠点があります。本章では、ヒーローデータを取得する実装をサービスに移行し、そのサービスを、ヒーローデータを必要とする全てのコンポーネントに共有します。
ヒーローサービスの作成
app
フォルダにhero.service.ts
というファイルを作成してください。
サービスファイルの命名規則は、小文字のサービス名に.service.ts
をつける、というものです。複数の単語が入ったサービスの場合、lowre-dash case
を使います。例えば、SpecialSuperHeroService
のファイル名はspecial-super-hero.service.ts
になります。
クラス名をHeroService
とし、他のクラスがインポートできるようにexport
します:
import { Injectable } from '@angular/core'; @Injectable() export class HeroService { }
注入可能なサービス
上記コードで、AngularのInjectable関数をインポートしその関数を@Injectable()
デコレータとして適用したことに気づいたでしょうか。
丸括弧()を忘れないでください。省略すると、特定が難しいエラーになります(笑)。
@Injectable()
デコレータはサービスに関するメタデータを発行するようにTypeScriptに指示します。メタデータはAngularが本サービスに他の依存性を注入する必要がある可能性を指定しています。
現時点では、HeroService
は他の依存を持ちません。しかし最初から@Injectable()
デコレータを使うことにより、一貫性と将来への担保を実現できます。
ヒーローデータの取得
スタブメソッドのgetHeroes
を追加します:
@Injectable() export class HeroService { getHeroes(): void {} // スタブ }
HeroService
は、色々な場所からヒーローデータを取得する可能性があります。例えば、ウェブサービス、ローカルストレージ、モックデータなどです。データアクセスをコンポーネントから隔離することにより、ヒーローデータを必要とするコンポーネントを変更することなく実装を変更できます。
モックのヒーローデータの移動
app.component.ts
からHeRoEs配列を削除し、app
ディレクトリ内にmock-heroes.ts
というファイルを作成してペーストします。また、import {Hero} ...
文をコピーします。なぜならば、ヒーローの配列はHero
クラスを利用するためです。
// mock-heroes.ts import { Hero } from './hero'; export const HEROES: Hero[] = [ { id: 11, name: 'Mr. Nice' }, { id: 12, name: 'Narco' }, { id: 13, name: 'Bombasto' }, { id: 14, name: 'Celeritas' }, { id: 15, name: 'Magneta' }, { id: 16, name: 'RubberMan' }, { id: 17, name: 'Dynama' }, { id: 18, name: 'Dr IQ' }, { id: 19, name: 'Magma' }, { id: 20, name: 'Tornado' } ];
HeRoEs
定数は、他の場所、例えばHeroService
からインポートできるようにexport
します。
app.component.ts
には、HeRoEs
配列を削除した場所に初期化しないheroes
属性を追加してください:
heroes: Hero[];
モックのヒーローデータの返却
HeroService
では、モックのHeRoEs
をインポートしgetHeroes()
メソッドから返却します。HeroService
はこうなります:
// hero.service.ts import { Injectable } from '@angular/core'; import { Hero } from './hero'; import { HeRoEs } from './mock-heroes'; @Injectable() export class HeroService { getHeroes(): Hero[] { return HEROES; } }
ヒーローサービスのインポート
これで、AppComponent
を始めとするコンポーネントでHeroService
を利用することができます。
プログラム内で参照できるよう、HeroService
をインポートします。
// app.component.ts import { HeroService } from './hero.service';
ヒーローサービスを利用する際、newは使いません
AppComponent
はHeroService
のコンクリートインスタンスをどうやって取得すべきでしょうか?
選択肢の1つとして、次のようにHeroService
のインスタンスを作成できます:
heroService = new HeroService(); // 実際にはこのようにしないでください
これは次の理由から不適切です:
- コンポーネントが
HeroService
の作り方を知っている必要があるHeroService
のコンストラクタを変更した場合、サービスをnewした場所を全て探し出し、更新しなければならない。複数箇所の更新はミスになりやすく、テストの負担増になります。 - newをする度に新しいサービスを作成します。しかしサービスがヒーローのデータをキャッシュし、他のコンポーネントとキャッシュを共有していたらどうでしょうか。それはできません
AppComponent
が`HeroService`の特定の実装に結合してしまうと、異なる状況に対応するための実装の変更、例えば、オフラインでの動作やテスト用に異なるモックデータの利用が、難しくなります。
ヒーローサービスの注入
newの代わりに、次の2行を追加します。
コンストラクタの追加
constructor(private heroService: HeroService) { }
コンストラクタ自体は何もしません。引数のheroService
は同時並行でprivateなheroService
属性を定義し、HeroService
の注入場所として明記します。
これで、Angularは、AppComponent
を作成する際、HeroService
のインスタンスを提供すべきことを知ることができます。
しかし、インジェクタはHeroService
の作り方がわかりません。現時点でプログラムを実行するとこのエラーが出るはずです:
EXCEPTION: No provider for HeroService! (AppComponent -> HeroService)
インジェクタにHeroService
の作り方を教えるため、次のproviders
配列を@Component
コールの、コンポーネントメタデータの最下部に追加します。
// app.component.ts providers: [HeroService]
providers
配列はAppComponent
作成時に新たなHeroService
のインスタンスを作るようAngularに対して指示します。AppComponent
、および、その子コンポーネントはそのサービスを使ってヒーローデータを取得できます。
AppComponentの中のgetHeroes()
サービスは、heroService
というprivate変数の中にいます。
サービスを呼ぶと、1行で、データを取得できます:
this.heroes = this.heroService.getHeroes();
1行だけのメソッドを作る必要はないですが、ここでは作りましょう:
getHeroes(): void { this.heroes = this.heroService.getHeroes(); }
ngOninitライフサイクルフック
AppComponent
はこれで、ヒーローデータを取得して表示できるはずです。
ここでコンストラクタ内でgetHeroes()
メソッドを呼ぶ誘惑にかられるかもしれませんが、
コンストラクタは複雑な処理を含むべきではありません。特に、データアクセスメソッドのようにサーバを呼ぶ
コンストラクタならなおさらです。コンストラクタは例えば、コンストラクタの引数を属性に設定するといった簡単な初期化程度に徹すべきです。
AngularにgetHeroes()
を呼ばせるにはAngularのngOnInit
ライフサイクルフックを実装します。Angularは、コンポーネントのライフサイクルにおける重要なポイントに介入するインタフェースを提供しています:生成時、変化時、そして最終的な破壊時です。
各インタフェースは1つのメソッドを備えています。コンポーネントがそのメソッドを実装すると、 Angularが適切なタイミングで呼び出します。
以下が、OnInit
インタフェースの概要です(これをお手元のソースに記述しないでください)
import { OnInit } from '@angular/core'; export class AppComponent implements OnInit { ngOnInit(): void { } }
OnInit
の実装をexport文に追加します:
export class AppComponent implements OnInit {}
次にngOnInit
メソッドを書き、その中に初期化の処理を含めます。Angularはこれを適切なタイミングで呼び出します。この場合、getHeroes()
を呼び出して初期化を行います:
ngOnInit(): void { this.getHeroes(); }
アプリは予定通り動くはずです。ヒーローのリストを表示し、ヒーロー名をクリックすると詳細が表示されるはずです。
現時点のソース
app.component.ts
import { Component, OnInit } from '@angular/core'; import { Hero } from './hero'; import { HeroService } from './hero.service'; @Component({ selector: 'app-root', template: `<h1>{{title}}</h1> <h2>私のヒーロー一覧</h2> <ul class="heroes"> <li *ngFor="let hero of heroes" (click)="onSelect(hero)" [class.selected]="hero === selectedHero"> <span class="badge">{{hero.id}}</span>{{hero.name}} </li> </ul> <hero-detail [hero]="selectedHero"></hero-detail> `, styleUrls: ['./app.component.css'], providers: [HeroService] }) export class AppComponent implements OnInit { title = 'Tour of Heroes'; selectedHero: Hero; heroes: Hero[]; onSelect(myhero: Hero): void { this.selectedHero = myhero; } constructor (private heroService: HeroService) { } getHeroes(): void { this.heroes = this.heroService.getHeroes(); } ngOnInit(): void { this.getHeroes(); } }
app.module.ts
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { FormsModule} from '@angular/forms'; import { AppComponent } from './app.component'; import { HeroDetailComponent } from './hero-detail.component'; @NgModule({ declarations: [ AppComponent, HeroDetailComponent ], imports: [ BrowserModule, FormsModule ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
hero-detail.compnent.ts
import { Component, Input } from '@angular/core'; import { Hero } from './hero'; @Component({ selector: 'hero-detail', template: ` <div *ngIf="hero"> <div> <input [(ngModel)]="hero.name"> </div> <div><label>id: </label>{{hero.id}}</div> <div><label>name: </label>{{hero.name}}</div> </div> ` }) export class HeroDetailComponent { @Input() hero: Hero; }
hero.service.ts
import { Injectable } from '@angular/core'; import { Hero } from './hero'; import { HeRoEs } from './mock-heroes'; @Injectable () export class HeroService { getHeroes(): Hero[] { return HeRoEs; } }
hero.ts
export class Hero { id: number; name: string; }
mock-heroes.ts
import { Hero } from './hero'; export const HeRoEs: Hero[] = [ { id: 11, name: 'ビアンカ' }, { id: 12, name: 'フローラ' }, { id: 13, name: 'デボラ' } ];
非同期サービスとPromiseについて
HeroService
はgetHeroes()
シグネチャは同期であり、即座にヒーローのモックデータを返却します。
this.heroes = this.heroService.getHeroes();
ヒーローデータは最終的にはリモートサーバから取得します。リモートサーバを使う場合、ユーザにサーバからの応答を待たせたくなく、また、応答待ちの際、UIをブロックすることはできません。
応答とビューを連携させるにはPromiseを利用します。これは非同期の技術で、getHeroes()
メソッドのシグネチャを変更します。
ヒーローサービスがPromiseを作成する
Promiseとは、結果が準備できたときにコールバックすることを約束する機構です。非同期のサービスに何らかの役務を依頼し、その際、コールバック関数を与えます。役務を遂行したサービスは最終的に結果もしくはエラーを持ってコールバックを呼びます。
HeroService
のgetHeroes()
がPromiseを返却するように以下のように変更します:
getHeroes(): Promise<Hero[]> { return Promise.resolve(HeRoEs); }
まだモックデータを返却していることにご注意ください。ここでは、即座に応答したPromiseを返却することにより超高速な、ゼロレイテンシのサーバをシミュレートしています。
Promiseに対して処理を行う
HeroService
への変更に伴い、this.heroes
はヒーローの配列ではなくPromise
に設定されています。
// 現時点のapp.component.ts getHeroes(): void{ this.heroes = this.heroService.getHeroes(); }
そこで、実装を変更し、Promiseが解決すると共にそれに対して処理を行わねばなりません。Promise
の解決に成功すると、ヒーローが表示されます。
Promiseのthen()
メソッドに対してコールバック関数を引数として渡します:
getHeroes(): void { this.heroService.getHeroes().then(heroes => this.heroes = heroes); }
このコールバック関数は、サービスによって返却されるヒーローの配列をコンポーネントのheroes
属性に設定します。
この状態でもアプリは正常に実行し、ヒーローの一覧を表示し、名前の選択に応じて詳細を表示します。
まとめ
- 複数のコンポーネントから共有可能なサービスクラスを作成しました
nOnInit
ライフサイクルフックを使い、AppComponent
の有効化と同時にヒーローデータを取得するようになりました。AppComponent
へのプロバイダとしてHeroService
を定義しました。- モックのヒーローデータを作成し、サービスにインポートしました。
- サービスがPromiseを返却するように変更し、コンポーネントがPromiseからデータを取得するように変更しました。
本章での最終的なapp.component.ts
import { Component, OnInit } from '@angular/core'; import { Hero } from './hero'; import { HeroService } from './hero.service'; @Component({ selector: 'app-root', template: `<h1>{{title}}</h1> <h2>私のヒーロー一覧</h2> <ul class="heroes"> <li *ngFor="let hero of heroes" (click)="onSelect(hero)" [class.selected]="hero === selectedHero"> <span class="badge">{{hero.id}}</span>{{hero.name}} </li> </ul> <hero-detail [hero]="selectedHero"></hero-detail> `, styleUrls: ['./app.component.css'], providers: [HeroService] }) export class AppComponent implements OnInit { title = 'Tour of Heroes'; selectedHero: Hero; heroes: Hero[]; onSelect(myhero: Hero): void { this.selectedHero = myhero; } constructor (private heroService: HeroService) { } ngOnInit(): void { this.getHeroes(); } getHeroes(): void { this.heroService.getHeroes().then(heroes => this.heroes = heroes); } }
本章での最終的なhero.service.ts
import { Injectable } from '@angular/core'; import { Hero } from './hero'; import { HeRoEs } from './mock-heroes'; @Injectable () export class HeroService { getHeroes(): Promise<Hero[]> { return Promise.resolve(HeRoEs); } }