RxJS 的 BehaviorSubject 為 Observable Data Service 的核心技術

當 HTML 都 component 化之後,看似美好,但卻也導出另外一個棘手的問題 : component 間共用的資料該如何處理? React 是以 Redux 來解決這個問題,當然在 Angular 也可使用類似 Redux 的 NgRx,但 Redux 稍嫌複雜,而 Observable Data Service 較為簡單。顧名思義,Observable 採用的是 RxJS,而 Data Service 採用是 DI。

Version


Node.js 8.9.3
Angular CLI 1.6.2
Angular 5.1.2
RxJS 5.5.2

User Story


ods000

  • 一開始會顯示所有 post
  • 當輸入 post 名稱,按下 Add Post 後,新增的 post 會顯示在下方
  • 顯示目前 post 筆數
  • 按下 Reload 會重新從 API server 下載最新資料

Task


obs001

  • 原來該有的功能必須保留,但拆成 4 個 component
  • 但 4 個 component 會共用一份資料,且互相影響

Architecture


ods003

Observable Data Service vs. 普通 Service

相同點 :

  • 兩者本質上都是 service
  • 都使用 @Injectable decorator
  • 都是回傳 Observable

相異點 :

  • Observable data service 內部會使用 BehaviorSubject 儲存一份資料;而普通 service 會直接回傳 HttpClientObservable

ods000

  • 原本只有 AppComponent,現在分成 AddPostComponentListPostsComponentShowCountComponentReloadPostsComponent 4 個 component
  • Component 依然注入 PostService,不過此時不是普通 service,而是 observable data service,為 4 個 component 共用的資料
  • 根據 依賴反轉原則,component 不直接相依於 PostService,而是兩者相依於 interface,根據 介面隔離原則,component 只相依於它所需要的 interface,因此訂出 IAddPostIListPostsIShowCountIReloadPosts 4 個以 component 為導向的 interface,PostService 必須實踐此 4 個 interface
  • Component 只負責 subscribe PostService,當資料改變時,PostService 會自動更新 component

Implementation


AppComponent

app.component.html

1
2
3
4
5
<app-add-post></app-add-post>
<app-list-posts></app-list-posts>
<app-show-count></app-show-count>
<p></p>
<app-reload-posts></app-reload-posts>

原本只有一個 AppComponent 時,所有 HTML 都在 app.component.html 下,現在因為都切成 component,所以只會看到 component 的 selector 而已。

切 Component 心法

切 component 與切 class、切 function 的心法一樣,就是 單一職責原則,讓你的 component 只有一個目的,且只有一個改變 component 的理由

理想的 component 會看到很多其他 component 的 selector,而不是一個 component 有大量的 HTML

AddPostComponent

ods004

add-post.component.html

1
2
<input type="text" [(ngModel)]="title">
<button (click)="onAddPostClick()">Add Post</button>

只負責新增 post 的 component,因此只有 <input><button>

add-post.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
import { Component } from '@angular/core';
import { Post } from '../../model/post.model';
import { IAddPost } from '../../interface/iadd-post.interface';

@Component({
selector: 'app-add-post',
templateUrl: './add-post.component.html',
styleUrls: ['./add-post.component.css']
})
export class AddPostComponent {
title: string;

constructor(private postService: IAddPost) { }

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

this.postService.addPost(post);

this.title = '';
}
}

15 行

1
2
3
4
5
6
7
8
9
10
onAddPostClick() {
const post: Post = {
'title': this.title,
'author': 'Sam'
};

this.postService.addPost(post);

this.title = '';
}

onAddPostClick() 負責回應 <button>click event。

因為 addPost() 傳入的型別為 Post,因此先宣告 postPost 型別,並準備 post 物件。

透過 PostService.addPost() 傳入 post

最後將 <input> 清空,因為 <input>title 使用 ngModel 的 two-way binding,因此對 title 指定空字串,就相當於清空 <input>

13 行

1
constructor(private postService: IAddPost) { }

因為 onAddPostClick() 使用了 PostService,因此需要 DI 注入 PostService

根據 依賴反轉原則,我們不應該直接相依於 PostService,而應該相依於 AddPostComponent 所定義出的 IAddPost interface。

iadd-post.interface.ts

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

export abstract class IAddPost {
abstract addPost(post: Post): void;
}

根據 介面隔離原則,因為 AddPostComponent 只使用了 addPost(),因此 IAddPost interface 也應該只有 addPost()

