MVU アプリケーションのコンポーネントレイヤーを考える

33 min読了の目安(約29700字TECH技術記事

はじめに

Bolero を使うと F# で MVU(Model-View-Update)アーキテクチャーで SPA を作成することができます。

筆者は delika というウェブサービスを Bolero で開発しています[1]。 MVU アーキテクチャーでの開発経験がない状態から手探り状況で始めており、お世辞にもうまく開発できているという状況ではないのですが、それなりにノウハウが集まってこうすればよいのではないかというのがつかめてきました。今後の開発のリファクタリング指標的な意味も含めて本記事にまとめます。

もしここに書いた方法以外にもこんなやり方がよいよみたいな情報がありましたら共有いただけるとありがたいです。

サンプルアプリケーション

実証実験の結果として、カウンターアプリケーションを Bolero で実装しました。

概要

アプリケーション画面は以下のようなものです。


アプリケーション画面

ボタンをクリックすると押したボタンに従って数値が更新されます。おまけ程度ですが、数字がでかくなるとリモートからレスポンスが返るまで時間がかかるようにしています。特筆すべきは以下の点です。

  • +10/-10 ボタンを利用するにはサインインが必要

ソースコード

サンプルアプリケーションのソースコードは以下の GitHub に公開しています。

実行環境

.NET SDK 5.0.100 以降が必要です。

MVU アーキテクチャー

本題に入る前に簡単に MVU アーキテクチャーについて触れておきます。

MVU アーキテクチャーは Elm アーキテクチャーをベースとしたアプリケーションのパターンです。 MVU アーキテクチャーは基本的に以下の 3 つの構成要素からなります。

  • Model: アプリケーションの状態
  • View: アプリケーションの状態をビューに変換する
  • Update: アプリケーションの状態をメッセージ経由で更新する


MVU アーキテクチャー概要

Model

Model はアプリケーションの状態を表す型です。 Model にはアプリケーションデータの他に描画に関わる情報(例えば表示テキスト)を持つ場合がありますが、 Model 自身が描画を行うことはありません。

View

View は Model をビュー(ウェブアプリケーションの文脈では HTML)に変換する関数です。 Bolero の文脈では Blazor ベースのフレームワークであり、 Blazor で管理されるコンポーネント型(ElmishComponent 型)として利用されることが多いです。

Update

Update は UI 上の入力等の Model に変更を起こすメッセージを受けて Model を更新する関数です。

Bolero

Bolero は Blazor ベースの SPA を作るためのフレームワークなので、その構成要素はボタンなどの簡単なコンポーネントから、ページやアプリケーションレベルまで様々な粒度のものを MVU アーキテクチャーで管理することになります。

Bolero のテンプレートには認証やカウンターアプリケーション(仕様は前述のカウンターアプリケーションとは多少異なる)を含むサンプルアプリケーションが提供されているのですが、サンプルアプリケーション程度のシンプルな構成なこともあり、 MVU がモノリシックな構造になっています。小規模アプリケーションであればモノリシックな構造でもなんとかなるかもしれませんが、アプリケーション規模が大きくなるにつれて適切にコンポーネントを作っていくべきでしょう。

MVU アーキテクチャーの 4 層設計

Bolero のようにアプリケーション全体が MVU アーキテクチャーで管理できると、アプリケーションのような上位レイヤーのコンポーネントがボタンのような下位レイヤーのコンポーネントを操作できます。しかしあらゆるコンポーネントを上位レイヤーが管理すると構造が煩雑になってしまいます。

レイヤー構造を簡潔にするために、現在は以下のような 4 層(下に行くほど上位レイヤー)に分類する設計を意識しています。

  • UI レイヤー(UI Layer): UI 定義
    • UI コンポーネント(UI Component)
      • 単純 UI コンポーネント(Simple UI Component)
      • 複合 UI コンポーネント(Complex UI Component)
    • レイアウトコンポーネント(Layout Component)
  • 機能レイヤー(Feature Layer): サービスの利用
    • 機能コンポーネント(Feature Component)
  • 画面レイヤー(Screen Layer): 機能のマッシュアップ
    • 画面コンポーネント(Screen Component)
  • アプリケーションレイヤー(Application Layer): 画面切り替えや共通処理
    • アプリケーションコンポーネント(Application Component)

各レイヤーは原則直下のレイヤーのコンポーネントのみ利用できます。例外はレイアウトで、レイアウトは画面レイヤーやアプリケーションレイヤーのコンポーネントから文字通りレイアウト定義の目的で利用されます。

概要図

レイヤー構造を表した図を以下に示します。


MVU アーキテクチャーの 4 層管理

UI レイヤー

UI レイヤーは、ボタンやラベルといった単純 UI コンポーネント、モーダルやタブコントロールのような単純 UI コンポーネントを組み合わせてできる複合 UI コンポーネント、および UI コンポーネントを配置するためのレイアウト定義からなります。 UI コンポーネントも定義上レイアウト的な要素を含みますが、ここでは UI コンポーネントはそれ自身で完結する UI 要素を表し、レイアウトは後からコンポーネントを差し込むことによってはじめて UI として完成するものを指します。

CSS 等の描画関連のクラス設定や JavaScript を利用した UI 操作はこのレイヤーで扱います。 UI レイヤーでは JavaScript による高度な UI 操作の場合を除き、基本的にコマンドを扱いません。

UI コンポーネント

UI コンポーネントは静的に記述される純粋な UI およびクリックなどの UI 操作を定義したコンポーネントです。

単純 UI コンポーネントはボタンやテキストボックスといった単純な UI を提供するコンポーネントです。 View によって表示される状態を Model で管理したり、 UI 操作と Update 操作で利用されるメッセージを紐づけたりします。

複合 UI コンポーネントはコンポーネント内に複数の UI コンポーネントを持ちます。 UI コンポーネントはあくまで UI 操作のみを定義するので、単純 UI コンポーネントと同様に複合 UI コンポーネントとして UI 操作を定義します。

ボタンクリックのような UI 操作に対してメッセージを送信しますが、トリガーとなるメッセージのみ定義し、実際のアクションは機能レイヤーで扱います。したがって Update 操作は MVU アーキテクチャー上定義されますが、メッセージを受けても Model の変更は基本的に行いません(そのまま返します)。ただし、ダイアログの閉じるボタンをクリックしたらダイアログを閉じるといった純粋な UI 操作については UI コンポーネントで定義します。

例)ボタン

