SAFE Stackで立ち上げてみる

※このエントリはF# Advent Calendar 2023の17日目の記事です。

※F#歴2週間かつ趣味で触っている程度(つまり経験値は実質2日ほど)の人間が書いています。

目標

SAFE Stackというものがあるようなので、SAFE StackとF#に入門してみることにしました。

SAFE Stackとは何なのか?

Isaac Abraham氏による解説を見てみましたが、どうやら以下のようなものらしいです。

Throughout this week, we've published a series of articles contributed by some well-known people within the F# community focused on web and cloud programming. Today, we're announcing the launch of the SAFE stack initiative, which brings all of the elements together as a cohesive story:

- `Saturn` model for server-side web programming
- `Azure` for cloud-based systems
- `Fable` for Javascript-enabled applications
- `Elmish` for an easy-to-understand UI programming model

Saturn, Azure, Fable, Elmishの頭文字を取ってSAFE Stackと呼んでいるようです。

Saturn - F#におけるMVCフレームワークの一つ

公式サイトによると、以下のような特徴があるようです。

- Modern programming model
  - Saturn combines the well known MVC pattern with the power of FP and F# to make web programming fun and easy.

- High performance
  - Saturn builds on top of highly optimized, and battle tested technologies such as ASP.NET Core, Giraffe and Kestrel.

- Developer experience
  - Saturn provides set of tools, templates and guides that makes creating and maintaining applications seamlessly.
  
- Created by Community
  - Saturn is created and maintained by well known members of the F# OSS Community and supported by industrial users.

要約すると、MVCパターンと関数型言語のアプローチを組み合わせた書き方ができ、パフォーマンスはGiraffeやKestrelに根ざした最適化済みのものであり、開発者向けのツール・テンプレが用意されていて、しかもOSSである、とのことです。最初の項目しか頭に入ってきていませんが、なかなか良さそうですね。

Azure - Microsoftのクラウドサービス

公式サイト

SAFE Stackの中では飛びぬけて有名なので、説明は省略します。

Fable - F#からJavaScriptを生成するコンパイラ

公式サイトを見て驚きました。まさか、F#からJavaScriptを生成するコンパイラがあるとは。Fableを使うと、F#で書いたコードをJavaScriptに変換して、Webブラウザ上で動かすことができるようです。 ちょっと凄すぎてよくわかりません・・・

Elmish - Elmの「Model View Update」を参考にした抽象化機構?

公式サイトを見てみましたが、いまいちピンと来ず。。。
もう少し深く調べてみたところ、Elm Architectureなるものがあるらしく、ElmishはそれをF#で実装したもののようです。

これらの情報から、ElmishはElmの「Model View Update」を参考にした抽象化機構っぽいな?という程度の理解しかできませんでした。

ともあれ、一旦これでSAFE Stackについてはなんとなくわかったようなわからんような感じになりました。

事前準備

VSCodeのインストールとDev Container Extensionのインストール

まずVSCodeが必要となりますので、インストールします。

次に、VSCodeの拡張機能であるRemote - Containersをインストールします。これを次の環境構築で使います。

環境構築

devcontainerを使って環境構築を行っていきます。

devcontainerの作成

まず、ブランクのディレクトリ(ここではディレクトリ名を fsharp-practice とします)を作成し、VSCodeで開きます。

次に、VSCodeのコマンドパレットを開き、 開発コンテナ―: コンテナ―機能の構成 を選択したら、イメージとして F# on Fedora を選択していきます。featuresには Node.js (via nvm), yarn and pnpm (node) を選択します。

その後、 開発コンテナ―: コンテナ―でリビルドして再度開く を選択します。しばらく待つと、 fsharp-practice ディレクトリがコンテナ―内にマウントされた状態でVSCodeが開きます。これで環境構築は完了です。

つくってみる

SAFE StackのQuickstartを参考に、 Hello, world! を返すJSON APIを作ってみます。

