F#とASP.NET CoreのMinimal APIを使ってAPIをつくる

こんにちは、Product Teamのあやぴーです。

今回はここ1年くらい私のいるチームで使っているF#とASP.NET Coreを使ったAPI開発について簡単にまとめていきます。

F#ってなんだ?

F#はMicrosoft(MSFT)が開発した、いわゆる関数型言語の特徴(第一級関数,イミュータブルデータなど)を持つ言語です。.NET VM上で動き、OCaml(ML系)に近いシンタックスを持っているというのがユニークなところです。また.NET VM上で動作するため、C#などのライブラリを使うことができるのもメリットです。JVM上で動くScalaと立ち位置としては近いのではないでしょうか。

軽量構文という明瞭で軽快な構文を持ち合わせているため、例えば以下のようにストレートに階乗のプログラムを表現することができます(ちなみに私はCoffeeScript以来、オフサイドルールを持つ言語が好きではなかったのですが、F#はいいなと感じました)。

let factorial x = 
    let rec loop result n = 
        match n with 
        | 0 -> result
        | _ -> loop (result * n) (n - 1)
    
    loop 1 x

とっても簡単そうですね。とっても簡単そうですよね :)

なぜ、今F#を採用したのか

私たちのチームでは様々な言語を導入しているので、その流れ…と言ってしまうとそこまでですが、実際には「今だからF#を採用できる」と考えた理由がふたつあります。

  1. .NET Coreの存在
  2. ASP.NET Core 6より導入されたMinimal API

まず、1つめが.NET Coreです(最近は単に.NETと書かれている気がしますが、わかりやすさのために.NET Coreと記述します)。私も詳しくはないのですが、以前はWindowsでしか開発できず、Windows Server上でしか動作しないというのが.NETに対する認識でした(正確に書くのであれば恐らく.NET FrameworkがWindows上でしか動作しない)。そこにMono、.NET Coreというものが登場して様々なOSで開発でき、クロスプラットフォームで動作するようになったようです。

learn.microsoft.com

2つめがMinimal APIです。F#上で動作するWAF(Web Application Framework)にはGiraffeなどいくつかの選択肢がありますが、最新のASP.NET CoreにはMinimal APIというものが実装されていて、これを使うと関数プログラミングの考え方に従ってコードを記述しやすいだろうと考えました。

learn.microsoft.com

簡単に例を示すと、以下のように app.MapGet にパスと関数を渡すだけでよく、余分にクラスなどを実装したり、典型的なDIフレームワークなどを使わず純然たる関数だけで実装を行えると分かったので採用に至りました。

[<EntryPoint>]
let main args =
    let builder = WebApplication.CreateBuilder(args)
    let app = builder.Build()

    app.MapGet("/", Func<IResult>(fun () -> Results.Ok { greeting = "Hello" }))
    |> ignore

    app.Run()

    0 // Exit code

このMinimal APIには他にも利点があって、それぞれのpathに対する機能を開発するさいに、フレームワークに強く依存することなく、ライブラリの組み合わせのみでうまく機能を実装していくことができる、というのも良いところです(反面、自由度が高すぎる、というのはありますが)。

tomasp.net

その他にも新興言語と比較すると、.NETという巨人の肩に乗っているのもあってC#のライブラリなどを利用できる、公式のドキュメントがかなり充実している、というのも後押しになりました。

learn.microsoft.com

まあ、本当のところはML系の特徴を色濃く持つ言語を使いたかっただけだったりするのは公然の秘密です。

Minimal APIを使って、APIをつくる

learn.microsoft.com

アプリケーションの雛形をつくる

簡単に例を交えて、F#でMinimal APIを使った実装をしてみます。.NET SDKは以下のバージョンです。

$ dotnet --info
.NET SDK:
 Version:   7.0.302
 Commit:    990cf98a27

まずは以下のようなコマンドを叩いてアプリケーションを作成します。ソリューションは作成しなくても動かせますが、とりあえず作っておきます。

$ mkdir minimal-api-example/App -p
$ cd minimal-api-example/App 
minimal-api-example/App$ dotnet new web -lang F#
minimal-api-example/App$ cd ../
minimal-api-example$ dotnet new sln
minimal-api-example$ dotnet sln add App/

この状態で、dotnet watch --project App と実行するとターミナルに以下のようにメッセージが表示され、APIを起動することができます。

minimal-api-example$ dotnet watch --project App
dotnet watch 🚀 Started
ビルドしています...
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5258
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /home/ayato-p/sources/local/fsharp-sandbox/minimal-api-example/App

任意のHTTPステータスとJSONレスポンスを返却する

適当な任意の型を MapGet など第二引数に設定できるDelegateの返り値に設定すると基本的には自動的に文字列化してくれます。 app.MapGet("/answer", Func<int> (fun () -> 42)) のように書くだけなので、簡単です。とはいえ、これでは正常なパターン( 200 )しか対応できないため、 IResults 型を使って返却するようにします。以下に例を示します。

App.Controller というモジュールを定義してみます。