ここでのボタンは表示テキストおよびボタンの状態、そしてクリック操作を定義します。

コード
Button.html
<button class="button" onclick="${Clicked}" attr="${ButtonAttr}">
  <span>${Text}</span>
</button>
Button.fs
module MvuApp.Client.UI.Button

open Bolero

type ButtonState =
   | Active
   | Disabled
   | Busy

type Model = {
   Text : string
   State : ButtonState
}

type Message =
   | SetText of string
   | SetState of ButtonState
   | Clicked

let init text =
   {
      Text = text
      State = Active
   }

let update message model =
   match message with
   | SetText(text) -> { model with Text = text }
   | SetState(state) -> { model with State = state }
   | Clicked -> model
   
let setText = SetText >> update
let setActive = SetState(Active) |> update
let setDisabled = SetState(Disabled) |> update
let setBusy = SetState(Busy) |> update

type private ViewTemplate = Template<const(__SOURCE_DIRECTORY__ + "/Button.html")>

let view model dispatch =
   ViewTemplate()
      .ButtonAttr(
         match model.State with
         | Active -> []
         | Disabled -> [Html.attr.disabled "disabled"]
         | Busy -> [Html.attr.disabled "disabled"; Html.attr.classes ["is-loading"]]
      )
      .Text(model.Text)
      .Clicked(fun _ -> Clicked |> dispatch)
      .Elt()

type UIComponent() =
   inherit ElmishComponent<Model, Message>()
   override _.View model dispatch = view model dispatch

update 関数の実装を見てわかる通り、クリック操作に関しては現在のモデル自身を返すだけで何も変更を行いません。

例)カウンターコンポーネント

テキストボックスとボタンを配置したカウンターの UI を定義します。

コード
Counter.html
<div class="field is-grouped">
  <p class="control">
    ${Decrement10Button}
  </p>
  <p class="control">
    ${DecrementButton}
  </p>
  <p class="control is-expanded">
    ${Count}
  </p>
  <p class="control">
    ${IncrementButton}
  </p>
  <p class="control">
    ${Increment10Button}
  </p>
</div>
Counter.fs
module MvuApp.Client.UI.Counter

open Bolero

type Model = {
   Input : NumericInput.Model
   IncrementButton : Button.Model
   Increment10Button : Button.Model
   DecrementButton : Button.Model
   Decrement10Button : Button.Model
}

type Message =
   | SetValue of int
   | SetButtonActive
   | SetButtonBusy
   | IncrementButtonClicked
   | Increment10ButtonClicked
   | DecrementButtonClicked
   | Decrement10ButtonClicked

let init () =
   {
      Input = NumericInput.init true 0
      IncrementButton = Button.init "+1"
      Increment10Button = Button.init "+10"
      DecrementButton = Button.init "-1"
      Decrement10Button = Button.init "-10"
   }

let update message model =
   match message with
   | SetValue(value) -> { model with Input = NumericInput.setValue value model.Input }
   | SetButtonActive -> { model with IncrementButton = Button.setActive model.IncrementButton; Increment10Button = Button.setActive model.Increment10Button; DecrementButton = Button.setActive model.DecrementButton; Decrement10Button = Button.setActive model.Decrement10Button }
   | SetButtonBusy -> { model with IncrementButton = Button.setBusy model.IncrementButton; Increment10Button = Button.setBusy model.Increment10Button; DecrementButton = Button.setBusy model.DecrementButton; Decrement10Button = Button.setBusy model.Decrement10Button }
   | IncrementButtonClicked -> model
   | Increment10ButtonClicked -> model
   | DecrementButtonClicked -> model
   | Decrement10ButtonClicked -> model

