包含 DI 與 RxJS 觀念

Angular 為前端 framework,因此必須依賴後端 API 提供資料,最常見的就是透過 HTTP GET 抓 JSON,這個看似簡單的動作,在 Angular 並不是單一 method 可完成,必須透過 DI 與 RxJS ,才能順利抓到資料。

Version

Angular CLI 1.1.2
Angular 4.2.3

HTTP GET

為了聚焦在 Angular 的 HTTP GET,在此我們就不自建後端 API,而使用網路上現成的 API 做示範。

httpget000

JSONPlaceholder 提供了現成的 API 服務,非常適合 Angular 練習使用。

httpget001

我們將使用 https://jsonplaceholder.typicode.com/posts API 作為示範。

httpget002

回傳為 JSON 物件陣列,每個物件有 userIdidtitlebody 4 個欄位。

Service 部分


建立 Service

Angular 除了引入 component 概念外,還提供了 service 概念:

  1. 負責前端商業邏輯
  2. 負責前端顯示邏輯
  3. 負責與 API 溝通

Angular 另外一個重要觀念 : component,則相當於後端 MVC 的 controller,負責管理 HTML,CSS 與 service。

由於我們要透過 HTTP GET 抓 JSONPlaceholder API 資料,因此必須先建立 service。

1
$ ng g s post

使用 Angular CLI 建立 PostService,Angular CLI 會自動幫我們在 class 名稱加上 Service,,因此在建立時只要提供 post 即可。

完整應為 ng generate service,但實務可取第一個字母即可,即 ng g s

httpget003

Angular CLI 會幫我們建立 2 個檔案:

  • post.service.ts:class 名稱自動會以大駝峰命名為 PostService
  • post.service.spec.tsPostService 的單元測試檔。

其中 PostService 還會自動加上 @Injectable decorator,表示此 class 可透過 provider 完成 DI。

1
2
> WARNING Service is generated but not provided, it must be provided to be used
>

Angular CLI 還會特別加上警告:此 service 僅被建立而已,還必須透過 provider 才能使用

Module 提供 Service

Angular CLI 僅幫我們建立了 service 而已,我們還必須由 module 的 provider 提供 service,才能完成 DI。

src/app/app.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';

import {AppComponent} from './app.component';
import {HttpModule} from '@angular/http';
import {PostService} from './post.service';

@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule
],
providers: [PostService],
bootstrap: [AppComponent]
})
export class AppModule { }

AppModule 為 Angular 預設的 module,每個 Angular 專案一定會有此 module。

15 行

1
providers: [PostService],

為了要讓 AppModule 的 provider 提供 PostService,須在 [] 陣列中加上 service 名稱:PostService,整個 service 才算建立完成,之前 Angular CLI 的 warning 就是在警告這件事情。

httpget004

若你覺得建立 service 後,還要另外修改 AppModule 很麻煩,也可以在使用 Angular CLI 建立 service 時,直接下 ng g s PostService -m app-m 表示 PostService 要由 AppModule 提供,Angular CLI 會自動幫我們在 AppModuleproviders 加上 PostService

使用 Http Class

Angular 提供了 Http class,讓我們使用 XMLHttpRequest 向後端 API 要資料。

src/app/post.service.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import {Injectable} from '@angular/core';
import 'rxjs/add/operator/map';
import {Observable} from 'rxjs/Observable';
import {Post} from './post';
import {Http, Response} from '@angular/http';

@Injectable()
export class PostService {
private getPostsURI = 'https://jsonplaceholder.typicode.com/posts';

constructor(private http: Http) { }

getPosts(): Observable<Post[]> {
return this.http.get(this.getPostsURI)
.map((response: Response) => response.json());
}
}

第 9 行

1
private getPostsURI = 'https://jsonplaceholder.typicode.com/posts';

將 API 網址寫在 class 的 private property。

11 行

1
constructor(private http: Http) { }

我們要透過 Http class 對後端 API 抓資料,因此要透過 constructor DI Http class。

為什麼 Http class 可以 DI 呢?

angular/packages/http/src/http.ts

