前回の記事、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ページで発生したApiError
やNotify
というメッセージを捕まえてトーストを表示しています。
ちなみにトーストにはThoth.Elmish.Toastを使っています。
ApiError
やNotify
以外のメッセージは 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
は一覧部分の抽出条件を格納するレコードで、TaxonomyType
とPagerModel
から構成されています。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#の関数合成という演算子を使って新たな関数を生成しています。かずき氏のブログがわかりやすいのではないかと。
Ok
やLoaded
、ApiError
は判別共用体のケース識別子です。判別共用体のケース識別子はそれ自体が関数なんですよね。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#はまだまだ にぅびぃ な私としては自分のコードにとても不安を感じています。「ここはこういう書き方がいいよ!」という指摘は大歓迎です! ぜひぜひ
特徴的なのは、Select
、Selected
やら、Save
、Saved
やら、Remove
、Removed
ですかねぇ。
Select
やSave
、Remove
は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によるログイン機能を実装したいと思っています。