Observable Data Service 也能用來儲存不寫回 server 的資料

Component 間共用的資料有兩類,一類是來自 API 的資料,將來還會寫回 server,另一類是 component 間自己的狀態資料,不必寫回 server;事實上這種不必寫回 server 的共用資料,也可以使用 Observable Data Service 實作。

Version


Node.js 8.9.3
Angular CLI 1.6.2
Angular 5.1.2
RxJS 5.5.2

User Story


counter000

  • 共用 counter 初始值為 0
  • 無論按哪個 +,或哪個 -,最後都是同一個 counter 作用

Task


counter001

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

Architecture


counter003

Observable Data Service vs. 普通 Service

相同點 :

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

相異點 :

  • Observable data service 內部會使用 BehaviorSubject 儲存一份資料;而普通 service 不會

counter002

  • 原本只有 AppComponent,現在分成 ChangeCounterComponentShowCounterComponent 2 個 component
  • Component 依然注入 CounterService,不過此時不是普通 service,而是 Observable Data Service,為 2 個 component 共用的資料
  • 根據 依賴反轉原則,component 不直接相依於 CounterService,而是兩者相依於 interface;根據 介面隔離原則,component 只相依於它所需要的 interface,因此訂出 ChangeCounterInterfaceShowCounterInterface 2 個以 component 為需求的 interface,而 CounterService 必須實踐此 2 個 interface。如此 component 與 service 將徹底解耦合
  • ShowCounterComponent 只負責 subscribe CounterService;當資料改變時,CounterService 會自動更新 ShowCounterComponent

Implementation


AppComponent

app.component.html

1
2
3
4
5
<app-change-value></app-change-value>
<p></p>
<app-change-value></app-change-value>
<p></p>
<app-show-value></app-show-value>

ChangeCounterComponent

counter004

change-counter.component.html

1
2
<button (click)="onIncrementClick()">+</button>
<button (click)="onDecrementClick()">-</button>

只負責 增加減少 counter 的 component,因此只有兩個 <button>

change-counter.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Component } from '@angular/core';
import { ChangeCounterInterface } from '../../interface/change-counter.interface';

@Component({
selector: 'app-change-value',
templateUrl: './change-counter.component.html',
styleUrls: ['./change-counter.component.css']
})
export class ChangeCounterComponent {

constructor(private counterService: ChangeCounterInterface) { }

onIncrementClick() {
this.counterService.addOne();
}

onDecrementClick() {
this.counterService.minusOne();
}
}

13 行

1
2
3
onIncrementClick() {
this.counterService.addOne();
}

使用 CounterService 提供的 addOne()counter + 1。

17 行

1
2
3
onDecrementClick() {
this.counterService.minusOne();
}

使用 CounterService 提供的 minusOne()counter - 1;

11 行

1
constructor(private counterService: ChangeCounterInterface) { }

因為 onIncrementClick()onDecrementClick() 都使用了 CounterService,因此需要 DI 注入 CounterService

根據 依賴反轉原則,我們不應該直接相依於 CounterService,而應該相依於根據 ChangeCounterComponent 需求所定義出的 ChangeCounterInterface

change-counter.interface.ts

1
2
3
4
export abstract class ChangeCounterInterface {
abstract addOne(): void;
abstract minusOne(): void;
}

根據 介面隔離原則,因為 ChangeCounterComponent 只使用了 addOne()minusOne(),因此 ChangeCounterInterface 也應該只有 addOne()minusOne()

ShowCounterComponent

counter005

show-counter.component.html

1
{{ counter$|async }}

只負責顯示 counter$ 的 component。

show-counter.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Component } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { ShowCounterInterface } from '../../interface/show-counter.interface';

@Component({
selector: 'app-show-value',
templateUrl: './show-counter.component.html',
styleUrls: ['./show-counter.component.css']
})
export class ShowValueComponent {
counter$: Observable<number> = this.counterService.counter$;

constructor(private counterService: ShowCounterInterface) { }
}

