前回の記事、F#でDapperを使ったDBアクセスはサーバー側のデータアクセスに関する内容でした。
今回はクライアント側、Fable.Elmishを主に解説します。

Fable.Elmish自体についてはこれまで幾度かご紹介してきたので、この記事ではTaxonomyマスタ保守画面でどのように実装したか?という観点で進めます。

コードは前回と同様のGitHubのリポジトリを見ていただければと思います。

ProgramとPage間のやりとり

クライアント側のエントリポイントとなるClient.fsで指定している以下の部分、

Program.mkProgram init update view
|> Program.toNavigable Pages.urlParser urlUpdate

  :
  :
  :

|> Program.run

Program.mkProgram init update viewで、アプリケーション全体のinit関数、update関数、view関数を指定していますが、この中のupdate関数で各ページのupdate関数を呼び出す形になっています。
(以下、「アプリケーション全体のinit関数」というのを「 Program のinit関数」というように表記します)

src\Client\State.fs

let update msg model =
    match msg, model.PageModel with
    // 通知メッセージ
    | NotificationMsg msg, _ ->
        let errorToast note = 
            Toast.message note.message
            |> Toast.position Toast.TopRight
            |> Toast.noTimeout
            |> Toast.icon Fa.I.TimesCircle
            |> Toast.error
        let warningToast note = 
            Toast.message note.message
            |> Toast.position Toast.TopRight
            |> Toast.title note.title
            |> Toast.noTimeout
            |> Toast.icon Fa.I.ExclamationTriangle
            |> Toast.warning
        let successToast note = 
            Toast.message note.message
            |> Toast.position Toast.TopRight
            |> Toast.title note.title
            |> Toast.icon Fa.I.CheckCircle
            |> Toast.success
        let infoToast note = 
            Toast.message note.message
            |> Toast.position Toast.TopRight
            |> Toast.title note.title
            |> Toast.icon Fa.I.InfoCircle
            |> Toast.info
        match msg with
        | MsgType.Error note ->
            model, errorToast note
        | MsgType.Warning note ->
            model, warningToast note
        | MsgType.Success note ->
            model, successToast note
        | MsgType.Info note ->
            model, infoToast note
    // 例外メッセージ
    | ErrorMsg exn, _ ->
        let notify (exn:exn) = 
            Cmd.ofMsg (NotificationMsg (MsgType.Error { Note.title = ""; message = exn.Message }))
        match exn with
        | :? ProxyRequestException as ex -> 
            match ex.StatusCode with
            | _ -> 
                { model with Note = ex.Message } , notify exn
        | _ ->
            { model with Note = exn.Message } , notify exn

    | HomeMsg msg, HomeModel m ->
        let (model', cmd) = Home.State.update msg m
        { model with PageModel = HomeModel model' }, Cmd.map HomeMsg cmd
    | HomeMsg _, _ ->
        model, Cmd.none
    
    | CounterMsg msg, CounterModel m ->
        let (model', cmd) = Counter.State.update msg m
        { model with PageModel = CounterModel model' }, Cmd.map CounterMsg cmd
    | CounterMsg _, _ ->
        model, Cmd.none

    | JankenMsg msg, JankenModel m ->
        let (model', cmd) = Janken.State.update msg m
        { model with PageModel = JankenModel model' }, Cmd.map JankenMsg cmd
    | JankenMsg _, _ ->
        model, Cmd.none

    | TaxonomiesMsg msg, TaxonomiesModel m ->
        match msg with
        | Taxonomies.Types.Msg.ApiError exn -> 
            model, Cmd.ofMsg (ErrorMsg exn)
        | Taxonomies.Types.Msg.Notify note -> 
            model, Cmd.ofMsg (NotificationMsg note)
        | _ ->
            let (model', cmd) = Taxonomies.State.update msg m
            { model with PageModel = TaxonomiesModel model' }, Cmd.map TaxonomiesMsg cmd
    | TaxonomiesMsg _, _ ->
        model, Cmd.none

うん。長いですね!

このパターンマッチングは Program が扱うメッセージと、モデルの状態を表すPageModelという判別共用体により分岐しています。
それぞれ以下のような定義になっています。

src\Client\Types.fs

type Msg =
  | HomeMsg of Home.Types.Msg
  | CounterMsg of Counter.Types.Msg
  | JankenMsg of Janken.Types.Msg
  | TaxonomiesMsg of Taxonomies.Types.Msg
  | ErrorMsg of exn
  | NotificationMsg of Notification.MsgType

type PageModel =
  | HomeModel of Home.Types.Model
  | CounterModel of Counter.Types.Model
  | JankenModel of Janken.Types.Model
  | TaxonomiesModel of Taxonomies.Types.Model

type Model = {
    Note: string
    PageModel: PageModel
  }
  with 
      member this.CurrentPage = 
        match this.PageModel with
        | HomeModel _ -> Page.Home
        | CounterModel _ -> Page.Counter
        | JankenModel _ -> Page.Janken
        | TaxonomiesModel _ -> Page.Taxonomies

判別共用体の各要素(ケース識別子と呼ぶようですね)はデータを保持することができます。
そしてこのデータ自体が各ページ毎のメッセージを保持するようになっています。
この辺はFable.Elmish.Browserによるルーティングの実装でご紹介したFable.Elmish.Reactのテンプレートを踏襲しています。

このことを踏まえて、もう一度update関数に戻ってみると、

    match msg, model.PageModel with
       // 途中省略
       //    :     
    | TaxonomiesMsg msg, TaxonomiesModel m ->
        match msg with
        | Taxonomies.Types.Msg.ApiError exn -> 
            model, Cmd.ofMsg (ErrorMsg exn)
        | Taxonomies.Types.Msg.Notify note -> 
            model, Cmd.ofMsg (NotificationMsg note)
        | _ ->
            let (model', cmd) = Taxonomies.State.update msg m
            { model with PageModel = TaxonomiesModel model' }, Cmd.map TaxonomiesMsg cmd
    | TaxonomiesMsg _, _ ->
        model, Cmd.none

Taxonomiesページで発生したApiErrorNotifyというメッセージを捕まえてトーストを表示しています。
ちなみにトーストにはThoth.Elmish.Toastを使っています。

ApiErrorNotify以外のメッセージは TaxonomiesMsgに付随しているメッセージ(msg)TaxonomiesModelに付随しているモデル(m) を使って、Taxonomies.State.updateという関数をコールしています。
TaxonomiesMsgはMsgという判別共用体のケース識別子、TaxonomiesModelはPageModelという判別共用体のケース識別子です。名前がややこしくてごめんなさい。

各ページで発生するメッセージは Programのupdate関数に渡ってきて、ここで 各ページのupdate関数を呼びなおす、というイメージになります。
各ページのupdate関数のコール結果から、Programのupdate関数自体の戻り値とすることでメッセージループが完了します。

Elmishのコーディングはupdate関数をいじってる時間が圧倒的に長いです。
init関数は初期状態のモデルとメッセージを構築してお終いで、それがupdate関数に渡ってループが始まります。
モデルの状態からviewを形成し、viewのイベントハンドラからメッセージがまたupdate関数に渡ってくるという部分ももちろん重要ですが、viewは書き方になかなか慣れないだけ(私だけ?)で、特に難しい部分は無いんじゃないかと思います。

Taxonomiesのコード解説

今回のメインとなる Taxonomiesのソースコードをひとつずつ解説します。

Types.fs

まずはTypes.fsから。
src\Client\pages\Taxonomies\Types.fs

module Taxonomies.Types

open App.Notification
open Shared

type TaxonomyType =
  | All
  | Category
  | Tag
  | Series

type ListCriteria = {
  taxonomyType: TaxonomyType;
  page: PagerModel;
}

type Model = {
  listCriteria: ListCriteria
  dataList: seq<BlogModels.Taxonomy> option
  currentRec: BlogModels.Taxonomy option
}

type Msg =
  | Reload
  | CriteriaChanged of ListCriteria
  | PageChanged of PagerModel
  | Loaded of Result<GetTaxonomiesResult, exn>
  | AddNew
  | Select of BlogModels.Taxonomy
  | Selected of Result<BlogModels.Taxonomy option, exn>
  | RecordChanged of BlogModels.Taxonomy
  | Save of BlogModels.Taxonomy
  | Saved of Result<int, exn>
  | Remove of BlogModels.Taxonomy
  | Removed of Result<int, exn>
  | ApiError of exn
  | Notify of MsgType

TaxonomyTypeは一覧部分のフィルタに使用する判別共用体です。

ListCriteriaは一覧部分の抽出条件を格納するレコードで、TaxonomyTypePagerModelから構成されています。PagerModelはページングに使用しているレコードでShared.fsに以下のように定義してあります。

/// ListPagerのModel
type PagerModel = {
  rowsPerPage : int64;
  currentPage : int64;
  allRowsCount : int64;
} with 
      member m.LastPage = 
        (m.allRowsCount / m.rowsPerPage) + (if m.allRowsCount % m.rowsPerPage > 0L then 1L else 0L)  

1ページの行数、現在ページとサーバーが返す全データ件数で構成されています。あるあるですね。F#のレコードはクラスと同様、メソッドやプロパティを持つことが出来るのですが、LastPageは最終ページ番号を返すようにしてあります。

さて、Taxonomiesの主役級のレコードがModelです。
これまでに定義したListCriteriaと、一覧データを保持するdataList、新規追加や一覧からデータを選択した際に表示される詳細部分のモデルであるcurrentRecから構成されています。
一覧データを保持するdataListの型がseq<BlogModels.Taxonomy> optionとなっていますが、これはC#ならIEnumerable<BlogModels.Taxonomy>になるでしょうか。optionはこれまでにもちらっと出てきたOption<'T>です。

もうひとつの主役がMsgという判別共用体です。
これがElmishにおけるメッセージの定義となります。update関数ととても深い関係にあるので、update関数のところで説明します。

State.fs

src\Client\pages\Taxonomies\State.fs がTaxonomiesページのメインプログラムになります。
F#は前方参照不可なので、他から使用される関数等が頭の方に集まってしまいますが、コード説明するとなるとまずは幹の部分からの方がやりやすいので、init関数から説明します。

init関数

/// <summary>
/// Init関数
/// <summary>
let init () : Model * Cmd<Msg> =
    let model = {
        listCriteria = { taxonomyType = All; page = initPagenation}
        dataList = None
        currentRec = None
    }
    let cmd = getList model.listCriteria
    model, cmd

やっていることはModelの初期化と一覧データの取得です。
listCriteriaの初期化の際、page(PagerModel)にはinitPagenationを与えていますが、これはソースの頭の方で定義してあります。

let initPagenation = { rowsPerPage = 5L; currentPage = 1L; allRowsCount = -1L;}

allRowsCount-1Lを指定してありますが、特に意味はありません おい

一覧データを取得する関数getListも同様に最初の方で定義してあります。

let getApi : ITaxonomyApi =
    Remoting.createApi()
    |> Remoting.withRouteBuilder Route.builder
    |> Remoting.buildProxy<ITaxonomyApi>

/// <summary>
/// 一覧取得
/// </summary>
let getList (criteria:ListCriteria) =
    let param = { taxonomyType =
                    match criteria.taxonomyType with
                    | Category -> Some TaxonomyTypeEnum.Category
                    | Tag -> Some TaxonomyTypeEnum.Tag
                    | Series -> Some TaxonomyTypeEnum.Series
                    | _ -> None
                  pagenation = criteria.page }

    Cmd.ofAsync
        getApi.getTaxonomies
        param
        (Ok >> Loaded)
        ApiError

getApi関数はFable.Remotingのクライアント側呼び出しの定義で、ITaxonomyApiはサーバーと共有しているAPI定義となります。
Shared.fsに定義しています。
実際の呼び出し方法は、Cmd.ofAsyncメソッド経由で呼び出します。
Cmd.ofAsyncメソッドの第一引数に実行する関数を指定し、第2引数にその関数に与えるパラメータ、第3引数にはメソッド成功時の処理を表す関数、第4引数には失敗時の関数です。
(Ok >> Loaded)というのはF#の関数合成という演算子を使って新たな関数を生成しています。かずき氏のブログがわかりやすいのではないかと。
OkLoadedApiErrorは判別共用体のケース識別子です。判別共用体のケース識別子はそれ自体が関数なんですよね。OkはFSharp.Coreで定義されているResultという判別共用体です。

細かい説明は他の方に依存しつつ、どんどん行きます! いいのか

update関数

let update (msg : Msg) (model : Model) : Model * Cmd<Msg> =
    let api = getApi

    let isNewRec (taxonomy:Taxonomy) =
        taxonomy.Id < 0L

    // idが負の値は追加、それ以外は更新を行う
    let insertOrUpdate (taxonomy:Taxonomy) : Cmd<Msg> =
        let serverApi = 
            if isNewRec taxonomy then
                api.addNewTaxonomy
            else
                api.updateTaxonomy
        Cmd.ofAsync serverApi taxonomy (Ok >> Saved) ApiError

    // 保存後のコマンド
    let savedCmd (note:Note) =
        // 通知と再表示
        Cmd.batch [
            Cmd.ofMsg (Notify (MsgType.Success note))
            Cmd.ofMsg Reload]    

    match msg with
    // 一覧再読み込み
    | Reload -> 
        {model with currentRec = None}, getList model.listCriteria

    // 一覧抽出条件変更
    | CriteriaChanged x ->
        let newCriteria = {x with page = initPagenation } 
        {model with listCriteria = newCriteria}, Cmd.ofMsg Reload

    // ページング
    | PageChanged newPage ->
        {model with listCriteria = { model.listCriteria with page = newPage} }, Cmd.ofMsg Reload

    // 一覧読み込み後
    | Loaded (Ok x) -> 
        { model with dataList = Some x.data; listCriteria = {model.listCriteria with page = x.pagenation } }, 
        Cmd.none

    // 新規追加
    | AddNew -> 
        { model with currentRec = Some { Id = -1L; Type = TaxonomyTypeEnum.Category; Name = ""; UrlSlug = ""; Description = None; }}, 
        Cmd.none

    // 一覧からデータ選択
    | Select x -> 
        model, 
        Cmd.ofAsync 
            api.getTaxonomy 
            x.Id 
            (Ok >> Selected) 
            ApiError
    | Selected (Ok x) ->
        { model with currentRec = x }, Cmd.none
    
    // 値変更
    | RecordChanged changed ->
        { model with currentRec = Some changed }, Cmd.none

    // 保存
    | Save x ->
        { model with currentRec = Some x}, insertOrUpdate x
    | Saved (Ok _)->
        let newCurrent = 
            if isNewRec model.currentRec.Value then
                // 新規の場合、末尾に持っていく。サーバー側でcurrentPageが調整される
                System.Int64.MaxValue
            else
                model.listCriteria.page.currentPage
        let newPager = {model.listCriteria.page with currentPage = newCurrent}
        let newCriteria = {model.listCriteria with page = newPager } 
        { model with listCriteria = newCriteria }, savedCmd { title=""; message="保存しました。" }

    // 削除
    | Remove x ->
        model,
        Cmd.ofAsync
            api.removeTaxonomy
            x
            (Ok >> Removed)
            ApiError
    | Removed (Ok _)->
        model, savedCmd { title=""; message="削除しました。" }

    // Apiエラーと通知はそのまま伝搬
    | ApiError _ | Notify _ ->
        model, Cmd.ofMsg msg

    | _ -> model, Cmd.none

メッセージがいっぱい!
match msg with以下が各メッセージに対する処理の記述ですが、こんな書きっぷりでいいのかなぁ。F#はまだまだ にぅびぃ な私としては自分のコードにとても不安を感じています。「ここはこういう書き方がいいよ!」という指摘は大歓迎です! ぜひぜひ

特徴的なのは、SelectSelectedやら、SaveSavedやら、RemoveRemovedですかねぇ。
SelectSaveRemoveはviewから送られてくるユーザー操作に対応したメッセージで、過去形の方はその後処理という形で定義しています。
この辺はSAFE Stackをつかったサンプルプロジェクトにヒントを得てます。Learning Resourcesにサンプルがいくつか載っていますが、DojoとBookStoreはソースコードを読んでみました。他も時間が有ったら読んでみたいなと思っています。

随所に出てくるCmdモジュールのユーティリティについてはどこかでまとめたいなと感じているのですが(知らないものも多そうなので)、ひとまず今回使っているものをいくつか。

Cmd.ofAsync
先ほど説明しちゃいましたので割愛。

Cmd.ofMsg
引数にメッセージを指定してCmd<'T>を作成。別のメッセージを生成して共通処理へ飛ばすみたいな使い方をしています。

Cmd.none
メッセージなし。そこで終了させる場合。

Cmd.batch
複数のメッセージを流したい場合。savedCmdという関数で通知(Notify)と一覧データ再取得(Reload)を一度に発生させています。

それにしても、マスタ保守画面程度でこのメッセージ数。うーん。もう少しスッキリ書きたいのですが、私には思いつきません。

View.fs

残りはview関数が定義されている src\Client\pages\Taxonomy\View.fs です。

viewのソースは画面レイアウトの複雑さに応じて長くなるので(と言ってもそれほど複雑な画面ではありませんが)、ここにソースコードを全て載せても意味が無いかなぁ。無いですよね。

描画する部分に応じて関数としているので、その説明だけにします。

listView
一覧部分です。さらに中身が criteria(抽出条件部分)とlistRows(明細行部分)に分かれてます。

detail
新規追加、または一覧からデータを選択した際のデータ詳細を表示するフォームです。

root
viewじゃねぇのかよ!とお叱りを受けそうですが、どこかのソースもこんな名前になっていたのでマネっこしてみました。意味はありません またか

はい。説明終わり!
やっぱり、viewは長いけど、構造は単純ですね。

いやいやいや...

大事なことを説明してませんでした。

各ページのview関数(rootですが...)はいったいどこから呼ばれているのでしょうか。

もちろん Programのview関数から呼ばれているのですが、そこの解説がまだでした。

Programのview関数

src\Client\View.fs

let view (model : Model) (dispatch : Msg -> unit) =
    let pageHtml =
        function
        | HomeModel m -> Home.View.root m (HomeMsg >> dispatch)
        | CounterModel m -> Counter.View.root m (CounterMsg >> dispatch)
        | JankenModel m -> Janken.View.root m (JankenMsg >> dispatch)
        | TaxonomiesModel m -> Taxonomies.View.root m (TaxonomiesMsg >> dispatch)        

    div [ ]
        [ navBrand
          Container.container [ ]
              [ Columns.columns [ ]
                  [ Column.column [ Column.Width (Screen.All, Column.Is3) ]
                      [ menu model.CurrentPage ]
                    Column.column [ Column.Width (Screen.All, Column.Is9) ]
                      [ 
                        pageHtml model.PageModel 
                      ] 
                  ] 
              ] 
        ]

これが Programのview関数です。
ProgramのモデルにはPageModelという判別共用体を持っていて、これを切り替えることでコンテンツ部のレンダリングを切り替えています。
切り替えることで切り替える。重複してるよ!と怒られそうですが、文章もお勉強中なので許してたもれ。

実際の挙動は左側のメニューを選択した際にページが切り替わるわけですが、これは State.fsのこの部分で行っています。

src\Client\State.fs

let urlUpdate (result: Page option) (model: App.Types.Model) =
    match result with
    | None ->
        Browser.console.error("Error parsing url: " + Browser.window.location.href)
        model, Navigation.modifyUrl (toPageUrl model.CurrentPage) 
    | Some Page.Home ->
        let m, cmd = Home.State.init()
        { model with PageModel = HomeModel m }, Cmd.map HomeMsg cmd
    | Some Page.Counter ->
        let m, cmd = Counter.State.init()
        { model with PageModel = CounterModel m }, Cmd.map CounterMsg cmd
    | Some Page.Janken ->
        let m, cmd = Janken.State.init()
        { model with PageModel = JankenModel m }, Cmd.map JankenMsg cmd
    | Some Page.Taxonomies ->
        let m, cmd = Taxonomies.State.init()
        { model with PageModel = TaxonomiesModel m }, Cmd.map TaxonomiesMsg cmd

そしてこのurlUpdate関数自体は、プログラムのエントリポイントで以下のように指定されています。

src\Client\Client.fs

Program.mkProgram init update view
|> Program.toNavigable Pages.urlParser urlUpdate
    :
    :

Fable.Elmish.BrowserのtoNavigableに指定されているのですね。
ブラウザのアドレスが変わった際に呼び出され、モデルに持っているPageModelを設定し、各ページ毎のメッセージを返すという。

これで一通りつながったかな。

  • ブラウザのアドレスが変わった
  • Pages.urlParserにてURLからページを示すメッセージが生成されurlUpdateが呼び出される。
  • urlUpdateにて各ページ毎のinit関数が呼び出され、初期化されたモデルと初期メッセージを取得。Programのメッセージとして流す。
  • Programのupdate関数では各ページを表すメッセージを受け取り、各ページのupdate関数を呼び出す。
  • 各ページのupdate関数の戻り値であるメッセージは Programのupdate関数内で ProgramのMsgに変換して戻り値とする。
  • ProgramのメッセージはCmd.none以外ならまたupdate関数が呼び出される。以下ループ

といった具合になります。


かなり駆け足でしたが、大まかな部分は説明できたかなぁと思っています。
細かいところはコードを見てくださいませ。

ひとまず、マスタ保守画面の機能としてはこれでOKかなぁと思います。使うのは私だけだし えへへ

ただ、このままリリースするわけには行かない。これじゃ誰でも触れちゃう!
というわけで、次回はJWTによるログイン機能を実装したいと思っています。