使用 JSON Server + JSON2TS + HttpClient + Service + DI Container 存取 Web API

前後端分離後,前端除了負責顯示邏輯外,最重要的就是與 API 溝通。在 JQuery 只要使用 $.ajax() 就可存取 API;但在 Angular,則必須透過 service 與 DI container ,component 才可存取 API,完全遵守 依賴反轉原則

Version


Node.js 8.9.0
Angular 5.1.0
JSON Server 0.12.1

User Story


http000

  • Add Post : 使用 POST 對 API 新增資料
  • Show All Posts : 使用 GET 對 API 抓資料

Architecture


http001

  • AppComponent : 專門負責顯示邏輯
  • PostService : 專門負責連接 API 與資料處理部份
  • IPostService : 由 AppComponent 觀點所定義出的 interface
  • HttpClient : Angular 4.3 所新增處理 AJAX 的 class

依賴反轉原則

高階模組不應該依賴低階模組;兩著都應該依賴高階模組所定義的 interface

AppComponent 不直接依賴 PostService,且 AppComponentPostService 都反過來依賴 AppComponent 所定義的 IPostService interface。

Implementation


JSON Server

前後端分離後,前後端所依據的就是 JSON 資料,傳統前端會在先 var 一個 JSON,等要連 API 時,再用 $.ajax() 去改變 JSON 資料,這樣雖然可行,但必須去修改 code。

http020

比較好的方式是改用 JSON Server,讓我們實際去模擬 HTTP 的 GET/POST/PUT/PATCH 與 DELETE,只要透過 proxy,就會連上 JSON Server,若不透過 proxy,就連上 API Server,無論怎麼切換,都不用去修改 code。

安裝 JSON Server

1
$ npm install -g json-server

將 JSON Server 安裝在 global 環境。

http002

第一次啟動 JSON Server

1
2
3
4
~$ cd MyProject
~/MyProject$ mkdir json-server
~/MyProject$ cd json-server
~/MyProject/json-server$ json-server db.json

http003

  1. 進入專案目錄
  2. 在專案目錄建立 json-server 子目錄
  3. 進入 json-server 子目錄
  4. 第一次啟動 JSON Server,指定 db.json 為資料庫檔案
  5. JSON Server 預設會啟動在 http://localhost:3000 ,並提供 postscommentsprofile 3 個 table

http004

  1. db.json 存在,則 JSON Server 會以此檔案為資料庫,若不存在,則會建立新的 db.json
  2. 預設會有 postscommentsprofile 3 個 table

測試 JSON Server

預設 db.json 已經有資料,可藉此測試 JSONServer 是否有成功啟動。

1
http://localhost:3000/posts

http005

使用 Postman 測試 GET

  1. 選擇 GET
  2. 輸入 http://localhost:3000/posts
  3. 正確回傳 JSON

http006

使用 Postman 測試 POST

  1. 選擇 POST
  2. 輸入 http://localhost:3000/posts
  3. 選擇 Headers
  4. Key 為 Content-Type,value 為 application/json

http007

  1. 選擇 Body
  2. 選擇 raw
  3. 選擇 JSON (application/json)
  4. 輸入要新增的 JSON 資料

不用包含 id,JSON Server 會自動幫你新增

http008

  1. 選擇 db.json
  2. 剛剛由 Postman 新增的資料會寫入 db.json

建立 JSON Server Routes

我們可以自行在 db.json 建立新的 table,但預設 URI 都會在 root,可能不適合使用 (一般都會放在 /api 下,如 /api/posts),我們可自行加入 route 加以 mapping。

json-server/routes.json

1
2
3
{
"/api/posts": "/posts"
}

json-server 目錄下新增 routes.json,將 /api/posts 對應到 JSON Server 的 /posts
http009

  1. json-server 目錄下新增 routes.json
  2. /api/routes 對應到 JSON Server 的 /posts

建立 Angular CLI Proxy

Angular CLI 預設並不會使用 JSON Server,因此我們必須讓 Angular 會透過 proxy 去打 JSON Server。

proxy.conf.json

1
2
3
4
5
6
{
"/api": {
"target": "http://localhost:3000",
"secure": false
}

}