11 行

1
counter$: Observable<number> = this.counterService.counter$;

counter$Observable,只接使用 CounterService.counter$ property。

13 行

1
constructor(private counterService: ShowCounterInterface) { }

因為 ShowCounterComponent 只用了 CounterService,因此需要 DI 注入 CounterService

根據 依賴反轉原則,我們不應該直接相依於 CounterService,而應該相依於根據 ShowCounterComponent 需求所定義出的 ShowCounterInterface

show-counter.interface.ts

1
2
3
4
5
import { Observable } from 'rxjs/Observable';

export abstract class ShowCounterInterface {
abstract counter$: Observable<number>;
}

根據 介面隔離原則,因為 ShowCounterComponent 只使用了 counter$ property,因此 ShowCounterInterface 也應該只有 counter$ property。

CounterService

counter006

counter.service.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Injectable } from '@angular/core';
import { ChangeCounterInterface } from '../../interface/change-counter.interface';
import { ShowCounterInterface } from '../../interface/show-counter.interface';
import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';

@Injectable()
export class CounterService implements ChangeCounterInterface, ShowCounterInterface {
private store$: BehaviorSubject<number> = new BehaviorSubject<number>(0);
counter$: Observable<number> = this.store$;

addOne(): void {
this.store$.next(this.store$.getValue() + 1);
}

minusOne(): void {
this.store$.next(this.store$.getValue() - 1);
}
}

第 8 行

1
export class CounterService implements ChangeCounterInterface, ShowCounterInterface

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

第 9 行

1
private store$: BehaviorSubject<number> = new BehaviorSubject<number>(0);

本文關鍵在此,使用 BehaviorSubject 當作 CounterService 內部的資料庫,儲存共用的 counter

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

12 行

1
2
3
addOne(): void {
this.store$.next(this.store$.getValue() + 1);
}

使用 this.store$.getValue() 獲得目前 store$ 最新一筆資料,也就是目前的 counter 值,然後 +1

最後使用 this.store$.next() 將最新的 counter 寫入 BehaviorSubject

16 行

1
2
3
minusOne(): void {
this.store$.next(this.store$.getValue() - 1);
}

使用 this.store$.getValue() 獲得目前 store$ 最新一筆資料,也就是目前的 counter 值,然後 -1

最後使用 this.store$.next() 將最新的 counter 寫入 BehaviorSubject

10 行

1
counter$: Observable<number> = this.store$;

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

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

由於 store$ 也是一種 Observable,根據 里式替換原則,父類別可被子類別取代,因此不需要特別傳型。

AppModule

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
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { ChangeCounterComponent } from './component/change-counter/change-counter.component';
import { ShowValueComponent } from './component/show-counter/show-counter.component';
import { ChangeCounterInterface } from './interface/change-counter.interface';
import { CounterService } from './service/counter/counter.service';
import { ShowCounterInterface } from './interface/show-counter.interface';

@NgModule({
declarations: [
AppComponent,
ChangeCounterComponent,
ShowValueComponent
],
imports: [
BrowserModule
],
providers: [
CounterService,
{provide: ChangeCounterInterface, useExisting: CounterService},
{provide: ShowCounterInterface, useExisting: CounterService}
],
bootstrap: [AppComponent]
})
export class AppModule { }

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

19 行

1
2
3
4
5
providers: [
CounterService,
{provide: ChangeCounterInterface, useExisting: CounterService},
{provide: ShowCounterInterface, useExisting: CounterService}
],

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

Conclusion


  • 只要是 component 間有共用資料,且彼此會根據資料互動,就可以使用 Observable Data Service
  • Observable Data Service 同時使用了 DI 與 RxJS,讓 component 與 service 解耦合,只要 service 的資料更新,所有 component 的資料解會隨之更新

Sample Code


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

2018-01-07