SAFE Stackのテンプレートをインストールする

以下のコマンドで入ります。

1
dotnet new install SAFE.Template

SAFE Stackのテンプレートを使ってプロジェクトを作成する

これもコマンドだけでOKです。

1
dotnet new SAFE

この時点で、以下のようなファイル構成になっているはずです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
vscode ➜ /workspaces/fsharp-practice $ tree
.
├── Build.fs
├── Build.fsproj
├── fsharp-practice.sln
├── global.json
├── Helpers.fs
├── package.json
├── package-lock.json
├── paket.dependencies
├── paket.lock
├── paket.references
├── README.md
├── src
│ ├── Client
│ │ ├── App.fs
│ │ ├── Client.fsproj
│ │ ├── index.css
│ │ ├── Index.fs
│ │ ├── index.html
│ │ ├── paket.references
│ │ ├── postcss.config.js
│ │ ├── public
│ │ │ └── favicon.png
│ │ ├── tailwind.config.js
│ │ └── vite.config.mts
│ ├── Server
│ │ ├── paket.references
│ │ ├── Properties
│ │ │ └── launchSettings.json
│ │ ├── Server.fs
│ │ └── Server.fsproj
│ └── Shared
│ ├── paket.references
│ ├── Shared.fs
│ └── Shared.fsproj
└── tests
├── Client
│ ├── Client.Tests.fs
│ ├── Client.Tests.fsproj
│ ├── index.html
│ ├── paket.references
│ └── vite.config.mts
├── Server
│ ├── paket.references
│ ├── Server.Tests.fs
│ └── Server.Tests.fsproj
└── Shared
├── paket.references
├── Shared.Tests.fs
└── Shared.Tests.fsproj

11 directories, 39 files

ローカル開発に使うツール群をインストールする

何やら色々入るようです。

1
dotnet tool restore

こんな感じのログが出ます。NuGetで4つほどパッケージが入っているようです。

1
2
3
4
5
6
7
8
Skipping NuGet package signature verification.
Skipping NuGet package signature verification.
Skipping NuGet package signature verification.
Skipping NuGet package signature verification.
Tool 'paket' (version '8.0.0') was restored. Available commands: paket
Tool 'fable' (version '4.1.4') was restored. Available commands: fable
Tool 'femto' (version '0.18.0') was restored. Available commands: femto
Tool 'fantomas' (version '6.2.3') was restored. Available commands: fantomas

ローカル開発サーバーを起動する

ここまで来たら起動もしていきましょう。

このコマンドでビルドもやってくれるようです。

1
dotnet run

最初20秒くらいは何も起きませんでしたが、その後、以下のようなログが出てきました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
run Run
Building project with version: LocalBuild
Shortened DependencyGraph for Target Run:
<== Run
<== InstallClient
<== Clean

The running order is:
Group - 1
- Clean
Group - 2
- InstallClient
Group - 3
- Run
Starting target 'Clean'
/workspaces/fsharp-practice/src/Client> "dotnet" fable clean --yes (In: false, Out: false, Err: false)
Fable 4.1.4: F# to JavaScript compiler
Minimum fable-library version (when installed from npm): 1.1.1

Thanks to the contributor! @zaaack
Stand with Ukraine! https://standwithukraine.com.ua/

This will recursively delete all *.fs.js[.map] files in /workspaces/fsharp-practice/src/Client
No files have been deleted. If Fable output is in another directory, pass it as argument.
Finished (Success) 'Clean' in 00:00:00.4986175
Starting target 'InstallClient'
.> "/usr/local/share/nvm/versions/node/v20.10.0/bin/npm" install (In: false, Out: false, Err: false)
npm WARN deprecated querystring@0.2.1: The querystring API is considered Legacy. new code should use the URLSearchParams API instead.
npm WARN deprecated querystring@0.2.0: The querystring API is considered Legacy. new code should use the URLSearchParams API instead.
npm WARN deprecated uuid@3.2.1: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.