ListPostsComponent

ods005

list-posts.component.html

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

只負責顯示post 的 component,因此只有 <ul><li>

posts$ 因為為 Observable,因此要搭配 async pipe 做 subscribe。

list-posts.component.ts

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

@Component({
selector: 'app-list-posts',
templateUrl: './list-posts.component.html',
styleUrls: ['./list-posts.component.css']
})
export class ListPostsComponent implements OnInit {
posts$: Observable<Post[]> = this.postService.posts$;

constructor(private postService: IListPosts) { }

ngOnInit(): void {
this.postService.reloadPosts();
}
}

12 行

1
posts$: Observable<Post[]> = this.postService.posts$;

posts$ 為 Observable,直接使用 PostService.post$ property。

當然也可以在 ngOnInit()this.posts$ = this.postService.post$,但因為 RxJS 為 Declarative Programming,主要都在定義 Observable 關係,而不重視執行順序,反正最後都是在 subscribe()async pipe 才執行,所以指令 Observable 時一般不寫在 ngOnInit() 內,直接寫在 field 即可。

14 行

1
constructor(private postService: IListPosts) { }

因為 ListPostComponent 使用了 PostService,因此需要 DI 注入 PostService

根據 依賴反轉原則,我們不應該直接相依於 PostService,而應該相依於 ListPostComponent 所定義出的 IListPosts interface。

16 行

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

ListPostsComponent 主要用於顯示 post,而需求是 一開始會顯示所有 post,因此在 ngOnInit() 時要呼叫 PostService.reloadPosts() 將全部 post 灌入 posts$ observable。

ilist-posts.interface.ts

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

export abstract class IListPosts {
readonly posts$: Observable<Post[]>;
abstract reloadPosts(): void;
}

根據 介面隔離原則,因為 ListPostsComponent 只使用了 post$ property 與 reloadPosts(),因此 IListPosts interface 也應該只有 post$ property 與 reloadPosts()

ShowCountComponent

ods006

show-count.component.html

1
{{ (posts$|async)?.length }}

只負責顯示 post 筆數的 component。

當使用 Observable 的 property 時,要加上 ?,表示當 posts$length property 時,才會執行 post$.length

因為 post$ 為非同步,可能 HTML 在做 data binding 時,post$ 還沒有產生

目前 ? 寫法只能用在 HTML template,還不能用在 TypeScript,將來會提供

show-count.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Component } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Post } from '../../model/post.model';
import { IShowCount } from '../../interface/ishow-count.interface';

@Component({
selector: 'app-show-count',
templateUrl: './show-count.component.html',
styleUrls: ['./show-count.component.css']
})
export class ShowCountComponent {
posts$: Observable<Post[]> = this.postService.posts$;

constructor(private postService: IShowCount) { }
}

12 行

1
posts$: Observable<Post[]> = this.postService.posts$;

posts$ 為 Observable,直接使用 PostService.post$ property。

14 行

1
constructor(private postService: IShowCount) { }

因為 ShowCountCompone 使用了 PostService,因此需要 DI 注入 PostService

根據 依賴反轉原則,我們不應該直接相依於 PostService,而應該相依於 ShowCountComponent 所定義出的 IShowCount interface。

ishow-count.interface.ts

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

export abstract class IShowCount {
readonly posts$: Observable<Post[]>;
}

根據 介面隔離原則,因為 ShowCountComponent 只使用了 post$ property ,因此 IShowCount interface 也應該只有 post$ property。

ReloadPostsComponent

ods007

reload-posts.component.html

1
<button (click)="onReloadClick()">Reload</button>

只負責重新從 API server 抓資料的 component,因此只有 <button>

reload-posts.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Component } from '@angular/core';
import { IReloadPosts } from '../../interface/ireload-posts.interface';

@Component({
selector: 'app-reload-posts',
templateUrl: './reload-posts.component.html',
styleUrls: ['./reload-posts.component.css']
})
export class ReloadPostsComponent {
constructor(private postService: IReloadPosts) { }

onReloadClick() {
this.postService.reloadPosts();
}
}

12 行

1
2
3
onReloadClick() {
this.postService.reloadPosts();
}

onReloadClick() 負責回應 <button>click event。

呼叫 PostService.reloadPosts() 重新透過 GET 向 API server 抓最新資料。

10 行

1
constructor(private postService: IReloadPosts) { }

因為 onReloadClick() 使用了 PostService,因此需要 DI 注入 PostService