let setValue = SetValue >> update
let setBusy = SetButtonBusy |> update
let setActive = SetButtonActive |> update

type private ViewTemplate = Template<const(__SOURCE_DIRECTORY__ + "/Counter.html")>

let view model dispatch =
   ViewTemplate()
      .IncrementButton(Html.ecomp<Button.UIComponent, _, _> [] model.IncrementButton (function
         | Button.Clicked -> IncrementButtonClicked |> dispatch
         | _ -> ()
      ))
      .Increment10Button(Html.ecomp<Button.UIComponent, _, _> [] model.Increment10Button (function
         | Button.Clicked -> Increment10ButtonClicked |> dispatch
         | _ -> ()
      ))
      .DecrementButton(Html.ecomp<Button.UIComponent, _, _> [] model.DecrementButton (function
         | Button.Clicked -> DecrementButtonClicked |> dispatch
         | _ -> ()
      ))
      .Decrement10Button(Html.ecomp<Button.UIComponent, _, _> [] model.Decrement10Button (function
         | Button.Clicked -> Decrement10ButtonClicked |> dispatch
         | _ -> ()
      ))
      .Count(Html.ecomp<NumericInput.UIComponent, _, _> [] model.Input ignore)
      .Elt()

type UIComponent() =
   inherit ElmishComponent<Model, Message>()
   override _.View model dispatch = view model dispatch

各ボタンのクリックを機能コンポーネントから利用されるためにメッセージ定義したり、ボタンを一括でビジー状態にしてクリック不可にするための処理を定義しています。

レイアウト

レイアウトはコンポーネントの配置のみを定義します。レイアウトでは画面操作に関連する処理は定義されず、レイアウト内に配置された上位コンポーネントが実際の処理を行うことになります。

レイアウトは、 UI コンポーネントが機能レイヤーから参照されるのと異なり、上位の画面レイヤーもしくはアプリケーションレイヤーから参照されます。機能レイヤーは対応する UI コンポーネントが定義できるのでレイアウトを基本的に必要としないためです。

レイアウトモジュール

レイアウト内に配置されるコンポーネントの描画及びメッセージの伝播を仲介するモジュールを作成します。以下は 3 つのコンポーネントを配置するための Layout3 モジュールです。同様に n 個のコンポーネントを配置するレイアウトモジュールを定義することができます。

コード
Layout.fs
namespace MvuApp.Client.UI.Layout

open Microsoft.AspNetCore.Components
open Bolero
open Elmish