1
2
3
@Injectable()
export class Http {
constructor(protected _backend: ConnectionBackend, protected _defaultOptions: RequestOptions) {}

因為 Http class 本身也是 @Injectable(),所以可以透過 constructor DI Http class。

13 行

1
2
getPosts(): Observable<Post[]>  {
}

新增 getPost() method,負責將 HTTP GET 的資料回傳。

注意其回傳型別為 Observable,因為 Angular Http classget(),已經整合了 RxJS,所以回傳為 Observable,因此 service 回傳也應該為 Observable,才能由 component 決定 subscribe()

其中 Observable 的泛型為 Post[],畢竟 API 回傳的資料,其實是 JSON 物件的陣列,每個物件有 userIdidtitlebody 4 個欄位,可將此 4 個欄位視為 Post ViewModel,所以回傳的資料本質為 Post[],會在稍後建立 Post ViewModel。

14 行

1
return this.http.get(this.getPostsURI)

既然已經在 constructor DI Http class,就可以使用 this.http.get() 對後端 API 抓資料。

值得注意的是,get() 的回傳值型別為 Observable<Response>,並不是我們想要回傳的 Observable<Post[]> ,因此仍需要進一步的轉換。

15 行

1
.map((response: Response) => response.json());

由於 get() 回傳的為 Observable<Response>,因此 RxJS 的 operator 都可以拿來用,其中最常用的就是 map(),我們可以利用 map()Observable<Response> 轉成 Observable<Post[]>

map() 傳入 arrow function,因為 get() 回傳為 Observable<Response>,因此 map() 會將 Response 物件傳進 arrow function 的第 1 個參數,我們就可透過 response.json() 回傳 Post[]

其中 response: ResponseResponse,目前 WebStorm 無法自動 import,必須手動加上 import {Response} from '@angular/http';,正常來說,WebStorm 會對型別自動 import,不過在 arrow function 內的參數型別,目前 WebStorm 還無法自動 import,需手動 import。

httpget006

實物上的商業邏輯,會需要更複雜的轉換與判斷,RxJS 提供了很豐富的 operator 可使用,詳細請參考 ReactiveX Operator

載入 HttpModule

src/app.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';

import {AppComponent} from './app.component';
import {HttpModule} from '@angular/http';
import {PostService} from './post.service';

@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
HttpModule
],
providers: [PostService],
bootstrap: [AppComponent]
})
export class AppModule { }

12 行

1
2
3
4
imports: [
BrowserModule,
HttpModule
],

由於 Http class 隸屬於 HttpModule,因此在 app.moduleimports 必須手動加上 HttpModule

目前 WebStorm 只能自動 import class,但還無法自動 import module,必須手動處理。

httpget005

為什麼我們 DI Http class 時,都不用自己用 provider 提供 Http class,但自己寫的 service 卻要 provider 提供呢?

angular/packages/http/src/http_module.ts

1
2
3
4
@NgModule({
providers: [
{provide: Http, useFactory: httpFactory, deps: [XHRBackend, RequestOptions]},
})

HttpModule 的 provider 中已經提供 Http class,因此我們不必自己手動提供,但PostService 因為是自己建立的,所以必須在 AppModule 手動提供。

建立 ViewModel

src/app/post.ts

1
2
3
4
5
6
export interface Post {
userId: number,
id: number,
title: string,
body: string
}

建立 Post ViewModel 提供 userIdidtitlebody 4 個 property,因為要給外界使用,記得加上 export

到目前為止,service 部分已經完成,接下來是 component 部分。

Component 部分


Component 部分有兩種寫法,一種是使用 async pipe,一種是使用 subscribe() ,將分別討論。

使用 Async Pipe

src/app/app.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import {Component, OnInit} from '@angular/core';
import {PostService} from './post.service';
import {Observable} from 'rxjs/Observable';
import {Post} from './post';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
posts: Observable<Post[]>;

constructor(private postService: PostService) {
}

ngOnInit(): void {
this.posts = this.postService.getPosts();
}
}

12 行

1
posts: Observable<Post[]>;

將從 API 抓下來的資料設定為 posts property,因為 PostService.getPosts() 回傳為 Observable<Post[]> 型別,所以 posts 型別也為 Observable<Post[]>

14 行

1
2
constructor(private postService: PostService) {
}

由 constructor DI PostService,因為 PostService@Injectable,且在 AppModule 的 providers 已經提供 PostService,因此可以順利 DI。

17 行

1
2
3
ngOnInit(): void {
this.posts = this.postService.getPosts();
}

若要在 AppComponent 一開始執行時就執行一段程式,可在 component 內實踐 ngOnInit() method,此為 OnInit interface 所定義。

