问题描述
我正在使用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 User
和writeUserToFile :: 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