Tutorial: Get Data from a server

kukudas·2022년 2월 18일
0

Angular

목록 보기
10/15

Get data from a server

이번 듀토리얼에서는 앵귤러의 HttpClient를 사용해서
data persistence 기능을 아래처럼 추가할 것임.

  • HeroService는 hero 데이터를 HTTP request로 가져옴
  • 유저가 HTTP를 통해서 hero를 추가, 수정, 삭제, 저장할 수 있음.
  • 유저가 hero를 이름을 통해서 찾을 수 있음.

Enable HTTP services

HttpClient는 remote server와 HTTP로 통신하기 위한
앵귤러의 mechanism임.

두 단계를 통해서 HttpClient가 앱 전체에서 사용가능하도록
만들 것임.
첫째로 AppModuleHttpClient를 import 해준 후에
둘쨰로 AppModule안에서 HttpClientModuleimports 배열안에
추가해줌.

// src/app/app.module.ts
import { HttpClientModlue } from `@angular/common/http';`
...
@NgModule({
  ...
  imports: [
    ...
    HttpClientModlue
  ],
  ...
})
export class AppModule { }

Simulate a data server

이 듀토리얼에서는 In-memeory Web API 모듈을 사용해서
remote data 서버와의 통신을 모방할 것임.

모듈을 설치하고 나면 앱은 HttpClient를 통해서 request를 보내고 response를 받을때
In-memeory Web API가 request를 intercept해서 in-memeory data stroe에 적용하고,
시뮬레이트된 response를 보내주게 되는데 앱은 In-memeory Web API가 하는 것을 모르고
실제 HTTP로 통신이 오고가는거로 알게됨.

아래 명령어로 npm에서 In-memory Web API 패키지설치

// --save default로 들어가는거라서 없어도됨
C:\Users\Home\Desktop\angular\angular-tour-of-heroes 여기가서 해야함.
npm install angular-in-memory-web-api --save

AppModule에서 HttpClientInMemortyWebApiModuelInMemoryDataService 클래스를
import 해줌.
추가로 AppModuleimports배열에 HttpClientInMemortyWebApiModuel
InMemoryDataService로 configure해서 넣어줌.

// src/app/app.module.ts
import { HttpClientInMemortyWebApiModuel } from 'angular-in-memory-web-api';
import { InMemoryDataService } from './in-memory-data.service'

@NgModule({
  ...
  imports: [
    HttpClientModlue,
    HttpClientInMemortyWebApiModuel.forRoot(
      InMemoryDataService, { dataEncapsulationL: false }
    )
  ],
  ...
})
export class AppModule { }

forRoot() configuration 메소드는 인메모리 데이터베이스를 prime하는 InMemoryDataService 클래스를 받아서
configuration을 해줌.

src/app/in-memory-data-service.ts 클래스를 아래 명령어로 생성하기

cd C:\Users\Home\Desktop\angular\angular-tour-of-heroes\src\app
ng gernerate service InMemoryData

in-memory-data.service.ts의 코드를 아래처럼 해줌.

// src/app/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};
  }

  // Overrides the genId method to ensure that a hero always has an id.
  // If the heroes array is empty,
  // the method below returns the initial number (11).
  // if the heroes array is not empty, the method below returns the highest
  // hero id + 1.
  genId(heroes: Hero[]): number {
    return heroes.length > 0 ? Math.max(...heroes.map(hero => hero.id)) + 1 : 11;
  }
}

in-memory-data.service.tsmock-heroes.ts가 하는 기능을 넘겨받을 것임. 그렇지만 아직 이 듀토리얼에서 mock-heroes.ts를 좀 더 사용할거라서 지우면안됨.

Heroes and HTTP

HeroService에서 HttpClientHttpHeaders를 import 해주고 HttpClient를 constructor에 private property인 http로 inject해줌.
MessageService를 아직 injet하고 있는데 MessageService를 자주 부르게 될거여서 private log() 메소드에 넣어주기.
추가로 heroesUrl을 서버의 hero resource 주소로:base/:collectionName의 형식으로 정의해줌. base는 request된 resource고 collectionNamein-memory-data-service.ts의 heroes data object임.

// src/app/hero.service.ts
import { HttpClient, HttpHeaders } from '@angular/common/http';

export class HeroService {
  
  private heroesUrl = 'api/heroes';
  