根據 依賴反轉原則,我們不應該直接相依於 PostService,而應該相依於 ReloadPostComponent 所定義出的 IReloadPosts interface。

ireload-posts.interface.ts

1
2
3
export abstract class IReloadPosts {
abstract reloadPosts(): void;
}

根據 介面隔離原則,因為 ReloadPostComponent 只使了 reloadPosts(),因此 IReloadPosts interface 也應該只有 reloadPosts()

PostService

ods008

post.service.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
import { Injectable } from '@angular/core';
import { IAddPost } from '../../interface/iadd-post.interface';
import { IListPosts } from '../../interface/ilist-posts.interface';
import { IShowCount } from '../../interface/ishow-count.interface';
import { IReloadPosts } from '../../interface/ireload-posts.interface';
import { Observable } from 'rxjs/Observable';
import { Post } from '../../model/post.model';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';

@Injectable()
export class PostService implements IAddPost, IListPosts, IShowCount, IReloadPosts {
private store$: BehaviorSubject<Post[]> = new BehaviorSubject<Post[]>([]);
readonly posts$: Observable<Post[]> = this.store$;

constructor(private httpClient: HttpClient) { }

addPost(post: Post): void {
this.httpClient
.post<Post>('api/posts', post)
.subscribe();

this.store$.next([...this.store$.getValue(), post]);
}

reloadPosts(): void {
this.httpClient
.get<Post[]>('api/posts')
.subscribe(posts => this.store$.next(posts));
}
}

12 行

1
export class PostService implements IAddPost, IListPosts, IShowCount, IReloadPosts

根據 介面隔離原則,我們根據 component 的需求訂出 IAddPostIListPostsIShowCountIReloadPosts interface,因此 PostService 必須概括承受實現這些 interface。

13 行

1
private store$: BehaviorSubject<Post[]> = new BehaviorSubject<Post[]>([]);

本文的關鍵在此,使用 BehaviorSubject 當作 PostService 內部的資料庫,儲存所有 post 資料,Observable Data Service 的關鍵就在於使用 BehaviorSubject

Q : 什麼是 BehaviorSubject ?

根據 RxJS 的 source code

BehaviorSubject.ts

1
export class BehaviorSubject<T> extends Subject<T>

BehaviorSubject 繼承於 Subject

Subject.ts

1
export class Subject<T> extends Observable<T> implements ISubscription

Subject 繼承於 Observable

ods009

因此 Observable 所有的特性,SubjectBehaviorSubject 都有,如被 subscribe()

SubjectBehaviorSubject 算是一種特殊的 Observable,提供一些原本 Observable 沒有的功能

Q : Observable 與 Subject 有何差別 ?

  • Observable 只能從 HttpClient 或 array、event 提供資料,無法自行新增
  • Subject 可以透過 next() 手動新增資料至 Observable
1
2
3
4
5
6
const subject: Subject = new Subject();
subject.subscribe((value) => {
console.log('Subscription got ', value);
});
subject.next('Hello World');
// Hello World

若你要手動將資料推進 Observable,就使用 Subject

Q : Subject 與 BehaviorSubject 有何差別 ?

  • Subject 不能提供初始值,但 BehaviorSubject 可提供初始值
  • Subject 在被 subscribe() 後,必續再 next() 才能收到變動資料;BehaviorSubject 只要被 subscribe() 後,就可收到之前最後一筆資料,不用再等 next()
  • BehaviorSubject 提供了 getValue(),能獲得目前 BehaviorSubject 最新一筆資料;而 Subject 無法
1
2
3
4
5
6
7
const subject: BehaviorSubject = new BehaviorSubject('Hello World');
subject.subscribe((value) => {
console.log('Subscription got ', value);
});
// Hello World
subject.next('Hello Taiwan')
// Hello Taiwan

Q : 為什麼 Observable Data Service 要使用 BehaviorSubject ?

因為 HTML 的 async pipe,每個 HTML template 執行 subscribe() 的時間不一致,有快有慢,若 subscribe() 早於 next(),則可收到變動資料;若晚於 next(),則可能收不到資料,必須等下一次 next()

若使用 BehaviorSubject,無論 subscribe() 早於 next() 或晚於 next()async pipe 都會獲得最新一筆 next(),因此 Observable Data Service 必須使用 BehaviorSubject

16 行

1
constructor(private httpClient: HttpClient) { }

因為 PostService 要使用 HttpClient,因此 DI 注入 HttpClient

18 行

