再利用可能な**サービス(Services)**を作り、ヒーローのデータ呼び出しを管理する
アプリケーションは進化を続けている。近いうち、たくさんのコンポーネントを追加することになるだろう。 それらのコンポーネントはヒーローのデータへアクセスする必要があるだろう。 しかし、その度にヒーローのデータをコピー&ペーストはしたくない。 その代わりに、今回は単一、かつ再利用できるサービスを作り、データが必要なコンポーネントに注入する方法を学ぶ。
データへのアクセスを分離されたサービスに再構築する(置き直す)ことは、 コンポーネントの機能を表示という目的に絞り込み注力させるという状態を維持することになる。 また、サービス自体をモック(ダミー)に置き換えることでユニットテストもしやすくなる。
サービスは常に非同期処理であるため、この章の最終的な目的は Promise パターンを利用したサービスを作ることとする。
この章を続ける前に、まず前回の章が終わった時のフォルダ構造を確認してみよう。 もし以下のようになっていないなら、前の章に戻ってどこが足りないのかを探してみてほしい。
angular2-tour-of-heroes
├app
│├app.component.ts
│├hero.ts
│├hero-detail.component.ts
│└main.ts
├node_modules ...
├typings ...
├index.html
├package.json
├styles.css
├tsconfig.json
└typings.json
npm start
で TypeScript の自動コンパイルとアプリケーションのリロードを有効にしておこう。
このコマンドは TypeScript のコンパイラを watch mode で動かし、サーバーを動作させる。
アプリケーションをブラウザで起動し、開発中にアプリケーションを動作させたまま更新を反映させることができる。
ステークホルダーが今のアプリを拡大していく見通しを我々に明かした。 彼らは、いろいろなページで、いろいろな形式でヒーローの情報を表示したいと言う。 今までの開発で、我々は一覧画面からヒーローを選択できるようになった。 この先、トップヒーローを表示するダッシュボード画面と、ヒーローの情報を編集する別画面を追加することになる。 この3つの画面では、共にヒーローの情報を必要としている。
今、AppComponent
の中に表示用のヒーロー一覧データが定義されている。
ここで2つ問題がある。
1つは、ヒーローのデータを定義するのはコンポーネントの責務ではない、ということ。
もう1つは、ここに書かれているヒーローのデータを、別のコンポーネントや画面と共有するのが簡単ではない、ということだ。
そこで、ヒーローのデータを提供し必要なコンポーネント同士で共有できるように、 ヒーローのデータを取得するという仕事を1つのサービスに再構築することができる。
app
フォルダに hero.service.ts
というファイルを作る。
サービスを記述するファイルには .service
を付けると言う決まりにしよう。
もしサービスの名前が複数単語で構成されるようなものであるなら、-
で繋ごう(ケバブケース)。
例えば、SpecialSuperHeroService
というサービスを定義するなら、そのファイル名は special-super-hero.service.ts
となるだろう。
ここでは、サービスのクラス名を HeroService
とし、
これを他のコンポーネントからインポートできるよう、エクスポートする。
import {Injectable} from 'angular2/core';
@Injectable()
export class HeroService {
}
ここで、Angular の Injectable
をインポートして、それをつかい @Injectable()
というデコレーターで(HeroService
を)修飾している、ということに気をつけてほしい。
後ろの()
を忘れてはいけない。付け忘れてしまうと、ややこしいエラーが出て訳が分からなくなるぞ。
TypeScript はこの @Injectable()
デコレーターを見て、 HeroService
にあるメタデータを発行する。
そのメタデータとは、Angular がこのサービスを必要とされるコンポーネントに**注入(Injection)**する必要があるかもしれない、ということだ。
デコレータを付けたが、今のところは HeroService
は何からも依存されていない。
これは、データの一貫性や、将来にわたっての互換性維持を最初から整えておくためのもので、
それは @Injectable()
デコレーター適用についての「ベストプラクティス」であるのだ。
getHeroes
という名前の**スタブ(stub)**メソッドを追加してみよう。
@Injectable()
export class HeroService {
getHeroes() {
}
}
今は中身の実装を保留しておくというのがポイントだ。
このサービスを使う「消費者」( = 各コンポーネント)は、このサービス自身がどうやって元となるデータを取得するかを知らないのだ。
HeroService
はどこからデータを取得しても良い。
ウェブサービスでも、ローカルストレージでも、どこかにべた書きしたダミーデータでもいい。
これは、コンポーネントからデータアクセスの機能を取り除く、という美学である。
保留していた getHeroes
に立ち返ると、この部分の実装は、データの取得方法に関して、コンポーネントに触ることなく、意のままに書いて良いということだ。
(注釈: getHeroes
とは「どこからかヒーローのデータを持ってくる」というメソッドだ。この機能がサービスにあるということは、コンポーネントは「どこからかヒーローのデータを持ってくる」ことを考えなくて良くなる。またコンポーネントの責任が減るのだ。今実装を保留した部分は意のままに書いていい、ここを変えても、コンポーネントはそれを知らなくて良い、つまりコンポーネントに影響を与えないと言う点の美しさについてしつこく語っている。例えば、ヒーローのデータをテスト用のデータにしたいときにここを触るだけでよくなる)
さて、今ダミーのヒーロー一覧のデータが AppComponent
に書かれている。 HeroService
には属していない。
このデータを独自のファイルに移動しよう。
app
フォルダに mock-heroes.ts
を作り app.component.ts
からヒーローの配列をカット&ペーストしよう。
import {Hero} from './hero';
のインポート宣言は、この配列が Hero
型であるために必要なものだ。
import {Hero} from './hero';
export var 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
からインポート出来るようにする。
(注釈: ここ原文だと配列が constant って書いてあるけど実は変数だし嘘っぽいので気をつける)
そして app.component.ts
に戻り、 AppComponent
の heroes
プロパティを初期化しないようにしておく。
heroes: Hero[];
HeroService
に戻って、ダミーの HEROES
をインポートし、その配列を返すように getHeroes
メソッドを書こう。
HeroService
はこうなる。
import {HEROES} from './mock-heroes';
import {Injectable} from 'angular2/core';
@Injectable()
export class HeroService {
getHeroes() {
return HEROES;
}
}
(疑問: ここで Hero
型のインポートがないのはなぜ? mock-heroes
からの型推論でメソッドの型までちゃんと とHero
型であると認識される?、例えばメソッドに型を明示したら怒られるのかな?)
まずAppComponent
から、コンポーネント内で HeroService
を使う準備をする。
まずいつものように使いたい HeroService
をインポートしよう。
import {HeroService} from './hero.service';
サービスをインポートしたことで、コンポーネントのコードから参照可能になった。
ここで、AppComponent
は具体的な HeroService
のインスタンスを取得するのだろうか。
HeroService
を new すればいいのか?まさか!
たしかに、以下のように宣言すればインスタンスを得られる。
heroService = new HeroService(); // それ以上いけない
しかし、これはいくつかの理由で良くない方法なのだ。
コンポーネントは HeroService
を作り出す方法を知る必要がある。
HeroService
の**コンストラクタ(constructor new する時の初期化メソッド)**が変更されるたびに
(たとえばメソッドに引数が追加されるとか)我々はサービスを作っている部分を全て探して書き換えなければならなくなる。
そのように書き換えが激しくなってしまったコードはエラーが発生しやすく、またテストの手間も掛かる。
サービスを毎回 new で作ることをもう少し考えてみよう。 このサービスが取得したデータを内部に溜め込んで、それを他の部分に使い回してくれるとしたら? それだと毎回 new することで台無しになってしまう。
このように、HeroService
の特定の実装によっては AppComponent
の実装をも束縛してしまうのだ。
状況が変化したときに実装を変更するのは大変になるだろう。
たとえばオフラインアプリケーションになったら?テストで別のデータを取り込むようにするべき時は?きっと簡単ではない。
こんなとき………どうすれば………いや、できることはある! 実のところ、上のような間違った書き方をする言い訳ができないほど、この問題の回避は簡単なのだ。
一行で new する代わりに、以下の2つの処理に置き換える。
AppComponent
にコンストラクタを追加する- コンポーネントのメタデータに
providers
プロパティを追加する
コンストラクタはこうだ。
constructor(private _heroService: HeroService) { }
このコンストラクタ自体はなにもしない。
このパラメーター定義は、_heroService
というプライベートなプロパティを作り、それを注入対象の HeroService
であると定義する。
ここで、このHeroService
型のプライベート変数が、コンポーネントのパブリックなAPIではないということを強調するため、変数名の先頭に _
を付けておこう。
これで、Angular は AppComponent
が作られたときに、そこに HeroService
のインスタンスを提供する方法を知ることができる。
Angular は、インスタンスをどこからか取得する必要がある、それは、Angular の DIコンテナ(Dependency Injector) の役割だ。
DIコンテナは(原文だとInjectorなので改めて説明すると)内部にコンテナを持っており、ここに作ったサービス(のインスタンス)を保持してくれる。
つまり、必要になったときに、コンテナに HeroService
のインスタンスが入っていればそれを Angular に返すし、入っていなければ自動でインスタンスを作ってコンテナに入れた後にそれを Angular に返すのだ。
注入については、依存性の注入の章で、より深く学べるだろう。
DIコンテナはまだ HeroService
の作り方を知らない。アプリケーションを実行したとき、Angular はこのようなエラーを出すだろう。
EXCEPTION: No provider for HeroService! (AppComponent -> HeroService)
ここで、HeroService
の提供者をコンポーネントに登録することで、DIコンテナに何が必要かを伝える必要がある。
@Component
デコレータの中に書かれたメタデータの最下部に、providers
という名前で配列プロパティを追加しよう。
providers: [HeroService]
この配列プロパティによって、Angular に対して AppComponent
を作るときに HeroService
の新しいインスタンスを作るよう、伝えることができる。
AppComponent
はこのサービスを通して、ヒーローのデータを取得することができる他、子コンポーネントも同じサービス(の同じインスタンス)を通して、ヒーローのデータを取得することができる。
サービスとコンポーネントツリー
AppComponent
がテンプレートの下部に書かれた<my-hero-detail>
で、対応するHeroDetailComponent
のインスタンスを作り出す、ということを思い出してほしい。 ということは、HeroDetailComponent
はAppComponent
の**子コンポーネント(child)**だということだ。
HeroDetailComponent
が親コンポーネントのHeroService
が必要なとき、AppComponent
でしたときと同じように、コンストラクタ経由で Angular にHeroService
を注入してもらう必要がある。constructor(private _heroService: HeroService) { }ただし、
HeroDetailComponent
のデコレータにproviders
を追加する必要はない! なぜかは付録で説明する。AppComponent
はアプリケーションで最も上位のコンポーネントだ。 つまりAppComponent
のインスタンスは必ず1つであり、HeroService
のインスタンスもアプリケーションを通して1つである。
今、AppComponent
のプライベート変数_heroService
に HeroService
のインスタンスが入っている。
これを使ってみよう。
一瞬考えてみよう。サービスの呼び出しとデータの取得は、合わせて1行で表現できる。
this.heroes = this._heroService.getHeroes();
本当はこの1行をラップするようなメソッドは必要ないのだが、以下のように書いておこう。
getHeroes() {
this.heroes = this._heroService.getHeroes();
}
AppComponent
は難なく(without a fuss)ヒーローの情報を取得し、表示する必要がある。
getHeroes
はどこで呼ばれるべきだろうか?コンストラクタ内部?そうではない。
プログラミングの長い苦しみの歴史とは、我々に「コンストラクタ内部からサーバーへのデータアクセスのような複雑なロジックを絶対に入れてはいけない」という教訓を残した。
コンストラクタは与えられたパラメータをプロパティにセットするなど軽い初期化処理のためにある。ヘビーリフティングの為にあるのではない。 我々は、テストでもコンポーネントを作れる状態にする必要があり、そこで実際にやるであろう(サーバー呼び出しなどの)作業を(コンストラクタの中で)気にすることがないようにする必要もある。
され、コンストラクタの中で getHeroes
を呼ばないのであれば、外部の何かがメソッドを呼ばなければならない。
Angular システム内に用意された ngOnInit
というライフサイクルフック(Lifecycle Hook) の中身を実装をすれば、Angular はそのメソッドを呼び出してくれる。
Angular は他にも、コンポーネントのライフサイクルに関して、重要な瞬間を活用するためのフックインターフェースを用意している。
コンポーネントが作られた時のフック、変更が起こったときのフック、コンポーネントが最終的に破棄される時のフックなどだ。
そうしたインターフェースは、それぞれ1つのメソッドを持っており、コンポーネントの中でそのメソッドの実装を記述すると、Angular は適切なタイミングで処理を実行してくれるのだ。
ライフサイクルフックについては、ライフサイクルフックの章で、より深く学べるだろう。
OnInit
インターフェースに関しての大筋は以下のようになる。
import {OnInit} from 'angular2/core';
export class AppComponent implements OnInit {
ngOnInit() {
}
}
ngOnInit
メソッドに初期化するためのロジックを書いて、Angular に正しいタイミングで呼び出すのを任せてみよう。
今回で言えば、getHeroes
を内部で呼んで、ヒーローの情報を初期化する処理だ。
ngOnInit() {
this.getHeroes();
}
意図した通り、ヒーローの一覧が表示され、名前をクリックしたヒーローの詳細が表示される。 ゴールにだいぶ近づいた。しかしちょっと違うのだ。
HeroService
はダミーのヒーローデータを即座に返す。getHeroes
のシグネチャは同期的だ。
(メソッドシグネチャはメソッドの定義を示す語としてよく使われている)
this.heroes = this._heroService.getHeroes();
ヒーローのデータを問い合わせて、それは返却値の中に入ってくる。 いつか、このヒーローのデータをサーバーから取得するようにするだろう。 まだ HTTP による問い合わせはしないが、後の章でやることになる。
そうしたときに、我々はサーバーからの応答を待たなければならない。 しかし、ブラウザの動作はブロックを許さない(UIの処理を止めてしまうと「応答がありません、このタブを閉じますか?」とか出てしまう)のだから、応答を待ってる間にUIの処理を止めたかったとしても止めてはいけない。
我々はgetHeroes
メソッドの内容を書き換え、非同期処理を使うようにしなければならない。
ここで Promise を使う。
Promise とは……結果が準備できたときに(結果を使った)処理をコールバックすることを約束することだ。
我々は非同期サービスに対して、何らかの仕事をしてもらうとともに、コールバック関数を渡すことになる。 そうすると、何らかの仕事(サービスの機能)を行った後、その結果や出てきたエラーをもとに、渡したコールバック関数を実行してもらえる。
簡単にするために、ここでは ES2015 の Promise について説明するが、 他の言語やフレームワークの Promise については web の他のリソースで学んでほしい。
HeroService
の getHeroes
を Promise
を返すように書き換えてみよう。
getHeroes() {
return Promise.resolve(HEROES);
}
ヒーローのデータは未だダミーである。
これをサーバーとして想定すると、要求にタイムラグなしで応答するとても速いサーバーで、Promise
はヒーローのダミーデータを
すぐさま解決された状態で返却するだろう。
AppComponent
の getHeroes
メソッドは以下のようになっているはずだ。
getHeroes() {
this.heroes = this._heroService.getHeroes();
}
HeroService
に変更を加えた結果 this.heroes
にはヒーローの一覧データではなく、Promise
オブジェクトがセットされてしまうようになっている。
我々は、渡された Promise
オブジェクトが解決されたときに動くように実装を変えなければならない。
Promise
オブジェクトが解決されたときに、ヒーロー一覧が表示されるようにするのだ。
Promise
が持つ then
メソッドに、以下のコールバック関数を入れて渡そう。
getHeroes() {
this._heroService.getHeroes().then(heroes => this.heroes = heroes);
}
コールバックとして渡しているES2015 のアロー関数は、それと等価な関数表現をより簡単に、より優雅に表現する。
コールバック関数は AppComponent
の heroes
プロパティにサービスから返された一覧データをセットする。これで全てが揃った!
見た目上ではアプリケーションはヒーロー一覧を出し、選択したヒーローの詳細が出る、というのは変わらないはずだ。
サーバーの応答が遅いような場合を想定したヒーローのデータ取得が遅いバージョンを確認してみよう。
この章でリファクタリングをした後のフォルダ構造を確認してみよう。
angular2-tour-of-heroes
├app
│├app.component.ts
│├hero.ts
│├hero-detail.component.ts
│├hero.service.ts
│├main.ts
│└mock-heroes.ts
├node_modules ...
├typings ...
├index.html
├package.json
├styles.css
├tsconfig.json
└typings.json
この章で議論に挙がったファイルはこちら。
この章で我々が得たものは、以下のようにまとめられる。
- 多くのコンポーネント間で共有できるサービスを作る
AppComponent
が作られたときにヒーローのデータを取得するよう、ngOnInit
というライフサイクルフックを使うHeroService
をAppComponent
のプロバイダとして登録する- ヒーローのダミーデータを作成し、それをサービスにインポートする
- サービスが
Promise
を返すようにし、コンポーネントはPromise
経由でデータを取得するようにする
このアプリケーションはコンポーネントやサービスの共有によりより再利用性が高まっている。
我々はダッシュボード画面を作り、画面を移動するためのリンクを作り、そしてテンプレートの中でデータを整形して表示したい。 この先のアプリケーションの進化の過程で、そのような構成を簡単に作り成長させる方法、そしてメンテナンスの方法を学ぶことになるだろう。
Angular のルーター(Router) について、そして画面間を移動できるような設計に関して、次回作業を行いながら説明していく。
ヒーローのデータがサーバーにあると考えて、サーバーとの接続が遅い場合を考えてみよう。
HeroService
型に Hero
型をインポートして、getHeroesSlowly
メソッドを作ってみよう。
getHeroesSlowly() {
return new Promise<Hero[]>(resolve =>
setTimeout(()=>resolve(HEROES), 2000) // 2 seconds
);
}
だいたいは getHeroes
と同じだ。しかし、 Promise
はヒーローのダミーデータを解決する前に2秒館待つようにしている。
AppComponent
に戻り、_heroService.getHeroes
を使っている部分を _heroService.getHeroesSlowly
を使うように変えてみて、アプリケーションがどのような動作をするか見てみよう。
AppComponent
で注入された HeroService
が子コンポーネントである HeroDetailComponent
でも注入されるとき、HeroDetailComponent
側ではproviders
プロパティの設定をしてはいけない というやり取りを行った。
なぜだろうか?
実はそうしてしまうと、 Angular は HeroDetailComponent
のために新しい HeroService
のインスタンスを作ってしまう。
HeroDetailComponent
は独自のインスタンスは不要で、本当に必要なのは AppComponent
の中のHeroService
なのだ。
それでも providers
プロパティの設定を行い HeroService
のインスタンスを作ってしまうと、AppComponent
の中の HeroService
のインスタンスを隠してしまう。
(注釈: 参照を上書きしてしまい、AppComponent
側に実体があっても、HeroDetailComponent
側で作られたインスタンスが優先的に参照されるようになる)
provider
プロパティへの登録は「どこにすべきか」を注意深く考えよう。
登録のスコープを理解して、間違ったコンポーネントの階層にサービスを作ってしまわないよう気をつけよう。