  constructor(
    private http: HttpClient,
    private messageService: MessageService) { }

    /** Log a HeroService message with the MessageService */
  private log(message: string) {
    this.messageService.add(`HeroService: ${message}`);
  }
  
}

Get heroes with HttpClient

지금까지 만들었던 HeroService.getHeroes()는 RxJS의 of() 함수를 이용해서 mock heroes를 Observable<Hero[]>를 리턴해줬음.

HeroService.getHeroes() 메소드를 아래처럼 HttpClient를 사용하도록 바꿔줌.

// src/app/hero.service.ts
/** GET heroes from the server */
getHeroes(): Observable<Hero[]> {
  return this.http.get<Hero[]>(this.heroesUrl)
}

새로고침하면 hero data가 mock server로부터 잘 들어오는것을 볼 수 있음. 이전과 달리 async 해서 바로 로딩이 안돼는 것을 볼 수 있음.

of()http.get()로 바꿔줘도 앱이 잘 동작하는데 두 함수 모두 Observable<Hero[]>를 리턴하기 때문임.

HttpClient methods return one value

모든 HttpClient 메소드는 RxJS Observable을 리턴함.

HTTP는 request/response 프로토콜임. request를 보내면 , 하나의 response를 돌려줌.

일반적으로 observable은 시간이 지나면서 여러개의 값을 리턴 가능하지만 HttpClient의 observable은 언제나 하나의 값을 emit하고 끝나게되면 다시 emit 안함.

HttpClient.get()Observable<Hero[]>을 리턴하는데 이는 hero array의 observable을 말하는 것임. 실제로 하나의 hero array만 리턴해줌.

HttpClient.get() returns response data

HttpClient.get()는 default로 HTTP response의 body를 untyped JSON objec로 리턴해줌. optional type specifier인 <Hero[]>를 적용해서 타입스크립트 capability를 추가하여 컴파일 타임에 에러를 줄여줌.

서버의 데이터 API가 JSON의 shape를 결정하는데 듀토리얼에서 만든 data API는 hero data를 배열로 리턴해줌.

다른 API들을 내가 원하는 데이터를 object 안에 넣어서 줄 수 있음. 따라서 Observable result를 RxJS의 map()을 이용해서 빼내야할 수 있음.
여기서는 안다루는데 getHeroNo404() 메소드에 map()에 대하 예시 코드가 있음.

Error handling

데이터를 remote server에서 가져올때는 문제가 발생하기 쉬움. 따라서 HeroService.getHeroes() 메소드는 에러를 처리할 수 있어야함.

에러를 잡기 위해서는 http.get()에서 나온 observable 결과를 RxJX의 catchError() operator를 사용해서 pipe 해야함.

rxjs/operators에서 catchError symbol과 다른 여러 operators를 import해줌.

추가로 observable result를 pipe()를 사용해서 extend 해주고 거기에 catchError() operator를 줌.

// src/app/hero.service.ts
import { catchError, map, tap } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class HeroService {

  getHeroes(): Observable<Hero[]> {
    return this.http.get<Hero[]>(this.heroesUrl)
      .pipe(
        catchError(this.handleError<Hero[]>('getHeroes', []))
      );
  }
}

catchError() operator는 실패한 Observable을 가로챔. 그리고 catchError()는 에러를 에러 핸들링 함수에 넘겨줌.

handleError() 메소드는 에러가 났다고 보고하고 앱이 계속 돌아갈 수 있도록 무해한 result를 리턴해줌.

  • handleError
    아래의 handleError()HeroService의 여러 메소드에서 사용될 것이기 떄문에 각각의 필요에 맞출 수 있게 일반화해야함.

에러를 직접 handling 하는 대신에 catchError로 실패한 작업의 이름이랑 safe return value(에러가 났으니 받아도 문제없이 진행할 수 있도록 처리한 값)으로 configure 한 error handler 함수를 리턴해줌.

// src/app/hero.service.ts
/**
 * Handle Http operation that failed.
 * Let the app continue.
 *
 * @param operation - name of the operation that failed
 * @param result - optional value to return as the observable result
 */
private handleError<T>(operation = 'operation', result?: T) {
  return (error: any): Observable<T> => {

    // TODO: send the error to remote logging infrastructure
    console.error(error); // log to console instead

    // TODO: better job of transforming error for user consumption
    this.log(`${operation} failed: ${error.message}`);

    // Let the app keep running by returning an empty result.
    return of(result as T);
  };
}