在專案根目錄下新增 proxy.conf.json,將 /api mapping 到 http://localhost:3000,也就是 JSON Server。

http010

  1. 在專案根目錄下新增 proxy.conf.json
  2. /api 對應到 JSON Server 的 http://localhost:3000

由 npm 管理 JSON Server 與 Proxy

由於每次都要啟動 JSON Server 與 proxy,且參數有點繁瑣,比較好的方式是統一由 npm 來管理。

package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"name": "ng5-http-client",
"version": "0.0.0",
"license": "MIT",
"scripts": {
"ng": "ng",
"start": "ng serve",
"proxy": "ng serve --proxy-config proxy.conf.json",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e",
"json-server": "json-server ./json-server/db.json --routes ./json-server/routes.json"
},
(略)
}

13 行

1
"json-server": "json-server ./json-server/db.json --routes ./json-server/routes.json"

新增 npm json-server,除了指定使用 db.json 外,還會使用自訂 route。

第 8 行

1
"proxy": "ng serve --proxy-config proxy.conf.json",

原本 npm start 得以保留,此為不透過 proxy 方式,另外新增 npm proxy,將會透過 proxy 使用 JSON Server。

http011

  1. 選擇 package.json
  2. 新增 proxy 使 Angular CLI 透過 proxy 使用 JSON Server
  3. 新增 json-server 啟動 JSON Server

每次要開發專案時 :

1
~/MyProject$ npm run json-server

npm run json-server 執行 JSON Server

http012

1
~/MyProject$ npm run proxy

npm run proxy 透過 proxy 使用 JSON Server

http013

必須開兩個 terminal 分別執行 npm run json-servernpm run proxy,因為都必須同時在背景執行

JSON Schema to TypeScript

後端會以 JSON Schema 定義 JSON 格式與型別,而 Angular 亦會使用 interface 定義 JSON 的型別提供檢查。

透過 JSON Schema to TypeScript,我們可以直接將 JSON Schema 轉成 TypeScript interface,可節省時間,也可避免 typo。

安裝 JSON Schema to TypeScript

1
$ npm install -g json-schema-to-typescript

將 JSON schema to TypeScript 安裝在 global 環境。

JSON Schema

post.schema.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"title": "Post",
"type": "object",
"properties": {
"id": {
"type": "number"
}
,

"title": {
"type": "string"
}
,

"author": {
"type": "string"
}

}
,

"additionalProperties": false,
"required": ["title", "author"]
}

定義了 idtitleauthor 3 個欄位,其中 titleauthor 為必填。

將 JSON Schema 轉 TypeScript Interface

1
~/MyProject/src/app/model$ json2ts post.schema.json post.model.ts

目前將 post.schema.json 放在 /src/app/model 目錄下。

使用 json2tspost.schema.json 轉成 post.model.ts

http014

TypeScript Interface

post.model.ts

1
2
3
4
5
6
7
8
9
10
11
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/


export interface Post {
id?: number;
title: string;
author: string;
}

json2ts 所轉出來的 TypeScript interface,最上面有 json2ts 所產生的註解。

將然若有 JSON 欄位異動,請不要直接修改 TypeScript interface,而應該直接去修改 JSON Schema,再由 json2ts 產生 TypeScript interface

http015

  1. json2ts 所產生的 post.model.ts
  2. json2ts 所產生的註解
  3. json2ts 所轉出的 TypeScript interface

AppComponent

http017

app.component.html

1
2
3
4
5
6
7
<input type="text" [(ngModel)]="title">
<button (click)="onClick()">Add Post</button>
<ul>
<li *ngFor="let post of posts">
{{ post.id}} {{post.title}} {{post.author}}
</li>
</ul>

第 1 行

1
<input type="text" [(ngModel)]="title">

title 的輸入框,直接使用 ngModel 的 two way binding 到 title field。

第 2 行

1
<button (click)="onClick()">Add Post</button>

Button 的 click event 對應到 AppComponent.onClick()

第 4 行

1
2
3
4
5
<ul>
<l *ngFor="let post of posts">
{{ post.id}} {{post.title}} {{post.author}}
</li>
</ul>