module Layout3 =

   type Model<'C1, 'C2, 'C3> = {
      Component1 : 'C1
      Component2 : 'C2
      Component3 : 'C3
   }

   type Message<'C1, 'C2, 'C3, 'M1, 'M2, 'M3> =
      | SetComponent1 of 'C1
      | SetComponent2 of 'C2
      | SetComponent3 of 'C3
      | ReceiveComponent1Message of 'M1
      | ReceiveComponent2Message of 'M2
      | ReceiveComponent3Message of 'M3

   let init c1 c2 c3 =
      {
         Component1 = c1
         Component2 = c2
         Component3 = c3
      }

   let update message model =
      match message with
      | SetComponent1(c1) -> { model with Component1 = c1 }
      | ReceiveComponent1Message(_) -> model
      | SetComponent2(c2) -> { model with Component2 = c2 }
      | ReceiveComponent2Message(_) -> model
      | SetComponent3(c3) -> { model with Component3 = c3 }
      | ReceiveComponent3Message(_) -> model
      
   let setComponent1<'C1, 'C2, 'C3, 'M1, 'M2, 'M3> = Message<'C1, 'C2, 'C3, 'M1, 'M2, 'M3>.SetComponent1 >> update
   let setComponent2<'C1, 'C2, 'C3, 'M1, 'M2, 'M3> = Message<'C1, 'C2, 'C3, 'M1, 'M2, 'M3>.SetComponent2 >> update
   let setComponent3<'C1, 'C2, 'C3, 'M1, 'M2, 'M3> = Message<'C1, 'C2, 'C3, 'M1, 'M2, 'M3>.SetComponent3 >> update

   let mapComponent1 mapper model =
      {
         Component1 = mapper model.Component1
         Component2 = model.Component2
         Component3 = model.Component3
      }
   let mapComponent2 mapper model =
      {
         Component1 = model.Component1
         Component2 = mapper model.Component2
         Component3 = model.Component3
      }
   let mapComponent3 mapper model =
      {
         Component1 = model.Component1
         Component2 = model.Component2
         Component3 = mapper model.Component3
      }

   let view<'C1, 'C2, 'C3, 'M1, 'M2, 'M3>
      (template:Node -> Node -> Node -> Node)
      (viewComponent1:'C1 -> Dispatch<'M1> -> Node)
      (viewComponent2:'C2 -> Dispatch<'M2> -> Node)
      (viewComponent3:'C3 -> Dispatch<'M3> -> Node)
      (model:Model<'C1, 'C2, 'C3>)
      (dispatch:Dispatch<Message<'C1, 'C2, 'C3, 'M1, 'M2, 'M3>>) =
      let node1 = viewComponent1 model.Component1 (ReceiveComponent1Message >> dispatch)
      let node2 = viewComponent2 model.Component2 (ReceiveComponent2Message >> dispatch)
      let node3 = viewComponent3 model.Component3 (ReceiveComponent3Message >> dispatch)
      template node1 node2 node3

   type LayoutComponent<'C1, 'C2, 'C3, 'M1, 'M2, 'M3>() =
      inherit ElmishComponent<Model<'C1, 'C2, 'C3>, Message<'C1, 'C2, 'C3, 'M1, 'M2, 'M3>>()
      [<Parameter>]
      member val Template : Node -> Node -> Node -> Node = fun node1 node2 node3 -> Html.concat [node1; node2; node3]  with get, set
      [<Parameter>]
      member val ViewComponent1 : 'C1 -> Dispatch<'M1> -> Node = fun _ _ -> Html.empty with get, set
      [<Parameter>]
      member val ViewComponent2 : 'C2 -> Dispatch<'M2> -> Node = fun _ _ -> Html.empty with get, set
      [<Parameter>]
      member val ViewComponent3 : 'C3 -> Dispatch<'M3> -> Node = fun _ _ -> Html.empty with get, set
      override this.View model dispatch = view this.Template this.ViewComponent1 this.ViewComponent2 this.ViewComponent3 model dispatch
      
   let layout<'L, 'C1, 'C2, 'C3, 'M1, 'M2, 'M3, 'A when 'L :> LayoutComponent<'C1, 'C2, 'C3, 'M1, 'M2, 'M3>>
      (template:Node -> Node -> Node -> Node)
      (viewComponent1:'C1 -> Dispatch<'M1> -> Node)
      (viewComponent2:'C2 -> Dispatch<'M2> -> Node)
      (viewComponent3:'C3 -> Dispatch<'M3> -> Node)
      (escalateMessage1:'M1 -> 'A)
      (escalateMessage2:'M2 -> 'A)
      (escalateMessage3:'M3 -> 'A)
      (model:Model<'C1, 'C2, 'C3>)
      (dispatch:Dispatch<'A>) =
      Html.ecomp<'L, _, _> [Attr("Template", template); Attr("ViewComponent1", viewComponent1); Attr("ViewComponent2", viewComponent2); Attr("ViewComponent3", viewComponent3)] model (function
      | ReceiveComponent1Message(msg) -> escalateMessage1 msg |> dispatch
      | ReceiveComponent2Message(msg) -> escalateMessage2 msg |> dispatch
      | ReceiveComponent3Message(msg) -> escalateMessage3 msg |> dispatch
         | _ -> ()
      )

レイアウトモジュールを利用して個別のレイアウトを定義することが容易になります。レイアウトモジュールの最後に定義している layout 関数に、コンポーネントの View 関数やメッセージを上位レイヤーの Update に伝播させる関数を与えてやることで、レイヤー間でメッセージ処理の仲介を行うことができます。

型定義がかなりえぐいことになっていますが、明示しないと緩い型(ElmishComponent)で推論されてしまい、 ElmishComponent が抽象型なのでインスタンス作成時に例外が投げられてしまいます。 layout 関数を呼び出すときはジェネリック型を明示することを忘れないようにします[2]

例)アプリケーションレイアウト

ここではヘッダー、サイドメニュー、メイン部分、認証モーダルの 4 画面からなるアプリケーションレイアウトを定義します。ただし、サイドメニューに関しては実証実験ではレイアウトの HTML 内で静的に定義しているためコンポーネント化していません。したがって、レイアウトモジュールは Layout3 を利用します。

コード
ApplicationLayout.html
${Header}
<div class="columns">
  <div class="column is-narrow">
    <aside class="sidemenu">
      <section class="section">
        <ul class="menu-list">
          <li><a href="/">Home</a></li>
          <li><a href="/counter">Counter</a></li>
        </ul>
      </section>
    </aside>
  </div>
  <div class="column">
    ${Main}
  </div>
</div>
${SignInModal}
ApplicationLayout.fs
module MvuApp.Client.UI.Layout.ApplicationLayout

open Bolero

type private ViewTemplate = Template<const(__SOURCE_DIRECTORY__ + "/ApplicationLayout.html")>

let template (header:Node) (main:Node) (modal:Node) =
   ViewTemplate()
      .Header(header)
      .Main(main)
      .SignInModal(modal)
      .Elt()

レイアウトモジュールを利用してテンプレートに当てはめるだけの template 関数を作成しています。これをアプリケーションコンポーネントから利用することでレイアウトの適用が行えます。

機能レイヤー

機能レイヤーは、 UI レイヤーに属するコンポーネントをラップして機能実装を行います。機能レイヤーのモデルは UI レイヤーに属するの Model をプロパティーに持ち、 UI レイヤーの Update 操作により UI 操作を行うことになります。

サービスと連携してアプリケーションデータをサーバーサイドとやり取りしたり、クライアントサイドで処理を行ったりといった、いわゆるインターフェイスを利用する操作はこのレイヤーで扱います。インターフェイス先から送信されたエラーはこのレイヤーで受け取ることになります。

処理によってはコンポーネント単体で処理できない・するべきではない場合があります。例えば認証操作のようなアプリケーション共通で行うべき操作については、共通処理メッセージをアプリケーションレイヤーに通知します[3]

機能コンポーネント

機能レイヤーのコンポーネントは、 UI コンポーネントを子コンポーネントとして持ち、そのメッセージ処理を管理します。つまり UI コンポーネントに送信されたメッセージを見ることで、機能レイヤーのコンポーネント自身がどのような処理を行うかを判断します。 View の実装は UI コンポーネントに委ねます。

機能コンポーネントは原則ただ一つの UI コンポーネントを利用します。機能コンポーネントはあくまで UI 操作によって行われる機能のみにフォーカスし、レイアウトに関して関与するべきではありません。

例)リモートカウンター

