SAFE Stack (link) のサンプルの ToDo アプリを眺める続き。 アプリ本体のソースを見ていく。 コメントはファイル全般を見たときより輪をかけて怪しくて、憶測や感想でしかないものが増えた。

対象のファイルは以下の 4 個。 大した量ではない。

ファイル行数
src/Client/App.fs19
src/Client/Index.fs125
src/Server/Server.fs57
src/Shared/Shared.fs21
合計222

src/Client/App.fs

 1	module App
 2	
 3	open Elmish
 4	open Elmish.React
 5	
 6	#if DEBUG
 7	open Elmish.Debug
 8	open Elmish.HMR
 9	#endif
10	
11	Program.mkProgram Index.init Index.update Index.view
12	#if DEBUG
13	|> Program.withConsoleTrace
14	#endif
15	|> Program.withReactSynchronous "elmish-app"
16	#if DEBUG
17	|> Program.withDebugger
18	#endif
19	|> Program.run
行番号コメント
7Elmish にデバッガがあることに驚いた。ここに載っている。
8“HMR” の意味は “Hot Module Replacement” だった。ここに載っている。
11Elmish プログラムの生成。Program.mkProgram についてはここを見るのがよさそう。ソースが載っているのだけど。3つある引数の型は、それぞれ
init : 'arg -> 'model * Cmd<'msg>
update : 'msg -> 'model -> 'model * Cmd<'msg>
view : 'model -> Dispatch<'msg> -> 'view
になっている。
13Program.withConsoleTrace の中で Log.toConsole というのを呼んでいる。
15Program.withReactSynchronous はここを見ると Elmish.React の中で定義されている。”elmish-app” は src/Client/index.html にある div 要素の ID と一致。
17Program.withDebugger については 7 行目と同じく、ここを見るのよさそう。
19Elmish プログラムの開始。

src/Client/Index.fs

 1	module Index
 2	
 3	open Elmish
 4	open Fable.Remoting.Client
 5	open Shared
 6	
 7	type Model = { Todos: Todo list; Input: string }
 8	
 9	type Msg =
10	    | GotTodos of Todo list
11	    | SetInput of string
12	    | AddTodo
13	    | AddedTodo of Todo
14	
行番号コメント
4Fable.Remoting を使っている。確か Elmish.Remoting というのもあったけど、ここでは Fable.Remoting だった。両者の違いは何だろうか。
7, 8モデルとメッセージの定義。
15	let todosApi =
16	    Remoting.createApi ()
17	    |> Remoting.withRouteBuilder Route.builder
18	    |> Remoting.buildProxy<ITodosApi>
19	
行番号コメント
15todosApi という名前で、サーバの API を Remoting 経由で呼ぶ窓を作っている。サーバにも同名の todosApi というものがあるけど、todosApi の役割がクライアントとサーバとで異なっている。クライアントでは todosApi が Remoting をラップしている。サーバで Remoting をラップするのは webApp というやつで、todosApi は webApp を組み立てる部品になっている。
18ITodosApi は Shared.fs で定義されている。……のだけど、使っているのはクライアントだけで、サーバでは使われていない。
20	let init () : Model * Cmd<Msg> =
21	    let model = { Todos = []; Input = "" }
22	
23	    let cmd =
24	        Cmd.OfAsync.perform todosApi.getTodos () GotTodos
25	
26	    model, cmd
27	
行番号コメント
20init 関数の定義。
21モデルの初期状態。
23最初のコマンド。コマンドは何かアクションをして、メッセージを返すもの、という感じ。
24Cmd についてはここに載っている。Cmd.OfAsync.perform の3つの引数はそれぞれ
task: 'a -> Async<_>
arg: 'a
ofSuccess: _ -> 'msg
になっている。
28	let update (msg: Msg) (model: Model) : Model * Cmd<Msg> =
29	    match msg with
30	    | GotTodos todos -> { model with Todos = todos }, Cmd.none
31	    | SetInput value -> { model with Input = value }, Cmd.none
32	    | AddTodo ->
33	        let todo = Todo.create model.Input
34	
35	        let cmd =
36	            Cmd.OfAsync.perform todosApi.addTodo todo AddedTodo
37	
38	        { model with Input = "" }, cmd
39	    | AddedTodo todo ->
40	        { model with
41	              Todos = model.Todos @ [ todo ] },
42	        Cmd.none
43	
行番号コメント
28update 関数の定義。メッセージとモデルを受け取って、新しいモデルとコマンドを返す。
32-42ここでも Cmd.OfAsync.perform を使っている。サーバの API を呼ぶときは非同期にして、結果を別のメッセージ (過去分詞で命名) で受け取る、というのが基本形ということか。
44	open Feliz
45	open Feliz.Bulma
46	
行番号コメント
44, 45SAFE Stack v3 からサンプルアプリケーションで Feliz を使うようになった。
47	let navBrand =
48	    Bulma.navbarBrand.div [
49	        Bulma.navbarItem.a [
50	            prop.href "https://safe-stack.github.io/"
51	            navbarItem.isActive
52	            prop.children [
53	                Html.img [
54	                    prop.src "/favicon.png"
55	                    prop.alt "Logo"
56	                ]
57	            ]
58	        ]
59	    ]
60	
行番号コメント
47navBrand 関数は view 関数から呼ばれる。モデルとメッセージに非依存の固定の画面部品。
61	let containerBox (model: Model) (dispatch: Msg -> unit) =
62	    Bulma.box [
63	        Bulma.content [
64	            Html.ol [
65	                for todo in model.Todos do
66	                    Html.li [ prop.text todo.Description ]
67	            ]
68	        ]
69	        Bulma.field.div [
70	            field.isGrouped
71	            prop.children [
72	                Bulma.control.p [
73	                    control.isExpanded
74	                    prop.children [
75	                        Bulma.input.text [
76	                            prop.value model.Input
77	                            prop.placeholder "What needs to be done?"
78	                            prop.onChange (fun x -> SetInput x |> dispatch)
79	                        ]
80	                    ]
81	                ]
82	                Bulma.control.p [
83	                    Bulma.button.a [
84	                        color.isPrimary
85	                        prop.disabled (Todo.isValid model.Input |> not)
86	                        prop.onClick (fun _ -> dispatch AddTodo)
87	                        prop.text "Add"
88	                    ]
89	                ]
90	            ]
91	        ]
92	    ]
93	
行番号コメント
61containerBox 関数は view 関数から呼ばれる。モデルに応じて内容が変化する画面の部品。また、on… 系のイベントで dispatch 関数にメッセージを渡す。
65-67モデルから todo の各項目を取り出して li 要素にする。
78onChange イベントで dispatch 関数を呼び出し。
86onClick イベントで dispatch 関数を呼び出し。
 94	let view (model: Model) (dispatch: Msg -> unit) =
 95	    Bulma.hero [
 96	        hero.isFullHeight
 97	        color.isPrimary
 98	        prop.style [
 99	            style.backgroundSize "cover"
100	            style.backgroundImageUrl "https://unsplash.it/1200/900?random"
101	            style.backgroundPosition "no-repeat center center fixed"
102	        ]
103	        prop.children [
104	            Bulma.heroHead [
105	                Bulma.navbar [
106	                    Bulma.container [ navBrand ]
107	                ]
108	            ]
109	            Bulma.heroBody [
110	                Bulma.container [
111	                    Bulma.column [
112	                        column.is6
113	                        column.isOffset3
114	                        prop.children [
115	                            Bulma.title [
116	                                text.hasTextCentered
117	                                prop.text "test_safe_stack_20210724"
118	                            ]
119	                            containerBox model dispatch
120	                        ]
121	                    ]
122	                ]
123	            ]
124	        ]
125	    ]
行番号コメント
94view 関数の定義。
95-125画面全体の骨格なので行数は多いけど、重要で動的なところは containerBox 関数に任せている。

