ここでは、BLoCパターンのガイドラインを参照し、それに基づきデモアプリを作成し、その解説を通じてBLoCパターンの考察を行う。 今までも何度かMeetup等でBLoCパターンの解説を行ってきたが、あらためてこのブログ記事にまとめておく。
BLoCパターンとは
BLoCは、Business Logic Componentの頭字語(acronym)で、状態管理に関するアーキテクチャパターン。
以下、https://www.youtube.com/watch?v=PLHln7wHgPEより、BLoCのガイドラインを引用する。
Business Logic Component(BLoC)のガイドライン
状態を管理するBLoCに以下の制約を課す。
- インプットとアウトプットは、単純なStreamとSinkに限定する。(Inputs and outputs are simple Streams/Sinks only.)
- 依存性は、必ず注入可能でプラットフォームに依存しないものとする。(Dependencies must be injectable and platform agnostic.)
- プラットフォームごとの条件分岐は、許可しない。(No platform branching allowed.)
上記の制約を守れば、どのような実装でも構わない。(Implementation can be whatever you want if you follow the previous rules.) ただし、reactive programmingを推奨したい。(But may I suggest reactive programming?).
UIのガイドライン
BLoCのインプット、アウトプットを利用するUIクライアントは以下のガイドラインに従うべきである。
- 「十分に複雑な」UIコンポーネントひとつひとつが、対応するBLoCをもつ。(Each "complex enough" component has a corresponding BLoC.)
- UIコンポーネントは、インプットをBLoCに「そのまま」送るべきである。(Components should send inputs "as is".)
- UIコンポーネントは、BLoCからのアウトプットをできるだけ「そのまま」表示するべきである。(Components should show outputs as close as posssible to "as is".)
- すべての条件分岐は、単なるBLoCからの真偽値のアウトプットを元にするべきである。(All branching should be based on simple BLoC boolean outputs.)
今年の1月のDart Confで初出。
https://www.youtube.com/watch?v=PLHln7wHgPE
そして、今年6月のGoogle I/Oで紹介されたことで広く知られるようになった。
https://www.youtube.com/watch?v=RS36gBEp8OI
AdWordsとAdSenseで実戦投入されている
Googleの利益の大部分を稼ぎ出すミッションクリティカルなサービスであるAdWordsとAdsenseのアプリは、AngularDartおよびFlutterで構築されており、それらのアプリで採用されているBattle Testedなアーキテクチャパターンであるとして権威づけがされている。 従来は、Webアプリ、Androidアプリ、iOSアプリそれぞれで、異なる言語で三回も同じロジックを書かざるをえなかったが、このパターンによりAngularDartとFlutterでロジックを共有することで一回ですむようになったとのこと。
Angular (Dart)
以下、AngularDartを単にAngularと呼称する。Angularは複雑な歴史を経て現在はTypeScript版とDart版がそれぞれ別のフレームワークとしてメンテナンスさている。だが、もともとは同一のフレームワークとして開発されてきたため、両者はいまだに多くの概念を共有している。そのため、どちらか一方を使用した開発経験があれば、その知識を活かしてもう一方にもさほど苦労せずに慣れることができるだろう。
TypeScript版Angularについてはここでは言及しないが、BLoCパターン自体は、TypeScript版Angularはもちろん、Streamに依存できるならばどのような環境でも適用可能だ。
BLoCパターンの実践
上記BLoCパターンのガイドラインに基づき、デモアプリとしてごく簡単なチャットアプリをFlutterとAngularの両方で作成した。その解説を通じてBLoCパターンを考察していく。
サンプルアプリのソースコード
https://github.com/ntaoo/bloc_chat
仕様
- ユーザーは、アプリの起動時にAnonymousUserとして自動サインインする。
- ユーザーは、チャットルームを作成できる。
- ユーザーは、チャットルーム一覧から特定のチャットルームに入室できる。
- ユーザーは、チャットルームでメッセージを追加できる。また、自らのメッセージのみ編集と削除ができる。
- メッセージ表示は、内容、および作成日時をフォーマットしたものから構成される。作成日時は、本日のものならば時刻のみ表示する。
- メッセージは、それらの追加/編集/削除の結果がリアルタイムで各クライアントに通知され、UIが書き換わる。
バックエンドとしてFirestore, Firebase authを採用している。 デモアプリのため、やや乱暴だがアプリ起動時にAnonymousUserとして自動サインインする仕様としている。
Model
Modelを共通化するので、package:modelを作成し、Angular, Flutterで利用するChatModelの公開インターフェースを定義している。
https://github.com/ntaoo/bloc_chat/tree/master/model/lib
なお、modelをDartのpackageとしているので、ここで解説されている型に従っている。
Chat library
model packageにchat libraryを定義した形となる。chat libraryはおおよそ、room, message, authというビジネスロジック、そしてfirebase backendと通信するrepository層から構成されている。
BLoCを使用したパターンと使用しないパターンを比較するために、roomに関してはBLoCを作成せず、逆にmessageに関しては作成している。
MessagesBloc
UIクライアントとのインターフェースとなり、状態を管理するfacadeとなる。
インプットSink、アウトプットStream
// Input signals Sink<String> get addMessage => _addMessageController.sink; Sink<String> get startEditingMessage => _startEditingMessageController.sink; Sink<Null> get cancelEditingMessage => _cancelEditingMessageController.sink; Sink<String> get updateMessage => _updateMessageController.sink; Sink<String> get deleteMessage => _deleteMessageController.sink; // Output streams Stream<UnmodifiableListView<MessageView>> get messages => _messagesSubject.stream; Stream<String> get newMessageContent => _newMessageSubject.stream; Stream<bool> get isEditingMessage => _isEditingMessageSubject.stream; Stream<String> get editingMessageContent => _editingMessageContentSubject.stream;
このBLoCの公開インターフェースは、ガイドラインに従い、インプットはSink、アウトプットはStreamに限定している。(Dartでは、classのmember名が"_"(アンダースコア)で始まっていればprivateとなり、それ以外はpublicとなる。)
クライアントは、Input signalsとコメントをつけているSinkにデータを追加し、BLoCはそれをlistenしてハンドラを起動し、状態変更した結果をOutput streamsに追加する。クライアントはそのstreamをlistenし、UIを更新する。
DI
MessagesBloc(AuthService authService, MessagesCollection messagesCollection) : _messagesService = MessagesService(authService.currentUser, messagesCollection) { _addMessageController.stream.listen(_addMessage); _updateMessageController.stream.listen(_updateMessage); _deleteMessageController.stream.listen(_deleteMessage); _startEditingMessageController.stream.listen(_startEditing); _cancelEditingMessageController.stream.listen(_cancelEditing); _messagesService.onMessagesChanged.listen(_emitMessages); }
「依存性は、必ず注入可能でプラットフォームに依存しないものとする。」という制約に基づき、依存性をコンストラクタで注入している。
AuthService、MessagesCollectionはバックエンドのfirebase auth, firestoreとそれぞれ通信する責務をもつ。ただし、firebaseはもちろんwebとnativeで実装が異なるため、依存性逆転の原則に従い、抽象化したInterfaceを定義している。このあたりはJavaのバックグラウンドがあれば馴染み深いだろう。(*注 Dartでは各classに暗黙的にinterfaceが定義される。)
abstract class MessagesCollection extends FirestoreCollection<MessageDocument> { Stream<List<MessageDocument>> get onAdded; Stream<List<MessageDocument>> get onUpdated; Stream<List<MessageDocument>> get onDeleted; Future add(DocumentAddRequest<MessageDocument> request); Future update(DocumentUpdateRequest<MessageDocument> request); Future delete(String documentId); }
厳密には、Firestoreの用語であるCollectionをBLoC内部に持ち込むべきではないかもしれないが、現状でFirestoreから他のインフラに移行することはまず考えられないため、妥協してCollectionという用語を採用している。また、将来、ユースケースの追加によってより多様なQuery APIなどが必要になれば、Repository classを追加してCollectionに依存する形でユースケースごとのメソッドを追加していけば良い。
「プラットフォームごとの条件分岐は、許可しない。」を守るため、Repositoryパターン等を活用し、プラットフォームに依存するコードがBLoC内部に入り込まないように注意する。
DIライブラリ
FlutterとAngularで共通で使用できる信頼できるライブラリがあればよいが、現状では選択肢に乏しいため、このデモアプリでは以下の選択をしている。
- Angular: AnguarのDIの仕組みをそのまま使用する。
- Flutter: InheritedWidgetを使用してService locator patternで依存性を管理し、手動でコンストラクタインジェクションコードを書く。
Flutterにおける他の可能性としては、
- https://pub.dartlang.org/packages/flutter_simple_dependency_injection
- https://github.com/google/inject.dart
があるが、プロジェクトが若い、またはExperimental扱いであるため、これらのライブラリに依存するにはリスクがあることを認識しておく。
なお、Angular 5からmodel calssに@Injectable()アノテーションが必要がなくなり、Angularのimport文を通じた静的な依存関係がなくなったため、プラットフォームから独立したpackageだけに依存したmodel componentの作成が用意になった。
インプット、アウトプットのデータはas isで
2. UIコンポーネントは、インプットをBLoCに「そのまま」送るべきである。(Components should send inputs "as is".) 3. UIコンポーネントは、BLoCからのアウトプットをできるだけ「そのまま」表示するべきである。(Components should show outputs as close as posssible to "as is".)
上記ガイドラインに基づき、アウトプットデータをそのままUIで表示するため、MessageのView Modelを定義する。
ここにおけるView Modelは、MVVMアーキテクチャにおけるView Modelとは定義が異なり、ただのimmutableなデータ構造にすぎない。この用語はClean Architectureから借用している。クラス名の接尾辞としてViewをつけると良いだろう。
import 'package:intl/intl.dart'; import 'package:meta/meta.dart'; @Immutable('View model should be immutable') class MessageView { factory MessageView( String id, String content, bool isEditable, DateTime createdTime) { return MessageView._( id, content, isEditable, _formatCreatedTime(createdTime)); } MessageView._(this.id, this.content, this.isEditable, this.createdTime); final String id; final String content; final bool isEditable; final String createdTime; static String _formatCreatedTime(DateTime createdTime) { if (createdTime == null) return ''; if (createdTime.day == DateTime.now().day) { return DateFormat.Hm().format(createdTime); } else { return DateFormat.yMMMMEEEEd().format(createdTime); } } }
"createdTime"はBLoC内部ではDateTimeとして扱うが、UIにわたす際はas isで表示させるため、Stringにformatしている。もし国際化する場合もBLoC内部で国際化モジュールを用意することになる。
UIで、メッセージが編集可能かどうかの表示を条件分岐させるため、"isEditable"を定義している。ビジネスロジックとしては、ログインユーザーのidがmessageのuidと一致しているかで判定するが、このロジックをUI側に露出させないように注意し、結果だけisEditableに格納して返すようにする。
MessageView _toViewModel(MessageDocument message) => MessageView( message.id, message.content, _messagesService.isEditableMessage(message), message.createdTime);
今回は単純な仕様なので、プライベートメソッドでMessageDocumentをMessageViewに変換しているが、仕様が複雑になればBLoC内部にViewModelを生成する責務をもつPresenterを抱えることになるだろう。
Angularのpipe、たとえばビルドインされたdate pipe等の使用については、BLoC内部に閉じ込めるべきロジックがAngular側に露出していることを意味するため、そのような用途では使用するべきはない。 BLoCパターンを抜きにしても、Pipeはささやかなデータ変換を手軽に実行する用途には便利だが、代償としてmodelと比較してテストによりコストがかかるviewがさらに複雑化してコストが高まるため、使用にはかなり慎重になるべきだと思う。Async pipeを除いて、通常はpipeを使用することはあまりないのではないか。
(* 本筋から離れるが、これでViewModelをBLoC内部の状態を表すModelと峻別できたが、開発が進めば、そのModelとFirestore DocumentのEntityも峻別することになるだろう)
BLoCへのインプットについて
MessagesBLoCのインプットを再掲する。
// Input signals Sink<String> get addMessage => _addMessageController.sink; Sink<String> get startEditingMessage => _startEditingMessageController.sink; Sink<Null> get cancelEditingMessage => _cancelEditingMessageController.sink; Sink<String> get updateMessage => _updateMessageController.sink; Sink<String> get deleteMessage => _deleteMessageController.sink;
インプットデータのハンドリングもBLoCの責務である。たとえば、今回の仕様からは外れるが、入力値の検証、NGワード判定、自動翻訳、アノテーションの付与などが考えられる。
void function(args)の誘惑
アウトプットをStreamに限定するべきなのは自明だが、インプットをSinkに限定するべきかは異論があるかもしれない。
MessagesBLoCのインプットを見ると、Sinkでなくvoid functionにしてそれを呼び出すほうが簡潔になりそうだ。voidであればUIがBLoCのAPIと密結合になる事態も防げそうだ。そしてSinkを管理するStreamControllerも削減できる。実際、このデモアプリの仕様ではなんの問題もでなさそうだ。
では、なぜBLoCパターンではインプットをSinkに限定することに議論の余地はないと言い切っているのか。
それは、UI、そしてUIに限らず環境からはStreamを通じてデータが渡されるため、BLoCのインプットもSinkにしておくのが自然なこと、そしてインターフェースの統一によるcomposabilityの維持が理由だと考える。
Dartではかなり初期からコアライブラリでStreamをサポートしており、あらゆるライブラリがそれに依存しているため、データをStreamで扱うのにとても都合が良い。マウスクリックやテキストインプットなどのユーザー操作イベント、HTTPレスポンス等あらゆるイベントがStreamに依存している。したがって、それをBLoCにpipeするのもとても自然な操作になる。つまり、主にユーザーのUI操作のイベントStreamを直接BLoCに流し込むことを想定しているのだろう。
また、BLoCをネストしてインプットStreamを別のBLoCに流し込む、または同じBLoCの別のSinkに流し込むなどが考えられる。そういったユースケースでstreamの取りあつかいが単純になるため、composabilityを維持できる。
BLoCをDCIアーキテクチャのContextのようなものと捉えれば、複雑なビジネスロジックを処理するにはBLoCのネストが有効であると考えられ、インプットとアウトプットをSink/Streamに限定する単純さは大きなメリットになりそうだ。
つまり、この制約を遵守することで、BLoCがコミュニケーションするUI、インフラ、バックエンド、そして他のBLoCなどの各コンポーネント間のデータの受け渡しをStreamに統一したアーキテクチャにできる。
また、将来、BLoCパターンをサポートするライブラリがでてきた場合も障害なく導入できるだろう。
したがって、現在の仕様から見えている範囲で判断して、UIからvoid function callするほうがひとつのstreamを管理するコストが減らせたとしても、BLoC設計の単純さ(Simplicity)によるComposabilityを損なうため、誘惑に抵抗し、インプットにvoid functionを混ぜるのは止めておくべきだと考える。
複雑なフォームをどう取り扱うか?
UI側のフレームワークがフォームのハンドリングのサポートをしている場合、バリデーションなどのロジックがBLoC内部と外部に分散してしまう事態が出現してしまいやすい。この問題への対策は、フォームのUI部分はフレームワークに任せ、入力値の検証ルールをBLoCから提供し、UIからのフォーム入力結果を同じ検証ルールであらためて検証するといったことが考えられる。
また、フォームの状態管理がUIのフォームmodelとBLoC内部のmodelの二重管理になってしまう危険も考えられるが、このハンドリングに関しては、たとえばngrxがAngular Formをどのようにバインディングして同期しているかが参考になるかもしれない。
UIにおけるアウトプットStreamのハンドリング
FlutterではStreamBuilderを、AngularではAsync Pipeを使えば良い。理想的な設計では、BLoCからのアウトプットstreamをそれらにそのまま渡すコードになっているはずだ。
BLoCパターンを使わない例: Rooms
チャットルームの一覧、作成を行う機能は、BLoCパターンを使わずにそれぞれのアプリでCollectionからのgetとViewへの反映のコードを直接記述している。
以下はAngular側のコードになる。
_roomsCollection.onAdded.listen((e) => rooms.insertAll(0, e));
Future addRoom() async { if (newRoomName.isEmpty) return; adding = true; await _roomsCollection.add(RoomDocumentAddRequest(name: newRoomName)); newRoomName = ''; adding = false; }
ごく簡単なコードなので問題にならないかもしれないが、BLoCパターンではBLoC内部に閉じ込められるコードが露出しているのは確かだ。そして、Flutter側でも同様のコードを書いて仕様を維持する必要がある。
要はSmartUIにするかModel Driven Viewにするか、という選択の問題になる。
BLoC内部の設計をどうするべきか
BLoCパターンでは、BLoC内部の設計は自由としている。(ただしリアクティブプログラミングを推奨している)
個人的な指針としては、Clean ArchitectureやDDDで広く敷衍されている設計手法に従って、BLoCをfacadeにしたコンポーネントを作成し、内部にドメインロジック(ビジネスロジック)を構築していくのが良いと考える。
BLoCパターンにライブラリやフレームワークは必要か
BLoCパターンは、Fluxのようにフレームワークが乱立する状況にはなっていないし、なるべきではない。フレームワーク化する意義も今のところは見いだせない。一般的に、アプリケーションが状態管理フレームワークに依存する事態は、たとえReduxのような大人気でエコシステムが繁栄してデファクトスタンダードに近いものであっても避けるべきだ。
BLoC内部ではもちろんReduxなどのフレームワークに依存するべきではない。たとえ内部で依存しなくとも、BLoCを採用するならば他のReduxのような状態管理フレームワークは役割が重複するため、ひとつのアプリケーションに同時に導入する意味はない。
ただし、Stream管理のボイラープレートを減らすライブラリは導入の余地がある。StreamChannelを採用してStreamの管理をまとめてBLoC内部で疑似ルーティングを構築するなどが考えられる。しかしまずはBLoCの責務を分割できないかを検討するべきだ。
BLoCパターンの利点
- 環境に依存するUI、およびインフラと、依存しないModelの間に明確な境界線を引くことができる。
- BLoCは環境に依存せず、代わりにインターフェースをDIする制約によりテスタビリティを維持できる。
- いかなるフレームワークにも依存せずに状態管理する指針となる。
Modelが独立してコンパイルできる。
BLoCパターンに厳密に従うことにより、WebアプリとネイティブアプリのModelのコードを共通化することで、Webアプリ、ネイティブアプリ間のmodelの仕様と実装の不一致やひとつのプラットフォームだけでmodelのロジックのバグを作り込むリスクなくすことができる。
BLoCパターンの欠点
MVVM, Flux, DDD, Clean Architecture等々のアーキテクチャパターンと同じく、ボイラープレートが増えるため、簡単な機能要件であることが確定している場合は適さない。 簡単なアプリは、Angularのチュートリアルのように、ViewController中心にロジックを書くほうが見通しがよく、難易度もコード量も抑えられて早く完成するだろう。
結論
BLoCパターンは、Streamの制約を除けば、ソフトウェア開発において見慣れた普遍的なプラクティスばかりであると気づくはずだ。
BLoCパターンは特に目新しい概念ではなく、Clean Architectureの要点ととても似ている。異なるところは、Input, OutputをStreamに限定しており、Clean Architectureほどの詳細なルールや用語はないことだ。BLoC内部の設計については自由としている。
BLoCパターンは、複雑なシステムの開発においてあるべきアーキテクチャの輪郭について、少数の明確なルールとともに名前をつけてくれたことにより、説明しやすくなったことに大きな意義があるのではないかと思う。
このパターンは、言うまでもないが、ボイラープレートのコード量がスマートUIパターンのものと比べると増加する。そのため、簡単なアプリケーションの場合、または開発チームの経験値が低い場合には適用すべきではない。
逆に、機能要件が複雑で長期間メンテナンスしつづけることが予想される場合は最初から採用を検討する。例えば、FlutterアプリとAngularアプリで共通の振る舞いが多い場合は、第一に検討する。片方しか開発する必要がない場合でも、ある程度複雑なアプリケーションならば同様。
BLoCパターンは、Flutter, Angular以外でもStreamに依存できるならば適用可能なので、他のアーキテクチャパターンとともに検討の俎上に上げると良い。