Linuxを日常的に使う実験ブログ

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にはいくつかツールが用意してあります。

関数名用途
hoistServerServerT api mをServerT api nに変更する
serveWithContextTコンテキストを含み任意のモナドを使用したServerT api mからアプリケーションを生成する

これらのツールを利用すると簡単にカスタムモナドを用いることができます。実用的なAPIを作成する際には認証関係でコンテキストを含めることが多いので、serveWithContextT関数の方が使用頻度が多いと思います。実はhoistServerserveWithContextTも内部ではhoistServerWithContextという関数で実装されていますが、hoistServerWithContextは直接使用しないほうが無難です。

ここでCustomMモナドとHandlerモナドの変換関数を用意しておく必要があります。この変換関数をhoistServerserveWithContextTに適用することで使用します。

モナド変換用の関数

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については触れていなかったため一度整理して記事化しておこうと思いました。