LoginSignup
26
6

More than 5 years have passed since last update.

servant+persistentを利用する

Last updated at Posted at 2017-12-06

Haskell Advent Calendar 2017 その3(!)、7日目の記事です。

HaskellでRESTサーバを書くライブラリはいくつかあるようですが、私が気に入っているのはservantです。対抗馬としてはScottyがあると思いますが、servantはより型に安全なそうなのと、APIのあり方が好きです。

servantはごく薄い機能しか提供していないので、本格的なRESTサーバに必須のDB連携を自分で書く必要があるのですが、巷にはこのサンプルが少ない気がしています。

また高レベルなDBアクセスフレームワークとしてはpersistentが有名だと思うのですが、servantと組み合わせた例が本家GitHubにあるにはあるのですが、persistentのバージョンが古くインターフェイスが変わっているのでコンパイルに通らなかったりするし、コネクションプーリングを使っていないので実戦的ではありません。

というわけで「一通り実戦で使えるであろう」というレベルのサンプルを作ったので紹介します。

なおこの記事内のコードで使っている主なライブラリとバージョンは以下の通りです。

  • servant-0.11
  • persistent-2.7.0
  • persistent-postgresql-2.6.1

全てのソースは次の場所にあります。
https://github.com/jabaraster/servant-persistent-sample/tree/7b9a086fc8cbd2742bed7758fd5618a1fe2252e9

まずは足回り

DBに関して、接続設定取得/コネクションプール作成/マイグレーション実施の3機能です。

module DataStore.Internal where

import Control.Monad.Logger         (runStdoutLoggingT)
import Control.Monad.Trans.Reader   (runReaderT)
import Control.Monad.Trans.Resource (runResourceT)
import Data.Yaml.Config             (loadYamlSettings
                                   , useEnv
                                    )
import Database.Persist.Postgresql  (PostgresConf(..)
                                   , withPostgresqlConn
                                   , createPostgresqlPool
                                    )
import Database.Persist.Sql         (Migration
                                   , ConnectionPool
                                   , runMigration
                                    )

-- loadYamlSettingsを使うと環境変数で設定を書き換えるのが楽になる
pgConf :: IO PostgresConf
pgConf = loadYamlSettings ["conf/database-setting.yml"] [] useEnv

pgPool :: IO ConnectionPool
pgPool = do
    conf <- pgConf
    runStdoutLoggingT $ createPostgresqlPool (pgConnStr conf) (pgPoolSize conf)

doMigration :: Migration -> IO ()
doMigration action = do
    conf <- pgConf
    runStdoutLoggingT $ runResourceT $ withPostgresqlConn (pgConnStr conf) $ runReaderT $ runMigration action

ここはservantと関係ないコードです。

一工夫したのは Data.Yaml.Config.loadYamlSettings 関数を使って設定値の読み込みを楽しているところ。この関数を使うとYAMLファイルから設定値を読み込めるのですが、環境変数やデフォルト値を簡単に設定できます。
次はYAMLファイルの例です。

user    : "_env:PG_USER:app"
password: "_env:PG_PASS:xxx"
host    : "_env:PG_HOST:localhost"
port    : "_env:PG_PORT:5432"
database: "_env:PG_DATABASE:app"
poolsize: "_env:PG_POOLSIZE:5"

ロギングについては、上記コードでは標準出力に出力しているのですが、もっと工夫の余地があると思います。

DBアクセス本体

ここもservantと関係ないです。DBアクセスする関数の引数でコネクションプールを受け取るようにするのがポイントです。

{-# LANGUAGE TemplateHaskell            #-}
{-# LANGUAGE FlexibleInstances          #-}
{-# LANGUAGE MultiParamTypeClasses      #-}
{-# LANGUAGE TypeFamilies               #-}
{-# LANGUAGE GADTs                      #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE DeriveGeneric              #-}
{-# LANGUAGE QuasiQuotes                #-}

module DataStore where

import Control.Lens                 ((^.))
import Data.Text                    (Text)
import Database.Persist
import Database.Persist.Sql         (ConnectionPool
                                   , runSqlPool
                                    )
import Database.Persist.TH          (mkMigrate
                                   , mkPersist
                                   , persistLowerCase
                                   , share
                                   , sqlSettings
                                   , mpsGenerateLenses
                                    )
import DataStore.Internal
import GHC.Generics

share [mkPersist sqlSettings { mpsGenerateLenses = True }, mkMigrate "migrateAll"] [persistLowerCase|
User json
    name Text
    age Int
    UniqueUserName name
    deriving Eq Show Generic
|]

getUsers :: ConnectionPool -> IO [Entity User]
getUsers pool = flip runSqlPool pool $ selectList [] []

getUser :: ConnectionPool -> Key User -> IO (Maybe (Entity User))
getUser pool = flip runSqlPool pool . getEntity

insertUser :: ConnectionPool -> User -> IO (Maybe (Entity User))
insertUser pool user = flip runSqlPool pool $ do
    mInDb <- getBy $ UniqueUserName $ user^.userName
    case mInDb of
      Just inDb -> pure Nothing
      Nothing   -> do
                     key <- insert user
                     pure $ Just $ Entity key user

migrateDb :: IO ()
migrateDb = doMigration migrateAll

State を使えばConnectionPoolを引数から消せそうですが、まあこれはこれでアリでしょう。

servantと統合

いよいよservantと統合します。ConnectionPoolを受け取ってServantを返す関数を定義するのがポイントです。


{-# LANGUAGE DataKinds                  #-}
{-# LANGUAGE TypeOperators              #-}
module App where

import Control.Monad.IO.Class   (liftIO)
import Database.Persist.Sql
import DataStore
import DataStore.Internal       (pgPool)
import Network.Wai.Handler.Warp (run, Port)
import Servant

type ApiDef  = Get '[JSON] [Entity User]
            :<|> "users" :> Get '[JSON] [Entity User]

server :: ConnectionPool -> Server ApiDef
server pool = (liftIO $ getUsers pool)
         :<|> (liftIO $ getUsers pool)

api :: Proxy ApiDef
api = Proxy

app :: ConnectionPool -> Application
app pool = serve api $ server pool

mkApp :: IO Application
mkApp = do
    migrateDb
    pool <- pgPool
    return $ app pool

startServer :: Port -> IO ()
startServer port = do
    putStrLn "{- ----------------------------"
    putStrLn " - start server!"
    putStrLn " ----------------------------- -}"
    run port =<< mkApp

まとめ

このコードに行き着くまでに色々調べて苦労はしたのですが、終わってみれば至極真っ当なコードになりました。

今回はコネクションプールを状態として扱う必要があり、これをどう実現するかがポイントでした。関数の引数として引き回すのは素直な実装だと思います。しかし、引き回したい状態が多くなってきた場合は、関数の引数にするのは難しくなってきます。
そうなるとState(Readerかな)をうまく使う必要が出てくると思います。
Yesodなんかは getYesod という関数で状態をまとめて取得できるようにしているようです。

26
6
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
6