Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
14
Help us understand the problem. What is going on with this article?
@gambare

Angluar2のクイックスタートとチュートリアルを実施 - その7

More than 3 years have passed since last update.

前回の投稿Angluar2のクイックスタートとチュートリアルを実施 - その6の続きです。
前回作成したソースを使用します。

本章ではhttpを通してリモートサーバーのweb APIを呼び出し、データの取得・追加・削除する方法を学びます。

本章もかなり長いです...

本投稿の参照元(英語)

HTTP - ts

本章で学ぶこと

前述のほか、以下を学びます。

  • インメモリ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/httpHttpModuleは、HTTPサービスのすべてを提供します。このサービスはアプリのどこからでもアクセスできます。
AppModuleimportsHttpModuleを追加します。

app/app.module.ts
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コマンドを実行し、最新のライブラリを使用してください。

app/app.module.ts
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クラスを取得します。

app/in-memory-data.service.ts
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を使用するように変更しましょう。

app/hero.service.ts
  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);
  }

インポートは以下の様に変更します。

app/hero.service.ts
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のObservabletoPromise演算子のような便利な機能を使うためには、演算子を独自に加える必要がありますがRxJSライブラリから取得するだけなので簡単です。

import 'rxjs/add/operator/toPromise';

thenコールバックによるデータの取得

プロミスのthenコールバックでは、HTTPのResponsejsonメソッドを呼び出し、データを取り出せます。

.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()メソッドを呼び出す様にします。

app/hero-detail.component.html
<button (click)="save()">Save</button>

saveメソッドはheroServiceupdate()メソッドを使用し、ヒーローの名前を変更します。同時に一つ前のビューに戻ります。

app/hero-detail.component.ts
save(): void {
  this.heroService.update(this.hero)
    .then(this.goBack);
}

ヒーローサービスのupdate()メソッド

update()メソッドは、getHeroes()メソッドに似ていまが、HTTP putにより永続的な変更を行います。

app/hero.service.ts
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と識別させます。

ブラウザを更新し、ヒーロー名を変更してください。変更が永続化されます。

ヒーローの追加

ヒーローを追加するためには、ヒーロー名が必要です。ヒーロー名を入れる要素とボタンを用意します。ヒーロー一覧のテンプレートの見出しの後に以下を追加します。

app/heroes.component.html
<div>
  <label>Hero name:</label> <input #heroName />
  <button (click)="add(heroName.value); heroName.value=''">
    Add
  </button>
</div>

クリックイベントのレスポンスでコンポーネントのクリックハンドラー(add()メソッド)を呼びます。また次のヒーロー名を入力できるように、入力フィールドを空にします。

app/heroes.component.ts
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クラスに実装します。

app/hero.service.ts
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>要素は以下の様になります。

app/heroes.component.html
  <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ハンドラはちょっと手がこみます。

app/heroes.component.ts
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を更新します。

app/heroes.component.css
button.delete {
  float:right;
  margin-top: 2px;
  margin-right: .8em;
  background-color: gray !important;
  color:white;
}

ヒーローサービスのdelete()メソッド

HTTPのdelete()メソッドにより、ヒーローをサーバから削除します。

app/hero.service.ts
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を返します。現在HeroServiceObservableを(toPromiseにより)Promiseに変換して返していますが、ここではObservableをそのまま返す方法および、なぜそうするのかを学びます。

バックグラウンド

observableは配列の様に処理ができるストリームです。
Angularコアはobservableのベース部分をサポートし、RxJS Observablesにある演算子などによる拡張をサポートしています。ここでは軽く説明します。
HeroServicehttp.getの結果のObservableにすぐにtoPromise演算子をつなぎ、Promiseを取得し、それを返します。普通http.getは単一のデータを取得するため、Promiseに変換することは良い選択ですが、リクエストは必ずしも一つずつ処理するわけではありません。利用方法によっては、最初は単一リクエストだったものが、サーバがレスポンスを返す前に次のリクエストが送られることにより、単一リクエストでなくなる可能性があります。この様に連続したリクエスト-キャンセル-新しいリクエストPromiseで処理することが難しいですが、Observableでは容易です。

HeroSearchServiceの作成を開始します。これは検索クエリをサーバAPIに送信します。

app/hero-search.service.ts
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[]);
  }
}

HeroSearchServicehttp.get()HeroServiceのものに似ていますが、URLはクエ文字列(?name=${term})があります。もう一つの違いは、toPromiseを呼ばず、observableをそのまま返している事です。

HeroSearchComponent

HeroSearchComponentを作り、HeroSearchServiceを呼びます。
テンプレートはシンプルに、検索用の入力テキストボックスと、検索により一致したヒーローの一覧を表示する欄です。

app/hero-search.component.html
<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>

このコンポーネントのスタイルシートを作ります。

app/hero-search.component.css
.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の結果を支援します。
HeroSearchComponentクラスとメタデータを以下の様に作成します。要点を後ほど説明します。

app/hero-search.component.ts
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により、新しい文字列をsubjectObservableストリームに格納します。

ヒーロー一覧プロパティの初期化 (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: 検索条件をdebouncedistinctUntilChangedに通して、検索サービスを呼び出す。前回以前に検索した全てのobservableの取り消し、破棄を行い、最後の検索結果のobservableのみを返す。

switchMap operator2(英語)は、とても賢いです。
各キーイベントがhttpイベントを呼び出します。300msのリクエストの間に複数のHTTPリクエストを飛ばすことができます。switchMapは一番新しいhttpメソッドからobservableを返す間、一番古いリクエストを保持します。検索テキストが空の場合、httpメソッドの呼び出しを短絡に行い、空の配列を持つobservableを返します。実はHeroSearchService呼び出しをキャンセルした場合、未解決のHTTPリクエストを中止しません。このトピックは別の日に。今は、期待しない結果を破棄していることに満足しましょう。

  • catchは失敗したobservableを横取りします。今回コンソールにエラーを出力しましたが、現実にはうまくやってください。今回は空配列をobservableに入れ検索結果をクリアしています。

RxJS演算子のインポート

先述の通り、Angularの基本のObservableの実装には、RxJSの演算子はありません。インポートして拡張する必要があります。演算子をインポートすることでObservableを拡張しますが、以下の例では異なったアプローチにより、RxJSの演算子の組み合わせた一つのファイルをインポートしObservableの拡張します。

app/rxjs-extensions.ts
// 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し、全ての演算子を一度にロードします。

app/app.module.ts
import './rxjs-extensions';

ダッシュボードに検索コンポーネントを追加

DashboardComponentのテンプレートに検索機能を追加します。

app/dashboard.component.html
<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配列に追加します。

app/app.module.ts
  declarations: [
    AppComponent,
    DashboardComponent,
    HeroDetailComponent,
    HeroesComponent,
    HeroSearchComponent
  ],

ブラウザを更新しダッシュボードに移動しテキストを入力すると、以下の様に表示されます。
aaa

アプリケーションの構造とコード

チュートリアル実施により、出来上がったアプリのファイルおよびコードは、公式にあります。ご参考願います。


以上でチュートリアルは終了です。お疲れ様でした。



  1. keyupイベントは、キーボードを押す -> 離す により実行されます 

  2. 以前は"flatMapLatest"と呼ばれていたそうです。 

14
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
gambare

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
14
Help us understand the problem. What is going on with this article?