Model View Whatever + Rx のアンチパターン

GUIアーキテクチャパターンとしてModel View Whateverを採用した際に、Rxのストリームをプレゼンテーション層からモデル層まで一気通貫でつなげてしまうのはアンチパターンである、という話をします。

前提

GUIアーキテクチャパターンにおける Model View Whatever パターン、とくにMVVMに近いパターンを前提とします。いわゆるサーバサイドの「web系MVC」は前提としません。

Model View WhateverパターンとPDS

そもそもGUIアプリケーションでModel View Whateverというアーキテクチャパターンを採用する理由として、PDSの実現があります。このあたりの話は詳しくは実況中継シリーズ Vue.jsで実現するMVVMパターン Fluxアーキテクチャとの距離 - Re.Ra.Ku アドベントカレンダー day 13 - Re.Ra.Ku tech blogを参照してください。

そして、リッチクライアントにおいてモデルはステートフルです。状態管理というのは非常に複雑なものです。この複雑なものを、GUIプラットフォームの都合に振り回されるプレゼンテーション層からひっぺがして、モデル層で独立に設計、管理できると、コードの見通しもよくなるしテストもしやすくて嬉しいね、というのが、リッチクライアントにおいてM V Whateverアーキテクチャパターンを採用する動機のひとつです。

これを徹底すると、モデル層とプレゼンテーション層のコミュニケーションは、以下のようになっているべきです。(参考:MVVMのModelにまつわる誤解

  • プレゼンテーション層 -> モデル層のコミュニケーションは、voidなメソッドの呼び出しと、属性の取得のみ行われる
  • モデル層 -> プレゼンテーション層のコミュニケーションはイベントの発行のみ行われる(MVPの場合Presenterで定義されるインターフェイスの呼び出しのみ行われる)

モデル層とプレゼンテーション層のコミュニケーションを上記のように整理、制限を持たせることによって、モデル層はプレゼンテーション層に依存しないで済むようになる、というのがそもそもMVWのアイデアです。

具体的な例

たとえば、API通信を伴う操作を例に取りましょう。ある画面で、ボタンをタップするとAPIから情報を取ってきて、それを画面に表示するとしましょう。

この場合、例えば一気通貫に

rx.tap
 .flatMap { // API通信を伴う操作。失敗時にはonErrorが呼ばれる  }
 .subscribe {
   onNext: { // APIの結果から表示を操作 }
   onError: { // エラーの型を見て分岐 }
 }

という感じで書くのではなく、

////////////////
// in presentation
////////////////

rx.tap
 .subscribe {
   onNext: { service.invokeNyan(param) }
 }

someModel.xxEvent.subscribe {
   onNext: { //someModelの状態に応じてViewを描画 }
}

errorModel.nyanErrrorOccured.subscribe{
   onNext: { //nyanErrorに応じたViewの描画 }
}

errorModel.wanErrrorOccured.subscribe{
   onNext: { //wanErrorに応じたViewの描画 }
}

///////////////////////
// in model
///////////////////////

// service
func invokeNyan(param) {
    validate(param).flatMap {validatedParam =>
      return api.dispatch(validatedParam)
    }.subscribe {
      onNext: {
          // データ・モデルやドメイン・モデルを操作。
          // MVVMなら、モデルの状態変化の結果として、イベントがdispatchされる
          // MVPならPresenterのインターフェイスを叩く
      }
      onError: {
          // エラー種別に応じてエラーモデルやデータ・モデルを操作
          // MVVMなら、モデルの状態変化の結果として、イベントがdispatchされる
          // MVPならPresenterのインターフェイスを叩く
      }
    }
  }
}

こういうふうに書いたほうが良い、ということです(擬似言語によるコードです)。

まず、前者の場合(そもそもtapがonErrorに入らないとかそういう問題もあるんだけど話がそれるので置いておきます)、まず「エラーの型を見て分岐」というロジックがプレゼンテーション層に漏れ出していますが、後者の場合、「このユースケースではAPIからnot foundエラーが返ってきてもエラーとせず単に空白表示にするが、あのユースケースの場合APIからnot foundエラーが返ってきた場合はエラーとして扱う」みたいな「ユースケースに応じたロジック」をモデル層に隠蔽することができます。(ユースケース依存のロジックなので、この分岐ロジックはアプリケーションサービス的な層に書かれることになるでしょう)

また、前者の場合、「このAPIを叩いた結果によってあのデータ・モデルの状態がこのように変化する」というようなロジックがこれまたプレゼンテーション層に漏れ出してしまう一方、後者の場合「このAPIを叩いて、成功すればこのデータ・モデルがこう変化し、not foundの場合はあのデータ・モデルがこう変化し、想定していないエラーが怒ったらエラー・モデルが変化し」という「アプリケーションの論理的な共同」をモデル層に閉じ込めることができています。

これは、ストリームの発生元がtapイベントであろうとAPI呼び出しであろうと同じことです。モデル層の奥深くや、プレゼンテーション層で発生したイベントを、PDSの境目を超えて一気通貫で扱おうとすると、PDSを成り立たせている「プレゼンテーション層とモデル層のコミュニケーションのルール」から外れてしまい、PDSが崩壊します。

別の例として、UIのイベントとはストリームを繋がなかったけど、非同期でAPIを叩いた結果をObservableで返したものを、そのまま返り値としてプレゼンテーション層まで返して、プレゼンテーション層でsubscribeしてしまう、というのもまた、アンチパターンであると言えるでしょう。あくまで、プレゼンテーション層からはvoidなメソッドを叩いて、APIから返ってきた非同期な値はモデル層内でsubscribeしてハンドルし、データ・モデルやドメイン・モデルに変化を起こす(その変化をプレゼンテーション層で知るためにはモデル層がイベントを発行する)、という形にするべきでしょう。

まとめ

まとめもなにも、「GUIアーキテクチャパターンとしてModel View Whateverを採用した際に、Rxのストリームをプレゼンテーション層からモデル層まで一気通貫でつなげてしまうのはアンチパターンである」が結論なのですが、もっと具体的なところまで話をしてしまうと、Model View WhateverパターンにおいてRxのストリームは以下のように使うのがよいお作法なのではないでしょうか。

  • プレゼンテーション層においては、UIイベントのストリームを論理的な意味のあるイベントのストリームへと変換する(たとえばrx.tapイベントのストリームをまとめて「ダブルタップされた」というイベントのストリームに変換するなど)のみに留める
  • モデル層においては、Rxが「アプリケーションの論理的な挙動」をうまくモデリングできる部分に自由に使う
  • モデル層からプレゼンテーション層へのイベント通知のためにモデルにPublishSubjectやBehaviorSubjectな属性を持たせるのはアリでしょう