에러를 콘솔에 알려준 후에 핸들러는 유저 친화적인 메시지를 만들고 앱이 계속 돌아갈 수 있도록 save value를 리턴해줌.

각각의 서비스 method가 다른 종류의 Observable result를 리턴하기에 handleError()는 타입 parameter를 받아서 앱이 기대하는 safe value를 parameter로 받은 타입으로 리턴해줌.

Tap into the Observable

HeroServicelog() 메소드를 사용해서 페이지 하단의 메시지를 보여주는 공간에 tap into the flow of observabl을 하고 메시지를 보내줌.

이거를 RxJS의 tap() operator를 통해서 할건데, 얘는 obervable value들을 보고 그 value들로 뭔가를 한다음에 전달(passes them along)해줌. tap() 콜백은 value들은 건들지 않음.

아래는 getHeroes()tap()으로 opertaion을 log한 마지막 버전임.

// src/app/hero.service.ts
/** GET heroes from the server */
getHeroes(): Observable<Hero[]> {
  return this.http.get<Hero[]>(this.heroesUrl)
    .pipe(
      tap(_ => this.log('fetched heroes')),
      catchError(this.handleError<Hero[]>('getHeroes', []))
    );
}

tap(_ => this.log('fetched heroes')) 여기서 _는 파이썬이랑 똑같이 인자를 받지만 신경쓰지 않는다는 것임.

Get hero by id

대부분의 web API는 get by id request를 :baseURL/:id의 형식으로 지원함.

여기서 base URL은 heroesURLapi/heroes고 id는 가져오고자 하는 hero의 id임. 예를들어서 api/heroes/11임.

HeroServicegetHero()를 아래처럼 바꿔주기.

