前回の記事、SAFE Templateで生成したプロジェクトの構成ではSAFE Templateで作成されたプロジェクトのファイル構成とサーバー側ソースの簡単な説明をしました。
今回は前回に引き続き、クライアント側ソースを見てみたいと思います。

クライアント側のソースも /src/Client/Client.fs というファイル一つなのですが、中身はサーバー側ほと単純ではありません。
前回の記事の最後の方でも少し書きましたが、Elm Architecture というアーキテクチャーの基で作成されています。

Elmish

Elmish (英語)のページにとても詳しく書いてありますが、日本語がいいという方は本家 Elm の説明がわかりやすいと思います。

SAFE Templateではcounterサンプルが実装されているので、次のようになっています。

  • Model ... カウンター値を保持する
  • Message ... + ボタン- ボタン に対応する Increment、Decrementメッセージなど
  • init関数 ... Modelの初期化を行っています。初期値はサーバーから取得しています
  • update関数 ... Messageに対してModelの変更を行います。Elm Architectureの中核の部分です
  • view関数 ... Modelの値を基にDOMを生成します。 また、イベントからメッセージを戻したりします
  • program ... init関数、update関数、view関数を指定してアプリケーションを構築します。

update関数やview関数を書いているとついつい忘れがちですが実際の画面更新やイベントハンドリングを行っているのは最後のprogramです。
マルチページアプリケーションを作成していると、Messageがprogramまで渡っていなかったりして、何故動かないんだろう?というハメになったりする(少なくとも私は2回ほどハマりました)ので、頭のどこかにシーケンス図を描くようにする癖を付けると良いかもしれません。

では、簡単にソースコードの解説をしようと思うのですが、/src/Client/Client.fs ひとつなので、上から切り刻んでいきます。

ModelとMessage定義部

module Client

open Elmish
open Elmish.React

open Fable.Helpers.React
open Fable.Helpers.React.Props
open Fable.PowerPack.Fetch

open Shared

open Fulma

open Fulma.FontAwesome

// The model holds data that you want to keep track of while the application is running
// in this case, we are keeping track of a counter
// we mark it as optional, because initially it will not be available from the client
// the initial value will be requested from server
type Model = { Counter: Counter option }

// The Msg type defines what events/actions can occur while the application is running
// the state of the application changes *only* in reaction to these events
type Msg =
| Increment
| Decrement
| InitialCountLoaded of Result<Counter, exn>

moduleの宣言と外部モジュールのopenは省略します。

type Model = { Counter: Counter option }

この部分がModelの定義になります。
F#のレコード型として定義されています。

Counterという名前の Option<Counter>型のプロパティが定義されています。
Counter型は src/Shared/Shared.fsにて、

type Counter = int

というエイリアスが定義されています。実体はOption<int>ということになりますね。

Optionってなんだよ?という方は公式ページの説明をどうぞ。
F#入門の方がわかりやすいかもしれません。

続いてMessageは判別共用体として定義されています。

type Msg =
| Increment
| Decrement
| InitialCountLoaded of Result<Counter, exn>

IncrementとDecrementは想像しやすいと思います。

InitialCountLoadedは、init関数でサーバーから初期値を取得してupdate関数へ伝搬するメッセージです。

Resultというのは、F#4.1で追加されたこれも判別共用体で、以下のような定義になっています。

type Result<'T,'TError> =
    | Ok of 'T 
    | Error of 'TError

OkとErrorという状態にそれぞれデータを付随できるようになっています。

Okの場合、Counterの初期値がInitialCountLoadedに付随されてくるということになります。

Fable.Remotingのクライアント側定義

module Server =

    open Shared
    open Fable.Remoting.Client

    /// A proxy you can use to talk to server directly
    let api : ICounterApi =
      Remoting.createApi()
      |> Remoting.withRouteBuilder Route.builder
      |> Remoting.buildProxy<ICounterApi>

Serverという名前のサブモジュールとして定義されているものが Fable.Remotingのクライアント側定義になります。

Route.builder関数やICounterApi型は前回の記事でも少し書きましたが、/src/Shared/Shared.fsに定義されているものです。

Shared.fsの内容を再掲します。

namespace Shared

type Counter = int

module Route =
    /// Defines how routes are generated on server and mapped from client
    let builder typeName methodName =
        sprintf "/api/%s/%s" typeName methodName

/// A type that specifies the communication protocol between client and server
/// to learn more, read the docs at https://zaid-ajaj.github.io/Fable.Remoting/src/basics.html
type ICounterApi =
    { initialCounter : unit -> Async<Counter> }

Remoting.withRouteBuilder関数に指定している Route.builder関数はサーバー側のapiのURLを返す関数として定義します。
引数 typeName と methodNameが与えられます。
今回の場合は、"/api/ICounterApi/initialCounter" というURLになります。
Sharedモジュールに定義されているので、仮に変更を行ったとしても、クライアントとサーバー側両方に反映されます。