// App/Controller.fs
module App.Controller

open Microsoft.AspNetCore.Http

type PongResponse = { Pong: bool }

let pongResponse () : IResult = Results.Ok { Pong = true }

IResult 型はResultsクラスを利用すると簡単に作成することができます。

learn.microsoft.com

MSBuildの作法に則ってController.fsを ItemGroup に追加しておきます。 learn.microsoft.com

<!-- App/App.fsproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <Compile Include="Controller.fs" />
    <Compile Include="Program.fs" />
  </ItemGroup>
</Project>
// App/Program.fs
open System
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Hosting

open App

[<EntryPoint>]
let main args =
    let builder = WebApplication.CreateBuilder(args)
    let app = builder.Build()

    app.MapGet("/ping", Func<IResult> Controller.pongResponse)
    |> ignore

    app.Run()

    0 // Exit code

このように記述したところで、実際にリクエストしてみると以下のような結果を得られます。

minimal-api-example$ curl localhost:5258/ping -v
*   Trying 127.0.0.1:5258...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 5258 (#0)
> GET /ping HTTP/1.1
> Host: localhost:5258
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Date: Mon, 29 May 2023 02:27:29 GMT
< Server: Kestrel
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
{"pong":true}

うまいことJSONを返却できていることが分かります。その他の任意の型でも同様に動くのでかなり便利です。ただ、判別共用体はサポートされていないため、返却する型に含まれていると例外を投げられてしまうので注意が必要です(エラーメッセージが優しいのですぐに気付くと思います)。

ルートパラメータやJSONボディの受け取り方

次はPOSTメソッドに対応した /articles とGETメソッドに対応した /articles/{id} というパスについて考えてみます。

POSTメソッドに対応した /articles ではJSONで {"title": "~~~", "author": "~~~"} という簡単なJSONを受け取るとしましょう。これは以下のように記述することができます。

// Program.fs
[<EntryPoint>]
let main args =
    // ~~ 中略
    let articles = app.MapGroup("/articles")

    articles.MapPost("", Func<Controller.Article, IResult> Controller.createArticle)
    |> ignore

app.MapGroup("/articles")ルートをグループ化するためのAPIです。気にするのは Func<Controller.Article, IResult> です。これは Article 型を受け取って、 IResult 型を返却するFunc Delegateをつくることを意味します。つまり、 Controller.createArticleArticle 型を受け取って、 IResult 型を返す関数として定義されています。具体的には以下のような実装になります。

// App/Controller.fs
type Article = { title: string; author: string }

let createArticle (article: Article) : IResult =
    Results.Created("/articles/someid", ())

HTTPリクエストのボディがJSONで単純に型にマッピングできる場合はこのように指定するだけで、特別な記述を必要としないので大変簡素になります。

次にGETメソッドに対応する /articles/{id} について考えてみることにします。まずはコントローラーに相当する関数から作成してみます。とりあえず、パスパラメーターが受け取れるかを確認したいので、先程と同様に簡単な実装にしておきます。

// App/Controller.fs
let showArticle (id: Guid) : IResult = Results.Ok(id)

そして先程の articles のルートグループにパスを書き足す形で記述するとこう書けます。

articles.MapGet("/{id}", Func<Guid, IResult>(fun (id) -> Controller.showArticle id))
|> ignore

少し様子がおかしいですね。 Controller.createArticle の場合は匿名関数でラップしなくても良かったのに、 Controller.showArticle は匿名関数でラップするように実装しています。パスパラメーターの名前と引数の名前が一致するとそれを取得する、という仕組みなので現在の仕様では仕方ないようです。

github.com

このようにパスパラメーターやリクエストボディをJSONで受け取るのは簡単にマッピングできるようになっているため、簡単に実装することができました。これら以外にも BindAsync を実装するなどすると、複雑なマッピングを実現することができるようになっています。

learn.microsoft.com

learn.microsoft.com

最後にF#を採用してみた雑感

F#を使って簡単にAPI開発を行うことができることが伝わったかと思います。Minimal APIを利用することで、F#の関数型言語としての魅力を最大限にひきだすことができると思いますし、今後利用する方が増えると嬉しいです。

また今回の記事ではIHttpClientFactoryやDIについては触れませんでした。どちらも.NETの文化,歴史や関数プログラミングによった話になるので、別途調べていただけるといいかと思います。

最後に採用してみた感想のようなものですが、この記事のところどころに参考としてドキュメントのページを併記していますが、それを見てもらえると分かる通りMicrosoftの公開しているドキュメントが非常に重厚で知りたいことがたいていの場合はしっかりと書いてあります。そのため初学者であったとしても参入しやすいですし、困ったことがあってもたいていは調べると解決できます。

そして、なによりF#はとても素直に関数プログラミングができる言語で、覚えることも少ないため、関数プログラミング初心者でも扱いやすいので導入するのが簡単です。難点は.NETの知識が少ないと運用でちょっと苦労するシーンがあるかもしれないってことくらいですね。

というわけで、F#とMinimal APIでAPIをつくる話でした。

Page top