added 171 packages, and audited 172 packages in 15s

21 packages are looking for funding
run `npm fund` for details

3 moderate severity vulnerabilities

To address all issues (including breaking changes), run:
npm audit fix --force

Run `npm audit` for details.
npm notice
npm notice New patch version of npm available! 10.2.3 -> 10.2.5
npm notice Changelog: https://github.com/npm/cli/releases/tag/v10.2.5
npm notice Run npm install -g npm@10.2.5 to update!
npm notice
Finished (Success) 'InstallClient' in 00:00:15.7005897
Starting target 'Run'
/workspaces/fsharp-practice/src/Shared> "dotnet" build (In: false, Out: false, Err: false)
MSBuild version 17.8.3+195e7f5a3 for .NET
Determining projects to restore...
Restored /workspaces/fsharp-practice/src/Shared/Shared.fsproj (in 163 ms).
Shared -> /workspaces/fsharp-practice/src/Shared/bin/Debug/net8.0/Shared.dll

Build succeeded.
0 Warning(s)
0 Error(s)

Time Elapsed 00:00:03.69
server: /workspaces/fsharp-practice/src/Server> dotnet watch run
client: /workspaces/fsharp-practice/src/Client> dotnet fable watch -o output -s --run npx vite
/workspaces/fsharp-practice/src/Server> "dotnet" watch run (In: false, Out: true, Err: true)/workspaces/fsharp-practice/src/Client> "dotnet" fable watch -o output -s --run npx vite (In: false, Out: true, Err: true)

