diff --git a/.circleci/config.yml b/.circleci/config.yml index 544f4a5..5dd8645 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,10 +3,10 @@ jobs: build: # Ref: https://mmhaskell.com/blog/2018/4/25/dockerizing-our-haskell-app docker: - - image: haskell:9.0.2-slim + - image: haskell:9.10.2-slim-bullseye steps: - run: apt update - - run: apt install -y zip jq curl + - run: apt install -y zip jq curl git openssh-client pkg-config - run: stack upgrade - run: "echo 'tcp 6 TCP' > /etc/protocols" - run: "stack config --system-ghc set system-ghc --global true" @@ -16,7 +16,7 @@ jobs: keys: - 'dependencies-{{ checksum "stack.yaml" }}-{{ checksum "haskell-jp-blog.cabal" }}' - 'dependencies-' - - run: stack build --compiler=ghc-9.0.2 --no-terminal --only-dependencies + - run: stack build --compiler=ghc-9.10.2 --no-terminal --only-dependencies - save_cache: key: 'dependencies-{{ checksum "stack.yaml" }}-{{ checksum "haskell-jp-blog.cabal" }}' paths: @@ -27,7 +27,7 @@ jobs: keys: - 'executable-{{ checksum "src/site.hs" }}' - 'executable-' - - run: stack --compiler=ghc-9.0.2 --local-bin-path='.' --no-terminal install --pedantic + - run: stack --compiler=ghc-9.10.2 --local-bin-path='.' --no-terminal install - save_cache: key: 'executable-{{ checksum "src/site.hs" }}' paths: @@ -55,7 +55,7 @@ jobs: deploy: docker: - - image: haskell:9.0.2-slim + - image: haskell:9.10.2-slim steps: - checkout: path: ~/project diff --git a/preprocessed-site/posts/2025/wai-sample.md b/preprocessed-site/posts/2025/wai-sample.md new file mode 100644 index 0000000..8080e25 --- /dev/null +++ b/preprocessed-site/posts/2025/wai-sample.md @@ -0,0 +1,504 @@ +--- +title: 単純なHaskellのみでServant並に高機能なライブラリーを作ろうとした振り返り +subHeading: +headingBackgroundImage: ../img/background.png +headingDivClass: post-heading +author: YAMAMOTO Yuji +postedBy: YAMAMOTO Yuji(@igrep) +date: July 27, 2025 +tags: +... +--- + +この記事では、「[Haskell製ウェブアプリケーションフレームワークを作る配信](https://www.youtube.com/playlist?list=PLRVf2pXOpAzJMFN810EWwGrH_qii7DKyn)」で配信していた、Haskell製ウェブアプリケーションフレームワークを作るプロジェクトについて振り返ります。Servantのような型安全なAPI定義を、(Servantのような)高度な型レベルプログラミングも、(Yesodのような)TemplateHaskellもなしに可能にするライブラリーを目指していましたが、開発を途中で止めることにしました。その振り返り --- とりわけ、そのゴールに基づいて実装するのが困難だと分かった機能などを中心にまとめます。 + +# 動機 + +そもそも、Haskellには既にServantやYesod、Scottyといった人気のフレームワークがあるにもかかわらず、なぜ新しいフレームワークを作ろうと思ったのでしょうか。第1に、かつて私が[「Haskellの歩き方」という記事の「Webアプリケーション」の節](https://wiki.haskell.jp/Hikers%20Guide%20to%20Haskell.html#web%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3)で述べた、次の問題を解決したかったから、という理由があります: + +> ただしServant, Yesod, 共通した困った特徴があります。 +> それぞれがHaskellの高度な機能を利用した独特なDSLを提供しているため、仕組みがわかりづらい、という点です。 +> Servantは、「型レベルプログラミング」と呼ばれる、GHCの言語拡張を使った仕組みを駆使して、型宣言だけでREST APIの仕様を記述できるようにしています。 +> YesodもGHCの言語拡張をたくさん使っているのに加え、特に変わった特徴として、TemplateHaskellやQuasiQuoteという仕組みを利用して、独自のDSLを提供しています。 +> それぞれ、見慣れたHaskellと多かれ少なかれ異なる構文で書かなければいけない部分があるのです。 +> つまり、これらのうちどちらかを使う以上、どちらかの魔法を覚えなければならないのです。 + +この「どちらかの魔法を覚えなければならない」という問題は、初心者がHaskellでウェブアプリケーションを作る上で大きな壁になりえます。入門書に書いてあるHaskellの機能だけでは、ServantやYesodなどのフレームワークで書くコードを理解できず、サンプルコードから雰囲気で書かなければならないのです。これが、新しいフレームワークを作ろうとした一番の動機です。 + +その他、このフレームワークを開発し始めるより更に前から開発・執筆している、[「失敗しながら学ぶHaskell入門」](https://github.com/haskell-jp/makeMistakesToLearnHaskell/)をウェブアプリケーションとして公開する際のフレームワークとしても使おうという考えもありました。「失敗しながら学ぶHaskell入門」はタイトルの通りHaskell入門者のためのコンテンツです。そのため、Haskellを学習したばかりの人でも簡単に修正できるフレームワークにしたかったのです。 + +# できたもの + +ソースコードはこちら👇️にあります。名前は仮に「wai-sample」としました。 + +[igrep/wai-sample: Prototype of a new web application framework based on WAI.](https://github.com/igrep/wai-sample) + +YouTubeで配信する前から行っていた(私の前職である)IIJの社内勉強会中の開発と、全128回のYouTubeでのライブコーディングを経て(一部配信終了後に手を入れたこともありましたが)、次のような構文でウェブアプリケーションを記述できるようにしました: + +```haskell +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeApplications #-} + +import WaiSample + +sampleRoutes :: [Handler] +sampleRoutes = + [ -- ... 中略 ... + + -- (1) 最も単純な例 + , get @(PlainText, T.Text) "aboutUs" (path "about/us") (\_ -> return "About IIJ") + + -- (2) ステータスコードを指定した例 + , get @(WithStatus Status503 PlainText, T.Text) "maintenance" (path "maintenance") + (\_ -> return "Sorry, we are under maintenance") + + -- ... 中略 ... + + -- (3) パスをパースして含まれる整数を取得する例 + , get @(PlainText, T.Text) + "customerTransaction" + ( (,) <$> (path "customer/" *> decimalPiece) + <*> (path "/transaction/" *> paramPiece) + ) + (\(cId, transactionName) -> + return $ "Customer " <> T.pack (show cId) <> " Transaction " <> transactionName + ) + + -- ... 中略 ... + ] +``` + +※完全なサンプルコードは[WaiSample/Sample.hs](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Sample.hs)をご覧ください。上記はその一部に説明用のコメントを加えています。 + +上記のサンプルコードにおける`sampleRoutes`が、Web APIの仕様を定めている部分です: + +```haskell +sampleRoutes :: [Handler] +``` + +`Handler`という型のリストで、それぞれの`Handler`には、Web APIのエンドポイントを表すのに必要な情報が全て含まれています。wai-sampleでは、この`Handler`のリストを解釈してWAIベースのサーバーアプリケーションを実行したり、Template Haskellを通じてクライアントコードを生成したり、はたまたサーバーアプリケーションのドキュメントを生成したりすることができるようになっています。 + +## (1) 最も単純な例 + +```haskell +get @(PlainText, T.Text) "aboutUs" (path "about/us") (\_ -> return "About IIJ") +``` + +先程のサンプルコードから抜粋した最も単純な例↑では、`get`関数を使ってエンドポイントを定義しています。`get`関数は名前のとおりHTTPのGETメソッドに対応するエンドポイントを定義します。`TypeApplications`言語拡張を使って指定している`(PlainText, T.Text)`という型が、このエンドポイントが返すレスポンスの型を表しています。ここでは、`get`に渡す最後の引数に当たる関数([`Responder`](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Types.hs#L104)と呼びます。詳細は後ほど)がレスポンスボディーとして返す型をお馴染みの`Text`型として指定しつつ、サーバーやクライアントが処理する際はMIMEタイプを`text/plain`として扱うように指定しています。 + +`get`関数の(値の)第1引数では、エンドポイントの名前を指定しています。この名前は、後述するクライアントコードを生成する機能において、関数名の一部として使われます。 + +`get`関数の第2引数は、エンドポイントのパスの仕様を表す[`Route`型](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Types.hs#L56-L65)の値です。この例では、`path`関数を使って`"about/us"`という単純な文字列を指定しています。結果、このエンドポイントのパスは`/about/us`となります[^leading-slash])。 + +[^leading-slash]: 先頭のスラッシュにご注意ください。wai-sampleが`Route`型の値を処理する際は、先頭のスラッシュは付けない前提としています。 + +`get`関数の最後の引数が、このエンドポイントがHTTPリクエストを受け取った際に実行する関数、[`Responder`](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Types.hs#L104)です。ここでは、単純にレスポンスボディーとして文字列を返すだけの関数を指定しています。 + +## (2) ステータスコードを指定した例 + +```haskell +get @(WithStatus Status503 PlainText, T.Text) "maintenance" (path "maintenance") + (\_ -> return "Sorry, we are under maintenance") +``` + +デフォルトでは、`get`関数で定義したエンドポイントはやっぱりステータスコード200(OK)を返します。この挙動を変えるには、先程指定したレスポンスの型のうち、MIMEタイプを指定していた箇所を`WithStatus`型でラップしましょう。型引数で指定しているタプルの1つ目の要素は、このようにHTTPのレスポンスに関する仕様をHaskellの型で指定するパラメーターとなっています。 + +この例では、`Status503`という型を指定しているため、HTTPステータスコード503(Service Unavailable)を返すエンドポイントを定義しています。 + +## (3) パスの中に含まれる整数を処理する例 + +よくあるWebアプリケーションフレームワークでは、パスの一部に含まれる整数など、文字列型以外の値を取得するための仕組みが用意されています。 + +Haskellにおいて、文字列から特定の型の値を取り出す...といえばそう、パーサーコンビネーターですね。wai-sampleでは、サーバーが受け取ったパスをパーサーコンビネーターでパースするようになっています。従って下記の例では、`/customer/123/transaction/abc`というパスを受け取った場合、`123`と`"abc"`をタプルに詰め込んで`Responder`に渡すパスのパーサーを定義しています: + +```haskell +get @(PlainText, T.Text) + "customerTransaction" + ( (,) <$> (path "customer/" *> decimalPiece) + <*> (path "/transaction/" *> paramPiece) + ) + (\(cId, transactionName) -> + return $ "Customer " <> T.pack (show cId) <> " Transaction " <> transactionName + ) +``` + +実際のところここまでの話は`Route`型の値をサーバーアプリケーションが解釈した場合の挙動です。`Route`型はパスの仕様を定義する`Applicative`な内部DSLとなっています。これによって、サーバーアプリケーションだけでなくクライアントのコード生成機能やドキュメントの生成など、様々な応用ができるようになっています。詳しくは後述しますが、例えばクライアントのコード生成機能が`Route`型の値を解釈すると、`decimalPiece`や`paramPiece`などの値は生成した関数の引数を一つずつ追加します。 + +## Content-Typeを複数指定する + +Ruby on Railsの`respond_to`メソッドなどで実現できるように、一つのエンドポイントで一つの種類のレスポンスボディーを、複数のContent-Typeで返す、といった機能は昨今のWebアプリケーションフレームワークではごく一般的な機能でしょう。wai-sampleの場合、例えば次のようにして、`Customer`という型の値をJSONや`application/x-www-form-urlencoded`な文字列として返すエンドポイントを定義できます: + +```haskell +sampleRoutes = + [ -- ... 中略 ... + , get @(ContentTypes '[Json, FormUrlEncoded], Customer) + -- ... 中略 ... + ] +``` + +これまでの例では`get`の型引数においてMIMEタイプを表す箇所に一つの型のみ(`PlainText`型)を指定していましたが、ここでは代わりに`ContentTypes`という型を使用しています。`ContentTypes`型コンストラクターに、MIMEタイプを表す型の型レベルリストを渡せば、レスポンスボディーを表す一つの型に対して、複数のMIMEタイプを指定できるようになります。 + +なお、`Json`や`FormUrlEncoded`と一緒に指定した`Customer`型は、当然[`ToJSON`](https://hackage.haskell.org/package/aeson/docs/Data-Aeson-Types.html#t:ToJSON)・[`FromJSON`](https://hackage.haskell.org/package/aeson/docs/Data-Aeson-Types.html#t:FromJSON)や[`ToForm`](https://hackage.haskell.org/package/http-api-data/docs/Web-FormUrlEncoded.html#t:ToForm)・[`FromForm`](https://hackage.haskell.org/package/http-api-data/docs/Web-FormUrlEncoded.html#t:FromForm)といった型クラスのインスタンスである必要があります[^http-api-data]。レスポンスボディーとして指定した型が、同時に指定したMIMEタイプに対応する形式に変換できることを、保証できるようになっているのです。 + +[^http-api-data]: 諸般の事情で、wai-sampleでは[`http-api-data`パッケージをフォーク](https://github.com/igrep/http-api-data/tree/151de32409960354de3a3f786f20bc4a496d2b65)して使っています。そのため、`ToForm`型クラスなどの仕様がHackageにあるものと異なっています。最終的にwai-sampleを公開する際、フォークしたhttp-api-dataを新しいパッケージとして同時に公開する予定でした。 + +## サーバーアプリケーションとしての使い方 + +ここまでで定義した`Handler`型の値、すなわちWeb APIのエンドポイントの仕様に基づいてサーバーアプリケーションを実行するには、次のように書きます: + +```haskell +import Network.Wai (Application) +import Network.Wai.Handler.Warp (runEnv) + +import WaiSample.Sample (sampleRoutes) +import WaiSample.Server (handles) + + +sampleApp :: Application +sampleApp = handles sampleRoutes + + +runSampleApp :: IO () +runSampleApp = runEnv 8020 sampleApp +``` + +ℹ️[こちら](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Server/Sample.hs)にあるコードと同じ内容です。 + +`get`関数などで作った`Handler`型のリストを`handles`関数に渡すと、WAIの[`Application`](https://hackage.haskell.org/package/wai-3.2.4/docs/Network-Wai.html#t:Application)型の値が出来上がります。`Application`型はWAIにおけるサーバーアプリケーションを表す型で、ServantやYesodなど他の多くのHaskell製フレームワークでも、最終的にこの`Application`型の値を作るよう設計されています。上記の例は`Application`型の値をWarpというウェブサーバーで動かす場合のコードです。`Application`型の値をWarpの`runEnv`関数に渡すことで、指定したポート番号でアプリケーションを起動できます。 + +ここで起動したサーバーアプリケーションが、実際にエンドポイントへのリクエストを受け取った際実行する関数は、`get`関数などの最後の引数にあたる関数です。その関数は`SimpleResponder`という型シノニム[^simple]が設定されており、次のような定義となっています: + +[^simple]: 名前から察せられるとおり`Simple`じゃない普通の`Responder`型もありますが、ここでは割愛します。`Responder`型はクエリーパラメーターやリクエストヘッダーなど、パスに含めるパラメーター以外の情報を受け取るためのものです。`SimpleResponder`型のすぐ近くで定義されているので、興味があったらご覧ください。 + +```haskell +type SimpleResponder p resObj = p -> IO resObj +``` + +ℹ️[こちら](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Types.hs#L106)より + +型パラメーター`p`は、エンドポイントのパスに含まれるパラメーターを表す型です。これまでの例で`get`関数に渡した`(path "about/us")`や`((,) <$> (path "customer/" *> decimalPiece) <*> (path "/transaction/" *> paramPiece))`という式で作られる、`Route`型の値を解釈した結果の型`p`です。 + +そして`resObj`は、エンドポイントが返すレスポンスボディーの型です。これまでの例でいうと、`get`関数の型引数で指定した`(PlainText, T.Text)`における`T.Text`型、`(ContentTypes '[Json, FormUrlEncoded], Customer)`における`Customer`型が該当します。 + +`runSampleApp`は各`Handler`型の値を解釈し、サーバーアプリケーションとして実行します。エンドポイントのパスの仕様(`(path "about/us")`など)をパーサーコンビネーターとして解釈し[^parser]、パースが成功した`Handler`が持つ`SimpleResponder`(`p -> IO resObj`)を呼び出します。そして`SimpleResponder`が返した`resObj`を、クライアントが要求したMIMEタイプに応じたレスポンスボディーに変換し、クライアントに返す、という流れで動くようになっています。 + +[^parser]: パーサーコンビネーター以外のアプローチ、例えば基数木を使ってより多くのエンドポイントを高速に処理できるようにするのも可能でしょう。 + +## Template Haskellによる、クライアントの生成 + +サーバーアプリケーションの定義だけであれば、Haskell以外のものも含め、従来の多くのウェブアプリケーションフレームワークでも可能でしょう。しかしServantを始め、昨今におけるREST APIの開発を想定したWebアプリケーションフレームワークは、クライアントコードを生成する機能まで備えていることが多いです。wai-sampleはそうしたフレームワークを目指しているため、当然クライアントコードの生成もできるようになっています: + +```haskell +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} + +import WaiSample.Client +import WaiSample.Sample + + +$(declareClient "sample" sampleRoutes) +``` + +ℹ️[こちら](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Client/Sample.hs)からほぼそのままコピペしたコードです。 + +上記の通り、クライアントコードの生成は`TemplateHaskell`を使って行います。`declareClient`という関数に、生成する関数の名前の接頭辞(prefix)とこれまで定義した`Handler`型のリスト(`sampleRoutes`)を渡すと、次のような型の関数の定義を生成します[^ddump-splices]: + +```haskell +sampleAboutUs :: Backend -> IO Text +sampleMaintenance :: Backend -> IO Text +sampleCustomerTransaction :: Backend -> Integer -> Text -> IO Text +``` + +[^ddump-splices]: `ghc`コマンドの`-ddump-splices`オプションを使って、`declareClient`関数が生成したコードを貼り付けました。みなさんの手元で試す場合は`stack build --ghc-options=-ddump-splices`などと実行するのが簡単でしょう。 + +生成された関数は、`get`関数などの第1引数として渡した関数の名前に、`declareClient`の第1引数として渡した接頭辞が付いた名前で定義されます。 + +生成された関数の第1引数、`Backend`型は、クライアントがサーバーアプリケーションに実際にHTTPリクエストを送るための関数です。次のように定義されています: + +```haskell +import qualified Data.ByteString.Lazy.Char8 as BL +import qualified Network.HTTP.Client as HC + +type Backend = Method -> Url -> RequestHeaders -> IO (HC.Response BL.ByteString) +``` + +このバックエンドを、例えば`http-client`パッケージの関数を使って実装することで、生成された関数がサーバーアプリケーションにリクエストを送ることができます。以下は実際に`http-client`パッケージを使って実装したバックエンドの例です: + +```haskell +import qualified Network.HTTP.Client as HC +import qualified Data.ByteString.UTF8 as BS + +httpClientBackend :: String -> Manager -> Backend +httpClientBackend rootUrl manager method pathPieces rawReqHds = do + req0 <- parseUrlThrow . BS.toString $ method <> B.pack " " <> BS.fromString rootUrl <> pathPieces + let req = req0 { HC.requestHeaders = rawReqHds } + httpLbs (setRequestIgnoreStatus req) manager +``` + +ℹ️[こちら](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Client.hs#L225-L230)からほぼそのままコピペしたコードです。 + +`Backend`型以外の引数は、パスパラメーターを始めとする、HTTPリクエストを組み立てるのに必要な情報です。`get`関数などで`Handler`型の値を定義する際に指定した`decimalPiece`や`paramPiece`を`declareClient`関数が回収して、生成した関数の引数に追加します。実際に生成した関数が受け取った引数は、もちろんパスの一部として当てはめるのに用います。 + +生成した関数の戻り値は、サーバーからのレスポンスを表す型です。`get`関数の型引数として渡した`(PlainText, T.Text)`や`(ContentTypes '[Json, FormUrlEncoded], Customer)`などにおける`T.Text`や`Customer`がそれに当たります。クライアントの関数はサーバーからのレスポンスを、MIMEタイプを表す型などに従って、この型に変換してから返すよう実装されているのです。 + +## ドキュメントの生成 + +[ServantではOpenAPIに則ったドキュメントを生成するパッケージがある](https://hackage.haskell.org/package/servant-openapi3)ように、Haskellの構文で定義したREST APIの仕様から、APIのドキュメントを生成する機能があると便利でしょう。wai-sampleでも、`Handler`型のリストからAPIのドキュメントを生成する機能を実装しました --- 残念ながら完成度が低く、とても実用に耐えるものではありませんが。 + +ともあれ、試しに使ってみましょう。これまで例として紹介した[`sampleRoutes`](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Sample.hs#L147)の各`Handler`に[`showHandlerSpec`](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample.hs#L47)という関数を適用すると、次のように各エンドポイントへのパスやリクエスト・レスポンスの情報を取得することが出来ます: + +```haskell +> mapM_ (TIO.putStrLn . showHandlerSpec) sampleRoutes +index "GET" / + Request: + Query Params: (none) + Headers: (none) + Response: (PlainText,Text) + +maintenance "GET" /maintenance + Request: + Query Params: (none) + Headers: (none) + Response: ((WithStatus Status503 PlainText),Text) + +aboutUs "GET" /about/us + Request: + Query Params: (none) + Headers: (none) + Response: (PlainText,Text) + +-- ... 中略 ... + +customerTransaction "GET" /customer/:param/transaction/:param + Request: + Query Params: (none) + Headers: (none) + Response: (PlainText,Text) + +createProduct "POST" /products + Request: + Query Params: (none) + Headers: (none) + Response: (PlainText,Text) + +-- ... 以下略 ... +``` + +...が、前述の通りあまりに完成度が低いので、詳しくは解説しません。実際に上記のコード実行すると、`Response`の型などがとても人間に読めるような出力になっていないことが分かります。今どきのWeb APIフレームワークであればOpenAPIに則ったドキュメントを生成する機能が欲しいでしょうが、それもありません。この方向で拡張すれば実装できるとは思いますが、次の節で述べるとおり開発を止めることにしたので、ここまでとしておきます。 + +# 何故開発を止めるのか + +開発をやめる最も大きな理由は、冒頭でも触れたとおり、当初考えていたゴールを達成するのが難しいと判断したからです[^motive]。wai-sampleのゴールは、「Servantのような型安全なAPI定義を、(Servantのような)高度な型レベルプログラミングも、(Yesodのような)TemplateHaskellもなしに可能にするライブラリー」にすることでした。ところが、後述の通りいくつかの機能においてそれが無理ではないか(少なくとも難しい)ということが発覚したのです。 + +[^motive]: もう1つは、大変申し訳ないですが、私自身のHaskellに対する情熱が落ち込んでしまった、という理由もあります😞。 + +## 想定通りにできなかったもの: レスポンスに複数のパターンがあるとき + +「できたもの」の節では割愛しましたが、wai-sampleでは、サーバーが返すレスポンスに複数のケースがあるエンドポイント --- 例えば、一方ではステータスコード200 OKと共に取得できたリソースの情報を返しつつ、一方では403 Forbiddenと共にエラーメッセージを返す --- の実装もサポートしています。例えば次のように書けば、`/customer/:id.txt`というパスで複数の種類のレスポンスを返すエンドポイントを定義することが出来ます: + +```haskell +get @(Sum '[(PlainText, T.Text), Response (WithStatus Status503 PlainText) T.Text]) + "customerIdTxt" + -- /customer/:id.txt + (path "customer/" *> decimalPiece <* path ".txt") + (\i -> + if i == 503 + then return . sumLift $ Response @(WithStatus Status503 PlainText) ("error" :: T.Text) + else return . sumLift $ "Customer " <> T.pack (show i)) +``` + +ℹ️[こちら](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Sample.hs#L175-L182)からほぼそのままコピペしたコードです。 + +`get`関数の型引数に、随分仰々しい型が現れました。`Sum`という型は、名前のとおり和型を作ります。型レベルリストの要素としてContent-Typeやステータスコードを表す型と、実際のレスポンスボディーの型を組み合わせたタプル(あるいは後述する`Response`型)を渡すことで、複数のケースを持つレスポンスの型を定義しています。上記の例における`Sum '[(PlainText, T.Text), Response (WithStatus Status503 PlainText) T.Text]`は、次の2つのケースを持つレスポンスの型を表しています: + +- ステータスコードが(デフォルトの)`200 OK`で、Content-Typeが`text/plain`、レスポンスボディーを表す型が`Text`型 +- ステータスコードが`503 Service Unavailable`で、Content-Typeが`text/plain`、レスポンスボディーを表す型が`Text`型 + +以上のように書くことで実装できるようにはしたのですが、これによって当初の目的である「高度な型レベルプログラミングなしに実装する」という目標から外れてしまいました。型レベルリストは「高度な型レベルプログラミング」に該当すると言って差し支えないでしょう。 + +なぜこのようなAPIになったのかというと、Web APIに対する「入力」に当たる、パスのパース(や、今回は実装しませんでしたがリクエストボディーなどの処理も)などと、Web APIからの「出力」に当たるレスポンスの処理では、実行時に使える情報が大きく異なっていたからです。「入力」は値レベルでも(高度な型レベルプログラミングなしで)Free Applicativeを応用したDSLを使えば[^free-applicative]、サーバーアプリケーション・クライアントコード・ドキュメント、いずれにも実行時に解釈できるフレームワークにできた一方、レスポンスボディーなど「出力」の型は値レベルのDSLを書いても、サーバーアプリケーションを実行しない限りそれに整合しているかどうかが分からない、という原理的な問題が判明したからです。 + +[^free-applicative]: 今回は詳細を省きましたが`Free Applicative`を使ったDSLの実装は、[WaiSample.Typesモジュール](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Types.hs)をご覧ください。 + +例えば、レスポンスボディーの仕様を次のような内部DSLで定義できるようにしたとします: + +```haskell +get [(plainText, text), ((withStatus status503 plainText), text)] + -- ... +``` + +型レベルプログラミングバージョンでは型引数に渡していた情報を、ほぼそのまま値レベルに落とし込んだものです。しかしこのように書いたとしても、サーバーアプリケーションを起動して、実際にクライアントからリクエストを受け取り、それに対して`get`に渡した関数(`Responder`)がレスポンスの元となる値を返すまで、レスポンスボディーの型が正しいかどうか、検証できないのです。「レスポンスの元となる値」の型はライブラリーのユーザー自身が`Responder`で返す値の型ですし、実行時以前にコンパイル時に保証できていて欲しいものです。これが、値レベルのDSLを採用した場合の限界です。 + +それから、型レベルリストを使ったこと以外においても、複雑で分かりづらい要因があります。先程から少し触れているとおり、Content-Typeやステータスコードとレスポンスボディーの型を組み合わせを表すのに、タプル以外にも`Response`という型を用いています。`Response`型とタプル型はいずれも[`ResponseSpec`](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Types/Response.hs#L47-L49)(下記に転載)という型クラスのインスタンスとなることで、「Content-Typeやステータスコード」を表す型(`ResponseType`)と`Responder`がレスポンスボディーとして返す型(`ResponseObject`)を宣言することが出来ます: + +```hs +class ResponseSpec resSpec where + type ResponseType resSpec + type ResponseObject resSpec + +instance ResponseSpec (resTyp, resObj) where + type ResponseType (resTyp, resObj) = resTyp + type ResponseObject (resTyp, resObj) = resObj + +instance ResponseSpec (Response resTyp resObj) where + type ResponseType (Response resTyp resObj) = resTyp + type ResponseObject (Response resTyp resObj) = Response resTyp resObj +``` + +`ResponseSpec (resTyp, resObj)`と`ResponseSpec (Response resTyp resObj)`の2つのインスタンスの違い、分かるでしょうか?まるで間違い探しですよね...😥。タプル型も`Response`型も`get`などに渡す型レベルリストでの役割はほぼ同じで、最初はタプルだけをとることにしていたのですが、やむを得ない理由があって`Response`を別途設けることにしました[^reason]。こうした分かりづらい部分が出来てしまったのも、失敗の1つです。 + +[^reason]: 詳しい理由は面倒なので解説しません!これまでに出てきたコードだけで推測できるはずですし考えてみてください! + +## パスのパーサー: 実は`<$>`がすでに危ない + +パスのパーサーを値レベルの、`Applicative`な内部DSLとして実装した結果、Servantと比べて型安全性を損なってしまうという問題があることも、作ってから気付きました。例えば、次のように`<$>`に渡す関数としてコンストラクターでない、普通の関数を渡した場合です: + +```haskell +path "integers/" *> (show <$> decimalPiece) +``` + +[`decimalPiece`](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Routes.hs#L22)は`Route Integer`という型で、それに`show <$>`を適用した結果は`Route String`となります。`Route String`は、パスの一部として文字列を受け取ることを表す型ですから、上記の式は`integers/<任意の文字列>`というパスを表すことになります。ところが!実際にサーバーアプリケーションがパスをパースするのに使っているのは`decimalPiece`なので、整数でなければなりません。このように`<$>`を使うだけで、`Route String`という型が表すパスのパーサーと、実際にパースできるパスの仕様が食い違ってしまうことがあります。`Applicative`(厳密に言えば`Functor`の機能ですが)を使ったDSLである以上、こうしたことが防げないのです。 + +まあ、実は同じ問題が同じように`Applicative`ベースの内部DSLを使った他のライブラリーにもあるでしょうから、敢えて気にしない、という手もあるのかも知れませんが。ちなみに、似たような問題を解決するため[relational-record](https://hackage.haskell.org/package/relational-record)というパッケージでは`Functor`や`Applicative`は使わず、[product-isomorphic](https://hackage.haskell.org/package/product-isomorphic)というパッケージにおいて、言わば「コンストラクターだけが適用できる`Functor`・`Applicative`」とも言うべき専用の型クラスを作ることで解決していました。wai-sampleもこれを使えないかと企みましたが、どうもうまく適用できなかったため諦めました。 + +# 実装し切れなかったもの + +ウェブアプリケーションフレームワークとして実装すべき機能のうち、実装し切れなかったものは当然たくさんあります。例えば以下のような機能でしょう: + +- HTTPリクエストに関わるもの: + - リクエストヘッダーの処理 + - クエリーパラメーターの処理 + - (この辺りは、リクエストボディーの処理と似たような要領で実装できるはず) +- HTTPレスポンスに関わるもの: + - 動的なHTMLの配信 + - ファイルシステムにあるファイルの配信 + - (REST APIに特化したフレームワークであればこれらは不要でしょうが、拡張として簡単に追加できるようにはしたいですね) +- 両方に関わるもの: + - Cookieの読み書き +- ドキュメント生成に関わるもの: + - OpenAPIに準拠したドキュメント生成 +- などなど! + +# 類似のライブラリー・解決策 + +手が遅いもので、私が最初にwai-sampleのリポジトリーに対して行った[最初のコミット](https://github.com/igrep/wai-sample/commit/37f49dfe86af7482b09ab82b2282c5b9bf1cd73d)から、既に約5年の歳月が過ぎました[^last-commit]。当時は私の前職、IIJにおける社内勉強会のネタとして始めたのが懐かしいです。私が知る限り、当時はwai-sampleのように「値レベルのプログラミングで」「Servantのように1つの定義からクライアントやドキュメントの生成も出来る」ことを目指したライブラリーはなかったように思います。しかし実際のところ、執筆時点で次のライブラリーが類似の機能を実装しているようです。これらのライブラリーがいつ開発を始めたのかは分かりませんが、やはり私がwai-sampleを作り始めた時点で同じような問題意識を持った人はいたのでしょう。 + +[^last-commit]: [実装に対する最後の修正](https://github.com/igrep/wai-sample/commit/b2647de2a1a4c7ec8c799ec07972c3d9df6fcb55)からも既に約1年が過ぎました。記録を作るのも遅い...😥 + +## [Okapi](https://okapi.wiki/) + +[Okapi](https://okapi.wiki/)にある[Endpoint](https://okapi.wiki/#endpoint)という機能は「An Endpoint is an executable specification representing a single Operation that can be taken against your API.」と謳っているとおり、APIの仕様を表現する内部DSLを提供します。しかもこれから紹介するとおり、wai-sampleより幾分洗練されているように見えます。 + +Okapiでは下記の`Endpoint`という型 --- wai-sampleでいう`Handler`に相当するようです --- に、「Script」と呼ばれる値レベルDSLを設定して使うようです: + +```haskell +data Endpoint p q h b r = Endpoint + { method :: StdMethod + , path :: Path.Script p + , query :: Query.Script q + , body :: Body.Script b + , headers :: Headers.Script h + , responder :: Responder.Script r + } +``` + +詳細はもちろん公式ドキュメントにも書かれていますが、読んでわかる範囲でこちらでも解説しましょう。`Endpoint`型の各フィールドは、HTTPリクエスト・レスポンスに関わる各要素の仕様を表しています。`method`フィールドを除くすべてのフィールドは、それぞれのフィールドのために作られた`Script`という型の`Applicative`[^alternaive]なDSLを使って仕様を表現します。`Path.Script p`はパスの仕様、`Query.Script q`はクエリーパラメーターの仕様、`Body.Script b`はリクエストボディーの仕様、`Headers.Script h`はリクエストヘッダーの仕様、`Responder.Script r`はレスポンスの仕様、といったところです。 + +[^alternaive]: 個人的には、なぜ`Alternative`にしなかったのかが気になります。`Body.optional`や`Headers.optional`などは文字通り[`Alternative`の`optional`](https://hackage.haskell.org/package/base-4.21.0.0/docs/Control-Applicative.html#v:optional)で実現できそうに見えるからです。 + +各`Script`型のうち、特筆すべきは`Responder.Script`でしょう。`Responder.Script`では、レスポンスの種類毎にレスポンスボディーの型やステータスコード、レスポンスヘッダーの型を、case analysisを表す型として定義できるようになっています。そして、`Handler`型は`Endpoint`型が各種`Script`を使って設定した値を使って、実際に`Response`型の値を組み立てます: + +(⚠️以下のコードは、[Okapiのドキュメントにあったサンプルコード](https://okapi.wiki/#cb10)を元に、私が推測してコメントを追加したものです。間違っていたらごめんなさい hask(\_ \_)eller) + +```haskell +-- | Responseにヘッダーを設定する関数群 +data SecretHeaders = SecretHeaders + { firstSecret :: Int -> Response -> Response + , secondSecret :: Int -> Response -> Response + } + +-- | Responseにヘッダーとボディーを設定する関数群 +-- レスポンスの種類毎にフィールドラベルを1つ備えた、case analysisを表す型 +data MyResponders = MyResponders + { allGood :: (SecretHeaders %1 -> Response -> Response) -> Text -> Response + , notGood :: (() %1 -> Response -> Response) -> Text -> Response + } + +-- | `Responder.Script`として定義する、レスポンスの仕様 +myResponderScript = do + -- allGood の場合はレスポンスボディーは`Text`型で、ステータスコードは200。 + -- レスポンスヘッダーとしては、`IntSecret`と`X-Another-Secret`という + -- `Int`型の2つのヘッダーを追加する。 + allGood <- Responder.json @Text status200 do + addSecret <- AddHeader.using @Int "IntSecret" + addAnotherSecret <- AddHeader.using @Int "X-Another-Secret" + pure SecretHeaders {..} + + -- notGood の場合はレスポンスボディーは`Text`型で、ステータスコードは501。 + -- レスポンスヘッダーはなし。 + notGood <- Responder.json @Text status501 $ pure () + pure MyResponders {..} + +-- | Responder.Scriptで定義したcase analysisを表す型、`MyResponders`を使って、 +-- レスポンスを組み立てる関数。 +-- `someNumber`が100未満なら`allGood`を、そうでなければ`notGood`を使う。 +-- この関数が利用していない引数は、`Endpoint`型の他のフィールドに対応するもの。 +myHandler someNumber _ _ _ _ (MyResponders allGood notGood) = do + if someNumber < 100 + then return $ allGood + (\(SecretHeaders firstSecret secondSecret) response -> secondSecret 0 $ firstSecret 7 response) + "All Good!" + else return $ notGood + (\() response -> response) + "Not Good!" +``` + +wai-sampleがうまく実装できなかった、レスポンスに複数のパターンがある場合の処理を、case analysisを表す型で実装しているのが興味深いですね。前述した「原理的な問題」に対する解決策なのでしょう。 + +## [IHP](https://ihp.digitallyinduced.com/) + +IHP (Integrated Haskell Platform)は、Haskellで書かれたフルスタックなWebアプリケーションフレームワークです。wai-sampleのような、与えられたパスに基づいて対応する関数を呼び出す機能(ルーティング機能)はもちろんのこと、PostgreSQLと接続するORMやメールの送信、バックグラウンド処理に加えてGUIから管理する機能など、様々な機能を備えています。[Architecture](https://ihp.digitallyinduced.com/Guide/architecture.html)を読むと察せられるとおり、古き良きRuby on Railsのようなスタイルのフレームワークのようです。 + +そんな[IHPのルーティング機能](https://ihp.digitallyinduced.com/Guide/routing.html)、とりわけREST APIの慣習では表現しきれず、[カスタマイズしたパスを定義する際の機能](https://ihp.digitallyinduced.com/Guide/routing.html#custom-routing)は、まさにパスのパーサーコンビネーターを書くことで実装できるようになっています。以下はドキュメントにあった例をそのまま貼り付けています: + +```haskell +-- /posts/an-example-blog-post というような記事の名前(slug)や +-- /posts/f85dc0bc-fc11-4341-a4e3-e047074a7982 というような記事のIDから +-- 記事を表示するアクションを呼び出すルーティング + +-- パスにあるパラメーターを表す型 +data PostsController + = ShowPostAction { postId :: !(Maybe (Id Post)), slug :: !(Maybe Text) } + + +-- CanRoute 型クラスのインスタンスで、 +-- パスのパーサーコンビネーターを定義する +instance CanRoute PostsController where + parseRoute' = do + string "/posts/" + let postById = do id <- parseId; endOfInput; pure ShowPostAction { postId = Just id, slug = Nothing } + let postBySlug = do slug <- remainingText; pure ShowPostAction { postId = Nothing, slug = Just slug } + postById <|> postBySlug + + +-- HasPath 型クラスのインスタンスで、 +-- パスに含めるパラメーターからパスを生成する関数を定義する +instance HasPath PostsController where + pathTo ShowPostAction { postId = Just id, slug = Nothing } = "/posts/" <> tshow id + pathTo ShowPostAction { postId = Nothing, slug = Just slug } = "/posts/" <> slug + + +action ShowPostAction { postId, slug } = do + post <- case slug of + Just slug -> query @Post |> filterWhere (#slug, slug) |> fetchOne + Nothing -> fetchOne postId + -- ... +``` + +wai-sampleやOkapiのようにパスの定義を1箇所で済ませられるわけではない(`CanRoute`と`HasPath`の2つの型クラスのインスタンスを定義する必要がある)ようですが、パーサーコンビネーターを使って自由にパスを定義できるところは似ていますね。 + +# 終わりに + +wai-sampleは、HaskellでWeb APIを実装するためのフレームワークとして、ServantやYesodのような既存のフレームワークとは異なるアプローチを試みました。残念ながら目標の達成が技術的に困難であることが分かり、開発を止めることにしましたが、HaskellでWeb APIを実装するため新しいアプローチとして、何かしら参考になれば幸いです。 diff --git a/stack.yaml b/stack.yaml index 31fd4a7..8825774 100644 --- a/stack.yaml +++ b/stack.yaml @@ -3,4 +3,4 @@ packages: - '.' extra-deps: [] -resolver: lts-19.22 +resolver: lts-24.11 diff --git a/stack.yaml.lock b/stack.yaml.lock index 6ab576e..9dc6b15 100644 --- a/stack.yaml.lock +++ b/stack.yaml.lock @@ -1,12 +1,12 @@ # This file was autogenerated by Stack. # You should not edit this file by hand. # For more information, please see the documentation at: -# https://docs.haskellstack.org/en/stable/lock_files +# https://docs.haskellstack.org/en/stable/topics/lock_files packages: [] snapshots: - completed: - size: 619399 - url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/19/22.yaml - sha256: 5098594e71bdefe0c13e9e6236f12e3414ef91a2b89b029fd30e8fc8087f3a07 - original: lts-19.22 + sha256: 468e1afa06cd069e57554f10e84fdf1ac5e8893e3eefc503ef837e2449f7e60c + size: 726310 + url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/24/11.yaml + original: lts-24.11