リモートカウンターは、ボタンをクリックするとリモートサービスを呼び出して increment/decrement を行うボタンです。

コード
RemoteCounterService.fs
module MvuApp.Client.Feature.RemoteCounterService

open Bolero.Remoting

type Service = {
   IncrementAsync : int -> Async<int>
   Increment10Async : int -> Async<int>
   DecrementAsync : int -> Async<int>
   Decrement10Async : int -> Async<int>
} with
   interface IRemoteService with
      member _.BasePath = "/-/counter"
RemotingCounter.fs
module MvuApp.Client.Feature.RemotingCounter

open Bolero
open Bolero.Remoting
open Elmish
open MvuApp.Client.UI
open RemoteCounterService

type Model = {
   Counter : Counter.Model
}

type FeatureError =
   | RemoteError of exn

type Message =
   | Increment
   | Increment10
   | Decrement
   | Decrement10
   | GetRemoteResult of int
   | ReceiveCounterMessage of Counter.Message
   | FeatureError of FeatureError

let init () =
   let model = {
      Counter = Counter.init ()
   }
   model, Cmd.none

let update service message model =
   match message with
   | Increment ->
      let currentValue = model.Counter.Input.Value
      let model = { model with Counter = Counter.setBusy model.Counter }
      let cmd = Cmd.OfAsync.either service.IncrementAsync currentValue GetRemoteResult (RemoteError >> FeatureError)
      model, cmd, None
   | Increment10 ->
      let currentValue = model.Counter.Input.Value
      let model = { model with Counter = Counter.setBusy model.Counter }
      let cmd = Cmd.OfAsync.either service.Increment10Async currentValue GetRemoteResult (RemoteError >> FeatureError)
      model, cmd, None
   | Decrement ->
      let currentValue = model.Counter.Input.Value
      let model = { model with Counter = Counter.setBusy model.Counter }
      let cmd = Cmd.OfAsync.either service.DecrementAsync currentValue GetRemoteResult (RemoteError >> FeatureError)
      model, cmd, None
   | Decrement10 ->
      let currentValue = model.Counter.Input.Value
      let model = { model with Counter = Counter.setBusy model.Counter }
      let cmd = Cmd.OfAsync.either service.Decrement10Async currentValue GetRemoteResult (RemoteError >> FeatureError)
      model, cmd, None
   | GetRemoteResult(value) ->
      let model = { model with Counter = (Counter.setValue value >> Counter.setActive) model.Counter }
      model, Cmd.none, None
   | ReceiveCounterMessage(msg) ->
      let model = { model with Counter = Counter.update msg model.Counter }
      let cmd =
         match msg with
         | Counter.IncrementButtonClicked -> Cmd.ofMsg Increment
         | Counter.Increment10ButtonClicked -> Cmd.ofMsg Increment10
         | Counter.DecrementButtonClicked -> Cmd.ofMsg Decrement
         | Counter.Decrement10ButtonClicked -> Cmd.ofMsg Decrement10
         | _ -> Cmd.none
      model, cmd, None
   | FeatureError(error) ->
      let model = { model with Counter = Counter.setActive model.Counter }
      match error with
      | RemoteError(RemoteUnauthorizedException) -> model, Cmd.none, Some(ApplicationFeature.RequestSignIn)
      | RemoteError(ex) -> model, Cmd.none, Some(ApplicationFeature.UnhandledError(ex))

let view model dispatch =
   Html.ecomp<Counter.UIComponent, _, _> [] model.Counter (ReceiveCounterMessage >> dispatch)

type FeatureComponent() =
   inherit ElmishComponent<Model, Message>()
   override _.View model dispatch = view model dispatch