// src/app/hero.service.ts
/** GET hero by id. Will 404 if id not found */
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}`))
  );
}

getHero()getHeroes()랑 3가지 다른 점이 있음.

  • getHero()는 필요한 hero의 id를 사용해서 request URL을 생성함.
  • 서버는 hero의 array가 아닌 하나의 hero를 response로 보내줌.
  • getHero()는 observable of Hero 배열이 아닌 Observable<Hero>("an observable of Hero objects")를 보내줌.

Delete a hero

hero 리스트에서 각각의 hero에 대해 삭제 버튼이 있어야함.

HeroesComponent 템플릿에 버튼 element를 추가하기.

<!--
	src/app/heroes/heroes.component.html
-->
<ul class="heroes">
  <li *ngFor="let hero of heroes">
    <a routerLink="/detail/{{hero.id}}">
      <span class="badge">{{hero.id}}</span> {{hero.name}}
    </a>
    <button type="button" class="delete" title="delete hero"
      (click)="delete(hero)">x</button>
  </li>
</ul>

컴포넌트 클래스에서 delete() 핸들러를 넣어줌.

// src/app/heroes/heroes.component.ts
delete(hero: Hero): void {
  // 삭제하고자 하는 hero를 제외하고 heroes 리스트를 새로 만들어줌.
  this.heroes = this.heroes.filter(h => h !== hero);
  this.heroService.deleteHero(hero.id).subscribe();
}

컴포넌트가 HeroService한테 hero 삭제를 위임하지만 컴포넌트가 heroes 리스트를 업데이트하는 책임은 가지고 있음.
컴포넌트의 delete() 메소드는 리스트에서 삭제하고자 하는 hero를 즉시 삭제하고(브라우저에서 보면 바로 삭제됨) HeroService가 서버에서도 삭제하기를 기대함. 컴포넌트에서만 지운거라서 서버에서는 지워진지 아닌지 모름.

subscribe()를 안쓰면 서비스는 delete retquest를 서버로 보내지 않음. Observable은 뭔가가 subscribe하기전에는 아무것도 하지않음.

deleteHero() 메소드를 HeroService에 추가해줌.

// src/app/hero.service.ts
/** DELETE: delete the hero from the server */
deleteHero(id: number): Observable<Hero> {
  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'))
  );
}

키 포인트는 다음과 같음.

  • deleteHero()HttpClient.delete()를 호출함.
  • URL은 heroes resource에 삭제하고자하는 hero의 id를 추가한것임.
  • put()이나 post()처럼 데이터를 보내지 않아도됨.
  • httpOptions도 보내줘야함.

브라우저를 새로고침하면 삭제기능이 작동하는것을 볼 수 있음.

Search by name

저번 연습에서 Observable operator의 체인을 통해서 비슷한 HTTP request의 숫자를 줄이고 네트워크 밴드윗을 효율적으로 사용하는것을 배웠음.

이제 대쉬보드에 hero를 이름으로 찾는 기능을 추가할거임. 유저가 search box에 이름을 넣으면 HTTP request로 해당 이름에 대한 hero를 찾을거임. 목표는 필요한 만큼의 reqeust만 보내는것임.

HeroService.searchHeroes()

HeroServicesearchHeroes() 메소드를 추가하는 것으로 시작함.

// src/app/hero.serivce.ts
/* GET heroes whose name contains search term */
searchHeroes(term: string): Observable<Hero[]> {
  if (!term.trim()) {
    // if not search term, return empty hero array.
    return of([]);
  }
  return this.http.get<Hero[]>(`${this.heroesUrl}/?name=${term}`).pipe(
    tap(x => x.length ?
       this.log(`found heroes matching "${term}"`) :
       this.log(`no heroes matching "${term}"`)),
    catchError(this.handleError<Hero[]>('searchHeroes', []))
  );
}

// 여기서 근데 ?을 해야하는이유가머임? undefined가 올 수있음?

메소드는 search term이 없으면 빈 배열을 리턴해줌. 나머지 코드는 getHeroes()랑 비슷한데 하나 다른점은 URL이 찾고자 하는 term을 포함한 query string이라는 점임.

Add search to the Dashboard

DashboardComponent 템플릿을 열어서 hero search element인 <app-hero-search>를 제일 아래에 추가해줌.

<!--
	src/app/dashboard/dashboard.component.html
-->
<h2>Top Heroes</h2>
<div class="heroes-menu">
  <a *ngFor="let hero of heroes"
      routerLink="/detail/{{hero.id}}">
      {{hero.name}}
  </a>
</div>

<app-hero-search></app-hero-search>

Create HeroSearchComponent

CLI로 HeroSearchComponent를 만들어줌

// cd C:\Users\Home\Desktop\angular\angular-tour-of-heroes\src\app
ng generate component hero-search

HeroSearchComponent을 아래처럼 바꿔주고 private CSS 스타일도 적용해줌.

<!--
	src/app/hero-search/hero-search.component.html
-->
<div id="search-component">
  <label for="search-box">Hero Search</label>
  <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>

유저가 search box에 타입을 하면 input event binding이 입력된 search box의 value를 가지고 컴포넌트의 search() 메소드를 호출함.

AsyncPipe

*ngFor는 hero object를 반복함. *ngForheroes가 아닌 heroes$라는 리스트를 순회하는 것을 볼 수 있음. $heroes$가 배열이 아닌 Observable이라는 것을 나타냄.

<!--
	src/app/hero-search/hero-search.component.html
-->
<li *ngFor="let hero of heroes$ | async" >

*ngForObservable로 뭘 할수가 없으니까 pipe character(|) 뒤에 async를 써줌. | async는 앵귤러의 AsyncPipe를 나타내는데 Observable을 자동으로subscribe해서 컴포넌트 클래스에서 해당 작업을 안해도 되게 해줌.

Edit the HeroSearchComponent class

HeroSearchComponent 클래스와 메타데이터를 아래처럼 바꿔줌.

// src/app/hero-search.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) {}

  // Push a search term into the observable stream.
  search(term: string): void {
    this.searchTerms.next(term);
  }

  ngOnInit(): void {
    this.heroes$ = this.searchTerms.pipe(
      // wait 300ms after each keystroke before considering the term
      debounceTime(300),

      // ignore new term if same as previous term
      distinctUntilChanged(),

      // switch to new search observable each time the term changes
      switchMap((term: string) => this.heroService.searchHeroes(term)),
    );
  }
}

heroes$Observable로 선언됨을 볼 수 있음.

// src/app/hero-search/hero-search.component.ts
heroes$!: Observable<Hero[]>;

The searchTerms RxJS subject

searchTerms property는 RxJS Subject임.

// src/app/hero-search/hero-search.component.ts
private searchTerms = new Subject<string>();

// Push a search term into the observable stream.
search(term: string): void {
  this.searchTerms.next(term);
}

Subject는 observable value의 source이면서 Observable 그 자체임. 따라서 Observable에 subscribe하는 것처럼 Subject에도 subscribe 할 수 있음.

next(value) 메소드로 값을 Observable push 할 수 있음.

<!--
	src/app/hero-search/hero-search.component.html
-->
<input #searchBox id="search-box" (input)="search(searchBox.value)" />

이제 유저가 textbox에 뭔가를 쳐서 입력하면 바인딩이 HeroSearchComponen클래스의 search()메소드를 textbox 값으로 부름(search term). searchTerm은 이제 Obeservable이 되서 search term들을 emit함( emitting a steady stream of search terms).

Chaining RxJS operators

유저가 키보드로 입력할 때마다 search term(찾을 hero)를 searchHeroes()로 넘기는거는 너무 많은 HTTP request와 서버 자원을 소모함.

대신에 ngOnInit() 메소드가 searchTerms observable을 searchHeroes()의 호출 횟수를 줄여주는 RxJS operator(debounceTime(), distinctUntilChanged(), switchMap()) 유저 입력도 잘 받고 서버의 부담도 줄여서 적절하게 나오는 hero search result들(각각이 Hero[]인)을 리턴해줌.

// src/app/hero-search/hero-search.component.ts
this.heroes$ = this.searchTerms.pipe(
  // wait 300ms after each keystroke before considering the term
  debounceTime(300),

  // ignore new term if same as previous term
  distinctUntilChanged(),

  // switch to new search observable each time the term changes
  switchMap((term: string) => this.heroService.searchHeroes(term)),
);
this.heroes$ = this.searchTerms.pipe

에서 아래 에러가나오는데
Type 'Observable' is not assignable to type 'Observable<Hero[]>'.
Type 'unknown' is not assignable to type 'Hero[]'.
아래에서 of([])를 리턴하게되면 Observable이 리턴되서 에러가 생겼던거임.
따라서 return of([] as unknown as Hero[]);로 타입 강제해주면 해결됨.

 // src/app/hero-service.ts
searchHeroes(term: string): Observable<Hero[]> {
   if (!term.trim()) {
     // if not search term, return empty hero array.
     return of([]);
   }

위 코드에서 각각의 operator는 다음과 같이 동작함.

  • debounceTime(300) 스트링이 들어온 후에 300밀리초를 기다린 후에만 다음 스트링 이벤트를 받을 수 있게함. 따라서 300밀리초에 한번만 reqeust를 보낼 수 있는거임.
  • distinctUntilChanged() filter text가 바뀌어야만 request를 보내줌.
  • switchMap()debounce()distinctUntilChanged()를 통과한 각각의 search term(유저가 입력한 찾고자 하는 hero)에 대해 search service를 불러줌. 이전의 search observable을 버리고 새로 찾은 search observable만 리턴해줌.

switchMap operator를 사용하면 조건에 만족하는 key evenvt만 HttpClient.get() 메소드를 호출할 수 있음. request간에 300밀리초의 간격이 있어도 네트워크상에는 여러개의 request가 있을 수 있고 이 request가 순서에 맞게 다시 돌아온다는 보장이 없음.
switchMap()은 original request order를 지키면서 가장 최근의 HTTP 메소드 호출에서 나온 하나의 ovservable만 리턴해줌. 따라서 가장 최근의 호출이 아닌 그 전의 호출들은 다 취소되고 버려짐.
이전 searchHeroes()의 Observable을 취소하는거는 HTTP request를 취소하는게 아니라서 result는 돌아오는데 얘네는 앱 코드로 들어오기전에 버려짐.

컴포넌트 클래스가 heroes$ observable을 subscribe하는게 아님을 주의해야함. subscribe는 템플릿의 AsyncPipe에서 하는거임.

  • HTTP를 사용하기 위해서 앱에 적절한 dependency들을 추가했음
  • HeroService를 refactor해서 hero를 web API로 가져올 수 있게 했음
  • HeroService를 확장해서 post(), put(), 그리고 delete() 메소드들을 지원할 수 있게 만들었음
  • 컴포넌트들을 업데이트해서 hero 추가, 수정, 삭제를 할 수 있게 했음
  • in-memory web API를 configure 했음
  • observable 사용법을 배웠음

출처

0개의 댓글