server: dotnet watch ⌚ Polling file watcher is enabled
client: Fable 4.1.4: F# to JavaScript compiler
client: Minimum fable-library version (when installed from npm): 1.1.1
client: Thanks to the contributor! @sasmithjr
client: Stand with Ukraine! https://standwithukraine.com.ua/
client: Using polling watcher.
client: Parsing Client.fsproj...
client: .> dotnet restore Client.csproj -p:FABLE_COMPILER=true -p:FABLE_COMPILER_4=true -p:FABLE_COMPILER_JAVASCRIPT=true
client: Determining projects to restore...
client: Paket version 8.0.0+6bcb14ec191f11e984ff0e58016f5987a5cfa8f6
client: The last full restore is still up to date. Nothing left to do.
client: Total time taken: 0 milliseconds
server: dotnet watch 🚀 Started
client: Paket version 8.0.0+6bcb14ec191f11e984ff0e58016f5987a5cfa8f6
client: Restoring /workspaces/fsharp-practice/src/Client/Client.csproj
client: Starting restore process.
client: Total time taken: 0 milliseconds
client: Restored /workspaces/fsharp-practice/src/Client/Client.csproj (in 214 ms).
client: 1 of 2 projects are up-to-date for restore.
server: Unhandled exception. System.ArgumentException: An item with the same key has already been added. Key: /workspaces/fsharp-practice/src/Server/obj/Debug/net8.0/staticwebassets
server: at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
server: at Microsoft.DotNet.Watcher.Internal.PollingFileWatcher.<CheckForChangedFiles>b__23_0(FileSystemInfo f)
server: at Microsoft.DotNet.Watcher.Internal.PollingFileWatcher.ForeachEntityInDirectory(DirectoryInfo dirInfo, Action`1 fileAction)
server: at Microsoft.DotNet.Watcher.Internal.PollingFileWatcher.ForeachEntityInDirectory(DirectoryInfo dirInfo, Action`1 fileAction)
server: at Microsoft.DotNet.Watcher.Internal.PollingFileWatcher.ForeachEntityInDirectory(DirectoryInfo dirInfo, Action`1 fileAction)
server: at Microsoft.DotNet.Watcher.Internal.PollingFileWatcher.ForeachEntityInDirectory(DirectoryInfo dirInfo, Action`1 fileAction)
server: at Microsoft.DotNet.Watcher.Internal.PollingFileWatcher.CheckForChangedFiles()
server: at Microsoft.DotNet.Watcher.Internal.PollingFileWatcher.PollingLoop()
client: Some Nuget packages contain information about NPM dependencies that can be managed by Femto: https://github.com/Zaid-Ajaj/Femto
client: Project and references (67 source files) parsed in 10372ms
server: warn: Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository[60]
server: Storing keys in a directory '/home/vscode/.aspnet/DataProtection-Keys' that may not be persisted outside of the container. Protected data will be unavailable when container is destroyed. For more information go to https://aka.ms/aspnet/dataprotectionwarning
server: info: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[62]
server: User profile is available. Using '/home/vscode/.aspnet/DataProtection-Keys' as key repository; keys will not be encrypted at rest.
server: info: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[58]
server: Creating key {b2652658-4977-45a5-809c-2f3f8a9cc018} with creation date 2023-12-16 16:56:54Z, activation date 2023-12-16 16:56:54Z, and expiration date 2024-03-15 16:56:54Z.
server: warn: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[35]
server: No XML encryptor configured. Key {b2652658-4977-45a5-809c-2f3f8a9cc018} may be persisted to storage in unencrypted form.
server: info: Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository[39]
server: Writing data to file '/home/vscode/.aspnet/DataProtection-Keys/key-b2652658-4977-45a5-809c-2f3f8a9cc018.xml'.
server: warn: Microsoft.AspNetCore.Hosting.Diagnostics[15]
server: Overriding HTTP_PORTS '8080' and HTTPS_PORTS ''. Binding to values defined by URLS instead 'http://localhost:5000'.
server: info: Microsoft.AspNetCore.Server.Kestrel[0]
server: Unable to bind to http://localhost:5000 on the IPv6 loopback interface: 'Cannot assign requested address'.
server: info: Microsoft.Hosting.Lifetime[14]
server: Now listening on: http://localhost:5000
server: info: Microsoft.Hosting.Lifetime[0]
server: Application started. Press Ctrl+C to shut down.
server: info: Microsoft.Hosting.Lifetime[0]
server: Hosting environment: Development
server: info: Microsoft.Hosting.Lifetime[0]
server: Content root path: /workspaces/fsharp-practice/src/Server
client: Loaded Feliz.HookAttribute from ../../../../home/vscode/.nuget/packages/feliz.compilerplugins/2.2.0/lib/netstandard2.0/Feliz.CompilerPlugins.dll
client: Loaded Feliz.ReactComponentAttribute from ../../../../home/vscode/.nuget/packages/feliz.compilerplugins/2.2.0/lib/netstandard2.0/Feliz.CompilerPlugins.dll
client: Loaded Feliz.ReactMemoComponentAttribute from ../../../../home/vscode/.nuget/packages/feliz.compilerplugins/2.2.0/lib/netstandard2.0/Feliz.CompilerPlugins.dll
client: Started Fable compilation...
client: Fable compilation finished in 8173ms
client: .> npx vite
client: Watching ..
client: VITE v5.0.5 ready in 1074 ms
client: ➜ Local: http://localhost:8080/
client: ➜ Network: use --host to expose
client: ➜ press h + enter to show help

ブラウザで確認する

ブラウザで http://127.0.0.1:8080 にアクセスすると、以下のような画面が表示されました。

テキストエリアに文字列を入れてから Add ボタンを押すことで、その内容がリストに追加されるようです。簡易的なToDoアプリでしょうか。

まとめ

本当はこのあとロジックの修正とかまでやってみたかったんですが、なかなか手が付かず、SAFE Stackなアプリケーションの立ち上げまでやっただけでした。

出来上がったソースコードを読んだりいじりながら、SAFE StackとF#の理解を深めていくことにします。

本当に初心者が書いたエントリですが、何かお役に立てれば幸いです。