ApplicationFeature は本文中に登場していませんが、アプリケーションレイヤーに通知する共通処理メッセージを定義しています。

画面レイヤー

画面レイヤーは、機能レイヤーに属するコンポーネントを配置し、機能を取りまとめることで UI/UX デザインを定義します。なお、画面という言葉で紛らわしいかもしれませんが、 1 つのウェブページ中に表示されるものを 1 画面と言っているのではなく、機能コンポーネントの適切な単位の塊のことを画面と呼んでいます。

UI レイヤーが UI を定義するレイヤーであるのに対し、画面レイヤーは UI および UI を利用して得られる機能をユーザーに提供する UX のレイヤーと考えてもよいでしょう。

画面コンポーネント

画面コンポーネントは機能コンポーネントを子コンポーネントとして持ちます。画面レイアウトは UI レイヤーで定義したレイアウトコンポーネントを利用します。

画面内に複数の機能コンポーネントがあり、機能同士が相互に作用するよう必要がある場合は画面コンポーネントで仲介します。

例)カウンター画面

カウンター画面はカウンターをレイアウトに配置したシンプルな画面です。カウンター機能コンポーネントをただ一つ配置した単純なレイアウトです(レイアウトのコードは省略)。

コード
CounterScreen.fs
module MvuApp.Client.Screen.CounterScreen

open Bolero
open Elmish
open MvuApp.Client.UI.Layout
open MvuApp.Client.Feature

type Model = {
   Layout : Layout1.Model<RemotingCounter.Model>
}

type Message =
   | ReceiveCounterMessage of RemotingCounter.Message

let init () =
   let counter, counterCmd = RemotingCounter.init ()
   let model = {
      Layout = Layout1.init counter
   }
   let cmd = Cmd.map ReceiveCounterMessage counterCmd
   model, cmd

let update service message model =
   match message with
   | ReceiveCounterMessage(msg) ->
      let counter, counterCmd, appMsg = RemotingCounter.update service msg model.Layout.Component
      let model = { Layout = Layout1.setComponent counter model.Layout }
      let cmd = Cmd.map ReceiveCounterMessage counterCmd
      model, cmd, appMsg

let view model dispatch =
   let viewComponent = Html.ecomp<RemotingCounter.FeatureComponent, _, _> []
   Layout1.layout CounterScreenLayout.template viewComponent ReceiveCounterMessage model.Layout dispatch

type ScreenComponent() =
   inherit ElmishComponent<Model, Message>()
   override _.View model dispatch = view model dispatch

アプリケーションレイヤー

アプリケーションレイヤーは、レイアウト中の画面コンポーネントを差し替えることで画面遷移を発生させたり、機能レイヤーから伝播された共通処理を行います。

画面遷移は MVU アーキテクチャーの仕組みでは単純です。画面情報(画面コンポーネント)を Model が保持しており、 Update でコンポーネントの状態を切り替えることによって View の結果が画面切り替えとなるだけです。 SPA の場合は画面コンポーネントに加え、ルーティング情報も管理することになります。

共通処理は、単一の機能レイヤーで処理できないような処理や、アプリケーションを通して管理されるべき情報の処理などに利用されます。例えば以下のようなものは共通処理として実装されます。

  • 認証
  • 通知
  • 追跡

アプリケーションコンポーネント

アプリケーションコンポーネントは、基本的に画面コンポーネントおよびその遷移に関する処理のみを管理します。共通処理に関することは原則 Update 処理で行います。共通処理の結果は、アプリケーションコンポーネントで直接管理せず、画面コンポーネントへの通知を持って画面に反映させます。

例えば認証に成功したときにヘッダーにユーザー名を表示するような処理を考えます。アプリケーションコンポーネントの責務は認証処理をすること、そして認証の結果をヘッダー画面に表示させるように通知することのみであり、どのような画面変化をもたらすかを決めるのは画面レイヤー以下の責務です。

アプリケーションコンポーネントはアプリケーションにただ一つだけ存在するべきです。複数のアプリケーションコンポーネントが存在した場合、それらのアプリケーションを管理する必要があるはずです。

例)カウンターアプリケーション

コード
AuthenticationService.fs
module MvuApp.Client.Application.AuthenticationService

open System
open Bolero.Remoting

type SignInRequestModel = {
   Name : string
   Password : string
}
type SignInUserResponseModel =
   | NotSignedIn
   | User of string * string

type Service = {
   SignInAsync : SignInRequestModel -> Async<unit>
   SignOutAsync : unit -> Async<unit>
   TryGetSignInUserAsync : unit -> Async<SignInUserResponseModel>
} with
   interface IRemoteService with
      member _.BasePath = "/-/authentication"
Application.fs
[<AutoOpen>]
module MvuApp.Client.Application.Application

open Elmish
open Bolero
open Bolero.Remoting
open Bolero.Remoting.Client
open Bolero.Templating.Client
open MvuApp.Client.UI.Layout
open MvuApp.Client.Feature
open MvuApp.Client.Screen