src/Server/Server.fs

1	module Server
2	
3	open Fable.Remoting.Server
4	open Fable.Remoting.Giraffe
5	open Saturn
6	
7	open Shared
8	
行番号コメント
3, 4Fable.Remoting のサーバ側。
5Saturn は Web サーバのフレームワーク。Web サイト (link) を見ると SAFE Stack で使われることを強く意識しているっぽい。
 9	type Storage() =
10	    let todos = ResizeArray<_>()
11	
12	    member __.GetTodos() = List.ofSeq todos
13	
14	    member __.AddTodo(todo: Todo) =
15	        if Todo.isValid todo.Description then
16	            todos.Add todo
17	            Ok()
18	        else
19	            Error "Invalid todo"
20	
行番号コメント
9Storage クラスの定義。
10保存先はメモリ上の ResizeArray のインスタンス。
12, 14メンバ関数は API と一対一で対応するシンプルなもの。
21	let storage = Storage()
22	
23	storage.AddTodo(Todo.create "Create new SAFE project")
24	|> ignore
25	
26	storage.AddTodo(Todo.create "Write your app")
27	|> ignore
28	
29	storage.AddTodo(Todo.create "Ship it !!!")
30	|> ignore
31	
行番号コメント
21Storage のインスタンスを作成。
23-30初期状態で 3 個の ToDo 項目を登録。
32	let todosApi =
33	    { getTodos = fun () -> async { return storage.GetTodos() }
34	      addTodo =
35	          fun todo ->
36	              async {
37	                  match storage.AddTodo todo with
38	                  | Ok () -> return todo
39	                  | Error e -> return failwith e
40	              } }
41	
42	let webApp =
43	    Remoting.createApi ()
44	    |> Remoting.withRouteBuilder Route.builder
45	    |> Remoting.fromValue todosApi
46	    |> Remoting.buildHttpHandler
47	
行番号コメント
32todosApi は API の呼び出しの受け口みたいな感じ。クライアントにも同名のものがある。レコードのラベルの名前が、サーバのそれと、クライアントで参照する ITodosApi とで同じになっている。
42webApp は todosApi を Remoting でラップしている。
48	let app =
49	    application {
50	        url "http://0.0.0.0:8085"
51	        use_router webApp
52	        memory_cache
53	        use_static "public"
54	        use_gzip
55	    }
56	
57	run app
行番号コメント
49application は Saturn の ApplicationBuilder のインスタンス。
50ワイルドカードでリッスンしているけど、これが安全なのか気になる。
51ここで webApp を渡している。
52-54オプションがいろいろある。ApplicationBuilder の API を見ると認証周りの指定とかもある。

src/Shared/Shared.fs

 1	namespace Shared
 2	
 3	open System
 4	
 5	type Todo = { Id: Guid; Description: string }
 6	
 7	module Todo =
 8	    let isValid (description: string) =
 9	        String.IsNullOrWhiteSpace description |> not
10	
11	    let create (description: string) =
12	        { Id = Guid.NewGuid()
13	          Description = description }
14	
行番号コメント
5個々の ToDo 項目の型の定義。
7-13ToDo 項目のバリデータと作成。
15	module Route =
16	    let builder typeName methodName =
17	        sprintf "/api/%s/%s" typeName methodName
18	
行番号コメント
15-18API の URI。
19	type ITodosApi =
20	    { getTodos: unit -> Async<Todo list>
21	      addTodo: Todo -> Async<Todo> }
行番号コメント
19-21ITodosApi を実際に参照しているのはクライアントだけ。(Shared.fs に定義されているけど) サーバでは ITodosApi を使わずに、型を一致させた todosApi を定義している。