postService.getPosts() 結果指定給 this.posts

src/app/app.component.html

1
2
3
4
5
6
7
8
9
10
11
12
<ul>
<li *ngFor="let post of posts|async">
Post ID: {{ post.id }}
<br>
User ID: {{ post.userId }}
<br>
Title: {{ post.title }}
<br>
Body: {{ post.body }}
<hr>
</li>
</ul>

將 API 全部回傳資料顯示。

第 2 行

1
<li *ngFor="let post of posts|async">

特別在 posts 之後加上 async pipe。

RxJS 的 Observable 有個特性,會在 subscribe() 後才真正執行向後端 API 抓資料,若在 HTML template 加上 async pipe,則會自動在顯示資料時加以 subscribe(),並在執行結束加以 unsubscribe()

使用 Subscribe()

src/app/app.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import {Component, OnInit} from '@angular/core';
import {PostService} from './post.service';
import {Post} from './post';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
posts: Post[];

constructor(private postService: PostService) {
}

ngOnInit(): void {
this.postService.getPosts()
.subscribe(
(posts: Post[]) => this.posts = posts,
(error: any) => console.log(error),
() => console.log('Get posts completed')
);
}
}

11 行

1
posts: Post[];

posts 的型別從 Observable<Post[]> 改成 Post[]

16 行

1
2
3
4
5
6
7
8
ngOnInit(): void {
this.postService.getPosts()
.subscribe(
(posts: Post[]) => this.posts = posts,
(error: any) => console.log(error),
() => console.log('Get posts completed')
);
}

getPosts() 回傳值為 RxJS 的 Observable<Post[]>,必須在 component 下了 subscribe()之後,才會真正的向後端 API 抓資料。

subscribe() 有 3 個參數,可分別傳入 arrow function:

  • nextsubscribe() 後接下來要做的事情,由於 map() 已經在 service 內完成,要做的只剩下將 posts 指定到 this.posts
  • error:抓 API 出錯該做的事情,相當於 try catch finallycatch
  • complete:抓 API 結束該做的事情,相當於 try catch finallyfinally

其中 next 為必須,errorcomplete 可視需求省略。

需要自己 unsubscribe() 嗎?

RxJS 中,若 Observable 沒有 completed 的一天,就需要手動 unsubscribe(),但因為 Http.get() 執行完後就會 completed,所以就不需要 unsubscribe() 動作。

src/app/app.component.html

1
2
3
4
5
6
7
8
9
10
11
12
<ul>
<li *ngFor="let post of posts">
Post ID: {{ post.id }}
<br>
User ID: {{ post.userId }}
<br>
Title: {{ post.title }}
<br>
Body: {{ post.body }}
<hr>
</li>
</ul>

第 2 行

1
<li *ngFor="let post of posts">

async 拿掉。

httpget007

實務上該使用 async pipe 或是 subscribe() 呢?

就功能面而言,兩種寫法結果都正確,但就程式的可維護性而言,建議使用 subscribe()

一般來說,HTML template 建議只用於 data binding,盡量不要寫程式碼,因為日後 debug 時,注意力都是放在 TypeScript 部分,比較不會注意 HTML template,所以若有程式碼藏在 HTML template,較不容易被發現。

此外,在 component 看到 subscribe(),也可很容易看出這是 RxJS 的 Observable,有助於日後維護。

為什麼要使用 Service?

或許你會認為,明明使用 XMLHttpRequest 向後端 API 抓資料是很單純的事情,為什麼 Angular 還要大費周章透過 service + DI,不是提供一個簡單的 method 就好了嗎?有幾個原因:

  • 將來若其他 component 要使用 API 時,將 service 直接 DI 進 compoent 即可。
  • 將來若要對 API 的 service 做抽換,可直接透過 DI 換掉即可。
  • 將來若要對 component 做單元測試,可輕易的 mock API service 即可。

間單的說,將 API 部分獨立成 service,目的要使 component 與 API 解耦合

Conclusion

  • 簡單的 HTTP GET 需求,就可以讓我們學會 DI 與 RxJS。
  • 實務上建議使用 subscribe(),程式的可維護性較高。
  • 使用 service 存取 API,而不在 component 內存取 API,可讓 component 與 API 解耦合。

Sample Code


完整的範例可以在我的 GitHub 上找到。

Reference


ReativeX Operator

2017-06-24