type Page =
   | [<EndPoint("/")>] Home
   | [<EndPoint("/counter")>] Counter

type Model = {
   Page : Page
   HeaderScreen : HeaderScreen.Model
   HomeScreen : HomeScreen.Model
   CounterScreen : CounterScreen.Model option
   SignInScreen : SignInScreen.Model option
}

type ApplicationError =
   | AuthenticationFailed of exn
   | UnhandledError of exn

type Message =
   | SetPage of Page
   | GetUser
   | GotUser of AuthenticationService.SignInUserResponseModel
   | StartSignIn
   | SignIn of Credentials
   | SignedIn
   | SignOut
   | SignedOut
   | StartCounter
   | ReceiveHeaderMessage of HeaderScreen.Message
   | ReceiveSignInMessage of SignInScreen.Message
   | ReceiveHomeMessage of HomeScreen.Message
   | ReceiveCounterMessage of CounterScreen.Message
   | ApplicationError of ApplicationError

let init () =
   let header = HeaderScreen.init ()
   let home = HomeScreen.init ()
   let model = {
      Page = Home
      HeaderScreen = header
      HomeScreen = home
      CounterScreen = None
      SignInScreen = None
   }
   let cmd = Cmd.ofMsg GetUser
   model, cmd

let router = Router.infer SetPage (fun model -> model.Page)

let mapAppFeatureCmd = function
   | ApplicationFeatureMessage.RequestSignIn -> Cmd.ofMsg StartSignIn
   | ApplicationFeatureMessage.SignIn(credentials) -> Cmd.ofMsg <| SignIn(credentials)
   | ApplicationFeatureMessage.SignOut -> Cmd.ofMsg SignOut
   | ApplicationFeatureMessage.UnhandledError(errorInfo) -> Cmd.ofMsg <| ApplicationError(UnhandledError(errorInfo))
let mapOptionAppFeatureCmd = function
   | Some(appMsg) -> mapAppFeatureCmd appMsg
   | None -> Cmd.none

let update (program:ProgramComponent<_, _>) message model =
   match message with
   | SetPage(page) ->
      let model = { model with Page = page }
      let cmd =
         match page with
         | Home -> Cmd.none
         | Counter -> Cmd.ofMsg StartCounter
      model, cmd
   | GetUser ->
      let service = program.Remote<AuthenticationService.Service>()
      let cmd = Cmd.OfAsync.perform service.TryGetSignInUserAsync () GotUser
      model, cmd
   | GotUser(user) ->
      let header, appMsg =
         match user with
         | AuthenticationService.NotSignedIn -> HeaderScreen.signedOut model.HeaderScreen
         | AuthenticationService.User(_, name) -> HeaderScreen.signedIn name model.HeaderScreen
      let model = { model with HeaderScreen = header }
      let cmd = mapOptionAppFeatureCmd appMsg
      model, cmd
   | StartSignIn ->
      let modal = SignInScreen.init ()
      let model = { model with SignInScreen = Some(modal) }
      model, Cmd.none
   | SignIn(credentials) ->
      let service = program.Remote<AuthenticationService.Service>()
      let request = { AuthenticationService.SignInRequestModel.Name = credentials.Name; AuthenticationService.SignInRequestModel.Password = credentials.Password }
      let cmd = Cmd.OfAsync.either service.SignInAsync request (fun _ -> SignedIn) (AuthenticationFailed >> ApplicationError)
      model, cmd
   | SignedIn ->
      let model = { model with SignInScreen = None }
      let cmd = Cmd.ofMsg GetUser
      model, cmd
   | SignOut ->
      let service = program.Remote<AuthenticationService.Service>()
      let cmd = Cmd.OfAsync.either service.SignOutAsync () (fun _ -> SignedOut) (AuthenticationFailed >> ApplicationError)
      model, cmd
   | SignedOut ->
      let cmd = Cmd.ofMsg GetUser
      model, cmd
   | StartCounter ->
      let counter, counterCmd = CounterScreen.init ()
      let model = { model with CounterScreen = Some(counter) }
      let cmd = Cmd.map ReceiveCounterMessage counterCmd
      model, cmd
   | ReceiveHeaderMessage(msg) ->
      let header, appMsg = HeaderScreen.update msg model.HeaderScreen
      let model = { model with HeaderScreen = header }
      let cmd = mapOptionAppFeatureCmd appMsg
      model, cmd
   | ReceiveSignInMessage(msg) ->
      match Option.map (SignInScreen.update msg) model.SignInScreen with
      | None -> model, Cmd.none
      | Some(signIn, appMsg) ->
         let model = { model with SignInScreen = Some(signIn) }
         let cmd = mapOptionAppFeatureCmd appMsg
         model, cmd
   | ReceiveHomeMessage(msg) ->
      let model = { model with HomeScreen = HomeScreen.update msg model.HomeScreen }
      model, Cmd.none
   | ReceiveCounterMessage(msg) ->
      let service = program.Remote<RemoteCounterService.Service>()
      match Option.map (CounterScreen.update service msg) model.CounterScreen with
      | None -> model, Cmd.none
      | Some(counter, counterCmd, appMsg) ->
         let model = { model with CounterScreen = Some(counter) }
         let cmd = Cmd.batch [
            Cmd.map ReceiveCounterMessage counterCmd
            mapOptionAppFeatureCmd appMsg
         ]
         model, cmd
   | ApplicationError(error) ->
      match error with
      | AuthenticationFailed(ex) ->
         match model.SignInScreen with
         | None ->
            model, Cmd.none
         | Some(signIn) ->
            let signIn, appMsg = SignInScreen.setSignInError ex.Message signIn
            let model = { model with SignInScreen = Some(signIn) }
            let cmd = mapOptionAppFeatureCmd appMsg
            model, cmd
      | UnhandledError(errorInfo) -> model, Cmd.none  // TODO

