前回の投稿Angluar2のクイックスタートとチュートリアルを実施 - その6の続きです。
前回作成したソースを使用します。
本章ではhttpを通してリモートサーバーのweb APIを呼び出し、データの取得・追加・削除する方法を学びます。
本章もかなり長いです...
本投稿の参照元(英語)
本章で学ぶこと
前述のほか、以下を学びます。
- インメモリweb APIの作り方(深入りはしません)
- Observableの使い方
尚、本章のメインテーマはAngularのHTTPライブラリの紹介ですので、インメモリwebAPIについての説明は主題ではありません。より多くインメモリwebAPIについて学習したい場合、HTTP client chapter(英語)を参照ください。インメモリwebAPIはデモンストレーションのために使用しています。何か替わりになるリモートサーバをお持ちであれば学習不要です。
実行結果
本章を完了すると以下リンク先のようなものが出来上がります。
ヒーローの追加・削除機能などが実装されています。
live-examples
事前準備
以下のコマンドによりサーバの起動を行います。プログラムの変更が即座にブラウザに反映されます。(サーバを落としていなければ不要です。)
cd angular2-tour-of-heroes
npm start
HTTPサービスの提供
HttpModule
はAngularのcoreモジュールには含まれていません。オプションのアプローチであり、@angular/http
として、Angular npmパッケージに存在します。
既にsystemjs.config
で同モジュールを取得する設定を行なっているので、すぐにインポートできます。
HTTPサービスの登録
@angular/http
のHttpModule
は、HTTPサービスのすべてを提供します。このサービスはアプリのどこからでもアクセスできます。
AppModule
のimports
にHttpModule
を追加します。
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
import { AppComponent } from './app.component';
import { DashboardComponent } from './dashboard.component';
import { HeroesComponent } from './heroes.component';
import { HeroDetailComponent } from './hero-detail.component';
import { HeroService } from './hero.service';
import { routing } from './app.routing';
@NgModule({
imports: [
BrowserModule,
FormsModule,
HttpModule,
routing
],
declarations: [
AppComponent,
DashboardComponent,
HeroDetailComponent,
HeroesComponent,
],
providers: [
HeroService,
],
bootstrap: [ AppComponent ]
})
export class AppModule {
}
模擬的web API
現在のアプリは開発中で、製品にするには程遠い状況です。ヒーロー一覧を扱うwebサーバもありません。そのため偽のサーバを立てます。
_インメモリWeb API_により、HTTPクライアントを偽装し、モックサービスのデータを取得・保存します。
以下が、偽装版のapp/app.module.ts
です。
[注意!]インメモリweb APIのパッケージ名称が
angular2-in-memory-web-api
からangular-in-memory-web-api
に変更されました。(2.0.1から?)クイックスタートのpackage.jsonにて指定したインメモリweb APIの名称に応じて、インポートするパッケージ名を変更するか、再度package.json
を更新し、古いライブラリを削除した後、npm install
コマンドを実行し、最新のライブラリを使用してください。
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
// Imports for loading & configuring the in-memory web api
import { InMemoryWebApiModule } from 'angular-in-memory-web-api';
import { InMemoryDataService } from './in-memory-data.service';
import { AppComponent } from './app.component';
import { DashboardComponent } from './dashboard.component';
import { HeroesComponent } from './heroes.component';
import { HeroDetailComponent } from './hero-detail.component';
import { HeroService } from './hero.service';
import { routing } from './app.routing';
@NgModule({
imports: [
BrowserModule,
FormsModule,
HttpModule,
InMemoryWebApiModule.forRoot(InMemoryDataService),
routing
],
declarations: [
AppComponent,
DashboardComponent,
HeroDetailComponent,
HeroesComponent,
],
providers: [
HeroService,
],
bootstrap: [ AppComponent ]
})
export class AppModule {
}
InMemoryWebApiModule
はデフォルトのhttpクライアントのバックエンドを置換し、_インメモリweb API拡張サービス_を持つ拡張サーバにします。
InMemoryWebApiModule.forRoot(InMemoryDataService),
forRoot
設定メソッドは以下のapp/in-memory-data.service.ts
の作成により、インメモリデータベースを実装するInMemoryDataService
クラスを取得します。
import { InMemoryDbService } from 'angular-in-memory-web-api';
export class InMemoryDataService implements InMemoryDbService {
createDb() {
let heroes = [
{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'}
];
return {heroes};
}
}
このファイルの作成によりモックデータを扱っていたapp/mock-heroes.ts
は削除できます。
ヒーロー一覧とHTTP
HeroService
は以下の様に実装しています。
getHeroes(): Promise<Hero[]> {
return Promise.resolve(HEROES);
}
プロミスの結果をモックのヒーロー配列と一緒に返しています。もともと、ただ単にヒーロー一覧を返せばいいため、プロミスで返すことは過剰な対応でしたが、これはHTTPクライアントから非同期にデータを取得することを予定していたためです。今がそのときですので、getHeroes()
メソッドをHTTPを使用するように変更しましょう。
private heroesUrl = 'app/heroes'; // web apiへのURL
constructor(private http: Http) { }
getHeroes(): Promise<Hero[]> {
return this.http.get(this.heroesUrl)
.toPromise()
.then(response => response.json().data as Hero[])
.catch(this.handleError);
}
インポートは以下の様に変更します。
import { Injectable } from '@angular/core';
import { Headers, Http } from '@angular/http';
import 'rxjs/add/operator/toPromise';
import { Hero } from './hero';
ブラウザ更新により、ヒーローデータは正常にモックサーバから取得できます。
HTTPプロミス
まだgetHeroes()
は、プロミスを返すメソッドですがその方法は違います。Angularのhttp.get
は、RxJSのObservable
を返します。_Observable_は非同期通信を扱うための有力な方法です。本章の後半で学びます。
Observable
からPromise
への変換は、toPromise
演算子により行います。
.toPromise()
残念ながら、AngularのObservable
はRxJSのものと異なり、.toPromise
演算子を持っておらず、ベアボーンな実装です。
AngularのObservable
でtoPromise
演算子のような便利な機能を使うためには、演算子を独自に加える必要がありますがRxJSライブラリから取得するだけなので簡単です。
import 'rxjs/add/operator/toPromise';
_then_コールバックによるデータの取得
プロミスのthen
コールバックでは、HTTPのResponse
のjson
メソッドを呼び出し、データを取り出せます。
.then(response => response.json().data as Hero[])
JSONのレスポンスは単一のdata
プロパティを返し、data
プロパティはヒーロー配列を持ちます。この配列を解決済みのプロミスとして返します。
注意点:サーバからデータを取得するとき、今回のインメモリweb APIはdata
プロパティのみを返しますが、これはweb APIにより変わります。必要に応じコードを改修してください。
エラーハンドリング
getHeroes()
メソッドの最後に、catch
でサーバのデータ取得失敗などをエラーハンドリングしています。
.catch(this.handleError);
ここは重要なステップです。HTTPの想定を超えて頻繁に発生するfailureを予想しなければなりません。
private handleError(error: any): Promise<any> {
console.error('An error occurred', error); // デモの目的のみ
return Promise.reject(error.message || error);
}
このデモでは、コンソールにログを出しますが、本来はもっとベターに扱うべきです。また、エラーフォームを呼び出し元へプロミスの破棄で返し、呼び出し元では適切にユーザにエラーメッセージを表示します。
getHeroes()
APIは変更しない
getHeroes()
メソッドを大きく変えましたが、外見は変わっておらず、まだプロミスを返します。そのため、このメソッドを呼び出す他のメソッドは変更の必要はありません。
それではヒーロー詳細を更新したときに何が起こるかを見ていきましょう。
ヒーロー詳細の更新
現在ヒーロー詳細は更新できますが、Back
ボタンを押すとその更新内容が失われます。以前は問題ありませんでした。アプリがモックデータのヒーローリストを使っていたときは、アプリ全体のヒーローオブジェクトをダイレクトに変更していました。現在はサーバからデータを取得しているので、永続的に変更(変更の保持)したい場合、サーバに書き込む必要があります。
ヒーロー詳細の保存
ヒーロー詳細を失うことなく保存する様にしましょう。[save]ボタンをヒーロー詳細テンプレートに追加します。saveボタンには(click)イベントをバインディングし、コンポーネントのsave()
メソッドを呼び出す様にします。
<button (click)="save()">Save</button>
save
メソッドはheroService
のupdate()
メソッドを使用し、ヒーローの名前を変更します。同時に一つ前のビューに戻ります。
save(): void {
this.heroService.update(this.hero)
.then(this.goBack);
}
ヒーローサービスのupdate()
メソッド
update()
メソッドは、getHeroes()
メソッドに似ていまが、HTTP _put_により永続的な変更を行います。
private headers = new Headers({'Content-Type': 'application/json'});
update(hero: Hero): Promise<Hero> {
const url = `${this.heroesUrl}/${hero.id}`;
return this.http
.put(url, JSON.stringify(hero), {headers: this.headers})
.toPromise()
.then(() => hero)
.catch(this.handleError);
}
URL用に変更したヒーローIDにより、サーバがどのヒーローを更新すべきかを識別します。putのボディ部分にはJSON.stringify
でヒーローをjson文字列でエンコーディングしたものです。リクエストヘッダによりボディのコンテントタイプをapplication/json
と識別させます。
ブラウザを更新し、ヒーロー名を変更してください。変更が永続化されます。
ヒーローの追加
ヒーローを追加するためには、ヒーロー名が必要です。ヒーロー名を入れる要素とボタンを用意します。ヒーロー一覧のテンプレートの見出しの後に以下を追加します。
<div>
<label>Hero name:</label> <input #heroName />
<button (click)="add(heroName.value); heroName.value=''">
Add
</button>
</div>
クリックイベントのレスポンスでコンポーネントのクリックハンドラー(add()
メソッド)を呼びます。また次のヒーロー名を入力できるように、入力フィールドを空にします。
add(name: string): void {
name = name.trim();
if (!name) { return; }
this.heroService.create(name)
.then(hero => {
this.heroes.push(hero);
this.selectedHero = null;
});
}
add()
メソッドでは、名前が空白でない場合に入力された名前のヒーローをサービスに渡すことにより、配列に新しいヒーローが追加されます。ここで呼び出すcreate()
メソッドをHeroService
クラスに実装します。
create(name: string): Promise<Hero> {
return this.http
.post(this.heroesUrl, JSON.stringify({name: name}), {headers: this.headers})
.toPromise()
.then(res => res.json().data)
.catch(this.handleError);
}
ブラウザ更新により、ヒーローが作成できるようになります。
ヒーローの削除
ヒーロー一覧の各ヒーローに削除ボタンを用意し、ヒーローを削除できるようにします。
各ヒーロー名の右側で繰り返す<li>
タグにボタン[x]を追加します。
<button class="delete"
(click)="delete(hero); $event.stopPropagation()">x</button>
結果、<li>
要素は以下の様になります。
<li *ngFor="let hero of heroes" (click)="onSelect(hero)"
[class.selected]="hero === selectedHero">
<span class="badge">{{hero.id}}</span>
<span>{{hero.name}}</span>
<button class="delete"
(click)="delete(hero); $event.stopPropagation()">x</button>
</li>
加えて、ボタンクリック時に呼ばれるコンポーネントのdelete
メソッドでは、クリックイベントの伝道を抑止します。この理由は、削除される予定のヒーローの<li>
要素がクリックされない様にするためです。delete
ハンドラはちょっと手がこみます。
delete(hero: Hero): void {
this.heroService
.delete(hero.id)
.then(() => {
this.heroes = this.heroes.filter(h => h !== hero);
if (this.selectedHero === hero) { this.selectedHero = null; }
});
}
ヒーローデータの削除はサービス側で行いますが、コンポーネント側では画面表示を更新する(削除されたヒーローを非表示にする)責任があります。配列からの削除(filter
)と、必要に応じて選択中ヒーローの選択解除(null
代入)が必要です。ヒーローエンティティの右端に削除ボタンを配置するため、CSSを更新します。
button.delete {
float:right;
margin-top: 2px;
margin-right: .8em;
background-color: gray !important;
color:white;
}
ヒーローサービスのdelete()
メソッド
HTTPのdelete()
メソッドにより、ヒーローをサーバから削除します。
delete(id: number): Promise<void> {
let url = `${this.heroesUrl}/${id}`;
return this.http.delete(url, {headers: this.headers})
.toPromise()
.then(() => null)
.catch(this.handleError);
}
ブラウザ更新して、削除機能を確認して見てください。
Observables
httpサービスのメソッドは、HTTP Response
オブジェクトのObservable
を返します。現在HeroService
はObservable
を(toPromise
により)Promise
に変換して返していますが、ここではObservable
をそのまま返す方法および、なぜそうするのかを学びます。
バックグラウンド
_observable_は配列の様に処理ができるストリームです。
Angularコアは_observable_のベース部分をサポートし、RxJS Observablesにある演算子などによる拡張をサポートしています。ここでは軽く説明します。
HeroService
はhttp.get
の結果のObservable
にすぐにtoPromise
演算子をつなぎ、Promise
を取得し、それを返します。普通http.get
は単一のデータを取得するため、Promise
に変換することは良い選択ですが、リクエストは必ずしも一つずつ処理するわけではありません。利用方法によっては、最初は単一リクエストだったものが、サーバがレスポンスを返す前に次のリクエストが送られることにより、単一リクエストでなくなる可能性があります。この様に連続した_リクエスト-キャンセル-新しいリクエスト_はPromise
で処理することが難しいですが、Observable
では容易です。
HeroSearchService
の作成を開始します。これは検索クエリをサーバAPIに送信します。
import { Injectable } from '@angular/core';
import { Http, Response } from '@angular/http';
import { Observable } from 'rxjs';
import { Hero } from './hero';
@Injectable()
export class HeroSearchService {
constructor(private http: Http) {}
search(term: string): Observable<Hero[]> {
return this.http
.get(`app/heroes/?name=${term}`)
.map((r: Response) => r.json().data as Hero[]);
}
}
HeroSearchService
のhttp.get()
はHeroService
のものに似ていますが、URLはクエ文字列(?name=${term}
)があります。もう一つの違いは、toPromise
を呼ばず、_observable_をそのまま返している事です。
HeroSearchComponent
HeroSearchComponent
を作り、HeroSearchService
を呼びます。
テンプレートはシンプルに、検索用の入力テキストボックスと、検索により一致したヒーローの一覧を表示する欄です。
<div id="search-component">
<h4>Hero Search</h4>
<input #searchBox id="search-box" (keyup)="search(searchBox.value)" />
<div>
<div *ngFor="let hero of heroes | async"
(click)="gotoDetail(hero)" class="search-result" >
{{hero.name}}
</div>
</div>
</div>
このコンポーネントのスタイルシートを作ります。
.search-result{
border-bottom: 1px solid gray;
border-left: 1px solid gray;
border-right: 1px solid gray;
width:195px;
height: 20px;
padding: 5px;
background-color: white;
cursor: pointer;
}
#search-box{
width: 200px;
height: 20px;
}
サーチボックスに文字を入れた時に、_keyup_イベント1がバインドされているコンポーネントのserach()
メソッドを、入力値とともに呼び出します。
*ngFor
はコンポーネントのheroes
プロパティの_hero_オブジェクトに対してループしますが、heroes
プロパティはヒーロー配列というより、_Observable_なヒーロー配列なのでasync
パイプ(AsyncPipe
)を通してでないと何もできません。async
パイプはObservable
と*ngFor
の結果を支援します。
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { HeroSearchService } from './hero-search.service';
import { Hero } from './hero';
@Component({
moduleId: module.id,
selector: 'hero-search',
templateUrl: 'hero-search.component.html',
styleUrls: [ 'hero-search.component.css' ],
providers: [HeroSearchService]
})
export class HeroSearchComponent implements OnInit {
heroes: Observable<Hero[]>;
private searchTerms = new Subject<string>();
constructor(
private heroSearchService: HeroSearchService,
private router: Router) {}
// 検索条件をobservableストリームに入れる。
search(term: string): void {
this.searchTerms.next(term);
}
ngOnInit(): void {
this.heroes = this.searchTerms
.debounceTime(300) // 300ms イベントを止める
.distinctUntilChanged() // 次の検索条件が以前と同じなら無視する
.switchMap(term => term // 新しいobservableに変換する
// http検索observableを返す
? this.heroSearchService.search(term)
// または条件がないとき、空のヒーローのobservableを返す
: Observable.of<Hero[]>([]))
.catch(error => {
// TODO: 現実のエラーハンドリングを行う
console.log(error);
return Observable.of<Hero[]>([]);
});
}
gotoDetail(hero: Hero): void {
let link = ['/detail', hero.id];
this.router.navigate(link);
}
}
検索条件
まずは、searchTerms
にフォーカスを合わせます。
private searchTerms = new Subject<string>();
// 検索条件をobservableストリームに入れる。
search(term: string): void {
this.searchTerms.next(term);
}
Subject
は、_observable_イベントストリームを作成します。searchTerms
は、Observable
文字列を作成します。ヒーロー名検索フィルタの判定基準(criteria)です。
search
を呼びだし、next
により、新しい文字列をsubject
のObservable
ストリームに格納します。
ヒーロー一覧プロパティの初期化 (NGONINIT)
Subject
は、Observable
です。検索条件のストリームをHero
配列に変え、heroes
プロパティに代入します。
heroes: Observable<Hero[]>;
ngOnInit(): void {
this.heroes = this.searchTerms
.debounceTime(300) // 300ms イベントを止める
.distinctUntilChanged() // 次の検索条件が以前と同じなら無視する
.switchMap(term => term // 新しいobservableに変換する
// http検索observableを返す
? this.heroSearchService.search(term)
// または条件がないとき、空のヒーローのobservableを返す
: Observable.of<Hero[]>([]))
.catch(error => {
// TODO: 現実のエラーハンドリングを行う
console.log(error);
return Observable.of<Hero[]>([]);
});
}
キー入力が直接HeroSearchService
を呼び出すとHTTPリクエストが嵐の様に発生します。これは良い手段ではありません。サーバリソース料や、ネットワーク使用料を払いたくありません。Observable
の演算子群をObservable
文字列をつなぐことで、リクエストの数を減らします。HeroSearchService
に対して少ないリクエスト行い、タイムリーに結果を返します。やり方は以下の通りです。
-
debounceTime(300)
: 最後の文字列の後から300ms、新しい文字列イベントの流れを止める。 -
distinctUntilChanged
: フィルターのテキストが変わったときのみ、リクエストを送る。同じ検索条件で繰り返しリクエストしても無駄。 -
switchMap
: 検索条件をdebounce
とdistinctUntilChanged
に通して、検索サービスを呼び出す。前回以前に検索した全ての_observable_の取り消し、破棄を行い、最後の検索結果の_observable_のみを返す。
switchMap operator^switchMapは、とても賢いです。
各キーイベントがhttp
イベントを呼び出します。300msのリクエストの間に複数のHTTPリクエストを飛ばすことができます。switchMap
は一番新しいhttp
メソッドからobservableを返す間、一番古いリクエストを保持します。検索テキストが空の場合、http
メソッドの呼び出しを短絡に行い、空の配列を持つobservableを返します。実はHeroSearchService
呼び出しをキャンセルした場合、未解決のHTTPリクエストを中止しません。このトピックは別の日に。今は、期待しない結果を破棄していることに満足しましょう。
-
catch
は失敗したobservable
を横取りします。今回コンソールにエラーを出力しましたが、現実にはうまくやってください。今回は空配列をobservable
に入れ検索結果をクリアしています。
RxJS
演算子のインポート
先述の通り、Angularの基本のObservable
の実装には、RxJSの演算子はありません。インポートして拡張する必要があります。演算子をインポートすることでObservable
を拡張しますが、以下の例では異なったアプローチにより、RxJSの演算子の組み合わせた一つのファイルをインポートしObservable
の拡張します。
// Observable class extensions
import 'rxjs/add/observable/of';
import 'rxjs/add/observable/throw';
// Observable operators
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/distinctUntilChanged';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/switchMap';
このファイルをAppModule
でimportし、全ての演算子を一度にロードします。
import './rxjs-extensions';
ダッシュボードに検索コンポーネントを追加
DashboardComponent
のテンプレートに検索機能を追加します。
<h3>Top Heroes</h3>
<div class="grid grid-pad">
<div *ngFor="let hero of heroes" (click)="gotoDetail(hero)" class="col-1-4">
<div class="module hero">
<h4>{{hero.name}}</h4>
</div>
</div>
</div>
<hero-search></hero-search>
最後に、HeroSearchComponent
をインポートし、declarations
配列に追加します。
declarations: [
AppComponent,
DashboardComponent,
HeroDetailComponent,
HeroesComponent,
HeroSearchComponent
],
ブラウザを更新しダッシュボードに移動しテキストを入力すると、以下の様に表示されます。
アプリケーションの構造とコード
チュートリアル実施により、出来上がったアプリのファイルおよびコードは、公式にあります。ご参考願います。
以上でチュートリアルは終了です。お疲れ様でした。
-
keyupイベントは、キーボードを押す -> 離す により実行されます
HeroSearchComponent
クラスとメタデータを以下の様に作成します。要点を後ほど説明します。 ↩