Haskell製WebフレームワークServantでHandlerモナドの代わりにカスタムモナドを使う方法
2023-11-05
2024-02-04
今回はServantでカスタムモナドを使うというテーマです。外部から読み込んだ設定やデータベース接続情報をServantで使いたい。しかし全ての関数に引数で渡すのは面倒すぎる。そういう場合はServantのHandlerにReaderTモナド変換子を適用してカスタムモナド化したい。というわけでHandlerモナドをカスタムするお話です。
Handlerをカスタムモナドに変換したい動機
まず、なぜカスタムモナドを使いたいのかという動機についてです。例えばプログラムの最初で読み込んだ設定情報やデータベースへのアクセス情報を保持するPoolなどをプログラム全体で共有したいとします。これら全ての情報をConfigというデータ型に入れてAPIを実装する関数全てから参照できれば便利です。
一方、Servantでは標準の使い方ではAPIに対応するServer api型を実装をすることでAPIの振る舞いを決めます。つまりAPI型がルーター、Server api型がアクセス時のアクションを表現するコントローラーとも解釈できます。このServer api型を実装しようとするとHandlerモナドで実装することになり、このままではReader rモナドやState sモナドなどの他のモナドを使うことができません。
このような場合、ServantのHandlerにReaderTモナド変換子を適用し設定情報やPoolを保持したConfig型などを定義しておくことで、APIの具体的な挙動を実装する関数全てからConfigを参照できるようになります。
このことはServent Cook Bookでも触れられているとおり、カスタムモナドを使って実装する工夫をしていく必要があります。今回はReaderTモナド変換子を用いてHandlerとReader rの合成モナド(カスタムモナド)を扱う方法を見ていきたいと思います。
Servantのバージョンについて
HandlerモナドのカスタマイズについてはServantのバージョンによって方法(用意されている関数)が変わります。今回の方法はVer.0.19以降の方法です。0.19より前のバージョンではserveWithContextT
関数は用意されていませんのでご注意下さい。今回検証したバージョンは0.20です。
今後、Servantのバージョンが上がることで関数名が変更されたり、異なる機能の関数が実装される可能性があり、この記事の方法も使えなくなる可能性があることにご注意下さい。
サンプルコードの準備
まずカスタムモナドを適用する前の単純なAPIを用意しましょう。http://localhost:8080/user/
にアクセスするとユーザーリストをJSONで返すという単純なものです。
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
import Network.Wai.Handler.Warp
import Servant
import GHC.Generics (Generic)
import Data.Aeson
import Data.Text
main :: IO ()
main = do
putStrLn "API Start!"
run 8080 (serveWithContext api context server) -- serveWithContext関数でAplicationを生成し実行
-- run 8080 (serve api server) -- contextを適用しない場合はこちら
context = EmptyContext -- サンプルのため空のコンテキストを使用
-- Userデータの定義
data User = User { userName :: String
, userAge :: Int
} deriving (Show, Generic)
instance ToJSON User
instance FromJSON User
-- APIを定義
type API =
-- /user/にアクセスしてUserリストを取得
"user" :> Get '[JSON] [User]
api :: Proxy API
api = Proxy
-- APIにアクセスされた際の挙動を定義
server :: Server API
server = userListGET
where
userListGET :: Handler [User] -- 各関数はHandlerモナドで記述
userListGET = return [ User "Tom" 28
, User "Kate" 21
]
何の変哲もない単純なServantで作ったAPIです。サンプル用に空のcontextを用意していますが、これは後の説明のためです。
ServantのServer api型の仕組み
ここで一度Server api型について見ていきたいと思います。Server api型の定義は
type Server api = ServerT api Handler
となります。すなわちServetT api (m :: * -> *) :: *
にHandlerを適用したのがServer apiだったと言うことになります。そこでServerT apiにHandler以外のモナド(今回はReaderT r Handler モナド)を適用しようと考えます。まず、読み込むConfigとカスタムモナドを定義しましょう。
newtype Config = Config Int -- サンプル用なので非常にシンプルなConfig
type CustomM = ReaderT Config Handler -- CustomMの定義
このCustomM
のkindは(* -> *)
ですのでServerT api
に適用可能です。では、このCustomM
モナドを使ってserver部分を書き換えてみましょう。
HandlerからCustomMへの書き換え
ではHandlerモナドからCustomMを使用した書き方に変えていきましょう。
-- APIにアクセスされた際の挙動を定義
server :: ServerT API CustomM
server = userListGET
where
userListGET :: CustomM [User] -- 各関数はHandlerモナドで記述
userListGET = do
Config age <- ask -- 各所でConfigの読み込みが可能になった
return [ User "Tom" age
, User "Kate" 21
]
CustomMを使用することでReaderモナドの関数であるask関数を使用できるようになりました。これで随所でConfigを読み込んで使用することが出来るようになりました。同じことは各関数に引数で渡すことでも実現できますが、Configを使用しない関数もある場合、余計な引数を渡すことになりあまり綺麗な実装ではなくなってしまいます。
Servantで用意されているモナドをリフトする道具
ここまでの書き換えだけでは不十分です。以下をご覧ください。
run 8080 (serveWithContext api context server) -- serveWithContext関数でAplicationを生成し実行
の部分でエラーが起こります。serveWithContext
関数の型注釈を見て下さい。
serveWithContext :: Proxy api -> Context context -> Server api -> Application (型クラス制約は省略)
現在server
の型はserver :: ServerT api CustomM
に変更されているので型が一致しません。このためエラーが出てしまします。変更後のserver
でも適用できるようにServerntにはいくつかツールが用意してあります。
関数名 | 用途 |
---|---|
hoistServer | ServerT api mをServerT api nに変更する |
serveWithContextT | コンテキストを含み任意のモナドを使用したServerT api mからアプリケーションを生成する |
これらのツールを利用すると簡単にカスタムモナドを用いることができます。実用的なAPIを作成する際には認証関係でコンテキストを含めることが多いので、serveWithContextT
関数の方が使用頻度が多いと思います。実はhoistServer
もserveWithContextT
も内部ではhoistServerWithContext
という関数で実装されていますが、hoistServerWithContext
は直接使用しないほうが無難です。
ここでCustomMモナドとHandlerモナドの変換関数を用意しておく必要があります。この変換関数をhoistServer
やserveWithContextT
に適用することで使用します。
モナド変換用の関数
nt :: Config -> CustomM a -> Handler a
nt c m = runReaderT m c
run関数に適用するApplicationの作り方を見ていきましょう。コンテキストの有無で使う関数が変わります。
コンテキストがある場合
config = Config 23 -- 数字は適当な値です
-- serveWithContextTでの書き方
app = serveWithContextT api context (nt config) server
-- hoistServerを使った書き方
app = serveWithContext api context $ hoistServer api (nt config) server
コンテキストがない場合
config = Config 23 -- 数字は適当な値です
app = serve api $ hoistServer api (nt config) server
カスタムモナドを使った実装例
これで準備が整いました。では先程紹介したWebAPIをCustomMモナドを使用した作りに変換していきましょう。今回はConfigは単純なIntを収納するだけのデータとします。
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DataKinds #-}
import Network.Wai.Handler.Warp
import Servant
import GHC.Generics (Generic)
import Data.Aeson
import Control.Monad.Trans.Reader
main :: IO ()
main = do
putStrLn "API Start!"
let config = Config 23 -- serverに適用する設定config
run 8080 (serveWithContextT api context (nt config) server) -- contextを適用する場合は一番シンプル
-- run 8080 $ serveWithContext api context $ hoistServer api (nt config) server -- hostServerを使用する場合
-- run 8080 $ serve api $ hoistServer api (nt config) server -- contextを適用しない場合
context = EmptyContext -- サンプルのため空のコンテキストを使用
-- Configを定義
newtype Config = Config Int
-- CustomMを定義
type CustomM = ReaderT Config Handler
-- CustomMとHandlerの変換関数を定義
nt :: Config -> CustomM a -> Handler a
nt c m = runReaderT m c
-- Userデータの定義
data User = User { userName :: String
, userAge :: Int
} deriving (Show, Generic)
instance ToJSON User
instance FromJSON User
-- APIを定義
type API =
-- /user/にアクセスしてUserリストを取得
"user" :> Get '[JSON] [User]
api :: Proxy API
api = Proxy
-- APIにアクセスされた際の挙動を定義
server :: ServerT API CustomM
server = userListGET
where
userListGET :: CustomM [User] -- 各関数はHandlerモナドで記述
userListGET = do
Config age <- ask -- 各所でConfigの読み込みが可能になった
return [ User "Tom" age
, User "Kate" 21
]
実際の使用例
以下に筆者が作成したプロジェクトから使用事例を示します。外部から読み込んだ設定情報とデータベースへの接続情報をPoolとして保持したConfigを各関数から読み込めるようにReaderT変換子を用いて合成モナドを利用しています。参考事例として本ブログのバックエンドシステムであるKurologの事例を載せておきます。
まとめ
ServantのDocumentにも書いてあった内容なのですが、hoistServer
のみについて触れておりServerWithContextTについては触れていなかったため一度整理して記事化しておこうと思いました。