使用 *ngFor 將所有 posts 展開。

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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { Component, OnInit } from '@angular/core';
import { IPostService } from './service/post/ipostservice.interface';
import { Post } from './model/post.model';

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

constructor(private postService: IPostService) {
}

ngOnInit(): void {
this.getAllPosts();
}

onClick() {
const post = <Post>{
'title' : this.title,
'author' : 'Sam'
};

this.postService.addPost(post)
.subscribe(
value => console.log(value),
error => console.log(error),
() => console.log('PUT completed')
);

this.getAllPosts();
}

private getAllPosts() {
this.postService.getAllPosts()
.subscribe(
value => this.posts = value,
error => console.log(error),
() => console.log('GET completed')
);
}
}

11 行

1
2
title: string;
posts: Post[];

要與 HTML 做 data binding 的東西都會宣告在 public field。

  • title : 給 <input type="text"> 做 two-way binding
  • posts : 給 *ngFor 做 data binding

14 行

1
2
constructor(private postService: IPostService) {
}

PostService 依賴注入,其型別為 IPostService interface。

依賴反轉原則

高階模組不應該依賴低階模組;兩著都應該依賴高階模組所定義的 interface

所以 AppComponent 不應該直接依賴 PostService,而應該依賴 IPostService interface,因此 PostService 的型別是 IPostService

17 行

1
2
3
ngOnInit(): void {
this.getAllPosts();
}

一載入時,希望能顯示所有 Post

37 行

1
2
3
4
5
6
7
8
private getAllPosts() {
this.postService.getAllPosts()
.subscribe(
value => this.posts = value,
error => console.log(error),
() => console.log('GET completed')
);
}

因為 ngOnInit()onClick() 都會呼叫 PostService.getAllPosts(),基於 DRY 原則,將共用抽到 getAllPosts()

因為呼叫 PostServicegetAllPosts(),也因此期望 IPostService interface 有 getAllPosts()

PostService.getAllPost() 回傳為 Observable,因此要使用 subscribe() 訂閱 Observable

subscribe() 共有 3 個參數 :

  • next : 當 service 的非同步執行完後,下一個 要執行的動作,value 會傳回 Post[],因此可直接指定給 this.posts
  • error : 當 HttpClient 執行錯誤時所執行的 arrow function
  • complete : 當 HttpClient 執行完成時所執行的 arrow function

21 行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
onClick() {
const post = <Post>{
'title' : this.title,
'author' : 'Sam'
};

this.postService.addPost(post)
.subscribe(
value => console.log(value),
error => console.log(error),
() => console.log('PUT completed')
);

this.getAllPosts();
}

新增 Post 到資料庫。

因為呼叫 PostServiceaddPost(),傳入 Post model,也因此希望 IPostService interface 有 addPost()

PostService.addPost() 也是回傳 Observable,因此要使用 subscribe() 訂閱 Observable

IPostService

http018

ipostservice.interface.ts

1
2
3
4
5
6
7
import { Post } from '../../model/post.model';
import { Observable } from 'rxjs/Observable';

export abstract class IPostService {
abstract addPost(post: Post): Observable<Post>;
abstract getAllPosts(): Observable<Post[]>;
}

根據 AppComponent 的需求,我們希望 IPostServiceaddPost()getAllPosts() 兩個 method。

IPostService 觀念上是 interface,所以理想上應該這樣寫 :

1
2
3
4
export interface IPostService {
addPost(post: Post): Observable<Post>;
getAllPosts(): Observable<Post[]>;
}

但若實際這樣寫,TypeScript 編譯沒問題,但 ng serve 時會執行錯誤。

http016

主要在於 interface 是 TypeScript 所擴充,在編譯成 JavaScript 後並沒有 interface,導致 DI container 在執行時找不到 interface 而造成 run-time 錯誤。

也就是說 interface 在 TypeScript 主要是用來 編譯檢查

抽象 除了使用 interface 實現外,也可以使用 abstract class,在 ES6 有 abstract class,在 ES5 也有相對應的解法,所以目前必須改用 abstract class 取代 interface 實現 抽象

