35

我对 Servant 是如何使用打字来实现它的魔力感到非常困惑。网站上的例子已经让我很困惑:

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

我得到“日期”、“时间”[JSON]和“tz”是类型级文字。它们是具有“成为”类型的值。好的。

我明白了,:>并且:<|>是类型运算符。好的。

我不明白这些东西在变成类型之后如何被提取回值。这样做的机制是什么?

我也不明白这种类型的第一部分如何让框架期待签名的功能IO Date,或者这种类型的第二部分如何让框架期待Timezone -> IO Time我的签名功能。这种转变是如何发生的?

那么框架如何调用一个它最初不知道类型的函数呢?

我敢肯定,这里有许多我不熟悉的 GHC 扩展和独特的功能在发挥作用,它们结合起来使这种神奇的发生。

有人可以解释这里涉及哪些功能以及它们如何协同工作吗?

4

1 回答 1

42

查看Servant 论文以获得完整的解释可能是最好的选择。尽管如此,我将尝试通过实现“TinyServant”来说明 Servant 所采用的方法,这是一个精简到最低限度的 Servant 版本。

抱歉,这个答案太长了。但是,它仍然比论文短一点,这里讨论的代码“只有” 81 行,也可以作为 Haskell 文件在此处获得。

准备工作

首先,这是我们需要的语言扩展:

{-# LANGUAGE DataKinds, PolyKinds, TypeOperators #-}
{-# LANGUAGE TypeFamilies, FlexibleInstances, ScopedTypeVariables #-}
{-# LANGUAGE InstanceSigs #-}

定义类型级 DSL 本身需要前三个。DSL 使用类型级别的字符串 ( DataKinds) 并且还使用种类多态性 ( PolyKinds)。使用类型级别的中缀运算符,例如:<|>and:>需要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. AGet a代表类型a(种类*)的端点。与完整的Servant相比,我们在这里忽略了内容类型。我们只需要 API 规范的数据类型。现在有直接对应的值,因此没有构造函数Get

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

  3. item :> rest表示嵌套路由,其中item ​​第一个组件和rest其余组件在哪里。在我们简化的 DSL 中,只有两种可能性 item:类型级别的字符串,或Capture. 因为类型级别的字符串是 kind Symbol,但Capture下面定义的 a 是 kind *,我们将第一个参数设为:> kind-polymorphic,这样两个选项都被 Haskell 种类系统接受。

  4. ACapture 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 的处理程序,WAIApplication本质上是一个从 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 类型计算处理程序必须具有的类型。这是使仆人正常工作的核心要素之一。

字符串列表表示请求,简化为 URL 组件列表。因此,我们总是返回String响应,并且我们允许使用IO. Full Servant 在这里使用了一些更复杂的类型,但想法是一样的。

Server类型族

我们首先定义Server为一个类型族。(在 Servant 中,实际使用的类型族是ServerT,它被定义为HasServer类的一部分。)

type family Server layout :: *

端点的处理程序Get a只是一个IO产生a. (再一次,在完整的仆人代码中,我们有更多的选择,比如产生错误。)

type instance Server (Get a) = IO a

处理程序a :<|> b是一对处理程序,所以我们可以定义

type instance Server (a :<|> b) = (Server a, Server b) -- preliminary

但正如上面所指出的,对于:<|>this 的嵌套出现会导致嵌套对,使用中缀对构造函数看起来会更好一些,所以 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. 在内部,我们必须将传入请求分派到正确的路由器。在 的情况下:<|>,这意味着我们必须在两个处理程序之间做出选择。我们如何做出这个选择?一个简单的选择是允许 route失败,通过返回一个Maybe. (同样,完整的 Servant 在这里稍微复杂一些,0.5 版将有一个大大改进的路由策略。)

一旦我们route定义了,我们可以很容易地serve定义route

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

所以处理程序是一个产生 的 IO 动作a,我们必须把它变成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

在这里,我们得到了一对处理程序,我们使用<|>forMaybe 来尝试两者。

文字字符串会发生什么?

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。我们要求请求是非空的,并且第一个组件要匹配类型级别字符串的值级别对应项。我们通过 apply 获得与类型级字符串字面量对应的值级字符串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)
于 2015-11-01T20:46:15.500 回答