1
2
3
4
5
6
7
addPost(post: Post): void {
this.httpClient
.post<Post>('api/posts', post)
.subscribe();

this.store$.next([...this.store$.getValue(), post]);
}

使用 HttpClient.Post() 寫入 API,並且馬上 subscribe() 執行。

由於我們自己有維護一份 store$ 儲存所有 post,因此新增的 post 也該寫入 $store

this.store$.getValue() 將取得目前最新的 post 陣列資料。

...this.store$.getValue() 將會展開 post 陣列所有資料,此為 ES6 的 array spread operator。

[...this.store$.getValue(), post] ,將 post 加到原本陣列之後,此為新的陣列。

使用 next() 將新的陣列推入 BehaviorSubject

Q : 這種寫法不就本地 store$ 不是最新資料 ?

1
2
3
4
5
6
7
addPost(post: Post): void {
this.httpClient
.post<Post>('api/posts', post)
.subscribe();

this.reloadPosts();
}

若你很在乎前端是否為最新資料,就下 reloadPosts() 重新 GET,當然執行效率會較差一點,依你實務上的需求決定。

26 行

1
2
3
4
5
reloadPosts(): void {
this.httpClient
.get<Post[]>('api/posts')
.subscribe(posts => this.store$.next(posts));
}

重新對 API 下 GET 抓資料,由於是最新的一份資料,馬上下 $store.next() 將資料寫進 BehaviorSubject

14 行

1
readonly posts$: Observable<Post[]> = this.store$;

已經都自己維護一份 BehaviorSubjectstore$,我們希望當 BehaviorSubject() 使用 next() 改變資料時,能通知所有 subscribe() 的 component 都能自動更新,這也是我們使用 Observable Data Service 的初衷。

既然 BehaviorSubject 繼承於 Observable,理論上我們也可直接將 $store 設定為 public,直接被 component subscribe(),但因為 BehaviorSubject() 提供 next(),因此也可能被 component 誤用 next() 新增資料,因此我們改用真的 Observable 給 component 訂閱,並且宣告為 readonly,表示不能被 component 修改。

由於 store$ 也是一種 Observable,基於 里式替換原則 : 父類別可用子類別取代,因此不需要轉型即可。

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
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { AddPostComponent } from './component/add-post/add-post.component';
import { ListPostsComponent } from './component/list-posts/list-posts.component';
import { ShowCountComponent } from './component/show-count/show-count.component';
import { ReloadPostsComponent } from './component/reload-posts/reload-posts.component';
import { IAddPost } from './interface/iadd-post.interface';
import { IListPosts } from './interface/ilist-posts.interface';
import { IShowCount } from './interface/ishow-count.interface';
import { IReloadPosts } from './interface/ireload-posts.interface';
import { PostService } from './service/post/post.service';

@NgModule({
declarations: [
AppComponent,
AddPostComponent,
ListPostsComponent,
ShowCountComponent,
ReloadPostsComponent
],
imports: [
BrowserModule,
FormsModule,
HttpClientModule
],
providers: [
PostService,
{provide: IAddPost, useExisting: PostService},
{provide: IListPosts, useExisting: PostService},
{provide: IShowCount, useExisting: PostService},
{provide: IReloadPosts, useExisting: PostService}
],
bootstrap: [AppComponent]
})
export class AppModule { }

由於我們 component 與 service 都是基於 依賴反轉原則介面隔離原則 所設計,因此必須藉由 DI container 幫我們相對應的 service 注入。

29 行

1
2
3
4
5
PostService,
{provide: IAddPost, useExisting: PostService},
{provide: IListPosts, useExisting: PostService},
{provide: IShowCount, useExisting: PostService},
{provide: IReloadPosts, useExisting: PostService}

一般我們使用普通 service 時,都是每個 component 注入新的 service,但使用 Observable Data Service 時不可如此,因為我們就是希望各 component 共用同一份資料,只要在 provider 使用 useExisting,Angular DI 就會以 Singleton 方式建立 service,各 component 才能共用一份 store$

Conclusion


  • Observable Data Service 在實務上常常使用,只要各 component 間有資料共用,且會根據共用資料有不同互動,就可以使用,不侷限在資料來自於 API,也可以用在與 API 無關的資料
  • Observable Data Service 同時使用了 DI 與 RxJS,讓 component 與 service 解耦合,只要 service 的資料更新,所有 component 的資料也會隨之更新

Sample Code


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

2018-01-04