阅读Servant论文,以获取完整说明可能是最佳选择。尽管如此,我将尝试在这里实现“TinyServant”来说明Servant所采取的方法,它是Servant的最简化版本。
很抱歉这个答案有点长。然而,它仍然比论文短一些,并且这里讨论的代码只有81行,也可以作为Haskell文件在这里找到。
准备工作
首先,这是我们需要的语言扩展:
{-# LANGUAGE DataKinds, PolyKinds, TypeOperators #-}
{-# LANGUAGE TypeFamilies, FlexibleInstances, ScopedTypeVariables #-}
{-# LANGUAGE InstanceSigs #-}
类型级别DSL的定义需要前三个元素。DSL使用类型级别字符串 (DataKinds
),同时还使用种类多态 (PolyKinds
)。使用类型级别中缀运算符,如 :<|>
和 :>
,需要 TypeOperators
扩展。
为了解释其含义(类似于Web服务器但不包括Web部分),我们需要定义其解释,这需要后三个元素:类型级别函数 (TypeFamilies
)、一些类型类编程,需要 (FlexibleInstances
),以及一些用于指导类型检查器的类型注释,需要 ScopedTypeVariables
。
仅供文档目的,我们还使用了 InstanceSigs
。
模块头如下:
module TinyServant where
import Control.Applicative
import GHC.TypeLits
import Text.Read
import Data.Time
完成这些准备工作后,我们准备开始。
API规范
第一步是定义API规范所使用的数据类型。
data Get (a :: *)
data a :<|> b = a :<|> b
infixr 8 :<|>
data (a :: k) :> (b :: *)
infixr 9 :>
data Capture (a :: *)
我们在我们简化的语言中仅定义了四个结构:
Get a
代表类型为a
(类型为*
)的端点。与完整的Servant相比,我们在此忽略了内容类型。我们仅需数据类型来进行API规范。现在没有直接对应的值,因此没有Get
的构造函数。
使用 a :<|> b
,表示两个路由之间的选择。同样,我们不需要构造函数,但事实证明,我们将使用一对处理程序来表示使用 :<|>
的API 的处理程序。对于:<|>
的嵌套应用,我们将得到嵌套的处理程序对,使用Haskell标准符号看起来有些丑陋,因此我们定义:<|>
构造函数等效于一个pair。
使用item:>rest
,表示嵌套路由,其中item
是第一个组件,rest
是剩余的组件。在我们简化的DSL中,有两种可能性的item
:类型级字符串或Capture
。由于类型级字符串是Symbol
类型,但Capture
(在下面定义)是类型为*
的,我们使:>
的第一个参数kind-polymorphic,以便Haskell kind系统接受两个选项。
Capture a
表示捕获、解析并将其作为a
类型的参数公开给处理程序的路由组件。在完整的Servant中,Capture
具有一个附加字符串作为参数,用于生成文档。我们在此省略了该字符串。
示例API
现在,我们可以写出问题中的API规范的版本,适应于Data.Time
中出现的实际类型和我们简化的DSL:
type MyAPI = "date" :> Get Day
:<|> "time" :> Capture TimeZone :> Get ZonedTime
作为服务器的解释
最有趣的方面当然是我们可以通过API实现什么,这也是这个问题大部分关注的内容。
Servant定义了几种解释方式,但它们都遵循相似的模式。我们将在此处定义一种受Web服务器启发的解释方式。
在Servant中,serve
函数接受API类型的代理和将API类型匹配到WAI Application
的处理程序,它本质上是一个从HTTP请求到响应的函数。在这里,我们将抽象出Web部分进行定义。
serve :: HasServer layout
=> Proxy layout -> Server layout -> [String] -> IO String
下面我们将定义的HasServer
类,拥有类型级DSL的所有不同构造的实例,因此编码了Haskell类型layout
被解释为服务器API类型的含义。
Proxy
在类型和值层之间建立连接。它被定义为:
data Proxy a = Proxy
它的唯一目的就是通过传入一个显式指定类型的Proxy
构造函数,
使得我们可以非常明确地指定想要为哪个API类型计算服务器。
Server
参数是API
的处理程序。在这里,Server
本身是一个类型族,并且从API类型计算处理程序必须具有的类型。
这是使Servant正确工作的核心因素之一。
字符串列表表示请求,缩减为URL组件列表。因此,我们始终返回String
响应,
并允许使用IO
。完整的Servant在这里使用更复杂的类型,但思想是相同的。
Server
类型族
我们首先将Server
定义为类型族。
(在Servant中,实际使用的类型族是ServerT
,并且它是作为HasServer
类的一部分定义的。)
type family Server layout :: *
Get a
端点的处理程序只是产生一个a
的IO
动作。(再次提醒,在完整的Servant代码中,我们有一些稍微更多的选项,比如产生一个错误。)
type instance Server (Get a) = IO a
a :<|> b
的处理程序是一对处理程序,因此我们可以定义
type instance Server (a :<|> b) = (Server a, Server b) -- preliminary
但是如上所示,对于嵌套的:<|>
的出现,这会导致嵌套的成对出现,使用中缀对构造器会更加美观。因此Servant定义了等效的
type instance Server (a :<|> b) = Server a :<|> Server b
需要解释每个路径组件是如何处理的。
路由中的字面字符串不会影响处理器类型:
type instance Server ((s :: Symbol) :> r) = Server r
然而,捕获意味着处理程序期望一个额外的参数是被捕获的类型:
type instance Server (Capture a :> r) = a -> Server r
计算示例API的处理程序类型
如果我们展开Server MyAPI
,我们会得到:
Server MyAPI ~ Server ("date" :> Get Day
:<|> "time" :> Capture TimeZone :> Get ZonedTime)
~ Server ("date" :> Get Day)
:<|> Server ("time" :> Capture TimeZone :> Get ZonedTime)
~ Server (Get Day)
:<|> Server ("time" :> Capture TimeZone :> Get ZonedTime)
~ IO Day
:<|> Server ("time" :> Capture TimeZone :> Get ZonedTime)
~ IO Day
:<|> Server (Capture TimeZone :> Get ZonedTime)
~ IO Day
:<|> TimeZone -> Server (Get ZonedTime)
~ IO Day
:<|> TimeZone -> IO ZonedTime
正如预期的那样,我们API的服务器需要一对处理程序,一个提供日期,另一个在给定时区的情况下提供时间。我们现在可以定义它们:
handleDate :: IO Day
handleDate = utctDay <$> getCurrentTime
handleTime :: TimeZone -> IO ZonedTime
handleTime tz = utcToZonedTime tz <$> getCurrentTime
handleMyAPI :: Server MyAPI
handleMyAPI = handleDate :<|> handleTime
HasServer
类
我们仍需实现HasServer
类,该类如下所示:
class HasServer layout where
route :: Proxy layout -> Server layout -> [String] -> Maybe (IO String)
route
函数的任务几乎像
serve
一样。在内部,我们必须将一个传入请求分派到正确的路由器。对于
:<|>
,这意味着我们必须在两个处理程序之间做出选择。我们如何做出这个选择?一个简单的方法是通过返回一个
Maybe
来允许
route
失败。(再次强调,完整的Servant在这里有些更为复杂,并且版本0.5将拥有一个更加改进的路由策略。)
一旦我们定义了
route
,就可以轻松地使用
route
来定义
serve
。
serve :: HasServer layout
=> Proxy layout -> Server layout -> [String] -> IO String
serve p h xs = case route p h xs of
Nothing -> ioError (userError "404")
Just m -> m
如果没有匹配的路由,我们会返回404错误。否则,我们返回结果。
HasServer
实例
对于一个Get
端点,我们定义了
type instance Server (Get a) = IO a
因此处理程序是一个产生a
的IO操作,我们必须将其转换为String
。我们使用show
实现此目的。在实际的Servant实现中,这种转换由内容类型机制处理,并且通常涉及编码为JSON或HTML。
instance Show a => HasServer (Get a) where
route :: Proxy (Get a) -> IO a -> [String] -> Maybe (IO String)
route _ handler [] = Just (show <$> handler)
route _ _ _ = Nothing
由于我们只匹配一个端点,因此在此时要求请求为空。如果不是,则此路由不匹配,我们返回Nothing
。
接下来让我们看看选择:
instance (HasServer a, HasServer b) => HasServer (a :<|> b) where
route :: Proxy (a :<|> b) -> (Server a :<|> Server b) -> [String] -> Maybe (IO String)
route _ (handlera :<|> handlerb) xs =
route (Proxy :: Proxy a) handlera xs
<|> route (Proxy :: Proxy b) handlerb xs
在这里,我们获取一对处理程序,并使用<|>
来尝试Maybe
中的两者。
对于字面字符串会发生什么?
instance (KnownSymbol s, HasServer r) => HasServer ((s :: Symbol) :> r) where
route :: Proxy (s :> r) -> Server r -> [String] -> Maybe (IO String)
route _ handler (x : xs)
| symbolVal (Proxy :: Proxy s) == x = route (Proxy :: Proxy r) handler xs
route _ _ _ = Nothing
s :> r
的处理程序与
r
的处理程序类型相同。我们要求请求非空,并且第一个组件与类型级字符串的值级对应项匹配。我们通过应用
symbolVal
获取对应于类型级字符串字面量的值级字符串。为此,我们需要在类型级字符串字面量上添加
KnownSymbol
约束。但是,在GHC中的所有具体字面量都自动成为
KnownSymbol
的实例。
最后一种情况是捕获:
instance (Read a, HasServer r) => HasServer (Capture a :> r) where
route :: Proxy (Capture a :> r) -> (a -> Server r) -> [String] -> Maybe (IO String)
route _ handler (x : xs) = do
a <- readMaybe x
route (Proxy :: Proxy r) (handler a) xs
route _ _ _ = Nothing
在这种情况下,我们可以假设我们的处理程序实际上是一个期望a
的函数。我们要求请求的第一个组件可解析为a
。在这里,我们使用Read
,而在Servant中,我们再次使用内容类型机制。如果读取失败,我们认为请求不匹配。否则,我们可以将其提供给处理程序并继续。
测试一切
现在我们完成了。
我们可以在GHCi中确认一切都正常:
GHCi> serve (Proxy :: Proxy MyAPI) handleMyAPI ["time", "CET"]
"2015-11-01 20:25:04.594003 CET"
GHCi> serve (Proxy :: Proxy MyAPI) handleMyAPI ["time", "12"]
*** Exception: user error (404)
GHCi> serve (Proxy :: Proxy MyAPI) handleMyAPI ["date"]
"2015-11-01"
GHCi> serve (Proxy :: Proxy MyAPI) handleMyAPI []
*** Exception: user error (404)
GHC.TypeLits.KnownSymbol
和相关函数用于将类型级别字符串(Symbol
)转换为值级别字符串。对于任何其他类型,机制本质上是相同的:使用类型类。对于从其他类型生成类型,可以使用类型类或类型族。关于“如何”的问题非常广泛,但这是简短版本。 - user2407038