チュートリアル: ASP.NET Core を使って最小 API を作成する - F#
概要
チュートリアル: ASP.NET Core を使って最小 API を作成する のF#版です。
重複する内容については、改変しています。
本ページで作成されるコードは以下に公開しています。
https://github.com/Kuroki-g/kuroki-g-public-zenn-code/tree/main/articles/fsharp-min-web-api
必須コンポーネント
Visual Studio Code で F# を始めるを参照し、Visual Studio CodeでF#を書くための準備をしてください。
API プロジェクトを作成する
次のコマンドを実行します。
dotnet new sln -o TodoApi
code TodoApi
次のディレクトリ構造が生成されます。
.
└── TodoApi
└── TodoApi.sln
ターミナルで、次のコマンドを実行します。
dotnet new web -lang "F#" -o src/App
dotnet new gitignore -o src/App/
この時点で、次のディレクトリ構造が生成されます。
.
├── TodoApi.sln
└── src
└── App
├── App.fsproj
├── Program.fs
├── Properties
│ └── launchSettings.json
├── appsettings.Development.json
└── appsettings.json
dotnet sln add コマンドを使用して、TodoApi ソリューションに App プロジェクトを追加します。
dotnet sln add src/App/App.fsproj
この時点で一度ビルドを行い、プロジェクトが正しく設定されていることを確認します。
正しく設定されていれば、src/App/bin
にビルドされたアプリケーションが生成されます。
dotnet build
コードを確認する
src/App/Program.fs
ファイルには、次のコードが含まれています。
open System
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.Hosting
[<EntryPoint>]
let main args =
let builder = WebApplication.CreateBuilder(args)
let app = builder.Build()
app.MapGet("/", Func<string>(fun () -> "Hello World!")) |> ignore
app.Run()
0 // Exit code
上記のコードでは次の操作が行われます。
- 事前に構成された既定値で
WebApplicationBuilder
とWebApplication
を作成します。 - / を返す HTTP GET エンドポイント Hello World! を作成します。
Null許容参照型を設定する
F#のプロジェクトでは、null許容参照型はONになっていません。
C#との相互運用性を高めるために導入します。
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <!-- <= add this -->
</PropertyGroup>
アプリを実行する
次のコマンドを実行し、HTTPS 開発証明書を信頼します。
dotnet dev-certs https --trust
NuGet パッケージを追加する
次のコマンドを実行します。
cd src/App/
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 9.0.3
dotnet add package Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore --version 9.0.3
この時点でビルドを行います。
dotnet build
モデルおよびデータベース コンテキスト クラス
Models
フォルダーを作成し、次のコードを含む Todo.fs
という名前のファイルを作成します。
namespace TodoApi.Models
type Todo() =
member val Id: int = 0 with get, set
member val Name: string | null = null with get, set
member val IsComplete: bool = false with get, set
App.fsproj
を開き、コンパイルできるように設定を追加します。
F#ではコンパイラに渡されるファイルの順番が重要なため、Program.fs
の上に追加します。
<ItemGroup>
<Compile Include="Models/*.fs" /> <!-- <= add this -->
<Compile Include="Program.fs" />
</ItemGroup>
Data
フォルダーを作成し、次のコードのファイルを、TodoDb.fs
という名前で作成します。
namespace TodoApi.Data
open Microsoft.EntityFrameworkCore
open TodoApi.Models
type TodoDb(options: DbContextOptions<TodoDb>) =
inherit DbContext(options)
member _.Todos = base.Set<Todo>()
Models
の設定の後に追加します。
<ItemGroup>
<Compile Include="Models/*.fs" />
<Compile Include="Data/*.fs" /> <!-- <= Add here! -->
<Compile Include="Program.fs" />
</ItemGroup>
コンパイルの設定が正しいか確認するため、この時点でビルドを行います。
dotnet build
API コードを追加する
データベース接続の追加
Program.fs
ファイルの内容を次のコードに置き換えます。
open System
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.DependencyInjection // add here
open Microsoft.Extensions.Hosting
open Microsoft.EntityFrameworkCore // add here
open TodoApi.Data // add here
[<EntryPoint>]
let main args =
let builder = WebApplication.CreateBuilder(args)
// add codes from here...
// AddDbContextのためにMicrosoft.Extensions.DependencyInjectionを追加する。
builder.Services.AddDbContext<TodoDb>(fun opt -> opt.UseInMemoryDatabase("TodoList") |> ignore)
|> ignore
builder.Services.AddDatabaseDeveloperPageExceptionFilter() |> ignore
// Next codes...
// let app = builder.Build()
この時点で、インメモリデータベースへの接続設定が追加されます。
APIの追加
APIを追加していきます。
GETの追加
GETリクエストを行うためのAPIのコードを追加します。
open System
open System.Linq // add here
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http // add here
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Hosting
open Microsoft.EntityFrameworkCore
open System.Threading.Tasks // add here
open TodoApi.Data
// ...
let app = builder.Build()
// add from here...
app.MapGet("/todoitems", Func<TodoDb, Task<Collections.Generic.List<Todo>>>(fun db -> db.Todos.ToListAsync()))
|> ignore
app.MapGet(
"/todoitems/complete",
Func<TodoDb, Task<Collections.Generic.List<Todo>>>(fun db ->
db.Todos.Where(fun t -> t.IsComplete).ToListAsync())
)
|> ignore
app.MapGet(
"/todoitems/{id}",
Func<int, TodoDb, Task<IResult>>(fun id db ->
task {
match! db.Todos.FindAsync id with
| null -> return Results.NotFound()
| todo -> return Results.Ok todo
})
)
|> ignore
// next codes...
app.Run()
F#の書き方については、以下を確認してください。
ここで、実際に動きを確認してみましょう。
開発環境用サーバーを起動してみます。
dotnet run
curlでリクエストをしてみましょう。
データは投入していませんので、空のデータ、もしくは404が返って来ます。
$ curl --fail http://localhost:5058/todoitems
[]
$ curl --fail http://localhost:5058/todoitems/complete
[]
$ curl --fail http://localhost:5058/todoitems/1
curl: (22) The requested URL returned error: 404
残りのAPIの追加
続いて、残りのAPIも加えます。
// ...
// next to get api
app.MapPost(
"/todoitems",
Func<Todo, TodoDb, Task<IResult>>(fun todo db ->
task {
db.Todos.Add todo |> ignore
let! result = db.SaveChangesAsync()
return Results.Created($"/todoitems/{todo.Id}", result)
})
)
|> ignore
app.MapPut(
"/todoitems/{id}",
Func<int, Todo, TodoDb, Task<IResult>>(fun id inputTodo db ->
task {
match! db.Todos.FindAsync id with
| null -> return Results.NotFound()
| todo ->
todo.Name <- inputTodo.Name
todo.IsComplete <- inputTodo.IsComplete
db.SaveChangesAsync() |> ignore
return Results.NoContent()
})
)
|> ignore
app.MapDelete(
"/todoitems/{id}",
Func<int, TodoDb, Task<IResult>>(fun id db ->
task {
match! db.Todos.FindAsync id with
| null -> return Results.NotFound()
| todo ->
db.Todos.Remove todo |> ignore
db.SaveChangesAsync() |> ignore
return Results.NoContent()
})
)
|> ignore
app.Run()
データを投入し、GETで見てみましょう。
$ curl --fail --request POST --header "Content-Type: application/json" --data '{ "Name":"Jone", "IsComplete":true }' http://localhost:5058/todoitems
1
$ curl --fail --request POST --header "Content-Type: application/json" --data '{ "Name":"Bob", "IsComplete":false }' http://localhost:5058/todoitems
1
$ curl -f http://localhost:5058/todoitems
[{"id":1,"name":"Jone","isComplete":true},{"id":2,"name":"Bob","isComplete":false}]
$ curl -f http://localhost:5058/todoitems/complete
[{"id":1,"name":"Jone","isComplete":true}]
データを投入したデータを編集してみましょう。
$ curl --fail --request PUT --header "Content-Ty
pe: application/json" --data '{ "IsComplete":false }' http://localhost:5058/todoitems/1
$ curl -f http://localhost:5058/todoitems/1
{"id":1,"name":null,"isComplete":false}
削除をすると、404となります。
$ curl -f --request DELETE http://localhost:5058
/todoitems/1
$ curl -f http://localhost:5058/todoitems/1
curl: (22) The requested URL returned error: 404
これで、C#の場合と同様に、CURD操作ができることが確かめられました。
Swagger を使って API テスト UI を作成する
Swagger ツールをインストールする
以下のコマンドを実行します。
dotnet add package NSwag.AspNetCore --version 14.2.0
Swagger ミドルウェアを構成する
builder.Services.AddDatabaseDeveloperPageExceptionFilter() |> ignore
// ...
// add codes
builder.Services.AddEndpointsApiExplorer() |> ignore
builder.Services.AddOpenApiDocument(fun config ->
config.DocumentName <- "TodoAPI"
config.Title <- "TodoAPI v1"
config.Version <- "v1")
|> ignore
let app = builder.Build()
if app.Environment.IsDevelopment() then
app.UseOpenApi() |> ignore
app.UseSwaggerUi(fun config ->
config.DocumentTitle <- "TodoAPI"
config.Path <- "/swagger"
config.DocumentPath <- "/swagger/{documentName}/swagger.json"
config.DocExpansion <- "list")
|> ignore
Swagger UIでデータの API をテストする
Swagger UIを見てみましょう。
先ほどはHTTPで確認しましたが、HTTPSで起動します。
dotnet run --launch-profile https
ブラウザで<https://localhost:port/swagger/index.html>にアクセスします。
portはsrc/App/Properties/launchSettings.json
の中のprofiles.https.applicationUrl
から確認できます。
Swagger UIでの確認手順については、C#向けの記述を確認してください。
MapGroup API を使う
MapGroup メソッドを使うことで、共通のURLプレフィックスを整理することができます。
todoitems
URLプレフィックスを共通化してみましょう。
let todoItems = app.MapGroup "/todoitems"
todoItems.MapGet("/", Func<TodoDb, Task<Collections.Generic.List<Todo>>>(fun db -> db.Todos.ToListAsync()))
|> ignore
// change rest of api
Moduleを使う
Delegate部分をmoduleに切り出すことで、コード全体の見通しを良くすることができます。
module TodoItems =
let getAllTodos =
Func<TodoDb, Task<Collections.Generic.List<Todo>>>(fun db -> db.Todos.ToListAsync())
// ...
todoItems.MapGet("/", TodoItems.getAllTodos) |> ignore
TypedResults API を使う
先ほどmodule
へと切り出した関数に対して、TypedResults
を使えるように変更してみます。
module TodoItems =
let getAllTodos =
Func<TodoDb, Task<IResult>>(fun db -> task { return db.Todos.ToListAsync() |> TypedResults.Ok :> IResult })
let getCompleteTodos =
Func<TodoDb, Task<IResult>>(fun db ->
task { return db.Todos.Where(fun t -> t.IsComplete).ToListAsync() |> TypedResults.Ok :> IResult })
let getTodo =
Func<int, TodoDb, Task<IResult>>(fun id db ->
task {
match! db.Todos.FindAsync id with
| null -> return TypedResults.NotFound() :> IResult
| todo -> return TypedResults.Ok todo :> IResult
})
let createTodo =
Func<Todo, TodoDb, Task<IResult>>(fun todo db ->
task {
db.Todos.Add todo |> ignore
let! result = db.SaveChangesAsync()
return TypedResults.Created($"/todoitems/{todo.Id}", result) :> IResult
})
let updateTodo =
Func<int, Todo, TodoDb, Task<IResult>>(fun id inputTodo db ->
task {
match! db.Todos.FindAsync id with
| null -> return TypedResults.NotFound() :> IResult
| todo ->
todo.Name <- inputTodo.Name
todo.IsComplete <- inputTodo.IsComplete
db.SaveChangesAsync() |> ignore
return TypedResults.NoContent() :> IResult
})
let deleteTodo =
Func<int, TodoDb, Task<IResult>>(fun id db ->
task {
match! db.Todos.FindAsync id with
| null -> return TypedResults.NotFound() :> IResult
| todo ->
db.Todos.Remove todo |> ignore
db.SaveChangesAsync() |> ignore
return TypedResults.NoContent() :> IResult
})
ここで用いられているキャスト(:>
)に関しては、アップキャストを参照してください。
過剰な投稿を防止する
DTOを導入します。
詳細は過剰な投稿を防止するを参照してください。
namespace TodoApi.Dtos
open TodoApi.Models
type TodoItemDTO =
{ Id: int
Name: string | null
IsComplete: bool }
static member Create(v: Todo) =
{ Id = v.Id
Name = v.Name
IsComplete = v.IsComplete }
モジュールを以下のように変更します。
module TodoItems =
let getAllTodos =
Func<TodoDb, Task<IResult>>(fun db ->
task { return db.Todos.Select(TodoItemDTO.Create).ToListAsync() |> TypedResults.Ok :> IResult })
let getCompleteTodos =
Func<TodoDb, Task<IResult>>(fun db ->
task {
return
db.Todos.Where(fun t -> t.IsComplete).Select(TodoItemDTO.Create).ToListAsync()
|> TypedResults.Ok
:> IResult
})
let getTodo =
Func<int, TodoDb, Task<IResult>>(fun id db ->
task {
match! db.Todos.FindAsync id with
| null -> return TypedResults.NotFound() :> IResult
| todo -> return todo |> TodoItemDTO.Create |> TypedResults.Ok :> IResult
})
let createTodo =
Func<TodoItemDTO, TodoDb, Task<IResult>>(fun todoItemDTO db ->
task {
let todoItem = new Todo()
todoItem.Name <- todoItemDTO.Name
todoItem.IsComplete <- todoItemDTO.IsComplete
db.Todos.Add todoItem |> ignore
let! result = db.SaveChangesAsync()
return TypedResults.Created($"/todoitems/{todoItem.Id}", result) :> IResult
})
let updateTodo =
Func<int, TodoItemDTO, TodoDb, Task<IResult>>(fun id todoItemDTO db ->
task {
match! db.Todos.FindAsync id with
| null -> return TypedResults.NotFound() :> IResult
| todo ->
todo.Name <- todoItemDTO.Name
todo.IsComplete <- todoItemDTO.IsComplete
db.SaveChangesAsync() |> ignore
return TypedResults.NoContent() :> IResult
})
let deleteTodo =
Func<int, TodoDb, Task<IResult>>(fun id db ->
task {
match! db.Todos.FindAsync id with
| null -> return TypedResults.NotFound() :> IResult
| todo ->
db.Todos.Remove todo |> ignore
db.SaveChangesAsync() |> ignore
return TypedResults.NoContent() :> IResult
})
感想・気になることろ
感想
WEB APIに、C#のコードに沿って書くやり方では、C#でstatic classで定義しているのとあまり変わらない、という印象が強いです。
C#と相互運用するというのは前提条件であるので、letバインド、Option型、Result型、|>
、パターンマッチにどれほどの価値を見出すかということになります。
OPPするというC#の書き方を頭の端っこに残しつつ、F#での書き方を強く意識する必要があるでしょう。
本質的ではないところで気になったところ
エディタについて
- 拡張メソッドでの
open
の補完が効かない。System.Linq
も手動でインポートが必要。C#での名前空間何だっけな…と調べて手動で記述ということで非効率です。 - 暗黙的なアップキャストが貧弱に感じます。
IResult
に対して入れないとコンパイルが通らないというのは煩雑です。 - C#資産を使えないと片手落ちにもかかわらず、null許容参照型がまだまだ弱いです。
- ただ、.NET 10 LTSでもnull許容参照型は維持されれば、導入は広まるでしょう。
- null 安全はデフォルトでオンにしておいて欲しいところです。
- 公式のドキュメントが少なすぎる。
- Microsoftのドキュメントで、明らかにF#のドキュメントの量が少なく、新しく学ぶ人へのサポートが貧弱です。
- そもそも、F#単体で完結させるやり方をするのが基本なのかもしれません。
コミュニティの状況
ランキング[1][2]でも、関数型プログラミング言語の使用率はかなり低いものになっています。
会社が Scala[3][4]、Elixir[5]、Clojure[4:1]を使っているというものでもない限り使うことはないでしょう。F#もひっそり使っている方がいらっしゃるよう[6]です。
コミュニティ層の薄さというのは、行き詰ったときの情報源の少なさに直結します。
新しく学ぶ人へのサポートが貧弱です。Why F#?というのをもっと強く推さないと、使用人口は増えないように思えます。Wikipediaにも利用実績書いてないし[7]。
- Erlangだと分散処理だよね、といったものがないです。
- Microsoftの機械学習ライブラリSynapseMLはScalaで開発されており、「Many of us feel that scala is the queen of the languages so we almost exclusively code in scala.」と述べています[8]
これくらい言えるとおっ?となりますね。
利用事例
実際の利用についてですが、Deep Researchに探してもらい見つけてもらいました。
- Goldman Sachsで使われているという記述[9]:https://medium.com/@robertdennyson/where-f-outshines-other-languages-a-deep-dive-with-use-cases-00f8ab187fc9
- Jet.com[10]
- Credit Suisse quants were the first users of a new programming language[11]
- https://fsharp.org/testimonials/
- https://theirstack.com/en/technology/f-sharp
- https://github.com/fsprojects/fsharp-companies
もう少し推せばいいのに…と思いますね。
Discussion