init関数

let init () : Model * Cmd<Msg> =
    let initialModel = { Counter = None }
    let loadCountCmd =
        Cmd.ofAsync
            Server.api.initialCounter
            ()
            (Ok >> InitialCountLoaded)
            (Error >> InitialCountLoaded)
    initialModel, loadCountCmd

init関数はModelの初期化を行います。
counterサンプルではサーバーから初期値を取得しそれをMessageに載せてprogramに伝搬します。
init関数の戻り値はModelとMessageのタプルとなります。

Elmish.CmdモジュールにはMessageを生成する関数がいくつか定義されており、counterサンプルでは非同期処理の結果をメッセージに変換する ofAsync関数を使用しています。

update関数

let update (msg : Msg) (currentModel : Model) : Model * Cmd<Msg> =
    match currentModel.Counter, msg with
    | Some x, Increment ->
        let nextModel = { currentModel with Counter = Some (x + 1) }
        nextModel, Cmd.none
    | Some x, Decrement ->
        let nextModel = { currentModel with Counter = Some (x - 1) }
        nextModel, Cmd.none
    | _, InitialCountLoaded (Ok initialCount)->
        let nextModel = { Counter = Some initialCount }
        nextModel, Cmd.none

    | _ -> currentModel, Cmd.none

init関数やview関数で生成されたメッセージはprogramに伝搬されて、このupdate関数に渡ってきます。
counterサンプルではModelの値とメッセージを条件にパターンマッチングを行い、Modelの新しい状態とメッセージをprogramに再度伝搬します。

view関数

let view (model : Model) (dispatch : Msg -> unit) =
    div [ ]
        [ navBrand
          Container.container [ ]
              [ Columns.columns [ ]
                  [ Column.column [ Column.Width (Screen.All, Column.Is3) ]
                      [ menu ]
                    Column.column [ Column.Width (Screen.All, Column.Is9) ]
                      [ breadcrump
                        hero
                        info
                        columns model dispatch ] ] ] ]

view関数の前に、view関数内で使用しているレイアウト部分の関数(safeComponent, navBrand, menu, breadcrump, hero, infoなど)が定義されていますが、counter以外は割愛します。
Fable.Elmishでは画面の構築にReact(Fable.React)を使用しています。
見慣れない目には、なんか[]だらけでなんじゃこりゃ?と思われるかもしれません。
基本的には

関数名 属性のリスト 子要素のリスト

という構造になっています。関数名がタグ(divなど)を表していることが多いですね。

view関数の最後の部分、

                        columns model dispatch ] ] ] ]

で呼び出している columns関数の最後の方で、同じように呼び出されている counter関数の中で +ボタンーボタン が定義されています。

let counter (model : Model) (dispatch : Msg -> unit) =
    Field.div [ Field.IsGrouped ]
        [ Control.p [ Control.IsExpanded ]
            [ Input.text
                [ Input.Disabled true
                  Input.Value (show model) ] ]
          Control.p [ ]
            [ Button.a
                [ Button.Color IsInfo
                  Button.OnClick (fun _ -> dispatch Increment) ]
                [ str "+" ] ]
          Control.p [ ]
            [ Button.a
                [ Button.Color IsInfo
                  Button.OnClick (fun _ -> dispatch Decrement) ]
                [ str "-" ] ] ]

Button.OnClick (fun _ -> dispatch Incremet)という部分がイベントハンドラからメッセージに変換している部分になります。
+ボタンでIncrement、-ボタンでDecrementメッセージがprogramに伝搬されます。

メッセージを受けたprogramはupdate関数を呼び出すという流れになっています。

program

#if DEBUG
open Elmish.Debug
open Elmish.HMR
#endif

Program.mkProgram init update view
#if DEBUG
|> Program.withConsoleTrace
|> Program.withHMR
#endif
|> Program.withReact "elmish-app"
#if DEBUG
|> Program.withDebugger
#endif
|> Program.run

#ifディレクティブが有ってちょっと見づらいですが、重要なポイントは、

Program.mkProgram init update view

の部分でinit関数、update関数、view関数を引数に指定してprogramを作成し、いくつか設定をして最後に

|> Program.run

で動作させています。

途中のオプション設定については、今後の記事で記載していきたいと思います。


さて、このcounterサンプルはとてもシンプルな内容になっているので、私がこれから作成予定のtaxonomyテーブルの保守機能まで持っていくにはかなり手を入れる必要がありそうです。

adminレイアウトをベースにしたのは、taxonomyテーブルの保守機能以外に機能が増えた時に簡単に追加できるかなぁという思いからでしたが、そのページ切り替えから次回以降で実装をしていきたいと思います。