Angularの公式チュートリアル:第5章「Services」

サービス

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は使いません

AppComponentHeroServiceのコンクリートインスタンスをどうやって取得すべきでしょうか? 選択肢の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について

HeroServicegetHeroes()シグネチャは同期であり、即座にヒーローのモックデータを返却します。

this.heroes = this.heroService.getHeroes();

ヒーローデータは最終的にはリモートサーバから取得します。リモートサーバを使う場合、ユーザにサーバからの応答を待たせたくなく、また、応答待ちの際、UIをブロックすることはできません。

応答とビューを連携させるにはPromiseを利用します。これは非同期の技術で、getHeroes()メソッドのシグネチャを変更します。

ヒーローサービスがPromiseを作成する

Promiseとは、結果が準備できたときにコールバックすることを約束する機構です。非同期のサービスに何らかの役務を依頼し、その際、コールバック関数を与えます。役務を遂行したサービスは最終的に結果もしくはエラーを持ってコールバックを呼びます。

HeroServicegetHeroes()が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);
  }
}