この記事ではAngularDart/Flutterの文脈で新しいコンポーネント設計パターンとして広まりつつあるBLoCパターンを、Angularの語彙で理解し、実装する方法を紹介する。
BLoCパターンとは
BLoCとは、Business Logic Componentの略である。 BLoCを使ったアプリケーションの実装パターンをBLoCパターンと呼ぶ。
まず誤解を招きそうなポイントとして、この"Component"はReactやAngularなどでいうところのビューを構築する"コンポーネント"ではない。 一般的な単語としての、アプリケーションを構成するひとかたまりの要素という意味の"Component"なので誤解しないこと。 対比するレベルとしては、"UI Component" vs "Business Logic Component"のようになる。
BLoCは複数の環境向けにアプリケーションを開発するときのコードシェアカバレッジを高めるための、リファクタリング指針のようなものだ。 具体的には、以下の指針を与える。
- BLoCの入力・出力インターフェースはすべてStream/Sinkである
- BLoCの依存は必ず注入可能で、環境に依存しない
- BLoC内に環境ごとの条件分岐は持たない
- 以上のルールに従う限り実装は自由である
詳しくはBLoCパターンの初出であるこのセッションを見るとよい。
Flutter / AngularDart – Code sharing, better together (DartConf 2018) - YouTube
AngularにおけるBLoCパターン
AngularにおいてBLoCパターンの恩恵がどれほどあるのかは議論の余地があるが、 持続可能なAngularアプリケーション開発のために大事なこと - lacolaco でも述べたようにフレームワークに依存しない部分を明確に分ける、というのは設計指針として重要である。 サーバーサイドでの実行やNativeScript、Ionic、あるいはReact/Vueなどへの換装など考えても、BLoCパターンはアプリケーションのAngular依存度を適切に保つために良いルールに思える。
さて、さっそくAngularでBLoCを実装してみよう。 Dartには言語標準のStreamとSinkがあるが、JavaScriptにはまだ存在しないため、非標準の実装が必要である。 幸運にもAngularはRxJSと相互運用可能なので、RxJSのObservableをStreamに見立ててBLoCを実装することができる。
まずはUIコンポーネントがビジネスロジックを持ってしまった状態の例を以下に挙げる。
https://stackblitz.com/edit/angular-bloc-example-1?file=src%2Fapp%2Fapp.component.ts
@Component({ selector: 'my-app', template: ` <div cdkTrapFocus [cdkTrapFocusAutoCapture]="false"> <mat-form-field appearance="outline" style="width: 80%;"> <input matInput placeholder="Search for..." ngModel (ngModelChange)="onInputChange($event)"> </mat-form-field> </div> <span> {{preamble}} </span> <ul> <li *ngFor="let result of results"> {{ result }} </li> </ul> `, }) export class AppComponent { private query = ''; results: string[] = []; get preamble() { return this.query == null || this.query.length == 0 ? '' : `Results for ${this.query}`; } constructor(private repository: SearchRepository) {} onInputChange(query: string) { this.query = query; this.executeSearch(query); } private async executeSearch(query: string) { const results = await this.repository.search(query); this.results = results; } }
UIコンポーネントがAPIの呼び出しや状態の保持などさまざまなビジネスロジックを持っているので、もしこのアプリケーションを別プラットフォームにも展開したくなってもコードが共有できない。
BLoCの作成
BLoCはポータビリティを考えると、ほとんどの場合は単なるクラスとして宣言される。
ここではSearchBloc
クラスを作成する。
もともとAppComponent
が持っていたビジネスロジックをすべてSearchBloc
に移動すると次のようになる。
class SearchBloc { private query = ''; results: string[] = []; get preamble() { return this.query == null || this.query.length == 0 ? '' : `Results for ${this.query}`; } constructor(private repository: SearchRepository) { } async executeSearch(query: string) { this.query = query; const results = await this.repository.search(query); this.results = results; } }
そしてAppComponent
はSearchBloc
に依存して次のようになる。
@Component({ selector: 'my-app', template: ` <div cdkTrapFocus [cdkTrapFocusAutoCapture]="false"> <mat-form-field appearance="outline" style="width: 80%;"> <input matInput placeholder="Search for..." ngModel (ngModelChange)="bloc.executeSearch($event)"> </mat-form-field> </div> <span> {{ bloc.preamble }} </span> <ul> <li *ngFor="let result of bloc.results"> {{ result }} </li> </ul> `, }) export class AppComponent { bloc: SearchBloc; constructor(private repository: SearchRepository) { this.bloc = new SearchBloc(this.repository); } }
https://stackblitz.com/edit/angular-bloc-example-2?file=src/app/app.component.ts
Observableへのリファクタリング
先述のとおり、BLoCパターンではBLoCのすべてのインターフェースはStreamでなければならない。 これはFlutterのStatefulWidgetやAngularDartのChange Detectionの間で、データの変更に対するUIのリアクションのアプローチが違うからだ。 同期的な状態の管理ではプラットフォームごとに特別な処理が必要になる。
一方StreamであればFlutterはStreamBuilder
でStreamからデータが流れてくるたびに再描画する仕組みをもっており、AngularDartもasync
パイプにより同様の反応機構をもっている。
プラットフォームに依存せず非同期的な値を描画するために、DartのBLoCパターンではStreamを活用する。
Angularの場合はRxJSがBLoCの実装を助けてくれる。
DartのStreamをObservable
、SinkをObserver
に置き換えると、SearchBloc
は次のようになる。
class SearchBloc { private _results$: Observable<string[]> get results$(): Observable<string[]> { return this._results$; } private _preamble$: Observable<string> get preamble$(): Observable<string> { return this._preamble$; } private _query$ = new BehaviorSubject<string>(''); get query(): Observer<string> { return this._query$; } constructor(private repository: SearchRepository) { this._results$ = this._query$ .pipe( switchMap(query => observableFrom(this.repository.search(query))) ); this._preamble$ = this.results$.pipe( withLatestFrom(this._query$, (_, q) => q ? `Results for ${q}` : '') ); } dispose() { this._query$.complete(); } }
results: string[]
がresults$: Observable<string[]>
になり、preamble: string
もpreamble$: Observable<string>
となった。
これらはquery
の変更に反応して変化する非同期的な値として表現される。
query
はObserver<string>
インターフェースを外部に公開し、新しい値の追加をUIに許可する。
SearchBloc
の内部では_query$: BehaviorSubject<string>
を実体として持ち、コンストラクタでは_query$
に反応する_results$
と_preamble$
が宣言されている。
これをAppComponent
から使うと次のようになる。テンプレート中でasync
パイプを使い、Observableの変更に反応してビューの再描画が実行されるようになる。
@Component({ selector: 'my-app', template: ` <div cdkTrapFocus [cdkTrapFocusAutoCapture]="false"> <mat-form-field appearance="outline" style="width: 80%;"> <input matInput placeholder="Search for..." ngModel (ngModelChange)="bloc.query.next($event)"> </mat-form-field> </div> <span> {{ bloc.preamble$ | async }} </span> <ul> <li *ngFor="let result of bloc.results$ | async"> {{ result }} </li> </ul> `, }) export class AppComponent { bloc: SearchBloc; constructor(private repository: SearchRepository) { this.bloc = new SearchBloc(this.repository); } ngOnDestroy() { this.bloc.dispose(); } }
https://stackblitz.com/edit/angular-bloc-example-3?file=src/app/app.component.ts
これでBLoCの実装が完了した。
考察
- AngularアプリケーションをRxJSベースで設計・実装をしていれば自然と似たような形になっているはず。
- BLoCはそのパターンに名前をつけ、ビジネスロジックのクラスにインターフェースの制約を設けたもの。
- AngularのコンポーネントはAngular環境での依存性を、DIを使って供給する役割も果たすことになる。
- UIコンポーネントはテストしにくいので、ビジネスロジックをBLoCに逃がすことでテスタビリティが高くなるのは目に見える恩恵のひとつ。