在 Angular 的 DI container,觀念上是 interface,但實作上須與 JavaScript 妥協,改用 abstract class 實踐 interface

PostService

http019

post.service.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Injectable } from '@angular/core';
import { IPostService } from './ipostservice.interface';
import { Post } from '../../model/post.model';
import { Observable } from 'rxjs/Observable';
import { HttpClient } from '@angular/common/http';

@Injectable()
export class PostService implements IPostService {
constructor(private httpClient: HttpClient) { }

addPost(post: Post): Observable<Post> {
return this.httpClient
.post<Post>('api/posts', post);
}

getAllPosts(): Observable<Post[]> {
return this.httpClient
.get<Post[]>('api/posts');
}
}

第 7 行

1
@Injectable()

因為加了 @Injectable() decorator,所以 PostService 可以透過 DI container 依賴注入。

並不是所有的 class 都可以由 DI container 依賴注入,必須要加上 @Injectable decorator 的 class 才可以

第 8 行

1
export class PostService implements IPostService {

因為 依賴反轉原則PostService 必須實作 IPostService interface。

第 9 行

1
constructor(private httpClient: HttpClient) { }

因為要使用 HttpClient,將 HttpClient 注入到 PostService

11 行

1
2
3
4
addPost(post: Post): Observable<Post> {
return this.httpClient
.post<Post>('api/posts', post);
}

使用 HttpClient.post<T>() 執行 POST,將 Post model 傳入,其中 <T> 傳入 Post model 的型別 Post

HttpClient.post<T>() 回傳的型別為 Observable,其本質為 Post model,會將傳進來的 Post 再傳回 Observable<Post>

由於 subscribe() 最後要由 component 決定,因此將 Observale 回傳給 component 去 subscribe()

由於在 addPost() 並沒有 subscribe(),因此 addPost() 目前為止還沒有真的執行 POST

16 行

1
2
3
4
getAllPosts(): Observable<Post[]> {
return this.httpClient
.get<Post[]>('api/posts');
}

使用 HttpClient.get<T>() 執行 GET,其中 <T> 為回傳的 Post[] 型別。

HttpClient.get<T>() 回傳的型別為 Observable,其本質為 Post[]

由於 subscribe() 最後要由 component 決定,因此將 Observale 回傳給 component 去 subscribe()

由於在 getAllPosts() 並沒有 subscribe(),因此 getAllPosts() 目前為止還沒有真的執行 GET

在 Angular 2 所提供的 Http,至今仍可用,但用法較麻煩 :

1
2
return this.http.get('api/posts')
.map(response => response.json());

Angular 4.3 的 HttpClient 改用泛型後,除了在 get()post() 都可以明顯看到型別,語意較佳,且寫法也教精簡,不用再透過 map() 去轉成 JSON。

實務上建議改用 HttpClient,不要再使用 Http

AppModule

app.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { IPostService } from './service/post/ipostservice.interface';
import { PostService } from './service/post/post.service';
import { HttpClientModule } from '@angular/common/http';
import { FormsModule } from '@angular/forms';

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

13 行

1
2
3
4
5
imports: [
BrowserModule,
HttpClientModule,
FormsModule
],
  • HttpClient 須使用 HttpClientModule
  • [(ngModel)] 須使用 FormsModule

18 行

1
2
3
providers: [
{provide: IPostService, useClass: PostService}
],

DI container 一定要提供 interface 與 class 的 mapping。

providers 後為 object array,每一個 object 為 interface 與 class 的 mapping。

  • provide : interface 名稱
  • useClass : class 名稱

為什麼要使用 Service?


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

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

簡單的說,將 API 部分獨立成 service,目的要使 component 與 API 解耦合,且讓 component 不直接相依於 service,兩者僅相依於 component 所定義的 interface,符合 依賴反轉原則

Conclusion


  • JSON Server : 讓我們在開發階段模擬真實的 API server
  • JSON Schema to TypeScript : 將 JSON Schema 轉成 TypeScript interface,更有效率也避免 typo
  • HttpClient : 語意更佳,也更簡單的寫法存取 API
  • Angular DI Container : Angular 實現 依賴反傳依賴注入 的解決方案

Sample Code


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

2017-12-24