おはこんばんにちは、かとじゅんです。 久しぶりにブログを書く…。最近、趣味でAngular2やらReactやらやっています。やっとWebpackになれました…。
さて、今回のお題は「FluxとDDDの統合方法」について。Angular2を先に触っていましたが、FluxといえばやはりReactだろうということで途中で浮気してReactで考えています。Angular2でもできるはずですが、今回はReactで統合方法*1について考えてみたいと思います。一つ断っておくと、FluxはDDDと統合することを想定していない設計パターンなんで云々とかはここでは考えていません。それはこのブログ記事を読む読まないに関わらずご自身で判断されてください。ソースコードについては、Githubへのリンクを一番下に書いてあるので興味がある人は参考にしてみてください。
Fluxって何?
まず基礎ということで、Flux is 何から。
本家曰く、クライアントサイドアプリケーションを構築するためにFacebookが使っているアプリケーションアーキテクチャ。図を見ればわかるように一方向のデータフローを提供するのが特徴です。Facebookが開発しているので、Reactと一緒に使われることが多いと思いますが、フレームワークというより設計パターンのようなものです。
では、Fluxの各コンポーネントの僕の解釈からということで以下を参照。
Flux | Application Architecture for Building User Interfaces
Viewはわかると思うので、Action, Action Creator, Dispatcher, Storeをみていきましょう。特にStoreってなんぞやって話が多いので整理します。Fluxについて解釈が間違っているところがあれば指摘してもらえるとうれしいです。
Action with Action Creator
Action Creatorはメソッドのパラメータからアクションを生成する、ヘルパーメソッドの集合。Actionにはタイプがアサインされ、それをDispatcherに提供する。
Dispatcher
すべてのActionは、StoreがDispathcerに登録したコールバックを経由してすべてのStoreに送られる。
- すべてのActionはStoreがDispatcherに登録したコールバックを経由してStoreに送られる。
- Dispatcherは、Fluxアプリケーションのためのデータフローを管理する中央ハブ的な存在で、アプリケーション固有の要件を含まないシンプルな配送システム。
- Action Creatorは新しいActionをDispatcherに提供する。
DispatcherはPublisher -> (Subscriber - Publisher) -> Subscriber みたいなものですね。技術的な要件しか含まなそう。
Store
はい。よくわかんねーと言われるもの。
ストアがアクションへのレスポンス処理でストア自身を更新した後、変更イベントを送信する。
- Storeはアプリケーション状態とアプリケーションロジックを表すもの。
- MVCのモデル相当だが、たくさんのオブジェクト状態を管理する。ORMモデルような単一のモデルを表現しない。
- TodoStoreはTodoアイテムのコレクションを管理するものに似ている。Storeはモデルのコレクションと論理領域のシングルトンなモデルの両方の表現する。
- StoreはDispatcherを使って自分自身の登録とコールバックを提供する。コールバックはパラメータとしてActionを受け取る。
- Storeに登録されたコールバックの内部にある、アクションタイプに基づくswitch文がActionを解釈するために使われ、適切なフックを提供する。これによって、Dispatcher経由でStoreが持つアプリケーション状態を更新できるようになる。
- Storeが更新された後は、更新イベントがブロードキャストされ、ビューが新しい状態をStoreに問い合わせたり、ビュー自体を更新するかもしれない。
ここでわかることは、StoreはMVCのモデル相当ということと、アプリケーション状態とアプリケーションロジックを含むということです。このモデルの振る舞いはコールバックの中にあるということになります。
まとめると、ActionはメッセージとしてDispatcherに渡すと、ActionはDispatcher経由でStoreに送られる。Storeはアプリケーション状態を持っていて、Actionに応じた振る舞いを起こし状態を変化させる。状態変化が起こるとViewに通知される。更新の通知を受け取ったViewはStoreから状態を取り出したり、自分自身の状態を更新する可能性がある。ということになると思います。
DDDではどう考えるか?
第2部 モデル駆動設計の構成要素 から考えてみます。
レイヤー化アーキテクチャ
ドメイン層 ビジネスの概念と、ビジネスが置かれた状況に関する情報、およびビジネスルールを表す責務を負う。ビジネスの状況を反映する状態はここで制御され使用されるが、それを格納するという技術的な詳細は、インフラストラクチャに委譲される。この層がビジネスソフトウェアの核心である。
ビジネスという言葉は少し大げさに聞こえるかもしれません。簡単に言い換えるならば、そのソフトウェアで解決すべき関心事とか知識という意味です。Todoアプリケーションならば、Todoのタイトル、期限、担当者、重要度、優先度などが当てはまるかもしれません。自分が担当しているTodoが今どれだけあるか、期限切れしているTodoは何かなどの状態を保持しています(状態の保持についての技術的都合はこの層の責務外)。そういう意味では、ソフトウェアのコアはドメインです。もう少し詳しくいうと、他のレイヤーのすべてのコンポーネントが直接的か間接的かによらずドメイン層に依存します。AngularやReactなどのフレームワークによって実装されるアプリケーションであっても、ドメイン層に従います。
アプリケーション層 ソフトウェアが行うことになっている仕事を定義し、表現力豊かなドメインオブジェクトが問題を解決するように導く。このレイヤが責務を負う作業は、ビジネス にとって意味があるものか、あるいは他システムのアプリケーション層と相互作用するのに必要なものである。このレイヤは薄く保たれる。ビジネスルールや知識を含まず、やるべき作業を調整するだけで、実際の処理は、ドメインオブジェクトによって直下のレイヤで実行される共同作業に委譲する。ビジネスの状況を反映する状態は持たないが、ユーザやプログラムが行う作業の進捗を反映する状態を持つことはできる。
アプリケーション層もわかりにくいものの一つかもしれませんが、ドメインモデルの状態や振る舞いを協調動作させてアプリケーション上の一つのユースケースを実現するものです。例えば、チャットの部屋で、誰かにメンションすると、チャットというドメインモデルを更新するだけではなく、To先のユーザアカウントのデバイスセッションを探し、通知しなければならないかもしれません。これがアプリケーションロジックと言われるもので、多くの場合はユースケースを担うアプリケーションサービスの責務となります。
DDDではアプリケーション状態はドメイン層が握っていますが、もう少し詳細に述べると、集約とリポジトリというものが担っています。集約は内部にドメインモデルが複数格納されていて、それらの複数の関連をひとまとまりとして表現しています。そして、集約を永続化する際はリポジトリに依頼します。一般的に実装されるインターフェイス形式としてはput(todo: Todo)やgetByid(id: TodoId), getAll(): List[Todo] などがあります。対応する永続化デバイスもRDB版、KVS版、API版など様々ですが、外からみるとMapのように見えるインターフェイスを持っています。わざわざリポジトリが存在する理由としては、ドメインモデルはソフトウェアで解決する関心事をそのまま表現するため、永続化などの技術都合はリポジトリに移譲すべきという考えからきています。
FluxへのDDDの統合方法
では、Fluxとの統合について話しを先に進めます。FluxのStoreはリポジトリに似たような責務を持っているのは上記で述べた通りです。どちらもアプリケーション状態を保持する責務を担います。また、FluxのStoreにはアプリケーションロジックも含まれています。一方、リポジトリには集約の永続化責務しかありません。DDDではアプリケーションロジックはドメインモデルを協調動作させるアプリケーションサービスの役割でした。つまり、Flux Store = リポジトリ + 集約の協調動作(=アプリケーションサービス)と考えることができます。この時点でStoreはアプリケーション層にあると考えられます。ビジネスロジック付きの永続化アプリケーションサービスのようなものとして解釈することができます。
全体像
単純に統合するならば、FluxのStore内部でリポジトリと集約を使って、アプリケーション状態とアプリケーションロジックを実装すれば、FluxやDDDの考え方から逸脱せずに統合することが可能です。以下が統合イメージです。最初に考えたものから少し変わっていますが、根本は変わっていません。
UI層にはUIに関連するActionとAction CreatorとViewを、アプリケーション層にはDispatcherとStoreを、ドメイン層にはビジネス上の概念を表す集約(ドメインモデルをカプセル化したもの)とリポジトリを、それ以外の汎用的なものはインフラストラクチャ層に配置します。アプリケーション状態を更新したり、取得したりする際に、Store経由でリポジトリと集約を利用します。それ以外の要件(たとえばドメインモデルとは関係がないRPC呼び出しなど)は別の方法で実現することを考えていますが、長くなるので後日にします。
では、Fluxを踏まえて各層をどのように実装するか提案します。
ドメイン層
まず最初に集約を定義します。この集約は、ビジネス上の利害関係者の頭の中にある概念なので、実現方法である技術要素からできる限り独立している方がよいです。なので、ここではインフラストラクチャ(要件の影響を受けない汎用的技術基盤)となる言語機能にしか依存しないように設計します。しかしながら、今回のドメインモデルはただのデータの入れ物と化していて貧血症になっています。本来はビジネス上の豊富な知識を投影したドメインモデルとなるようにしなければいけませんが、今回の趣旨とは外れるのでご容赦くださし…。
export class TodoAggregate { constructor(public id: string, public text: string, public createAt: Date) { } }
次にこの集約の永続化を担うリポジトリを実装します。リポジトリは集約のインスタンスを保存したり検索したりすることができます*2。インターフェイスだけを見ると集約のコレクションのように見え、内部の実装はMapであったり、ローカルストレージであったり、APIサーバであったり様々なものが実装できます。REST APIサーバから集約をI/Oしたいなら、内部の実装をHTTPクライアントに切り替える必要があるでしょう。
export class TodoRepository { private _todos: {[id: string]: TodoAggregate} = {}; constructor(aggreates: TodoAggregate[] = []) { this.storeMulti(aggreates); } store(aggregate: TodoAggregate): void { this._todos[aggregate.id] = _.cloneDeep(aggregate); } storeMulti(aggreates: TodoAggregate[]): void { aggreates.forEach((a) => this.store(a)); } resoleBy(id: string): TodoAggregate { return _.cloneDeep(this._todos[id]); } resolveAll(): TodoAggregate[] { return Object.keys(this._todos).map((id) => this.resoleBy(id)); } }
アプリケーション層
Storeの前にStateについて説明します。Stateには画面の入力状態以外にビューに出力するための状態として、先ほど定義したリポジトリを含めるようにします*3。
export class TodoState { constructor(public currentTodo: string, private _repository: TodoRepository) { } getRepository = (): TodoRepository => { return _.cloneDeep(this._repository); }; }
次はStoreです。今回はFluxUtilsのReduceStoreで実装します。肝心な部分はreduceメソッドとcreateTodoメソッドです。このコードから、アプリケーション状態はリポジトリが担っていることがわかります。今回のTodoは振る舞いがないので微妙ですが、複雑な要件であればこのドメインモデルに設計の意図を十分に表せる振る舞いが実装されるはずです。
export class TodoStore extends FluxUtils.ReduceStore<TodoState, TodoAction> { constructor(dispatcher: Flux.Dispatcher<TodoAction>) { super(dispatcher); } getInitialState(): TodoState { return new TodoState('', new TodoRepository()); } reduce(state: TodoState, action: TodoAction): TodoState { switch (action.type) { case 'CreateTodo': return this.createTodo(state, action as CreateTodo); default: throw Error('no match error'); } } private createTodo(state: TodoState, action: CreateTodo): TodoState { console.log(action); const currentTodo = state.currentTodo; const repository = new TodoRepository(state.getRepository().resolveAll()); const aggregate = new TodoAggregate(new Guid().toString(), action.text, new Date()); repository.store(aggregate); return new TodoState(currentTodo, repository); } } export const todoStore = new TodoStore(todoDispatcher);
Dispatcherが抜けていた…。以下の一行です。
export const todoDispatcher = new Flux.Dispatcher<TodoAction>();
UI層
いよいよ、Reactのコンポーネントの結合ですが、最初にビューモデルを作ります。このモデルはビューに関連する知識を表しています。集約などのドメインモデルは、実際のビジネスなどの問題領域の概念と対応付きますが、ビューでは異なる表現形式が必要になる場合があります。集約のモデルを画面によって形式を変えて出力する場合などです。ビューモデルの生成はいろいろやり方がありますが、ここではConverterを使って集約から変換して導出します。
export class TodoViewModel { constructor(public key: string, public text: string, public dateString: string) { } }
Converterのコードは以下。リポジトリが持つ集約の集合をビューの要件に合わせてmapしているだけです。
export class TodoViewModelConverter { constructor(private _repository: TodoRepository) { } getTodoVMs = (): TodoViewModel[] => { return this._repository.resolveAll().map((a) => { return new TodoViewModel(a.id, a.text, a.createAt.toLocaleDateString() + " " + a.createAt.toLocaleTimeString()); }); }; }
最後に、React.Componentです。このコンポーネントはAction CreatorとStore(リポジトリ含む)にしか依存していません。 View自身も状態を持っていますが、UI上の何かのイベントが起こるとAction Creatorを使ってActionをDispatcherに送信します。すると、Storeで振る舞いを起こし状態変化が起こり、ViewがStoreにあらかじめ登録したハンドラが呼ばれてView自身の状態を書き換えます。
export class TodoComponent extends React.Component<{}, TodoState> { private listenerSubscription: { remove: Function }; constructor(props: {}) { super(props); this.state = new TodoState('', new TodoRepository()); } componentDidMount() { this.listenerSubscription = todoStore.addListener(this.handleStateChange.bind(this)); } componentWillUnmount() { this.listenerSubscription.remove(); } handleStateChange() { const storeState = todoStore.getState(); const newRepository = new TodoRepository(storeState.getRepository().resolveAll()); const newState = new TodoState(storeState.currentTodo, newRepository); this.setState(newState); } handleValueChange(event: React.SyntheticEvent) { const todoText = (event.target as HTMLInputElement).value; this.setState(new TodoState(todoText, this.state.getRepository())); } handleClick() { TodoActionCreator.createTodo(this.state.currentTodo); } render(): JSX.Element { const todos = new TodoViewModelConverter(this.state.getRepository()).getTodoVMs(); return <div> <input type='input' value={this.state.currentTodo} onChange={this.handleValueChange.bind(this)}/> <button onClick={this.handleClick.bind(this)}>Update</button> <div> {todos.map((a) => { return <p key={a.key}>{a.text} : {a.dateString}</p>; })} </div> </div>; } }
コードはGithubにあります。リポジトリがメモリ版なのでそれをAPI版に返れば複数ユーザで特定のアプリケーション状態を共有できるようになるはずです。
以上、長くなりましたが、ReactでのFluxとDDDの統合方法の解説でした!統合方法は他にもいろいろあると思いますが、僕が考える一例を示してみました。
次はAngular2版をまとめてみます。