let private viewHeaderScreen screen dispatch =
   Html.ecomp<HeaderScreen.ScreenComponent, _, _> [] screen dispatch
let private viewSignInScreen screen dispatch =
   match screen with
   | None -> Html.empty
   | Some(screen) -> Html.ecomp<SignInScreen.ScreenComponent, _, _> [] screen dispatch
let private viewHomeScreen screen dispatch =
   Html.ecomp<HomeScreen.ScreenComponent, _, _> [] screen dispatch
let private viewCounterScreen screen dispatch =
   match screen with
   | None -> Html.empty
   | Some(screen) -> Html.ecomp<CounterScreen.ScreenComponent, _, _> [] screen dispatch

let private layout getMainScreen viewMainScreen receiveMainMessage model dispatch =
   let layoutModel = Layout3.init model.HeaderScreen (getMainScreen model) model.SignInScreen
   Layout3.layout ApplicationLayout.template viewHeaderScreen viewMainScreen viewSignInScreen ReceiveHeaderMessage receiveMainMessage ReceiveSignInMessage layoutModel dispatch

let view model dispatch =
   match model.Page with
   | Home -> layout (fun model -> model.HomeScreen) viewHomeScreen ReceiveHomeMessage model dispatch
   | Counter -> layout (fun model -> model.CounterScreen) viewCounterScreen ReceiveCounterMessage model dispatch

type ApplicationComponent() =
   inherit ProgramComponent<Model, Message>()

   override this.Program =
      Program.mkProgram (fun _ -> init ()) (update this) view
      |> Program.withRouter router
#if DEBUG
      |> Program.withHotReload
#endif

画面コンポーネント定義では Model にレイアウトコンポーネントを持たせましたが、アプリケーションコンポーネントで Model にレイアウトコンポーネントを持たせようとすると、メイン画面の型が画面によって変わるため、レイアウトコンポーネントに型パラメーターが必要になってしまいます。それを避けるためそれぞれの Model にはそれぞれの画面コンポーネントを持たせています。

まとめ

MVU アーキテクチャーではその柔軟さからあらゆるものをコンポーネント化したり、逆にモノリシックな構成のアプリケーションを作ることもできます。しかしアプリケーションの保守を考えると適切なレイヤー構造を設けて型にはめてやる必要があります。

本記事では UI、機能、画面、アプリケーションの 4 層による MVU アーキテクチャーの設計についてサンプルコード付きでまとめました。レイヤーごとに責務を定義することで、各レイヤーのコンポーネントが行うべき処理の範囲が制限され、コンポーネントの肥大化を防ぎます。また、コンポーネント参照を直下のレイヤーのコンポーネントのみに制約することにより、コンポーネント間の依存関係が単純化されます。

4 層 MVU についてはまだ実証実験程度のアプリケーションしか作っていないので、今後実環境(delika)に適用していけそうかを検討していく予定です。

脚注
  1. delika は GitHub みたいな感覚で簡単にデータ共有ができるプラットフォームです。スクレイピングやオフラインで集めて作成したデータを共有することで、他のユーザーがすぐに利用できる状態のデータを享受してデータ分析等に利用することができます。スクレイピングしたデータを共有したり、分析用データを一時的に置いたり、お好きな用途で使ってください。 ↩︎

  2. ジェネリック型パラーメーターの制約で具象クラスのようなもの書ければジェネリック型を指定し忘れた際にコンパイルエラーになってよいのですが。 ↩︎

  3. Elmish 的にはコマンドを利用して子コンポーネントのコマンドを親コンポーネントのコマンドに変換して伝播させていくのが正統派です。しかし機能レイヤーで発生させる共通処理メッセージは画面レイヤーで処理されることはないため、処理が複雑になります。共通処理メッセージをそのままアプリケーションレイヤーに通過させる意味で、共通処理メッセージは Update の結果として返すやり方が個人的にはよいと思います。 Update の結果のタプル数が変わるので上位レイヤーでコンパイルエラーになって気が付きやすいというのもあります。サンプルアプリケーションでは後者の方法で実装しています。 ↩︎