Angularのデータ通信
Vue.jsと比較をするためAngularの公式のチュートリアルを通じて学んできました。チュートリアルの項目は今回が最後になります。
In-memory Web APIというWebサーバーをエミュレートするAPIを使って、前回少し触れたRxJSを使って通信をします。
httpClient
httpClientはその名の通り、HTTPプロトコルでリモートサーバーと通信をするためのモジュールです。
この機能をアプリケーションのルートにインポートします。そうすることでどこでもモジュールが利用可能となります。
ここまで何度か修正を加えてきたsrc¥appフォルダにあるapp.module.tsに次の記述を加えます。
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 { HeroesComponent } from './heroes/heroes.component';
import { HeroDetailComponent } from './hero-detail/hero-detail.component';
import { MessagesComponent } from './messages/messages.component';
import { AppRouteingModule } from './app-routeing.module';
import { DashboardComponent } from './dashboard/dashboard.component';
import { HttpClientModule } from '@angular/common/http';
@NgModule({
declarations: [
AppComponent,
HeroesComponent,
HeroDetailComponent,
MessagesComponent,
DashboardComponent
],
imports: [
BrowserModule,
FormsModule,
AppRouteingModule,
HttpClientModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
In-memory Web API
ここまでサーバーから取得するはずのデータはRxJSの機能を使って静的に値を返していました。
チュートリアルではサーバーレスポンスをシミュレートするIn-memory Web APIを使っているのでそれに従います。
このAPIはデフォルトでは存在しませんのでコマンドラインのnpmから取得する必要があります。コマンドラインから(ここではWindowsのPowerShellを使っています)次のコマンドを実行します。これはsrcの一つ上のフォルダで実行します。
ページでは --saveとなっていましたが、ここでは--save-devとして開発時のパッケージとしてインストールします。
app.module.tsを再度修正します。importに追加したInMemoryDataServiceはこの後作成します。
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 { HeroesComponent } from './heroes/heroes.component';
import { HeroDetailComponent } from './hero-detail/hero-detail.component';
import { MessagesComponent } from './messages/messages.component';
import { AppRouteingModule } from './app-routeing.module';
import { DashboardComponent } from './dashboard/dashboard.component';
import { HttpClientModule } from '@angular/common/http';
import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';
import { InMemoryDataService } from './in-memory-data.service';
@NgModule({
declarations: [
AppComponent,
HeroesComponent,
HeroDetailComponent,
MessagesComponent,
DashboardComponent
],
imports: [
BrowserModule,
FormsModule,
AppRouteingModule,
HttpClientModule,
HttpClientInMemoryWebApiModule.forRoot(
InMemoryDataService, { dataEncapsulation: false }
)
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
InMemoryDataServiceを作成します。場所はscr¥appです。以前generate serviceで記述したケバブケース(-区切りのファイル名)はキャメルケース(文字の区切り毎に大文字)のクラス名に変換されると紹介しましたが、ここではその逆をしています。キャメルケースで作成するとファイル名はケバブケースになります。
作成したin-memory-data.service.tsを次のように編集します。
in-memory-data.service.ts
import { Injectable } from '@angular/core';
import { InMemoryDbService } from 'angular-in-memory-web-api';
import { Hero } from './hero';
@Injectable({
providedIn: 'root',
})
export class InMemoryDataService implements InMemoryDbService {
createDb() {
const heroes = [
{ id: 11, name: 'Dr 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};
}
genId(heroes: Hero[]): number {
return heroes.length > 0 ? Math.max(...heroes.map(hero => hero.id)) + 1 : 11;
}
}
genIdはint(11)の数値を返すメソッドで、新しいidを発行すする役目をになっています。ここでは既存のメソッドをオーバーライド(上書き)してそれを自作しています。
中身は三項演算子になっていて、heroes配列の長さが0以下だったら11、そうでなければMath.max以下で計算される値を返します。
Math.maxでは,で区切った引数の中から最大の値を取得する関数です。そこで取得した最大値に+1して新たなidを作成しています。
mapは配列の中身を読み出し新たな配列を作る関数で、内側の関数でhero.idを返しているのでidだけの配列ができます。
mapで作成したidの配列を...(スプレッド構文)により展開してMath.maxの引数の規則に一致するようにしています。
httpClientでデータを取得する
以前作成したheroサービスを修正してhttpClientからデータを取得するように変更します。今後行う変更をすべて摘要するとファイルは次のようになります。
hero.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, of } from 'rxjs';
//※1エラートラップのためにcatchError, map, tapをインポートしています。
import { catchError, map, tap } from 'rxjs/operators';
import { Hero } from './hero';
import { MessageService } from './message.service';
@Injectable({ providedIn: 'root' })
export class HeroService {
//WebClientがアクセスするURLです。
private heroesUrl = 'api/heroes';
//通信時のヘッダをここで定義しています。
httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' })
};
constructor(
private http: HttpClient,
private messageService: MessageService) { }
/** サーバーからヒーローを取得する */
getHeroes(): Observable<Hero[]> {
return this.http.get<Hero[]>(this.heroesUrl)
.pipe(
tap(heroes => this.log('fetched heroes')),
catchError(this.handleError<Hero[]>('getHeroes', []))
);
}
/** IDによりヒーローを取得する。idが見つからない場合は`undefined`を返す。 */
getHeroNo404<Data>(id: number): Observable<Hero> {
const url = `${this.heroesUrl}/?id=${id}`;
return this.http.get<Hero[]>(url)
.pipe(
map(heroes => heroes[0]), // {0|1} 要素の配列を返す
tap(h => {
const outcome = h ? `fetched` : `did not find`;
this.log(`${outcome} hero id=${id}`);
}),
catchError(this.handleError<Hero>(`getHero id=${id}`))
);
}
/** IDによりヒーローを取得する。見つからなかった場合は404を返却する。 */
getHero(id: number): Observable<Hero> {
const url = `${this.heroesUrl}/${id}`;
return this.http.get<Hero>(url).pipe(
tap(_ => this.log(`fetched hero id=${id}`)),
catchError(this.handleError<Hero>(`getHero id=${id}`))
);
}
/* 検索語を含むヒーローを取得する */
searchHeroes(term: string): Observable<Hero[]> {
if (!term.trim()) {
// 検索語がない場合、空のヒーロー配列を返す
return of([]);
}
return this.http.get<Hero[]>(`${this.heroesUrl}/?name=${term}`).pipe(
tap(_ => this.log(`found heroes matching "${term}"`)),
catchError(this.handleError<Hero[]>('searchHeroes', []))
);
}
//////// Save methods //////////
/** POST: サーバーに新しいヒーローを登録する */
addHero(hero: Hero): Observable<Hero> {
return this.http.post<Hero>(this.heroesUrl, hero, this.httpOptions).pipe(
tap((newHero: Hero) => this.log(`added hero w/ id=${newHero.id}`)),
catchError(this.handleError<Hero>('addHero'))
);
}
/** DELETE: サーバーからヒーローを削除 */
deleteHero(hero: Hero | number): Observable<Hero> {
const id = typeof hero === 'number' ? hero : hero.id;
const url = `${this.heroesUrl}/${id}`;
return this.http.delete<Hero>(url, this.httpOptions).pipe(
tap(_ => this.log(`deleted hero id=${id}`)),
catchError(this.handleError<Hero>('deleteHero'))
);
}
/** PUT: サーバー上でヒーローを更新 */
updateHero(hero: Hero): Observable<any> {
return this.http.put(this.heroesUrl, hero, this.httpOptions).pipe(
tap(_ => this.log(`updated hero id=${hero.id}`)),
catchError(this.handleError<any>('updateHero'))
);
}
//エラー処理のための関数定義
/**
* 失敗したHttp操作を処理します。
* アプリを持続させます。
* @param operation - 失敗した操作の名前
* @param result - observableな結果として返す任意の値
*/
private handleError<T>(operation = 'operation', result?: T) {
return (error: any): Observable<T> => {
// TODO: リモート上のロギング基盤にエラーを送信する
console.error(error); // かわりにconsoleに出力
// TODO: ユーザーへの開示のためにエラーの変換処理を改善する
this.log(`${operation} failed: ${error.message}`);
// 空の結果を返して、アプリを持続可能にする
return of(result as T);
};
}
}
上記のコード中で、筆者がわかりづらかった部分を補足説明します。
- `バッククォート。
スクリプト中`バッククォートで囲まれた部分ではその中で変数を使うことができます。その際通常の文字特別するために変数名は${}の中に記述します。
- heroesUrl
In-memory Web APIを使用してデータを受信する場合、URLはデフォルトで:base/:collection/:idという規則になっています。collectionの部分にInMemoryDataServiceで定義したheroesを設定しています。baseは「クエストが行われるリソース」ということですが、apiがある場所ぐらいに考えておきましょう。
:baseの部分をapiから変更したい場合は、InMemoryDataServiceの設定をしているapp.module.tsでapiBaseを使って設定します。
HttpClientInMemoryWebApiModule.forRoot(
InMemoryDataService, { dataEncapsulation: false, apiBase: 'some/api/'}
)ただしこの設定は文字列を判定しません。in-memory-web-api :「Default parseRequestUrl」で解説されているように、単に/で区切られた数で判定しているのでsome/api/と設定した場合でも、a/b/やfoo/bar/でアクセスができてしまいます。
これはデフォルトでも同じ挙動で、今回のサンプルの場合heroesUrlの'api/heroes'の「api」の値を何にしても同じ結果が得られます。
- HttpClient.get,post,put,delete
チュートリアル内では「HttpClient.get()はデフォルトではレスポンスの本文を型のないJSONで返しますと記述されています。」と書いてありますが、InMemoryWebApiではAPIが常にObservableで値を返すようです。
getのほかHTTPリクエストメソッドに準じて、post(実体の送信)、put(置き換え)、delete(削除)を指示します。
また通常と同じように?でパラメータを渡すことができます。ここで作成するパラメータは日本語文字列でもそのままセットすることができます。
- Observable.pipe
ところどころでObservableのpipeメソッドを利用しています。これはObservableの値を受け取るものです。
pipeの中では,で区切る形で値を順に処理していくことができます。ここではmapで絞り込んだ配列にして、tapで配列に対して操作しています。また catchErrorで例外を受け取った際の挙動も定義しています。
- handleError関数
エラーで想定しない値が返り処理が止まってしまうのを防ぐために継続処理可能な値を返すために設定しています。
まず引数の部分です。operationはoperation: string='operation'の省略形になっていると思われます。=で初期値を設定しています。
resultの?の部分は、引数をオプションにしています。JavaScriptでは引数はすべて省略が可能でしたが、TypeScriptではすべて省略不可となっています。そのためオプションにしたい場合は?を変数名の後ろに付けます。
この関数は関数を返します。any(すべて)の型を第一引数にもちObservableを返します。その際、handleErrorの呼び出し元からジェネリクスで受け取った型を、Observableに渡しています。
as Tの部分は型アサーションです。resultをジェネリクスで受け取った型に指定しています。
データの更新
InMemoryWebApiを使っている場合、サーバー側のデータの変更も簡単にエミューレートしてくれます。まず変更を保存するために、src¥app¥hero-detailにある、hero-detail.component.htmlと、hero-detail.component.tsを次のように修正します。
データを実際に更新するのは先に紹介したHeroServiceのupdateHeroメソッドです。データの更新はサービスで行われるべきなのでここではデータを操作するための記述を行いません。データを操作するための関数の入ったObservableを受け取って、subscribeで実行指示をするだけです。処理が終わったら、goBackメソッドで前の画面に戻ります。
hero-detail.component.html
<div *ngIf="hero">
<h2>{{hero.name | uppercase}} Details</h2>
<div><span>id: </span>{{hero.id}}</div>
<div>
<label>name:
<input [(ngModel)]="hero.name" placeholder="name"/>
</label>
</div>
<button (click)="goBack()">go back</button>
<button (click)="save()">save</button>
</div>
hero-detail.component.ts
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Location } from '@angular/common';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html',
styleUrls: [ './hero-detail.component.css' ]
})
export class HeroDetailComponent implements OnInit {
hero: Hero;
constructor(
private route: ActivatedRoute,
private heroService: HeroService,
private location: Location
) {}
ngOnInit(): void {
this.getHero();
}
getHero(): void {
const id = +this.route.snapshot.paramMap.get('id');
this.heroService.getHero(id)
.subscribe(hero => this.hero = hero);
}
goBack(): void {
this.location.back();
}
save(): void {
this.heroService.updateHero(this.hero)
.subscribe(() => this.goBack());
}
}
同様にsrc¥app¥heroesにあるheroes.component.htmlとheroes.component.tsを修正して、削除と追加の機能を与えます。
inputタグ中の、#heroNameの部分はテンプレート変数と呼ばれます。ここでは該当のinputエレメントを#heroNameとして定義しています。この変数はテンプレート中のどこからでも利用できます。#は定義する時だけ付けます。
inputの後の挙動は、addでinputの値(value)を渡したあと、クリアしています。;で区切って複数の処理を記述できます。ただデータ操作はテンプレートでなくモジュールで行われるべきものなので複雑な処理を書くのは避けます。
heroes.component.html
<h2>My Heroes</h2>
<div>
<label>Hero name:
<input #heroName />
</label>
<!-- (click) 入力値をadd()に渡したあと、入力をクリアする -->
<button (click)="add(heroName.value); heroName.value=''">
add
</button>
</div>
<ul class="heroes">
<li *ngFor="let hero of heroes">
<a routerLink="/detail/{{hero.id}}">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</a>
<button class="delete" title="delete hero"
(click)="delete(hero)">x</button>
</li>
</ul>
heroes.component.ts
import { Component, OnInit } from '@angular/core';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
export class HeroesComponent implements OnInit {
heroes: Hero[];
constructor(private heroService: HeroService) { }
ngOnInit() {
this.getHeroes();
}
getHeroes(): void {
this.heroService.getHeroes()
.subscribe(heroes => this.heroes = heroes);
}
add(name: string): void {
name = name.trim();
if (!name) { return; }
this.heroService.addHero({ name } as Hero)
.subscribe(hero => {
this.heroes.push(hero);
});
}
delete(hero: Hero): void {
this.heroes = this.heroes.filter(h => h !== hero);
this.heroService.deleteHero(hero).subscribe();
}
}
検索機能の追加
最後にアプリに検索機能を付与します。検索機能はhero-searchコンポーネントを作成して、dashbordに追加します。
まずsrc¥app¥dashboardのdashboard.component.htmlに次のように修正します。
dashboard.component.ts
<h3>Top Heroes</h3>
<div class="grid grid-pad">
<a *ngFor="let hero of heroes" class="col-1-4"
routerLink="/detail/{{hero.id}}">
<div class="module hero">
<h4>{{hero.name}}</h4>
</div>
</a>
</div>
<app-hero-search></app-hero-search>
hero-searchコンポーネントを作成します。
hero-search.component.html
<div id="search-component">
<h4><label for="search-box">Hero Search</label></h4>
<input #searchBox id="search-box" (input)="search(searchBox.value)" />
<ul class="search-result">
<li *ngFor="let hero of heroes$ | async" >
<a routerLink="/detail/{{hero.id}}">
{{hero.name}}
</a>
</li>
</ul>
</div>
hero-search.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import {
debounceTime, distinctUntilChanged, switchMap
} from 'rxjs/operators';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-hero-search',
templateUrl: './hero-search.component.html',
styleUrls: [ './hero-search.component.css' ]
})
export class HeroSearchComponent implements OnInit {
heroes$: Observable<Hero[]>;
private searchTerms = new Subject<string>();
constructor(private heroService: HeroService) {}
// 検索語をobservableストリームにpushする
search(term: string): void {
this.searchTerms.next(term);
}
ngOnInit(): void {
this.heroes$ = this.searchTerms.pipe(
// 各キーストロークの後、検索前に300ms待つ
debounceTime(300),
// 直前の検索語と同じ場合は無視する
distinctUntilChanged(),
// 検索語が変わる度に、新しい検索observableにスイッチする
switchMap((term: string) => this.heroService.searchHeroes(term)),
);
}
}
ここではRxJSからObservableだけでなく、Subject、debounceTime、distinctUntilChanged、switchMapをインポートしています。
debounceTimeは処理の実行を待機する指示です。
distinctUntilChangedは前と同じ場合は処理を中止する処理です。ここでは引数をなにも持っていませんがprevとcurrentという前回値と今回値を引数にもつ関数を渡し、その中でtrueを返すことで処理を止めることも可能です。
ここで処理が中止されるとheroes$は書き換わりませんので、同じ値が表示されたままになります。
switchMapでは非同期が終わる前に次の処理が来ると前の処理をキャンセルする機能です。これにより余計な通信を削減しています。