我应该使用ReaderT在Servant中传递数据库连接池吗?

问题描述

我正在使用Servant和Persistent构建Web API。我计划定义一些API端点(大约15个),这些端点使用连接池来访问数据库

例如,端点定义(Handler s)之一是:

getUser :: ConnectionPool -> Int -> Handler User
getUser pool uid = do
  user <- inPool pool $ get (toId @User uid)
  user & orErr err404 {errBody = "This user does not exist."}

其中inPool只是提升的withResource函数,而orErr是提升的fromMaybe

然后,一个更高级别的API定义(Server s)看起来像这样:

type Point (s :: Symbol) (a :: *) =
  s :> Capture "id" Int :>
  (                         Get  '[JSON] a
  :<|> ReqBody '[JSON] a :> Post '[JSON] NoContent
  )

type UserPoint = Point "users" User

userServer :: ConnectionPool -> Server UserPoint
userServer pool uid =
    getUser pool uid :<|>
    postUser pool uid

我将main定义为:

main = runStdoutLoggingT . withPostgresqlPool connectionString numConnections $ \pool -> do
  withResource pool (runsqlConn $ runMigration migrateall)
  liftIO $ run appPort (userServer pool)

但是我很快注意到,我必须将池逐层传递(在上面的示例中有2层,在我的真实项目中有3层),传递给每个函数(超过20个)。我的直觉告诉我这是难闻的气味,但我不太确定。

然后我想到了ReaderT,因为我认为这可能会使池抽象出来。但我担心的是,ReaderT的引入可能会导致不必要的复杂性:

  • 我需要手动搬运很多东西;
  • 类型的心理模型将变得更加复杂,因此难以思考;
  • 这意味着我将不得不放弃Handler类型,这也使得使用Servant更加困难。

我不确定在这种情况下是否应该使用ReaderT。请提供一些建议(如果您也能提供有关何时使用ReaderT或什至其他monad转换器的一些准则,我将不胜感激。

更新:我发现我可以使用where条来简化很多操作,这基本上解决了我的问题。但是我不确定这是否是最佳做法,因此我仍然希望找到答案。

userServer :: Pooled (Server UserPoint)
userServer pool auth = c :<|> rud where
  c :: UserCreation -> Handler NoContent
  c = undefined
  rud uid = r :<|> u :<|> d where
    r :: Handler User
    r = do
      checkAuth pool auth
      user <- inPool pool $ get (toId @User uid)
      user & orErr err404 {errBody = "This user does not exist."}
    u :: User -> Handler NoContent
    u = undefined
    d :: Handler NoContent
    d = undefined

解决方法

在与服务器一起定义处理程序时,可以避免参数传递,因为服务器的复杂性不断提高,您可能需要单独定义一些处理程序:

  • 也许某些处理程序提供了一些通用功能,并且可能在其他服务器中很有用。

  • 一起定义所有内容意味着所有内容都知道其他所有内容。 将处理程序移至顶层,甚至移至另一个模块,将 帮助明确他们真正需要知道的全部部分。 这可以使处理程序更易于理解。

一旦我们分离了处理程序,就必须为其提供环境。这可以通过函数的简单参数或使用ReaderT来完成。随着参数数量的增加,ReaderT(通常与辅助HasX类型类结合使用)变得更具吸引力,因为它使您不必担心参数顺序。

我必须逐层向下传递池(在示例中 上面有2层,在我的真实项目中有3层, 每个功能

除了必须传递参数的额外负担(可能是不可避免的负担)之外,我认为潜伏着一个潜在的更糟的问题:您正在通过多个函数层来处理低级细节(连接池)。这可能很糟糕,因为:

  • 您正在将整个应用程序提交为使用实际数据库。如果在测试过程中想要使用某种内存中的存储库进行切换,会发生什么?

  • 如果需要更改执行持久性的方式,则重构将在应用程序的所有层中回荡,而不是保持本地化。

这些问题的一种可能解决方案:第N + 1层的函数不应接收连接池作为参数,而应从第N层接收其使用的函数。来自第N层的那些功能将已经部分地应用于连接池。

一个简单的例子:如果您有一些高级逻辑transferUser :: Conn -> Handle -> IO (),其中包括对函数readUserFromDb :: Conn -> IO UserwriteUserToFile :: Handle -> User -> IO ()的硬连线调用,请将其更改为transferUser :: IO User -> (User -> IO) -> IO ()

请注意,第N级的辅助功能可以存储在ReaderT上下文中; N + 1级别的功能可以从那里获取它们。


这意味着我必须放弃Handler类型,这使得使用 仆人也更努力。

您可以使用ReaderT上的Handler转换器定义服务器,然后将其传递到hoistServer函数,该函数将“缩减”到可运行的服务器:

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
import Servant
import Servant.API
import Control.Monad.Trans.Reader

type UserAPI1 = "users" :> Capture "foo" Int :> Get '[JSON] Int

data Env = Env

-- also valid type
--      server1 :: Int -> ReaderT Env Handler Int
server1 :: ServerT UserAPI1 (ReaderT Env Handler)
server1 = 
    \ param -> 
        do _ <- ask
           return param

-- also valid types:
--      server2 :: ServerT UserAPI1 Handler
--      server2 :: Int -> Handler Int
server2 :: Server UserAPI1 
server2 = hoistServer (Proxy :: Proxy UserAPI1) (flip runReaderT Env) server1