Servant如何通过类型来实现API?使用了哪些机制?

36

我对 Servant 如何使用类型实现其神奇功能感到非常困惑。网站上的示例已经让我大感困惑:

type MyAPI = "date" :> Get '[JSON] Date
        :<|> "time" :> Capture "tz" Timezone :> Get '[JSON] Time

"date"、"time"、[JSON]和"tz"是类型级字面量。它们是已成为类型的值。我明白了。

我知道:>:<|>是类型操作符。好的。

但我不知道这些成为类型后如何被还原为值。这个机制是什么?

我也不知道为什么这个类型的第一部分可以让框架期望一个签名为IO Date的函数,或者这个类型的第二部分怎么可以让框架期望我提供一个签名为Timezone -> IO Time的函数。这个转换是怎么发生的?

然后框架又是怎么去调用一个一开始它不知道类型的函数的呢?

我相信这里使用了许多我不熟悉的GHC扩展和独特的功能,它们结合在一起实现了这种神奇的效果。

有人能解释一下这里涉及到的特性以及它们如何相互作用吗?


2
你看过这篇论文了吗?不知道我们能不能找到比那更好的解释...也许你可以先阅读一下,然后回来问你不理解的详细问题 - 这里涉及的问题至少和论文一样广泛 ;) - Random Dev
GHC.TypeLits.KnownSymbol和相关函数用于将类型级别字符串(Symbol)转换为值级别字符串。对于任何其他类型,机制本质上是相同的:使用类型类。对于从其他类型生成类型,可以使用类型类或类型族。关于“如何”的问题非常广泛,但这是简短版本。 - user2407038
@Carsten Oh. 我不知道有一篇论文。谢谢 :) - user1002430
1
嗨,我是其中一位作者。这篇论文的写作目的是为了准确回答“它是如何工作的?”-- 如果您有更具体的问题,请告诉我,我会尽力回答。 - Alp Mestanogullari
这是一段关于Servant的好视频:https://www.youtube.com/watch?v=gMDiKOuwLXw - The Internet
1个回答

42

阅读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 :: *)

我们在我们简化的语言中仅定义了四个结构:

  1. Get a 代表类型为a(类型为*)的端点。与完整的Servant相比,我们在此忽略了内容类型。我们仅需数据类型来进行API规范。现在没有直接对应的值,因此没有Get的构造函数。

  2. 使用 a :<|> b,表示两个路由之间的选择。同样,我们不需要构造函数,但事实证明,我们将使用一对处理程序来表示使用 :<|> 的API 的处理程序。对于:<|>的嵌套应用,我们将得到嵌套的处理程序对,使用Haskell标准符号看起来有些丑陋,因此我们定义:<|>构造函数等效于一个pair。

  3. 使用item:>rest,表示嵌套路由,其中item是第一个组件,rest是剩余的组件。在我们简化的DSL中,有两种可能性的item:类型级字符串或Capture。由于类型级字符串是Symbol类型,但Capture(在下面定义)是类型为*的,我们使:>的第一个参数kind-polymorphic,以便Haskell kind系统接受两个选项。

  4. 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端点的处理程序只是产生一个aIO动作。(再次提醒,在完整的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)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接